Merge branch 'next' into mpharoah/typescript-events

This commit is contained in:
Matt Pharoah
2023-02-14 22:59:20 -05:00
24 changed files with 675 additions and 37 deletions

View File

@@ -295,13 +295,15 @@ This example demonstrates custom validation styles using `data-user-invalid` and
required
></sl-input>
<sl-select label="Favorite Animal" help-text="Select the best option." clearable required>
<sl-select name="animal" label="Favorite Animal" help-text="Select the best option." clearable required>
<sl-option value="birds">Birds</sl-option>
<sl-option value="cats">Cats</sl-option>
<sl-option value="dogs">Dogs</sl-option>
<sl-option value="other">Other</sl-option>
</sl-select>
<sl-checkbox value="accept" required>Accept terms and conditions</sl-checkbox>
<sl-button type="submit" variant="primary">Submit</sl-button>
<sl-button type="reset" variant="default">Reset</sl-button>
</form>
@@ -316,40 +318,57 @@ This example demonstrates custom validation styles using `data-user-invalid` and
<style>
.validity-styles sl-input,
.validity-styles sl-select {
.validity-styles sl-select,
.validity-styles sl-checkbox {
display: block;
margin-bottom: var(--sl-spacing-medium);
}
/* user invalid styles */
.validity-styles sl-input[data-user-invalid]::part(base),
.validity-styles sl-select[data-user-invalid]::part(combobox) {
.validity-styles sl-select[data-user-invalid]::part(combobox),
.validity-styles sl-checkbox[data-user-invalid]::part(control) {
border-color: var(--sl-color-danger-600);
}
.validity-styles [data-user-invalid]::part(form-control-label),
.validity-styles [data-user-invalid]::part(form-control-help-text) {
.validity-styles [data-user-invalid]::part(form-control-help-text),
.validity-styles sl-checkbox[data-user-invalid]::part(label) {
color: var(--sl-color-danger-700);
}
.validity-styles sl-checkbox[data-user-invalid]::part(control) {
outline: none;
}
.validity-styles sl-input:focus-within[data-user-invalid]::part(base),
.validity-styles sl-select:focus-within[data-user-invalid]::part(combobox) {
.validity-styles sl-select:focus-within[data-user-invalid]::part(combobox),
.validity-styles sl-checkbox:focus-within[data-user-invalid]::part(control) {
border-color: var(--sl-color-danger-600);
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-300);
}
/* User valid styles */
.validity-styles sl-input[data-user-valid]::part(base),
.validity-styles sl-select[data-user-valid]::part(combobox) {
.validity-styles sl-select[data-user-valid]::part(combobox),
.validity-styles sl-checkbox[data-user-valid]::part(control) {
border-color: var(--sl-color-success-600);
}
.validity-styles [data-user-valid]::part(form-control-label),
.validity-styles [data-user-valid]::part(form-control-help-text) {
.validity-styles [data-user-valid]::part(form-control-help-text),
.validity-styles sl-checkbox[data-user-valid]::part(label) {
color: var(--sl-color-success-700);
}
.validity-styles sl-checkbox[data-user-valid]::part(control) {
background-color: var(--sl-color-success-600);
outline: none;
}
.validity-styles sl-input:focus-within[data-user-valid]::part(base),
.validity-styles sl-select:focus-within[data-user-valid]::part(combobox) {
.validity-styles sl-select:focus-within[data-user-valid]::part(combobox),
.validity-styles sl-checkbox:focus-within[data-user-valid]::part(control) {
border-color: var(--sl-color-success-600);
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-success-300);
}

View File

@@ -10,6 +10,8 @@ New versions of Shoelace are released as-needed and generally occur when a criti
- Added the `sl-focus` and `sl-blur` events to `<sl-color-picker>`
- Added the `focus()` and `blur()` methods to `<sl-color-picker>`
- Added the `sl-invalid` event to all form controls to enable custom validation logic [#1167](https://github.com/shoelace-style/shoelace/pull/1167)
- Added `validity` and `validationMessage` properties to all form controls [#1167](https://github.com/shoelace-style/shoelace/pull/1167)
- Fixed a bug in `<sl-animated-image>` where the play and pause buttons were transposed [#1147](https://github.com/shoelace-style/shoelace/issues/1147)
- Fixed a bug that prevented `web-types.json` from being generated [#1154](https://github.com/shoelace-style/shoelace/discussions/1154)
- Fixed a bug in `<sl-color-picker>` that prevented `sl-change` and `sl-input` from emitting when using the eye dropper [#1157](https://github.com/shoelace-style/shoelace/issues/1157)

View File

@@ -1,4 +1,5 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import sinon from 'sinon';
import type SlButton from './button';
@@ -234,4 +235,31 @@ describe('<sl-button>', () => {
expect(clickHandler).to.have.been.calledOnce;
});
});
runFormControlBaseTests({
tagName: 'sl-button',
variantName: 'type="button"',
init: (control: SlButton) => {
control.type = 'button';
}
});
runFormControlBaseTests({
tagName: 'sl-button',
variantName: 'type="submit"',
init: (control: SlButton) => {
control.type = 'submit';
}
});
runFormControlBaseTests({
tagName: 'sl-button',
variantName: 'href="xyz"',
init: (control: SlButton) => {
control.href = 'some-url';
}
});
});

View File

@@ -2,7 +2,7 @@ import '../icon/icon';
import '../spinner/spinner';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { FormControlController } from '../../internal/form';
import { FormControlController, validValidityState } from '../../internal/form';
import { HasSlotController } from '../../internal/slot';
import { html, literal } from 'lit/static-html.js';
import { ifDefined } from 'lit/directives/if-defined.js';
@@ -24,6 +24,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
*
* @event sl-blur - Emitted when the button loses focus.
* @event sl-focus - Emitted when the button gains focus.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @slot - The button's label.
* @slot prefix - A presentational prefix icon or similar element.
@@ -140,6 +141,24 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
/** Used to override the form owner's `target` attribute. */
@property({ attribute: 'formtarget' }) formTarget: '_self' | '_blank' | '_parent' | '_top' | string;
/** Gets the validity state object */
get validity() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).validity;
}
return validValidityState;
}
/** Gets the validation message */
get validationMessage() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).validationMessage;
}
return '';
}
connectedCallback() {
super.connectedCallback();
this.handleHostClick = this.handleHostClick.bind(this);
@@ -185,6 +204,11 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
}
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private isButton() {
return this.href ? false : true;
}
@@ -216,7 +240,7 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
this.button.blur();
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).checkValidity();
@@ -290,6 +314,7 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
tabindex=${this.disabled ? '-1' : '0'}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@invalid=${this.isButton() ? this.handleInvalid : null}
@click=${this.handleClick}
>
<slot name="prefix" part="prefix" class="button__prefix"></slot>

