chromium/ui/webui/resources/cr_elements/cr_input/cr_input.ts

// Copyright 2018 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 {PropertyValues} from '//resources/lit/v3_0/lit.rollup.js';
import {CrLitElement} from '//resources/lit/v3_0/lit.rollup.js';

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

/**
 * Input types supported by cr-input.
 */
const SUPPORTED_INPUT_TYPES: Set<string> = new Set([
  'number',
  'password',
  'search',
  'text',
  'url',
]);

/**
 * @fileoverview 'cr-input' is a component similar to native input.
 *
 * Native input attributes that are currently supported by cr-inputs are:
 *   autofocus
 *   disabled
 *   max (only applicable when type="number")
 *   min (only applicable when type="number")
 *   maxlength
 *   minlength
 *   pattern
 *   placeholder
 *   readonly
 *   required
 *   tabindex (set through input-tabindex)
 *   type (see |SUPPORTED_INPUT_TYPES| above)
 *   value
 *
 * Additional attributes that you can use with cr-input:
 *   label
 *   auto-validate - triggers validation based on |pattern| and |required|,
 *                   whenever |value| changes.
 *   error-message - message displayed under the input when |invalid| is true.
 *   invalid
 *
 * You may pass an element into cr-input via [slot="suffix"] to be vertically
 * center-aligned with the input field, regardless of position of the label and
 * error-message. Example:
 *   <cr-input>
 *     <cr-button slot="suffix"></cr-button>
 *   </cr-input>
 */
export interface CrInputElement {
  $: {
    error: HTMLElement,
    label: HTMLElement,
    input: HTMLInputElement,
    underline: HTMLElement,
  };
}

export class CrInputElement extends CrLitElement {
  static get is() {
    return 'cr-input';
  }

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

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

  static override get properties() {
    return {
      ariaDescription: {type: String},
      ariaLabel: {type: String},

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

      autoValidate: {type: Boolean},

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

      errorMessage: {type: String},
      errorRole_: {type: String},
      displayErrorMessage_: {type: String},

      /**
       * This is strictly used internally for styling, do not attempt to use
       * this to set focus.
       */
      focused_: {
        type: Boolean,
        reflect: true,
      },

      invalid: {
        type: Boolean,
        notify: true,
        reflect: true,
      },

      max: {
        type: Number,
        reflect: true,
      },

      min: {
        type: Number,
        reflect: true,
      },

      maxlength: {
        type: Number,
        reflect: true,
      },

      minlength: {
        type: Number,
        reflect: true,
      },

      pattern: {
        type: String,
        reflect: true,
      },

      inputmode: {type: String},
      label: {type: String},
      placeholder: {type: String},

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

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

      inputTabindex: {type: Number},
      type: {type: String},

      value: {
        type: String,
        notify: true,
      },

      internalValue_: {
        type: String,
        state: true,
      },
    };
  }

  override ariaDescription: string|null = null;
  override ariaLabel: string = '';
  override autofocus: boolean = false;
  autoValidate: boolean = false;
  disabled: boolean = false;
  errorMessage: string = '';
  inputmode?: string;
  inputTabindex: number = 0;
  invalid: boolean = false;
  label: string = '';
  max?: number;
  min?: number;
  maxlength?: number;
  minlength?: number;
  pattern?: string;
  placeholder: string|null = null;
  readonly: boolean = false;
  required: boolean = false;
  type: string = 'text';
  value: string = '';
  protected internalValue_: string = '';
  protected focused_: boolean = false;

  override firstUpdated() {
    // Use inputTabindex instead.
    assert(!this.hasAttribute('tabindex'));
  }

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

    if (changedProperties.has('value')) {
      // Don't allow null or undefined as these will render in the input.
      // cr-input cannot use Lit's "nothing" in the HTML template; this breaks
      // the underlying native input's auto validation if |required| is set.
      this.internalValue_ =
          (this.value === undefined || this.value === null) ? '' : this.value;
    }

