chromium/ui/webui/resources/cr_elements/cr_button/cr_button.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-button' is a button which displays slotted elements. It can
 * be interacted with like a normal button using click as well as space and
 * enter to effectively click the button and fire a 'click' event. It can also
 * style an icon inside of the button with the [has-icon] attribute.
 */
import {FocusOutlineManager} from '//resources/js/focus_outline_manager.js';
import {CrLitElement} from '//resources/lit/v3_0/lit.rollup.js';
import type {PropertyValues} from '//resources/lit/v3_0/lit.rollup.js';

import {CrRippleMixin} from '../cr_ripple/cr_ripple_mixin.js';

import {getCss} from './cr_button.css.js';
import {getHtml} from './cr_button.html.js';

export interface CrButtonElement {
  $: {
    prefixIcon: HTMLSlotElement,
    suffixIcon: HTMLSlotElement,
  };
}

const CrButtonElementBase = CrRippleMixin(CrLitElement);

export class CrButtonElement extends CrButtonElementBase {
  static get is() {
    return 'cr-button';
  }

  static override get styles() {
    return getCss();
  }

  override render() {
    return getHtml.bind(this)();
  }

  static override get properties() {
    return {
      disabled: {
        type: Boolean,
        reflect: true,
      },

      hasPrefixIcon_: {
        type: Boolean,
        reflect: true,
      },

      hasSuffixIcon_: {
        type: Boolean,
        reflect: true,
      },
    };
  }

  disabled: boolean = false;
  protected hasPrefixIcon_: boolean = false;
  protected hasSuffixIcon_: boolean = false;

  /**
   * It is possible to activate a tab when the space key is pressed down. When
   * this element has focus, the keyup event for the space key should not
   * perform a 'click'. |spaceKeyDown_| tracks when a space pressed and
   * handled by this element. Space keyup will only result in a 'click' when
   * |spaceKeyDown_| is true. |spaceKeyDown_| is set to false when element
   * loses focus.
   */
  private spaceKeyDown_: boolean = false;
  private timeoutIds_: Set<number> = new Set();

  constructor() {
    super();

    this.addEventListener('blur', this.onBlur_.bind(this));
    // Must be added in constructor so that stopImmediatePropagation() works as
    // expected.
    this.addEventListener('click', this.onClick_.bind(this));
    this.addEventListener('keydown', this.onKeyDown_.bind(this));
    this.addEventListener('keyup', this.onKeyUp_.bind(this));
    this.ensureRippleOnPointerdown();
  }

  override firstUpdated() {
    if (!this.hasAttribute('role')) {
      this.setAttribute('role', 'button');
    }
    if (!this.hasAttribute('tabindex')) {
      this.setAttribute('tabindex', '0');
    }

    FocusOutlineManager.forDocument(document);
  }

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

    if (changedProperties.has('disabled')) {
      this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
      this.disabledChanged_(this.disabled, changedProperties.get('disabled'));
    }
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    this.timeoutIds_.forEach(clearTimeout);
    this.timeoutIds_.clear();
  }

  private setTimeout_(fn: () => void, delay?: number) {
    if (!this.isConnected) {
      return;
    }
    const id = setTimeout(() => {
      this.timeoutIds_.delete(id);
      fn();
    }, delay);
    this.timeoutIds_.add(id);
  }

  private disabledChanged_(newValue: boolean, oldValue: boolean|undefined) {
    if (!newValue && oldValue === undefined) {
      return;
    }
    if (this.disabled) {
      this.blur();
    }
    this.setAttribute('tabindex', String(this.disabled ? -1 : 0));
  }

  private onBlur_() {
    this.spaceKeyDown_ = false;
    // If a keyup event is never fired (e.g. after keydown the focus is moved to
    // another element), we need to clear the ripple here. 100ms delay was
    // chosen manually as a good time period for the ripple to be visible.
    this.setTimeout_(() => this.getRipple().uiUpAction(), 100);
  }

  private onClick_(e: Event) {
    if (this.disabled) {
      e.stopImmediatePropagation();
    }
  }

  protected onPrefixIconSlotChanged_() {
    this.hasPrefixIcon_ = this.$.prefixIcon.assignedElements().length > 0;
  }

  protected onSuffixIconSlotChanged_() {
    this.hasSuffixIcon_ = this.$.suffixIcon.assignedElements().length > 0;
  }

  private onKeyDown_(e: KeyboardEvent) {
    if (e.key !== ' ' && e.key !== 'Enter') {
      return;
    }

    e.preventDefault();
    e.stopPropagation();

    if (e.repeat) {
      return;
    }

    this.getRipple().uiDownAction();
    if (e.key === 'Enter') {
      this.click();
      // Delay was chosen manually as a good time period for the ripple to be
      // visible.
      this.setTimeout_(() => this.getRipple().uiUpAction(), 100);
    } else if (e.key === ' ') {
      this.spaceKeyDown_ = true;
    }
  }

  private onKeyUp_(e: KeyboardEvent) {
    if (e.key !== ' ' && e.key !== 'Enter') {
      return;
    }

    e.preventDefault();
    e.stopPropagation();

    if (this.spaceKeyDown_ && e.key === ' ') {
      this.spaceKeyDown_ = false;
      this.click();
      this.getRipple().uiUpAction();
    }
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'cr-button': CrButtonElement;
  }
}

customElements.define(CrButtonElement.is, CrButtonElement);