chromium/ash/webui/common/resources/cr_elements/cr_tabs/cr_tabs.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.

/**
 * @fileoverview 'cr-tabs' is a control used for selecting different sections or
 * tabs. cr-tabs was created to replace paper-tabs and paper-tab. cr-tabs
 * displays the name of each tab provided by |tabs|. A 'selected-changed' event
 * is fired any time |selected| is changed.
 *
 * cr-tabs takes its #selectionBar animation from paper-tabs.
 *
 * Keyboard behavior
 *   - Home, End, ArrowLeft and ArrowRight changes the tab selection
 *
 * Known limitations
 *   - no "disabled" state for the cr-tabs as a whole or individual tabs
 *   - cr-tabs does not accept any <slot> (not necessary as of this writing)
 *   - no horizontal scrolling, it is assumed that tabs always fit in the
 *     available space
 *
 * Forked from ui/webui/resources/cr_elements/cr_tabs/cr_tabs.ts
 */
import '../cr_hidden_style.css.js';
import '../cr_shared_vars.css.js';

import {DomRepeatEvent, PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './cr_tabs.html.js';

export class CrTabsElement extends PolymerElement {
  static get is() {
    return 'cr-tabs';
  }

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

  static get properties() {
    return {
      // Optional icon urls displayed in each tab.
      tabIcons: {
        type: Array,
        value: () => [],
      },

      // Tab names displayed in each tab.
      tabNames: {
        type: Array,
        value: () => [],
      },

      /** Index of the selected tab. */
      selected: {
        type: Number,
        notify: true,
        observer: 'onSelectedChanged_',
      },
    };
  }

  tabIcons: string[];
  tabNames: string[];
  selected: number;

  private isRtl_: boolean = false;
  private lastSelected_: number|null = null;

  override connectedCallback() {
    super.connectedCallback();
    this.isRtl_ = this.matches(':host-context([dir=rtl]) cr-tabs');
  }

  override ready() {
    super.ready();

    this.setAttribute('role', 'tablist');
    this.addEventListener('keydown', this.onKeyDown_.bind(this));
  }

  private getAriaSelected_(index: number): string {
    return index === this.selected ? 'true' : 'false';
  }

  private getIconStyle_(index: number): string {
    const icon = this.tabIcons[index];
    return icon ? `-webkit-mask-image: url(${icon}); display: block;` : '';
  }

  private getTabindex_(index: number): string {
    return index === this.selected ? '0' : '-1';
  }

  private getSelectedClass_(index: number): string {
    return index === this.selected ? 'selected' : '';
  }

  private onSelectedChanged_(newSelected: number, oldSelected: number) {
    const tabs = this.shadowRoot!.querySelectorAll('.tab');
    if (tabs.length === 0 || oldSelected === undefined ||
        tabs.length <= newSelected || tabs.length <= oldSelected) {
      // Tabs are not fully rendered yet.
      return;
    }

    const oldTabRect = tabs[oldSelected]!.getBoundingClientRect();
    const newTabRect = tabs[newSelected]!.getBoundingClientRect();

    const newIndicator =
        tabs[newSelected]!.querySelector<HTMLElement>('.tab-indicator')!;
    newIndicator.classList.remove('expand', 'contract');

    // Make new indicator look like it is the old indicator.
    this.updateIndicator_(
        newIndicator, newTabRect, oldTabRect.left, oldTabRect.width);
    newIndicator.getBoundingClientRect();  // Force repaint.

    // Expand to cover both the previous selected tab, the newly selected tab,
    // and everything in between.
    newIndicator.classList.add('expand');
    newIndicator.addEventListener(
        'transitionend', e => this.onIndicatorTransitionEnd_(e), {once: true});
    const leftmostEdge = Math.min(oldTabRect.left, newTabRect.left);
    const fullWidth = newTabRect.left > oldTabRect.left ?
        newTabRect.right - oldTabRect.left :
        oldTabRect.right - newTabRect.left;
    this.updateIndicator_(newIndicator, newTabRect, leftmostEdge, fullWidth);
  }

  private onKeyDown_(e: KeyboardEvent) {
    const count = this.tabNames.length;
    let newSelection;
    if (e.key === 'Home') {
      newSelection = 0;
    } else if (e.key === 'End') {
      newSelection = count - 1;
    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
      const delta = e.key === 'ArrowLeft' ? (this.isRtl_ ? 1 : -1) :
                                            (this.isRtl_ ? -1 : 1);
      newSelection = (count + this.selected + delta) % count;
    } else {
      return;
    }
    e.preventDefault();
    e.stopPropagation();
    this.selected = newSelection;
    this.shadowRoot!.querySelector<HTMLElement>('.tab.selected')!.focus();
  }

  private onIndicatorTransitionEnd_(event: Event) {
    const indicator = event.target as HTMLElement;
    indicator.classList.replace('expand', 'contract');
    indicator.style.transform = `translateX(0) scaleX(1)`;
  }

  private onTabClick_(e: DomRepeatEvent<string>) {
    this.selected = e.model.index;
  }

  private updateIndicator_(
      indicator: HTMLElement, originRect: ClientRect, newLeft: number,
      newWidth: number) {
    const leftDiff = 100 * (newLeft - originRect.left) / originRect.width;
    const widthRatio = newWidth / originRect.width;
    const transform = `translateX(${leftDiff}%) scaleX(${widthRatio})`;
    indicator.style.transform = transform;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'cr-tabs': CrTabsElement;
  }
}

customElements.define(CrTabsElement.is, CrTabsElement);