mirror of
https://github.com/material-components/material-web.git
synced 2026-03-09 00:09:23 +08:00
232 lines
6.6 KiB
TypeScript
232 lines
6.6 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2022 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import {ReactiveController} from 'lit';
|
|
|
|
/**
|
|
* An element that supports single-selection with `SingleSelectionController`.
|
|
*/
|
|
export interface SingleSelectionElement extends HTMLElement {
|
|
/**
|
|
* Whether or not the element is selected.
|
|
*/
|
|
checked: boolean;
|
|
}
|
|
|
|
/**
|
|
* A `ReactiveController` that provides root node-scoped single selection for
|
|
* elements, similar to native `<input type="radio">` selection.
|
|
*
|
|
* To use, elements should add the controller and call
|
|
* `selectionController.handleCheckedChange()` in a getter/setter. This must
|
|
* be synchronous to match native behavior.
|
|
*
|
|
* @example
|
|
* const CHECKED = Symbol('checked');
|
|
*
|
|
* class MyToggle extends LitElement {
|
|
* get checked() { return this[CHECKED]; }
|
|
* set checked(checked: boolean) {
|
|
* const oldValue = this.checked;
|
|
* if (oldValue === checked) {
|
|
* return;
|
|
* }
|
|
*
|
|
* this[CHECKED] = checked;
|
|
* this.selectionController.handleCheckedChange();
|
|
* this.requestUpdate('checked', oldValue);
|
|
* }
|
|
*
|
|
* [CHECKED] = false;
|
|
*
|
|
* private selectionController = new SingleSelectionController(this);
|
|
*
|
|
* constructor() {
|
|
* super();
|
|
* this.addController(this.selectionController);
|
|
* }
|
|
* }
|
|
*/
|
|
export class SingleSelectionController implements ReactiveController {
|
|
private focused = false;
|
|
private root: ParentNode | null = null;
|
|
|
|
constructor(private readonly host: SingleSelectionElement) {}
|
|
|
|
hostConnected() {
|
|
this.root = this.host.getRootNode() as ParentNode;
|
|
this.host.addEventListener('keydown', this.handleKeyDown);
|
|
this.host.addEventListener('focusin', this.handleFocusIn);
|
|
this.host.addEventListener('focusout', this.handleFocusOut);
|
|
if (this.host.checked) {
|
|
// Uncheck other siblings when attached if already checked. This mimics
|
|
// native <input type="radio"> behavior.
|
|
this.uncheckSiblings();
|
|
}
|
|
|
|
// Update for the newly added host.
|
|
this.updateTabIndices();
|
|
}
|
|
|
|
hostDisconnected() {
|
|
this.host.removeEventListener('keydown', this.handleKeyDown);
|
|
this.host.removeEventListener('focusin', this.handleFocusIn);
|
|
this.host.removeEventListener('focusout', this.handleFocusOut);
|
|
// Update for siblings that are still connected.
|
|
this.updateTabIndices();
|
|
this.root = null;
|
|
}
|
|
|
|
/**
|
|
* Should be called whenever the host's `checked` property changes
|
|
* synchronously.
|
|
*/
|
|
handleCheckedChange() {
|
|
if (!this.host.checked) {
|
|
return;
|
|
}
|
|
|
|
this.uncheckSiblings();
|
|
this.updateTabIndices();
|
|
}
|
|
|
|
private readonly handleFocusIn = () => {
|
|
this.focused = true;
|
|
this.updateTabIndices();
|
|
};
|
|
|
|
private readonly handleFocusOut = () => {
|
|
this.focused = false;
|
|
this.updateTabIndices();
|
|
};
|
|
|
|
private uncheckSiblings() {
|
|
for (const sibling of this.getNamedSiblings()) {
|
|
if (sibling !== this.host) {
|
|
sibling.checked = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the `tabindex` of the host and its siblings.
|
|
*/
|
|
private updateTabIndices() {
|
|
// There are three tabindex states for a group of elements:
|
|
// 1. If any are checked, that element is focusable.
|
|
const siblings = this.getNamedSiblings();
|
|
const checkedSibling = siblings.find((sibling) => sibling.checked);
|
|
// 2. If an element is focused, the others are no longer focusable.
|
|
if (checkedSibling || this.focused) {
|
|
const focusable = checkedSibling || this.host;
|
|
focusable.tabIndex = 0;
|
|
|
|
for (const sibling of siblings) {
|
|
if (sibling !== focusable) {
|
|
sibling.tabIndex = -1;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 3. If none are checked or focused, all are focusable.
|
|
for (const sibling of siblings) {
|
|
sibling.tabIndex = 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves all siblings in the host element's root with the same `name`
|
|
* attribute.
|
|
*/
|
|
private getNamedSiblings() {
|
|
const name = this.host.getAttribute('name');
|
|
if (!name || !this.root) {
|
|
return [];
|
|
}
|
|
|
|
return Array.from(
|
|
this.root.querySelectorAll<SingleSelectionElement>(`[name="${name}"]`),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handles arrow key events from the host. Using the arrow keys will
|
|
* select and check the next or previous sibling with the host's
|
|
* `name` attribute.
|
|
*/
|
|
private readonly handleKeyDown = (event: KeyboardEvent) => {
|
|
const isDown = event.key === 'ArrowDown';
|
|
const isUp = event.key === 'ArrowUp';
|
|
const isLeft = event.key === 'ArrowLeft';
|
|
const isRight = event.key === 'ArrowRight';
|
|
// Ignore non-arrow keys
|
|
if (!isLeft && !isRight && !isDown && !isUp) {
|
|
return;
|
|
}
|
|
|
|
// Don't try to select another sibling if there aren't any.
|
|
const siblings = this.getNamedSiblings();
|
|
if (!siblings.length) {
|
|
return;
|
|
}
|
|
|
|
// Prevent default interactions on the element for arrow keys,
|
|
// since this controller will introduce new behavior.
|
|
event.preventDefault();
|
|
|
|
// Check if moving forwards or backwards
|
|
const isRtl = getComputedStyle(this.host).direction === 'rtl';
|
|
const forwards = isRtl ? isLeft || isDown : isRight || isDown;
|
|
|
|
const hostIndex = siblings.indexOf(this.host);
|
|
let nextIndex = forwards ? hostIndex + 1 : hostIndex - 1;
|
|
// Search for the next sibling that is not disabled to select.
|
|
// If we return to the host index, there is nothing to select.
|
|
while (nextIndex !== hostIndex) {
|
|
if (nextIndex >= siblings.length) {
|
|
// Return to start if moving past the last item.
|
|
nextIndex = 0;
|
|
} else if (nextIndex < 0) {
|
|
// Go to end if moving before the first item.
|
|
nextIndex = siblings.length - 1;
|
|
}
|
|
|
|
// Check if the next sibling is disabled. If so,
|
|
// move the index and continue searching.
|
|
const nextSibling = siblings[nextIndex];
|
|
if (nextSibling.hasAttribute('disabled')) {
|
|
if (forwards) {
|
|
nextIndex++;
|
|
} else {
|
|
nextIndex--;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
// Uncheck and remove focusability from other siblings.
|
|
for (const sibling of siblings) {
|
|
if (sibling !== nextSibling) {
|
|
sibling.checked = false;
|
|
sibling.tabIndex = -1;
|
|
sibling.blur();
|
|
}
|
|
}
|
|
|
|
// The next sibling should be checked, focused and dispatch a change event
|
|
nextSibling.checked = true;
|
|
nextSibling.tabIndex = 0;
|
|
nextSibling.focus();
|
|
// Fire a change event since the change is triggered by a user action.
|
|
// This matches native <input type="radio"> behavior.
|
|
nextSibling.dispatchEvent(new Event('change', {bubbles: true}));
|
|
|
|
break;
|
|
}
|
|
};
|
|
}
|