material-web/switch/internal/switch.ts
Elizabeth Mitchell 3e13634d64 chore: make formAssociated readonly
PiperOrigin-RevId: 558255234
2023-08-18 14:57:38 -07:00

225 lines
6.1 KiB
TypeScript

/**
* @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, PropertyValues, 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} from '../../internal/controller/events.js';
/**
* @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 LitElement {
static {
requestUpdateOnAriaChange(Switch);
}
/** @nocollapse */
static override shadowRootOptions:
ShadowRootInit = {mode: 'open', delegatesFocus: true};
/** @nocollapse */
static readonly formAssociated = true;
/**
* Disables the switch and makes it non-interactive.
*/
@property({type: Boolean, reflect: true}) disabled = false;
/**
* 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;
// Button
@query('button') private readonly button!: HTMLButtonElement|null;
/**
* The value associated with this switch on form submission. `null` is
* submitted when `selected` is `false`.
*/
@property() value = 'on';
/**
* The HTML name to use in form submission.
*/
get name() {
return this.getAttribute('name') ?? '';
}
set name(name: string) {
this.setAttribute('name', name);
}
/**
* The associated form element with which this element's value will submit.
*/
get form() {
return this.internals.form;
}
/**
* The labels this element is associated with.
*/
get labels() {
return this.internals.labels;
}
private readonly internals =
(this as HTMLElement /* needed for closure */).attachInternals();
constructor() {
super();
if (!isServer) {
this.addEventListener('click', (event: MouseEvent) => {
if (!isActivationClick(event)) {
return;
}
this.button?.focus();
if (this.button != null) {
// this triggers the click behavior, and the ripple
dispatchActivationClick(this.button);
}
});
}
}
protected override update(changed: PropertyValues<Switch>) {
const state = String(this.selected);
this.internals.setFormValue(this.selected ? this.value : null, state);
super.update(changed);
}
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`
<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>
<span class="track">
${this.renderHandle()}
</span>
</button>
`;
}
private getRenderClasses(): ClassInfo {
return {
'switch--selected': this.selected,
'switch--unselected': !this.selected,
};
}
private renderHandle() {
const classes = {
'with-icon': this.showOnlySelectedIcon ? this.selected : this.icons,
};
return html`
${this.renderTouchTarget()}
<span class="handle-container">
<md-ripple for="switch" ?disabled="${this.disabled}"></md-ripple>
<span class="handle ${classMap(classes)}">
${this.shouldShowIcons() ? this.renderIcons() : html``}
</span>
</span>
`;
}
private renderIcons() {
return html`
<div class="icons">
${this.renderOnIcon()}
${this.showOnlySelectedIcon ? html`` : this.renderOffIcon()}
</div>
`;
}
/**
* https://fonts.google.com/icons?selected=Material%20Symbols%20Outlined%3Acheck%3AFILL%400%3Bwght%40500%3BGRAD%400%3Bopsz%4024
*/
private renderOnIcon() {
return html`
<svg class="icon icon--on" viewBox="0 0 24 24">
<path d="M9.55 18.2 3.65 12.3 5.275 10.675 9.55 14.95 18.725 5.775 20.35 7.4Z"/>
</svg>
`;
}
/**
* https://fonts.google.com/icons?selected=Material%20Symbols%20Outlined%3Aclose%3AFILL%400%3Bwght%40500%3BGRAD%400%3Bopsz%4024
*/
private renderOffIcon() {
return html`
<svg class="icon icon--off" viewBox="0 0 24 24">
<path d="M6.4 19.2 4.8 17.6 10.4 12 4.8 6.4 6.4 4.8 12 10.4 17.6 4.8 19.2 6.4 13.6 12 19.2 17.6 17.6 19.2 12 13.6Z"/>
</svg>
`;
}
private renderTouchTarget() {
return html`<span class="touch"></span>`;
}
private shouldShowIcons(): boolean {
return this.icons || this.showOnlySelectedIcon;
}
private handleClick() {
if (this.disabled) {
return;
}
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}));
}
/** @private */
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');
}
/** @private */
formStateRestoreCallback(state: string) {
this.selected = state === 'true';
}
}