| // Copyright 2014 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| // clang-format off |
| import {assert, assertInstanceof} from './assert.js'; |
| import {EventTracker} from './event_tracker.js'; |
| import {hasKeyModifiers, isRTL} from './util.js'; |
| // clang-format on |
| |
| const ACTIVE_CLASS: string = 'focus-row-active'; |
| |
| /** |
| * A class to manage focus between given horizontally arranged elements. |
| * |
| * Pressing left cycles backward and pressing right cycles forward in item |
| * order. Pressing Home goes to the beginning of the list and End goes to the |
| * end of the list. |
| * |
| * If an item in this row is focused, it'll stay active (accessible via tab). |
| * If no items in this row are focused, the row can stay active until focus |
| * changes to a node inside |this.boundary_|. If |boundary| isn't specified, |
| * any focus change deactivates the row. |
| */ |
| export class FocusRow { |
| root: HTMLElement; |
| delegate: FocusRowDelegate|undefined; |
| protected eventTracker: EventTracker = new EventTracker(); |
| private boundary_: Element; |
| |
| /** |
| * @param root The root of this focus row. Focus classes are |
| * applied to |root| and all added elements must live within |root|. |
| * @param boundary Focus events are ignored outside of this element. |
| * @param delegate An optional event delegate. |
| */ |
| constructor(root: HTMLElement, boundary: Element|null, |
| delegate?: FocusRowDelegate) { |
| this.root = root; |
| this.boundary_ = boundary || document.documentElement; |
| this.delegate = delegate; |
| } |
| |
| /** |
| * Whether it's possible that |element| can be focused. |
| */ |
| static isFocusable(element: Element): boolean { |
| if (!element || (element as Element & {disabled?: boolean}).disabled) { |
| return false; |
| } |
| |
| // We don't check that element.tabIndex >= 0 here because inactive rows |
| // set a tabIndex of -1. |
| let current = element; |
| while (true) { |
| assertInstanceof(current, Element); |
| |
| const style = window.getComputedStyle(current); |
| if (style.visibility === 'hidden' || style.display === 'none') { |
| return false; |
| } |
| |
| const parent = current.parentNode; |
| if (!parent) { |
| return false; |
| } |
| |
| if (parent === current.ownerDocument || |
| parent instanceof DocumentFragment) { |
| return true; |
| } |
| |
| current = parent as Element; |
| } |
| } |
| |
| /** |
| * A focus override is a function that returns an element that should gain |
| * focus. The element may not be directly selectable for example the element |
| * that can gain focus is in a shadow DOM. Allowing an override via a |
| * function leaves the details of how the element is retrieved to the |
| * component. |
| */ |
| static getFocusableElement(element: HTMLElement): HTMLElement { |
| const withFocusable = |
| element as HTMLElement & { getFocusableElement?: () => HTMLElement}; |
| if (withFocusable.getFocusableElement) { |
| return withFocusable.getFocusableElement(); |
| } |
| return element; |
| } |
| |
| /** |
| * Register a new type of focusable element (or add to an existing one). |
| * |
| * Example: an (X) button might be 'delete' or 'close'. |
| * |
| * When FocusRow is used within a FocusGrid, these types are used to |
| * determine equivalent controls when Up/Down are pressed to change rows. |
| * |
| * Another example: mutually exclusive controls that hide each other on |
| * activation (i.e. Play/Pause) could use the same type (i.e. 'play-pause') |
| * to indicate they're equivalent. |
| * |
| * @param type The type of element to track focus of. |
| * @param selectorOrElement The selector of the element |
| * from this row's root, or the element itself. |
| * @return Whether a new item was added. |
| */ |
| addItem(type: string, selectorOrElement: string|HTMLElement): boolean { |
| assert(type); |
| |
| let element; |
| if (typeof selectorOrElement === 'string') { |
| element = this.root.querySelector<HTMLElement>(selectorOrElement); |
| } else { |
| element = selectorOrElement; |
| } |
| if (!element) { |
| return false; |
| } |
| |
| element.setAttribute('focus-type', type); |
| element.tabIndex = this.isActive() ? 0 : -1; |
| |
| this.eventTracker.add(element, 'blur', this.onBlur_.bind(this)); |
| this.eventTracker.add(element, 'focus', this.onFocus_.bind(this)); |
| this.eventTracker.add(element, 'keydown', this.onKeydown_.bind(this)); |
| this.eventTracker.add(element, 'mousedown', this.onMousedown_.bind(this)); |
| return true; |
| } |
| |
| /** Dereferences nodes and removes event handlers. */ |
| destroy() { |
| this.eventTracker.removeAll(); |
| } |
| |
| /** |
| * @param sampleElement An element for to find an equivalent |
| * for. |
| * @return An equivalent element to focus for |
| * |sampleElement|. |
| */ |
| protected getCustomEquivalent(_sampleElement: HTMLElement): HTMLElement { |
| const focusable = this.getFirstFocusable(); |
| assert(focusable); |
| return focusable; |
| } |
| |
| /** |
| * @return All registered elements (regardless of focusability). |
| */ |
| getElements(): HTMLElement[] { |
| return Array.from(this.root.querySelectorAll<HTMLElement>('[focus-type]')) |
| .map(FocusRow.getFocusableElement); |
| } |
| |
| /** |
| * Find the element that best matches |sampleElement|. |
| * @param sampleElement An element from a row of the same |
| * type which previously held focus. |
| * @return The element that best matches sampleElement. |
| */ |
| getEquivalentElement(sampleElement: HTMLElement): HTMLElement { |
| if (this.getFocusableElements().indexOf(sampleElement) >= 0) { |
| return sampleElement; |
| } |
| |
| const sampleFocusType = this.getTypeForElement(sampleElement); |
| if (sampleFocusType) { |
| const sameType = this.getFirstFocusable(sampleFocusType); |
| if (sameType) { |
| return sameType; |
| } |
| } |
| |
| return this.getCustomEquivalent(sampleElement); |
| } |
| |
| /** |
| * @param type An optional type to search for. |
| * @return The first focusable element with |type|. |
| */ |
| getFirstFocusable(type?: string): HTMLElement|null { |
| const element = this.getFocusableElements().find( |
| el => !type || el.getAttribute('focus-type') === type); |
| return element || null; |
| } |
| |
| /** @return Registered, focusable elements. */ |
| getFocusableElements(): HTMLElement[] { |
| return this.getElements().filter(FocusRow.isFocusable); |
| } |
| |
| /** |
| * @param element An element to determine a focus type for. |
| * @return The focus type for |element| or '' if none. |
| */ |
| getTypeForElement(element: Element): string { |
| return element.getAttribute('focus-type') || ''; |
| } |
| |
| /** @return Whether this row is currently active. */ |
| isActive(): boolean { |
| return this.root.classList.contains(ACTIVE_CLASS); |
| } |
| |
| /** |
| * Enables/disables the tabIndex of the focusable elements in the FocusRow. |
| * tabIndex can be set properly. |
| * @param active True if tab is allowed for this row. |
| */ |
| makeActive(active: boolean) { |
| if (active === this.isActive()) { |
| return; |
| } |
| |
| this.getElements().forEach(function(element) { |
| element.tabIndex = active ? 0 : -1; |
| }); |
| |
| this.root.classList.toggle(ACTIVE_CLASS, active); |
| } |
| |
| private onBlur_(e: FocusEvent) { |
| if (!this.boundary_.contains(e.relatedTarget as Element)) { |
| return; |
| } |
| |
| const currentTarget = e.currentTarget as HTMLElement; |
| if (this.getFocusableElements().indexOf(currentTarget) >= 0) { |
| this.makeActive(false); |
| } |
| } |
| |
| private onFocus_(e: Event) { |
| if (this.delegate) { |
| this.delegate.onFocus(this, e); |
| } |
| } |
| |
| private onMousedown_(e: MouseEvent) { |
| // Only accept left mouse clicks. |
| if (e.button) { |
| return; |
| } |
| |
| // Allow the element under the mouse cursor to be focusable. |
| const target = e.currentTarget as HTMLElement & {disabled?: boolean}; |
| if (!target.disabled) { |
| target.tabIndex = 0; |
| } |
| } |
| |
| private onKeydown_(e: KeyboardEvent) { |
| const elements = this.getFocusableElements(); |
| const currentElement = FocusRow.getFocusableElement( |
| e.currentTarget as HTMLElement); |
| const elementIndex = elements.indexOf(currentElement); |
| assert(elementIndex >= 0); |
| |
| if (this.delegate && this.delegate.onKeydown(this, e)) { |
| return; |
| } |
| |
| const isShiftTab = !e.altKey && !e.ctrlKey && !e.metaKey && e.shiftKey && |
| e.key === 'Tab'; |
| |
| if (hasKeyModifiers(e) && !isShiftTab) { |
| return; |
| } |
| |
| let index = -1; |
| let shouldStopPropagation = true; |
| |
| if (isShiftTab) { |
| // This always moves back one element, even in RTL. |
| index = elementIndex - 1; |
| if (index < 0) { |
| // Bubble up to focus on the previous element outside the row. |
| return; |
| } |
| } else if (e.key === 'ArrowLeft') { |
| index = elementIndex + (isRTL() ? 1 : -1); |
| } else if (e.key === 'ArrowRight') { |
| index = elementIndex + (isRTL() ? -1 : 1); |
| } else if (e.key === 'Home') { |
| index = 0; |
| } else if (e.key === 'End') { |
| index = elements.length - 1; |
| } else { |
| shouldStopPropagation = false; |
| } |
| |
| const elementToFocus = elements[index]; |
| if (elementToFocus) { |
| this.getEquivalentElement(elementToFocus).focus(); |
| e.preventDefault(); |
| } |
| if (shouldStopPropagation) { |
| e.stopPropagation(); |
| } |
| } |
| } |
| |
| export interface FocusRowDelegate { |
| /** |
| * Called when a key is pressed while on a FocusRow's item. If true is |
| * returned, further processing is skipped. |
| * @param row The row that detected a keydown. |
| * @return Whether the event was handled. |
| */ |
| onKeydown(row: FocusRow, e: KeyboardEvent): boolean; |
| |
| onFocus(row: FocusRow, e: Event): void; |
| |
| /** |
| * @param sampleElement An element to find an equivalent for. |
| * @return An equivalent element to focus, or null to use the |
| * default FocusRow element. |
| */ |
| getCustomEquivalent(sampleElement: HTMLElement): HTMLElement|null; |
| } |
| |
| export class VirtualFocusRow extends FocusRow { |
| constructor(root: HTMLElement, delegate: FocusRowDelegate) { |
| super(root, /* boundary */ null, delegate); |
| } |
| |
| override getCustomEquivalent(sampleElement: HTMLElement) { |
| const equivalent = |
| this.delegate ? this.delegate.getCustomEquivalent(sampleElement) : null; |
| return equivalent || super.getCustomEquivalent(sampleElement); |
| } |
| } |