add validation states to all form controls; closes #1011

This commit is contained in:
Cory LaViska
2022-11-18 09:56:05 -05:00
parent a3f658938d
commit daebd08475
13 changed files with 295 additions and 112 deletions

View File

@@ -5,10 +5,12 @@ import { html, literal } from 'lit/static-html.js';
import { FormSubmitController } from '../../internal/form';
import ShoelaceElement from '../../internal/shoelace-element';
import { HasSlotController } from '../../internal/slot';
import { watch } from '../../internal/watch';
import { LocalizeController } from '../../utilities/localize';
import '../icon/icon';
import '../spinner/spinner';
import styles from './button.styles';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
import type { CSSResultGroup } from 'lit';
/**
@@ -34,13 +36,13 @@ import type { CSSResultGroup } from 'lit';
* @csspart caret - The button's caret icon.
*/
@customElement('sl-button')
export default class SlButton extends ShoelaceElement {
export default class SlButton extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('.button') button: HTMLButtonElement | HTMLLinkElement;
private readonly formSubmitController = new FormSubmitController(this, {
form: (input: HTMLInputElement) => {
form: input => {
// Buttons support a form attribute that points to an arbitrary form, so if this attribute it set we need to query
// the form from the same root using its id
if (input.hasAttribute('form')) {
@@ -57,6 +59,7 @@ export default class SlButton extends ShoelaceElement {
private readonly localize = new LocalizeController(this);
@state() private hasFocus = false;
@state() invalid = false;
/** The button's variant. */
@property({ reflect: true }) variant: 'default' | 'primary' | 'success' | 'neutral' | 'warning' | 'danger' | 'text' =
@@ -90,16 +93,16 @@ export default class SlButton extends ShoelaceElement {
@property() type: 'button' | 'submit' | 'reset' = 'button';
/** An optional name for the button. Ignored when `href` is set. */
@property() name?: string;
@property() name = '';
/** An optional value for the button. Ignored when `href` is set. */
@property() value?: string;
@property() value = '';
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
@property() href?: string;
@property() href = '';
/** Tells the browser where to open the link. Only used when `href` is set. */
@property() target?: '_blank' | '_parent' | '_self' | '_top';
@property() target: '_blank' | '_parent' | '_self' | '_top';
/** Tells the browser to download the linked file as this filename. Only used when `href` is set. */
@property() download?: string;
@@ -122,6 +125,12 @@ export default class SlButton extends ShoelaceElement {
/** Used to override the form owner's `target` attribute. */
@property({ attribute: 'formtarget' }) formTarget: '_self' | '_blank' | '_parent' | '_top' | string;
firstUpdated() {
if (this.isButton()) {
this.invalid = !(this.button as HTMLButtonElement).checkValidity();
}
}
/** Simulates a click on the button. */
click() {
this.button.click();
@@ -137,6 +146,32 @@ export default class SlButton extends ShoelaceElement {
this.button.blur();
}
/** Checks for validity but does not show the browser's validation message. */
checkValidity() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).checkValidity();
}
return true;
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).reportValidity();
}
return true;
}
/** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */
setCustomValidity(message: string) {
if (this.isButton()) {
(this.button as HTMLButtonElement).setCustomValidity(message);
this.invalid = !(this.button as HTMLButtonElement).checkValidity();
}
}
handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
@@ -163,11 +198,29 @@ export default class SlButton extends ShoelaceElement {
}
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
if (this.isButton()) {
this.button.disabled = this.disabled;
this.invalid = !(this.button as HTMLButtonElement).checkValidity();
}
}
private isButton() {
return this.href ? false : true;
}
private isLink() {
return this.href ? true : false;
}
render() {
const isLink = this.href ? true : false;
const isLink = this.isLink();
const tag = isLink ? literal`a` : literal`button`;
/* eslint-disable lit/binding-positions, lit/no-invalid-html */
/* eslint-disable lit/no-invalid-html */
/* eslint-disable lit/binding-positions */
return html`
<${tag}
part="base"
@@ -226,7 +279,8 @@ export default class SlButton extends ShoelaceElement {
${this.loading ? html`<sl-spinner></sl-spinner>` : ''}
</${tag}>
`;
/* eslint-enable lit/binding-positions, lit/no-invalid-html */
/* eslint-enable lit/no-invalid-html */
/* eslint-enable lit/binding-positions */
}
}

View File

@@ -9,6 +9,7 @@ import ShoelaceElement from '../../internal/shoelace-element';
import { watch } from '../../internal/watch';
import '../icon/icon';
import styles from './checkbox.styles';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
import type { CSSResultGroup } from 'lit';
/**
@@ -32,7 +33,7 @@ import type { CSSResultGroup } from 'lit';
* @csspart label - The checkbox label.
*/
@customElement('sl-checkbox')
export default class SlCheckbox extends ShoelaceElement {
export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('input[type="checkbox"]') input: HTMLInputElement;
@@ -45,6 +46,7 @@ export default class SlCheckbox extends ShoelaceElement {
});
@state() private hasFocus = false;
@state() invalid = false;
/** Name of the HTML form control. Submitted with the form as part of a name/value pair. */
@property() name: string;
@@ -64,12 +66,8 @@ export default class SlCheckbox extends ShoelaceElement {
/** Draws the checkbox in an indeterminate state. Usually applies to a checkbox that represents "select all" or "select none" when the items to which it applies are a mix of selected and unselected. */
@property({ type: Boolean, reflect: true }) indeterminate = false;
/** This will be true when the control is in an invalid state. Validity is determined by the `required` prop. */
@property({ type: Boolean, reflect: true }) invalid = false;
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue('checked')
defaultChecked = false;
@defaultValue('checked') defaultChecked = false;
firstUpdated() {
this.invalid = !this.input.checkValidity();
@@ -90,6 +88,11 @@ export default class SlCheckbox extends ShoelaceElement {
this.input.blur();
}
/** Checks for validity but does not show the browser's validation message. */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.input.reportValidity();
@@ -127,6 +130,8 @@ export default class SlCheckbox extends ShoelaceElement {
@watch('checked', { waitUntilFirstUpdate: true })
@watch('indeterminate', { waitUntilFirstUpdate: true })
handleStateChange() {
this.input.checked = this.checked; // force a sync update
this.input.indeterminate = this.indeterminate; // force a sync update
this.invalid = !this.input.checkValidity();
}

View File

@@ -19,6 +19,7 @@ import '../icon/icon';
import '../input/input';
import '../visually-hidden/visually-hidden';
import styles from './color-picker.styles';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
import type SlDropdown from '../dropdown/dropdown';
import type SlInput from '../input/input';
import type { CSSResultGroup } from 'lit';
@@ -84,7 +85,7 @@ declare const EyeDropper: EyeDropperConstructor;
* @cssproperty --swatch-size - The size of each predefined color swatch.
*/
@customElement('sl-color-picker')
export default class SlColorPicker extends ShoelaceElement {
export default class SlColorPicker extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('[part~="input"]') input: SlInput;
@@ -105,13 +106,13 @@ export default class SlColorPicker extends ShoelaceElement {
@state() private lightness = 100;
@state() private brightness = 100;
@state() private alpha = 100;
@state() invalid = false;
/** The current color. */
@property() value = '';
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue()
defaultValue = '';
@defaultValue() defaultValue = '';
/**
* The color picker's label. This will not be displayed, but it will be announced by assistive devices. If you need to
@@ -141,12 +142,6 @@ export default class SlColorPicker extends ShoelaceElement {
/** Disables the color picker. */
@property({ type: Boolean, reflect: true }) disabled = false;
/**
* This will be true when the control is in an invalid state. Validity is determined by the `setCustomValidity()`
* method using the browser's constraint validation API.
*/
@property({ type: Boolean, reflect: true }) invalid = false;
/**
* Enable this option to prevent the panel from being clipped when the component is placed inside a container with
* `overflow: auto|scroll`.
@@ -233,22 +228,20 @@ export default class SlColorPicker extends ShoelaceElement {
return clamp(((((200 - this.saturation) * brightness) / 100) * 5) / 10, 0, 100);
}
/** Checks for validity but does not show the browser's validation message. */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
// If the input is invalid, show the dropdown so the browser can focus on it
if (!this.inline && this.input.invalid) {
return new Promise<void>(resolve => {
this.dropdown.addEventListener(
'sl-after-show',
() => {
this.input.reportValidity();
resolve();
},
{ once: true }
);
this.dropdown.show();
});
// 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();
}
return this.input.reportValidity();
}

View File

@@ -11,6 +11,7 @@ import { watch } from '../../internal/watch';
import { LocalizeController } from '../../utilities/localize';
import '../icon/icon';
import styles from './input.styles';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
import type { CSSResultGroup } from 'lit';
// It's currently impossible to hide Firefox's built-in clear icon when using <input type="date|time">, so we need this
@@ -58,7 +59,7 @@ const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox');
* @csspart suffix - The input suffix container.
*/
@customElement('sl-input')
export default class SlInput extends ShoelaceElement {
export default class SlInput extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('.input__control') input: HTMLInputElement;
@@ -68,6 +69,7 @@ export default class SlInput extends ShoelaceElement {
private readonly localize = new LocalizeController(this);
@state() private hasFocus = false;
@state() invalid = false;
/** The input's type. */
@property({ reflect: true }) type:
@@ -92,8 +94,7 @@ export default class SlInput extends ShoelaceElement {
@property() value = '';
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue()
defaultValue = '';
@defaultValue() defaultValue = '';
/** Draws a filled input. */
@property({ type: Boolean, reflect: true }) filled = false;
@@ -135,10 +136,10 @@ export default class SlInput extends ShoelaceElement {
@property({ type: Number }) maxlength: number;
/** The input's minimum value. */
@property() min: number | string;
@property() min: number;
/** The input's maximum value. */
@property() max: number | string;
@property() max: number;
/**
* Specifies the granularity that the value must adhere to, or the special value `any` which means no stepping is
@@ -152,12 +153,6 @@ export default class SlInput extends ShoelaceElement {
/** Makes the input a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/**
* This will be true when the control is in an invalid state. Validity is determined by props such as `type`,
* `required`, `minlength`, `maxlength`, and `pattern` using the browser's constraint validation API.
*/
@property({ type: Boolean, reflect: true }) invalid = false;
/** The input's autocapitalize attribute. */
@property() autocapitalize: 'off' | 'none' | 'on' | 'sentences' | 'words' | 'characters';
@@ -252,6 +247,11 @@ export default class SlInput extends ShoelaceElement {
}
}
/** Checks for validity but does not show the browser's validation message. */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.input.reportValidity();
@@ -338,6 +338,7 @@ export default class SlInput extends ShoelaceElement {
@watch('value', { waitUntilFirstUpdate: true })
handleValueChange() {
this.input.value = this.value; // force a sync update
this.invalid = !this.input.checkValidity();
}

View File

@@ -7,6 +7,7 @@ import { HasSlotController } from '../../internal/slot';
import { watch } from '../../internal/watch';
import '../button-group/button-group';
import styles from './radio-group.styles';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
import type SlRadioButton from '../radio-button/radio-button';
import type SlRadio from '../radio/radio';
import type { CSSResultGroup } from 'lit';
@@ -32,11 +33,11 @@ import type { CSSResultGroup } from 'lit';
* @csspart button-group__base - The button group's `base` part.
*/
@customElement('sl-radio-group')
export default class SlRadioGroup extends ShoelaceElement {
export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
protected readonly formSubmitController = new FormSubmitController(this, {
defaultValue: (control: SlRadioGroup) => control.defaultValue
defaultValue: control => control.defaultValue
});
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
@@ -46,7 +47,8 @@ export default class SlRadioGroup extends ShoelaceElement {
@state() private hasButtonGroup = false;
@state() private errorMessage = '';
@state() private customErrorMessage = '';
@state() private defaultValue = '';
@state() defaultValue = '';
@state() invalid = false;
/**
* The radio group label. Required for proper accessibility. If you need to display HTML, you can use the `label` slot
@@ -63,12 +65,6 @@ export default class SlRadioGroup extends ShoelaceElement {
/** The name assigned to the radio controls. */
@property() name = 'option';
/**
* This will be true when the control is in an invalid state. Validity is determined by props such as `type`,
* `required`, `minlength`, `maxlength`, and `pattern` using the browser's constraint validation API.
*/
@property({ type: Boolean, reflect: true }) invalid = false;
/** Ensures a child radio is checked before allowing the containing form to submit. */
@property({ type: Boolean, reflect: true }) required = false;
@@ -89,6 +85,11 @@ export default class SlRadioGroup extends ShoelaceElement {
this.invalid = !this.validity.valid;
}
/** Checks for validity but does not show the browser's validation message. */
checkValidity() {
return this.validity.valid;
}
/** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */
setCustomValidity(message = '') {
this.customErrorMessage = message;

View File

@@ -10,6 +10,7 @@ import { HasSlotController } from '../../internal/slot';
import { watch } from '../../internal/watch';
import { LocalizeController } from '../../utilities/localize';
import styles from './range.styles';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
import type { CSSResultGroup } from 'lit';
/**
@@ -41,7 +42,7 @@ import type { CSSResultGroup } from 'lit';
* @cssproperty --track-active-offset - The point of origin of the active track.
*/
@customElement('sl-range')
export default class SlRange extends ShoelaceElement {
export default class SlRange extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('.range__control') input: HTMLInputElement;
@@ -55,6 +56,7 @@ export default class SlRange extends ShoelaceElement {
@state() private hasFocus = false;
@state() private hasTooltip = false;
@state() invalid = false;
/** The input's name attribute. */
@property() name = '';
@@ -71,12 +73,6 @@ export default class SlRange extends ShoelaceElement {
/** Disables the range. */
@property({ type: Boolean, reflect: true }) disabled = false;
/**
* This will be true when the control is in an invalid state. Validity in range inputs is determined by the message
* provided by the `setCustomValidity` method.
*/
@property({ type: Boolean, reflect: true }) invalid = false;
/** The input's min attribute. */
@property({ type: Number }) min = 0;
@@ -93,8 +89,7 @@ export default class SlRange extends ShoelaceElement {
@property({ attribute: false }) tooltipFormatter: (value: number) => string = (value: number) => value.toString();
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue()
defaultValue = 0;
@defaultValue() defaultValue = 0;
connectedCallback() {
super.connectedCallback();
@@ -128,6 +123,16 @@ export default class SlRange extends ShoelaceElement {
this.input.blur();
}
/** Checks for validity but does not show the browser's validation message. */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.input.reportValidity();
}
/** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */
setCustomValidity(message: string) {
this.input.setCustomValidity(message);

View File

@@ -13,6 +13,7 @@ import '../icon/icon';
import '../menu/menu';
import '../tag/tag';
import styles from './select.styles';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
import type SlDropdown from '../dropdown/dropdown';
import type SlIconButton from '../icon-button/icon-button';
import type SlMenuItem from '../menu-item/menu-item';
@@ -41,6 +42,7 @@ import type { TemplateResult, CSSResultGroup } from 'lit';
*
* @event sl-clear - Emitted when the clear button is activated.
* @event sl-change - Emitted when the control's value changes.
* @event sl-input - Emitted when the control receives input.
* @event sl-focus - Emitted when the control gains focus.
* @event sl-blur - Emitted when the control loses focus.
*
@@ -63,7 +65,7 @@ import type { TemplateResult, CSSResultGroup } from 'lit';
* @csspart tags - The container in which multi select options are rendered.
*/
@customElement('sl-select')
export default class SlSelect extends ShoelaceElement {
export default class SlSelect extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('.select') dropdown: SlDropdown;
@@ -82,6 +84,7 @@ export default class SlSelect extends ShoelaceElement {
@state() private isOpen = false;
@state() private displayLabel = '';
@state() private displayTags: TemplateResult[] = [];
@state() invalid = false;
/** Enables multi select. With this enabled, value will be an array. */
@property({ type: Boolean, reflect: true }) multiple = false;
@@ -137,12 +140,8 @@ export default class SlSelect extends ShoelaceElement {
/** Adds a clear button when the select is populated. */
@property({ type: Boolean }) clearable = false;
/** This will be true when the control is in an invalid state. Validity is determined by the `required` prop. */
@property({ type: Boolean, reflect: true }) invalid = false;
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue()
defaultValue = '';
@defaultValue() defaultValue = '';
connectedCallback() {
super.connectedCallback();
@@ -163,6 +162,11 @@ export default class SlSelect extends ShoelaceElement {
this.resizeObserver.unobserve(this);
}
/** Checks for validity but does not show the browser's validation message. */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.input.reportValidity();
@@ -365,8 +369,11 @@ export default class SlSelect extends ShoelaceElement {
async handleValueChange() {
this.syncItemsFromValue();
await this.updateComplete;
this.invalid = !this.input.checkValidity();
this.emit('sl-change');
this.emit('sl-input');
}
resizeMenu() {

View File

@@ -8,6 +8,7 @@ import { FormSubmitController } from '../../internal/form';
import ShoelaceElement from '../../internal/shoelace-element';
import { watch } from '../../internal/watch';
import styles from './switch.styles';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
import type { CSSResultGroup } from 'lit';
/**
@@ -32,7 +33,7 @@ import type { CSSResultGroup } from 'lit';
* @cssproperty --thumb-size - The size of the thumb.
*/
@customElement('sl-switch')
export default class SlSwitch extends ShoelaceElement {
export default class SlSwitch extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('input[type="checkbox"]') input: HTMLInputElement;
@@ -45,6 +46,7 @@ export default class SlSwitch extends ShoelaceElement {
});
@state() private hasFocus = false;
@state() invalid = false;
/** The switch's name attribute. */
@property() name: string;
@@ -61,12 +63,8 @@ export default class SlSwitch extends ShoelaceElement {
/** Draws the switch in a checked state. */
@property({ type: Boolean, reflect: true }) checked = false;
/** This will be true when the control is in an invalid state. Validity is determined by the `required` prop. */
@property({ type: Boolean, reflect: true }) invalid = false;
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue('checked')
defaultChecked = false;
@defaultValue('checked') defaultChecked = false;
firstUpdated() {
this.invalid = !this.input.checkValidity();
@@ -87,6 +85,11 @@ export default class SlSwitch extends ShoelaceElement {
this.input.blur();
}
/** Checks for validity but does not show the browser's validation message. */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.input.reportValidity();
@@ -105,7 +108,7 @@ export default class SlSwitch extends ShoelaceElement {
@watch('checked', { waitUntilFirstUpdate: true })
handleCheckedChange() {
this.input.checked = this.checked;
this.input.checked = this.checked; // force a sync update
this.invalid = !this.input.checkValidity();
}

View File

@@ -9,6 +9,7 @@ import ShoelaceElement from '../../internal/shoelace-element';
import { HasSlotController } from '../../internal/slot';
import { watch } from '../../internal/watch';
import styles from './textarea.styles';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
import type { CSSResultGroup } from 'lit';
/**
@@ -33,7 +34,7 @@ import type { CSSResultGroup } from 'lit';
* @csspart textarea - The textarea control.
*/
@customElement('sl-textarea')
export default class SlTextarea extends ShoelaceElement {
export default class SlTextarea extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('.textarea__control') input: HTMLTextAreaElement;
@@ -44,6 +45,7 @@ export default class SlTextarea extends ShoelaceElement {
private resizeObserver: ResizeObserver;
@state() private hasFocus = false;
@state() invalid = false;
/** The textarea's size. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
@@ -87,12 +89,6 @@ export default class SlTextarea extends ShoelaceElement {
/** Makes the textarea a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/**
* This will be true when the control is in an invalid state. Validity is determined by props such as `type`,
* `required`, `minlength`, and `maxlength` using the browser's constraint validation API.
*/
@property({ type: Boolean, reflect: true }) invalid = false;
/** The textarea's autocapitalize attribute. */
@property() autocapitalize: 'off' | 'none' | 'on' | 'sentences' | 'words' | 'characters';
@@ -118,8 +114,7 @@ export default class SlTextarea extends ShoelaceElement {
@property() inputmode: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue()
defaultValue = '';
@defaultValue() defaultValue = '';
connectedCallback() {
super.connectedCallback();
@@ -200,6 +195,11 @@ export default class SlTextarea extends ShoelaceElement {
}
}
/** Checks for validity but does not show the browser's validation message. */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.input.reportValidity();
@@ -246,6 +246,7 @@ export default class SlTextarea extends ShoelaceElement {
@watch('value', { waitUntilFirstUpdate: true })
handleValueChange() {
this.input.value = this.value; // force a sync update
this.invalid = !this.input.checkValidity();
this.updateComplete.then(() => this.setTextareaHeight());
}

View File

@@ -9,11 +9,9 @@
//
// Usage:
//
// @property({ type: Boolean, reflect: true })
// checked = false;
// @property({ type: Boolean, reflect: true }) checked = false;
//
// @defaultValue('checked')
// defaultChecked = false;
// @defaultValue('checked') defaultChecked = false;
//
import { defaultConverter } from 'lit';

View File

@@ -1,61 +1,82 @@
import './formdata-event-polyfill';
import type SlButton from '../components/button/button';
import type { ShoelaceFormControl } from '../internal/shoelace-element';
import type { ReactiveController, ReactiveControllerHost } from 'lit';
//
// We store a WeakMap of forms + controls so we can keep references to all Shoelace controls within a given form. As
// elements connect and disconnect to/from the DOM, their containing form is used as the key and the form control is
// added and removed from the form's set, respectively.
//
const formCollections: WeakMap<HTMLFormElement, Set<ShoelaceFormControl>> = new WeakMap();
//
// We store a WeakMap of controls that users have interacted with. This allows us to determine the interaction state
// without littering the DOM with additional data attributes.
//
const userInteractedControls: WeakMap<ShoelaceFormControl, boolean> = new WeakMap();
//
// We store a WeakMap of reportValidity() overloads so we can override it when form controls connect to the DOM and
// restore the original behavior when they disconnect.
//
const reportValidityOverloads: WeakMap<HTMLFormElement, () => boolean> = new WeakMap();
export interface FormSubmitControllerOptions {
/** A function that returns the form containing the form control. */
form: (input: unknown) => HTMLFormElement | null;
form: (input: ShoelaceFormControl) => HTMLFormElement | null;
/** A function that returns the form control's name, which will be submitted with the form data. */
name: (input: unknown) => string;
name: (input: ShoelaceFormControl) => string;
/** A function that returns the form control's current value. */
value: (input: unknown) => unknown | unknown[];
value: (input: ShoelaceFormControl) => unknown | unknown[];
/** A function that returns the form control's default value. */
defaultValue: (input: unknown) => unknown | unknown[];
defaultValue: (input: ShoelaceFormControl) => unknown | unknown[];
/** A function that returns the form control's current disabled state. If disabled, the value won't be submitted. */
disabled: (input: unknown) => boolean;
disabled: (input: ShoelaceFormControl) => boolean;
/**
* A function that maps to the form control's reportValidity() function. When the control is invalid, this will
* prevent submission and trigger the browser's constraint violation warning.
*/
reportValidity: (input: unknown) => boolean;
reportValidity: (input: ShoelaceFormControl) => boolean;
/** A function that sets the form control's value */
setValue: (input: unknown, value: unknown) => void;
setValue: (input: ShoelaceFormControl, value: unknown) => void;
}
export class FormSubmitController implements ReactiveController {
host?: ReactiveControllerHost & Element;
host: ShoelaceFormControl & ReactiveControllerHost;
form?: HTMLFormElement | null;
options: FormSubmitControllerOptions;
constructor(host: ReactiveControllerHost & Element, options?: Partial<FormSubmitControllerOptions>) {
constructor(host: ReactiveControllerHost & ShoelaceFormControl, options?: Partial<FormSubmitControllerOptions>) {
(this.host = host).addController(this);
this.options = {
form: (input: HTMLInputElement) => input.closest('form'),
name: (input: HTMLInputElement) => input.name,
value: (input: HTMLInputElement) => input.value,
defaultValue: (input: HTMLInputElement) => input.defaultValue,
disabled: (input: HTMLInputElement) => input.disabled,
reportValidity: (input: HTMLInputElement) => {
return typeof input.reportValidity === 'function' ? input.reportValidity() : true;
},
setValue: (input: HTMLInputElement, value: string) => {
input.value = value;
},
form: input => input.closest('form'),
name: input => input.name,
value: input => input.value,
defaultValue: input => input.defaultValue,
disabled: input => input.disabled ?? false,
reportValidity: input => (typeof input.reportValidity === 'function' ? input.reportValidity() : true),
setValue: (input, value: string) => (input.value = value),
...options
};
this.handleFormData = this.handleFormData.bind(this);
this.handleFormSubmit = this.handleFormSubmit.bind(this);
this.handleFormReset = this.handleFormReset.bind(this);
this.reportFormValidity = this.reportFormValidity.bind(this);
this.handleUserInput = this.handleUserInput.bind(this);
}
hostConnected() {
this.form = this.options.form(this.host);
if (this.form) {
// Add this element to the form's collection
if (formCollections.has(this.form)) {
formCollections.get(this.form)!.add(this.host);
} else {
formCollections.set(this.form, new Set<ShoelaceFormControl>([this.host]));
}
this.form.addEventListener('formdata', this.handleFormData);
this.form.addEventListener('submit', this.handleFormSubmit);
this.form.addEventListener('reset', this.handleFormReset);
@@ -66,10 +87,15 @@ export class FormSubmitController implements ReactiveController {
this.form.reportValidity = () => this.reportFormValidity();
}
}
this.host.addEventListener('sl-input', this.handleUserInput);
}
hostDisconnected() {
if (this.form) {
// Remove this element from the form's collection
formCollections.get(this.form)?.delete(this.host);
this.form.removeEventListener('formdata', this.handleFormData);
this.form.removeEventListener('submit', this.handleFormSubmit);
this.form.removeEventListener('reset', this.handleFormReset);
@@ -82,6 +108,39 @@ export class FormSubmitController implements ReactiveController {
this.form = undefined;
}
this.host.removeEventListener('sl-input', this.handleUserInput);
}
hostUpdated() {
//
// We're mapping the following "states" to data attributes. In the future, we can use ElementInternals.states to
// create a similar mapping, but instead of [data-invalid] it will look like :--invalid.
//
// See this RFC for more details: https://github.com/shoelace-style/shoelace/issues/1011
//
const host = this.host;
const hasInteracted = Boolean(userInteractedControls.get(host));
const invalid = Boolean(host.invalid);
const required = Boolean(host.required);
if (this.form?.noValidate) {
// Form validation is disabled, remove the attributes
host.removeAttribute('data-required');
host.removeAttribute('data-optional');
host.removeAttribute('data-invalid');
host.removeAttribute('data-valid');
host.removeAttribute('data-user-invalid');
host.removeAttribute('data-user-valid');
} else {
// Form validation is enabled, set the attributes
host.toggleAttribute('data-required', required);
host.toggleAttribute('data-optional', !required);
host.toggleAttribute('data-invalid', invalid);
host.toggleAttribute('data-valid', !invalid);
host.toggleAttribute('data-user-invalid', invalid && hasInteracted);
host.toggleAttribute('data-user-valid', !invalid && hasInteracted);
}
}
handleFormData(event: FormDataEvent) {
@@ -104,6 +163,13 @@ export class FormSubmitController implements ReactiveController {
const disabled = this.options.disabled(this.host);
const reportValidity = this.options.reportValidity;
// Update the interacted state for all controls when the form is submitted
if (this.form && !this.form.noValidate) {
formCollections.get(this.form)?.forEach(control => {
this.setUserInteracted(control, true);
});
}
if (this.form && !this.form.noValidate && !disabled && !reportValidity(this.host)) {
event.preventDefault();
event.stopImmediatePropagation();
@@ -112,6 +178,12 @@ export class FormSubmitController implements ReactiveController {
handleFormReset() {
this.options.setValue(this.host, this.options.defaultValue(this.host));
this.setUserInteracted(this.host, false);
}
async handleUserInput() {
await this.host.updateComplete;
this.setUserInteracted(this.host, true);
}
reportFormValidity() {
@@ -144,6 +216,11 @@ export class FormSubmitController implements ReactiveController {
return true;
}
setUserInteracted(el: ShoelaceFormControl, hasInteracted: boolean) {
userInteractedControls.set(el, hasInteracted);
el.requestUpdate();
}
doAction(type: 'submit' | 'reset', invoker?: HTMLInputElement | SlButton) {
if (this.form) {
const button = document.createElement('button');

View File

@@ -21,3 +21,29 @@ export default class ShoelaceElement extends LitElement {
return event;
}
}
export interface ShoelaceFormControl extends ShoelaceElement {
// Standard form attributes
name: string;
value: unknown;
disabled?: boolean;
defaultValue?: unknown;
defaultChecked?: boolean;
// Standard validation attributes
pattern?: string;
min?: number | Date;
max?: number | Date;
step?: number | 'any';
required?: boolean;
minlength?: number;
maxlength?: number;
// Proprietary validation properties (non-attributes)
invalid: boolean;
// Validation methods
checkValidity: () => boolean;
reportValidity: () => boolean;
setCustomValidity: (message: string) => void;
}