chromium/ash/webui/common/resources/quick_unlock/pin_keyboard.ts

// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview
 * 'pin-keyboard' is a keyboard that can be used to enter PINs or more generally
 * numeric values.
 *
 * Properties:
 *    value: The value of the PIN keyboard. Writing to this property will adjust
 *           the PIN keyboard's value.
 *
 * Events:
 *    pin-change: Fired when the PIN value has changed. The PIN is available at
 *                event.detail.pin.
 *    submit: Fired when the PIN is submitted. The PIN is available at
 *            event.detail.pin.
 *
 * Example:
 *    <pin-keyboard on-pin-change="onPinChange" on-submit="onPinSubmit">
 *    </pin-keyboard>
 */

import 'chrome://resources/ash/common/cr_elements/cros_color_overrides.css.js';
import 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_input/cr_input.js';
import 'chrome://resources/ash/common/cr_elements/icons.html.js';
import 'chrome://resources/ash/common/cr_elements/cr_shared_vars.css.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import './pin_keyboard_icons.html.js';

import {CrButtonElement} from 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import {CrInputElement} from 'chrome://resources/ash/common/cr_elements/cr_input/cr_input.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {WebUiListenerMixin} from 'chrome://resources/ash/common/cr_elements/web_ui_listener_mixin.js';
import {assert, assertInstanceof} from 'chrome://resources/js/assert.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

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

/**
 * Once auto backspace starts, the time between individual backspaces.
 * @type {number}
 * @const
 */
const REPEAT_BACKSPACE_DELAY_MS = 150;

/**
 * How long the backspace button must be held down before auto backspace
 * starts.
 * @type {number}
 * @const
 */
const INITIAL_BACKSPACE_DELAY_MS = 500;

/**
 * The key codes of the keys allowed to be used on the pin input, in addition to
 * number keys. We allow some editing keys. We also allow system keys, otherwise
 * preventDefault() will prevent the user from changing screen brightness,
 * taking screenshots, etc. https://crbug.com/1002863
 * @type {!Set<number>}
 * @const
 */
const PIN_INPUT_ALLOWED_NON_NUMBER_KEY_CODES = new Set([
  8,   // backspace
  9,   // tab
  27,  // escape
  37,  // left
  39,  // right
  // We don't allow back or forward.
  183,  // ZoomToggle, aka fullscreen
  182,  // LaunchApplication1, aka overview mode
  216,  // BrightnessDown
  217,  // BrightnessUp
  179,  // MediaPlayPause
  173,  // AudioVolumeMute
  174,  // AudioVolumeDown
  175,  // AudioVolumeUp
  154,  // LaunchControlPanel, aka system tray menu
]);

function receivedEventFromKeyboard(event: Event): boolean {
  if (!(event instanceof CustomEvent)) {
    return false;
  }
  if (!('sourceEvent' in event.detail)) {
    return false;
  }

  return event.detail.sourceEvent.detail === 0;
}

const PinKeyboardElementBase = WebUiListenerMixin(I18nMixin(PolymerElement));

export interface PinKeyboardElement {
  $: {
    pinInput: CrInputElement,
  };
}

export class PinKeyboardElement extends PinKeyboardElementBase {
  static get is(): string {
    return 'pin-keyboard' as const;
  }

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

  static get properties(): object {
    return {
      /**
       * Whether or not the keyboard's input element should be numerical
       * or password.
       */
      enablePassword: {
        type: Boolean,
        value: false,
      },

      // Whether or not non-digit pins are allowed.
      // If allowNonDigit is false, any characters typed in the pin dialog
      // will be swallowed.
      allowNonDigit: {
        type: Boolean,
        value: false,
      },

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

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

      /**
       * The password input element the pin keyboard is associated with. If this
       * is not set, then a default input element is shown and used. If set,
       * this must be an HTMLInputElement of a |type| to which the
       * |selectionStart| and |selectionEnd| attributes apply, for example
       * "password" but not "date".
       */
      passwordElement: {
        type: Object,
        value: null,
      },

      /**
       * The intervalID used for the backspace button set/clear interval.
       */
      repeatBackspaceIntervalId_: {
        type: Number,
        value: 0,
      },

      /**
       * The timeoutID used for the auto backspace.
       */
      startAutoBackspaceId_: {
        type: Number,
        value: 0,
      },

      /**
       * The value stored in the keyboard's input element.
       */
      value: {
        type: String,
        notify: true,
        value: '',
        observer: 'onPinValueChange_',
      },

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

      /**
       * Enables pin placeholder.
       */
      enablePlaceholder: {
        type: Boolean,
        value: false,
      },

      /**
       * The aria label to be used for the input element.
       */
      ariaLabel: {
        type: String,
      },
    };
  }

