chromium/ui/webui/resources/cr_elements/cr_selectable_mixin.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.

import {assert} from '//resources/js/assert.js';
import type {CrLitElement, PropertyValues} from '//resources/lit/v3_0/lit.rollup.js';

/**
 * CrSelectableMixin maintains a collection of selectable elements. The
 * elements are queried from the light DOM, and are identified using a
 * |selectable| CSS selector, if specified.
 *
 * The mixin observes click events on its children, and selects an item when
 * clicked. Items can also be selected using the select* methods, or by
 * updating the |selected| property. The mixin sets the 'selected' CSS
 * class on the selected item, if any, and also sets the |selectedAttribute|
 * boolean attribute on the selected item if it is specified.
 *
 * Events fired:
 * iron-activate: Fired when an item is activated by a "click" event, before
 * the item is selected (see below).
 * iron-select: Fired when an item is selected.
 * iron-deselect: Fired when an item is deselected.
 * iron-items-changed: Fired when the list of selectable items changes.
 * TODO (rbpotter): Rename these events, now that they are not fired by an
 * "iron-" behavior.
 */

type Constructor<T> = new (...args: any[]) => T;

export const CrSelectableMixin = <T extends Constructor<CrLitElement>>(
    superClass: T): T&Constructor<CrSelectableMixinInterface> => {
  class CrSelectableMixin extends superClass implements
      CrSelectableMixinInterface {
    static get properties() {
      return {
        /**
         * To use an attribute value of an element for determining `selected`
         * instead of using the index, set this property to the name of the HTML
         * attribute.
         */
        attrForSelected: {type: String},

        /**
         * Gets or sets the selected value. The default is to use the index of
         * the selected item. If attrForSelected is set, this is instead the
         * value of the |attrForSelected| attribute of the selected item.
         */
        selected: {
          type: String,
          notify: true,
        },

        /** Boolean attribute name to set on items that are selected. */
        selectedAttribute: {type: String},

        /**
         * This is a CSS selector string.  If this is set, only items that match
         * the CSS selector are selectable.
         */
        selectable: {type: String},
      };
    }

    attrForSelected: string|null = null;
    selectable?: string;
    selected?: string|number;
    selectedAttribute: string|null = null;

    // Whether to select items when they or their children are clicked. Note:
    // value is only checked in firstUpdated().
    selectOnClick: boolean = true;

    private items_: Element[] = [];
    private selectedItem_: Element|null = null;

    override firstUpdated(changedProperties: PropertyValues<this>) {
      super.firstUpdated(changedProperties);
      if (this.selectOnClick) {
        this.addEventListener('click', e => this.onClick_(e));
      }
      this.observeItems();
    }

    // Override this method in client code to modify the observation logic,
    // or to turn it off completely. By default it listens for any changes on
    // the first <slot> node in this shadowRoot.
    observeItems() {
      this.getSlot().addEventListener('slotchange', () => this.itemsChanged());
    }

    override connectedCallback() {
      super.connectedCallback();
      this.updateItems_();
    }

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

      if (changedProperties.has('attrForSelected')) {
        if (this.selectedItem_) {
          assert(this.attrForSelected);
          const value = this.selectedItem_.getAttribute(this.attrForSelected);
          assert(value !== null);
          this.selected = value;
        }
      }
    }

    override updated(changedProperties: PropertyValues<this>) {
      super.updated(changedProperties);
      if (changedProperties.has('selected')) {
        this.updateSelectedItem_();
      }
    }

    /**
     * Selects the given value.
     */
    select(value: string|number) {
      this.selected = value;
    }

    /**
     * Selects the previous item.
     */
    selectPrevious() {
      const length = this.items_.length;
      let index = length - 1;
      if (this.selected !== undefined) {
        index = ((this.valueToIndex_(this.selected)) - 1 + length) % length;
      }
      this.selected = this.indexToValue_(index);
    }

    /**
     * Selects the next item.
     */
    selectNext() {
      const index = this.selected === undefined ?
          0 :
          (this.valueToIndex_(this.selected) + 1) % this.items_.length;
      this.selected = this.indexToValue_(index);
    }

    getItemsForTest(): Element[] {
      return this.items_;
    }

    getSlot(): HTMLSlotElement {
      const slot = this.shadowRoot!.querySelector('slot');
      assert(slot);
      return slot;
    }

    // Override this method in client code to modify this logic, for example to
    // grab children that don't reside in a <slot>.
    queryItems(): Element[] {
      const selectable = this.selectable === undefined ? '*' : this.selectable;
      return Array.from(this.querySelectorAll(`:scope > ${selectable}`));
    }

    // If overriding queryItems(), override this method to return the list item
    // element matching the CSS selector string |selector|.
    queryMatchingItem(selector: string): HTMLElement|null {
      const selectable = this.selectable || '*';
      return this.querySelector<HTMLElement>(
          `:scope > :is(${selectable})${selector}`);
    }

    private updateItems_() {
      this.items_ = this.queryItems();
      this.items_.forEach(
          (item, index) =>
              item.setAttribute('data-selection-index', index.toString()));
    }

    get selectedItem(): Element|null {
      return this.selectedItem_;
    }

    private updateSelectedItem_() {
      if (!this.items_) {
        return;
      }

      const item = this.selected == null ?
          null :
          this.items_[this.valueToIndex_(this.selected)];
      if (!!item && this.selectedItem_ !== item) {
        this.setItemSelected_(this.selectedItem_, false);
        this.setItemSelected_(item, true);
      } else if (!item) {
        this.setItemSelected_(this.selectedItem_, false);
      }
    }

    private setItemSelected_(item: Element|null, isSelected: boolean) {
      if (!item) {
        return;
      }

      item.classList.toggle('selected', isSelected);
      if (this.selectedAttribute) {
        item.toggleAttribute(this.selectedAttribute, isSelected);
      }
      this.selectedItem_ = isSelected ? item : null;
      this.fire('iron-' + (isSelected ? 'select' : 'deselect'), {item: item});
    }

    private valueToIndex_(value: string|number): number {
      if (!this.attrForSelected) {
        return Number(value);
      }

      const match =
          this.queryMatchingItem(`[${this.attrForSelected}="${value}"]`);
      return match ? Number(match.dataset['selectionIndex']) : -1;
    }

    private indexToValue_(index: number): string|number {
      if (!this.attrForSelected) {
        return index;
      }

      const item = this.items_[index];
      if (!item) {
        return index;
      }

      return item.getAttribute(this.attrForSelected) || index;
    }

    itemsChanged() {
      this.updateItems_();
      this.updateSelectedItem_();

      // Let other interested parties know about the change.
      this.fire('iron-items-changed');
    }

    private onClick_(e: MouseEvent) {
      let element = e.target as HTMLElement;
      while (element && element !== this) {
        const idx = this.items_.indexOf(element);
        if (idx >= 0) {
          const value = this.indexToValue_(idx);
          assert(value !== null);
          this.fire('iron-activate', {item: element, selected: value});
          this.select(value);
          return;
        }
        element = element.parentNode as HTMLElement;
      }
    }
  }

  return CrSelectableMixin;
};

export interface CrSelectableMixinInterface {
  attrForSelected: string|null;
  selected?: string|number;
  selectable?: string;
  readonly selectedItem: Element|null;
  selectOnClick: boolean;

  getItemsForTest(): Element[];
  getSlot(): HTMLSlotElement;
  itemsChanged(): void;
  selectNext(): void;
  selectPrevious(): void;
  select(value: string|number): void;

  // Methods to override to modify default behavior.
  observeItems(): void;
  queryItems(): Element[];
  queryMatchingItem(selector: string): HTMLElement|null;
}