material-components_materia.../radio/internal/single-selection-controller.ts
Elizabeth Mitchell c390291687 chore: format files with prettier
PiperOrigin-RevId: 576601342
2023-10-25 11:59:00 -07:00

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