pull/1059/head^2
Cory LaViska 2022-12-07 17:40:46 -05:00
rodzic 1c359fbea9
commit c6df057e15
22 zmienionych plików z 866 dodań i 157 usunięć

Wyświetl plik

@ -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 `<sl-checkbox>`, `<sl-color-picker>`, `<sl-radio>`, `<sl-range>`, and `<sl-switch>`
- Fixed a bug in `<sl-input>` 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 `<sl-color-picker>` that sometimes prevented the color from updating when clicking or tapping on the controls
- Fixed a bug in `<sl-color-picker>` that prevented text from being entered in the color input
## 2.0.0-beta.86
- 🚨 BREAKING: changed the default value of `date` in `<sl-relative-time>` to the current date instead of the Unix epoch

Wyświetl plik

@ -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.

Wyświetl plik

@ -52,27 +52,42 @@ describe('<sl-checkbox>', () => {
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<SlCheckbox>(html` <sl-checkbox></sl-checkbox> `);
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<SlCheckbox>(html` <sl-checkbox></sl-checkbox> `);
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<SlCheckbox>(html` <sl-checkbox></sl-checkbox> `);
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;

Wyświetl plik

@ -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}
/>

Wyświetl plik

@ -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('<sl-color-picker>', () => {
it('should emit change and show correct color when the value changes', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector<HTMLElement>('[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<SlColorPicker>(html` <sl-color-picker></sl-color-picker> `);
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<SlColorPicker>(html` <sl-color-picker></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]')!;
const grid = el.shadowRoot!.querySelector<HTMLElement>('[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<SlColorPicker>(html` <sl-color-picker></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]')!;
const slider = el.shadowRoot!.querySelector<HTMLElement>('[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<SlColorPicker>(html` <sl-color-picker opacity></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]')!;
const slider = el.shadowRoot!.querySelector<HTMLElement>('[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<SlColorPicker>(html` <sl-color-picker value="#fff"></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]')!;
const formatButton = el.shadowRoot!.querySelector<HTMLElement>('[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<SlColorPicker>(html` <sl-color-picker></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]')!;
const swatch = el.shadowRoot!.querySelector<HTMLElement>('[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<SlColorPicker>(html` <sl-color-picker></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]')!;
const gridHandle = el.shadowRoot!.querySelector<HTMLElement>('[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<SlColorPicker>(html` <sl-color-picker></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]')!;
const handle = el.shadowRoot!.querySelector<HTMLElement>('[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<SlColorPicker>(html` <sl-color-picker></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]')!;
const handle = el.shadowRoot!.querySelector<HTMLElement>('[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<SlColorPicker>(html` <sl-color-picker opacity></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]')!;
const handle = el.shadowRoot!.querySelector<HTMLElement>('[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<SlColorPicker>(html` <sl-color-picker opacity></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]')!;
const input = el.shadowRoot!.querySelector<HTMLElement>('[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<SlColorPicker>(html` <sl-color-picker opacity></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]')!;
const input = el.shadowRoot!.querySelector<HTMLElement>('[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<SlColorPicker>(html` <sl-color-picker format="rgb"></sl-color-picker> `);
const trigger = el.shadowRoot!.querySelector<HTMLButtonElement>('[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<HTMLElement>('[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 () => {

Wyświetl plik

@ -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<HTMLElement>('.color-picker__slider.color-picker__alpha')!;
const handle = container.querySelector<HTMLElement>('.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<HTMLElement>('.color-picker__slider.color-picker__hue')!;
const handle = container.querySelector<HTMLElement>('.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<HTMLElement>('.color-picker__grid')!;
const handle = grid.querySelector<HTMLElement>('.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 <sl-input>'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 <sl-input>'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}
>
<span
part="grid-handle"
@ -730,8 +808,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
<div
part="slider hue-slider"
class="color-picker__hue color-picker__slider"
@mousedown=${this.handleHueDrag}
@touchstart=${this.handleHueDrag}
@pointerdown=${this.handleHueDrag}
@touchmove=${this.handleTouchMove}
>
<span
part="slider-handle"
@ -755,8 +833,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
<div
part="slider opacity-slider"
class="color-picker__alpha color-picker__slider color-picker__transparent-bg"
@mousedown="${this.handleAlphaDrag}"
@touchstart="${this.handleAlphaDrag}"
@pointerdown="${this.handleAlphaDrag}"
@touchmove=${this.handleTouchMove}
>
<div
class="color-picker__alpha-gradient"
@ -809,11 +887,12 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
autocorrect="off"
autocapitalize="off"
spellcheck="false"
.value=${live(this.isEmpty ? '' : this.inputValue)}
value=${this.isEmpty ? '' : this.inputValue}
?disabled=${this.disabled}
aria-label=${this.localize.term('currentValue')}
@keydown=${this.handleInputKeyDown}
@sl-change=${this.handleInputChange}
@sl-input=${this.handleInputInput}
></sl-input>
<sl-button-group>
@ -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)}
>

Wyświetl plik

@ -234,6 +234,46 @@ describe('<sl-input>', () => {
});
});
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<SlInput>(html` <sl-input></sl-input> `);
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<SlInput>(html` <sl-input></sl-input> `);
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<SlInput>(html` <sl-input value="hi there"></sl-input> `);
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<SlInput>(html` <sl-input type="number" step=".5" value="1.5"></sl-input> `);
@ -254,7 +294,7 @@ describe('<sl-input>', () => {
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<SlInput>(html` <sl-input type="number" step="2" value="2"></sl-input> `);
el.stepUp();
@ -262,7 +302,7 @@ describe('<sl-input>', () => {
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<SlInput>(html` <sl-input type="number" step="2" value="2"></sl-input> `);
el.stepDown();
@ -270,35 +310,24 @@ describe('<sl-input>', () => {
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<SlInput>(html` <sl-input type="number" step="2" value="2"></sl-input> `);
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<SlInput>(html` <sl-input type="number" step="2" value="2"></sl-input> `);
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;
});
});
});

Wyświetl plik

@ -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');
}
}

Wyświetl plik

@ -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<SlRadioGroup>(html`
<sl-radio-group>
<sl-radio id="radio-1" value="1"></sl-radio>
<sl-radio id="radio-2" value="2"></sl-radio>
</sl-radio-group>
`);
const radio1 = radioGroup.querySelector<SlRadio>('#radio-1')!;
const firstRadio = radioGroup.querySelector<SlRadio>('#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<SlRadioGroup>(html`
<sl-radio-group>
<sl-radio id="radio-1" value="1"></sl-radio>
@ -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<SlRadioGroup>(html`
<sl-radio-group>
<sl-radio id="radio-1" value="1"></sl-radio>
@ -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<SlRadioGroup>(html`
<sl-radio-group value="1">
<sl-radio id="radio-1" value="1"></sl-radio>
<sl-radio id="radio-2" value="2"></sl-radio>
</sl-radio-group>
`);
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;
});
});

Wyświetl plik

@ -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();
}

Wyświetl plik

@ -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('<sl-range>', () => {
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<SlRange>(html` <sl-range value="0"></sl-range> `);
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<SlRange>(html` <sl-range value="50"></sl-range> `);
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<SlRange>(html` <sl-range value="50"></sl-range> `);
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<SlRange>(html` <sl-range value="0"></sl-range> `);
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<SlRange>(html` <sl-range step="2" value="2"></sl-range> `);
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<SlRange>(html` <sl-range step="2" value="2"></sl-range> `);
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<SlRange>(html` <sl-range step="2" value="2"></sl-range> `);
el.stepUp();
@ -49,33 +128,13 @@ describe('<sl-range>', () => {
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<SlRange>(html` <sl-range step="2" value="2"></sl-range> `);
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<SlRange>(html` <sl-range step="2" value="2"></sl-range> `);
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<SlRange>(html` <sl-range step="2" value="2"></sl-range> `);
const changeHandler = sinon.spy();
el.addEventListener('sl-change', changeHandler);
el.stepUp();
await waitUntil(() => changeHandler.calledOnce);
expect(changeHandler).to.have.been.calledOnce;
});
});
describe('when serializing', () => {

Wyświetl plik

@ -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}

Wyświetl plik

@ -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('<sl-rating>', () => {
@ -48,6 +51,41 @@ describe('<sl-rating>', () => {
expect(base.getAttribute('aria-valuenow')).to.equal('3');
});
it('should emit sl-change when clicked', async () => {
const el = await fixture<SlRating>(html` <sl-rating></sl-rating> `);
const lastSymbol = el.shadowRoot!.querySelector<HTMLSpanElement>('.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<SlRating>(html` <sl-rating></sl-rating> `);
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<SlRating>(html` <sl-rating label="Test" value="1"></sl-rating> `);
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<SlRating>(html` <sl-rating label="Test"></sl-rating> `);

Wyświetl plik

@ -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;

Wyświetl plik

@ -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('<sl-select>', () => {
@ -45,21 +47,77 @@ describe('<sl-select>', () => {
expect(submitHandler).to.have.been.calledOnce;
});
it('should emit sl-change when the value changes', async () => {
const el = await fixture<SlSelect>(html`
<sl-select>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
</sl-select>
`);
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<SlSelect>(html`
<sl-select value="option-1">
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
</sl-select>
`);
const trigger = el.shadowRoot!.querySelector<HTMLElement>('[part~="control"]')!;
const secondOption = el.querySelectorAll<SlMenuItem>('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<SlSelect>(html`
<sl-select value="option-1">
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
</sl-select>
`);
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<SlSelect>(html`
<sl-select value="option-1">
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
</sl-select>
`);
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 () => {

Wyświetl plik

@ -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<MenuSelectEventDetail>) {
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() {

Wyświetl plik

@ -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('<sl-switch>', () => {
@ -40,44 +41,72 @@ describe('<sl-switch>', () => {
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<SlSwitch>(html` <sl-switch></sl-switch> `);
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<SlSwitch>(html` <sl-switch></sl-switch> `);
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<SlSwitch>(html` <sl-switch></sl-switch> `);
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<SlSwitch>(html` <sl-switch checked></sl-switch> `);
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<SlSwitch>(html` <sl-switch></sl-switch> `);
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;

Wyświetl plik

@ -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}

Wyświetl plik

@ -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('<sl-textarea>', () => {
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<SlTextarea>(html` <sl-textarea></sl-textarea> `);
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<SlTextarea>(html` <sl-textarea></sl-textarea> `);
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<SlTextarea>(html` <sl-textarea value="hi there"></sl-textarea> `);
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<SlTextarea>(html` <sl-textarea></sl-textarea> `);

Wyświetl plik

@ -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');
}
}

Wyświetl plik

@ -39,7 +39,7 @@ export function drag(container: HTMLElement, options?: Partial<DragOptions>) {
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);
}
}

Wyświetl plik

@ -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] });
}