chromium/ash/webui/common/resources/sea_pen/sea_pen_options_element.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.

/**
 * @fileoverview A polymer element that displays all the options to fill in the
 * template placeholder.
 */

import 'chrome://resources/ash/common/personalization/common.css.js';
import 'chrome://resources/ash/common/personalization/cros_button_style.css.js';

import {CrButtonElement} from 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {IronA11yKeysElement} from 'chrome://resources/polymer/v3_0/iron-a11y-keys/iron-a11y-keys.js';
import {IronSelectorElement} from 'chrome://resources/polymer/v3_0/iron-selector/iron-selector.js';
import {afterNextRender, Debouncer, PolymerElement, timeOut} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {SeaPenOption} from './constants.js';
import {SeaPenTemplateChip} from './sea_pen_generated.mojom-webui.js';
import {getTemplate} from './sea_pen_options_element.html.js';
import {ChipToken, isNonEmptyArray} from './sea_pen_utils.js';

const SeaPenOptionsElementBase = I18nMixin(PolymerElement);

export class SeaPenOptionEscapeEvent extends CustomEvent<null> {
  static readonly EVENT_NAME = 'sea-pen-option-escape';

  constructor() {
    super(
        SeaPenOptionEscapeEvent.EVENT_NAME,
        {
          bubbles: true,
          composed: true,
          detail: null,
        },
    );
  }
}

export interface SeaPenOptionsElement {
  $: {
    container: HTMLDivElement,
    expandButton: CrButtonElement,
    optionKeys: IronA11yKeysElement,
    optionSelector: IronSelectorElement,
  };
}

