/** * @license * Copyright 2021 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import '../../focus/md-focus-ring.js'; import '../../ripple/ripple.js'; import {html, isServer, LitElement, nothing, TemplateResult} from 'lit'; 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, redispatchEvent, } from '../../internal/controller/events.js'; import { internals, mixinElementInternals, } from '../../labs/behaviors/element-internals.js'; import { getFormState, getFormValue, mixinFormAssociated, } from '../../labs/behaviors/form-associated.js'; // Separate variable needed for closure. const switchBaseClass = mixinFormAssociated(mixinElementInternals(LitElement)); /** * @fires input {InputEvent} Fired whenever `selected` changes due to user * interaction (bubbles and composed). * @fires change {Event} Fired whenever `selected` changes due to user * interaction (bubbles). */ export class Switch extends switchBaseClass { static { requestUpdateOnAriaChange(Switch); } /** @nocollapse */ static override shadowRootOptions: ShadowRootInit = { mode: 'open', delegatesFocus: true, }; /** * Puts the switch in the selected state and sets the form submission value to * the `value` property. */ @property({type: Boolean}) selected = false; /** * Shows both the selected and deselected icons. */ @property({type: Boolean}) icons = false; /** * Shows only the selected icon, and not the deselected icon. If `true`, * overrides the behavior of the `icons` property. */ @property({type: Boolean, attribute: 'show-only-selected-icon'}) showOnlySelectedIcon = false; /** * 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 * submitted when `selected` is `false`. */ @property() value = 'on'; /** * 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; // Needed for Safari, see https://bugs.webkit.org/show_bug.cgi?id=261432 // Replace with this[internals].validity.customError when resolved. private hasCustomValidityError = false; constructor() { super(); if (!isServer) { this.addEventListener('click', (event: MouseEvent) => { if (!isActivationClick(event)) { return; } 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.hasCustomValidityError = !!error; this[internals].setValidity({customError: !!error}, error, this.getInput()); } protected override render(): TemplateResult { // NOTE: buttons must use only [phrasing // content](https://html.spec.whatwg.org/multipage/dom.html#phrasing-content) // children, which includes custom elements, but not `div`s return html`
${this.renderHandle()}
`; } protected override updated() { // Sync validity when properties change, since validation properties may // have changed. this.syncValidity(); } private getRenderClasses(): ClassInfo { return { 'selected': this.selected, 'unselected': !this.selected, 'disabled': this.disabled, }; } private renderHandle() { const classes = { 'with-icon': this.showOnlySelectedIcon ? this.selected : this.icons, }; return html` ${this.renderTouchTarget()} ${this.shouldShowIcons() ? this.renderIcons() : html``} `; } private renderIcons() { return html`
${this.renderOnIcon()} ${this.showOnlySelectedIcon ? html`` : this.renderOffIcon()}
`; } /** * https://fonts.google.com/icons?selected=Material%20Symbols%20Outlined%3Acheck%3AFILL%400%3Bwght%40500%3BGRAD%400%3Bopsz%4024 */ private renderOnIcon() { return html` `; } /** * https://fonts.google.com/icons?selected=Material%20Symbols%20Outlined%3Aclose%3AFILL%400%3Bwght%40500%3BGRAD%400%3Bopsz%4024 */ private renderOffIcon() { return html` `; } private renderTouchTarget() { return html``; } private shouldShowIcons(): boolean { return this.icons || this.showOnlySelectedIcon; } 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.hasCustomValidityError) { 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(); } return this.input!; } // Writable mixin properties for lit-html binding, needed for lit-analyzer declare disabled: boolean; declare name: string; override [getFormValue]() { return this.selected ? this.value : null; } override [getFormState]() { return String(this.selected); } override formResetCallback() { // The selected property does not reflect, so the original attribute set by // the user is used to determine the default value. this.selected = this.hasAttribute('selected'); } override formStateRestoreCallback(state: string) { this.selected = state === 'true'; } }