UNPKG

@material/web

Version:
199 lines 7.51 kB
/** * @license * Copyright 2022 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * 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 { /** * All single selection elements in the host element's root with the same * `name` attribute, including the host element. */ get controls() { 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}"]`)); } constructor(host) { this.host = host; this.focused = false; this.root = null; this.handleFocusIn = () => { this.focused = true; this.updateTabIndices(); }; this.handleFocusOut = () => { this.focused = false; this.updateTabIndices(); }; /** * 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. */ this.handleKeyDown = (event) => { 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 <input type="radio"> behavior. nextSibling.dispatchEvent(new Event('change', { bubbles: true })); break; } }; } hostConnected() { this.root = this.host.getRootNode(); 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(); } uncheckSiblings() { for (const sibling of this.controls) { if (sibling !== this.host) { sibling.checked = false; } } } /** * Updates the `tabindex` of the host and its siblings. */ 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; } } } //# sourceMappingURL=single-selection-controller.js.map