chromium/ui/webui/resources/cr_elements/focus_row_mixin_lit.ts

// Copyright 2024 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 type {CrLitElement, PropertyValues} from '//resources/lit/v3_0/lit.rollup.js';
import {assert} from '//resources/js/assert.js';
import {focusWithoutInk} from '//resources/js/focus_without_ink.js';
import {FocusRow, VirtualFocusRow} from '//resources/js/focus_row.js';

import {FocusRowMixinDelegate} from './focus_row_mixin_delegate.js';
// clang-format on

/**
 * Any element that is being used as an iron-list or infinite-list row item can
 * extend this mixin, which encapsulates focus controls of mouse and keyboards.
 * To use this mixin:
 *    - 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.
 *
 */
type Constructor<T> = new (...args: any[]) => T;

export const FocusRowMixinLit = <T extends Constructor<CrLitElement>>(
    superClass: T): T&Constructor<FocusRowMixinLitInterface> => {
  class FocusRowMixinLit extends superClass implements
      FocusRowMixinLitInterface {
    static get properties() {
      return {
        row_: {type: Object},
        mouseFocused_: {type: Boolean},

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

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

        focusRowIndex: {type: Number},

        lastFocused: {
          type: Object,
          notify: true,
        },

        listTabIndex: {type: Number},

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

    private row_: VirtualFocusRow|null = null;
    private mouseFocused_: boolean = false;

    // For notifying when the row is in focus.
    isFocused: boolean = false;

    // Should be bound to the index of the item from the iron-list or
    // infinite-list.
    focusRowIndex?: number;

    lastFocused: HTMLElement|null = null;

    /**
     * This is different from tabIndex, since the template only does a
     * one-way binding on both attributes, and the mixin makes use of this.
     * For example, when a control within a row is focused, it will have
     * tabIndex = -1 and listTabIndex = 0.
     */
    listTabIndex?: number;
    listBlurred: boolean = false;

    private firstControl_: HTMLElement|null = null;
    private controlObservers_: MutationObserver[] = [];
    private subtreeObserver_: MutationObserver|null = null;
    private boundOnFirstControlKeydown_: ((e: Event) => void)|null = null;

    override connectedCallback() {
      super.connectedCallback();
      this.classList.add('no-outline');
      this.boundOnFirstControlKeydown_ = this.onFirstControlKeydown_.bind(this);

      this.updateComplete.then(() => {
        const rowContainer = this.shadowRoot!.querySelector<HTMLElement>(
            '[focus-row-container]');
        assert(rowContainer);
        this.row_ =
            new VirtualFocusRow(rowContainer, new FocusRowMixinDelegate(this));
        this.addItems_();

        // Adding listeners asynchronously to reduce blocking time, since
        // this behavior will be used by items in potentially long lists.
        this.addEventListener('focus', this.onFocus_);
        this.subtreeObserver_ = new MutationObserver(() => this.addItems_());
        this.subtreeObserver_.observe(
            this.shadowRoot!, {childList: true, subtree: true});
        this.addEventListener('mousedown', this.onMouseDown_);
        this.addEventListener('blur', this.onBlur_);
      });
    }

    override disconnectedCallback() {
      super.disconnectedCallback();
      this.removeEventListener('focus', this.onFocus_);
      if (this.subtreeObserver_) {
        this.subtreeObserver_.disconnect();
        this.subtreeObserver_ = null;
      }
      this.removeEventListener('mousedown', this.onMouseDown_);
      this.removeEventListener('blur', this.onBlur_);
      this.removeObservers_();
      if (this.firstControl_ && this.boundOnFirstControlKeydown_) {
        this.firstControl_.removeEventListener(
            'keydown', this.boundOnFirstControlKeydown_);
        this.boundOnFirstControlKeydown_ = null;
      }
      if (this.row_) {
        this.row_.destroy();
      }
    }

    override willUpdate(changedProperties: PropertyValues<this>) {
      super.willUpdate(changedProperties);

      if (changedProperties.has('focusRowIndex') &&
          this.focusRowIndex !== undefined) {
        // focusRowIndex is 0-based where aria-rowindex is 1-based.
        this.setAttribute('aria-rowindex', (this.focusRowIndex + 1).toString());

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

    override updated(changedProperties: PropertyValues<this>) {
      super.updated(changedProperties);

      if (changedProperties.has('listTabIndex')) {
        this.listTabIndexChanged_();
      }
    }

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

    getFocusRow(): FocusRow {
      assert(this.row_);
      return this.row_;
    }

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

      if (this.firstControl_) {
        this.firstControl_.removeEventListener(
            'keydown', this.boundOnFirstControlKeydown_!);
      }
      this.firstControl_ = newFirstControl;
      if (this.firstControl_) {
        this.firstControl_.addEventListener(
            'keydown', this.boundOnFirstControlKeydown_!);
      }
    }

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

    private addItems_() {
      this.listTabIndexChanged_();
      if (this.row_) {
        this.removeObservers_();
        this.row_.destroy();

        const controls = this.shadowRoot!.querySelectorAll<HTMLElement>(
            '[focus-row-control]');

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

    private createObserver_(): MutationObserver {
      return new MutationObserver(mutations => {
        const mutation = mutations[0]!;
        if (mutation.attributeName === 'style' && mutation.oldValue) {
          const newStyle = window.getComputedStyle(mutation.target as Element);
          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.
     */
    private addMutationObservers_(control: Element) {
      let current: Node|null = control;
      while (current && current !== this.shadowRoot) {
        const currentObserver = this.createObserver_();
        currentObserver.observe(current as Element, {
          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.
     */
    private onFocus_(e: Event) {
      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) {
        assert(this.row_);
        focusWithoutInk(this.row_.getEquivalentElement(this.lastFocused));
      } else {
        assert(this.firstControl_);
        const firstFocusable = this.firstControl_;
        focusWithoutInk(firstFocusable);
      }
      this.listBlurred = false;
      this.isFocused = true;
    }

    private onFirstControlKeydown_(e: Event) {
      const keyEvent = e as KeyboardEvent;
      if (keyEvent.shiftKey && keyEvent.key === 'Tab') {
        this.focus();
      }
    }

    private listTabIndexChanged_() {
      if (this.row_) {
        this.row_.makeActive(this.listTabIndex === 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.listTabIndex === 0) {
        this.listBlurred = false;
      }
    }

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

    private onBlur_(e: FocusEvent) {
      // Reset focused flags since it's not active anymore.
      this.mouseFocused_ = false;
      this.isFocused = false;

      const node = e.relatedTarget ? e.relatedTarget as Node : null;
      if (!this.parentNode!.contains(node)) {
        this.listBlurred = true;
      }
    }
  }

  return FocusRowMixinLit;
};

export interface FocusRowMixinLitInterface {
  id: string;
  isFocused: boolean;
  focusRowIndex?: number;
  lastFocused: HTMLElement|null;
  listTabIndex?: number;
  listBlurred: boolean;
  overrideCustomEquivalent?: boolean;

  getCustomEquivalent?(el: HTMLElement): HTMLElement|null;

  getFocusRow(): FocusRow;
}