From 9694191ec02bb37575df8d74af6e530f3e4e45e9 Mon Sep 17 00:00:00 2001 From: Elizabeth Mitchell Date: Thu, 24 Aug 2023 00:54:16 -0700 Subject: [PATCH] feat(switch): add required and form validity Swapped switch's interactive element back to an `` to more easily support platform validation messages. PiperOrigin-RevId: 559671594 --- switch/harness.ts | 2 +- switch/internal/_handle.scss | 40 ++--- switch/internal/_icon.scss | 24 +-- switch/internal/_switch.scss | 30 ++-- switch/internal/_track.scss | 24 +-- switch/internal/forced-colors-styles.scss | 1 + switch/internal/switch.ts | 182 ++++++++++++++++++---- switch/internal/switch_test.ts | 59 ++++--- 8 files changed, 242 insertions(+), 120 deletions(-) diff --git a/switch/harness.ts b/switch/harness.ts index 8b424ce65d..d8c6ec0bf9 100644 --- a/switch/harness.ts +++ b/switch/harness.ts @@ -14,6 +14,6 @@ import {Switch} from './internal/switch.js'; export class SwitchHarness extends Harness { protected override async getInteractiveElement() { await this.element.updateComplete; - return this.element.renderRoot.querySelector('.switch')!; + return this.element.renderRoot.querySelector('input')!; } } diff --git a/switch/internal/_handle.scss b/switch/internal/_handle.scss index bce6b55da5..bb617263de 100644 --- a/switch/internal/_handle.scss +++ b/switch/internal/_handle.scss @@ -25,15 +25,15 @@ $_easing-standard: map.get($_md-sys-motion, 'easing-standard'); $margin: calc(var(--_track-width) - var(--_track-height)); - .switch--selected .handle-container { + .selected .handle-container { margin-inline-start: $margin; } - .switch--unselected .handle-container { + .unselected .handle-container { margin-inline-end: $margin; } - .switch:disabled .handle-container { + .disabled .handle-container { transition: none; } @@ -63,12 +63,12 @@ $_easing-standard: map.get($_md-sys-motion, 'easing-standard'); transition: background-color 67ms linear; } - .switch:disabled .handle, - .switch:disabled .handle::before { + .disabled .handle, + .disabled .handle::before { transition: none; } - .switch--selected .handle { + .selected .handle { height: var(--_selected-handle-height); width: var(--_selected-handle-width); } @@ -78,52 +78,52 @@ $_easing-standard: map.get($_md-sys-motion, 'easing-standard'); width: var(--_with-icon-handle-width); } - .switch--selected:enabled:active .handle, - .switch--unselected:enabled:active .handle { + .selected:not(.disabled):active .handle, + .unselected:not(.disabled):active .handle { height: var(--_pressed-handle-height); width: var(--_pressed-handle-width); transition-timing-function: linear; transition-duration: 100ms; } - .switch--selected .handle::before { + .selected .handle::before { background-color: var(--_selected-handle-color); } - .switch--selected:hover .handle::before { + .selected:hover .handle::before { background-color: var(--_selected-hover-handle-color); } - .switch--selected:focus-within .handle::before { + .selected:focus-within .handle::before { background-color: var(--_selected-focus-handle-color); } - .switch--selected:active .handle::before { + .selected:active .handle::before { background-color: var(--_selected-pressed-handle-color); } - .switch--selected:disabled .handle::before { + .selected.disabled .handle::before { background-color: var(--_disabled-selected-handle-color); opacity: var(--_disabled-selected-handle-opacity); } - .switch--unselected .handle::before { + .unselected .handle::before { background-color: var(--_handle-color); } - .switch--unselected:hover .handle::before { + .unselected:hover .handle::before { background-color: var(--_hover-handle-color); } - .switch--unselected:focus-within .handle::before { + .unselected:focus-within .handle::before { background-color: var(--_focus-handle-color); } - .switch--unselected:active .handle::before { + .unselected:active .handle::before { background-color: var(--_pressed-handle-color); } - .switch--unselected:disabled .handle::before { + .unselected.disabled .handle::before { background-color: var(--_disabled-handle-color); opacity: var(--_disabled-handle-opacity); } @@ -135,7 +135,7 @@ $_easing-standard: map.get($_md-sys-motion, 'easing-standard'); width: var(--_state-layer-size); } - .switch--selected md-ripple { + .selected md-ripple { @include ripple.theme( ( 'hover-color': var(--_selected-hover-state-layer-color), @@ -146,7 +146,7 @@ $_easing-standard: map.get($_md-sys-motion, 'easing-standard'); ); } - .switch--unselected md-ripple { + .unselected md-ripple { @include ripple.theme( ( 'hover-color': var(--_hover-state-layer-color), diff --git a/switch/internal/_icon.scss b/switch/internal/_icon.scss index 57c4ee4c3c..6c2af4301b 100644 --- a/switch/internal/_icon.scss +++ b/switch/internal/_icon.scss @@ -30,17 +30,17 @@ $_easing-standard: map.get($_md-sys-motion, 'easing-standard'); opacity: 0; } - .switch:disabled .icon { + .disabled .icon { transition: none; } - .switch--selected .icon--on, - .switch--unselected .icon--off { + .selected .icon--on, + .unselected .icon--off { opacity: 1; } // rotate selected icon into view when there is no unselected icon - .switch--unselected .handle:not(.with-icon) .icon--on { + .unselected .handle:not(.with-icon) .icon--on { transform: rotate(-45deg); } @@ -50,19 +50,19 @@ $_easing-standard: map.get($_md-sys-motion, 'easing-standard'); fill: var(--_icon-color); } - .switch--unselected:hover .icon--off { + .unselected:hover .icon--off { fill: var(--_hover-icon-color); } - .switch--unselected:focus-within .icon--off { + .unselected:focus-within .icon--off { fill: var(--_focus-icon-color); } - .switch--unselected:active .icon--off { + .unselected:active .icon--off { fill: var(--_pressed-icon-color); } - .switch--unselected:disabled .icon--off { + .unselected.disabled .icon--off { fill: var(--_disabled-icon-color); opacity: var(--_disabled-icon-opacity); } @@ -73,19 +73,19 @@ $_easing-standard: map.get($_md-sys-motion, 'easing-standard'); fill: var(--_selected-icon-color); } - .switch--selected:hover .icon--on { + .selected:hover .icon--on { fill: var(--_selected-hover-icon-color); } - .switch--selected:focus-within .icon--on { + .selected:focus-within .icon--on { fill: var(--_selected-focus-icon-color); } - .switch--selected:active .icon--on { + .selected:active .icon--on { fill: var(--_selected-pressed-icon-color); } - .switch--selected:disabled .icon--on { + .selected.disabled .icon--on { fill: var(--_disabled-selected-icon-color); opacity: var(--_disabled-selected-icon-opacity); } diff --git a/switch/internal/_switch.scss b/switch/internal/_switch.scss index a9413b9747..420e364bda 100644 --- a/switch/internal/_switch.scss +++ b/switch/internal/_switch.scss @@ -106,14 +106,8 @@ .switch { align-items: center; - background: none; - border: none; - cursor: pointer; display: inline-flex; flex-shrink: 0; // Stop from collapsing in flex containers - margin: 0; - outline: none; - padding: 0; position: relative; width: var(--_track-width); height: var(--_track-height); @@ -125,34 +119,32 @@ border-end-start-radius: var(--_track-shape-end-start); } - // Touch target - .touch { - position: absolute; + // Input is also touch target + input { + appearance: none; height: 48px; + outline: none; + margin: 0; + position: absolute; width: 100%; + z-index: 1; } - :host([touch-target='none']) .touch { + :host([touch-target='none']) input { display: none; } - // Disabled - .switch:disabled { - cursor: default; - pointer-events: none; - } - // Disabled - Track - .switch:disabled .track { + .disabled .track { background-color: transparent; border-color: transparent; } - .switch:disabled .track::before { + .disabled .track::before { background-clip: content-box; } - .switch--selected:disabled .track { + .selected.disabled .track { background-clip: border-box; } diff --git a/switch/internal/_track.scss b/switch/internal/_track.scss index 526bb13bfe..717db3ceeb 100644 --- a/switch/internal/_track.scss +++ b/switch/internal/_track.scss @@ -36,55 +36,55 @@ transition-duration: 67ms; } - .switch:disabled .track::before, - .switch:disabled .track::after { + .disabled .track::before, + .disabled .track::after { transition: none; opacity: var(--_disabled-track-opacity); } - .switch--selected .track::before { + .selected .track::before { background-color: var(--_selected-track-color); } - .switch--selected:hover .track::before { + .selected:hover .track::before { background-color: var(--_selected-hover-track-color); } - .switch--selected:focus-within .track::before { + .selected:focus-within .track::before { background-color: var(--_selected-focus-track-color); } - .switch--selected:active .track::before { + .selected:active .track::before { background-color: var(--_selected-pressed-track-color); } - .switch--selected:disabled .track::before { + .selected.disabled .track::before { background-color: var(--_disabled-selected-track-color); } - .switch--unselected .track::before { + .unselected .track::before { background-color: var(--_track-color); border-color: var(--_track-outline-color); border-style: solid; border-width: var(--_track-outline-width); } - .switch--unselected:hover .track::before { + .unselected:hover .track::before { background-color: var(--_hover-track-color); border-color: var(--_hover-track-outline-color); } - .switch--unselected:focus-visible .track::before { + .unselected:focus-visible .track::before { background-color: var(--_focus-track-color); border-color: var(--_focus-track-outline-color); } - .switch--unselected:active .track::before { + .unselected:active .track::before { background-color: var(--_pressed-track-color); border-color: var(--_pressed-track-outline-color); } - .switch--unselected:disabled .track::before { + .unselected.disabled .track::before { background-color: var(--_disabled-track-color); border-color: var(--_disabled-track-outline-color); } diff --git a/switch/internal/forced-colors-styles.scss b/switch/internal/forced-colors-styles.scss index 6edf97d1ef..85a36dd5a8 100644 --- a/switch/internal/forced-colors-styles.scss +++ b/switch/internal/forced-colors-styles.scss @@ -14,6 +14,7 @@ 'disabled-selected-icon-color': GrayText, 'disabled-selected-icon-opacity': 1, 'disabled-selected-track-color': GrayText, + 'disabled-track-outline-color': GrayText, 'disabled-track-opacity': 1, 'disabled-handle-color': GrayText, 'disabled-handle-opacity': 1, diff --git a/switch/internal/switch.ts b/switch/internal/switch.ts index 904541fae9..8579b7317b 100644 --- a/switch/internal/switch.ts +++ b/switch/internal/switch.ts @@ -12,7 +12,7 @@ import {property, query} from 'lit/decorators.js'; import {ClassInfo, classMap} from 'lit/directives/class-map.js'; import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js'; -import {dispatchActivationClick, isActivationClick} from '../../internal/controller/events.js'; +import {dispatchActivationClick, isActivationClick, redispatchEvent} from '../../internal/controller/events.js'; /** * @fires input {InputEvent} Fired whenever `selected` changes due to user @@ -55,8 +55,13 @@ export class Switch extends LitElement { @property({type: Boolean, attribute: 'show-only-selected-icon'}) showOnlySelectedIcon = false; - // Button - @query('button') private readonly button!: HTMLButtonElement|null; + /** + * When true, require the switch to be selected when participating in + * form submission. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#validation + */ + @property({type: Boolean}) required = false; /** * The value associated with this switch on form submission. `null` is @@ -88,6 +93,42 @@ export class Switch extends LitElement { return this.internals.labels; } + /** + * Returns a ValidityState object that represents the validity states of the + * switch. + * + * Note that switches will only set `valueMissing` if `required` and not + * selected. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#validation + */ + get validity() { + this.syncValidity(); + return this.internals.validity; + } + + /** + * Returns the native validation error message. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation#constraint_validation_process + */ + get validationMessage() { + this.syncValidity(); + return this.internals.validationMessage; + } + + /** + * Returns whether an element will successfully validate based on forms + * validation rules and constraints. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation#constraint_validation_process + */ + get willValidate() { + this.syncValidity(); + return this.internals.willValidate; + } + + @query('input') private readonly input!: HTMLInputElement|null; private readonly internals = (this as HTMLElement /* needed for closure */).attachInternals(); @@ -98,15 +139,60 @@ export class Switch extends LitElement { if (!isActivationClick(event)) { return; } - this.button?.focus(); - if (this.button != null) { - // this triggers the click behavior, and the ripple - dispatchActivationClick(this.button); - } + this.focus(); + dispatchActivationClick(this.input!); }); } } + /** + * Checks the switch's native validation and returns whether or not the + * element is valid. + * + * If invalid, this method will dispatch the `invalid` event. + * + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/checkValidity + * + * @return true if the switch is valid, or false if not. + */ + checkValidity() { + this.syncValidity(); + return this.internals.checkValidity(); + } + + /** + * Checks the switch's native validation and returns whether or not the + * element is valid. + * + * If invalid, this method will dispatch the `invalid` event. + * + * The `validationMessage` is reported to the user by the browser. Use + * `setCustomValidity()` to customize the `validationMessage`. + * + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/reportValidity + * + * @return true if the switch is valid, or false if not. + */ + reportValidity() { + this.syncValidity(); + return this.internals.reportValidity(); + } + + /** + * Sets the switch's native validation error message. This is used to + * customize `validationMessage`. + * + * When the error is not an empty string, the switch is considered invalid + * and `validity.customError` will be true. + * + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setCustomValidity + * + * @param error The error message to display. + */ + setCustomValidity(error: string) { + this.internals.setValidity({customError: !!error}, error, this.getInput()); + } + protected override update(changed: PropertyValues) { const state = String(this.selected); this.internals.setFormValue(this.selected ? this.value : null, state); @@ -118,28 +204,38 @@ export class Switch extends LitElement { // content](https://html.spec.whatwg.org/multipage/dom.html#phrasing-content) // children, which includes custom elements, but not `div`s return html` - + `; } + protected override updated() { + // Sync validity when properties change, since validation properties may + // have changed. + this.syncValidity(); + } + private getRenderClasses(): ClassInfo { return { - 'switch--selected': this.selected, - 'switch--unselected': !this.selected, + 'selected': this.selected, + 'unselected': !this.selected, + 'disabled': this.disabled, }; } @@ -197,17 +293,41 @@ export class Switch extends LitElement { return this.icons || this.showOnlySelectedIcon; } - private handleClick() { - if (this.disabled) { - return; + private handleChange(event: Event) { + const target = event.target as HTMLInputElement; + this.selected = target.checked; + redispatchEvent(this, event); + } + + private syncValidity() { + // Sync the internal 's validity and the host's ElementInternals + // validity. We do this to re-use native `` validation messages. + const input = this.getInput(); + if (this.internals.validity.customError) { + input.setCustomValidity(this.internals.validationMessage); + } else { + input.setCustomValidity(''); + } + + this.internals.setValidity( + input.validity, input.validationMessage, this.getInput()); + } + + private getInput() { + if (!this.input) { + // If the input is not yet defined, synchronously render. + this.connectedCallback(); + this.performUpdate(); + } + + if (this.isUpdatePending) { + // If there are pending updates, synchronously perform them. This ensures + // that constraint validation properties (like `required`) are synced + // before interacting with input APIs that depend on them. + this.scheduleUpdate(); } - this.selected = !this.selected; - this.dispatchEvent( - new InputEvent('input', {bubbles: true, composed: true})); - // Bubbles but does not compose to mimic native browser &