diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index 2534ef17..87504ebc 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -8,6 +8,13 @@ New versions of Shoelace are released as-needed and generally occur when a criti ?> During the beta period, these restrictions may be relaxed in the event of a mission-critical bug. 🐛 +## Next + +- Added the `sl-input` event to ``, ``, ``, ``, and `` +- Fixed a bug in `` that caused the `sl-change` event to be incorrectly emitted when the value was set programmatically [#917](https://github.com/shoelace-style/shoelace/issues/917) +- Fixed a bug in `` that sometimes prevented the color from updating when clicking or tapping on the controls +- Fixed a bug in `` that prevented text from being entered in the color input + ## 2.0.0-beta.86 - 🚨 BREAKING: changed the default value of `date` in `` to the current date instead of the Unix epoch diff --git a/docs/resources/contributing.md b/docs/resources/contributing.md index d02e8dc5..72f8feef 100644 --- a/docs/resources/contributing.md +++ b/docs/resources/contributing.md @@ -248,6 +248,10 @@ Components must only emit custom events, and all custom events must start with ` This convention avoids the problem of browsers lowercasing attributes, causing some frameworks to be unable to listen to them. This problem isn't specific to one framework, but [Vue's documentation](https://vuejs.org/v2/guide/components-custom-events.html#Event-Names) provides a good explanation of the problem. +### Change Events + +When change events are emitted by Shoelace components, they should be named `sl-change` and they should only be emitted as a result of user input. Programmatic changes, such as setting `el.value = '…'` _should not_ result in a change event being emitted. This is consistent with how native form controls work. + ### CSS Custom Properties To expose custom properties as part of a component's API, scope them to the `:host` block. diff --git a/src/components/checkbox/checkbox.test.ts b/src/components/checkbox/checkbox.test.ts index e3d0e58c..219ce0b7 100644 --- a/src/components/checkbox/checkbox.test.ts +++ b/src/components/checkbox/checkbox.test.ts @@ -52,27 +52,42 @@ describe('', () => { expect(el.invalid).to.be.false; }); - it('should fire sl-change when clicked', async () => { + it('should emit sl-change and sl-input when clicked', async () => { const el = await fixture(html` `); - setTimeout(() => el.shadowRoot!.querySelector('input')!.click()); - const event = (await oneEvent(el, 'sl-change')) as CustomEvent; - expect(event.target).to.equal(el); + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + el.click(); + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; expect(el.checked).to.be.true; }); - it('should fire sl-change when toggled via keyboard', async () => { + it('should emit sl-change and sl-input when toggled with spacebar', async () => { const el = await fixture(html` `); - const input = el.shadowRoot!.querySelector('input')!; - input.focus(); - setTimeout(() => sendKeys({ press: ' ' })); - const event = (await oneEvent(el, 'sl-change')) as CustomEvent; - expect(event.target).to.equal(el); + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + el.focus(); + await el.updateComplete; + await sendKeys({ press: ' ' }); + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; expect(el.checked).to.be.true; }); - it('should not fire sl-change when checked is set by javascript', async () => { + it('should not emit sl-change or sl-input when checked programmatically', async () => { const el = await fixture(html` `); - el.addEventListener('sl-change', () => expect.fail('event fired')); + + el.addEventListener('sl-change', () => expect.fail('sl-change should not be emitted')); + el.addEventListener('sl-input', () => expect.fail('sl-input should not be emitted')); el.checked = true; await el.updateComplete; el.checked = false; diff --git a/src/components/checkbox/checkbox.ts b/src/components/checkbox/checkbox.ts index d7251c78..2d68c9e4 100644 --- a/src/components/checkbox/checkbox.ts +++ b/src/components/checkbox/checkbox.ts @@ -25,6 +25,7 @@ import type { CSSResultGroup } from 'lit'; * @event sl-blur - Emitted when the checkbox loses focus. * @event sl-change - Emitted when the checked state changes. * @event sl-focus - Emitted when the checkbox gains focus. + * @event sl-input - Emitted when the checkbox receives input. * * @csspart base - The component's base wrapper. * @csspart control - The square container that wraps the checkbox's checked state. @@ -124,6 +125,10 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC this.emit('sl-blur'); } + handleInput() { + this.emit('sl-input'); + } + @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange() { // Disabled form controls are always valid, so we need to recheck validity when the state changes @@ -168,6 +173,7 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC .required=${this.required} aria-checked=${this.checked ? 'true' : 'false'} @click=${this.handleClick} + @input=${this.handleInput} @blur=${this.handleBlur} @focus=${this.handleFocus} /> diff --git a/src/components/color-picker/color-picker.test.ts b/src/components/color-picker/color-picker.test.ts index e850f7e5..5bfd5ade 100644 --- a/src/components/color-picker/color-picker.test.ts +++ b/src/components/color-picker/color-picker.test.ts @@ -1,21 +1,263 @@ -import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; +import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; import sinon from 'sinon'; +import { clickOnElement } from '../../internal/test'; import type SlColorPicker from './color-picker'; describe('', () => { - it('should emit change and show correct color when the value changes', async () => { - const el = await fixture(html` `); - const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!; - const changeHandler = sinon.spy(); - const color = 'rgb(255, 204, 0)'; + describe('when the value changes', () => { + it('should not emit sl-change or sl-input when the value is changed programmatically', async () => { + const el = await fixture(html` `); + const color = 'rgb(255, 204, 0)'; - el.addEventListener('sl-change', changeHandler); - el.value = color; + el.addEventListener('sl-change', () => expect.fail('sl-change should not be emitted')); + el.addEventListener('sl-input', () => expect.fail('sl-change should not be emitted')); + el.value = color; + await el.updateComplete; + }); - await waitUntil(() => changeHandler.calledOnce); + it('should emit sl-change and sl-input when the color grid selector is moved', async () => { + const el = await fixture(html` `); + const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!; + const grid = el.shadowRoot!.querySelector('[part~="grid"]')!; + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); - expect(changeHandler).to.have.been.calledOnce; - expect(trigger.style.color).to.equal(color); + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + + await clickOnElement(trigger); // open the dropdown + await aTimeout(200); // wait for the dropdown to open + await clickOnElement(grid); // click on the grid + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; + }); + + it('should emit sl-change and sl-input when the hue slider is moved', async () => { + const el = await fixture(html` `); + const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!; + const slider = el.shadowRoot!.querySelector('[part~="hue-slider"]')!; + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + + await clickOnElement(trigger); // open the dropdown + await aTimeout(200); // wait for the dropdown to open + await clickOnElement(slider); // click on the hue slider + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; + }); + + it('should emit sl-change and sl-input when the opacity slider is moved', async () => { + const el = await fixture(html` `); + const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!; + const slider = el.shadowRoot!.querySelector('[part~="opacity-slider"]')!; + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + + await clickOnElement(trigger); // open the dropdown + await aTimeout(200); // wait for the dropdown to open + await clickOnElement(slider); // click on the opacity slider + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; + }); + + it('should emit sl-change and sl-input when toggling the format', async () => { + const el = await fixture(html` `); + const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!; + const formatButton = el.shadowRoot!.querySelector('[part~="format-button"]')!; + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + + await clickOnElement(trigger); // open the dropdown + await aTimeout(200); // wait for the dropdown to open + await clickOnElement(formatButton); // click on the format button + await el.updateComplete; + + expect(el.value).to.equal('rgb(255, 255, 255)'); + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; + }); + + it('should emit sl-change and sl-input when clicking on a swatch', async () => { + const el = await fixture(html` `); + const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!; + const swatch = el.shadowRoot!.querySelector('[part~="swatch"]')!; + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + + await clickOnElement(trigger); // open the dropdown + await aTimeout(200); // wait for the dropdown to open + await clickOnElement(swatch); // click on the swatch + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; + }); + + it('should emit sl-change and sl-input when selecting a color with the keyboard', async () => { + const el = await fixture(html` `); + const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!; + const gridHandle = el.shadowRoot!.querySelector('[part~="grid-handle"]')!; + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + + await clickOnElement(trigger); // open the dropdown + await aTimeout(200); // wait for the dropdown to open + gridHandle.focus(); + await sendKeys({ press: 'ArrowRight' }); // move the grid handle + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; + }); + + it('should emit sl-change and sl-input when selecting a color with the keyboard', async () => { + const el = await fixture(html` `); + const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!; + const handle = el.shadowRoot!.querySelector('[part~="grid-handle"]')!; + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + + await clickOnElement(trigger); // open the dropdown + await aTimeout(200); // wait for the dropdown to open + handle.focus(); + await sendKeys({ press: 'ArrowRight' }); // move the handle + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; + }); + + it('should emit sl-change and sl-input when selecting hue with the keyboard', async () => { + const el = await fixture(html` `); + const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!; + const handle = el.shadowRoot!.querySelector('[part~="hue-slider"] > span')!; + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + + await clickOnElement(trigger); // open the dropdown + await aTimeout(200); // wait for the dropdown to open + handle.focus(); + await sendKeys({ press: 'ArrowRight' }); // move the handle + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; + }); + + it('should emit sl-change and sl-input when selecting opacity with the keyboard', async () => { + const el = await fixture(html` `); + const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!; + const handle = el.shadowRoot!.querySelector('[part~="opacity-slider"] > span')!; + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + + await clickOnElement(trigger); // open the dropdown + await aTimeout(200); // wait for the dropdown to open + handle.focus(); + await sendKeys({ press: 'ArrowRight' }); // move the handle + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; + }); + + it('should emit sl-change and sl-input when entering a value in the color input and pressing enter', async () => { + const el = await fixture(html` `); + const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!; + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + + await clickOnElement(trigger); // open the dropdown + await aTimeout(200); // wait for the dropdown to open + input.focus(); // focus the input + await el.updateComplete; + await sendKeys({ type: 'fc0' }); // type in a color + await sendKeys({ press: 'Enter' }); // press enter + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; + }); + + it('should emit sl-change and sl-input when entering a value in the color input and blurring the field', async () => { + const el = await fixture(html` `); + const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!; + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + + await clickOnElement(trigger); // open the dropdown + await aTimeout(200); // wait for the dropdown to open + input.focus(); // focus the input + await el.updateComplete; + await sendKeys({ type: 'fc0' }); // type in a color + input.blur(); // commit changes by blurring the field + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; + }); + + it('should render the correct format when selecting a swatch of a different format', async () => { + const el = await fixture(html` `); + const trigger = el.shadowRoot!.querySelector('[part~="trigger"]')!; + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + + el.swatches = ['#fff']; + await el.updateComplete; + const swatch = el.shadowRoot!.querySelector('[part~="swatch"]')!; + + await clickOnElement(trigger); // open the dropdown + await aTimeout(200); // wait for the dropdown to open + await clickOnElement(swatch); // click on the swatch + await el.updateComplete; + + expect(el.value).to.equal('rgb(255, 255, 255)'); + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; + }); }); it('should render in a dropdown', async () => { diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index d854ac64..0a163a77 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -3,7 +3,6 @@ import { html } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { live } from 'lit/directives/live.js'; import { styleMap } from 'lit/directives/style-map.js'; import { defaultValue } from '../../internal/default-value'; import { drag } from '../../internal/drag'; @@ -51,6 +50,7 @@ declare const EyeDropper: EyeDropperConstructor; * @slot label - The color picker's form label. Alternatively, you can use the `label` attribute. * * @event sl-change Emitted when the color picker's value changes. + * @event sl-input Emitted when the color picker receives input. * * @csspart base - The component's base wrapper. * @csspart trigger - The color picker's dropdown trigger. @@ -270,12 +270,16 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo const formats = ['hex', 'rgb', 'hsl']; const nextIndex = (formats.indexOf(this.format) + 1) % formats.length; this.format = formats[nextIndex] as 'hex' | 'rgb' | 'hsl'; + this.setColor(this.value); + this.emit('sl-change'); + this.emit('sl-input'); } handleAlphaDrag(event: PointerEvent) { const container = this.shadowRoot!.querySelector('.color-picker__slider.color-picker__alpha')!; const handle = container.querySelector('.color-picker__slider-handle')!; const { width } = container.getBoundingClientRect(); + let oldValue = this.value; handle.focus(); event.preventDefault(); @@ -284,6 +288,12 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo onMove: x => { this.alpha = clamp((x / width) * 100, 0, 100); this.syncValues(); + + if (this.value !== oldValue) { + oldValue = this.value; + this.emit('sl-change'); + this.emit('sl-input'); + } }, initialEvent: event }); @@ -293,6 +303,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo const container = this.shadowRoot!.querySelector('.color-picker__slider.color-picker__hue')!; const handle = container.querySelector('.color-picker__slider-handle')!; const { width } = container.getBoundingClientRect(); + let oldValue = this.value; handle.focus(); event.preventDefault(); @@ -301,6 +312,12 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo onMove: x => { this.hue = clamp((x / width) * 360, 0, 360); this.syncValues(); + + if (this.value !== oldValue) { + oldValue = this.value; + this.emit('sl-change'); + this.emit('sl-input'); + } }, initialEvent: event }); @@ -310,6 +327,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo const grid = this.shadowRoot!.querySelector('.color-picker__grid')!; const handle = grid.querySelector('.color-picker__grid-handle')!; const { width, height } = grid.getBoundingClientRect(); + let oldValue = this.value; handle.focus(); event.preventDefault(); @@ -322,6 +340,12 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo this.brightness = clamp(100 - (y / height) * 100, 0, 100); this.lightness = this.getLightness(this.brightness); this.syncValues(); + + if (this.value !== oldValue) { + oldValue = this.value; + this.emit('sl-change'); + this.emit('sl-input'); + } }, onStop: () => (this.isDraggingGridHandle = false), initialEvent: event @@ -330,6 +354,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo handleAlphaKeyDown(event: KeyboardEvent) { const increment = event.shiftKey ? 10 : 1; + const oldValue = this.value; if (event.key === 'ArrowLeft') { event.preventDefault(); @@ -354,10 +379,16 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo this.alpha = 100; this.syncValues(); } + + if (this.value !== oldValue) { + this.emit('sl-change'); + this.emit('sl-input'); + } } handleHueKeyDown(event: KeyboardEvent) { const increment = event.shiftKey ? 10 : 1; + const oldValue = this.value; if (event.key === 'ArrowLeft') { event.preventDefault(); @@ -382,10 +413,16 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo this.hue = 360; this.syncValues(); } + + if (this.value !== oldValue) { + this.emit('sl-change'); + this.emit('sl-input'); + } } handleGridKeyDown(event: KeyboardEvent) { const increment = event.shiftKey ? 10 : 1; + const oldValue = this.value; if (event.key === 'ArrowLeft') { event.preventDefault(); @@ -414,10 +451,19 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo this.lightness = this.getLightness(this.brightness); this.syncValues(); } + + if (this.value !== oldValue) { + this.emit('sl-change'); + this.emit('sl-input'); + } } handleInputChange(event: CustomEvent) { const target = event.target as HTMLInputElement; + const oldValue = this.value; + + // Prevent the 's sl-change event from bubbling up + event.stopPropagation(); if (this.input.value) { this.setColor(target.value); @@ -426,14 +472,30 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo this.value = ''; } + if (this.value !== oldValue) { + this.emit('sl-change'); + this.emit('sl-input'); + } + } + + handleInputInput(event: CustomEvent) { + // Prevent the 's sl-input event from bubbling up event.stopPropagation(); } handleInputKeyDown(event: KeyboardEvent) { if (event.key === 'Enter') { + const oldValue = this.value; + if (this.input.value) { this.setColor(this.input.value); this.input.value = this.value; + + if (this.value !== oldValue) { + this.emit('sl-change'); + this.emit('sl-input'); + } + setTimeout(() => this.input.select()); } else { this.hue = 0; @@ -441,6 +503,10 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo } } + handleTouchMove(event: TouchEvent) { + event.preventDefault(); + } + normalizeColorString(colorString: string) { // // The color module we're using doesn't parse % values for the alpha channel in RGBA and HSLA. It also doesn't parse @@ -634,6 +700,19 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo }); } + selectSwatch(color: string) { + const oldValue = this.value; + + if (!this.disabled) { + this.setColor(color); + + if (this.value !== oldValue) { + this.emit('sl-change'); + this.emit('sl-input'); + } + } + } + @watch('format', { waitUntilFirstUpdate: true }) handleFormatChange() { this.syncValues(); @@ -671,7 +750,6 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo } if (this.value !== this.lastValueEmitted) { - this.emit('sl-change'); this.lastValueEmitted = this.value; } } @@ -704,8 +782,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo part="grid" class="color-picker__grid" style=${styleMap({ backgroundColor: `hsl(${this.hue}deg, 100%, 50%)` })} - @mousedown=${this.handleGridDrag} - @touchstart=${this.handleGridDrag} + @pointerdown=${this.handleGridDrag} + @touchmove=${this.handleTouchMove} >
@@ -870,7 +949,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo tabindex=${ifDefined(this.disabled ? undefined : '0')} role="button" aria-label=${swatch} - @click=${() => !this.disabled && this.setColor(swatch)} + @click=${() => this.selectSwatch(swatch)} @keydown=${(event: KeyboardEvent) => !this.disabled && event.key === 'Enter' && this.setColor(swatch)} > diff --git a/src/components/input/input.test.ts b/src/components/input/input.test.ts index 39337f20..475a8461 100644 --- a/src/components/input/input.test.ts +++ b/src/components/input/input.test.ts @@ -234,6 +234,46 @@ describe('', () => { }); }); + describe('when the value changes', () => { + it('should emit sl-change and sl-input when the user types in the input', async () => { + const el = await fixture(html` `); + const inputHandler = sinon.spy(); + const changeHandler = sinon.spy(); + + el.addEventListener('sl-input', inputHandler); + el.addEventListener('sl-change', changeHandler); + el.focus(); + await sendKeys({ type: 'abc' }); + el.blur(); + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledThrice; + }); + + it('should not emit sl-change or sl-input when the value is set programmatically', async () => { + const el = await fixture(html` `); + + el.addEventListener('sl-change', () => expect.fail('sl-change should not be emitted')); + el.addEventListener('sl-input', () => expect.fail('sl-input should not be emitted')); + el.value = 'abc'; + + await el.updateComplete; + }); + + it('should not emit sl-change or sl-input when calling setRangeText()', async () => { + const el = await fixture(html` `); + + el.addEventListener('sl-change', () => expect.fail('sl-change should not be emitted')); + el.addEventListener('sl-input', () => expect.fail('sl-input should not be emitted')); + el.focus(); + el.setSelectionRange(0, 2); + el.setRangeText('hello'); + + await el.updateComplete; + }); + }); + describe('when type="number"', () => { it('should be valid when the value is within the boundary of a step', async () => { const el = await fixture(html` `); @@ -254,7 +294,7 @@ describe('', () => { expect(el.invalid).to.be.true; }); - it('should increment by step if stepUp() is called', async () => { + it('should increment by step when stepUp() is called', async () => { const el = await fixture(html` `); el.stepUp(); @@ -262,7 +302,7 @@ describe('', () => { expect(el.value).to.equal('4'); }); - it('should decrement by step if stepDown() is called', async () => { + it('should decrement by step when stepDown() is called', async () => { const el = await fixture(html` `); el.stepDown(); @@ -270,35 +310,24 @@ describe('', () => { expect(el.value).to.equal('0'); }); - it('should fire sl-input and sl-change if stepUp() is called', async () => { + it('should not emit sl-input or sl-change when stepUp() is called programmatically', async () => { const el = await fixture(html` `); - const inputHandler = sinon.spy(); - const changeHandler = sinon.spy(); - el.addEventListener('sl-input', inputHandler); - el.addEventListener('sl-change', changeHandler); - + el.addEventListener('sl-change', () => expect.fail('sl-change should not be emitted')); + el.addEventListener('sl-input', () => expect.fail('sl-input should not be emitted')); el.stepUp(); - await waitUntil(() => inputHandler.calledOnce); - await waitUntil(() => changeHandler.calledOnce); - expect(inputHandler).to.have.been.calledOnce; - expect(changeHandler).to.have.been.calledOnce; + await el.updateComplete; }); - it('should fire sl-input and sl-change if stepDown() is called', async () => { + it('should not emit sl-input and sl-change when stepDown() is called programmatically', async () => { const el = await fixture(html` `); - const inputHandler = sinon.spy(); - const changeHandler = sinon.spy(); - el.addEventListener('sl-input', inputHandler); - el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-change', () => expect.fail('sl-change should not be emitted')); + el.addEventListener('sl-input', () => expect.fail('sl-input should not be emitted')); + el.stepDown(); - el.stepUp(); - - await waitUntil(() => inputHandler.calledOnce); - await waitUntil(() => changeHandler.calledOnce); - expect(changeHandler).to.have.been.calledOnce; + await el.updateComplete; }); }); }); diff --git a/src/components/input/input.ts b/src/components/input/input.ts index bb413c0f..2680292a 100644 --- a/src/components/input/input.ts +++ b/src/components/input/input.ts @@ -41,11 +41,11 @@ const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox'); * @slot hide-password-icon - An icon to use in lieu of the default hide password icon. * @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute. * + * @event sl-blur - Emitted when the control loses focus. * @event sl-change - Emitted when an alteration to the control's value is committed by the user. * @event sl-clear - Emitted when the clear button is activated. - * @event sl-input - Emitted when the control receives input and its value changes. * @event sl-focus - Emitted when the control gains focus. - * @event sl-blur - Emitted when the control loses focus. + * @event sl-input - Emitted when the control receives input. * * @csspart form-control - The form control that wraps the label, input, and help text. * @csspart form-control-label - The label's wrapper. @@ -241,16 +241,15 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont /** Replaces a range of text with a new string. */ setRangeText( replacement: string, - start: number, - end: number, - selectMode: 'select' | 'start' | 'end' | 'preserve' = 'preserve' + start?: number, + end?: number, + selectMode?: 'select' | 'start' | 'end' | 'preserve' ) { + // @ts-expect-error - start, end, and selectMode are optional this.input.setRangeText(replacement, start, end, selectMode); if (this.value !== this.input.value) { this.value = this.input.value; - this.emit('sl-input'); - this.emit('sl-change'); } } @@ -266,8 +265,6 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont this.input.stepUp(); if (this.value !== this.input.value) { this.value = this.input.value; - this.emit('sl-input'); - this.emit('sl-change'); } } @@ -276,8 +273,6 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont this.input.stepDown(); if (this.value !== this.input.value) { this.value = this.input.value; - this.emit('sl-input'); - this.emit('sl-change'); } } diff --git a/src/components/radio-group/radio-group.test.ts b/src/components/radio-group/radio-group.test.ts index 5508f1ca..af2d11ad 100644 --- a/src/components/radio-group/radio-group.test.ts +++ b/src/components/radio-group/radio-group.test.ts @@ -162,24 +162,30 @@ describe('when submitting a form', () => { }); }); -describe('when emitting "sl-change" event', () => { - it('should fire sl-change when toggled via keyboard - arrow key', async () => { +describe('when the value changes', () => { + it('should emit sl-change when toggled with the arrow keys', async () => { const radioGroup = await fixture(html` `); - const radio1 = radioGroup.querySelector('#radio-1')!; + const firstRadio = radioGroup.querySelector('#radio-1')!; + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); - radio1.focus(); - setTimeout(() => sendKeys({ press: 'ArrowRight' })); - await oneEvent(radioGroup, 'sl-change'); + radioGroup.addEventListener('sl-change', changeHandler); + radioGroup.addEventListener('sl-input', inputHandler); + firstRadio.focus(); + await sendKeys({ press: 'ArrowRight' }); + await radioGroup.updateComplete; + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; expect(radioGroup.value).to.equal('2'); }); - it('should fire sl-change when clicked', async () => { + it('should emit sl-change and sl-input when clicked', async () => { const radioGroup = await fixture(html` @@ -193,7 +199,7 @@ describe('when emitting "sl-change" event', () => { expect(radioGroup.value).to.equal('1'); }); - it('should fire sl-change when toggled via keyboard - space', async () => { + it('should emit sl-change and sl-input when toggled with spacebar', async () => { const radioGroup = await fixture(html` @@ -207,4 +213,18 @@ describe('when emitting "sl-change" event', () => { expect(event.target).to.equal(radioGroup); expect(radioGroup.value).to.equal('1'); }); + + it('should not emit sl-change or sl-input when the value is changed programmatically', async () => { + const radioGroup = await fixture(html` + + + + + `); + + radioGroup.addEventListener('sl-change', () => expect.fail('sl-change should not be emitted')); + radioGroup.addEventListener('sl-input', () => expect.fail('sl-input should not be emitted')); + radioGroup.value = '2'; + await radioGroup.updateComplete; + }); }); diff --git a/src/components/radio-group/radio-group.ts b/src/components/radio-group/radio-group.ts index 1a34d7c3..ff08af1a 100644 --- a/src/components/radio-group/radio-group.ts +++ b/src/components/radio-group/radio-group.ts @@ -25,6 +25,7 @@ import type { CSSResultGroup } from 'lit'; * attribute. * * @event sl-change - Emitted when the radio group's selected value changes. + * @event sl-input - Emitted when the radio group receives user input. * * @csspart form-control - The form control that wraps the label, input, and help text. * @csspart form-control-label - The label's wrapper. @@ -72,7 +73,6 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor @watch('value') handleValueChange() { if (this.hasUpdated) { - this.emit('sl-change'); this.updateCheckedRadio(); } } @@ -143,14 +143,20 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor handleRadioClick(event: MouseEvent) { const target = event.target as SlRadio | SlRadioButton; + const radios = this.getAllRadios(); + const oldValue = this.value; if (target.disabled) { return; } this.value = target.value; - const radios = this.getAllRadios(); radios.forEach(radio => (radio.checked = radio === target)); + + if (this.value !== oldValue) { + this.emit('sl-change'); + this.emit('sl-input'); + } } handleKeyDown(event: KeyboardEvent) { @@ -161,10 +167,13 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor const radios = this.getAllRadios().filter(radio => !radio.disabled); const checkedRadio = radios.find(radio => radio.checked) ?? radios[0]; const incr = event.key === ' ' ? 0 : ['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1; + const oldValue = this.value; let index = radios.indexOf(checkedRadio) + incr; + if (index < 0) { index = radios.length - 1; } + if (index > radios.length - 1) { index = 0; } @@ -187,6 +196,11 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor radios[index].shadowRoot!.querySelector('button')!.focus(); } + if (this.value !== oldValue) { + this.emit('sl-change'); + this.emit('sl-input'); + } + event.preventDefault(); } diff --git a/src/components/range/range.test.ts b/src/components/range/range.test.ts index 731e6cd1..f459c6cd 100644 --- a/src/components/range/range.test.ts +++ b/src/components/range/range.test.ts @@ -1,5 +1,7 @@ -import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; +import { expect, fixture, html, oneEvent } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; import sinon from 'sinon'; +import { clickOnElement } from '../../internal/test'; import { serialize } from '../../utilities/form'; import type SlRange from './range'; @@ -40,8 +42,85 @@ describe('', () => { expect(input.disabled).to.be.true; }); + describe('when the value changes', () => { + it('should emit sl-change and sl-input when the value changes from clicking the slider', async () => { + const el = await fixture(html` `); + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + await clickOnElement(el, 'right'); + await el.updateComplete; + + expect(el.value).to.equal(100); + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; + }); + + it('should emit sl-change and sl-input and decrease the value when pressing left arrow', async () => { + const el = await fixture(html` `); + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + el.focus(); + await sendKeys({ press: 'ArrowLeft' }); + await el.updateComplete; + + expect(el.value).to.equal(49); + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; + }); + + it('should emit sl-change and sl-input and decrease the value when pressing right arrow', async () => { + const el = await fixture(html` `); + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + el.focus(); + await sendKeys({ press: 'ArrowRight' }); + await el.updateComplete; + + expect(el.value).to.equal(51); + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; + }); + + it('should not emit sl-change or sl-input when changing the value programmatically', async () => { + const el = await fixture(html` `); + + el.addEventListener('sl-change', () => expect.fail('sl-change should not be emitted')); + el.addEventListener('sl-input', () => expect.fail('sl-input should not be emitted')); + el.value = 50; + + await el.updateComplete; + }); + + it('should not emit sl-change or sl-input when stepUp() is called programmatically', async () => { + const el = await fixture(html` `); + + el.addEventListener('sl-change', () => expect.fail('sl-change should not be emitted')); + el.addEventListener('sl-input', () => expect.fail('sl-input should not be emitted')); + el.stepUp(); + await el.updateComplete; + }); + + it('should not emit sl-change or sl-input when stepDown() is called programmatically', async () => { + const el = await fixture(html` `); + + el.addEventListener('sl-change', () => expect.fail('sl-change should not be emitted')); + el.addEventListener('sl-input', () => expect.fail('sl-input should not be emitted')); + el.stepDown(); + await el.updateComplete; + }); + }); + describe('step', () => { - it('should increment by step if stepUp() is called', async () => { + it('should increment by step when stepUp() is called', async () => { const el = await fixture(html` `); el.stepUp(); @@ -49,33 +128,13 @@ describe('', () => { expect(el.value).to.equal(4); }); - it('should decrement by step if stepDown() is called', async () => { + it('should decrement by step when stepDown() is called', async () => { const el = await fixture(html` `); el.stepDown(); await el.updateComplete; expect(el.value).to.equal(0); }); - - it('should fire sl-change if stepUp() is called', async () => { - const el = await fixture(html` `); - - const changeHandler = sinon.spy(); - el.addEventListener('sl-change', changeHandler); - el.stepUp(); - await waitUntil(() => changeHandler.calledOnce); - expect(changeHandler).to.have.been.calledOnce; - }); - - it('should fire sl-change if stepDown() is called', async () => { - const el = await fixture(html` `); - - const changeHandler = sinon.spy(); - el.addEventListener('sl-change', changeHandler); - el.stepUp(); - await waitUntil(() => changeHandler.calledOnce); - expect(changeHandler).to.have.been.calledOnce; - }); }); describe('when serializing', () => { diff --git a/src/components/range/range.ts b/src/components/range/range.ts index a090d26d..2884e1e0 100644 --- a/src/components/range/range.ts +++ b/src/components/range/range.ts @@ -22,9 +22,10 @@ import type { CSSResultGroup } from 'lit'; * @slot label - The range's label. Alternatively, you can use the `label` attribute. * @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute. * - * @event sl-change - Emitted when the control's value changes. * @event sl-blur - Emitted when the control loses focus. + * @event sl-change - Emitted when an alteration to the control's value is committed by the user. * @event sl-focus - Emitted when the control gains focus. + * @event sl-input - Emitted when the control receives input. * * @csspart form-control - The form control that wraps the label, input, and help text. * @csspart form-control-label - The label's wrapper. @@ -132,7 +133,6 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont this.input.stepUp(); if (this.value !== Number(this.input.value)) { this.value = Number(this.input.value); - this.emit('sl-change'); } } @@ -141,7 +141,6 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont this.input.stepDown(); if (this.value !== Number(this.input.value)) { this.value = Number(this.input.value); - this.emit('sl-change'); } } @@ -161,10 +160,13 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont this.invalid = !this.input.checkValidity(); } + handleChange() { + this.emit('sl-change'); + } + handleInput() { this.value = parseFloat(this.input.value); - this.emit('sl-change'); - + this.emit('sl-input'); this.syncRange(); } @@ -299,6 +301,7 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont step=${ifDefined(this.step)} .value=${live(this.value.toString())} aria-describedby="help-text" + @change=${this.handleChange} @input=${this.handleInput} @focus=${this.handleFocus} @blur=${this.handleBlur} diff --git a/src/components/rating/rating.test.ts b/src/components/rating/rating.test.ts index abdc0af4..269eed94 100644 --- a/src/components/rating/rating.test.ts +++ b/src/components/rating/rating.test.ts @@ -1,4 +1,7 @@ -import { expect, fixture, html } from '@open-wc/testing'; +import { expect, fixture, html, oneEvent } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; +import sinon from 'sinon'; +import { clickOnElement, tapOnElement } from '../../internal/test'; import type SlRating from './rating'; describe('', () => { @@ -48,6 +51,41 @@ describe('', () => { expect(base.getAttribute('aria-valuenow')).to.equal('3'); }); + it('should emit sl-change when clicked', async () => { + const el = await fixture(html` `); + const lastSymbol = el.shadowRoot!.querySelector('.rating__symbol:last-child')!; + const changeHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + + await clickOnElement(lastSymbol); + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(el.value).to.equal(5); + }); + + it('should emit sl-change when the value is changed with the keyboard', async () => { + const el = await fixture(html` `); + const changeHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.focus(); + await el.updateComplete; + await sendKeys({ press: 'ArrowRight' }); + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(el.value).to.equal(1); + }); + + it('should not emit sl-change when the value is changed programmatically', async () => { + const el = await fixture(html` `); + el.addEventListener('sl-change', () => expect.fail('sl-change incorrectly emitted')); + el.value = 5; + await el.updateComplete; + }); + describe('focus', () => { it('should focus inner div', async () => { const el = await fixture(html` `); diff --git a/src/components/rating/rating.ts b/src/components/rating/rating.ts index 82b131ac..e9bcd8e6 100644 --- a/src/components/rating/rating.ts +++ b/src/components/rating/rating.ts @@ -5,7 +5,6 @@ import { styleMap } from 'lit/directives/style-map.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { clamp } from '../../internal/math'; import ShoelaceElement from '../../internal/shoelace-element'; -import { watch } from '../../internal/watch'; import { LocalizeController } from '../../utilities/localize'; import '../icon/icon'; import styles from './rating.styles'; @@ -97,6 +96,7 @@ export default class SlRating extends ShoelaceElement { handleClick(event: MouseEvent) { this.setValue(this.getValueFromMousePosition(event)); + this.emit('sl-change'); } setValue(newValue: number) { @@ -111,6 +111,7 @@ export default class SlRating extends ShoelaceElement { handleKeyDown(event: KeyboardEvent) { const isLtr = this.localize.dir() === 'ltr'; const isRtl = this.localize.dir() === 'rtl'; + const oldValue = this.value; if (this.disabled || this.readonly) { return; @@ -137,6 +138,10 @@ export default class SlRating extends ShoelaceElement { this.value = this.max; event.preventDefault(); } + + if (this.value !== oldValue) { + this.emit('sl-change'); + } } handleMouseEnter() { @@ -166,16 +171,12 @@ export default class SlRating extends ShoelaceElement { handleTouchEnd(event: TouchEvent) { this.isHovering = false; this.setValue(this.hoverValue); + this.emit('sl-change'); // Prevent click on mobile devices event.preventDefault(); } - @watch('value', { waitUntilFirstUpdate: true }) - handleValueChange() { - this.emit('sl-change'); - } - roundToPrecision(numberToRound: number, precision = 0.5) { const multiplier = 1 / precision; return Math.ceil(numberToRound * multiplier) / multiplier; diff --git a/src/components/select/select.test.ts b/src/components/select/select.test.ts index 778d7abe..47439f55 100644 --- a/src/components/select/select.test.ts +++ b/src/components/select/select.test.ts @@ -1,6 +1,8 @@ -import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; +import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; import { sendKeys } from '@web/test-runner-commands'; import sinon from 'sinon'; +import { clickOnElement } from '../../internal/test'; +import type SlMenuItem from '../menu-item/menu-item'; import type SlSelect from './select'; describe('', () => { @@ -45,21 +47,77 @@ describe('', () => { expect(submitHandler).to.have.been.calledOnce; }); - it('should emit sl-change when the value changes', async () => { - const el = await fixture(html` - - Option 1 - Option 2 - Option 3 - - `); - const changeHandler = sinon.spy(); + describe('when the value changes', () => { + it('should emit sl-change when the value is changed with the mouse', async () => { + const el = await fixture(html` + + Option 1 + Option 2 + Option 3 + + `); + const trigger = el.shadowRoot!.querySelector('[part~="control"]')!; + const secondOption = el.querySelectorAll('sl-menu-item')[1]; + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); - el.addEventListener('sl-change', changeHandler); - el.value = 'option-2'; - await waitUntil(() => changeHandler.calledOnce); + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); - expect(changeHandler).to.have.been.calledOnce; + await clickOnElement(trigger); + await el.updateComplete; + await clickOnElement(secondOption); + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; + expect(el.value).to.equal('option-2'); + }); + + it('should emit sl-change and sl-input when the value is changed with the keyboard', async () => { + const el = await fixture(html` + + Option 1 + Option 2 + Option 3 + + `); + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + + el.focus(); + await sendKeys({ press: ' ' }); // open the dropdown + await aTimeout(200); // wait for the dropdown to open + await sendKeys({ press: 'ArrowDown' }); // select the first option + await el.updateComplete; + await sendKeys({ press: 'ArrowDown' }); // select the second option + await el.updateComplete; + await sendKeys({ press: 'Enter' }); // commit the selection + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; + expect(el.value).to.equal('option-2'); + }); + + it('should not emit sl-change or sl-input when the value is changed programmatically', async () => { + const el = await fixture(html` + + Option 1 + Option 2 + Option 3 + + `); + + el.addEventListener('sl-change', () => expect.fail('sl-change should not be emitted')); + el.addEventListener('sl-input', () => expect.fail('sl-input should not be emitted')); + el.value = 'option-2'; + + await el.updateComplete; + }); }); it('should open the menu when any letter key is pressed with sl-select is on focus', async () => { diff --git a/src/components/select/select.ts b/src/components/select/select.ts index b762f50f..cbf33dc6 100644 --- a/src/components/select/select.ts +++ b/src/components/select/select.ts @@ -206,9 +206,17 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon } handleClearClick(event: MouseEvent) { + const oldValue = this.value; + event.stopPropagation(); this.value = this.multiple ? [] : ''; - this.emit('sl-clear'); + + if (this.value !== oldValue) { + this.emit('sl-clear'); + this.emit('sl-input'); + this.emit('sl-change'); + } + this.syncItemsFromValue(); } @@ -291,6 +299,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon handleMenuSelect(event: CustomEvent) { const item = event.detail.item; + const oldValue = this.value; if (this.multiple) { this.value = this.value.includes(item.value) @@ -300,6 +309,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon this.value = item.value; } + if (this.value !== oldValue) { + this.emit('sl-change'); + this.emit('sl-input'); + } + this.syncItemsFromValue(); } @@ -371,9 +385,6 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon await this.updateComplete; this.invalid = !this.input.checkValidity(); - - this.emit('sl-change'); - this.emit('sl-input'); } resizeMenu() { @@ -450,12 +461,18 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon syncValueFromItems() { const checkedItems = this.menuItems.filter(item => item.checked); const checkedValues = checkedItems.map(item => item.value); + const oldValue = this.value; if (this.multiple) { this.value = (this.value as []).filter(val => checkedValues.includes(val)); } else { this.value = checkedValues.length > 0 ? checkedValues[0] : ''; } + + if (this.value !== oldValue) { + this.emit('sl-change'); + this.emit('sl-input'); + } } render() { diff --git a/src/components/switch/switch.test.ts b/src/components/switch/switch.test.ts index 6dd52b71..a876cf21 100644 --- a/src/components/switch/switch.test.ts +++ b/src/components/switch/switch.test.ts @@ -1,5 +1,6 @@ import { expect, fixture, html, oneEvent } from '@open-wc/testing'; import { sendKeys } from '@web/test-runner-commands'; +import sinon from 'sinon'; import type SlSwitch from './switch'; describe('', () => { @@ -40,44 +41,72 @@ describe('', () => { expect(el.invalid).to.be.false; }); - it('should fire sl-change when clicked', async () => { + it('should emit sl-change and sl-input when clicked', async () => { const el = await fixture(html` `); - setTimeout(() => el.shadowRoot!.querySelector('input')!.click()); - const event = (await oneEvent(el, 'sl-change')) as CustomEvent; - expect(event.target).to.equal(el); + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); + el.click(); + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; expect(el.checked).to.be.true; }); - it('should fire sl-change when toggled with spacebar', async () => { + it('should emit sl-change when toggled with spacebar', async () => { const el = await fixture(html` `); + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); el.focus(); - setTimeout(() => sendKeys({ press: ' ' })); - const event = (await oneEvent(el, 'sl-change')) as CustomEvent; - expect(event.target).to.equal(el); + await sendKeys({ press: ' ' }); + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; expect(el.checked).to.be.true; }); - it('should fire sl-change when toggled with the right arrow', async () => { + it('should emit sl-change and sl-input when toggled with the right arrow', async () => { const el = await fixture(html` `); + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); el.focus(); - setTimeout(() => sendKeys({ press: 'ArrowRight' })); - const event = (await oneEvent(el, 'sl-change')) as CustomEvent; - expect(event.target).to.equal(el); + await sendKeys({ press: 'ArrowRight' }); + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; expect(el.checked).to.be.true; }); - it('should fire sl-change when toggled with the left arrow', async () => { + it('should emit sl-change and sl-input when toggled with the left arrow', async () => { const el = await fixture(html` `); + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + el.addEventListener('sl-change', changeHandler); + el.addEventListener('sl-input', inputHandler); el.focus(); - setTimeout(() => sendKeys({ press: 'ArrowLeft' })); - const event = (await oneEvent(el, 'sl-change')) as CustomEvent; - expect(event.target).to.equal(el); + await sendKeys({ press: 'ArrowLeft' }); + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledOnce; expect(el.checked).to.be.false; }); - it('should not fire sl-change when checked is set by javascript', async () => { + it('should not emit sl-change or sl-input when checked is set by javascript', async () => { const el = await fixture(html` `); - el.addEventListener('sl-change', () => expect.fail('event fired')); + el.addEventListener('sl-change', () => expect.fail('sl-change incorrectly emitted')); + el.addEventListener('sl-input', () => expect.fail('sl-change incorrectly emitted')); el.checked = true; await el.updateComplete; el.checked = false; diff --git a/src/components/switch/switch.ts b/src/components/switch/switch.ts index 8d711325..87e20299 100644 --- a/src/components/switch/switch.ts +++ b/src/components/switch/switch.ts @@ -21,6 +21,7 @@ import type { CSSResultGroup } from 'lit'; * * @event sl-blur - Emitted when the control loses focus. * @event sl-change - Emitted when the control's checked state changes. + * @event sl-input - Emitted when the control receives input. * @event sl-focus - Emitted when the control gains focus. * * @csspart base - The component's base wrapper. @@ -107,6 +108,10 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon this.emit('sl-blur'); } + handleInput() { + this.emit('sl-input'); + } + @watch('checked', { waitUntilFirstUpdate: true }) handleCheckedChange() { this.input.checked = this.checked; // force a sync update @@ -135,12 +140,14 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon event.preventDefault(); this.checked = false; this.emit('sl-change'); + this.emit('sl-input'); } if (event.key === 'ArrowRight') { event.preventDefault(); this.checked = true; this.emit('sl-change'); + this.emit('sl-input'); } } @@ -167,6 +174,7 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon role="switch" aria-checked=${this.checked ? 'true' : 'false'} @click=${this.handleClick} + @input=${this.handleInput} @blur=${this.handleBlur} @focus=${this.handleFocus} @keydown=${this.handleKeyDown} diff --git a/src/components/textarea/textarea.test.ts b/src/components/textarea/textarea.test.ts index ec1c23c1..67dc81e0 100644 --- a/src/components/textarea/textarea.test.ts +++ b/src/components/textarea/textarea.test.ts @@ -1,4 +1,5 @@ import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; import sinon from 'sinon'; import { serialize } from '../../utilities/form'; import type SlTextarea from './textarea'; @@ -63,6 +64,46 @@ describe('', () => { expect(submitHandler).to.have.been.calledOnce; }); + describe('when the value changes', () => { + it('should emit sl-change and sl-input when the user types in the textarea', async () => { + const el = await fixture(html` `); + const inputHandler = sinon.spy(); + const changeHandler = sinon.spy(); + + el.addEventListener('sl-input', inputHandler); + el.addEventListener('sl-change', changeHandler); + el.focus(); + await sendKeys({ type: 'abc' }); + el.blur(); + await el.updateComplete; + + expect(changeHandler).to.have.been.calledOnce; + expect(inputHandler).to.have.been.calledThrice; + }); + + it('should not emit sl-change or sl-input when the value is set programmatically', async () => { + const el = await fixture(html` `); + + el.addEventListener('sl-change', () => expect.fail('sl-change should not be emitted')); + el.addEventListener('sl-input', () => expect.fail('sl-input should not be emitted')); + el.value = 'abc'; + + await el.updateComplete; + }); + + it('should not emit sl-change or sl-input when calling setRangeText()', async () => { + const el = await fixture(html` `); + + el.addEventListener('sl-change', () => expect.fail('sl-change should not be emitted')); + el.addEventListener('sl-input', () => expect.fail('sl-input should not be emitted')); + el.focus(); + el.setSelectionRange(0, 2); + el.setRangeText('hello'); + + await el.updateComplete; + }); + }); + describe('when using constraint validation', () => { it('should be valid by default', async () => { const el = await fixture(html` `); diff --git a/src/components/textarea/textarea.ts b/src/components/textarea/textarea.ts index 69713cb0..14b02c34 100644 --- a/src/components/textarea/textarea.ts +++ b/src/components/textarea/textarea.ts @@ -21,10 +21,10 @@ import type { CSSResultGroup } from 'lit'; * @slot label - The textarea's label. Alternatively, you can use the `label` attribute. * @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute. * - * @event sl-change - Emitted when an alteration to the control's value is committed by the user. - * @event sl-input - Emitted when the control receives input and its value changes. - * @event sl-focus - Emitted when the control gains focus. * @event sl-blur - Emitted when the control loses focus. + * @event sl-change - Emitted when an alteration to the control's value is committed by the user. + * @event sl-focus - Emitted when the control gains focus. + * @event sl-input - Emitted when the control receives input. * * @csspart form-control - The form control that wraps the label, input, and help text. * @csspart form-control-label - The label's wrapper. @@ -180,22 +180,20 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC /** Replaces a range of text with a new string. */ setRangeText( replacement: string, - start: number, - end: number, - selectMode: 'select' | 'start' | 'end' | 'preserve' = 'preserve' + start?: number, + end?: number, + selectMode?: 'select' | 'start' | 'end' | 'preserve' ) { + // @ts-expect-error - start, end, and selectMode are optional this.input.setRangeText(replacement, start, end, selectMode); if (this.value !== this.input.value) { this.value = this.input.value; - this.emit('sl-input'); } if (this.value !== this.input.value) { this.value = this.input.value; this.setTextareaHeight(); - this.emit('sl-input'); - this.emit('sl-change'); } } diff --git a/src/internal/drag.ts b/src/internal/drag.ts index 8e3ea64c..5a22f33d 100644 --- a/src/internal/drag.ts +++ b/src/internal/drag.ts @@ -39,7 +39,7 @@ export function drag(container: HTMLElement, options?: Partial) { document.addEventListener('pointerup', stop); // If an initial event is set, trigger the first drag immediately - if (options?.initialEvent?.type === 'pointermove') { + if (options?.initialEvent instanceof PointerEvent) { move(options.initialEvent); } } diff --git a/src/internal/test.ts b/src/internal/test.ts new file mode 100644 index 00000000..a9446fa9 --- /dev/null +++ b/src/internal/test.ts @@ -0,0 +1,46 @@ +import { sendMouse } from '@web/test-runner-commands'; + +/** A testing utility that measures an element's position and clicks on it. */ +export async function clickOnElement( + /** The element to click */ + el: HTMLElement, + /** The location of the element to click */ + position: 'top' | 'right' | 'bottom' | 'left' | 'center' = 'center', + /** The horizontal offset to apply to the position when clicking */ + offsetX = 0, + /** The vertical offset to apply to the position when clicking */ + offsetY = 0 +) { + const { x, y, width, height } = el.getBoundingClientRect(); + const centerX = Math.floor(x + window.pageXOffset + width / 2); + const centerY = Math.floor(y + window.pageYOffset + height / 2); + let clickX: number; + let clickY: number; + + switch (position) { + case 'top': + clickX = centerX; + clickY = y; + break; + case 'right': + clickX = x + width - 1; + clickY = centerY; + break; + case 'bottom': + clickX = centerX; + clickY = y + height - 1; + break; + case 'left': + clickX = x; + clickY = centerY; + break; + default: + clickX = centerX; + clickY = centerY; + } + + clickX += offsetX; + clickY += offsetY; + + await sendMouse({ type: 'click', position: [clickX, clickY] }); +}