  enablePassword: boolean;
  allowNonDigit: boolean;
  hasError: boolean;
  disabled: boolean;
  passwordElement: HTMLElement|undefined;
  value: string;
  enablePlaceholder: boolean;

  private repeatBackspaceIntervalId_: number;
  private startAutoBackspaceId_: number;
  private focused_: boolean;

  override ready(): void {
    super.ready();
    this.addWebUiListener('blur', this.onBlur_.bind(this));
    this.addWebUiListener('focus', this.onFocus_.bind(this));
  }

  /**
   * Gets the selection start of the input field.
   */
  private get selectionStart_(): number {
    const selectionStart = this.passwordElement_().selectionStart;
    assert(selectionStart !== null);
    return selectionStart;
  }

  /**
   * Gets the selection end of the input field.
   */
  private get selectionEnd_(): number {
    const selectionEnd = this.passwordElement_().selectionEnd;
    assert(selectionEnd !== null);
    return selectionEnd;
  }

  /**
   * Sets the selection start of the input field.
   */
  private set selectionStart_(start: number) {
    this.passwordElement_().selectionStart = start;
  }

  /**
   * Sets the selection end of the input field.
   */
  private set selectionEnd_(end: number) {
    this.passwordElement_().selectionEnd = end;
  }

  /**
   * Transfers blur to the input element.
   */
  override blur(): void {
    this.passwordElement_().blur();
  }

  /**
   * Schedules a call to focusInputSynchronously().
   */
  focusInput(selectionStart?: number, selectionEnd?: number): void {
    setTimeout(
        () => this.focusInputSynchronously(selectionStart, selectionEnd), 0);
  }

  /**
   * Transfers focus to the input element. This should not bring up the virtual
   * keyboard, if it is enabled. After focus, moves the caret to the correct
   * location if specified.
   */
  focusInputSynchronously(selectionStart?: number, selectionEnd?: number):
      void {
    this.passwordElement_().focus();
    if (selectionStart !== undefined) {
      this.selectionStart_ = selectionStart;
    }
    if (selectionEnd !== undefined) {
      this.selectionEnd_ = selectionEnd;
    }
  }

  /**
   * Transfers focus to the input. Called when a non button element on the
   * PIN button area is clicked to prevent focus from leaving the input.
   */
  private onRootClick_(): void {
    // Focus the input and place the selected region to its exact previous
    // location, as this function will not be called by something that will also
    // modify the input value.
    this.focusInput(this.selectionStart_, this.selectionEnd_);
  }

  private onFocus_(): void {
    this.focused_ = true;
  }

  private onBlur_(): void {
    this.focused_ = false;
  }

  /**
   * Called when a keypad number has been clicked.
   */
  private onNumberClick_(event: Event): void {
    const button = event.target;
    assertInstanceof(button, CrButtonElement);
    const numberValue = button.getAttribute('value');
    assert(numberValue !== null);

    // Add the number where the caret is, then update the selection range of the
    // input element.
    const selectionStart = this.selectionStart_;
    const selectionEnd = this.selectionEnd_;

    const beforeStart = this.value.substring(0, selectionStart);
    const afterEnd = this.value.substring(selectionEnd);
    this.value = beforeStart + numberValue + afterEnd;

    // If a number button is clicked, we do not want to switch focus to the
    // button, therefore we transfer focus back to the input, but if a number
    // button is tabbed into, it should keep focus, so users can use tab and
    // spacebar/return to enter their PIN.
    if (!receivedEventFromKeyboard(event) && selectionStart !== null) {
      this.focusInputSynchronously(selectionStart + 1, selectionStart + 1);
    }
    event.stopImmediatePropagation();
  }

  /** Fires a submit event with the current PIN value. */
  private firePinSubmitEvent_(): void {
    this.dispatchEvent(new CustomEvent('submit', {detail: {pin: this.value}}));
  }

