/** * @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 `` 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 { /** * All single selection elements in the host element's root with the same * `name` attribute, including the host element. */ get controls(): [SingleSelectionElement, ...SingleSelectionElement[]] { const name = this.host.getAttribute('name'); if (!name || !this.root || !this.host.isConnected) { return [this.host]; } // Cast as unknown since there is not enough information for typescript to // know that there is always at least one element (the host). return Array.from( this.root.querySelectorAll(`[name="${name}"]`), ) as unknown as [SingleSelectionElement, ...SingleSelectionElement[]]; } private focused = false; private root: ParentNode | null = null; constructor(private readonly host: SingleSelectionElement) {} hostConnected() { this.host.addEventListener('keydown', this.handleKeyDown); this.host.addEventListener('focusin', this.handleFocusIn); this.host.addEventListener('focusout', this.handleFocusOut); // Update siblings after a microtask to allow other synchronous connected // callbacks to settle before triggering additional Lit updates. This avoids // stack overflow issues when too many elements are being rendered and // connected at the same time. queueMicrotask(() => { // Update for the newly added host. this.root = this.host.getRootNode() as ParentNode; if (this.host.checked) { // Uncheck other siblings when attached if already checked. This mimics // native behavior. this.uncheckSiblings(); } this.updateTabIndices(); }); } hostDisconnected() { this.host.removeEventListener('keydown', this.handleKeyDown); this.host.removeEventListener('focusin', this.handleFocusIn); this.host.removeEventListener('focusout', this.handleFocusOut); // Update siblings after a microtask to allow other synchronous disconnected // callbacks to settle before triggering additional Lit updates. This avoids // stack overflow issues when too many elements are being rendered and // connected at the same time. queueMicrotask(() => { // 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.controls) { 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.controls; 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; } } /** * 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.controls; 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 behavior. nextSibling.dispatchEvent(new Event('change', {bubbles: true})); break; } }; }