View File

@@ -1,5 +1,6 @@
import { clickOnElement } from '../../internal/test';
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlCheckbox from './checkbox';
@@ -308,5 +309,7 @@ describe('<sl-checkbox>', () => {
expect(indeterminateIcon).to.be.null;
});
runFormControlBaseTests('sl-checkbox');
});
});

View File

@@ -26,6 +26,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
* @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.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart base - The component's base wrapper.
* @csspart control - The square container that wraps the checkbox's checked state.
@@ -85,6 +86,16 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
/** Makes the checkbox a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
firstUpdated() {
this.formControlController.updateValidity();
}
@@ -104,6 +115,11 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
this.emit('sl-input');
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
@@ -137,12 +153,12 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
this.input.blur();
}
/** Checks for validity but does not show a validation message. Returns true when valid and false when invalid. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows a validation message if the control is invalid. */
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.input.reportValidity();
}
@@ -189,6 +205,7 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
aria-checked=${this.checked ? 'true' : 'false'}
@click=${this.handleClick}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
/>

View File

@@ -1,5 +1,6 @@
import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import { serialize } from '../../utilities/form';
import sinon from 'sinon';
@@ -545,4 +546,6 @@ describe('<sl-color-picker>', () => {
// expect(el.hasAttribute('data-user-valid')).to.be.false;
});
});
runFormControlBaseTests('sl-color-picker');
});