    if (changedProperties.has('inputTabindex')) {
      // CrInput only supports 0 or -1 values for the input's tabindex to allow
      // having the input in tab order or not. Values greater than 0 will not
      // work as the shadow root encapsulates tabindices.
      assert(this.inputTabindex === 0 || this.inputTabindex === -1);
    }

    if (changedProperties.has('type')) {
      // Check that the 'type' is one of the supported types.
      assert(SUPPORTED_INPUT_TYPES.has(this.type));
    }
  }

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

    if (changedProperties.has('value')) {
      const previous = changedProperties.get('value');
      if ((!!this.value || !!previous) && this.autoValidate) {
        this.invalid = !this.inputElement.checkValidity();
      }
    }

    if (changedProperties.has('placeholder')) {
      if (this.placeholder === null || this.placeholder === undefined) {
        this.inputElement.removeAttribute('placeholder');
      } else {
        this.inputElement.setAttribute('placeholder', this.placeholder);
      }
    }
  }

  get inputElement(): HTMLInputElement {
    return this.$.input;
  }

  override focus() {
    this.focusInput();
  }

  /**
   * Focuses the input element.
   * TODO(crbug.com/40593040): Replace this with focus() after resolving the text
   * selection issue described in onFocus_().
   * @return Whether the <input> element was focused.
   */
  focusInput(): boolean {
    if (this.shadowRoot!.activeElement === this.inputElement) {
      return false;
    }
    this.inputElement.focus();
    return true;
  }

  /**
   * 'change' event fires when <input> value changes and user presses 'Enter'.
   * This function helps propagate it to host since change events don't
   * propagate across Shadow DOM boundary by default.
   */
  protected async onInputChange_(e: Event) {
    // Ensure that |value| has been updated before re-firing 'change'.
    await this.updateComplete;
    this.fire('change', {sourceEvent: e});
  }

  protected onInput_(e: Event) {
    this.internalValue_ = (e.target as HTMLInputElement).value;
    this.value = this.internalValue_;
  }

  protected onInputFocus_() {
    this.focused_ = true;
  }

  protected onInputBlur_() {
    this.focused_ = false;
  }

  protected getAriaLabel_() {
    return this.ariaLabel || this.label || this.placeholder;
  }

  protected getAriaInvalid_() {
    return this.invalid ? 'true' : 'false';
  }

  protected getErrorMessage_() {
    return this.invalid ? this.errorMessage : '';
  }

  protected getErrorRole_() {
    // On VoiceOver role="alert" is not consistently announced when its
    // content changes. Adding and removing the |role| attribute every time
    // there is an error, triggers VoiceOver to consistently announce.
    return this.invalid ? 'alert' : '';
  }

  protected getAriaErrorMessage_() {
    return this.invalid ? 'error' : '';
  }

  /**
   * Selects the text within the input. If no parameters are passed, it will
   * select the entire string. Either no params or both params should be passed.
   * Publicly, this function should be used instead of inputElement.select() or
   * manipulating inputElement.selectionStart/selectionEnd because the order of
   * execution between focus() and select() is sensitive.
   */
  select(start?: number, end?: number) {
    this.inputElement.focus();
    if (start !== undefined && end !== undefined) {
      this.inputElement.setSelectionRange(start, end);
    } else {
      // Can't just pass one param.
      assert(start === undefined && end === undefined);
      this.inputElement.select();
    }
  }

  // Note: In order to preserve it as a synchronous API, validate() forces 2
  // rendering updates to cr-input. This allows this function to be used to
  // synchronously determine the validity of a <cr-input>, however, as a result
  // of these 2 forced updates it may result in slower performance. validate()
  // should not be called internally from within cr_input.ts, and should only
  // be called where necessary from clients.
  validate(): boolean {
    // Ensure that any changes to |value| have propagated to the native <input>.
    this.performUpdate();
    this.invalid = !this.inputElement.checkValidity();
    // Perform update again to ensure change propagates via 2 way binding to
    // Polymer parent before returning.
    this.performUpdate();
    return !this.invalid;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'cr-input': CrInputElement;
  }
}

customElements.define(CrInputElement.is, CrInputElement);