0

recorder: Add language list component

Extract language list and list item from language picker, which can be
used to separate headers and contents when we render language picker as
a dialog.

Bug: b:377885042
Test: manually

Change-Id: I91a20101d6a3e23da6161a3ad9f291f10abdd1fe
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6148113
Reviewed-by: Pi-Hsun Shih <pihsun@chromium.org>
Commit-Queue: Jennifer Ling <hsuanling@google.com>
Cr-Commit-Position: refs/heads/main@{#1407695}
This commit is contained in:
hsuanling
2025-01-16 18:50:49 -08:00
committed by Chromium LUCI CQ
parent c2c3df3e1f
commit 93fd53bca0
5 changed files with 321 additions and 145 deletions

@ -26,8 +26,10 @@ component_files = [
"genai-feedback-buttons.ts",
"genai-placeholder.ts",
"language-dropdown.ts",
"language-selection-dialog.ts",
"language-list-item.ts",
"language-list.ts",
"language-picker.ts",
"language-selection-dialog.ts",
"mic-selection-button.ts",
"onboarding-dialog.ts",
"recording-file-list.ts",

@ -0,0 +1,219 @@
// Copyright 2025 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/mwc/@material/web/focus/md-focus-ring.js';
import 'chrome://resources/mwc/@material/web/progress/circular-progress.js';
import './cra/cra-button.js';
import './cra/cra-icon.js';
import './settings-row.js';
import {
css,
html,
nothing,
PropertyDeclarations,
} from 'chrome://resources/mwc/lit/index.js';
import {i18n} from '../core/i18n.js';
import {ModelState} from '../core/on_device_model/types.js';
import {ReactiveLitElement} from '../core/reactive/lit.js';
import {LangPackInfo} from '../core/soda/language_info.js';
import {
assertExhaustive,
assertNotReached,
} from '../core/utils/assert.js';
import {stopPropagation, suppressEvent} from '../core/utils/event_handler.js';
/**
* An item in the language selection list.
*/
export class LanguageListItem extends ReactiveLitElement {
static override styles = css`
md-focus-ring {
--md-focus-ring-outward-offset: -8px;
--md-focus-ring-shape: 20px;
}
#root {
outline: none;
position: relative;
}
/*
* TODO: b/377885042 - Move the circular progress to a separate component.
*/
settings-row cra-button md-circular-progress {
--md-circular-progress-active-indicator-color: var(--cros-sys-disabled);
/*
* This has a lower precedence than the size override in cros-button,
* but still need to be set to have correct line width.
*/
--md-circular-progress-size: 24px;
/*
* This is to override the size setting for slotted element in
* cros-button. On figma the circular progress have 2px padding, but
* md-circular-progres has a non-configurable 4px padding. Setting a
* negative margin so the extra padding doesn't expand the button size.
*/
height: 24px;
margin: -2px;
width: 24px;
}
`;
static override properties: PropertyDeclarations = {
langPackInfo: {attribute: false},
selected: {type: Boolean},
sodaState: {attribute: false},
};
langPackInfo: LangPackInfo|null = null;
selected = false;
sodaState: ModelState = {kind: 'unavailable'};
private onDownload() {
if (this.langPackInfo === null) {
return;
}
this.dispatchEvent(new CustomEvent('language-download-click', {
detail: this.langPackInfo.languageCode,
bubbles: true,
composed: true,
}));
}
private onSelect() {
if (this.langPackInfo === null) {
return;
}
this.dispatchEvent(new CustomEvent('language-select-click', {
detail: this.langPackInfo.languageCode,
bubbles: true,
composed: true,
}));
}
private activateRow() {
this.renderRoot.querySelector('settings-row')?.click();
}
private onKeyUp(ev: KeyboardEvent) {
if (ev.key === ' ') {
suppressEvent(ev);
this.activateRow();
}
}
private onKeyDown(ev: KeyboardEvent) {
if (ev.key === ' ') {
// Prevents page from scroll down.
ev.preventDefault();
}
if (ev.key === 'Enter') {
suppressEvent(ev);
this.activateRow();
}
}
private renderDescriptionAndAction(): RenderResult {
const downloadButton = html`
<cra-button
slot="action"
button-style="secondary"
tabindex="-1"
.label=${i18n.languagePickerLanguageDownloadButton}
@keyup=${stopPropagation}
@keydown=${stopPropagation}
@click=${this.onDownload}
></cra-button>
`;
const kind = this.sodaState.kind;
switch (kind) {
case 'notInstalled':
return downloadButton;
case 'error':
// Shows the download button for users to try again.
return html`
<span slot="description" class="error">
${i18n.languagePickerLanguageErrorDescription}
</span>
${downloadButton}
`;
case 'installing': {
const progressDescription =
i18n.languagePickerLanguageDownloadingProgressDescription(
this.sodaState.progress,
);
return html`
<span slot="description">${progressDescription}</span>
<cra-button
slot="action"
button-style="secondary"
.label=${i18n.languagePickerLanguageDownloadingButton}
disabled
>
<md-circular-progress indeterminate slot="leading-icon">
</md-circular-progress>
</cra-button>
`;
}
case 'installed':
if (this.selected) {
// Row is not interactive when the language is selected.
return html`
<cra-icon slot="action" name="checked" disabled></cra-icon>
`;
}
return html`<span slot="action" @click=${this.onSelect}></span>`;
case 'unavailable':
return assertNotReached('SODA unavailable but the row is rendered.');
default:
return assertExhaustive(kind);
}
}
private isFocusable() {
if (this.sodaState.kind === 'installing' ||
(this.sodaState.kind === 'installed' && this.selected)) {
return false;
}
return true;
}
override render(): RenderResult {
if (this.langPackInfo === null || this.sodaState.kind === 'unavailable') {
return nothing;
}
// TODO: b/384418702 - Add aria label of each state in #root and set
// settings-row aria-hidden to `true` to avoid redundant announcement by
// screen reader.
return html`
<div id="root"
tabindex=${this.isFocusable() ? 0 : -1}
@click=${this.activateRow}
@keydown=${this.onKeyDown}
@keyup=${this.onKeyUp}
>
<settings-row>
<span slot="label">${this.langPackInfo.displayName}</span>
${this.renderDescriptionAndAction()}
</settings-row>
${this.isFocusable() ? html`<md-focus-ring></md-focus-ring>` : nothing}
</div>
`;
}
}
window.customElements.define('language-list-item', LanguageListItem);
declare global {
interface HTMLElementTagNameMap {
'language-list-item': LanguageListItem;
}
}

@ -0,0 +1,57 @@
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import './language-list-item.js';
import {
html,
map,
PropertyDeclarations,
} from 'chrome://resources/mwc/lit/index.js';
import {usePlatformHandler} from '../core/lit/context.js';
import {ReactiveLitElement} from '../core/reactive/lit.js';
import {LangPackInfo, LanguageCode} from '../core/soda/language_info.js';
/**
* A list of language options.
*/
export class LanguageList extends ReactiveLitElement {
static override properties: PropertyDeclarations = {
selectedLanguage: {attribute: false},
};
selectedLanguage: LanguageCode|null = null;
private readonly platformHandler = usePlatformHandler();
private renderLanguageRow(langPack: LangPackInfo): RenderResult {
const {languageCode} = langPack;
const sodaState = this.platformHandler.getSodaState(languageCode).value;
return html`
<language-list-item
.langPackInfo=${langPack}
.sodaState=${sodaState}
?selected=${languageCode === this.selectedLanguage}
>
</language-list-item>
`;
}
override render(): RenderResult {
const list = this.platformHandler.getLangPackList();
return map(
list,
(langPack) => this.renderLanguageRow(langPack),
);
}
}
window.customElements.define('language-list', LanguageList);
declare global {
interface HTMLElementTagNameMap {
'language-list': LanguageList;
}
}

@ -2,20 +2,18 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'chrome://resources/mwc/@material/web/progress/circular-progress.js';
import './cra/cra-button.js';
import './cra/cra-icon.js';
import './cra/cra-icon-button.js';
import './settings-row.js';
import './language-list.js';
import {css, html, map, nothing} from 'chrome://resources/mwc/lit/index.js';
import {css, html} from 'chrome://resources/mwc/lit/index.js';
import {i18n} from '../core/i18n.js';
import {usePlatformHandler} from '../core/lit/context.js';
import {ReactiveLitElement} from '../core/reactive/lit.js';
import {LangPackInfo, LanguageCode} from '../core/soda/language_info.js';
import {LanguageCode} from '../core/soda/language_info.js';
import {setTranscriptionLanguage} from '../core/state/transcription.js';
import {assertExhaustive} from '../core/utils/assert.js';
import {withTooltip} from './directives/with-tooltip.js';
@ -81,29 +79,6 @@ export class LanguagePicker extends ReactiveLitElement {
overflow: hidden;
}
}
/*
* TODO: b/377885042 - Move the circular progress to a separate component.
*/
settings-row cra-button md-circular-progress {
--md-circular-progress-active-indicator-color: var(--cros-sys-disabled);
/*
* This has a lower precedence than the size override in cros-button,
* but still need to be set to have correct line width.
*/
--md-circular-progress-size: 24px;
/*
* This is to override the size setting for slotted element in
* cros-button. On figma the circular progress have 2px padding, but
* md-circular-progres has a non-configurable 4px padding. Setting a
* negative margin so the extra padding doesn't expand the button size.
*/
height: 24px;
margin: -2px;
width: 24px;
}
`;
private readonly platformHandler = usePlatformHandler();
@ -112,130 +87,41 @@ export class LanguagePicker extends ReactiveLitElement {
this.dispatchEvent(new Event('close'));
}
private renderLanguageRow(
{displayName, languageCode}: LangPackInfo,
selectedLanguage: LanguageCode|null,
): RenderResult {
const sodaState = this.platformHandler.getSodaState(languageCode).value;
if (sodaState.kind === 'unavailable') {
return nothing;
}
const name = html`<span slot="label">${displayName}</span>`;
function onSelectAndDownload() {
setTranscriptionLanguage(languageCode);
}
const downloadButton = html`
<cra-button
slot="action"
button-style="secondary"
.label=${i18n.languagePickerLanguageDownloadButton}
@click=${onSelectAndDownload}
></cra-button>
`;
switch (sodaState.kind) {
case 'notInstalled': {
return html`<settings-row>${name} ${downloadButton}</settings-row>`;
}
// Shows the download button for users to try again.
case 'error': {
return html`
<settings-row>
${name}
<span slot="description" class="error">
${i18n.languagePickerLanguageErrorDescription}
</span>
${downloadButton}
</settings-row>
`;
}
case 'installing': {
const progressDescription =
i18n.languagePickerLanguageDownloadingProgressDescription(
sodaState.progress,
);
return html`
<settings-row>
${name}
<span slot="description">${progressDescription}</span>
<cra-button
slot="action"
button-style="secondary"
.label=${i18n.languagePickerLanguageDownloadingButton}
disabled
>
<md-circular-progress indeterminate slot="leading-icon">
</md-circular-progress>
</cra-button>
</settings-row>
`;
}
case 'installed': {
if (languageCode === selectedLanguage) {
return html`
<settings-row>
${name}
<cra-icon slot="action" name="checked"></cra-icon>
</settings-row>
`;
} else {
// Set and install the language to avoid inconsistent SODA state.
// TODO: b/375306309 - Separate set and install steps when the state
// become consistent after implementing `OnSodaUninstalled`.
return html`
<settings-row>
${name}
<span slot="action" @click=${onSelectAndDownload}></span>
</settings-row>
`;
}
}
default:
return assertExhaustive(sodaState.kind);
}
private onSelectAndDownload(ev: CustomEvent<LanguageCode>) {
setTranscriptionLanguage(ev.detail);
}
private renderSelectedLanguage(): RenderResult {
const selectedLanguage = this.platformHandler.getSelectedLanguage();
private renderSelectedLanguage(
selectedLanguage: LanguageCode|null,
): RenderResult {
const noSelectionRow = html`
<settings-row>
<span slot="label">
${i18n.languagePickerSelectedLanguageNoneLabel}
</span>
</settings-row>
`;
if (selectedLanguage === null) {
return html`
<settings-row>
<span slot="label">
${i18n.languagePickerSelectedLanguageNoneLabel}
</span>
</settings-row>
`;
return noSelectionRow;
}
const sodaState = this.platformHandler.getSodaState(selectedLanguage).value;
if (sodaState.kind !== 'installed' && sodaState.kind !== 'installing') {
return html`
<settings-row>
<span slot="label">
${i18n.languagePickerSelectedLanguageNoneLabel}
</span>
</settings-row>
`;
return noSelectionRow;
}
return this.renderLanguageRow(
this.platformHandler.getLangPackInfo(selectedLanguage),
selectedLanguage,
);
}
private renderAvailableLanguages(): RenderResult {
const list = this.platformHandler.getLangPackList();
const selectedLanguage = this.platformHandler.getSelectedLanguage();
return map(
list,
(langPack) => this.renderLanguageRow(langPack, selectedLanguage),
);
const langPack = this.platformHandler.getLangPackInfo(selectedLanguage);
return html`
<settings-row>
<span slot="label">
${langPack.displayName}
</span>
</settings-row>
`;
}
override render(): RenderResult {
// TODO: b/377885042 - Render "close" button when language picker is not
// inside the setting menu.
const selectedLanguage = this.platformHandler.getSelectedLanguage();
// TODO: b/384418702 - Update back button aria label and language list role
// after spec is ready.
return html`
<div id="root">
<div id="header">
@ -255,13 +141,21 @@ export class LanguagePicker extends ReactiveLitElement {
<div id="content">
<div class="section">
<h4 class="title">${i18n.languagePickerSelectedLanguageHeader}</h4>
<div class="body">${this.renderSelectedLanguage()}</div>
<div class="body">
${this.renderSelectedLanguage(selectedLanguage)}
</div>
</div>
<div class="section">
<h4 class="title">
${i18n.languagePickerAvailableLanguagesHeader}
</h4>
<div class="body">${this.renderAvailableLanguages()}</div>
<language-list
class="body"
.selectedLanguage=${selectedLanguage}
@language-select-click=${this.onSelectAndDownload}
@language-download-click=${this.onSelectAndDownload}
>
</language-list>
</div>
</div>
</div>

@ -132,6 +132,10 @@ export class SettingsRow extends ReactiveLitElement {
<slot name="status"></slot>
</div>`;
}
override click(): void {
this.onClick();
}
}
window.customElements.define('settings-row', SettingsRow);