src/focus-zone.ts (457 lines of code) (raw):

import {polyfill as eventListenerSignalPolyfill} from './polyfills/event-listener-signal.js' import {isMacOS} from './utils/user-agent.js' import {iterateFocusableElements} from './utils/iterate-focusable-elements.js' import {uniqueId} from './utils/unique-id.js' eventListenerSignalPolyfill() export type Direction = 'previous' | 'next' | 'start' | 'end' export type FocusMovementKeys = | 'ArrowLeft' | 'ArrowDown' | 'ArrowUp' | 'ArrowRight' | 'h' | 'j' | 'k' | 'l' | 'a' | 's' | 'w' | 'd' | 'Tab' | 'Home' | 'End' | 'PageUp' | 'PageDown' export enum FocusKeys { // Left and right arrow keys (previous and next, respectively) ArrowHorizontal = 0b000000001, // Up and down arrow keys (previous and next, respectively) ArrowVertical = 0b000000010, // The "J" and "K" keys (next and previous, respectively) JK = 0b000000100, // The "H" and "L" keys (previous and next, respectively) HL = 0b000001000, // The Home and End keys (previous and next, respectively, to end) HomeAndEnd = 0b000010000, // The PgUp and PgDn keys (previous and next, respectively, to end) PageUpDown = 0b100000000, // The "W" and "S" keys (previous and next, respectively) WS = 0b000100000, // The "A" and "D" keys (previous and next, respectively) AD = 0b001000000, // The Tab key (next) Tab = 0b010000000, ArrowAll = FocusKeys.ArrowHorizontal | FocusKeys.ArrowVertical, HJKL = FocusKeys.HL | FocusKeys.JK, WASD = FocusKeys.WS | FocusKeys.AD, All = FocusKeys.ArrowAll | FocusKeys.HJKL | FocusKeys.HomeAndEnd | FocusKeys.PageUpDown | FocusKeys.WASD | FocusKeys.Tab } const KEY_TO_BIT = { ArrowLeft: FocusKeys.ArrowHorizontal, ArrowDown: FocusKeys.ArrowVertical, ArrowUp: FocusKeys.ArrowVertical, ArrowRight: FocusKeys.ArrowHorizontal, h: FocusKeys.HL, j: FocusKeys.JK, k: FocusKeys.JK, l: FocusKeys.HL, a: FocusKeys.AD, s: FocusKeys.WS, w: FocusKeys.WS, d: FocusKeys.AD, Tab: FocusKeys.Tab, Home: FocusKeys.HomeAndEnd, End: FocusKeys.HomeAndEnd, PageUp: FocusKeys.PageUpDown, PageDown: FocusKeys.PageUpDown } as {[k in FocusMovementKeys]: FocusKeys} const KEY_TO_DIRECTION = { ArrowLeft: 'previous', ArrowDown: 'next', ArrowUp: 'previous', ArrowRight: 'next', h: 'previous', j: 'next', k: 'previous', l: 'next', a: 'previous', s: 'next', w: 'previous', d: 'next', Tab: 'next', Home: 'start', End: 'end', PageUp: 'start', PageDown: 'end' } as {[k in FocusMovementKeys]: Direction} /** * Options that control the behavior of the arrow focus behavior. */ export interface FocusZoneSettings { /** * Choose the behavior applied in cases where focus is currently at either the first or * last element of the container. * * "stop" - do nothing and keep focus where it was * "wrap" - wrap focus around to the first element from the last, or the last element from the first * * Default: "stop" */ focusOutBehavior?: 'stop' | 'wrap' /** * If set, this will be called to get the next focusable element. If this function * returns null, we will try to determine the next direction ourselves. Use the * `bindKeys` option to customize which keys are listened to. * * The function can accept a Direction, indicating the direction focus should move, * the HTMLElement that was previously focused, and lastly the `KeyboardEvent` object * created by the original `"keydown"` event. */ getNextFocusable?: (direction: Direction, from: Element | undefined, event: KeyboardEvent) => HTMLElement | undefined /** * Called to decide if a focusable element is allowed to participate in the arrow * key focus behavior. * * By default, all focusable elements within the given container will participate * in the arrow key focus behavior. If you need to withhold some elements from * participation, implement this callback to return false for those elements. */ focusableElementFilter?: (element: HTMLElement) => boolean /** * Bit flags that identify keys that will be bound to. Each available key either * moves focus to the "next" element or the "previous" element, so it is best * to only bind the keys that make sense to move focus in your UI. Use the `FocusKeys` * object to discover supported keys. * * Use the bitwise "OR" operator (`|`) to combine key types. For example, * `FocusKeys.WASD | FocusKeys.HJKL` represents all of W, A, S, D, H, J, K, and L. * * A note on FocusKeys.PageUpDown: This behavior does not support paging, so by default * using these keys will result in the same behavior as Home and End. To override this * behavior, implement `getNextFocusable`. * * The default for this setting is `FocusKeys.ArrowVertical | FocusKeys.HomeAndEnd`, unless * `getNextFocusable` is provided, in which case `FocusKeys.ArrowAll | FocusKeys.HomeAndEnd` * is used as the default. */ bindKeys?: FocusKeys /** * If provided, this signal can be used to disable the behavior and remove any * event listeners. */ abortSignal?: AbortSignal /** * If `activeDescendantControl` is supplied, do not move focus or alter `tabindex` on * any element. Instead, manage `aria-activedescendant` according to the ARIA best * practices guidelines. * @see https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_focus_activedescendant * * The given `activeDescendantControl` will be given an `aria-controls` attribute that * references the ID of the `container`. Additionally, it will be given an * `aria-activedescendant` attribute that references the ID of the currently-active * descendant. * * This element will retain DOM focus as arrow keys are pressed. */ activeDescendantControl?: HTMLElement /** * Called each time the active descendant changes. Note that either of the parameters * may be undefined, e.g. when an element in the container first becomes active, or * when the controlling element becomes unfocused. */ onActiveDescendantChanged?: ( newActiveDescendant: HTMLElement | undefined, previousActiveDescendant: HTMLElement | undefined, directlyActivated: boolean ) => void /** * This option allows customization of the behavior that determines which of the * focusable elements should be focused when focus enters the container via the Tab key. * * When set to "first", whenever focus enters the container via Tab, we will focus the * first focusable element. When set to "previous", the most recently focused element * will be focused (fallback to first if there was no previous). * * The "closest" strategy works like "first", except either the first or the last element * of the container will be focused, depending on the direction from which focus comes. * * If a function is provided, this function should return the HTMLElement intended * to receive focus. This is useful if you want to focus the currently "selected" * item or element. * * Default: "previous" * * For more information, @see https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_general_within */ focusInStrategy?: 'first' | 'closest' | 'previous' | ((previousFocusedElement: Element) => HTMLElement | undefined) } function getDirection(keyboardEvent: KeyboardEvent) { const direction = KEY_TO_DIRECTION[keyboardEvent.key as keyof typeof KEY_TO_DIRECTION] if (keyboardEvent.key === 'Tab' && keyboardEvent.shiftKey) { return 'previous' } const isMac = isMacOS() if ((isMac && keyboardEvent.metaKey) || (!isMac && keyboardEvent.ctrlKey)) { if (keyboardEvent.key === 'ArrowLeft' || keyboardEvent.key === 'ArrowUp') { return 'start' } else if (keyboardEvent.key === 'ArrowRight' || keyboardEvent.key === 'ArrowDown') { return 'end' } } return direction } /** * There are some situations where we do not want various keys to affect focus. This function * checks for those situations. * 1. Home and End should not move focus when a text input or textarea is active * 2. Keys that would normally type characters into an input or navigate a select element should be ignored * 3. The down arrow sometimes should not move focus when a select is active since that sometimes invokes the dropdown * 4. Page Up and Page Down within a textarea should not have any effect * 5. When in a text input or textarea, left should only move focus if the cursor is at the beginning of the input * 6. When in a text input or textarea, right should only move focus if the cursor is at the end of the input * 7. When in a textarea, up and down should only move focus if cursor is at the beginning or end, respectively. * @param keyboardEvent * @param activeElement */ function shouldIgnoreFocusHandling(keyboardEvent: KeyboardEvent, activeElement: Element | null) { const key = keyboardEvent.key // Get the number of characters in `key`, accounting for double-wide UTF-16 chars. If keyLength // is 1, we can assume it's a "printable" character. Otherwise it's likely a control character. // One exception is the Tab key, which is technically printable, but browsers generally assign // its function to move focus rather than type a <TAB> character. const keyLength = [...key].length const isTextInput = (activeElement instanceof HTMLInputElement && activeElement.type === 'text') || activeElement instanceof HTMLTextAreaElement // If we would normally type a character into an input, ignore // Also, Home and End keys should never affect focus when in a text input if (isTextInput && (keyLength === 1 || key === 'Home' || key === 'End')) { return true } // Some situations we want to ignore with <select> elements if (activeElement instanceof HTMLSelectElement) { // Regular typeable characters change the selection, so ignore those if (keyLength === 1) { return true } // On macOS, bare ArrowDown opens the select, so ignore that if (key === 'ArrowDown' && isMacOS() && !keyboardEvent.metaKey) { return true } // On other platforms, Alt+ArrowDown opens the select, so ignore that if (key === 'ArrowDown' && !isMacOS() && keyboardEvent.altKey) { return true } } // Ignore page up and page down for textareas if (activeElement instanceof HTMLTextAreaElement && (key === 'PageUp' || key === 'PageDown')) { return true } if (isTextInput) { const textInput = activeElement as HTMLInputElement | HTMLTextAreaElement const cursorAtStart = textInput.selectionStart === 0 && textInput.selectionEnd === 0 const cursorAtEnd = textInput.selectionStart === textInput.value.length && textInput.selectionEnd === textInput.value.length // When in a text area or text input, only move focus left/right if at beginning/end of the field if (key === 'ArrowLeft' && !cursorAtStart) { return true } if (key === 'ArrowRight' && !cursorAtEnd) { return true } // When in a text area, only move focus up/down if at beginning/end of the field if (textInput instanceof HTMLTextAreaElement) { if (key === 'ArrowUp' && !cursorAtStart) { return true } if (key === 'ArrowDown' && !cursorAtEnd) { return true } } } return false } export const isActiveDescendantAttribute = 'data-is-active-descendant' /** * A value of activated-directly for data-is-active-descendant indicates the descendant was activated * by a manual user interaction with intent to move active descendant. This usually translates to the * user pressing one of the bound keys (up/down arrow, etc) to move through the focus zone. This is * intended to be roughly equivalent to the :focus-visible pseudo-class **/ export const activeDescendantActivatedDirectly = 'activated-directly' /** * A value of activated-indirectly for data-is-active-descendant indicates the descendant was activated * implicitly, and not by a direct key press. This includes focus zone being created from scratch, focusable * elements being added/removed, and mouseover events. This is intended to be roughly equivalent * to :focus:not(:focus-visible) **/ export const activeDescendantActivatedIndirectly = 'activated-indirectly' export const hasActiveDescendantAttribute = 'data-has-active-descendant' /** * Sets up the arrow key focus behavior for all focusable elements in the given `container`. * @param container * @param settings * @returns */ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings): AbortController { const focusableElements: HTMLElement[] = [] const savedTabIndex = new WeakMap<HTMLElement, string | null>() const bindKeys = settings?.bindKeys ?? (settings?.getNextFocusable ? FocusKeys.ArrowAll : FocusKeys.ArrowVertical) | FocusKeys.HomeAndEnd const focusOutBehavior = settings?.focusOutBehavior ?? 'stop' const focusInStrategy = settings?.focusInStrategy ?? 'previous' const activeDescendantControl = settings?.activeDescendantControl const activeDescendantCallback = settings?.onActiveDescendantChanged let currentFocusedElement: HTMLElement | undefined function getFirstFocusableElement() { return focusableElements[0] as HTMLElement | undefined } function isActiveDescendantInputFocused() { return document.activeElement === activeDescendantControl } function updateFocusedElement(to?: HTMLElement, directlyActivated = false) { const from = currentFocusedElement currentFocusedElement = to if (activeDescendantControl) { if (to && isActiveDescendantInputFocused()) { setActiveDescendant(from, to, directlyActivated) } else { clearActiveDescendant() } return } if (from && from !== to && savedTabIndex.has(from)) { from.setAttribute('tabindex', '-1') } to?.setAttribute('tabindex', '0') } function setActiveDescendant(from: HTMLElement | undefined, to: HTMLElement, directlyActivated = false) { if (!to.id) { to.setAttribute('id', uniqueId()) } if (from && from !== to) { from.removeAttribute(isActiveDescendantAttribute) } if ( !activeDescendantControl || (!directlyActivated && activeDescendantControl.getAttribute('aria-activedescendant') === to.id) ) { // prevent active descendant callback from being called repeatedly if the same element is activated (e.g. via mousemove) return } activeDescendantControl.setAttribute('aria-activedescendant', to.id) container.setAttribute(hasActiveDescendantAttribute, to.id) to.setAttribute( isActiveDescendantAttribute, directlyActivated ? activeDescendantActivatedDirectly : activeDescendantActivatedIndirectly ) activeDescendantCallback?.(to, from, directlyActivated) } function clearActiveDescendant(previouslyActiveElement = currentFocusedElement) { if (focusInStrategy === 'first') { currentFocusedElement = undefined } activeDescendantControl?.removeAttribute('aria-activedescendant') container.removeAttribute(hasActiveDescendantAttribute) previouslyActiveElement?.removeAttribute(isActiveDescendantAttribute) activeDescendantCallback?.(undefined, previouslyActiveElement, false) } function beginFocusManagement(...elements: HTMLElement[]) { const filteredElements = elements.filter(e => settings?.focusableElementFilter?.(e) ?? true) if (filteredElements.length === 0) { return } // Insert all elements atomically. Assume that all passed elements are well-ordered. const insertIndex = focusableElements.findIndex( e => (e.compareDocumentPosition(filteredElements[0]) & Node.DOCUMENT_POSITION_PRECEDING) > 0 ) focusableElements.splice(insertIndex === -1 ? focusableElements.length : insertIndex, 0, ...filteredElements) for (const element of filteredElements) { // Set tabindex="-1" on all tabbable elements, but save the original // value in case we need to disable the behavior if (!savedTabIndex.has(element)) { savedTabIndex.set(element, element.getAttribute('tabindex')) } element.setAttribute('tabindex', '-1') } if (!currentFocusedElement) { updateFocusedElement(getFirstFocusableElement()) } } function endFocusManagement(...elements: HTMLElement[]) { for (const element of elements) { const focusableElementIndex = focusableElements.indexOf(element) if (focusableElementIndex >= 0) { focusableElements.splice(focusableElementIndex, 1) } const savedIndex = savedTabIndex.get(element) if (savedIndex !== undefined) { if (savedIndex === null) { element.removeAttribute('tabindex') } else { element.setAttribute('tabindex', savedIndex) } savedTabIndex.delete(element) } // If removing the last-focused element, move focus to the first element in the list. if (element === currentFocusedElement) { const nextElementToFocus = getFirstFocusableElement() updateFocusedElement(nextElementToFocus) } } } // Take all tabbable elements within container under management beginFocusManagement(...iterateFocusableElements(container)) // Open the first tabbable element for tabbing updateFocusedElement(getFirstFocusableElement()) // If the DOM structure of the container changes, make sure we keep our state up-to-date // with respect to the focusable elements cache and its order const observer = new MutationObserver(mutations => { // Perform all removals first, in case element order has simply changed for (const mutation of mutations) { for (const removedNode of mutation.removedNodes) { if (removedNode instanceof HTMLElement) { endFocusManagement(...iterateFocusableElements(removedNode)) } } } for (const mutation of mutations) { for (const addedNode of mutation.addedNodes) { if (addedNode instanceof HTMLElement) { beginFocusManagement(...iterateFocusableElements(addedNode)) } } } }) observer.observe(container, { subtree: true, childList: true }) const controller = new AbortController() const signal = settings?.abortSignal ?? controller.signal signal.addEventListener('abort', () => { // Clean up any modifications endFocusManagement(...focusableElements) }) let elementIndexFocusedByClick: number | undefined = undefined container.addEventListener( 'mousedown', event => { // Since focusin is only called when focus changes, we need to make sure the clicked // element isn't already focused. if (event.target instanceof HTMLElement && event.target !== document.activeElement) { elementIndexFocusedByClick = focusableElements.indexOf(event.target) } }, {signal} ) if (activeDescendantControl) { container.addEventListener('focusin', event => { if (event.target instanceof HTMLElement && focusableElements.includes(event.target)) { // Move focus to the activeDescendantControl if one of the descendants is focused activeDescendantControl.focus() updateFocusedElement(event.target) } }) container.addEventListener( 'mousemove', ({target}) => { if (!(target instanceof Node)) { return } const focusableElement = focusableElements.find(element => element.contains(target)) if (focusableElement) { updateFocusedElement(focusableElement) } }, {signal, capture: true} ) // Listeners specifically on the controlling element activeDescendantControl.addEventListener('focusin', () => { // Focus moved into the active descendant input. Activate current or first descendant. if (!currentFocusedElement) { updateFocusedElement(getFirstFocusableElement()) } else { setActiveDescendant(undefined, currentFocusedElement) } }) activeDescendantControl.addEventListener('focusout', () => { clearActiveDescendant() }) } else { // This is called whenever focus enters an element in the container container.addEventListener( 'focusin', event => { if (event.target instanceof HTMLElement) { // If a click initiated the focus movement, we always want to set our internal state // to reflect the clicked element as the currently focused one. if (elementIndexFocusedByClick !== undefined) { if (elementIndexFocusedByClick >= 0) { if (focusableElements[elementIndexFocusedByClick] !== currentFocusedElement) { updateFocusedElement(focusableElements[elementIndexFocusedByClick]) } } elementIndexFocusedByClick = undefined } else { // Set tab indexes and internal state based on the focus handling strategy if (focusInStrategy === 'previous') { updateFocusedElement(event.target) } else if (focusInStrategy === 'closest' || focusInStrategy === 'first') { if (event.relatedTarget instanceof Element && !container.contains(event.relatedTarget)) { // Regardless of the previously focused element, if we're coming from outside the // container, put focus onto the first encountered element (from above, it's The // first element of the container; from below, it's the last). If the // focusInStrategy is set to "first", lastKeyboardFocusDirection will always // be undefined. const targetElementIndex = lastKeyboardFocusDirection === 'previous' ? focusableElements.length - 1 : 0 const targetElement = focusableElements[targetElementIndex] as HTMLElement | undefined targetElement?.focus() return } else { updateFocusedElement(event.target) } } else if (typeof focusInStrategy === 'function') { if (event.relatedTarget instanceof Element && !container.contains(event.relatedTarget)) { const elementToFocus = focusInStrategy(event.relatedTarget) const requestedFocusElementIndex = elementToFocus ? focusableElements.indexOf(elementToFocus) : -1 if (requestedFocusElementIndex >= 0 && elementToFocus instanceof HTMLElement) { // Since we are calling focus() this handler will run again synchronously. Therefore, // we don't want to let this invocation finish since it will clobber the value of // currentFocusedElement. elementToFocus.focus() return } else { // eslint-disable-next-line no-console console.warn('Element requested is not a known focusable element.') } } else { updateFocusedElement(event.target) } } } } lastKeyboardFocusDirection = undefined }, {signal} ) } const keyboardEventRecipient = activeDescendantControl ?? container // If the strategy is "closest", we need to capture the direction that the user // is trying to move focus before our focusin handler is executed. let lastKeyboardFocusDirection: Direction | undefined = undefined if (focusInStrategy === 'closest') { document.addEventListener( 'keydown', event => { if (event.key === 'Tab') { lastKeyboardFocusDirection = getDirection(event) } }, {signal, capture: true} ) } function getCurrentFocusedIndex() { if (!currentFocusedElement) { return 0 } const focusedIndex = focusableElements.indexOf(currentFocusedElement) const fallbackIndex = currentFocusedElement === container ? -1 : 0 return focusedIndex !== -1 ? focusedIndex : fallbackIndex } // "keydown" is the event that triggers DOM focus change, so that is what we use here keyboardEventRecipient.addEventListener( 'keydown', event => { if (event.key in KEY_TO_DIRECTION) { const keyBit = KEY_TO_BIT[event.key as keyof typeof KEY_TO_BIT] // Check if the pressed key (keyBit) is one that is being used for focus (bindKeys) if ( !event.defaultPrevented && (keyBit & bindKeys) > 0 && !shouldIgnoreFocusHandling(event, document.activeElement) ) { // Moving forward or backward? const direction = getDirection(event) let nextElementToFocus: HTMLElement | undefined = undefined // If there is a custom function that retrieves the next focusable element, try calling that first. if (settings?.getNextFocusable) { nextElementToFocus = settings.getNextFocusable(direction, document.activeElement ?? undefined, event) } if (!nextElementToFocus) { const lastFocusedIndex = getCurrentFocusedIndex() let nextFocusedIndex = lastFocusedIndex if (direction === 'previous') { nextFocusedIndex -= 1 } else if (direction === 'start') { nextFocusedIndex = 0 } else if (direction === 'next') { nextFocusedIndex += 1 } else { // end nextFocusedIndex = focusableElements.length - 1 } if (nextFocusedIndex < 0) { // Tab should never cause focus to wrap. Use focusTrap for that behavior. if (focusOutBehavior === 'wrap' && event.key !== 'Tab') { nextFocusedIndex = focusableElements.length - 1 } else { nextFocusedIndex = 0 } } if (nextFocusedIndex >= focusableElements.length) { if (focusOutBehavior === 'wrap' && event.key !== 'Tab') { nextFocusedIndex = 0 } else { nextFocusedIndex = focusableElements.length - 1 } } if (lastFocusedIndex !== nextFocusedIndex) { nextElementToFocus = focusableElements[nextFocusedIndex] } } if (activeDescendantControl) { updateFocusedElement(nextElementToFocus || currentFocusedElement, true) } else if (nextElementToFocus) { lastKeyboardFocusDirection = direction // updateFocusedElement will be called implicitly when focus moves, as long as the event isn't prevented somehow nextElementToFocus.focus() } // Tab should always allow escaping from this container, so only // preventDefault if tab key press already resulted in a focus movement if (event.key !== 'Tab' || nextElementToFocus) { event.preventDefault() } } } }, {signal} ) return controller }