View File

@@ -51,10 +51,11 @@ declare const EyeDropper: EyeDropperConstructor;
*
* @slot label - The color picker's form label. Alternatively, you can use the `label` attribute.
*
* @event sl-blur Emitted when the color picker loses focus.
* @event sl-change Emitted when the color picker's value changes.
* @event sl-focus Emitted when the color picker receives focus.
* @event sl-input Emitted when the color picker receives input.
* @event sl-blur - Emitted when the color picker loses focus.
* @event sl-change - Emitted when the color picker's value changes.
* @event sl-focus - Emitted when the color picker receives focus.
* @event sl-input - Emitted when the color picker receives input.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart base - The component's base wrapper.
* @csspart trigger - The color picker's dropdown trigger.
@@ -176,6 +177,19 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
*/
@property({ reflect: true }) form = '';
/** Makes the color picker a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
connectedCallback() {
super.connectedCallback();
this.handleFocusIn = this.handleFocusIn.bind(this);
@@ -190,6 +204,12 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
this.removeEventListener('focusout', this.handleFocusOut);
}
firstUpdated() {
this.input.updateComplete.then(() => {
this.formControlController.updateValidity();
});
}
private handleCopy() {
this.input.select();
document.execCommand('copy');
@@ -446,6 +466,11 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
private handleInputInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private handleTouchMove(event: TouchEvent) {
event.preventDefault();
}
@@ -734,18 +759,24 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
if (!this.inline && !this.checkValidity()) {
if (!this.inline && !this.validity.valid) {
// If the input is inline and invalid, show the dropdown so the browser can focus on it
this.dropdown.show();
this.addEventListener('sl-after-show', () => this.input.reportValidity(), { once: true });
return this.checkValidity();
if (!this.disabled) {
// By standards we have to emit a `sl-invalid` event here synchronously.
this.formControlController.emitInvalidEvent();
}
return false;
}
return this.input.reportValidity();
@@ -895,11 +926,13 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
autocapitalize="off"
spellcheck="false"
value=${this.isEmpty ? '' : this.inputValue}
?required=${this.required}
?disabled=${this.disabled}
aria-label=${this.localize.term('currentValue')}
@keydown=${this.handleInputKeyDown}
@sl-change=${this.handleInputChange}
@sl-input=${this.handleInputInput}
@sl-invalid=${this.handleInputInvalid}
@sl-blur=${this.stopNestedEventPropagation}
@sl-focus=${this.stopNestedEventPropagation}
></sl-input>

View File

@@ -1,8 +1,9 @@
// eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { getFormControls } from '../../../dist/utilities/form.js';
import { sendKeys } from '@web/test-runner-commands';
import { serialize } from '../../utilities/form'; // must come from the same module
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands'; // must come from the same module
import { serialize } from '../../utilities/form';
import sinon from 'sinon';
import type SlInput from './input';
@@ -496,4 +497,6 @@ describe('<sl-input>', () => {
expect(formControls.map((fc: HTMLInputElement) => fc.value).join('')).to.equal('12345678910'); // eslint-disable-line
});
});
runFormControlBaseTests('sl-input');
});

View File

@@ -47,6 +47,7 @@ const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox');
* @event sl-clear - Emitted when the clear button is activated.
* @event sl-focus - Emitted when the control gains focus.
* @event sl-input - Emitted when the control receives input.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart form-control - The form control that wraps the label, input, and help text.
* @csspart form-control-label - The label's wrapper.
@@ -227,6 +228,16 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
this.value = input.value;
}
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
firstUpdated() {
this.formControlController.updateValidity();
}
@@ -262,8 +273,9 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
this.emit('sl-input');
}
private handleInvalid() {
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private handleKeyDown(event: KeyboardEvent) {
@@ -372,7 +384,7 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
}
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
return this.input.checkValidity();
}

View File

@@ -38,7 +38,7 @@ import type { CSSResultGroup } from 'lit';
export default class SlPopup extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private anchorEl: HTMLElement | null;
private anchorEl: Element | null;
private cleanup: ReturnType<typeof autoUpdate> | undefined;
/** A reference to the internal popup container. Useful for animating and styling the popup with JavaScript. */
@@ -223,7 +223,7 @@ export default class SlPopup extends ShoelaceElement {
// Locate the anchor by id
const root = this.getRootNode() as Document | ShadowRoot;
this.anchorEl = root.getElementById(this.anchor);
} else if (this.anchor instanceof HTMLElement) {
} else if (this.anchor instanceof Element) {
// Use the anchor's reference
this.anchorEl = this.anchor;
} else {

View File

@@ -1,5 +1,6 @@
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlChangeEvent from '../../events/sl-change';
@@ -316,4 +317,6 @@ describe('when the value changes', () => {
radioGroup.value = '2';
await radioGroup.updateComplete;
});
runFormControlBaseTests('sl-radio-group');
});

