From 7c76fb381347631a799eb257246e64a23eeacc90 Mon Sep 17 00:00:00 2001 From: Cory LaViska <cory@abeautifulsite.net> Date: Fri, 28 Aug 2020 16:14:39 -0400 Subject: [PATCH] Rework input validation --- CHANGELOG.md | 8 ++ docs/components/form.md | 126 +++++++++++++++++++++++- docs/components/input.md | 18 +--- docs/components/textarea.md | 2 + src/components.d.ts | 132 ++++++++++++++++++-------- src/components/checkbox/checkbox.tsx | 4 + src/components/form/form.tsx | 16 +++- src/components/input/input.scss | 33 +------ src/components/input/input.tsx | 103 ++++++++++++++------ src/components/select/select.tsx | 19 ++-- src/components/switch/switch.tsx | 4 + src/components/textarea/textarea.scss | 27 ------ src/components/textarea/textarea.tsx | 89 ++++++++++++----- src/styles/shoelace.scss | 8 -- 14 files changed, 397 insertions(+), 192 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e88953..fcc6d938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 2.0.0-beta.17 +- Added `required` to `sl-checkbox` and `sl-switch` +- Added `minlength` and `spellcheck` attributes to `sl-textarea` - Fixed a bug where clicking a tag in `sl-select` wouldn't toggle the menu - Fixed a bug where options where `sl-select` options weren't always visible or scrollable - Fixed a bug where setting `null` on `sl-input`, `sl-textarea`, or `sl-select` would throw an error @@ -10,6 +12,12 @@ - Fixed a bug where the value wasn't updated and events weren't emitted when using `setRangeText` in `sl-input` and `sl-textarea` - Optimized `hasSlot` utility by using a simpler selector +**Form validation has been reworked and is much more powerful now!** The following changes affect `sl-input`, `sl-select`, and `sl-textarea`. + +- The `invalid` prop now reflects the control's validity as determined by the browser's constraint validation API +- Removed the `valid` prop +- Removed valid and invalid design tokens and related styles (you can use your own custom styles to achieve this) + ## 2.0.0-beta.16 - Add `hoist` prop to `sl-color-picker`, `sl-dropdown`, and `sl-select` to work around panel clipping diff --git a/docs/components/form.md b/docs/components/form.md index c6e0f443..f449d783 100644 --- a/docs/components/form.md +++ b/docs/components/form.md @@ -64,6 +64,130 @@ This component solves that problem by serializing _both_ Shoelace form controls </script> ``` -?> Shoelace forms don't make use of `action` and `method` attributes and they don't submit automatically like native forms. To handle submission, you need to listen for the `slSubmit` event as shown in the example above. +?> Shoelace forms don't make use of `action` and `method` attributes and they don't submit like native forms. To handle submission, you need to listen for the `slSubmit` event as shown in the example above and make an XHR request with the resulting form data. + +## Form Control Validation + +Client-side validation can be enabled through the browser's [constraint validations API](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/HTML5/Constraint_validation) for many form controls. You can enable it using props such as `required`, `pattern`, `minlength`, `maxlength`, and `customValidity`. As the user interacts with the form control, the `invalid` attribute will reflect its validity based on its current value and the constraints that have been defined. + +When a form control is invalid, the containing form will not be submitted. Instead, the browser will show the user a relevant error message. + +Form controls that support validation include [`sl-input`](/components/input), [`sl-textarea`](/components/textarea), [`sl-select`](/components/select), and [`sl-checkbox`](/components/checkbox). Not all validation props are available for every component. Refer to each component's documentation to see which validation props it supports. + +Note that validity is not checked until the user interacts with the control or its containing form is submitted. This prevents required controls from being rendered as invalid right away, which can result in a poor user experience. If you need this behavior, set the `invalid` attribute initially. + +!> Client-side validation can be used to improve the UX of forms, but it is not a replacement for server-side validation. **You should always validate and sanitize user input on the server!** + +### Required Fields + +To make a field required, use the `required` prop. The form will not be submitted if a required form control is empty. + +```html preview +<sl-form class="input-validation-required"> + <sl-input name="name" label="Name" required></sl-input> + <br> + <sl-textarea name="comment" label="Comment" required></sl-textarea> + <br> + <sl-button type="primary" submit>Submit</sl-button> +</sl-form> + +<script> + const form = document.querySelector('.input-validation-required'); + form.addEventListener('slSubmit', () => alert('All fields are valid!')); +</script> +``` + +### Input Patterns + +To restrict a value to a specific [pattern](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/pattern), use the `pattern` attribute. This example only allows the letters A-Z, so the form will not submit if a number or symbol is entered. This only works with `<sl-input>` elements. + +```html preview +<sl-form class="input-validation-pattern"> + <sl-input name="letters" required label="Letters" pattern="[A-Za-z]+"></sl-input> + <br> + <sl-button type="primary" submit>Submit</sl-button> +</sl-form> + +<script> + const form = document.querySelector('.input-validation-pattern'); + form.addEventListener('slSubmit', () => alert('All fields are valid!')); +</script> +``` + +### Input Types + +Some input types will automatically trigger constraints, such as `email` and `url`. + +```html preview +<sl-form class="input-validation-type"> + <sl-input type="email" label="Email" placeholder="you@example.com" required></sl-input> + <br> + <sl-input type="url" label="URL" placeholder="https://example.com/" required></sl-input> + <br> + <sl-button type="primary" submit>Submit</sl-button> +</sl-form> + +<script> + const form = document.querySelector('.input-validation-type'); + form.addEventListener('slSubmit', () => alert('All fields are valid!')); +</script> +``` + +### Custom Validation + +To create a custom validation error, use the `customValidity` prop. The form will not be submitted when this prop is set to anything other than an empty string, and its value will be shown by the browser as the error message. + +```html preview +<sl-form class="input-validation-custom"> + <sl-input label="Type 'shoelace'" required></sl-input> + <br> + <sl-button type="primary" submit>Submit</sl-button> +</sl-form> + +<script> + const form = document.querySelector('.input-validation-custom'); + const input = form.querySelector('sl-input'); + + form.addEventListener('slSubmit', () => alert('All fields are valid!')); + input.addEventListener('slInput', () => { + if (input.value === 'shoelace') { + input.customValidity = ''; + } else { + input.customValidity = 'Hey, you\'re supposed to type \'shoelace\' before submitting this!'; + } + }); +</script> +``` + +### Custom Validation Styles + +The `invalid` attribute reflects the form control's validity, so you can style invalid fields using the `[invalid]` selector. The example below demonstrates how you can give erroneous fields a different appearance. Type something other than "shoelace" to demonstrate this. + +```html preview +<sl-input class="custom-input" required pattern="shoelace"> + <small slot="help-text">Please enter "shoelace" to continue</small> +</sl-input> + +<style> + .custom-input[invalid]:not([disabled])::part(label), + .custom-input[invalid]:not([disabled])::part(help-text) { + color: var(--sl-color-danger-40); + } + + .custom-input[invalid]:not([disabled])::part(base) { + border-color: var(--sl-color-danger-50); + } + + .custom-input[invalid] { + --focus-ring: 0 0 0 var(--sl-focus-ring-width) + hsla( + var(--sl-color-danger-hue), + var(--sl-color-danger-saturation), + var(--sl-focus-ring-lightness), + var(--sl-focus-ring-alpha) + ); + } +</style> +``` [component-metadata:sl-form] diff --git a/docs/components/input.md b/docs/components/input.md index f131f5ea..75abb64f 100644 --- a/docs/components/input.md +++ b/docs/components/input.md @@ -10,6 +10,8 @@ Inputs collect data from the user. ?> This component doesn't work with standard forms. Use [`<sl-form>`](/components/form.md) instead. +?> Please refer to the section on [form control validation](/components/form?id=form-control-validation) to learn how to do client-side validation. + ## Examples ### Placeholders @@ -117,20 +119,4 @@ Add descriptive help text to an input with the `help-text` slot. </sl-input> ``` -### Validation - -Show a valid or invalid state by setting the `valid` and `invalid` attributes, respectively. Help text can be used to provide feedback for validation and will be styled accordingly. - -```html preview -<sl-input label="Valid" valid> - <div slot="help-text">This is a valid input</div> -</sl-input> - -<br> - -<sl-input label="Invalid" invalid> - <div slot="help-text">This is an invalid input</div> -</sl-input> -``` - [component-metadata:sl-input] diff --git a/docs/components/textarea.md b/docs/components/textarea.md index fb29770a..72876279 100644 --- a/docs/components/textarea.md +++ b/docs/components/textarea.md @@ -10,6 +10,8 @@ Textareas collect data from the user and allow multiple lines of text. ?> This component doesn't work with standard forms. Use [`<sl-form>`](/components/form.md) instead. +?> Please refer to the section on [form control validation](/components/form?id=form-control-validation) to learn how to do client-side validation. + ## Examples ### Rows diff --git a/src/components.d.ts b/src/components.d.ts index 89e52554..1294def4 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -225,6 +225,10 @@ export namespace Components { * Removes focus from the checkbox. */ "removeFocus": () => Promise<void>; + /** + * Set to true to make the checkbox a required field. + */ + "required": boolean; /** * Sets focus on the checkbox. */ @@ -405,9 +409,9 @@ export namespace Components { */ "getFormData": () => Promise<FormData>; /** - * Submits the form. + * Submits the form. If all controls are valid, the `slSubmit` event will be emitted and the promise will resolve with `true`. If any form control is invalid, the promise will resolve with `false` and no event will be emitted. */ - "submit": () => Promise<void>; + "submit": () => Promise<boolean>; } interface SlFormatBytes { /** @@ -482,6 +486,10 @@ export namespace Components { * Set to true to add a clear button when the input is populated. */ "clearable": boolean; + /** + * Sets a custom validation message for the control. When this prop is not an empty string, the browser will assume the control is invalid and show this message as an error when the form is submitted. + */ + "customValidity": string; /** * Set to true to disable the input. */ @@ -491,7 +499,7 @@ export namespace Components { */ "inputmode": 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; /** - * Set to true to indicate that the user input is invalid. + * This will be true when the control is in an invalid state. Validity is determined by props such as `type`, `required`, `pattern`, and `customValidity` using the browser's constraint validation API. */ "invalid": boolean; /** @@ -499,19 +507,19 @@ export namespace Components { */ "label": string; /** - * The input's max attribute. + * The input's maximum value. */ "max": number; /** - * The input's maxlength attribute. + * The maximum length of input that will be considered valid. */ "maxlength": number; /** - * The input's min attribute. + * The input's minimum value. */ "min": number; /** - * The input's minlength attribute. + * The minimum length of input that will be considered valid. */ "minlength": number; /** @@ -519,7 +527,7 @@ export namespace Components { */ "name": string; /** - * The input's pattern attribute. + * A pattern to validate input against. */ "pattern": string; /** @@ -531,7 +539,7 @@ export namespace Components { */ "placeholder": string; /** - * Set to true for a readonly input. + * Set to true to make the input readonly. */ "readonly": boolean; /** @@ -539,7 +547,11 @@ export namespace Components { */ "removeFocus": () => Promise<void>; /** - * The input's required attribute. + * Checks for validity and shows the browser's validation message if the control is invalid. + */ + "reportValidity": () => Promise<boolean>; + /** + * Set to true to make the checkbox a required field. */ "required": boolean; /** @@ -574,10 +586,6 @@ export namespace Components { * The input's type. */ "type": 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url'; - /** - * Set to true to indicate that the user input is valid. - */ - "valid": boolean; /** * The input's value attribute. */ @@ -790,10 +798,6 @@ export namespace Components { * The select's size. */ "size": 'small' | 'medium' | 'large'; - /** - * Set to true to indicate that the user input is valid. - */ - "valid": boolean; /** * The value of the control. This will be a string or an array depending on `multiple`. */ @@ -824,6 +828,10 @@ export namespace Components { * Removes focus from the switch. */ "removeFocus": () => Promise<void>; + /** + * Set to true to make the switch a required field. + */ + "required": boolean; /** * Sets focus on the switch. */ @@ -910,6 +918,10 @@ export namespace Components { * The textarea's autofocus attribute. */ "autofocus": boolean; + /** + * Sets a custom validation message for the control. When this prop is not an empty string, the browser will assume the control is invalid and show this message as an error when the form is submitted. + */ + "customValidity": string; /** * Set to true to disable the textarea. */ @@ -919,7 +931,7 @@ export namespace Components { */ "inputmode": 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; /** - * Set to true to indicate that the user input is invalid. + * This will be true when the control is in an invalid state. Validity is determined by props such as `type`, `required`, `pattern`, and `customValidity` using the browser's constraint validation API. */ "invalid": boolean; /** @@ -927,9 +939,13 @@ export namespace Components { */ "label": string; /** - * The textarea's maxlength attribute. + * The maximum length of input that will be considered valid. */ "maxlength": number; + /** + * The minimum length of input that will be considered valid. + */ + "minlength": number; /** * The textarea's name attribute. */ @@ -946,6 +962,10 @@ export namespace Components { * Removes focus fromt the textarea. */ "removeFocus": () => Promise<void>; + /** + * Checks for validity and shows the browser's validation message if the control is invalid. + */ + "reportValidity": () => Promise<boolean>; /** * The textarea's required attribute. */ @@ -979,9 +999,9 @@ export namespace Components { */ "size": 'small' | 'medium' | 'large'; /** - * Set to true to indicate that the user input is valid. + * The textarea's spellcheck attribute. */ - "valid": boolean; + "spellcheck": boolean; /** * The textarea's value attribute. */ @@ -1531,6 +1551,10 @@ declare namespace LocalJSX { * Emitted when the control gains focus. */ "onSlFocus"?: (event: CustomEvent<any>) => void; + /** + * Set to true to make the checkbox a required field. + */ + "required"?: boolean; /** * The checkbox's value attribute. */ @@ -1848,6 +1872,10 @@ declare namespace LocalJSX { * Set to true to add a clear button when the input is populated. */ "clearable"?: boolean; + /** + * Sets a custom validation message for the control. When this prop is not an empty string, the browser will assume the control is invalid and show this message as an error when the form is submitted. + */ + "customValidity"?: string; /** * Set to true to disable the input. */ @@ -1857,7 +1885,7 @@ declare namespace LocalJSX { */ "inputmode"?: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; /** - * Set to true to indicate that the user input is invalid. + * This will be true when the control is in an invalid state. Validity is determined by props such as `type`, `required`, `pattern`, and `customValidity` using the browser's constraint validation API. */ "invalid"?: boolean; /** @@ -1865,19 +1893,19 @@ declare namespace LocalJSX { */ "label"?: string; /** - * The input's max attribute. + * The input's maximum value. */ "max"?: number; /** - * The input's maxlength attribute. + * The maximum length of input that will be considered valid. */ "maxlength"?: number; /** - * The input's min attribute. + * The input's minimum value. */ "min"?: number; /** - * The input's minlength attribute. + * The minimum length of input that will be considered valid. */ "minlength"?: number; /** @@ -1905,7 +1933,15 @@ declare namespace LocalJSX { */ "onSlInput"?: (event: CustomEvent<any>) => void; /** - * The input's pattern attribute. + * Emitted when the value changes and the control is invalid. + */ + "onSlInvalid"?: (event: CustomEvent<any>) => void; + /** + * Emitted when the value changes and the control is valid. + */ + "onSlValid"?: (event: CustomEvent<any>) => void; + /** + * A pattern to validate input against. */ "pattern"?: string; /** @@ -1917,11 +1953,11 @@ declare namespace LocalJSX { */ "placeholder"?: string; /** - * Set to true for a readonly input. + * Set to true to make the input readonly. */ "readonly"?: boolean; /** - * The input's required attribute. + * Set to true to make the checkbox a required field. */ "required"?: boolean; /** @@ -1940,10 +1976,6 @@ declare namespace LocalJSX { * The input's type. */ "type"?: 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url'; - /** - * Set to true to indicate that the user input is valid. - */ - "valid"?: boolean; /** * The input's value attribute. */ @@ -2180,10 +2212,6 @@ declare namespace LocalJSX { * The select's size. */ "size"?: 'small' | 'medium' | 'large'; - /** - * Set to true to indicate that the user input is valid. - */ - "valid"?: boolean; /** * The value of the control. This will be a string or an array depending on `multiple`. */ @@ -2222,6 +2250,10 @@ declare namespace LocalJSX { * Emitted when the control gains focus. */ "onSlFocus"?: (event: CustomEvent<any>) => void; + /** + * Set to true to make the switch a required field. + */ + "required"?: boolean; /** * The switch's value attribute. */ @@ -2304,6 +2336,10 @@ declare namespace LocalJSX { * The textarea's autofocus attribute. */ "autofocus"?: boolean; + /** + * Sets a custom validation message for the control. When this prop is not an empty string, the browser will assume the control is invalid and show this message as an error when the form is submitted. + */ + "customValidity"?: string; /** * Set to true to disable the textarea. */ @@ -2313,7 +2349,7 @@ declare namespace LocalJSX { */ "inputmode"?: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; /** - * Set to true to indicate that the user input is invalid. + * This will be true when the control is in an invalid state. Validity is determined by props such as `type`, `required`, `pattern`, and `customValidity` using the browser's constraint validation API. */ "invalid"?: boolean; /** @@ -2321,9 +2357,13 @@ declare namespace LocalJSX { */ "label"?: string; /** - * The textarea's maxlength attribute. + * The maximum length of input that will be considered valid. */ "maxlength"?: number; + /** + * The minimum length of input that will be considered valid. + */ + "minlength"?: number; /** * The textarea's name attribute. */ @@ -2344,6 +2384,14 @@ declare namespace LocalJSX { * Emitted when the control receives input. */ "onSlInput"?: (event: CustomEvent<any>) => void; + /** + * Emitted when the value changes and the control is invalid. + */ + "onSlInvalid"?: (event: CustomEvent<any>) => void; + /** + * Emitted when the value changes and the control is valid. + */ + "onSlValid"?: (event: CustomEvent<any>) => void; /** * The textarea's placeholder text. */ @@ -2369,9 +2417,9 @@ declare namespace LocalJSX { */ "size"?: 'small' | 'medium' | 'large'; /** - * Set to true to indicate that the user input is valid. + * The textarea's spellcheck attribute. */ - "valid"?: boolean; + "spellcheck"?: boolean; /** * The textarea's value attribute. */ diff --git a/src/components/checkbox/checkbox.tsx b/src/components/checkbox/checkbox.tsx index f385db58..a1d23e12 100644 --- a/src/components/checkbox/checkbox.tsx +++ b/src/components/checkbox/checkbox.tsx @@ -36,6 +36,9 @@ export class Checkbox { /** Set to true to disable the checkbox. */ @Prop() disabled = false; + /** Set to true to make the checkbox a required field. */ + @Prop() required = false; + /** Set to true to draw the checkbox in a checked state. */ @Prop({ mutable: true, reflect: true }) checked = false; @@ -155,6 +158,7 @@ export class Checkbox { value={this.value} checked={this.checked} disabled={this.disabled} + required={this.required} role="checkbox" aria-checked={this.checked} aria-labelledby={this.labelId} diff --git a/src/components/form/form.tsx b/src/components/form/form.tsx index 90b0375d..efb3f55f 100644 --- a/src/components/form/form.tsx +++ b/src/components/form/form.tsx @@ -188,13 +188,27 @@ export class Form { .filter(el => tags.includes(el.tagName.toLowerCase())) as HTMLElement[]; } - /** Submits the form. */ + /** + * Submits the form. If all controls are valid, the `slSubmit` event will be emitted and the promise will resolve with + * `true`. If any form control is invalid, the promise will resolve with `false` and no event will be emitted. + */ @Method() async submit() { const formData = await this.getFormData(); const formControls = await this.getFormControls(); + const formControlsThatReport = formControls.filter((el: any) => typeof el.reportValidity === 'function') as any; + + for (const el of formControlsThatReport) { + const isValid = await el.reportValidity(); + + if (!isValid) { + return false; + } + } this.slSubmit.emit({ formData, formControls }); + + return true; } handleClick(event: MouseEvent) { diff --git a/src/components/input/input.scss b/src/components/input/input.scss index 5e919986..178db0cf 100644 --- a/src/components/input/input.scss +++ b/src/components/input/input.scss @@ -2,7 +2,11 @@ @import 'form-control-label'; @import 'form-control-help-text'; +/** + * @prop --focus-ring: The focus ring style to use when the control receives focus, a `box-shadow` property. + */ :host { + --focus-ring: var(--sl-focus-ring-box-shadow); display: block; } @@ -35,7 +39,7 @@ &.input--focused:not(.input--disabled) { background-color: var(--sl-input-background-color-focus); border-color: var(--sl-input-border-color-focus); - box-shadow: var(--sl-focus-ring-box-shadow); + box-shadow: var(--focus-ring); .input__control { color: var(--sl-input-color-focus); @@ -56,33 +60,6 @@ } } } - - &.input--valid:not(.input--disabled) { - border-color: var(--sl-input-border-color-valid); - - .input__control { - color: var(--sl-input-color-valid); - } - - &.input--focused { - box-shadow: 0 0 0 var(--sl-focus-ring-width) - hsla(var(--sl-color-success-hue), var(--sl-color-success-saturation), 50%, var(--sl-focus-ring-alpha)); - border-color: var(--sl-input-border-color-valid); - } - } - - &.input--invalid:not(.input--disabled) { - border-color: var(--sl-color-danger-50); - - .input__control { - color: var(--sl-input-color-invalid); - } - - &.input--focused { - box-shadow: 0 0 0 var(--sl-focus-ring-width) - hsla(var(--sl-color-danger-hue), var(--sl-color-danger-saturation), 50%, var(--sl-focus-ring-alpha)); - } - } } .input__control { diff --git a/src/components/input/input.tsx b/src/components/input/input.tsx index 87cd91de..783be3c5 100644 --- a/src/components/input/input.tsx +++ b/src/components/input/input.tsx @@ -1,4 +1,4 @@ -import { Component, Element, Event, EventEmitter, Method, Prop, State, h } from '@stencil/core'; +import { Component, Element, Event, EventEmitter, Method, Prop, State, Watch, h } from '@stencil/core'; let id = 0; @@ -16,7 +16,7 @@ let id = 0; * @part base - The component's base wrapper. * @part form-control - The form control that wraps the label and the input. * @part label - The input label. - * @part input - The synthetic input container. + * @part input - The input control. * @part prefix - The input prefix container. * @part clear-button - The clear button. * @part password-toggle-button - The password toggle button. @@ -41,19 +41,19 @@ export class Input { @State() isPasswordVisible = false; /** The input's type. */ - @Prop() type: 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url' = 'text'; + @Prop({ reflect: true }) type: 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url' = 'text'; /** The input's size. */ - @Prop() size: 'small' | 'medium' | 'large' = 'medium'; + @Prop({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; /** The input's name attribute. */ - @Prop() name = ''; + @Prop({ reflect: true }) name = ''; /** The input's value attribute. */ - @Prop({ mutable: true }) value: string = ''; + @Prop({ mutable: true, reflect: true }) value: string = ''; /** Set to true to draw a pill-style input with rounded edges. */ - @Prop() pill = false; + @Prop({ reflect: true }) pill = false; /** The input's label. */ @Prop() label = ''; @@ -62,25 +62,31 @@ export class Input { @Prop() placeholder: string; /** Set to true to disable the input. */ - @Prop() disabled = false; + @Prop({ reflect: true }) disabled = false; - /** Set to true for a readonly input. */ - @Prop() readonly = false; + /** Set to true to make the input readonly. */ + @Prop({ reflect: true }) readonly = false; - /** The input's minlength attribute. */ - @Prop() minlength: number; + /** The minimum length of input that will be considered valid. */ + @Prop({ reflect: true }) minlength: number; - /** The input's maxlength attribute. */ - @Prop() maxlength: number; + /** The maximum length of input that will be considered valid. */ + @Prop({ reflect: true }) maxlength: number; - /** The input's min attribute. */ - @Prop() min: number; + /** The input's minimum value. */ + @Prop({ reflect: true }) min: number; - /** The input's max attribute. */ - @Prop() max: number; + /** The input's maximum value. */ + @Prop({ reflect: true }) max: number; /** The input's step attribute. */ - @Prop() step: number; + @Prop({ reflect: true }) step: number; + + /** A pattern to validate input against. */ + @Prop({ reflect: true }) pattern: string; + + /** Set to true to make the checkbox a required field. */ + @Prop({ reflect: true }) required: boolean; /** The input's autocaptialize attribute. */ @Prop() autocapitalize: string; @@ -94,11 +100,17 @@ export class Input { /** The input's autofocus attribute. */ @Prop() autofocus: boolean; - /** The input's pattern attribute. */ - @Prop() pattern: string; + /** + * This will be true when the control is in an invalid state. Validity is determined by props such as `type`, + * `required`, `pattern`, and `customValidity` using the browser's constraint validation API. + */ + @Prop({ mutable: true, reflect: true }) invalid = false; - /** The input's required attribute. */ - @Prop() required: boolean; + /** + * Sets a custom validation message for the control. When this prop is not an empty string, the browser will assume + * the control is invalid and show this message as an error when the form is submitted. + */ + @Prop() customValidity = ''; /** Set to true to add a clear button when the input is populated. */ @Prop() clearable = false; @@ -109,11 +121,21 @@ export class Input { /** The input's inputmode attribute. */ @Prop() inputmode: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; - /** Set to true to indicate that the user input is valid. */ - @Prop() valid = false; + @Watch('value') + handleValueChange() { + this.invalid = !this.input.checkValidity(); + } - /** Set to true to indicate that the user input is invalid. */ - @Prop() invalid = false; + @Watch('invalid') + handleInvalidChange() { + this.invalid ? this.slInvalid.emit() : this.slValid.emit(); + } + + @Watch('customValidity') + handleCustomValidityChange() { + this.input.setCustomValidity(this.customValidity); + this.invalid = !this.input.checkValidity(); + } /** Emitted when the control's value changes. */ @Event() slChange: EventEmitter; @@ -130,9 +152,16 @@ export class Input { /** Emitted when the control loses focus. */ @Event() slBlur: EventEmitter; + /** Emitted when the value changes and the control is valid. */ + @Event() slValid: EventEmitter; + + /** Emitted when the value changes and the control is invalid. */ + @Event() slInvalid: EventEmitter; + connectedCallback() { this.handleChange = this.handleChange.bind(this); this.handleInput = this.handleInput.bind(this); + this.handleInvalid = this.handleInvalid.bind(this); this.handleBlur = this.handleBlur.bind(this); this.handleFocus = this.handleFocus.bind(this); this.handleClearClick = this.handleClearClick.bind(this); @@ -140,6 +169,10 @@ export class Input { this.handlePasswordToggle = this.handlePasswordToggle.bind(this); } + componentDidLoad() { + this.input.setCustomValidity(this.customValidity); + } + /** Sets focus on the input. */ @Method() async setFocus() { @@ -185,6 +218,12 @@ export class Input { } } + /** Checks for validity and shows the browser's validation message if the control is invalid. */ + @Method() + async reportValidity() { + return this.input.reportValidity(); + } + handleChange() { this.value = this.input.value; this.slChange.emit(); @@ -195,6 +234,10 @@ export class Input { this.slInput.emit(); } + handleInvalid() { + this.invalid = true; + } + handleBlur() { this.hasFocus = false; this.slBlur.emit(); @@ -237,7 +280,6 @@ export class Input { class={{ 'form-control': true, 'form-control--has-label': this.label.length > 0, - 'form-control--valid': this.valid, 'form-control--invalid': this.invalid }} > @@ -248,7 +290,6 @@ export class Input { 'label--small': this.size === 'small', 'label--medium': this.size === 'medium', 'label--large': this.size === 'large', - 'label--valid': this.valid, 'label--invalid': this.invalid }} htmlFor={this.inputId} @@ -271,7 +312,6 @@ export class Input { 'input--disabled': this.disabled, 'input--focused': this.hasFocus, 'input--empty': this.value?.length === 0, - 'input--valid': this.valid, 'input--invalid': this.invalid }} onMouseDown={this.handleMouseDown} @@ -305,8 +345,10 @@ export class Input { inputMode={this.inputmode} aria-labelledby={this.labelId} aria-describedby={this.helpTextId} + aria-invalid={this.invalid} onChange={this.handleChange} onInput={this.handleInput} + onInvalid={this.handleInvalid} onFocus={this.handleFocus} onBlur={this.handleBlur} /> @@ -359,7 +401,6 @@ export class Input { 'help-text--small': this.size === 'small', 'help-text--medium': this.size === 'medium', 'help-text--large': this.size === 'large', - 'help-text--valid': this.valid, 'help-text--invalid': this.invalid }} > diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx index 182e8942..241fb5e9 100644 --- a/src/components/select/select.tsx +++ b/src/components/select/select.tsx @@ -13,10 +13,10 @@ let id = 0; * * @part base - The component's base wrapper. * @part form-control - The form control that wraps the label and the input. - * @part help-text - The select help text. - * @part icon - The select icon. - * @part input - The select input. - * @part label - The input label. + * @part help-text - The select's help text. + * @part icon - The select's icon. + * @part input - The select's input control. + * @part label - The select's label. * @part menu - The select menu, a <sl-menu> element. * @part tag - The multiselect option, a <sl-tag> element. * @part tags - The container in which multiselect options are rendered. @@ -81,16 +81,13 @@ export class Select { @Prop() label = ''; /** The select's required attribute. */ - @Prop() required: boolean; + @Prop() required = false; /** Set to true to add a clear button when the select is populated. */ @Prop() clearable = false; - /** Set to true to indicate that the user input is valid. */ - @Prop() valid = false; - /** Set to true to indicate that the user input is invalid. */ - @Prop() invalid = false; + @Prop({ mutable: true }) invalid = false; @Watch('multiple') handleMultipleChange() { @@ -325,7 +322,6 @@ export class Select { class={{ 'form-control': true, 'form-control--has-label': this.label.length > 0, - 'form-control--valid': this.valid, 'form-control--invalid': this.invalid }} > @@ -337,7 +333,6 @@ export class Select { 'label--small': this.size === 'small', 'label--medium': this.size === 'medium', 'label--large': this.size === 'large', - 'label--valid': this.valid, 'label--invalid': this.invalid }} htmlFor={this.inputId} @@ -381,7 +376,6 @@ export class Select { placeholder={this.displayLabel === '' && this.displayTags.length === 0 ? this.placeholder : null} readonly={true} size={this.size} - valid={this.valid} invalid={this.invalid} clearable={this.clearable} required={this.required} @@ -422,7 +416,6 @@ export class Select { 'help-text--small': this.size === 'small', 'help-text--medium': this.size === 'medium', 'help-text--large': this.size === 'large', - 'help-text--valid': this.valid, 'help-text--invalid': this.invalid }} > diff --git a/src/components/switch/switch.tsx b/src/components/switch/switch.tsx index d2298fd9..fbf44054 100644 --- a/src/components/switch/switch.tsx +++ b/src/components/switch/switch.tsx @@ -35,6 +35,9 @@ export class Switch { /** Set to true to disable the switch. */ @Prop() disabled = false; + /** Set to true to make the switch a required field. */ + @Prop() required = false; + /** Set to true to draw the switch in a checked state. */ @Prop({ mutable: true, reflect: true }) checked = false; @@ -129,6 +132,7 @@ export class Switch { value={this.value} checked={this.checked} disabled={this.disabled} + required={this.required} role="switch" aria-checked={this.checked} aria-labelledby={this.labelId} diff --git a/src/components/textarea/textarea.scss b/src/components/textarea/textarea.scss index 149b974a..4fab42b4 100644 --- a/src/components/textarea/textarea.scss +++ b/src/components/textarea/textarea.scss @@ -55,33 +55,6 @@ } } } - - &.textarea--valid:not(.textarea--disabled) { - border-color: var(--sl-input-border-color-valid); - - .textarea__control { - color: var(--sl-input-color-valid); - } - - &.textarea--focused { - box-shadow: 0 0 0 var(--sl-focus-ring-width) - hsla(var(--sl-color-success-hue), var(--sl-color-success-saturation), 50%, var(--sl-focus-ring-alpha)); - border-color: var(--sl-input-border-color-valid); - } - } - - &.textarea--invalid:not(.textarea--disabled) { - border-color: var(--sl-color-danger-50); - - .textarea__control { - color: var(--sl-input-color-invalid); - } - - &.textarea--focused { - box-shadow: 0 0 0 var(--sl-focus-ring-width) - hsla(var(--sl-color-danger-hue), var(--sl-color-danger-saturation), 50%, var(--sl-focus-ring-alpha)); - } - } } .textarea__control { diff --git a/src/components/textarea/textarea.tsx b/src/components/textarea/textarea.tsx index 6f9bf718..cf6e8cee 100644 --- a/src/components/textarea/textarea.tsx +++ b/src/components/textarea/textarea.tsx @@ -31,13 +31,13 @@ export class Textarea { @State() hasFocus = false; /** The textarea's size. */ - @Prop() size: 'small' | 'medium' | 'large' = 'medium'; + @Prop({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; /** The textarea's name attribute. */ - @Prop() name = ''; + @Prop({ reflect: true }) name = ''; /** The textarea's value attribute. */ - @Prop({ mutable: true }) value = ''; + @Prop({ mutable: true, reflect: true }) value = ''; /** The textarea's label. */ @Prop() label = ''; @@ -45,17 +45,38 @@ export class Textarea { /** The textarea's placeholder text. */ @Prop() placeholder: string; - /** Set to true to disable the textarea. */ - @Prop() disabled = false; - - /** Set to true for a readonly textarea. */ - @Prop() readonly = false; + /** The number of rows to display by default. */ + @Prop() rows = 4; /** Controls how the textarea can be resized. */ @Prop() resize: 'none' | 'vertical' | 'auto' = 'vertical'; - /** The textarea's maxlength attribute. */ - @Prop() maxlength: number; + /** Set to true to disable the textarea. */ + @Prop({ reflect: true }) disabled = false; + + /** Set to true for a readonly textarea. */ + @Prop({ reflect: true }) readonly = false; + + /** The minimum length of input that will be considered valid. */ + @Prop({ reflect: true }) minlength: number; + + /** The maximum length of input that will be considered valid. */ + @Prop({ reflect: true }) maxlength: number; + + /** The textarea's required attribute. */ + @Prop({ reflect: true }) required: boolean; + + /** + * This will be true when the control is in an invalid state. Validity is determined by props such as `type`, + * `required`, `pattern`, and `customValidity` using the browser's constraint validation API. + */ + @Prop({ mutable: true, reflect: true }) invalid = false; + + /** + * Sets a custom validation message for the control. When this prop is not an empty string, the browser will assume + * the control is invalid and show this message as an error when the form is submitted. + */ + @Prop() customValidity = ''; /** The textarea's autocaptialize attribute. */ @Prop() autocapitalize: string; @@ -69,21 +90,12 @@ export class Textarea { /** The textarea's autofocus attribute. */ @Prop() autofocus: boolean; - /** The textarea's required attribute. */ - @Prop() required: boolean; + /** The textarea's spellcheck attribute. */ + @Prop() spellcheck: boolean; /** The textarea's inputmode attribute. */ @Prop() inputmode: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; - /** Set to true to indicate that the user input is valid. */ - @Prop() valid = false; - - /** Set to true to indicate that the user input is invalid. */ - @Prop() invalid = false; - - /** The number of rows to display by default. */ - @Prop() rows = 4; - /** Emitted when the control's value changes. */ @Event() slChange: EventEmitter; @@ -101,6 +113,28 @@ export class Textarea { this.setTextareaHeight(); } + @Watch('value') + handleValueChange() { + this.invalid = !this.textarea.checkValidity(); + } + + @Watch('invalid') + handleInvalidChange() { + this.invalid ? this.slInvalid.emit() : this.slValid.emit(); + } + + @Watch('customValidity') + handleCustomValidityChange() { + this.textarea.setCustomValidity(this.customValidity); + this.invalid = !this.textarea.checkValidity(); + } + + /** Emitted when the value changes and the control is valid. */ + @Event() slValid: EventEmitter; + + /** Emitted when the value changes and the control is invalid. */ + @Event() slInvalid: EventEmitter; + connectedCallback() { this.handleChange = this.handleChange.bind(this); this.handleInput = this.handleInput.bind(this); @@ -109,6 +143,7 @@ export class Textarea { } componentDidLoad() { + this.textarea.setCustomValidity(this.customValidity); this.setTextareaHeight(); this.resizeObserver = new ResizeObserver(() => this.setTextareaHeight()); this.resizeObserver.observe(this.textarea); @@ -164,6 +199,12 @@ export class Textarea { } } + /** Checks for validity and shows the browser's validation message if the control is invalid. */ + @Method() + async reportValidity() { + return this.textarea.reportValidity(); + } + handleChange() { this.slChange.emit(); } @@ -200,7 +241,6 @@ export class Textarea { class={{ 'form-control': true, 'form-control--has-label': this.label.length > 0, - 'form-control--valid': this.valid, 'form-control--invalid': this.invalid }} > @@ -211,7 +251,6 @@ export class Textarea { 'label--small': this.size === 'small', 'label--medium': this.size === 'medium', 'label--large': this.size === 'large', - 'label--valid': this.valid, 'label--invalid': this.invalid }} htmlFor={this.textareaId} @@ -232,7 +271,6 @@ export class Textarea { 'textarea--disabled': this.disabled, 'textarea--focused': this.hasFocus, 'textarea--empty': this.value?.length === 0, - 'textarea--valid': this.valid, 'textarea--invalid': this.invalid, // Modifiers @@ -251,11 +289,13 @@ export class Textarea { disabled={this.disabled} readOnly={this.readonly} rows={this.rows} + minLength={this.minlength} maxLength={this.maxlength} value={this.value} autoCapitalize={this.autocapitalize} autoCorrect={this.autocorrect} autoFocus={this.autofocus} + spellcheck={this.spellcheck} required={this.required} inputMode={this.inputmode} aria-labelledby={this.labelId} @@ -274,7 +314,6 @@ export class Textarea { 'help-text--small': this.size === 'small', 'help-text--medium': this.size === 'medium', 'help-text--large': this.size === 'large', - 'help-text--valid': this.valid, 'help-text--invalid': this.invalid }} > diff --git a/src/styles/shoelace.scss b/src/styles/shoelace.scss index 8c064f6f..1e0fa5c4 100644 --- a/src/styles/shoelace.scss +++ b/src/styles/shoelace.scss @@ -168,8 +168,6 @@ --sl-input-border-color-hover: var(--sl-color-gray-70); --sl-input-border-color-focus: var(--sl-color-primary-50); --sl-input-border-color-disabled: var(--sl-color-gray-80); - --sl-input-border-color-valid: var(--sl-color-success-50); - --sl-input-border-color-invalid: var(--sl-color-danger-50); --sl-input-border-width: 1px; --sl-input-border-radius-small: var(--sl-border-radius-medium); @@ -187,8 +185,6 @@ --sl-input-color-hover: var(--sl-color-gray-30); --sl-input-color-focus: var(--sl-color-gray-30); --sl-input-color-disabled: var(--sl-color-gray-10); - --sl-input-color-valid: var(var(--sl-color-gray-30)); - --sl-input-color-invalid: var(var(--sl-color-gray-30)); --sl-input-icon-color: var(--sl-color-gray-60); --sl-input-icon-color-hover: var(--sl-color-gray-40); @@ -207,8 +203,6 @@ --sl-input-label-font-size-large: var(--sl-font-size-large); --sl-input-label-color: inherit; - --sl-input-label-color-valid: inherit; - --sl-input-label-color-invalid: inherit; // Help text --sl-input-help-text-font-size-small: var(--sl-font-size-x-small); @@ -216,8 +210,6 @@ --sl-input-help-text-font-size-large: var(--sl-font-size-medium); --sl-input-help-text-color: var(--sl-color-gray-60); - --sl-input-help-text-color-valid: var(--sl-color-success-40); - --sl-input-help-text-color-invalid: var(--sl-color-danger-40); // Toggles (checkboxes, radios, switches) --sl-toggle-size: 16px;