  /**
   * Fires an update event with the current PIN value. The event will only be
   * fired if the PIN value has actually changed.
   */
  private onPinValueChange_(value: string): void {
    if (this.passwordElement) {
      assertInstanceof(this.passwordElement, HTMLInputElement);
      this.passwordElement.value = value;
    }
    this.dispatchEvent(new CustomEvent('pin-change', {detail: {pin: value}}));
  }

  /**
   * Called when the user wants to erase the last character of the entered
   * PIN value.
   */
  private onPinClear_(): void {
    // If the input is shown, clear the text based on the caret location or
    // selected region of the input element. If it is just a caret, remove the
    // character in front of the caret.
    let selectionStart = this.selectionStart_;
    const selectionEnd = this.selectionEnd_;
    if (selectionStart === selectionEnd && selectionStart) {
      selectionStart--;
    }

    this.value = this.value.substring(0, selectionStart) +
        this.value.substring(selectionEnd);

    // Move the caret or selected region to the correct new place.
    this.selectionStart_ = selectionStart;
    this.selectionEnd_ = selectionStart;
  }

  /**
   * Called when user taps the backspace the button. Only does something when
   * the tap comes from the keyboard. onBackspacePointerDown_ and
   * onBackspacePointerUp_ will handle the events if they come from mouse or
   * touch. Note: This does not support repeatedly backspacing by holding down
   * the space or enter key like touch or mouse does.
   */
  private onBackspaceClick_(event: Event): void {
    if (!receivedEventFromKeyboard(event)) {
      return;
    }

    this.onPinClear_();
    this.clearAndReset_();
    event.stopImmediatePropagation();
  }

  /**
   * Called when the user presses or touches the backspace button. Starts a
   * timer which starts an interval to repeatedly backspace the pin value until
   * the interval is cleared.
   */
  private onBackspacePointerDown_(event: Event): void {
    this.startAutoBackspaceId_ = setTimeout(() => {
      this.repeatBackspaceIntervalId_ =
          setInterval(this.onPinClear_.bind(this), REPEAT_BACKSPACE_DELAY_MS);
    }, INITIAL_BACKSPACE_DELAY_MS);

    if (!receivedEventFromKeyboard(event)) {
      this.focusInput(this.selectionStart_, this.selectionEnd_);
    }
    event.stopImmediatePropagation();
  }

  /**
   * Helper function which clears the timer / interval ids and resets them.
   * @private
   */
  private clearAndReset_(): void {
    clearInterval(this.repeatBackspaceIntervalId_);
    this.repeatBackspaceIntervalId_ = 0;
    clearTimeout(this.startAutoBackspaceId_);
    this.startAutoBackspaceId_ = 0;
  }

  /**
   * Called when the user unpresses or untouches the backspace button. Stops the
   * interval callback and fires a backspace event if there is no interval
   * running.
   */
  private onBackspacePointerUp_(event: Event): void {
    // If an interval has started, do not fire event on pointer up.
    if (!this.repeatBackspaceIntervalId_) {
      this.onPinClear_();
    }
    this.clearAndReset_();

    // Since on-down gives the input element focus, the input element will
    // already have focus when on-up is called. This will actually bring up the
    // virtual keyboard, even if focusInput() is wrapped in a setTimeout. Blur
    // the input element first to workaround this.
    this.blur();
    if (!receivedEventFromKeyboard(event)) {
      this.focusInput(this.selectionStart_, this.selectionEnd_);
    }
    event.stopImmediatePropagation();
  }

