chromium/ash/webui/camera_app_ui/resources/js/lit/components/mode-selector.ts

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

import {
  classMap,
  css,
  html,
  LitElement,
  PropertyDeclarations,
  repeat,
} from 'chrome://resources/mwc/lit/index.js';

import {
  assertExists,
  checkEnumVariant,
  checkInstanceof,
} from '../../assert.js';
import {I18nString} from '../../i18n_string.js';
import {getI18nMessage} from '../../models/load_time_data.js';
import {Mode} from '../../type.js';
import {getKeyboardShortcut} from '../../util.js';
import {DEFAULT_STYLE} from '../styles.js';

const MODE_LABELS = {
  [Mode.VIDEO]: {
    ariaLabel: I18nString.SWITCH_RECORD_VIDEO_BUTTON,
    text: I18nString.LABEL_SWITCH_RECORD_VIDEO_BUTTON,
  },
  [Mode.PHOTO]: {
    ariaLabel: I18nString.SWITCH_TAKE_PHOTO_BUTTON,
    text: I18nString.LABEL_SWITCH_TAKE_PHOTO_BUTTON,
  },
  [Mode.SCAN]: {
    ariaLabel: I18nString.SWITCH_SCAN_MODE_BUTTON,
    text: I18nString.LABEL_SWITCH_SCAN_MODE_BUTTON,
  },
  [Mode.PORTRAIT]: {
    ariaLabel: I18nString.SWITCH_TAKE_PORTRAIT_BOKEH_PHOTO_BUTTON,
    text: I18nString.LABEL_SWITCH_TAKE_PORTRAIT_BOKEH_PHOTO_BUTTON,
  },
};

export class ModeSelector extends LitElement {
  static override shadowRootOptions = {
    ...LitElement.shadowRootOptions,
    delegatesFocus: true,
  };

  static override styles = [
    DEFAULT_STYLE,
    css`
      :host {
        --fade-padding: 24px;
        --scrollbar-height: 4px;
      }

      :host::before,
      :host::after {
        /* This is for "fading" effect when window is narrow and the mode
         * selector overflows, so we use the same color as the background
         * color here. */
        background: linear-gradient(to right, var(--cros-sys-app_base),
                                    transparent);
        content: '';
        display: block;
        height: calc(100% - var(--scrollbar-height));
        pointer-events: none;
        position: absolute;
        top: 0;
        width: var(--fade-padding);
        z-index: 2;
      }

      :host::before {
        left: 0;
      }

      :host::after {
        right: 0;
        transform: scaleX(-1);
      }

      #modes-group {
        font-size: 0; /* Remove space between inline-block. */
        overflow: auto;
        padding: 10px 0;
        text-align: center;
        user-select: none;
        white-space: nowrap;

        &::-webkit-scrollbar-thumb {
          background: var(--cros-sys-scrollbar);
          border-radius: 2px;
          height: auto;
          width: auto;
        }

        &::-webkit-scrollbar {
          height: var(--scrollbar-height);
          width: auto;
        }
      }

      .mode-item {
        display: inline-block;
        margin: 0 8px;
        position: relative;

        &:first-child {
          margin-inline-start: var(--fade-padding);
        }

        &:last-child {
          margin-inline-end: var(--fade-padding);
        }

        &.disabled {
          opacity: 0.38;
        }
      }

      input {
        border-radius: 16px/50%;
        height: 100%;
        outline-offset: 7px;
        position: absolute;
        width: 100%;
        z-index: 1;
      }

      span {
        border-radius: 16px/50%;
        color: var(--cros-sys-on_surface);
        display: inline-block;
        font: var(--cros-button-1-font);
        padding: 8px 12px;
        position: relative;
        z-index: 0;

        input:checked + & {
          background: var(--cros-sys-primary);
          color: var(--cros-sys-inverse_on_surface);
        }
      }
    `,
  ];

  static override properties: PropertyDeclarations = {
    disabled: {type: Boolean},
    selectedMode: {type: Mode},
    supportedModes: {attribute: false},
  };

  /**
   * Whether the mode selector is temporarily disabled.
   */
  disabled = false;

  /**
   * List of modes that should be rendered.
   */
  supportedModes: Mode[] = [];

  /**
   * The current selected mode.
   */
  selectedMode: Mode|null = null;

  private getModeItem(mode: Mode) {
    return checkInstanceof(
        this.renderRoot.querySelector(`input[data-mode=${mode}]`),
        HTMLInputElement);
  }

  private scrollToMode(mode: Mode): void {
    this.getModeItem(mode)?.scrollIntoView({behavior: 'smooth'});
  }

  private handleClick(e: Event) {
    if (this.disabled) {
      e.preventDefault();
      e.stopPropagation();
    }
  }

  private handleChange(e: Event) {
    const target = checkInstanceof(e.target, HTMLInputElement);
    if (target === null) {
      return;
    }
    const mode = checkEnumVariant(Mode, target.dataset['mode']);
    if (mode === null) {
      return;
    }
    this.selectedMode = mode;
    this.dispatchEvent(new CustomEvent('mode-change', {detail: mode}));
  }

  // TODO(pihsun): This corresponds to setupToggles in main.ts. Consider how to
  // extract this so it works easier for all <input> element in lit components.
  private handleKeypress(e: KeyboardEvent) {
    if (getKeyboardShortcut(e) === 'Enter') {
      checkInstanceof(e.target, HTMLElement)?.click();
    }
  }

  private renderModeItem(mode: Mode) {
    const modeItemClass = {
      disabled: this.disabled,
    };
    // Use an additional radio input with same name, so we can utilize the
    // default keyboard navigation behavior of the browser.
    return html`
      <div class="mode-item ${classMap(modeItemClass)}">
        <input type="radio" name="mode"
            @keypress=${this.handleKeypress}
            data-mode=${mode}
            .checked=${this.selectedMode === mode}
            aria-label=${getI18nMessage(MODE_LABELS[mode].ariaLabel)}>
        <span aria-hidden="true">
          ${getI18nMessage(MODE_LABELS[mode].text)}
        </span>
      </div>
    `;
  }

  changeModeForTesting(mode: Mode): void {
    assertExists(this.getModeItem(mode)).click();
  }

  override render(): RenderResult {
    const shownModes = [
      Mode.VIDEO,
      Mode.PHOTO,
      Mode.SCAN,
      Mode.PORTRAIT,
    ].filter((mode) => this.supportedModes.includes(mode));
    const modeItems =
        repeat(shownModes, (mode) => mode, (mode) => this.renderModeItem(mode));
    return html`
      <div id="modes-group"
          role="radiogroup"
          @click=${this.handleClick}
          @change=${this.handleChange}
          aria-label=${getI18nMessage(I18nString.ARIA_CAMERA_MODE_GROUP)}>
        ${modeItems}
      </div>
    `;
  }

  override updated(changedProperties: Map<string, unknown>): void {
    if (this.selectedMode !== null && changedProperties.has('selectedMode')) {
      this.scrollToMode(this.selectedMode);
    }
  }
}

window.customElements.define('mode-selector', ModeSelector);

declare global {
  interface HTMLElementTagNameMap {
    /* eslint-disable-next-line @typescript-eslint/naming-convention */
    'mode-selector': ModeSelector;
  }
}