export class SeaPenOptionsElement extends SeaPenOptionsElementBase {
  static get is() {
    return 'sea-pen-options';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      options: {
        type: Array,
      },

      selectedChip: {
        type: Object,
        observer: 'onSelectedChipChanged_',
      },

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

      chipsExpanded_: {
        type: Boolean,
        value: false,
      },

      shouldShowExpandButton_: {
        type: Boolean,
        value: false,
      },

      ironSelectedOption_: Object,
    };
  }

  private options: SeaPenOption[]|null;
  private selectedChip: ChipToken|null;
  private selectedOptions: Map<SeaPenTemplateChip, SeaPenOption>;
  private chipsExpanded_: boolean;
  private shouldShowExpandButton_: boolean;
  private debouncer_: Debouncer;
  private onResized_: () => void = () => {
    this.debouncer_ =
        Debouncer.debounce(this.debouncer_, timeOut.after(50), () => {
          this.calculateHiddenOptions_();
        });
  };
  private ironSelectedOption_: HTMLElement;

  override connectedCallback() {
    super.connectedCallback();

    window.addEventListener('resize', this.onResized_);
  }

  override disconnectedCallback() {
    super.disconnectedCallback();

    window.removeEventListener('resize', this.onResized_);
  }

  // set focus on the nth option of the option list.
  private focusOnTargetOption_(n: number) {
    const prevButton = this.ironSelectedOption_;
    // Remove focus state of previous option button.
    if (prevButton) {
      prevButton.removeAttribute('tabindex');
    }

    // update focus state on the nth option.
    this.$.optionSelector.selectIndex(n);
    this.ironSelectedOption_.setAttribute('tabindex', '0');
    this.ironSelectedOption_.focus();
  }

  // handle keyboard navigation.
  private onOptionKeyPressed_(
      e: CustomEvent<{key: string, keyboardEvent: KeyboardEvent}>) {
    const selector = this.$.optionSelector;
    const prevButton = this.ironSelectedOption_;

    switch (e.detail.key) {
      case 'left':
        selector.selectPrevious();
        // If the previous item is hidden after pressing 'left' key at the first
        // option or at Expand button, we should navigate to the last visible
        // chip option.
        if (this.isHiddenOptionSelected_()) {
          selector.selectIndex(this.getLastVisibleChipOptionIndex_());
        }
        break;
      case 'right':
        selector.selectNext();
        if (this.isHiddenExpandButtonSelected_()) {
          // If the options are fully expanded and the previous selector is at
          // last chip option, pressing 'right' should navigate to the first
          // chip option.
          selector.selectIndex(0);
        } else if (this.isHiddenChipOptionSelected_()) {
          // If the next option is hidden, select and focus the expand button.
          const expandButton = selector.querySelector('#expandButton');
          selector.selectIndex(selector.indexOf(expandButton!));
        }
        break;
      case 'esc':
        this.dispatchEvent(new SeaPenOptionEscapeEvent());
        return;
      default:
        return;
    }
    // Remove focus state of previous button.
    if (prevButton) {
      prevButton.removeAttribute('tabindex');
    }
    // Add focus state for new button.
    if (this.ironSelectedOption_) {
      this.ironSelectedOption_.setAttribute('tabindex', '0');
      this.ironSelectedOption_.focus();
    }
    e.detail.keyboardEvent.preventDefault();
  }

  private onClickOption_(event: Event&{model: {option: SeaPenOption}}) {
    const option = event.model.option;
    // Notifies the selected options has changed to the UI by overriding Polymer
    // dirty check
    this.selectedOptions.set(this.selectedChip!.id, option);
    const copiedSelectedOptions = this.selectedOptions;
    this.selectedOptions = new Map<SeaPenTemplateChip, SeaPenOption>();
    this.selectedOptions = copiedSelectedOptions;
    // Stop the event propagation, otherwise, the event will be passed to parent
    // element (sea pen template query element), onClick_ on template query
    // element will be triggered improperly.
    event.preventDefault();
    event.stopPropagation();
  }

  private isHiddenOptionSelected_() {
    return this.ironSelectedOption_.classList.contains('hidden');
  }

  private isHiddenExpandButtonSelected_() {
    return this.ironSelectedOption_?.id === 'expandButton' &&
        this.isHiddenOptionSelected_();
  }

  private isHiddenChipOptionSelected_() {
    return this.ironSelectedOption_.classList.contains('option') &&
        this.isHiddenOptionSelected_();
  }

  private isSelected_(
      option: SeaPenOption, selectedChip: ChipToken|null,
      selectedOptions: Map<SeaPenTemplateChip, SeaPenOption>): boolean {
    return !!selectedOptions && !!selectedChip &&
        selectedOptions.has(selectedChip.id) &&
        option === selectedOptions.get(selectedChip.id);
  }

  private getLastVisibleChipOptionIndex_(): number {
    const options = this.shadowRoot!.querySelectorAll<CrButtonElement>(
        '.option:not(.hidden)');
    return options.length > 0 ? options.length - 1 : 0;
  }

  private getOptionTabIndex_(
      option: SeaPenOption, selectedChip: ChipToken|null,
      selectedOptions: Map<SeaPenTemplateChip, SeaPenOption>): string {
    return this.isSelected_(option, selectedChip, selectedOptions) ? '0' : '-1';
  }

  private getOptionAriaChecked_(
      option: SeaPenOption, selectedChip: ChipToken|null,
      selectedOptions: Map<SeaPenTemplateChip, SeaPenOption>): string {
    return this.isSelected_(option, selectedChip, selectedOptions).toString();
  }

  private calculateHiddenOptions_() {
    if (this.chipsExpanded_ || !isNonEmptyArray(this.options)) {
      return;
    }
    this.shouldShowExpandButton_ = true;
    const items = Array.from(
        this.shadowRoot!.querySelectorAll<CrButtonElement>('.option'));
    // Add a placeholder to hold the button width.
    this.$.expandButton.innerText =
        this.i18n('seaPenExpandOptionsButton', items.length);
    const gap = 8;  // 8px gap between chips.
    const expandButtonWidth = this.$.expandButton.clientWidth + gap;

    let row = 1;
    let remainingWidth: number = this.$.container.clientWidth;
    let numHiddenItems: number = 0;
    items.forEach((item, i) => {
      item.classList.remove('hidden');
      const itemWidth = item.clientWidth + gap;
      if (itemWidth <= remainingWidth) {
        remainingWidth -= itemWidth;
      } else {
        // Insufficient space to fit in another chip.
        switch (row) {
          case 1:
            remainingWidth =
                this.$.container.clientWidth - itemWidth - expandButtonWidth;
            row++;
            break;
          case 2:
            // Hide expand button if the last chip can fit in the second row.
            if (i < this.options!.length - 1 ||
                itemWidth > remainingWidth + expandButtonWidth) {
              numHiddenItems = this.options!.length - i;
              item.classList.add('hidden');
            }
            remainingWidth = 0;
            row++;
            break;
          case 3:
            // The number of chips to display may change both ways so we always
            // need to go through the whole list of chips again.
            item.classList.add('hidden');
            break;
        }
      }
    });
    this.shouldShowExpandButton_ = numHiddenItems > 0;
    if (this.shouldShowExpandButton_) {
      this.$.expandButton.innerText =
          this.i18n('seaPenExpandOptionsButton', numHiddenItems);
    }
  }

  private onClickExpandButton_(event: Event) {
    this.chipsExpanded_ = true;
    this.shouldShowExpandButton_ = false;
    let firstHiddenIndex = -1;
    this.shadowRoot!.querySelectorAll('.option').forEach((option, index) => {
      if (firstHiddenIndex === -1 && option.classList.contains('hidden')) {
        firstHiddenIndex = index;
      }
      option.classList.remove('hidden');
    });

    if (firstHiddenIndex >= 0) {
      afterNextRender(this, () => {
        // focus on the option that was first hidden before clicking on expand
        // button.
        this.focusOnTargetOption_(firstHiddenIndex);
      });
    }

    // Stop the event propagation, otherwise, the event will be passed to parent
    // element (sea pen template query element), onClick_ on template query
    // element will be triggered improperly.
    event.preventDefault();
    event.stopPropagation();
  }

  private onSelectedChipChanged_() {
    this.chipsExpanded_ = false;
    this.shouldShowExpandButton_ = false;
    afterNextRender(this, () => {
      // Called when the options are fully rendered.
      this.calculateHiddenOptions_();
      // focus on the first option of the list when clicking on a chip.
      this.focusOnTargetOption_(0);
    });
  }

  private getOptionsClassName_(chipsExpanded: boolean): string {
    return chipsExpanded ? 'expanded' : '';
  }

  private getExpandButtonClassName_(shouldShowExpandButton: boolean): string {
    return shouldShowExpandButton ? '' : 'hidden';
  }
}

customElements.define(SeaPenOptionsElement.is, SeaPenOptionsElement);