chromium/ui/webui/resources/js/focus_row.ts

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