feat(switch): add required and form validity

Swapped switch's interactive element back to an `<input>` to more easily support platform validation messages.

PiperOrigin-RevId: 559671594
This commit is contained in:
Elizabeth Mitchell 2023-08-24 00:54:16 -07:00 committed by Copybara-Service
parent b542d2aa12
commit 9694191ec0
8 changed files with 242 additions and 120 deletions

View File

@ -14,6 +14,6 @@ import {Switch} from './internal/switch.js';
export class SwitchHarness extends Harness<Switch> {
protected override async getInteractiveElement() {
await this.element.updateComplete;
return this.element.renderRoot.querySelector<HTMLElement>('.switch')!;
return this.element.renderRoot.querySelector('input')!;
}
}

View File

@ -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),

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Switch>) {
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`
<button
id="switch"
type="button"
class="switch ${classMap(this.getRenderClasses())}"
role="switch"
aria-checked="${this.selected}"
aria-label=${(this as ARIAMixin).ariaLabel || nothing}
?disabled=${this.disabled}
@click=${this.handleClick}
>
<md-focus-ring part="focus-ring"></md-focus-ring>
<div class="switch ${classMap(this.getRenderClasses())}">
<input
id="switch"
class="touch"
type="checkbox"
role="switch"
aria-label=${(this as ARIAMixin).ariaLabel || nothing}
?checked=${this.selected}
?disabled=${this.disabled}
?required=${this.required}
@change=${this.handleChange}
>
<md-focus-ring part="focus-ring" for="switch"></md-focus-ring>
<span class="track">
${this.renderHandle()}
</span>
</button>
</div>
`;
}
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 <input>'s validity and the host's ElementInternals
// validity. We do this to re-use native `<input>` validation messages.
const input = this.getInput();
if (this.internals.validity.customError) {
input.setCustomValidity(this.internals.validationMessage);
} else {
input.setCustomValidity('');
}
this.selected = !this.selected;
this.dispatchEvent(
new InputEvent('input', {bubbles: true, composed: true}));
// Bubbles but does not compose to mimic native browser <input> & <select>
// Additionally, native change event is not an InputEvent.
this.dispatchEvent(new Event('change', {bubbles: true}));
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();
}
return this.input!;
}
/** @private */

View File

@ -76,30 +76,20 @@ describe('md-switch', () => {
expect(toggle.selected).toBeFalse();
});
it('sets `aria-checked` of button', () => {
const toggleButton = toggle.shadowRoot!.querySelector('button')!;
expect(toggleButton.getAttribute('aria-checked')).toEqual('false');
const selectedButton = selected.shadowRoot!.querySelector('button')!;
expect(selectedButton.getAttribute('aria-checked')).toEqual('true');
});
it('adds switch--selected class when true', () => {
it('adds selected class when true', () => {
const toggleRoot = toggle.shadowRoot!.querySelector('.switch')!;
expect(Array.from(toggleRoot.classList))
.not.toContain('switch--selected');
expect(Array.from(toggleRoot.classList)).not.toContain('selected');
const selectedRoot = selected.shadowRoot!.querySelector('.switch')!;
expect(Array.from(selectedRoot.classList)).toContain('switch--selected');
expect(Array.from(selectedRoot.classList)).toContain('selected');
});
it('adds switch--unselected class when false', () => {
it('adds unselected class when false', () => {
const toggleRoot = toggle.shadowRoot!.querySelector('.switch')!;
expect(Array.from(toggleRoot.classList)).toContain('switch--unselected');
expect(Array.from(toggleRoot.classList)).toContain('unselected');
const selectedRoot = selected.shadowRoot!.querySelector('.switch')!;
expect(Array.from(selectedRoot.classList))
.not.toContain('switch--unselected');
expect(Array.from(selectedRoot.classList)).not.toContain('unselected');
});
});
@ -114,32 +104,32 @@ describe('md-switch', () => {
expect(toggle.disabled).toBeFalse();
});
it('sets disabled of button', () => {
const toggleButton = toggle.shadowRoot!.querySelector('button')!;
expect(toggleButton.disabled).toBeFalse();
it('sets disabled of input', () => {
const toggleInput = toggle.shadowRoot!.querySelector('input')!;
expect(toggleInput.disabled).toBeFalse();
const selectedButton = disabled.shadowRoot!.querySelector('button')!;
expect(selectedButton.disabled).toBeTrue();
const selectedInput = disabled.shadowRoot!.querySelector('input')!;
expect(selectedInput.disabled).toBeTrue();
});
});
describe('aria', () => {
it('delegates aria-label to the proper element', async () => {
const button = toggle.shadowRoot!.querySelector('button')!;
const input = toggle.shadowRoot!.querySelector('input')!;
toggle.setAttribute('aria-label', 'foo');
await toggle.updateComplete;
expect(toggle.ariaLabel).toEqual('foo');
expect(toggle.getAttribute('aria-label')).toEqual('foo');
expect(button.getAttribute('aria-label')).toEqual('foo');
expect(input.getAttribute('aria-label')).toEqual('foo');
});
it('delegates .ariaLabel to the proper element', async () => {
const button = toggle.shadowRoot!.querySelector('button')!;
const input = toggle.shadowRoot!.querySelector('input')!;
toggle.ariaLabel = 'foo';
await toggle.updateComplete;
expect(toggle.ariaLabel).toEqual('foo');
expect(toggle.getAttribute('aria-label')).toEqual('foo');
expect(button.getAttribute('aria-label')).toEqual('foo');
expect(input.getAttribute('aria-label')).toEqual('foo');
});
});
@ -225,4 +215,23 @@ describe('md-switch', () => {
});
});
});
describe('validation', () => {
it('should set valueMissing when required and not selected', async () => {
toggle.required = true;
expect(toggle.validity.valueMissing)
.withContext('toggle.validity.valueMissing')
.toBeTrue();
});
it('should not set valueMissing when required and selected', async () => {
toggle.required = true;
toggle.selected = true;
expect(toggle.validity.valueMissing)
.withContext('toggle.validity.valueMissing')
.toBeFalse();
});
});
});