chromium/ash/webui/recorder_app_ui/resources/components/recording-search-box.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.

import 'chrome://resources/cros_components/textfield/textfield.js';
import './cra/cra-icon.js';
import './cra/cra-icon-button.js';

import {
  Textfield,
} from 'chrome://resources/cros_components/textfield/textfield.js';
import {
  classMap,
  createRef,
  css,
  html,
  nothing,
  ref,
} from 'chrome://resources/mwc/lit/index.js';

import {i18n} from '../core/i18n.js';
import {ReactiveLitElement} from '../core/reactive/lit.js';
import {computed, signal} from '../core/reactive/signal.js';
import {assertExists} from '../core/utils/assert.js';

export class RecordingSearchBox extends ReactiveLitElement {
  static override styles = css`
    #container {
      align-items: center;
      display: flex;

      & > cra-icon-button {
        transition: visibility 200ms step-end allow-discrete;
      }

      &.opened > cra-icon-button {
        transition: visibility 200ms step-start allow-discrete;
        visibility: hidden;
      }
    }

    cros-textfield {
      /*
       * The margin and width are chosen so it perfectly align with the icon on
       * the start of transition.
       */
      margin-right: -48px;

      /*
       * The transition property is not visibility but opacity here because
       * visibility mess up with focus and somehow we can't auto-focus on the
       * textfield afterward.
       * The text field is disabled when it's not shown, so it won't capture
       * focus even with opacity: 0.
       */
      opacity: 0;
      transition:
        width 200ms ease,
        opacity 200ms step-end;
      width: 48px;

      #container.opened & {
        opacity: 1;
        transition:
          width 200ms ease,
          opacity 200ms step-start;
        width: 224px;
      }
    }
  `;

  private readonly query = signal('');

  private readonly opened = signal(false);

  private readonly hasQuery = computed(
    () => this.opened.value && this.query.value.trim().length !== 0,
  );

  private readonly textFieldRef = createRef<Textfield>();

  private async openSearchBox() {
    this.opened.value = true;
    // Similar to recording-title, it requires 3 updates for focus to work.
    await this.updateComplete;
    await this.updateComplete;
    await this.updateComplete;
    this.textFieldRef.value?.focusTextfield();
  }

  private updateQuery(newQuery: string) {
    this.query.value = newQuery;
    this.dispatchEvent(new CustomEvent('query-changed', {detail: newQuery}));
  }

  private clearSearchBox() {
    this.updateQuery('');
    if (this.textFieldRef.value !== undefined) {
      this.textFieldRef.value.value = '';
    }
  }

  private closeSearchBox() {
    this.opened.value = false;
    this.clearSearchBox();
  }

  private onFocusOut() {
    if (!this.hasQuery.value) {
      this.closeSearchBox();
    }
  }

  private onKeyDown(e: KeyboardEvent) {
    const key = e.key;
    if (key === 'Escape') {
      if (this.hasQuery.value) {
        this.clearSearchBox();
      } else {
        this.closeSearchBox();
      }
    }
  }

  private onInputUpdated() {
    this.updateQuery(assertExists(this.textFieldRef.value).value);
  }

  override render(): RenderResult {
    const searchButton = html`<cra-icon-button
      buttonstyle="floating"
      @click=${this.openSearchBox}
    >
      <cra-icon slot="icon" name="search"></cra-icon>
    </cra-icon-button>`;

    const cancelButton = !this.hasQuery.value ? nothing : html`<cra-icon-button
          buttonstyle="floating"
          size="small"
          slot="trailing"
          shape="circle"
          @click=${this.closeSearchBox}
        >
          <cra-icon slot="icon" name="remove_fill"></cra-icon>
        </cra-icon-button>`;

    // TODO(pihsun): The textfield is disabled a bit too early and can be seen
    // "fade out" on the slowed down animation. It'd be nicer if we can
    // transition "visibility"/"display" instead of "opacity" so we don't
    // need to disable the search box here, or disable the search box only
    // after the transition is completed.
    const searchBox = html`<cros-textfield
      ?disabled=${!this.opened.value}
      placeholder=${i18n.recordingListSearchBoxPlaceholder}
      shaded
      type="text"
      @focusout=${this.onFocusOut}
      @keydown=${this.onKeyDown}
      @input=${this.onInputUpdated}
      ${ref(this.textFieldRef)}
    >
      <cra-icon-button
        ?disabled=${!this.opened.value}
        buttonstyle="floating"
        size="small"
        slot="leading"
        @click=${this.closeSearchBox}
      >
        <cra-icon slot="icon" name="search"></cra-icon>
      </cra-icon-button>
      ${cancelButton}
    </cros-textfield>`;

    const classes = {
      opened: this.opened.value,
    };

    return html`<div id="container" class=${classMap(classes)}>
      ${searchBox}${searchButton}
    </div>`;
  }
}

window.customElements.define('recording-search-box', RecordingSearchBox);

declare global {
  interface HTMLElementTagNameMap {
    'recording-search-box': RecordingSearchBox;
  }
}