diff --git a/.github/workflows/azure-static-web-apps-delightful-beach-055ecb503.yml b/.github/workflows/azure-static-web-apps-delightful-beach-055ecb503.yml index 80d4e34d3..df43cffea 100644 --- a/.github/workflows/azure-static-web-apps-delightful-beach-055ecb503.yml +++ b/.github/workflows/azure-static-web-apps-delightful-beach-055ecb503.yml @@ -16,6 +16,9 @@ on: - 'v*/dev' - 'release/*' +env: + NODE_OPTIONS: --max_old_space_size=16384 + jobs: build_and_deploy_job: if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 6593a3693..a4580d7c1 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -33,6 +33,9 @@ on: description: The head branch associated with the pull request. required: true +env: + NODE_OPTIONS: --max_old_space_size=16384 + # List of jobs jobs: chromatic-deployment: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 007816ff3..8534b067c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,6 +16,9 @@ on: description: The head branch associated with the pull request. required: true +env: + NODE_OPTIONS: --max_old_space_size=16384 + jobs: build: # The type of runner that the job will run on diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3067f8a84..c5bfdd095 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,9 @@ on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: +env: + NODE_OPTIONS: --max_old_space_size=16384 + # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # This workflow contains a single job called "build" diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..5cb297e3e --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +16.17 diff --git a/packages/uui-range-slider/lib/uui-range-slider.element.ts b/packages/uui-range-slider/lib/uui-range-slider.element.ts index 10e93a118..8d9955f97 100644 --- a/packages/uui-range-slider/lib/uui-range-slider.element.ts +++ b/packages/uui-range-slider/lib/uui-range-slider.element.ts @@ -2,7 +2,7 @@ import { UUIHorizontalPulseKeyframes } from '@umbraco-ui/uui-base/lib/animations import { FormControlMixin } from '@umbraco-ui/uui-base/lib/mixins'; import { defineElement } from '@umbraco-ui/uui-base/lib/registration'; -import { css, html, LitElement, svg } from 'lit'; +import { css, html, LitElement, nothing, svg } from 'lit'; import { property, query, state } from 'lit/decorators.js'; import { UUIRangeSliderEvent } from './UUIRangeSliderEvent'; @@ -12,6 +12,8 @@ const STEP_MIN_WIDTH = 24; /** * @element uui-range-slider * @description - Range slider with two handles. Use uui-slider for a single handle. + * @fires UUIRangeSliderEvent#input on input + * @fires UUIRangeSliderEvent#change on change */ @defineElement('uui-range-slider') export class UUIRangeSliderElement extends FormControlMixin(LitElement) { @@ -19,11 +21,9 @@ export class UUIRangeSliderElement extends FormControlMixin(LitElement) { UUIHorizontalPulseKeyframes, css` :host { - position: relative; + display: block; + min-height: 50px; width: 100%; - min-height: 30px; - padding: 0; - margin: 0; place-items: center; -webkit-user-select: none; /* Safari */ -moz-user-select: none; /* Firefox */ @@ -36,154 +36,125 @@ export class UUIRangeSliderElement extends FormControlMixin(LitElement) { cursor: default; } - #wrapper { - position: relative; - border-radius: 20px; - min-height: 40px; - } - - #wrapper:focus-visible { - outline: none; - } - - #wrapper:focus-visible .slider-track { - outline: calc(2px * var(--uui-show-focus-outline, 1)) solid - var(--uui-color-focus); - } - - .slider-track { - left: 0; - right: 0; - height: 0; - position: absolute; - height: 18px; - border-radius: 10px; - } + /** NATIVE INPUT STYLING */ - .inner-track { + input { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; position: absolute; - border-radius: 10px; - top: 50%; - transform: translateY(-50%); + top: 0; + background-color: transparent; + pointer-events: none; left: 0; right: 0; - height: 3px; - margin: -23px 0; - z-index: -1; - background-color: #a1a1a1; + border-radius: 20px; } - .inner-track:focus { - background-color: var(--uui-color-border-standalone); + input::-webkit-slider-thumb { + pointer-events: all; + position: relative; + z-index: 1; + outline: 0; } - .inner-track .color { - height: 3px; - position: absolute; - transition: left 120ms ease, right 120ms ease; + input::-moz-range-thumb { + pointer-events: all; + position: relative; + z-index: 10; + -moz-appearance: none; + background: linear-gradient(to bottom, #ededed 0%, #dedede 100%); + width: 11px; } - input[type='range']:not([disabled]) ~ .inner-track .color { - background-color: var(--uui-color-selected); + input::-moz-range-track { + position: relative; + z-index: -1; + background-color: rgba(0, 0, 0, 0.15); + border: 0; } - input[type='range']:disabled ~ .inner-track .color { - background-color: #555; + input:last-of-type::-moz-range-track { + -moz-appearance: none; + background: none transparent; + border: 0; } - #thumb-wrapper { - position: relative; - margin: 0 ${TRACK_PADDING}px; - } + /** TRACK */ - .thumb { - width: 17px; - height: 17px; + #inner-track .color-target { position: absolute; - top: -31px; - bottom: 0; + z-index: 2; left: 0; - margin-left: -8px; - margin-right: -8px; - border-radius: 50%; - box-sizing: border-box; - background-color: var(--uui-color-surface); - border: 2px solid var(--uui-color-selected); - transition: 120ms left ease; - z-index: 10; + right: 0; + height: 25px; + transform: translateY(-50%); } - .thumb:after { - content: ''; + #inner-track .color { + height: 3px; position: absolute; - top: 2px; - left: 2px; - height: 9px; - width: 9px; - border-radius: 50%; - background-color: var(--uui-color-selected); + transition: background-color 320ms ease-out; } - :host([disabled]) .thumb { - background-color: var(--uui-color-disabled); - border-color: var(--uui-palette-mine-grey); - } - :host([disabled]) .thumb:after { - background-color: var(--uui-palette-mine-grey); + #range-slider #inner-track .color:has(.color-target:hover), + #range-slider #inner-track .color:has(.color-target:active) { + background-color: var(--uui-color-focus); } - .thumb .value { - position: absolute; - box-sizing: border-box; - font-weight: 700; - bottom: 15px; - left: 50%; - width: 40px; - margin-left: -20px; - text-align: center; - opacity: 1; - transition: 120ms opacity; - color: var(--uui-color-selected); - visibility: hidden; - opacity: 0; - } - :host([disabled]) .thumb .value { - color: var(--uui-palette-mine-grey); + :host(:not([disabled])) #range-slider .color { + background-color: var(--uui-color-selected); } - #wrapper:active .thumb .value, - #wrapper:focus .thumb .value, - #wrapper:hover .thumb .value { - visibility: visible; - opacity: 1; + :host([disabled]) #range-slider .color { + background-color: #555; } - .svg-wrapper svg { - margin-top: -6px; - height: 30px; + #range-slider { + transform: translateY(50%); + position: relative; + height: 18px; + display: flex; + flex-direction: column; width: 100%; } - #wrapper:hover .track-step, - #wrapper:active .track-step { - fill: #a1a1a1; + #inner-track { + border-radius: 10px; + position: absolute; + height: 3px; + background-color: var(--uui-color-border-standalone); + left: ${TRACK_PADDING}px; /* Match TRACK_MARGIN */ + right: ${TRACK_PADDING}px; /* Match TRACK_MARGIN */ + } + + #range-slider:hover #inner-track, + #range-slider:active #inner-track { + background-color: #a1a1a1; } + /** STEP VALUES */ + .track-step { fill: var(--uui-color-border); } - #wrapper .track-step.filled { + :host .track-step.filled { fill: var(--uui-color-selected) !important; } - #wrapper .track-step.filled-disabled { + :host .track-step.filled-disabled { fill: var(--uui-palette-mine-grey) !important; } + #range-slider:hover .track-step, + #range-slider:active .track-step { + fill: #a1a1a1; + } + #step-values { margin: 0 ${TRACK_PADDING}px; /* Match TRACK_MARGIN */ - padding-top: 24px; + padding-top: ${TRACK_PADDING + 3}px; display: flex; align-items: flex-end; box-sizing: border-box; @@ -207,35 +178,123 @@ export class UUIRangeSliderElement extends FormControlMixin(LitElement) { flex-grow: 0; } - #input-wrapper { - position: relative; - margin: -45px ${TRACK_PADDING / 2}px 45px; + .svg-wrapper { + margin: 0 ${-1 * TRACK_PADDING}px; + height: 18px; + transform: translateY(-75%); } - input { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; + .svg-wrapper svg { + margin-top: ${TRACK_PADDING / 2}px; + } + + /** FOCUS */ + + input[type='range'] { position: absolute; - top: 0; - background-color: transparent; - pointer-events: none; left: 0; right: 0; - margin: -30px -8px; - z-index: 12; - border-radius: 20px; + top: -50%; } input[type='range']:focus-visible { outline: none; } + #low-input:focus-visible ~ #inner-track #low-thumb, + #high-input:focus-visible ~ #inner-track #high-thumb, + #low-input:focus ~ #inner-track #low.thumb, + #high-input:focus ~ #inner-track #high-thumb, + #low-input:active ~ #inner-track #low.thumb, + #high-input:active ~ #inner-track #high-thumb { + outline: calc(2px * var(--uui-show-focus-outline, 1)) solid + var(--uui-color-focus); + } + input[type='range']:focus + .thumb { outline: calc(2px * var(--uui-show-focus-outline, 1)) solid var(--uui-color-focus); } + #range-slider #inner-track .color:has(.color-target:hover) ~ #low-thumb, + #range-slider #inner-track .color:has(.color-target:active) ~ #low-thumb, + #range-slider #inner-track .color:has(.color-target:hover) ~ #high-thumb, + #range-slider + #inner-track + .color:has(.color-target:active) + ~ #high-thumb { + outline: calc(2px * var(--uui-show-focus-outline, 1)) solid + var(--uui-color-focus); + } + + /** THUMBS */ + + .thumb { + z-index: 3; + transform: translateY(-50%); + position: absolute; + top: 2px; + bottom: 0px; + left: 0px; + height: 17px; + width: 17px; + margin-left: -8px; + margin-right: -8px; + border-radius: 50%; + box-sizing: border-box; + background-color: var(--uui-color-surface, #fff); + border: 2px solid var(--uui-color-selected, #3544b1); + transition: left 120ms ease 0s; + } + + .thumb:after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + height: 9px; + width: 9px; + border-radius: 50%; + background-color: var(--uui-color-selected); + } + + :host([disabled]) .thumb { + background-color: var(--uui-color-disabled); + border-color: var(--uui-palette-mine-grey); + } + :host([disabled]) .thumb:after { + background-color: var(--uui-palette-mine-grey); + } + + .thumb .value { + position: absolute; + box-sizing: border-box; + font-weight: 700; + bottom: 15px; + left: 50%; + width: 40px; + margin-left: -20px; + text-align: center; + opacity: 1; + transition: 120ms opacity; + color: var(--uui-color-selected); + visibility: hidden; + opacity: 0; + } + + :host([disabled]) .thumb .value { + color: var(--uui-palette-mine-grey); + } + + #range-slider:active .thumb .value, + #range-slider:focus .thumb .value, + #range-slider:hover .thumb .value { + visibility: visible; + opacity: 1; + } + + /** NATIVE THUMB STYLING */ + input[type='range']::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; @@ -319,13 +378,6 @@ export class UUIRangeSliderElement extends FormControlMixin(LitElement) { @property({ type: Boolean, attribute: 'hide-step-values' }) hideStepValues = false; - private _min = 0; - private _max = 100; - private _valueLow = 0; - private _valueHigh = 100; - private _minGap = 1; - private _maxGap?: number; - /** * Sets the minimum allowed value. * @type {number} @@ -333,21 +385,7 @@ export class UUIRangeSliderElement extends FormControlMixin(LitElement) { * @default 0 */ @property({ type: Number }) - set min(newVal) { - const old = this._min; - if (newVal < this.max) { - this._min = newVal; - this.requestUpdate('min', old); - if (this.valueLow < newVal) { - const move = newVal - old; - this.valueHigh = this.valueHigh + move; - this.valueLow = this.valueLow + move; - } - } - } - get min() { - return this._min; - } + min = 0; /** * Sets the maximum allowed value. @@ -356,21 +394,25 @@ export class UUIRangeSliderElement extends FormControlMixin(LitElement) { * @default 100 */ @property({ type: Number }) - set max(newVal) { - const old = this._max; - if (newVal > this.min) { - this._max = newVal; - this.requestUpdate('max', old); - if (this.valueHigh > newVal) { - const move = newVal - old; - this.valueLow = this.valueLow + move; - this.valueHigh = this.valueHigh + move; - } - } - } - get max() { - return this._max; - } + max = 100; + + /** + * Minimum value gap between the the two picked values. Cannot be lower than the step value and cannot be higher than the maximum gap + * @type {number} + * @attr min-gap + * @default undefined + */ + @property({ type: Number, attribute: 'min-gap' }) + minGap?: number; + + /** + * Maximum value gap between the the two picked values. Cannot be lower than the minimum gap. + * @type {number} + * @attr max-gap + * @default undefined + */ + @property({ type: Number, attribute: 'max-gap' }) + maxGap?: number; /** * This is a value property of the uui-range-slider. Split the two values with comma, forexample 10,50 sets the values to 10 and 50. @@ -385,13 +427,13 @@ export class UUIRangeSliderElement extends FormControlMixin(LitElement) { set value(newVal) { if (newVal instanceof String) { super.value = newVal; - const values = newVal.split(','); - this._valueLow = parseInt(values[0]); - this._valueHigh = parseInt(values[1]); + this.valueLow = parseInt(values[0]); + this.valueHigh = parseInt(values[1]); } } + private _valueLow = 0; /** * The lower picked value. * @type {number} @@ -399,26 +441,29 @@ export class UUIRangeSliderElement extends FormControlMixin(LitElement) { * @default 0 */ @property({ type: Number, attribute: 'value-low' }) - set valueLow(newVal) { - const old = this._valueLow; - if ( - newVal <= this.valueHigh - this.minGap && - newVal >= this.min && - (!this.maxGap || this.maxGap >= this.valueHigh - newVal) - ) { - this._valueLow = newVal; - super.value = `${newVal},${this.valueHigh}`; - this.requestUpdate('valueLow', old); - } else if (newVal < this.min) { + set valueLow(newLow) { + const old = this._valueHigh; + if (newLow <= this.min) { this._valueLow = this.min; - super.value = `${newVal},${this.min}`; + super.value = `${this.min},${this.valueHigh}`; + this.requestUpdate('valueLow', old); + return; + } + if (newLow >= this.valueHigh - this.step) { + this._valueLow = this.valueHigh - this.step; + super.value = `${this.valueHigh - this.step},${this.valueHigh}`; this.requestUpdate('valueLow', old); + return; } + this._valueLow = newLow; + super.value = `${newLow},${this.valueHigh}`; + this.requestUpdate('valueLow', old); } get valueLow() { return this._valueLow; } + private _valueHigh = 100; /** * The higher picked value. * @type {number} @@ -426,134 +471,109 @@ export class UUIRangeSliderElement extends FormControlMixin(LitElement) { * @default 100 */ @property({ type: Number, attribute: 'value-high' }) - set valueHigh(newVal) { + set valueHigh(newHigh) { const old = this._valueHigh; - if ( - newVal >= this.valueLow + this.minGap && - newVal <= this.max && - (!this.maxGap || this.maxGap >= newVal - this.valueLow) - ) { - this._valueHigh = newVal; - super.value = `${this.valueLow},${newVal}`; - this.requestUpdate('valueHigh', old); - } else if (newVal > this.max) { - this.valueHigh = this.max; + if (newHigh >= this.max) { + this._valueHigh = this.max; super.value = `${this.valueLow},${this.max}`; this.requestUpdate('valueHigh', old); + return; } + if (newHigh <= this.valueLow + this.step) { + this._valueHigh = this.valueLow + this.step; + super.value = `${this.valueLow},${this.valueLow + this.step}`; + this.requestUpdate('valueHigh', old); + return; + } + this._valueHigh = newHigh; + super.value = `${this.valueLow},${newHigh}`; + this.requestUpdate('valueHigh', old); } get valueHigh() { return this._valueHigh; } - /** - * Minimum value gap between the the two picked values. Cannot be lower than the step value and cannot be higher than the maximum gap - * @type {number} - * @attr min-gap - * @default 1 - */ - @property({ type: Number, attribute: 'min-gap' }) - set minGap(newVal) { - const old = this._minGap; - if (newVal > this.step && newVal > 0) { - this._minGap = newVal; - } else { - this._minGap = this.step; - } - this.requestUpdate('minGap', old); - } - get minGap() { - return this._minGap; - } + @state() + private _trackWidth = 0; - /** - * Maximum value gap between the the two picked values. Cannot be lower than the minimum gap. - * @type {number} - * @attr max-gap - * @default undefined - */ - @property({ type: Number, attribute: 'max-gap' }) - set maxGap(newVal) { - const old = this._maxGap; - if (newVal && newVal > this.minGap && newVal > this.step) { - this._maxGap = newVal; - } else { - this._maxGap = undefined; - } - this.requestUpdate('maxGap', old); - } - get maxGap() { - return this._maxGap; - } + @state() + private _currentInputFocus?: HTMLInputElement; - @property({ type: Number }) - private _trackWidth = 0; + @state() + private _currentThumbFocus: 'high' | 'low' = 'low'; @state() - private _handle = { - low: false, - high: false, - both: false, - startPosition: 0, - lowStart: 0, - highStart: 0, - }; + private _grabbingBoth?: boolean; + + @state() + private _startPos = 0; - @query('#min-slider') + @state() + private _startLow = 0; + + @state() + private _startHigh = 0; + + @query('#low-input') private _inputLow!: HTMLInputElement; - @query('#max-slider') + @query('#high-input') private _inputHigh!: HTMLInputElement; - @query('.slider-track') - private _sliderTrack!: HTMLElement; + @query('#range-slider') + private _outerTrack!: HTMLElement; + + @query('#inner-track') + private _innerTrack!: HTMLElement; - @query('.inner-track') - private _innerSliderTrack!: HTMLElement; + @query('#low-thumb') + private _thumbLow!: HTMLElement; + + @query('#high-thumb') + private _thumbHigh!: HTMLElement; @query('.color') private _innerColor!: HTMLElement; - public focus() { - this._inputLow.focus(); + @query('.color-target') + private _bothThumbsTarget!: HTMLElement; + + #setValue(val?: string) { + this._value = val ? val : `${this.valueLow},${this.valueHigh}`; } - protected getFormElement(): HTMLElement { - return this._inputLow; + protected getFormElement(): HTMLInputElement { + return this._currentInputFocus ? this._currentInputFocus : this._inputLow; } - private _onMinInput(e: Event) { - e.stopPropagation(); - const low = parseInt(this._inputLow.value); - const high = parseInt(this._inputHigh.value) - this.minGap; - if (low >= high && (!this.maxGap || this.maxGap >= high - low)) { - this._inputLow.value = String(high); - this.valueLow = high; - } else { - this.valueLow = parseInt(this._inputLow.value); - } - this.dispatchEvent(new UUIRangeSliderEvent(UUIRangeSliderEvent.INPUT)); + public focus() { + this._currentInputFocus + ? this._currentInputFocus.focus() + : this._inputLow.focus(); } - private _onMaxInput(e: Event) { - e.stopPropagation(); - const high = parseInt(this._inputHigh.value); - const low = parseInt(this._inputLow.value) + this.minGap; - if (high <= low && (!this.maxGap || this.maxGap >= high - low)) { - this._inputHigh.value = String(low); - this.valueHigh = low; - } else { - this.valueHigh = parseInt(this._inputHigh.value); + private _onKeypress(e: KeyboardEvent) { + if (e.key == 'Enter') { + this.submit(); } - this.dispatchEvent(new UUIRangeSliderEvent(UUIRangeSliderEvent.INPUT)); } - private _onChange(e: Event) { - e.stopPropagation(); - this.pristine = false; - this.dispatchEvent(new UUIRangeSliderEvent(UUIRangeSliderEvent.CHANGE)); + /** Thumb position */ + + private _sliderLowThumbPosition() { + const ratio = (this.valueLow - this.min) / (this.max - this.min); + const valueLowPercent = `${Math.floor(ratio * 100000) / 1000}%`; + return valueLowPercent; } + private _sliderHighThumbPosition() { + const ratio = (this.valueHigh - this.min) / (this.max - this.min); + const valueHighPercent = `${Math.floor(ratio * 100000) / 1000}%`; + return valueHighPercent; + } + + /** Coloring of the line between thumbs */ + private _fillColor() { const percentStart = ((this.valueLow - this.min) / (this.max - this.min)) * 100; @@ -564,173 +584,269 @@ export class UUIRangeSliderElement extends FormControlMixin(LitElement) { this._innerColor.style.right = `${100 - percentEnd}%`; } - //Keyboards + /** Moving thumb */ - private _onKeypress(e: KeyboardEvent): void { - if (e.key == 'Enter') { - this.submit(); - } + private _moveThumb(pageX: number) { + const value = this._getValue(pageX); + if (value >= this.valueHigh) this._setThumb(this._thumbHigh); + if (value <= this.valueLow) this._setThumb(this._thumbLow); + this._setValueBasedOnCurrentThumb( + this._validateValueBasedOnCurrentThumb(value) + ); } - // Touch events + /** Mouse events */ - private _onTouchStart = (e: TouchEvent) => { + private _onMouseDown = (e: MouseEvent) => { e.preventDefault(); - if (!this.disabled) { - const target = e.composedPath()[0]; - if (target == this._inputLow) { - this._handle.low = true; - } else if (target == this._inputHigh) { - this._handle.high = true; - } else { - const thumb = this._getHandles(e.touches[0].pageX); - if (thumb?.type == 'low' && this._handle.low == true) { - this.valueLow = thumb.value; - } else if (thumb?.type == 'high' && this._handle.high == true) { - this.valueHigh = thumb.value; - } - } - window.addEventListener('touchend', this._onTouchEnd); - window.addEventListener('touchmove', this._onTouchMove); + if (this.disabled) return; + window.addEventListener('mouseup', this._onMouseUp); + window.addEventListener('mousemove', this._onMouseMove); + + const target = e.composedPath()[0]; + const pageX = e.pageX; + + target == this._bothThumbsTarget + ? (this._grabbingBoth = true) + : (this._grabbingBoth = false); + + if (this._grabbingBoth) { + this._saveStartPoint(pageX, this.valueLow, this.valueHigh); + return; } + + this._moveThumb(pageX); }; - private _onTouchMove = (e: TouchEvent) => { - if (this._innerSliderTrack) { - const offsetX = - e.touches[0].pageX - - this._innerSliderTrack.getBoundingClientRect().left; - if (this._handle.both == true) { - this._updateBothValues(offsetX); - } else if (this._handle.low == true) { - this.valueLow = this._getValue(offsetX); - } else if (this._handle.high == true) { - this.valueHigh = this._getValue(offsetX); - } - } + private _onMouseMove = (e: MouseEvent) => { + e.preventDefault(); + const pageX = e.pageX; + const val = this._getValue(pageX); + if (!this._grabbingBoth) + this._setValueBasedOnCurrentThumb( + this._validateValueBasedOnCurrentThumb(val) + ); + else this._moveBoth(pageX); + + this.dispatchEvent(new UUIRangeSliderEvent(UUIRangeSliderEvent.INPUT)); }; - private _onTouchEnd = () => { - this.stopMoving(); + private _onMouseUp = () => { + this._stop(); + window.removeEventListener('mouseup', this._onMouseUp); + window.removeEventListener('mousemove', this._onMouseMove); }; - // Mouse events + /** Touch / mobile events */ - private _onMouseDown = (e: MouseEvent) => { - if (!this.disabled) { - const target = e.composedPath()[0]; - - if (target == this._inputLow) { - this._handle.low = true; - } else if (target == this._inputHigh) { - this._handle.high = true; - } else { - const thumb = this._getHandles(e.pageX); - if (thumb?.type == 'low' && this._handle.low == true) { - this.valueLow = thumb.value; - } else if (thumb?.type == 'high' && this._handle.high == true) { - this.valueHigh = thumb.value; - } - } - window.addEventListener('mouseup', this._onMouseUp); - window.addEventListener('mousemove', this._onMouseMove); + private _onTouchStart = (e: TouchEvent) => { + e.preventDefault(); + if (this.disabled) return; + + window.addEventListener('touchend', this._onTouchEnd); + window.addEventListener('touchmove', this._onTouchMove); + + const target = e.composedPath()[0]; + const pageX = e.touches[0].pageX; + + target == this._bothThumbsTarget + ? (this._grabbingBoth = true) + : (this._grabbingBoth = false); + + if (this._grabbingBoth) { + this._saveStartPoint(pageX, this.valueLow, this.valueHigh); + return; } + this._moveThumb(pageX); }; - private _onMouseMove = (e: MouseEvent) => { - if (this._handle.both == true) { - e.preventDefault(); - this._updateBothValues(e.offsetX); - } else if (this._handle.low == true) { - const newVal = this._getValue(e.offsetX); - if (newVal != this.valueLow) { - this.valueLow = newVal; - this.dispatchEvent(new UUIRangeSliderEvent(UUIRangeSliderEvent.INPUT)); - } - } else if (this._handle.high == true) { - const newVal = this._getValue(e.offsetX); - if (newVal != this.valueHigh) { - this.valueHigh = newVal; - this.dispatchEvent(new UUIRangeSliderEvent(UUIRangeSliderEvent.INPUT)); - } - } + private _onTouchMove = (e: TouchEvent) => { + const pageX = e.touches[0].pageX; + const val = this._getValue(pageX); + if (!this._grabbingBoth) + this._setValueBasedOnCurrentThumb( + this._validateValueBasedOnCurrentThumb(val) + ); + else this._moveBoth(pageX); + + this.dispatchEvent(new UUIRangeSliderEvent(UUIRangeSliderEvent.INPUT)); }; - private _onMouseUp = () => { - this.stopMoving(); - window.removeEventListener('mouseup', this._onMouseUp); - window.removeEventListener('mousemove', this._onMouseMove); + private _onTouchEnd = () => { + this._stop(); + window.removeEventListener('touchend', this._onTouchEnd); + window.removeEventListener('touchmove', this._onTouchMove); }; - // Event logic + /** */ - private stopMoving = () => { - this._handle.both = false; - this._handle.high = false; - this._handle.low = false; - this._handle.startPosition = 0; + private _stop() { + this._grabbingBoth = false; this.pristine = false; this.dispatchEvent(new UUIRangeSliderEvent(UUIRangeSliderEvent.CHANGE)); - }; + } - private _getValue(offsetX: number) { - const p = offsetX / (this._trackWidth + TRACK_PADDING * 2); - const trackDiff = this.max - this.min; - const positionValue = p * trackDiff + this.min; - const value = Math.round(positionValue / this.step) * this.step; - return value; + /** The latest thumb in use */ + + private _setThumb(target: EventTarget | HTMLElement) { + this._currentThumbFocus = target === this._thumbLow ? 'low' : 'high'; + + this._currentThumbFocus === 'low' + ? (this._currentInputFocus = this._inputLow) + : (this._currentInputFocus = this._inputHigh); + + this.focus(); + } + + private _setValueBasedOnCurrentThumb(val: number) { + this._currentThumbFocus === 'low' + ? (this.valueLow = val) + : (this.valueHigh = val); } - private _getHandles(pageX: number) { + /** Get the value depends on where clicked/touched */ + + private _getValue(pageX: number) { const mouseXPosition = - pageX - this._innerSliderTrack.getBoundingClientRect().left; + pageX - this._innerTrack.getBoundingClientRect().left; const clickPercent = mouseXPosition / (this._trackWidth - TRACK_PADDING * 2); const clickedValue = clickPercent * (this.max - this.min) + this.min; const newValue = Math.round(clickedValue / this.step) * this.step; - if (clickedValue < this.valueLow) { - this._handle.low = true; - return { type: 'low', value: newValue }; - } else if (clickedValue > this.valueHigh) { - this._handle.high = true; - return { type: 'high', value: newValue }; - } else if (clickedValue > this.valueLow && clickedValue < this.valueHigh) { - this._handle.both = true; - this._handle.lowStart = this.valueLow; - this._handle.highStart = this.valueHigh; - this._handle.startPosition = mouseXPosition; - return { type: 'both', value: newValue }; + return newValue; + } + + private _validateLowByMinGap(value: number) { + if (!this.minGap || this.minGap <= this.step) return value; + return value + this.minGap >= this.valueHigh + ? this.valueHigh - this.minGap + : value; + } + + private _validateLowByMaxGap(value: number) { + if (!this.maxGap) return value; + return this.valueHigh - value >= this.maxGap + ? this.valueHigh - this.maxGap + : value; + } + + private _validateHighByMinGap(value: number) { + if (!this.minGap || this.minGap <= this.step) return value; + return value <= this.valueLow + this.minGap + ? this.valueLow + this.minGap + : value; + } + + private _validateHighByMaxGap(value: number) { + if (!this.maxGap) return value; + return value >= this.valueLow + this.maxGap + ? this.valueLow + this.maxGap + : value; + } + + private _validateValueBasedOnCurrentThumb(newValue: number): number { + if (this._currentThumbFocus == 'low') { + let newLow: number; + newLow = + newValue < this.valueHigh - this.step + ? newValue + : this.valueHigh - this.step; + newLow = newLow >= this.min ? newLow : this.min; + + newLow = this.minGap ? this._validateLowByMinGap(newLow) : newLow; + newLow = this.maxGap ? this._validateLowByMaxGap(newLow) : newLow; + + return newLow; } - return; + + let newHigh: number; + newHigh = + newValue > this.valueLow + this.step + ? newValue + : this.valueLow + this.step; + newHigh = newHigh <= this.max ? newHigh : this.max; + + newHigh = this.minGap ? this._validateHighByMinGap(newHigh) : newHigh; + newHigh = this.maxGap ? this._validateHighByMaxGap(newHigh) : newHigh; + + return newHigh; + } + + /** Methods when moving both thumbs */ + + private _saveStartPoint(pageX: number, lowVal: number, highVal: number) { + this._startPos = pageX; + this._startLow = lowVal; + this._startHigh = highVal; } - private _updateBothValues(mousePosition: number) { - const drag = mousePosition - this._handle.startPosition; + private _moveBoth(pageX: number) { + const drag = pageX - this._startPos; const trackDiff = this.max - this.min; const dragPercent = drag / (this._trackWidth + TRACK_PADDING * 2); const dragValue = Math.round((dragPercent * trackDiff) / this.step) * this.step; - const newLow = this._handle.lowStart + dragValue; - const newHigh = this._handle.highStart + dragValue; - - if ( - this.valueLow !== newLow && - newLow >= this.min && - newHigh <= this.max && - dragValue != 0 - ) { - this.valueLow = newLow; - this.valueHigh = newHigh; + const newValueLow = this._startLow + dragValue; + const newValueHigh = this._startHigh + dragValue; + + const value = this._getValidatedValues(newValueLow, newValueHigh); + + if (newValueLow === value.low && newValueHigh === value.high) { + this.valueLow = newValueLow; + this.valueHigh = newValueHigh; this.dispatchEvent(new UUIRangeSliderEvent(UUIRangeSliderEvent.INPUT)); } } + private _getValidatedValues(low: number, high: number) { + const validatedLow = low > this.min ? low : this.min; + const validatedHigh = high < this.max ? high : this.max; + return { low: validatedLow, high: validatedHigh }; + } + + /** CHANGE AND INPUT EVENT LISTENERS */ + + private _onChange(e: Event) { + e.stopPropagation(); + this.pristine = false; + this.dispatchEvent(new UUIRangeSliderEvent(UUIRangeSliderEvent.CHANGE)); + } + + private _onLowInput(e: Event) { + e.stopPropagation(); + let value = parseInt(this._inputLow.value); + + value = this._validateLowByMinGap(value); + value = this._validateLowByMaxGap(value); + + this._inputLow.value = String(value); + this.valueLow = value; + + this.dispatchEvent(new UUIRangeSliderEvent(UUIRangeSliderEvent.INPUT)); + } + + private _onHighInput(e: Event) { + e.stopPropagation(); + let value = parseInt(this._inputHigh.value); + + value = this._validateHighByMinGap(value); + value = this._validateHighByMaxGap(value); + + this._inputHigh.value = String(value); + this.valueHigh = value; + + this.dispatchEvent(new UUIRangeSliderEvent(UUIRangeSliderEvent.INPUT)); + } + + /** Constructor */ + constructor() { super(); + // Keyboard this.addEventListener('keypress', this._onKeypress); // Mouse this.addEventListener('mousedown', this._onMouseDown); @@ -746,7 +862,7 @@ export class UUIRangeSliderElement extends FormControlMixin(LitElement) { this.addValidator( 'stepMismatch', () => `Maxmimum gap needs to be higher than minimum gap`, - () => !!this.maxGap && this.maxGap <= this.minGap + () => !!this.maxGap && !!this.minGap && this.maxGap <= this.minGap ); this.addValidator( @@ -763,35 +879,85 @@ export class UUIRangeSliderElement extends FormControlMixin(LitElement) { ); } + connectedCallback(): void { + super.connectedCallback(); + this.#setValue(); + window.addEventListener('resize', () => { + this._trackWidth = this._outerTrack.offsetWidth; + }); + } + updated(changedProperties: Map) { super.updated(changedProperties); - this._trackWidth = this._sliderTrack.offsetWidth; + this._trackWidth = this._outerTrack.offsetWidth; this._fillColor(); } - connectedCallback() { - super.connectedCallback(); - window.addEventListener('resize', () => { - this._trackWidth = this._sliderTrack.offsetWidth; - }); - } + /** RENDER */ - //Render stuff + render() { + return html` +
+ ${this.renderNativeInputs()} +
+ + ${this.renderStepsOutput()} ${this.renderThumbs()} +
+
${this.renderStepValues()}
+
+ `; + } - private _sliderLowThumbPosition() { - const ratio = - (parseFloat((this.valueLow || '0') as string) - this.min) / - (this.max - this.min); - const valueLowPercent = `${Math.floor(ratio * 100000) / 1000}%`; - return valueLowPercent; + renderNativeInputs() { + return html` + `; + } + + renderThumbs() { + return html`
+
${this.valueLow}
+
+
+
${this.valueHigh}
+
`; } - private _sliderHighThumbPosition() { - const ratio = - (parseFloat((this.valueHigh || '0') as string) - this.min) / - (this.max - this.min); - const valueHighPercent = `${Math.floor(ratio * 100000) / 1000}%`; - return valueHighPercent; + /** RENDER STEPS & STEP VALUES */ + renderStepsOutput() { + return html`
+ + + ${this.renderSteps()} + +
`; } renderSteps() { @@ -823,11 +989,12 @@ export class UUIRangeSliderElement extends FormControlMixin(LitElement) { } } - renderStepValues(hide: boolean) { + renderStepValues() { + if (this.hideStepValues) return nothing; const stepAmount = (this.max - this.min) / this.step; const stepWidth = (this._trackWidth - TRACK_PADDING * 2) / stepAmount; - if (stepWidth >= STEP_MIN_WIDTH && stepAmount <= 20 && !hide) { + if (stepWidth >= STEP_MIN_WIDTH && stepAmount <= 20) { let i = 0; const stepValues = []; for (i; i <= stepAmount; i++) { @@ -840,65 +1007,6 @@ export class UUIRangeSliderElement extends FormControlMixin(LitElement) { return html``; } } - - private _onInputMouseDown = (e: MouseEvent) => { - e.stopPropagation(); - }; - - render() { - return html` -
-
-
- - - ${this.renderSteps()} - -
-
- -
-
${this.valueLow}
-
- -
-
${this.valueHigh}
-
-
-
-
-
- ${this.renderStepValues(this.hideStepValues)} -
-
- `; - } } declare global { diff --git a/packages/uui-range-slider/lib/uui-range-slider.story.ts b/packages/uui-range-slider/lib/uui-range-slider.story.ts index 4cf31b05d..2bc8159d5 100644 --- a/packages/uui-range-slider/lib/uui-range-slider.story.ts +++ b/packages/uui-range-slider/lib/uui-range-slider.story.ts @@ -2,6 +2,7 @@ import '.'; import { Story } from '@storybook/web-components'; import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; export default { id: 'uui-range-slider', @@ -11,7 +12,7 @@ export default { step: 10, minGap: 10, maxGap: 0, - valueLow: 20, + valueLow: 0, valueHigh: 70, disabled: false, hideStepValues: false, @@ -30,13 +31,13 @@ const Template: Story = props => html` diff --git a/packages/uui-range-slider/lib/uui-range-slider.test.ts b/packages/uui-range-slider/lib/uui-range-slider.test.ts index fef62536c..16dc6bafc 100644 --- a/packages/uui-range-slider/lib/uui-range-slider.test.ts +++ b/packages/uui-range-slider/lib/uui-range-slider.test.ts @@ -21,10 +21,10 @@ describe('UUIRangeSliderElement', () => { beforeEach(async () => { element = await fixture(html` `); inputLow = element.shadowRoot?.querySelector( - '#min-slider' + '#low-input' ) as HTMLInputElement; inputHigh = element.shadowRoot?.querySelector( - '#max-slider' + '#high-input' ) as HTMLInputElement; });