diff --git a/packages/slider/src/vaadin-range-slider.js b/packages/slider/src/vaadin-range-slider.js index 131b690cc40..6cc0a27fbba 100644 --- a/packages/slider/src/vaadin-range-slider.js +++ b/packages/slider/src/vaadin-range-slider.js @@ -101,8 +101,6 @@ class RangeSlider extends SliderMixin( this.__value = [...this.value]; this.__inputId0 = `slider-${generateUniqueId()}`; this.__inputId1 = `slider-${generateUniqueId()}`; - - this.addEventListener('mousedown', (e) => this._onMouseDown(e)); } /** @protected */ @@ -163,9 +161,9 @@ class RangeSlider extends SliderMixin( super.updated(props); if (props.has('value') || props.has('min') || props.has('max')) { - const value = Array.isArray(this.value) ? this.value : []; - value.forEach((value, idx) => { - this.__updateValue(value, idx); + const value = [...this.value]; + value.forEach((v, idx) => { + this.__updateValue(v, idx, value); }); } } @@ -200,14 +198,11 @@ class RangeSlider extends SliderMixin( /** * @param {PointerEvent} event - * @protected + * @private */ - _onMouseDown(event) { - // Prevent blur if already focused - event.preventDefault(); - - // Focus the input to allow modifying value using keyboard - this.focus({ focusVisible: false }); + __focusInput(event) { + const index = this.__getThumbIndex(event); + this._inputElements[index].focus(); } /** @private */ @@ -215,9 +210,69 @@ class RangeSlider extends SliderMixin( this.value = [...this.__value]; } + /** + * @param {Event} event + * @return {number} + */ + __getThumbIndex(event) { + if (event.type === 'input') { + return this._inputElements.indexOf(event.target); + } + + return this.__getClosestThumb(event); + } + + /** + * @param {PointerEvent} event + * @return {number} + * @private + */ + __getClosestThumb(event) { + let closestThumb; + + // If both thumbs are at the start, use the second thumb, + // and if both are at tne end, use the first one instead. + if (this.__value[0] === this.__value[1]) { + const { min, max } = this.__getConstraints(); + if (this.__value[0] === min) { + return 1; + } + + if (this.__value[0] === max) { + return 0; + } + } + + const percent = this.__getEventPercent(event); + const value = this.__getValueFromPercent(percent); + + // First thumb position from the "end" + const index = this.__value.findIndex((v) => value - v < 0); + + // Pick the first one + if (index === 0) { + closestThumb = index; + } else if (index === -1) { + // Pick the last one (position is past all the thumbs) + closestThumb = this.__value.length - 1; + } else { + const lastStart = this.__value[index - 1]; + const firstEnd = this.__value[index]; + // Pick the first one from the "start" unless thumbs are stacked on top of each other + if (Math.abs(lastStart - value) < Math.abs(firstEnd - value)) { + closestThumb = index - 1; + } else { + // Pick the last one from the "end" + closestThumb = index; + } + } + + return closestThumb; + } + /** @private */ __onKeyDown(event) { - this.__thumbIndex = this._inputElements.indexOf(event.target); + const index = this._inputElements.indexOf(event.target); const prevKeys = ['ArrowLeft', 'ArrowDown']; const nextKeys = ['ArrowRight', 'ArrowUp']; @@ -226,8 +281,7 @@ class RangeSlider extends SliderMixin( // to prevent the case where slotted range inputs would end up in broken state. if ( this.__value[0] === this.__value[1] && - ((this.__thumbIndex === 0 && nextKeys.includes(event.key)) || - (this.__thumbIndex === 1 && prevKeys.includes(event.key))) + ((index === 0 && nextKeys.includes(event.key)) || (index === 1 && prevKeys.includes(event.key))) ) { event.preventDefault(); } diff --git a/packages/slider/src/vaadin-slider-mixin.js b/packages/slider/src/vaadin-slider-mixin.js index 1474e5f51cd..4bc4c9c1f36 100644 --- a/packages/slider/src/vaadin-slider-mixin.js +++ b/packages/slider/src/vaadin-slider-mixin.js @@ -46,33 +46,56 @@ export const SliderMixin = (superClass) => constructor() { super(); - this.__thumbIndex = 0; + this.__onPointerMove = this.__onPointerMove.bind(this); + this.__onPointerUp = this.__onPointerUp.bind(this); + + // Use separate mousedown listener for focusing the input, as + // pointerdown fires too early and the global `keyboardActive` + // flag isn't updated yet, which incorrectly shows focus-ring + this.addEventListener('mousedown', (e) => this.__onMouseDown(e)); + this.addEventListener('pointerdown', (e) => this.__onPointerDown(e)); + } + + /** @protected */ + firstUpdated() { + super.firstUpdated(); + + this.__lastCommittedValue = this.value; + } + + /** + * @param {Event} event + * @return {number} + */ + __getThumbIndex(_event) { + return 0; } /** * @param {number} value * @param {number} index + * @param {number[]} fullValue * @private */ - __updateValue(value, index = this.__thumbIndex) { + __updateValue(value, index, fullValue = this.__value) { const { min, max, step } = this.__getConstraints(); - const minValue = this.__value[index - 1] || min; - const maxValue = this.__value[index + 1] || max; + const minValue = fullValue[index - 1] !== undefined ? fullValue[index - 1] : min; + const maxValue = fullValue[index + 1] !== undefined ? fullValue[index + 1] : max; const safeValue = Math.min(Math.max(value, minValue), maxValue); - const offset = safeValue - min; + const offset = safeValue - minValue; const nearestOffset = Math.round(offset / step) * step; - const nearestValue = min + nearestOffset; + const nearestValue = minValue + nearestOffset; const newValue = Math.round(nearestValue); - this.__value = this.__value.with(index, newValue); + this.__value = fullValue.with(index, newValue); } /** - * @return {{ min: number, max: number}} + * @return {{ min: number, max: number, step: number}} * @private */ __getConstraints() { @@ -93,24 +116,141 @@ export const SliderMixin = (superClass) => return (100 * (value - min)) / (max - min); } + /** + * @param {number} percent + * @return {number} + * @private + */ + __getValueFromPercent(percent) { + const { min, max } = this.__getConstraints(); + return min + percent * (max - min); + } + + /** + * @param {PointerEvent} event + * @return {number} + * @private + */ + __getEventPercent(event) { + const offset = event.offsetX; + const size = this.offsetWidth; + const safeOffset = Math.min(Math.max(offset, 0), size); + return safeOffset / size; + } + + /** + * @param {PointerEvent} event + * @return {number} + * @private + */ + __getEventValue(event) { + const percent = this.__getEventPercent(event); + return this.__getValueFromPercent(percent); + } + + /** + * @param {PointerEvent} event + * @private + */ + __onMouseDown(event) { + const part = event.composedPath()[0].getAttribute('part'); + if (!part || (!part.startsWith('track') && !part.startsWith('thumb'))) { + return; + } + + // Prevent losing focus + event.preventDefault(); + + this.__focusInput(event); + } + + /** + * @param {PointerEvent} event + * @private + */ + __onPointerDown(event) { + if (event.button !== 0) { + return; + } + + const part = event.composedPath()[0].getAttribute('part'); + if (!part || (!part.startsWith('track') && !part.startsWith('thumb'))) { + return; + } + + this.setPointerCapture(event.pointerId); + this.addEventListener('pointermove', this.__onPointerMove); + this.addEventListener('pointerup', this.__onPointerUp); + this.addEventListener('pointercancel', this.__onPointerUp); + + this.__thumbIndex = this.__getThumbIndex(event); + + // Update value on track click + if (part.startsWith('track')) { + const newValue = this.__getEventValue(event); + this.__updateValue(newValue, this.__thumbIndex); + this.__commitValue(); + } + } + + /** + * @param {PointerEvent} event + * @private + */ + __onPointerMove(event) { + const newValue = this.__getEventValue(event); + this.__updateValue(newValue, this.__thumbIndex); + this.__commitValue(); + } + + /** + * @param {PointerEvent} event + * @private + */ + __onPointerUp(event) { + this.__thumbIndex = null; + + this.releasePointerCapture(event.pointerId); + this.removeEventListener('pointermove', this.__onPointerMove); + this.removeEventListener('pointerup', this.__onPointerUp); + this.removeEventListener('pointercancel', this.__onPointerUp); + + this.__detectAndDispatchChange(); + } + + /** + * @param {Event} event + * @private + */ + __focusInput(_event) { + this.focus({ focusVisible: false }); + } + /** @private */ __detectAndDispatchChange() { - if (this.__lastCommittedValue !== this.value) { + if (JSON.stringify(this.__lastCommittedValue) !== JSON.stringify(this.value)) { this.__lastCommittedValue = this.value; this.dispatchEvent(new Event('change', { bubbles: true })); } } - /** @private */ + /** + * @param {Event} event + * @private + */ __onInput(event) { - this.__updateValue(event.target.value); + const index = this.__getThumbIndex(event); + this.__updateValue(event.target.value, index); this.__commitValue(); - this.__detectAndDispatchChange(); } - /** @private */ + /** + * @param {Event} event + * @private + */ __onChange(event) { event.stopPropagation(); + this.__detectAndDispatchChange(); } /** diff --git a/packages/slider/src/vaadin-slider.js b/packages/slider/src/vaadin-slider.js index e1b365abe0d..44eaad6f11b 100644 --- a/packages/slider/src/vaadin-slider.js +++ b/packages/slider/src/vaadin-slider.js @@ -95,8 +95,6 @@ class Slider extends SliderMixin( this.__value = [this.value]; this.__inputId = `slider-${generateUniqueId()}`; - - this.addEventListener('mousedown', (e) => this._onMouseDown(e)); } /** @protected */ @@ -167,18 +165,6 @@ class Slider extends SliderMixin( __commitValue() { this.value = this.__value[0]; } - - /** - * @param {PointerEvent} event - * @protected - */ - _onMouseDown(event) { - // Prevent blur if already focused - event.preventDefault(); - - // Focus the input to allow modifying value using keyboard - this.focus({ focusVisible: false }); - } } defineCustomElement(Slider); diff --git a/packages/slider/test/range-slider.test.ts b/packages/slider/test/range-slider.test.ts index 77d0e40c911..659ee825399 100644 --- a/packages/slider/test/range-slider.test.ts +++ b/packages/slider/test/range-slider.test.ts @@ -1,6 +1,6 @@ import { expect } from '@vaadin/chai-plugins'; -import { sendKeys } from '@vaadin/test-runner-commands'; -import { fixtureSync, nextRender } from '@vaadin/testing-helpers'; +import { resetMouse, sendKeys, sendMouse, sendMouseToElement } from '@vaadin/test-runner-commands'; +import { fixtureSync, isFirefox, middleOfNode, nextRender } from '@vaadin/testing-helpers'; import sinon from 'sinon'; import '../vaadin-range-slider.js'; import type { RangeSlider } from '../vaadin-range-slider.js'; @@ -249,4 +249,275 @@ describe('vaadin-range-slider', () => { }); }); }); + + // Pointer tests randomly fail in Firefox + (isFirefox ? describe.skip : describe)('pointer', () => { + let thumbs: Element[]; + let inputs: HTMLInputElement[]; + + function middleOfThumb(idx: number) { + const { x, y } = middleOfNode(thumbs[idx]); + return { + x: Math.round(x), + y: Math.round(y), + }; + } + + beforeEach(async () => { + slider = fixtureSync(''); + await nextRender(); + thumbs = [...slider.shadowRoot!.querySelectorAll('[part~="thumb"]')]; + inputs = [...slider.querySelectorAll('input')]; + }); + + afterEach(async () => { + await resetMouse(); + }); + + describe('individual thumbs', () => { + it('should update slider value property on first thumb pointermove', async () => { + const { x, y } = middleOfThumb(0); + + await sendMouseToElement({ type: 'move', element: thumbs[0] }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [x + 20, y] }); + + expect(slider.value).to.deep.equal([10, 100]); + }); + + it('should update slider value property on second thumb pointermove', async () => { + const { x, y } = middleOfThumb(1); + + await sendMouseToElement({ type: 'move', element: thumbs[1] }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [x - 20, y] }); + + expect(slider.value).to.deep.equal([0, 90]); + }); + + it('should only fire change event on thumb pointerup but not pointermove', async () => { + const { x, y } = middleOfThumb(0); + + const spy = sinon.spy(); + slider.addEventListener('change', spy); + + await sendMouseToElement({ type: 'move', element: thumbs[0] }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [x + 20, y] }); + + expect(spy).to.be.not.called; + + await sendMouse({ type: 'up' }); + expect(spy).to.be.calledOnce; + }); + + it('should fire change event on pointerup outside of the element', async () => { + const spy = sinon.spy(); + slider.addEventListener('change', spy); + + await sendMouseToElement({ type: 'move', element: thumbs[0] }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [20, 100] }); + await sendMouse({ type: 'up' }); + + expect(spy).to.be.calledOnce; + }); + + it('should not fire change event on pointerup if value remains the same', async () => { + const { x, y } = middleOfThumb(0); + + const spy = sinon.spy(); + slider.addEventListener('change', spy); + + await sendMouseToElement({ type: 'move', element: thumbs[0] }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [x + 20, y] }); + + await sendMouse({ type: 'move', position: [x, y] }); + await sendMouse({ type: 'up' }); + + expect(spy).to.be.not.called; + }); + }); + + describe('track', () => { + beforeEach(() => { + slider.value = [20, 80]; + }); + + it('should focus first input on track pointerdown before the first thumb', async () => { + const { x, y } = middleOfThumb(0); + + await sendMouse({ type: 'move', position: [x - 20, y] }); + await sendMouse({ type: 'down' }); + + expect(slider.value).to.deep.equal([10, 80]); + expect(document.activeElement).to.equal(inputs[0]); + }); + + it('should focus second input on track pointerdown after the second thumb', async () => { + const { x, y } = middleOfThumb(1); + + await sendMouse({ type: 'move', position: [x + 20, y] }); + await sendMouse({ type: 'down' }); + + expect(slider.value).to.deep.equal([20, 90]); + expect(document.activeElement).to.equal(inputs[1]); + }); + + it('should focus first input on track pointerdown between thumbs closer to the first one', async () => { + const { x, y } = middleOfThumb(0); + + await sendMouse({ type: 'move', position: [x + 40, y] }); + await sendMouse({ type: 'down' }); + + expect(slider.value).to.deep.equal([40, 80]); + expect(document.activeElement).to.equal(inputs[0]); + }); + + it('should focus second input on track pointerdown between thumbs closer to the second one', async () => { + const { x, y } = middleOfThumb(1); + + await sendMouse({ type: 'move', position: [x - 40, y] }); + await sendMouse({ type: 'down' }); + + expect(slider.value).to.deep.equal([20, 60]); + expect(document.activeElement).to.equal(inputs[1]); + }); + + it('should not focus any of inputs on pointerdown below the track', async () => { + const { y } = middleOfThumb(0); + + await sendMouse({ type: 'move', position: [50, y + 10] }); + await sendMouse({ type: 'down' }); + inputs.forEach((input) => { + expect(document.activeElement).to.not.equal(input); + }); + }); + + it('should only fire change event on track pointerup', async () => { + const { x, y } = middleOfThumb(0); + + const spy = sinon.spy(); + slider.addEventListener('change', spy); + + await sendMouse({ type: 'move', position: [x - 20, y] }); + await sendMouse({ type: 'down' }); + + expect(spy).to.be.not.called; + await sendMouse({ type: 'up' }); + + expect(spy).to.be.calledOnce; + }); + }); + + describe('thumbs limits', () => { + beforeEach(() => { + slider.value = [40, 60]; + }); + + it('should use the first thumb position as a min limit on second thumb pointermove', async () => { + const { x, y } = middleOfThumb(1); + + await sendMouseToElement({ type: 'move', element: thumbs[0] }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [x + 20, y] }); + await sendMouse({ type: 'up' }); + + expect(slider.value).to.deep.equal([60, 60]); + }); + + it('should use the second thumb position as a max limit on first thumb pointermove', async () => { + const { x, y } = middleOfThumb(0); + + await sendMouseToElement({ type: 'move', element: thumbs[1] }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [x - 20, y] }); + await sendMouse({ type: 'up' }); + + expect(slider.value).to.deep.equal([40, 40]); + }); + + it('should use the first thumb position as a min limit on when min is a negative value', async () => { + slider.min = -10; + slider.max = 10; + slider.value = [0, 1]; + + const { x, y } = middleOfThumb(1); + + await sendMouseToElement({ type: 'move', element: thumbs[1] }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [x + -40, y] }); + await sendMouse({ type: 'up' }); + + expect(slider.value).to.deep.equal([0, 0]); + }); + }); + + describe('thumbs on top of each other', () => { + let x: number, y: number; + + beforeEach(() => { + slider.value = [50, 50]; + ({ x, y } = middleOfThumb(0)); + }); + + it('should focus first input and move first thumb on pointerdown closer to the left', async () => { + await sendMouse({ type: 'move', position: [x - 5, y] }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [x - 20, y] }); + + expect(document.activeElement).to.equal(inputs[0]); + expect(slider.value).to.deep.equal([40, 50]); + }); + + it('should not move first thumb to the right on pointerdown closer to the left', async () => { + await sendMouse({ type: 'move', position: [x - 5, y] }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [x + 20, y] }); + + expect(slider.value).to.deep.equal([50, 50]); + }); + + it('should focus second input and move second thumb on pointerdown closer to the right', async () => { + await sendMouse({ type: 'move', position: [x + 5, y] }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [x + 20, y] }); + + expect(slider.value).to.deep.equal([50, 60]); + expect(document.activeElement).to.equal(inputs[1]); + }); + + it('should not move second thumb to the left on pointerdown closer to the right', async () => { + await sendMouse({ type: 'move', position: [x + 5, y] }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [x - 20, y] }); + + expect(slider.value).to.deep.equal([50, 50]); + }); + + it('should always move second thumb when both thumbs are at the start', async () => { + slider.value = [0, 0]; + x = middleOfThumb(0).x; + + await sendMouse({ type: 'move', position: [x - 5, y] }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [x + 20, y] }); + + expect(slider.value).to.deep.equal([0, 10]); + }); + + it('should always move first thumb when both thumbs are at the end', async () => { + slider.value = [100, 100]; + x = middleOfThumb(1).x; + debugger; + + await sendMouse({ type: 'move', position: [x + 5, y] }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [x - 20, y] }); + + expect(slider.value).to.deep.equal([90, 100]); + }); + }); + }); }); diff --git a/packages/slider/test/slider.test.ts b/packages/slider/test/slider.test.ts index 7dfd418f5e9..5bec70927dc 100644 --- a/packages/slider/test/slider.test.ts +++ b/packages/slider/test/slider.test.ts @@ -1,6 +1,6 @@ import { expect } from '@vaadin/chai-plugins'; -import { sendKeys } from '@vaadin/test-runner-commands'; -import { fixtureSync, nextRender } from '@vaadin/testing-helpers'; +import { resetMouse, sendKeys, sendMouse, sendMouseToElement } from '@vaadin/test-runner-commands'; +import { fixtureSync, middleOfNode, nextRender } from '@vaadin/testing-helpers'; import sinon from 'sinon'; import '../vaadin-slider.js'; import type { Slider } from '../vaadin-slider.js'; @@ -119,4 +119,109 @@ describe('vaadin-slider', () => { expect(spy).to.be.calledOnce; }); }); + + describe('pointer', () => { + let thumb: Element; + let y: number; + + beforeEach(async () => { + slider = fixtureSync(''); + await nextRender(); + thumb = slider.shadowRoot!.querySelector('[part="thumb"]')!; + y = Math.round(middleOfNode(thumb).y); + }); + + afterEach(async () => { + await resetMouse(); + }); + + it('should update slider value property on thumb pointermove', async () => { + await sendMouseToElement({ type: 'move', element: thumb }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [20, y] }); + + expect(slider.value).to.equal(10); + }); + + it('should only fire change event on thumb pointerup but not pointermove', async () => { + const spy = sinon.spy(); + slider.addEventListener('change', spy); + + await sendMouseToElement({ type: 'move', element: thumb }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [20, y] }); + + expect(spy).to.be.not.called; + + await sendMouse({ type: 'up' }); + expect(spy).to.be.calledOnce; + }); + + it('should fire change event on pointerup outside of the element', async () => { + const spy = sinon.spy(); + slider.addEventListener('change', spy); + + await sendMouseToElement({ type: 'move', element: thumb }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [20, y + 100] }); + await sendMouse({ type: 'up' }); + + expect(spy).to.be.calledOnce; + }); + + it('should not fire change event on pointerup if value remains the same', async () => { + const spy = sinon.spy(); + slider.addEventListener('change', spy); + + await sendMouseToElement({ type: 'move', element: thumb }); + await sendMouse({ type: 'down' }); + await sendMouse({ type: 'move', position: [20, y] }); + + await sendMouse({ type: 'move', position: [0, y] }); + await sendMouse({ type: 'up' }); + + expect(spy).to.be.not.called; + }); + + it('should update slider value property on track pointerdown', async () => { + const track = slider.shadowRoot!.querySelector('[part="track"]')!; + + await sendMouseToElement({ type: 'move', element: track }); + await sendMouse({ type: 'down' }); + + expect(slider.value).to.equal(50); + }); + + it('should only fire change event on track pointerup', async () => { + const track = slider.shadowRoot!.querySelector('[part="track"]')!; + + const spy = sinon.spy(); + slider.addEventListener('change', spy); + + await sendMouseToElement({ type: 'move', element: track }); + await sendMouse({ type: 'down' }); + expect(spy).to.be.not.called; + + await sendMouse({ type: 'up' }); + expect(spy).to.be.calledOnce; + }); + + it('should focus slotted range input on thumb pointerdown', async () => { + await sendMouseToElement({ type: 'move', element: thumb }); + await sendMouse({ type: 'down' }); + expect(document.activeElement).to.equal(slider.querySelector('input')); + }); + + it('should focus slotted range input on track pointerdown', async () => { + await sendMouse({ type: 'move', position: [50, y] }); + await sendMouse({ type: 'down' }); + expect(document.activeElement).to.equal(slider.querySelector('input')); + }); + + it('should not focus slotted range input on pointerdown below the track', async () => { + await sendMouse({ type: 'move', position: [50, y + 5] }); + await sendMouse({ type: 'down' }); + expect(document.activeElement).to.not.equal(slider.querySelector('input')); + }); + }); });