chromium/chrome/browser/resources/chromeos/accessibility/switch_access/focus_ring_manager.ts

// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {RectUtil} from '/common/rect_util.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';

import {MenuManager} from './menu_manager.js';
import {SAChildNode, SANode} from './nodes/switch_access_node.js';
import {SwitchAccess} from './switch_access.js';
import {ErrorType, Mode} from './switch_access_constants.js';

import FocusRingInfo = chrome.accessibilityPrivate.FocusRingInfo;
import FocusType = chrome.accessibilityPrivate.FocusType;
import ScreenRect = chrome.accessibilityPrivate.ScreenRect;

type Observer = (primary: SANode | null, preview: SANode | null) => void;

/** Class to handle focus rings. */
export class FocusRingManager {
  private observer_?: Observer;
  /** A map of all the focus rings. */
  private rings_: Record<RingId, FocusRingInfo>;
  private ringNodesForTesting_: Record<RingId, SANode | null> = {
    [RingId.PRIMARY]: null,
    [RingId.PREVIEW]: null,
  };

  private static instance_?: FocusRingManager;

  private constructor() {
    this.rings_ = this.createRings_();
  }

  static init(): void {
    if (FocusRingManager.instance_) {
      throw SwitchAccess.error(
          ErrorType.DUPLICATE_INITIALIZATION,
          'Cannot initialize focus ring manager twice.');
    }
    FocusRingManager.instance_ = new FocusRingManager();
  }

  static get instance(): FocusRingManager {
    if (!FocusRingManager.instance_) {
      throw SwitchAccess.error(
          ErrorType.UNINITIALIZED,
          'FocusRingManager cannot be accessed before being initialized');
    }
    return FocusRingManager.instance_;
  }

  /** Sets the focus ring color. */
  static setColor(color: string): void {
    if (!COLOR_PATTERN.test(color)) {
      console.error(SwitchAccess.error(
          ErrorType.INVALID_COLOR,
          'Problem setting focus ring color: ' + color + ' is not' +
              'a valid CSS color string.'));
      return;
    }
    FocusRingManager.instance.setColorValidated_(color);
  }

  /** Sets the primary and preview focus rings based on the provided node. */
  static setFocusedNode(node: SAChildNode): void {
    if (node.ignoreWhenComputingUnionOfBoundingBoxes()) {
      FocusRingManager.instance.setFocusedNodeIgnorePrimary_(node);
      return;
    }

    if (!node.location) {
      throw SwitchAccess.error(
          ErrorType.MISSING_LOCATION,
          'Cannot set focus rings if node location is undefined',
          true /* shouldRecover */);
    }

    // If the primary node is a group, show its first child as the "preview"
    // focus.
    if (node.isGroup()) {
      // TODO(b/314203187): Not null asserted, check that this is correct.
      const firstChild = node.asRootNode()!.firstChild;
      FocusRingManager.instance.setFocusedNodeGroup_(node, firstChild);
      return;
    }

    FocusRingManager.instance.setFocusedNodeLeaf_(node);
  }

  /** Clears all focus rings. */
  static clearAll(): void {
    FocusRingManager.instance.clearAll_();
  }

  /**
   * Set an observer that will be called every time the focus rings
   * are updated. It will be called with two arguments: the node for
   * the primary ring, and the node for the preview ring. Either may
   * be null.
   */
  static setObserver(observer: Observer): void {
    FocusRingManager.instance.observer_ = observer;
  }

  // ======== Private methods ========

  private clearAll_(): void {
    this.forEachRing_(ring => ring.rects = []);
    this.updateNodesForTesting_(null, null);
    this.updateFocusRings_();
  }

  /** Creates the map of focus rings. */
  private createRings_(): Record<RingId, FocusRingInfo> {
    const primaryRing = {
      id: RingId.PRIMARY,
      rects: [],
      type: FocusType.SOLID,
      color: PRIMARY_COLOR,
      secondaryColor: OUTER_COLOR,
    };

    const previewRing = {
      id: RingId.PREVIEW,
      rects: [],
      type: FocusType.DASHED,
      color: PREVIEW_COLOR,
      secondaryColor: OUTER_COLOR,
    };

    return {
      [RingId.PRIMARY]: primaryRing,
      [RingId.PREVIEW]: previewRing,
    };
  }

  /** Calls a function for each focus ring. */
  private forEachRing_(callback: (info: FocusRingInfo) => void): void {
    Object.values(this.rings_).forEach(ring => callback(ring));
  }

  /** Sets the focus ring color. Assumes the color has been validated. */
  private setColorValidated_(color: string): void {
    this.forEachRing_(ring => ring.color = color);
  }