  /**
   * Helper function to check whether a given |event| should be processed by
   * the input.
   */
  private isValidEventForInput_(event: KeyboardEvent): boolean {
    // Valid if the key is a non-digit and allowNonDigit is enabled.
    if (this.allowNonDigit) {
      return true;
    }

    // Valid if the key is a number, and shift is not pressed.
    if ((event.keyCode >= 48 && event.keyCode <= 57) && !event.shiftKey) {
      return true;
    }

    // Valid if the key is a numpad number, and shift is not pressed.
    if ((event.keyCode >= 96 && event.keyCode <= 105) && !event.shiftKey) {
      return true;
    }

    // Valid if the key is one of the selected special keys defined in
    // |PIN_INPUT_ALLOWED_NON_NUMBER_KEY_CODES|.
    if (PIN_INPUT_ALLOWED_NON_NUMBER_KEY_CODES.has(event.keyCode)) {
      return true;
    }

    // Valid if the key is CTRL+A to allow users to quickly select the entire
    // PIN.
    if (event.keyCode === 65 && event.ctrlKey) {
      return true;
    }

    // Valid if the key is CTRL+-, CTRL+=, or CTRL+0 to zoom in, zoom out, and
    // zoom reset the screen.
    if (event.ctrlKey && [48, 187, 189].includes(event.keyCode)) {
      return true;
    }

    // Valid if the key is Ctrl+Shift+Refresh to allow users rotate the screen
    if (event.keyCode === 168 && event.ctrlKey && event.shiftKey) {
      return true;
    }

    // Valid for the ChromeVox combination.
    if (event.ctrlKey && event.altKey && event.key === 'z') {
      return true;
    }

    // The rest of the keys are invalid.
    return false;
  }

  /**
   * Called when a key event is pressed while the input element has focus.
   */
  private onInputKeyDown_(event: KeyboardEvent): void {
    assertInstanceof(event, KeyboardEvent);

    // Up/down pressed, swallow the event to prevent the input value from
    // being incremented or decremented.
    if (event.keyCode === 38 || event.keyCode === 40 ||
        event.code === 'ArrowUp' || event.code === 'ArrowDown') {
      event.preventDefault();
      return;
    }

    // Enter pressed.
    if (event.keyCode === 13 || event.code === 'Enter') {
      this.firePinSubmitEvent_();
      event.preventDefault();
      return;
    }

    // If only digits are allowed in the pin input (allowNonDigit is set to
    // false), then do not pass events that are not numbers or special keys we
    // care about. We use this instead of input type number because there are
    // several issues with input type number, such as no
    // selectionStart/selectionEnd and entered non numbers causes the caret to
    // jump to the left.
    if (!this.isValidEventForInput_(event)) {
      event.preventDefault();
      return;
    }
  }

  /**
   * Indicates if something is entered.
   */
  private hasInput_(value: string): boolean {
    return value.length > 0;
  }

  /**
   * Determines if the pin input should be contrasted.
   */
  private hasInputOrFocus_(value: string, focused: boolean): boolean {
    return this.hasInput_(value) || focused;
  }

  /**
   * Computes the value of the pin input placeholder.
   */
  private getInputPlaceholder_(
      enablePassword: boolean, enablePlaceholder: boolean): string {
    if (!enablePlaceholder) {
      return '';
    }

    return enablePassword ? this.i18n('pinKeyboardPlaceholderPinPassword') :
                            this.i18n('pinKeyboardPlaceholderPin');
  }

  /**
   * Computes the direction of the pin input.
   */
  private isInputRtl_(password: string): boolean {
    // +password will convert a string to a number or to NaN if that's not
    // possible. Number.isInteger will verify the value is not a NaN and that it
    // does not contain decimals.
    // This heuristic will fail for inputs like '1.0'.
    //
    // Since we still support users entering their passwords through the PIN
    // keyboard, we swap the input box to rtl when we think it is a password
    // (just numbers), if the document direction is rtl.
    return (document.dir === 'rtl') && !Number.isInteger(+password);
  }

  private onBackspaceContextMenu_(e: Event): void {
    assertInstanceof(e, MouseEvent);
    // Note: If e.which is 0, this represents "no button" (i.e., a long-press).
    // If this event was triggered by another value (e.g., right click - 3),
    // return early and allow the context menu to be shown.
    if (e.which) {
      return;
    }

    // If the user was long-pressing the backspace button, that user likely was
    // trying to remove several numbers from the PIN text field rapidly, so
    // don't show the context menu.
    e.preventDefault();
    e.stopPropagation();
  }

  /**
   * Returns the native input element of |pinInput|.
   */
  private passwordElement_(): HTMLInputElement {
    // |passwordElement| is null by default. It can be set to override the
    // input field that will be populated with the keypad.
    if (this.passwordElement) {
      assertInstanceof(this.passwordElement, HTMLInputElement);
      return this.passwordElement;
    } else {
      // Check that our type assertion about |pinInput| is actually true.
      assertInstanceof(this.$.pinInput, CrInputElement);
      return this.$.pinInput.inputElement;
    }
  }
}

customElements.define(PinKeyboardElement.is, PinKeyboardElement);