View File

@@ -1,7 +1,12 @@
import '../button-group/button-group';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { FormControlController } from '../../internal/form';
import {
customErrorValidityState,
FormControlController,
validValidityState,
valueMissingValidityState
} from '../../internal/form';
import { HasSlotController } from '../../internal/slot';
import { html } from 'lit';
import { watch } from '../../internal/watch';
@@ -26,6 +31,7 @@ import type SlRadioButton from '../radio-button/radio-button';
*
* @event sl-change - Emitted when the radio group's selected value changes.
* @event sl-input - Emitted when the radio group receives user input.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart form-control - The form control that wraps the label, input, and help text.
* @csspart form-control-label - The label's wrapper.
@@ -75,6 +81,34 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
/** Ensures a child radio is checked before allowing the containing form to submit. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
const isRequiredAndEmpty = this.required && !this.value;
const hasCustomValidityMessage = this.customValidityMessage !== '';
if (hasCustomValidityMessage) {
return customErrorValidityState;
} else if (isRequiredAndEmpty) {
return valueMissingValidityState;
}
return validValidityState;
}
/** Gets the validation message */
get validationMessage() {
const isRequiredAndEmpty = this.required && !this.value;
const hasCustomValidityMessage = this.customValidityMessage !== '';
if (hasCustomValidityMessage) {
return this.customValidityMessage;
} else if (isRequiredAndEmpty) {
return this.validationInput.validationMessage;
}
return '';
}
connectedCallback() {
super.connectedCallback();
this.defaultValue = this.value;
@@ -187,10 +221,15 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
}
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private updateCheckedRadio() {
const radios = this.getAllRadios();
radios.forEach(radio => (radio.checked = radio.value === this.value));
this.formControlController.setValidity(this.checkValidity());
this.formControlController.setValidity(this.validity.valid);
}
@watch('value')
@@ -200,12 +239,13 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
}
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
const isRequiredAndEmpty = this.required && !this.value;
const hasCustomValidityMessage = this.customValidityMessage !== '';
if (isRequiredAndEmpty || hasCustomValidityMessage) {
this.formControlController.emitInvalidEvent();
return false;
}
@@ -222,7 +262,7 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity(): boolean {
const isValid = this.checkValidity();
const isValid = this.validity.valid;
this.errorMessage = this.customValidityMessage || isValid ? '' : this.validationInput.validationMessage;
this.formControlController.setValidity(isValid);
@@ -289,6 +329,7 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
?required=${this.required}
tabindex="-1"
hidden
@invalid=${this.handleInvalid}
/>
</label>
</div>

View File

@@ -1,5 +1,6 @@
import { clickOnElement } from '../../internal/test';
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import { serialize } from '../../utilities/form';
import sinon from 'sinon';
@@ -229,4 +230,6 @@ describe('<sl-range>', () => {
expect(input.value).to.equal(0);
});
});
runFormControlBaseTests('sl-range');
});