  /**
   * Sets the primary focus ring to |node|, and the preview focus ring to
   * |firstChild|.
   */
  private setFocusedNodeGroup_(
      group: SAChildNode, firstChild: SAChildNode): void {
    // Clear the dashed ring between transitions, as the animation is
    // distracting.
    this.rings_[RingId.PREVIEW].rects = [];

    let focusRect: ScreenRect = group.location!;
    const childRect = firstChild ? firstChild.location : null;
    if (childRect) {
      // If the current element is not specialized in location handling, e.g.
      // the back button, the focus rect should expand to contain the child
      // rect.
      focusRect =
          RectUtil.expandToFitWithPadding(GROUP_BUFFER, focusRect, childRect)!;
      this.rings_[RingId.PREVIEW].rects = [childRect];
    }
    this.rings_[RingId.PRIMARY].rects = [focusRect];
    this.updateNodesForTesting_(group, firstChild);
    this.updateFocusRings_();
  }

  /**
   * Clears the primary focus ring and sets the preview focus ring based on the
   * provided node.
   */
  private setFocusedNodeIgnorePrimary_(node: SAChildNode): void {
    // Nodes of this type, e.g. the back button node, handles setting its own
    // focus, as it has special requirements (a round focus ring that has no
    // gap with the edges of the view).
    this.rings_[RingId.PRIMARY].rects = [];
    // Clear the dashed ring between transitions, as the animation is
    // distracting.
    this.rings_[RingId.PREVIEW].rects = [];
    this.updateFocusRings_();

    // Show the preview focus ring unless the menu is open (it has a custom exit
    // button).
    if (!MenuManager.isMenuOpen()) {
      // TODO(b/314203187): Not null asserted, check that this is correct.
      this.rings_[RingId.PREVIEW].rects = [node.group!.location];
    }
    this.updateNodesForTesting_(node, node.group);
    this.updateFocusRings_();
  }

  /** Sets the primary focus to |node| and clears the secondary focus. */
  private setFocusedNodeLeaf_(node: SAChildNode): void {
    // TODO(b/314203187): Not nulls asserted, check these to make sure
    // this is correct.
    this.rings_[RingId.PRIMARY].rects = [node.location!];
    this.rings_[RingId.PREVIEW].rects = [];
    this.updateNodesForTesting_(node, null);
    this.updateFocusRings_();
  }

  /**
   * Updates all focus rings to reflect new location, color, style, or other
   * changes. Enables observers to monitor what's focused.
   */
  private updateFocusRings_(): void {
    if (SwitchAccess.mode === Mode.POINT_SCAN && !MenuManager.isMenuOpen()) {
      return;
    }

    const focusRings = Object.values(this.rings_);
    chrome.accessibilityPrivate.setFocusRings(
        focusRings,
        chrome.accessibilityPrivate.AssistiveTechnologyType.SWITCH_ACCESS);
  }

  /** Saves the primary/preview focus for testing. */
  private updateNodesForTesting_(
      primary: SANode | null, preview: SANode | null): void {
    // Keep track of the nodes associated with each focus ring for testing
    // purposes, since focus ring locations are not guaranteed to exactly match
    // node locations.
    this.ringNodesForTesting_[RingId.PRIMARY] = primary;
    this.ringNodesForTesting_[RingId.PREVIEW] = preview;

    const observer = FocusRingManager.instance.observer_;
    if (observer) {
      observer(primary, preview);
    }
  }
}

/**
 * Regex pattern to verify valid colors. Checks that the first character
 * is '#', followed by 3, 4, 6, or 8 valid hex characters, and no other
 * characters (ignoring case).
 */
const COLOR_PATTERN = /^#([0-9A-F]{3,4}|[0-9A-F]{6}|[0-9A-F]{8})$/i;

/**
 * The buffer (in dip) between a child's focus ring and its parent's focus
 * ring.
 */
const GROUP_BUFFER = 2;

/**
 * The focus ring IDs used by Switch Access.
 * Exported for testing.
 */
export enum RingId {
  // The ID for the ring showing the user's current focus.
  PRIMARY = 'primary',
  // The ID for the ring showing a preview of the next focus, if the user
  // selects the current element.
  PREVIEW = 'preview',
}

/** The secondary color for both rings. */
const OUTER_COLOR = '#174EA6';  // Google Blue 900

/** The inner color of the preview focus ring. */
const PREVIEW_COLOR = '#8AB4F880';  // Google Blue 300, 50% opacity

/** The inner color of the primary focus ring. */
const PRIMARY_COLOR = '#8AB4F8';  // Google Blue 300

TestImportManager.exportForTesting(FocusRingManager, ['RingId', RingId]);