diff --git a/packages/core/src/elements/BasicElement.ts b/packages/core/src/elements/BasicElement.ts index e18822a394..194d4d8f01 100644 --- a/packages/core/src/elements/BasicElement.ts +++ b/packages/core/src/elements/BasicElement.ts @@ -15,6 +15,9 @@ const NOTIFY_REGEXP = /([a-zA-Z])(?=[A-Z])/g; const toChangedEvent = (name: string): string => `${name.replace(NOTIFY_REGEXP, '$1-').toLowerCase()}-changed`; +const toInputEvent = (name: string): string => + name === 'value' ? 'input' : `${name.replace(NOTIFY_REGEXP, '$1-').toLowerCase()}-input`; + /** * Basic element base class. * Usually used for creating low-level elements. @@ -141,6 +144,30 @@ export abstract class BasicElement extends LitElement { return !event.defaultPrevented; } + /** + * Dispatch input event when the property's value is being input. + * Event name is transformed to hyphen case, e.g. myProperty -> my-property-input. + * Except for value property it will transformed to input instead of value-input. + * Event details contain the new value. + * @param name Property name + * @param value New value + * @param [cancelable=false] Set to true if the event can be cancelled + * @returns false if the event is prevented + */ + protected notifyPropertyInput(name: string, value: unknown, cancelable = false): boolean { + const event = new CustomEvent(toInputEvent(name), { + cancelable, + bubbles: false, + detail: { + value + } + }); + + this.dispatchEvent(event); + + return !event.defaultPrevented; + } + /** * Registers the connection to the DOM * @returns {void} diff --git a/packages/elements/src/slider/__demo__/index.html b/packages/elements/src/slider/__demo__/index.html index d977e331a0..69bb7b5dca 100644 --- a/packages/elements/src/slider/__demo__/index.html +++ b/packages/elements/src/slider/__demo__/index.html @@ -149,6 +149,15 @@ valueToText.nodeValue = 'To:' + e.detail.value; minRangeText.nodeValue = 'MinRage:' + slider.minRange; }); + slider.addEventListener('input', function (e) { + valueText.nodeValue = 'Value Input:' + e.detail.value; + }); + slider.addEventListener('from-input', function (e) { + valueFromText.nodeValue = 'From Input:' + e.detail.value; + }); + slider.addEventListener('to-input', function (e) { + valueToText.nodeValue = 'To Input:' + e.detail.value; + }); }); diff --git a/packages/elements/src/slider/__test__/slider.event.test.js b/packages/elements/src/slider/__test__/slider.event.test.js index f9843665db..8ad5924788 100644 --- a/packages/elements/src/slider/__test__/slider.event.test.js +++ b/packages/elements/src/slider/__test__/slider.event.test.js @@ -7,6 +7,7 @@ import { calculateValue, tabSliderPosition } from './utils.js'; const isDragging = (el) => el.dragging; const getSliderTrackElement = (el) => el.sliderRef.value; +const getNumberField = (el, name) => el.shadowRoot.querySelector(`ef-number-field[name=${name}]`); describe('slider/Events', function () { let el; @@ -17,7 +18,7 @@ describe('slider/Events', function () { slider = getSliderTrackElement(el); }); - it('Drag thumb slider on desktop', async function () { + it('Drag thumb slider with mouse', async function () { setTimeout(() => slider.dispatchEvent(new MouseEvent('mousedown'))); await oneEvent(slider, 'mousedown'); expect(isDragging(el)).to.be.true; @@ -31,7 +32,7 @@ describe('slider/Events', function () { expect(el.value).to.equal(calculateValue(el, 100).toFixed(0).toString()); }); - it('Drag thumb slider has range on desktop', async function () { + it('Drag thumb slider has range with mouse', async function () { el.range = true; await elementUpdated(el); expect(el.from).to.equal('0'); @@ -157,7 +158,7 @@ describe('slider/Events', function () { expect(el.to).to.equal(el.from); }); - it('Click near from thumb and click near to thumb has range slider on desktop', async function () { + it('Click near from thumb and click near to thumb has range slider with mouse', async function () { el.range = true; await elementUpdated(el); expect(el.from).to.equal('0'); @@ -1273,4 +1274,268 @@ describe('slider/Events', function () { // Check call fire event expect(callCountValue).to.equal(1); }); + + it('Should fires input event when dragging from thumb slider with mouse', async function () { + // Drag 'value' from 0 to 10 + const dragPositionStart = tabSliderPosition(el, 0); + const dragPosition10 = tabSliderPosition(el, 10); + + let callCountValue = 0; + let inputValue = 0; + el.addEventListener('input', (e) => { + callCountValue += 1; + inputValue = e.detail.value; + }); + setTimeout(() => + slider.dispatchEvent(new MouseEvent('mousedown', { clientX: dragPositionStart, clientY: 0 })) + ); + await oneEvent(slider, 'mousedown'); + setTimeout(() => + window.dispatchEvent(new MouseEvent('mousemove', { clientX: dragPosition10, clientY: 0 })) + ); + await oneEvent(window, 'mousemove'); + setTimeout(() => + window.dispatchEvent(new MouseEvent('mouseup', { clientX: dragPosition10, clientY: 0 })) + ); + await oneEvent(window, 'mouseup'); + + // Check call fire event + expect(callCountValue).to.equal(1); + expect(inputValue).to.equal(calculateValue(el, dragPosition10).toString()); + }); + + it('Should fires input event twice when start dragging far from thumb slider with mouse', async function () { + // Drag 'value' from 10 to 0 + const dragPositionStart = tabSliderPosition(el, 0); + const dragPosition10 = tabSliderPosition(el, 10); + + let callCountValue = 0; + let inputValue = 0; + el.addEventListener('input', (e) => { + callCountValue += 1; + inputValue = e.detail.value; + }); + setTimeout(() => + slider.dispatchEvent(new MouseEvent('mousedown', { clientX: dragPosition10, clientY: 0 })) + ); + await oneEvent(slider, 'mousedown'); + setTimeout(() => + window.dispatchEvent(new MouseEvent('mousemove', { clientX: dragPositionStart, clientY: 0 })) + ); + await oneEvent(window, 'mousemove'); + setTimeout(() => + window.dispatchEvent(new MouseEvent('mouseup', { clientX: dragPositionStart, clientY: 0 })) + ); + await oneEvent(window, 'mouseup'); + + // Check call fire event + expect(callCountValue).to.equal(2); + expect(inputValue).to.equal(calculateValue(el, dragPositionStart).toString()); + }); + + it('Should fires from-input event when dragging thumb slider range with mouse', async function () { + // Drag 'from' from 0 to 10 + const dragPositionStart = tabSliderPosition(el, 0); + const dragPosition10 = tabSliderPosition(el, 10); + + el.range = true; + await elementUpdated(el); + + let callCountValue = 0; + let inputFromValue = 0; + el.addEventListener('from-input', (e) => { + callCountValue += 1; + inputFromValue = e.detail.value; + }); + + // Drag from + setTimeout(() => + slider.dispatchEvent(new MouseEvent('mousedown', { clientX: dragPositionStart, clientY: 0 })) + ); + await oneEvent(slider, 'mousedown'); + setTimeout(() => + window.dispatchEvent(new MouseEvent('mousemove', { clientX: dragPosition10, clientY: 0 })) + ); + await oneEvent(window, 'mousemove'); + setTimeout(() => + window.dispatchEvent(new MouseEvent('mouseup', { clientX: dragPosition10, clientY: 0 })) + ); + await oneEvent(window, 'mouseup'); + + // Check call fire event + expect(callCountValue).to.equal(1); + expect(inputFromValue).to.equal(calculateValue(el, dragPosition10).toString()); + }); + + it('Should fires to-input event when dragging thumb slider range with mouse', async function () { + // Drag 'to' from 100 to 80 + const dragPositionEnd = tabSliderPosition(el, 100); + const dragPosition80 = tabSliderPosition(el, 80); + + el.range = true; + await elementUpdated(el); + + let callCountValue = 0; + let inputToValue = 0; + el.addEventListener('to-input', (e) => { + callCountValue += 1; + inputToValue = e.detail.value; + }); + + // Drag to + setTimeout(() => + slider.dispatchEvent(new MouseEvent('mousedown', { clientX: dragPositionEnd, clientY: 0 })) + ); + await oneEvent(slider, 'mousedown'); + setTimeout(() => + window.dispatchEvent(new MouseEvent('mousemove', { clientX: dragPosition80, clientY: 0 })) + ); + await oneEvent(window, 'mousemove'); + setTimeout(() => + window.dispatchEvent(new MouseEvent('mouseup', { clientX: dragPosition80, clientY: 0 })) + ); + await oneEvent(window, 'mouseup'); + // Check call fire event + expect(callCountValue).to.equal(1); + expect(inputToValue).to.equal(calculateValue(el, dragPosition80).toString()); + }); + + it('Should fires input event when input value from number-field', async function () { + el.showInputField = ''; + await elementUpdated(el); + + const inputEvent = 'input'; + let callCountValue = 0; + let inputValue = 0; + el.addEventListener(inputEvent, (e) => { + callCountValue += 1; + inputValue = e.detail.value; + }); + const input = getNumberField(el, 'value'); + input.value = '40'; + setTimeout(() => input.dispatchEvent(new Event('input'))); + await oneEvent(el, inputEvent); + + // Check call fire event + expect(callCountValue).to.equal(1); + expect(inputValue).to.equal('40'); + }); + + it('Should fires from-input event when input value from `from` number-field', async function () { + el.showInputField = ''; + el.range = true; + await elementUpdated(el); + + const inputFromEvent = 'from-input'; + let callCountValue = 0; + let inputFromValue = 0; + el.addEventListener(inputFromEvent, (e) => { + callCountValue += 1; + inputFromValue = e.detail.value; + }); + const input = getNumberField(el, 'from'); + input.value = '40'; + setTimeout(() => input.dispatchEvent(new Event('input'))); + await oneEvent(el, inputFromEvent); + + // Check call fire event + expect(callCountValue).to.equal(1); + expect(inputFromValue).to.equal('40'); + }); + + it('Should fires to-input event when input value from `to` number-field', async function () { + el.showInputField = ''; + el.range = true; + await elementUpdated(el); + + const inputToEvent = 'to-input'; + let callCountValue = 0; + let inputToValue = 0; + el.addEventListener(inputToEvent, (e) => { + callCountValue += 1; + inputToValue = e.detail.value; + }); + const input = getNumberField(el, 'to'); + input.value = '40'; + setTimeout(() => input.dispatchEvent(new Event('input'))); + await oneEvent(el, inputToEvent); + + // Check call fire event + expect(callCountValue).to.equal(1); + expect(inputToValue).to.equal('40'); + }); + + it('Should fires input event when press ArrowUp/ArrowDown from number-field', async function () { + el.showInputField = ''; + await elementUpdated(el); + + const inputEvent = 'input'; + let callCountValue = 0; + let inputValue = 0; + el.addEventListener(inputEvent, (e) => { + callCountValue += 1; + inputValue = e.detail.value; + }); + const input = getNumberField(el, 'value'); + + setTimeout(() => input.inputElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }))); + await oneEvent(el, inputEvent); + expect(callCountValue).to.equal(1); + expect(inputValue).to.equal('1'); + + setTimeout(() => input.inputElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))); + await oneEvent(el, inputEvent); + expect(callCountValue).to.equal(2); + expect(inputValue).to.equal('0'); + }); + + it('Should fires from-input event when press ArrowUp/ArrowDown from `from` number-field', async function () { + el.showInputField = ''; + el.range = true; + await elementUpdated(el); + + const inputFromEvent = 'from-input'; + let callCountValue = 0; + let inputFromValue = 0; + el.addEventListener(inputFromEvent, (e) => { + callCountValue += 1; + inputFromValue = e.detail.value; + }); + const input = getNumberField(el, 'from'); + + setTimeout(() => input.inputElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }))); + await oneEvent(el, inputFromEvent); + expect(callCountValue).to.equal(1); + expect(inputFromValue).to.equal('1'); + + setTimeout(() => input.inputElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))); + await oneEvent(el, inputFromEvent); + expect(callCountValue).to.equal(2); + expect(inputFromValue).to.equal('0'); + }); + + it('Should fires to-input event when press ArrowUp/ArrowDown from `to` number-field', async function () { + el.showInputField = ''; + el.range = true; + await elementUpdated(el); + + const inputToEvent = 'to-input'; + let callCountValue = 0; + let inputToValue = 0; + el.addEventListener(inputToEvent, (e) => { + callCountValue += 1; + inputToValue = e.detail.value; + }); + const input = getNumberField(el, 'to'); + + setTimeout(() => input.inputElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))); + await oneEvent(el, inputToEvent); + expect(callCountValue).to.equal(1); + expect(inputToValue).to.equal('99'); + + setTimeout(() => input.inputElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }))); + await oneEvent(el, inputToEvent); + expect(callCountValue).to.equal(2); + expect(inputToValue).to.equal('100'); + }); }); diff --git a/packages/elements/src/slider/index.ts b/packages/elements/src/slider/index.ts index 21e380cf54..c79dab393f 100644 --- a/packages/elements/src/slider/index.ts +++ b/packages/elements/src/slider/index.ts @@ -41,6 +41,9 @@ import type { NumberField } from '../number-field'; * @fires value-changed - Fired when the user commits a value change. The event is not triggered if `value` property is changed programmatically. * @fires from-changed - Fired when the user changes from's value. The event is not triggered if `from` property is changed programmatically. * @fires to-changed - Fired when the user changes to's value. The event is not triggered if `to` property is changed programmatically. + * @fires input - Fired when the user inputs a value by interacting with the slider or updating its input field. + * @fires from-input - Fired when the user inputs from's value by interacting with the slider or updating its input field. + * @fires to-input - Fired when the user inputs to's value by interacting with the slider or updating its input field. */ @customElement('ef-slider') export class Slider extends ControlElement { @@ -135,6 +138,9 @@ export class Slider extends ControlElement { private valuePrevious = ''; private fromPrevious = ''; private toPrevious = ''; + private valuePreviousInput = ''; // dynamically accessed + private fromPreviousInput = ''; // dynamically accessed + private toPreviousInput = ''; // dynamically accessed /** * Specified size of increment or decrement jump between value. @@ -778,6 +784,23 @@ export class Slider extends ControlElement { event.preventDefault(); } + /** + * On number-field input + * @param event input event + * @returns {void} + */ + private onNumberFieldInput(event: InputEvent): void { + if (this.readonly) { + return; + } + const { value, name } = event.target as NumberField; + const currentData = name as SliderDataName; + + this.notifyPropertyInput(currentData, value); + event.preventDefault(); + event.stopPropagation(); + } + /** * On number-field keydown * @param event keyboard event @@ -833,6 +856,22 @@ export class Slider extends ControlElement { } } + /** + * Dispatch data {input, from-input, to-input} changing event + * @returns {void} + */ + private dispatchDataInputEvent(): void { + const name = this.changedThumb?.getAttribute('name') || ''; + const currentData = name as SliderDataName; + const previousDataInput = `${name}PreviousInput` as SliderPreviousDataName; + + // Dispatch event only when changing the input value + if (this[previousDataInput] !== this[currentData]) { + this.notifyPropertyInput(name, this[currentData]); + this[previousDataInput] = this[currentData]; + } + } + /** * Start dragging event on slider * @param event event dragstart @@ -850,12 +889,15 @@ export class Slider extends ControlElement { if (distanceFrom < distanceTo) { this.changedThumb = this.fromThumbRef.value; + this.fromPreviousInput = this.from; } else if (distanceFrom > distanceTo) { this.changedThumb = this.toThumbRef.value; + this.toPreviousInput = this.to; } // When from === to, use latest value of changedThumb and z-index will determine thumb on top } else { this.changedThumb = this.valueThumbRef.value; + this.valuePreviousInput = this.value; } this.onDrag(event); @@ -915,6 +957,7 @@ export class Slider extends ControlElement { const value = this.getValueFromPosition(newThumbPosition); this.persistChangedData(value); + this.dispatchDataInputEvent(); } /** @@ -1349,6 +1392,8 @@ export class Slider extends ControlElement { aria-hidden="true" @blur=${this.onNumberFieldBlur} @keydown=${this.onNumberFieldKeyDown} + @input=${this.onNumberFieldInput} + @value-changed=${this.onNumberFieldInput} part="input" name="${name}" no-spinner