chromium/ash/webui/common/resources/focus_row_behavior.js

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

// Note: This file is deprecated, and should only be used by legacy code that
// has not yet finished migrating to TypeScript/Polymer class based syntax. New
// code should use focus_row_mixin.ts.

// clang-format off
import {afterNextRender} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {assert} from 'chrome://resources/ash/common/assert.js';
import {focusWithoutInk} from './focus_without_ink_js.js';
import {FocusRow, FocusRowDelegate} from './focus_row_js.js';
// clang-format on

  /** @implements {FocusRowDelegate} */
  class FocusRowBehaviorDelegate {
    /**
     * @param {{lastFocused: Object,
     *          overrideCustomEquivalent: boolean,
     *          getCustomEquivalent: (Function|undefined)}} listItem
     */
    constructor(listItem) {
      /** @private */
      this.listItem_ = listItem;
    }

    /**
     * This function gets called when the [focus-row-control] element receives
     * the focus event.
     * @override
     * @param {!FocusRow} row
     * @param {!Event} e
     */
    onFocus(row, e) {
      const element = /** @type {!HTMLElement} */ (e.composedPath()[0]);
      const focusableElement = FocusRow.getFocusableElement(element);
      if (element !== focusableElement) {
        focusableElement.focus();
      }
      this.listItem_.lastFocused = focusableElement;
    }

    /**
     * @override
     * @param {!FocusRow} row The row that detected a keydown.
     * @param {!Event} e
     * @return {boolean} Whether the event was handled.
     */
    onKeydown(row, e) {
      // Prevent iron-list from changing the focus on enter.
      if (e.key === 'Enter') {
        e.stopPropagation();
      }

      return false;
    }

    /** @override */
    getCustomEquivalent(sampleElement) {
      return this.listItem_.overrideCustomEquivalent ?
          this.listItem_.getCustomEquivalent(sampleElement) :
          null;
    }
  }

  /** @extends {FocusRow} */
  class VirtualFocusRow extends FocusRow {
    /**
     * @param {!Element} root
     * @param {FocusRowDelegate} delegate
     */
    constructor(root, delegate) {
      super(root, /* boundary */ null, delegate);
    }

    /** @override */
    getCustomEquivalent(sampleElement) {
      return this.delegate.getCustomEquivalent(sampleElement) ||
          super.getCustomEquivalent(sampleElement);
    }
  }

  /**
   * Any element that is being used as an iron-list row item can extend this
   * behavior, which encapsulates focus controls of mouse and keyboards.
   * To use this behavior:
   *    - The parent element should pass a "last-focused" attribute double-bound
   *      to the row items, to track the last-focused element across rows, and
   *      a "list-blurred" attribute double-bound to the row items, to track
   *      whether the list of row items has been blurred.
   *    - There must be a container in the extending element with the
   *      [focus-row-container] attribute that contains all focusable controls.
   *    - On each of the focusable controls, there must be a [focus-row-control]
   *      attribute, and a [focus-type=] attribute unique for each control.
   *
   * @polymerBehavior
   */
  export const FocusRowBehavior = {
    properties: {
      /** @private {VirtualFocusRow} */
      row_: Object,

      /** @private {boolean} */
      mouseFocused_: Boolean,

      /** Will be updated when |index| is set, unless specified elsewhere. */
      id: {
        type: String,
        reflectToAttribute: true,
      },

      /** For notifying when the row is in focus. */
      isFocused: {
        type: Boolean,
        notify: true,
      },

      /** Should be bound to the index of the item from the iron-list */
      focusRowIndex: {
        type: Number,
        observer: 'focusRowIndexChanged',
      },

      /** @type {HTMLElement} */
      lastFocused: {
        type: Object,
        notify: true,
      },

      /**
       * This is different from tabIndex, since the template only does a one-way
       * binding on both attributes, and the behavior actually make use of this
       * fact. For example, when a control within a row is focused, it will have
       * tabIndex = -1 and ironListTabIndex = 0.
       * @type {number}
       */
      ironListTabIndex: {
        type: Number,
        observer: 'ironListTabIndexChanged_',
      },

      listBlurred: {
        type: Boolean,
        notify: true,
      },
    },

    /**
     * Returns an ID based on the index that was passed in.
     * @param {?number} index
     * @return {?string}
     */
    computeId_(index) {
      return index !== undefined ? `frb${index}` : undefined;
    },

    /**
     * Sets |id| if it hasn't been set elsewhere. Also sets |aria-rowindex|.
     * @param {number} newIndex
     * @param {number} oldIndex
     */
    focusRowIndexChanged(newIndex, oldIndex) {
      // focusRowIndex is 0-based where aria-rowindex is 1-based.
      this.setAttribute('aria-rowindex', newIndex + 1);

      // Only set ID if it matches what was previously set. This prevents
      // overriding the ID value if it's set elsewhere.
      if (this.id === this.computeId_(oldIndex)) {
        this.id = this.computeId_(newIndex);
      }
    },

    /** @private {?Element} */
    firstControl_: null,

    /** @private {!Array<!MutationObserver>} */
    controlObservers_: [],

    /** @override */
    attached() {
      this.classList.add('no-outline');

      afterNextRender(this, function() {
        const rowContainer = this.root.querySelector('[focus-row-container]');
        assert(rowContainer);
        this.row_ = new VirtualFocusRow(
            rowContainer, new FocusRowBehaviorDelegate(this));
        this.addItems_();

        // Adding listeners asynchronously to reduce blocking time, since this
        // behavior will be used by items in potentially long lists.
        this.listen(this, 'focus', 'onFocus_');
        this.listen(this, 'dom-change', 'addItems_');
        this.listen(this, 'mousedown', 'onMouseDown_');
        this.listen(this, 'blur', 'onBlur_');
      });
    },

    /** @override */
    detached() {
      this.unlisten(this, 'focus', 'onFocus_');
      this.unlisten(this, 'dom-change', 'addItems_');
      this.unlisten(this, 'mousedown', 'onMouseDown_');
      this.unlisten(this, 'blur', 'onBlur_');
      this.removeObservers_();
      if (this.firstControl_) {
        this.unlisten(this.firstControl_, 'keydown', 'onFirstControlKeydown_');
      }
      if (this.row_) {
        this.row_.destroy();
      }
    },

    /** @return {!FocusRow} */
    getFocusRow() {
      return assert(this.row_);
    },

    /** @private */
    updateFirstControl_() {
      const newFirstControl = this.row_.getFirstFocusable();
      if (newFirstControl === this.firstControl_) {
        return;
      }

      if (this.firstControl_) {
        this.unlisten(this.firstControl_, 'keydown', 'onFirstControlKeydown_');
      }
      this.firstControl_ = newFirstControl;
      if (this.firstControl_) {
        this.listen(
            /** @type {!Element} */ (this.firstControl_), 'keydown',
            'onFirstControlKeydown_');
      }
    },

    /** @private */
    removeObservers_() {
      if (this.controlObservers_.length > 0) {
        this.controlObservers_.forEach(observer => {
          observer.disconnect();
        });
      }
      this.controlObservers_ = [];
    },

    /** @private */
    addItems_() {
      this.ironListTabIndexChanged_();
      if (this.row_) {
        this.removeObservers_();
        this.row_.destroy();

        const controls = this.root.querySelectorAll('[focus-row-control]');

        controls.forEach(control => {
          this.row_.addItem(
              control.getAttribute('focus-type'),
              /** @type {!HTMLElement} */
              (FocusRow.getFocusableElement(control)));
          this.addMutationObservers_(assert(control));
        });
        this.updateFirstControl_();
      }
    },

    /**
     * @return {!MutationObserver}
     * @private
     */
    createObserver_() {
      return new MutationObserver(mutations => {
        const mutation = mutations[0];
        if (mutation.attributeName === 'style' && mutation.oldValue) {
          const newStyle = window.getComputedStyle(
              /** @type {!Element} */ (mutation.target));
          const oldDisplayValue = mutation.oldValue.match(/^display:(.*)(?=;)/);
          const oldVisibilityValue =
              mutation.oldValue.match(/^visibility:(.*)(?=;)/);
          // Return early if display and visibility have not changed.
          if (oldDisplayValue &&
              newStyle.display === oldDisplayValue[1].trim() &&
              oldVisibilityValue &&
              newStyle.visibility === oldVisibilityValue[1].trim()) {
            return;
          }
        }
        this.updateFirstControl_();
      });
    },

    /**
     * The first focusable control changes if hidden, disabled, or style.display
     * changes for the control or any of its ancestors. Add mutation observers
     * to watch for these changes in order to ensure the first control keydown
     * listener is always on the correct element.
     * @param {!Element} control
     * @private
     */
    addMutationObservers_(control) {
      let current = control;
      while (current && current !== this.root) {
        const currentObserver = this.createObserver_();
        currentObserver.observe(current, {
          attributes: true,
          attributeFilter: ['hidden', 'disabled', 'style'],
          attributeOldValue: true,
        });
        this.controlObservers_.push(currentObserver);
        current = current.parentNode;
      }
    },

    /**
     * This function gets called when the row itself receives the focus event.
     * @param {!Event} e The focus event
     * @private
     */
    onFocus_(e) {
      if (this.mouseFocused_) {
        this.mouseFocused_ = false;  // Consume and reset flag.
        return;
      }

      // If focus is being restored from outside the item and the event is fired
      // by the list item itself, focus the first control so that the user can
      // tab through all the controls. When the user shift-tabs back to the row,
      // or focus is restored to the row from a dropdown on the last item, the
      // last child item will be focused before the row itself. Since this is
      // the desired behavior, do not shift focus to the first item in these
      // cases.
      const restoreFocusToFirst =
          this.listBlurred && e.composedPath()[0] === this;

      if (this.lastFocused && !restoreFocusToFirst) {
        focusWithoutInk(this.row_.getEquivalentElement(this.lastFocused));
      } else {
        const firstFocusable = assert(this.firstControl_);
        focusWithoutInk(firstFocusable);
      }
      this.listBlurred = false;
      this.isFocused = true;
    },

    /** @param {!KeyboardEvent} e */
    onFirstControlKeydown_(e) {
      if (e.shiftKey && e.key === 'Tab') {
        this.focus();
      }
    },

    /** @private */
    ironListTabIndexChanged_() {
      if (this.row_) {
        this.row_.makeActive(this.ironListTabIndex === 0);
      }

      // If a new row is being focused, reset listBlurred. This means an item
      // has been removed and iron-list is about to focus the next item.
      if (this.ironListTabIndex === 0) {
        this.listBlurred = false;
      }
    },

    /** @private */
    onMouseDown_() {
      this.mouseFocused_ = true;  // Set flag to not do any control-focusing.
    },

    /**
     * @param {!Event} e
     * @private
     */
    onBlur_(e) {
      // Reset focused flags since it's not active anymore.
      this.mouseFocused_ = false;
      this.isFocused = false;

      const node =
          e.relatedTarget ? /** @type {!Node} */ (e.relatedTarget) : null;
      if (!this.parentNode.contains(node)) {
        this.listBlurred = true;
      }
    },
  };

  /** @interface */
  export class FocusRowBehaviorInterface {
    constructor() {
      /** @type {string} */
      this.id;

      /** @type {boolean} */
      this.isFocused;

      /** @type {number} */
      this.focusRowIndex;

      /** @type {?HTMLElement} */
      this.lastFocused;

      /** @type {number} */
      this.ironListTabIndex;

      /** @type {boolean} */
      this.listBlurred;
    }

    /**
     * @param {number} newIndex
     * @param {number} oldIndex
     */
    focusRowIndexChanged(newIndex, oldIndex) {}

    /** @return {!FocusRow} */
    getFocusRow() {}
  }