View File

@@ -26,6 +26,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
* @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.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart form-control - The form control that wraps the label, input, and help text.
* @csspart form-control-label - The label's wrapper.
@@ -101,6 +102,16 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue() defaultValue = 0;
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
connectedCallback() {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.syncRange());
@@ -207,6 +218,11 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
}
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
/** Sets focus on the range. */
focus(options?: FocusOptions) {
this.input.focus(options);
@@ -233,7 +249,7 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
}
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
return this.input.checkValidity();
}
@@ -306,8 +322,9 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
.value=${live(this.value.toString())}
aria-describedby="help-text"
@change=${this.handleChange}
@input=${this.handleInput}
@focus=${this.handleFocus}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@blur=${this.handleBlur}
/>
${this.tooltip !== 'none' && !this.disabled

View File

@@ -1,5 +1,6 @@
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import { serialize } from '../../utilities/form';
import sinon from 'sinon';
@@ -548,4 +549,6 @@ describe('<sl-select>', () => {
expect(tag.hasAttribute('pill')).to.be.true;
});
runFormControlBaseTests('sl-select');
});

View File

@@ -47,6 +47,7 @@ import type SlRemoveEvent from '../../events/sl-remove';
* @event sl-after-show - Emitted after the select's menu opens and all animations are complete.
* @event sl-hide - Emitted when the select's menu closes.
* @event sl-after-hide - Emitted after the select's menu closes and all animations are complete.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart form-control - The form control that wraps the label, input, and help text.
* @csspart form-control-label - The label's wrapper.
@@ -163,6 +164,16 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
/** The select's required attribute. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
return this.valueInput.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.valueInput.validationMessage;
}
connectedCallback() {
super.connectedCallback();
this.handleDocumentFocusIn = this.handleDocumentFocusIn.bind(this);
@@ -521,6 +532,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
});
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Close the listbox when the control is disabled
@@ -604,7 +620,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
return waitForEvent(this, 'sl-after-hide');
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
return this.valueInput.checkValidity();
}
@@ -753,6 +769,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
tabindex="-1"
aria-hidden="true"
@focus=${() => this.focus()}
@invalid=${this.handleInvalid}
/>
${hasClearIcon

View File

@@ -1,4 +1,5 @@
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlSwitch from './switch';
@@ -260,4 +261,6 @@ describe('<sl-switch>', () => {
expect(switchEl.checked).to.false;
});
});
runFormControlBaseTests('sl-switch');
});

View File

@@ -23,6 +23,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
* @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.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart base - The component's base wrapper.
* @csspart control - The control that houses the switch's thumb.
@@ -76,6 +77,16 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
/** Makes the switch a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
firstUpdated() {
this.formControlController.updateValidity();
}
@@ -89,6 +100,11 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
this.emit('sl-input');
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private handleClick() {
this.checked = !this.checked;
this.emit('sl-change');
@@ -142,7 +158,7 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
this.input.blur();
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
return this.input.checkValidity();
}
@@ -185,6 +201,7 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
aria-checked=${this.checked ? 'true' : 'false'}
@click=${this.handleClick}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@keydown=${this.handleKeyDown}

View File

@@ -1,4 +1,5 @@
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import { serialize } from '../../utilities/form';
import sinon from 'sinon';
@@ -292,4 +293,6 @@ describe('<sl-textarea>', () => {
expect(textarea.spellcheck).to.be.false;
});
});
runFormControlBaseTests('sl-textarea');
});

View File

@@ -25,6 +25,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
* @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.
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*
* @csspart form-control - The form control that wraps the label, input, and help text.
* @csspart form-control-label - The label's wrapper.
@@ -135,6 +136,16 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue() defaultValue = '';
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
connectedCallback() {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.setTextareaHeight());
@@ -175,6 +186,11 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
this.emit('sl-input');
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitInvalidEvent(event);
}
private setTextareaHeight() {
if (this.resize === 'auto') {
this.input.style.height = 'auto';
@@ -260,7 +276,7 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
}
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
checkValidity() {
return this.input.checkValidity();
}
@@ -344,6 +360,7 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
aria-describedby="help-text"
@change=${this.handleChange}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
></textarea>

View File

@@ -127,7 +127,7 @@ export class FormControlController implements ReactiveController {
}
if (this.host.hasUpdated) {
this.setValidity(this.host.checkValidity());
this.setValidity(this.host.validity.valid);
}
}
@@ -341,11 +341,68 @@ export class FormControlController implements ReactiveController {
}
/**
* Updates the form control's validity based on the current value of `host.checkValidity()`. Call this when anything
* Updates the form control's validity based on the current value of `host.validity.valid`. Call this when anything
* that affects constraint validation changes so the component receives the correct validity states.
*/
updateValidity() {
const host = this.host;
this.setValidity(host.checkValidity());
this.setValidity(host.validity.valid);
}
/**
* Dispatches a non-bubbling, cancelable custom event of type `sl-invalid`.
* If the `sl-invalid` event will be cancelled then the original `invalid`
* event (which may have been passed as argument) will also be cancelled.
* If no original `invalid` event has been passed then the `sl-invalid`
* event will be cancelled before being dispatched.
*/
emitInvalidEvent(originalInvalidEvent?: Event) {
const slInvalidEvent = new CustomEvent<void>('sl-invalid', {
bubbles: false,
composed: false,
cancelable: true
});
if (!originalInvalidEvent) {
slInvalidEvent.preventDefault();
}
if (!this.host.dispatchEvent(slInvalidEvent)) {
originalInvalidEvent?.preventDefault();
}
}
}
/*
* Predefined common validity states.
* All of them are read-only.
*/
// A validity state object that represents `valid`
export const validValidityState: ValidityState = Object.freeze({
badInput: false,
customError: false,
patternMismatch: false,
rangeOverflow: false,
rangeUnderflow: false,
stepMismatch: false,
tooLong: false,
tooShort: false,
typeMismatch: false,
valid: true,
valueMissing: false
});
// A validity state object that represents `value missing`
export const valueMissingValidityState: ValidityState = Object.freeze({
...validValidityState,
valid: false,
valueMissing: true
});
// A validity state object that represents a custom error
export const customErrorValidityState: ValidityState = Object.freeze({
...validValidityState,
valid: false,
customError: true
});

View File

@@ -99,8 +99,13 @@ export interface ShoelaceFormControl extends ShoelaceElement {
minlength?: number;
maxlength?: number;
// Validation properties
readonly validity: ValidityState;
readonly validationMessage: string;
// Validation methods
checkValidity: () => boolean;
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity: () => boolean;
setCustomValidity: (message: string) => void;
}

View File

@@ -0,0 +1,307 @@
import { expect, fixture } from '@open-wc/testing';
import type { ShoelaceFormControl } from '../shoelace-element';
type CreateControlFn = () => Promise<ShoelaceFormControl>;
/** Runs a set of generic tests for Shoelace form controls */
export function runFormControlBaseTests<T extends ShoelaceFormControl = ShoelaceFormControl>(
tagNameOrConfig:
| string
| {
tagName: string;
init?: (control: T) => void;
variantName: string;
}
) {
const isStringArg = typeof tagNameOrConfig === 'string';
const tagName = isStringArg ? tagNameOrConfig : tagNameOrConfig.tagName;
// component initialization function or null
const init =
isStringArg || !tagNameOrConfig.init //
? null
: tagNameOrConfig.init || null;
// either `<tagName>` or `<tagName> (<variantName>)
const displayName = isStringArg //
? tagName
: `${tagName} (${tagNameOrConfig.variantName})`;
// creates a testable form control instance
const createControl = async () => {
const control = await createFormControl<T>(tagName);
init?.(control);
return control;
};
runAllValidityTests(tagName, displayName, createControl);
}
//
// Applicable for all Shoelace form controls. This function checks the behavior of:
// - `.validity`
// - `.validationMessage`,
// - `.checkValidity()`
// - `.reportValidity()`
// - `.setCustomValidity(msg)`
//
function runAllValidityTests(
tagName: string, //
displayName: string,
createControl: () => Promise<ShoelaceFormControl>
) {
// will be used later to retrieve meta information about the control
describe(`Form validity base test for ${displayName}`, async () => {
it('should have a property `validity` of type `object`', async () => {
const control = await createControl();
expect(control).satisfy(() => control.validity !== null && typeof control.validity === 'object');
});
it('should have a property `validationMessage` of type `string`', async () => {
const control = await createControl();
expect(control).satisfy(() => typeof control.validationMessage === 'string');
});
it('should implement method `checkValidity`', async () => {
const control = await createControl();
expect(control).satisfies(() => typeof control.checkValidity === 'function');
});
it('should implement method `setCustomValidity`', async () => {
const control = await createControl();
expect(control).satisfies(() => typeof control.setCustomValidity === 'function');
});
it('should implement method `reportValidity`', async () => {
const control = await createControl();
expect(control).satisfies(() => typeof control.reportValidity === 'function');
});
it('should be valid initially', async () => {
const control = await createControl();
expect(control.validity.valid).to.equal(true);
});
it('should make sure that calling `.checkValidity()` will return `true` when valid', async () => {
const control = await createControl();
expect(control.checkValidity()).to.equal(true);
});
it('should make sure that calling `.reportValidity()` will return `true` when valid', async () => {
const control = await createControl();
expect(control.reportValidity()).to.equal(true);
});
it('should not emit an `sl-invalid` event when `.checkValidity()` is called while valid', async () => {
const control = await createControl();
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity());
expect(emittedEvents.length).to.equal(0);
});
it('should not emit an `sl-invalid` event when `.reportValidity()` is called while valid', async () => {
const control = await createControl();
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity());
expect(emittedEvents.length).to.equal(0);
});
// TODO: As soon as `SlRadioGroup` has a property `disabled` this
// condition can be removed
if (tagName !== 'sl-radio-group') {
it('should not emit an `sl-invalid` event when `.checkValidity()` is called in custom error case while disabled', async () => {
const control = await createControl();
control.setCustomValidity('error');
control.disabled = true;
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity());
expect(emittedEvents.length).to.equal(0);
});
it('should not emit an `sl-invalid` event when `.reportValidity()` is called in custom error case while disabled', async () => {
const control = await createControl();
control.setCustomValidity('error');
control.disabled = true;
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity());
expect(emittedEvents.length).to.equal(0);
});
}
// Run special tests depending on component type
const mode = getMode(await createControl());
if (mode === 'slButtonOfTypeButton') {
runSpecialTests_slButtonOfTypeButton(createControl);
} else if (mode === 'slButtonWithHRef') {
runSpecialTests_slButtonWithHref(createControl);
} else {
runSpecialTests_standard(createControl);
}
});
}
//
// Special tests for <sl-button type="button">
//
function runSpecialTests_slButtonOfTypeButton(createControl: CreateControlFn) {
it('should make sure that `.validity.valid` is `false` in custom error case', async () => {
const control = await createControl();
control.setCustomValidity('error');
expect(control.validity.valid).to.equal(false);
});
it('should make sure that calling `.checkValidity()` will still return `true` when custom error has been set', async () => {
const control = await createControl();
control.setCustomValidity('error');
expect(control.checkValidity()).to.equal(true);
});
it('should make sure that calling `.reportValidity()` will still return `true` when custom error has been set', async () => {
const control = await createControl();
control.setCustomValidity('error');
expect(control.reportValidity()).to.equal(true);
});
it('should not emit an `sl-invalid` event when `.checkValidity()` is called in custom error case, and not disabled', async () => {
const control = await createControl();
control.setCustomValidity('error');
control.disabled = false;
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity());
expect(emittedEvents.length).to.equal(0);
});
it('should not emit an `sl-invalid` event when `.reportValidity()` is called in custom error case, and not disabled', async () => {
const control = await createControl();
control.setCustomValidity('error');
control.disabled = false;
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity());
expect(emittedEvents.length).to.equal(0);
});
}
//
// Special tests for <sl-button href="...">
//
function runSpecialTests_slButtonWithHref(createControl: CreateControlFn) {
it('should make sure that calling `.checkValidity()` will return `true` in custom error case', async () => {
const control = await createControl();
control.setCustomValidity('error');
expect(control.checkValidity()).to.equal(true);
});
it('should make sure that calling `.reportValidity()` will return `true` in custom error case', async () => {
const control = await createControl();
control.setCustomValidity('error');
expect(control.reportValidity()).to.equal(true);
});
it('should not emit an `sl-invalid` event when `.checkValidity()` is called in custom error case', async () => {
const control = await createControl();
control.setCustomValidity('error');
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity());
expect(emittedEvents.length).to.equal(0);
});
it('should not emit an `sl-invalid` event when `.reportValidity()` is called in custom error case', async () => {
const control = await createControl();
control.setCustomValidity('error');
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity());
expect(emittedEvents.length).to.equal(0);
});
}
//
// Special tests for all components with standard behavior
//
function runSpecialTests_standard(createControl: CreateControlFn) {
it('should make sure that `.validity.valid` is `false` in custom error case', async () => {
const control = await createControl();
control.setCustomValidity('error');
expect(control.validity.valid).to.equal(false);
});
it('should make sure that calling `.checkValidity()` will return `false` in custom error case', async () => {
const control = await createControl();
control.setCustomValidity('error');
expect(control.checkValidity()).to.equal(false);
});
it('should make sure that calling `.reportValidity()` will return `false` in custom error case', async () => {
const control = await createControl();
control.setCustomValidity('error');
expect(control.reportValidity()).to.equal(false);
});
it('should emit an `sl-invalid` event when `.checkValidity()` is called in custom error case and not disabled', async () => {
const control = await createControl();
control.setCustomValidity('error');
control.disabled = false;
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity());
expect(emittedEvents.length).to.equal(1);
});
it('should emit an `sl-invalid` event when `.reportValidity()` is called in custom error case and not disabled', async () => {
const control = await createControl();
control.setCustomValidity('error');
control.disabled = false;
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity());
expect(emittedEvents.length).to.equal(1);
});
}
//
// Local helper functions
//
// Creates a testable Shoelace form control instance
async function createFormControl<T extends ShoelaceFormControl = ShoelaceFormControl>(tagName: string): Promise<T> {
return await fixture<T>(`<${tagName}></${tagName}>`);
}
// Runs an action while listening for emitted events of a given type. Returns an array of all events of the given type
// that have been been emitted while the action was running.
function checkEventEmissions(control: ShoelaceFormControl, eventType: string, action: () => void): Event[] {
const emittedEvents: Event[] = [];
const eventHandler = (event: Event) => {
emittedEvents.push(event);
};
try {
control.addEventListener(eventType, eventHandler);
action();
} finally {
control.removeEventListener(eventType, eventHandler);
}
return emittedEvents;
}
// Component `sl-button` behaves quite different to the other components. To keep things simple we use simple conditions
// here. `sl-button` might stay the only component in Shoelace core behaves that way, so we just hard code it here.
function getMode(control: ShoelaceFormControl) {
if (
control.localName === 'sl-button' && //
'href' in control &&
'type' in control &&
control.type === 'button' &&
!control.href
) {
return 'slButtonOfTypeButton';
}
// <sl-button href="...">
if (control.localName === 'sl-button' && 'href' in control && !!control.href) {
return 'slButtonWithHRef';
}
// all other components
return 'standard';
}