refactor(audiobook): compact, grouped UI for the audiobook tab (#1214)

Group the sprawling right-hand settings column into consistent collapsible
Sections (Output open by default; Book details / Pronunciation / Markup
collapsed) built on a small reusable native-<details> Section component, so a
first-timer sees script + voice + language + Create without scrolling. Extract
the cover+metadata and lexicon blocks into BookDetails / LexiconEditor
sub-components (also relieves the max-lines lint). New i18n key audiobook.output
added to all 21 locales. Behaviour-identical; adds an AudiobookTab layout test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
debpalash
2026-07-20 23:57:41 +05:30
co-authored by Claude Opus 4.8
parent 93e9a5b8a9
commit fc2b0d4cb5
26 changed files with 337 additions and 178 deletions
@@ -0,0 +1,89 @@
import { Image as ImageIcon, X } from 'lucide-react';
import { Button } from '../../ui';
import { buttonVariants } from '@/components/ui/button.tsx';
const META_FIELDS = ['title', 'author', 'narrator', 'year', 'genre'];
/**
* Cover picker + the embedded-metadata fields for the Audiobook tab. Extracted
* from AudiobookTab so the tab stays under the max-lines lint and the block can
* live inside a collapsible Section. Behaviour-identical to the inline version:
* same inputs, same aria-labels, same store-backed `meta` / `setMetaField`.
*/
export default function BookDetails({
t,
coverPreview,
onCoverPick,
clearCover,
meta,
setMetaField,
}) {
return (
<div className="flex gap-[12px] items-start">
<div style={{ position: 'relative', width: 96, height: 96, flexShrink: 0 }}>
{coverPreview ? (
<>
<img
src={coverPreview}
alt={t('audiobook.cover')}
style={{ width: 96, height: 96, objectFit: 'cover', borderRadius: 6 }}
/>
<Button
variant="icon"
iconSize="sm"
onClick={clearCover}
aria-label={t('audiobook.cover_remove')}
style={{ position: 'absolute', top: 4, right: 4 }}
>
<X size={14} />
</Button>
</>
) : (
<label
className={buttonVariants({ variant: 'subtle', size: 'omniMd' })}
style={{
width: 96,
height: 96,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
cursor: 'pointer',
}}
>
<ImageIcon size={20} />
<span style={{ fontSize: '0.65rem' }}>{t('audiobook.cover_add')}</span>
<input
type="file"
accept="image/png,image/jpeg"
onChange={onCoverPick}
style={{ display: 'none' }}
/>
</label>
)}
</div>
<div className="grid grid-cols-[1fr_1fr] gap-[8px] flex-1 min-w-0">
{META_FIELDS.map((k) => (
<input
key={k}
className="input-base"
placeholder={t(`audiobook.meta_${k}`)}
value={meta[k]}
onChange={setMetaField(k)}
aria-label={t(`audiobook.meta_${k}`)}
/>
))}
<input
className="input-base"
placeholder={t('audiobook.meta_description')}
value={meta.description}
onChange={setMetaField('description')}
aria-label={t('audiobook.meta_description')}
style={{ gridColumn: '1 / -1' }}
/>
</div>
</div>
);
}
@@ -0,0 +1,52 @@
import { Plus, X } from 'lucide-react';
import { Button } from '../../ui';
/**
* Pronunciation lexicon editor — the editable {word → respelling} rows plus the
* Add button. Extracted from AudiobookTab so the block can live inside a
* collapsible Section and the tab stays under the max-lines lint. Behaviour is
* identical: the parent still owns the `lex` rows and the row mutators.
*/
export default function LexiconEditor({ t, lex, setLexRow, addLexRow, removeLexRow }) {
return (
<>
{lex.map((row, i) => (
<div key={i} className="flex gap-[6px]">
<input
className="input-base"
placeholder={t('audiobook.lex_word')}
value={row.word}
onChange={setLexRow(i, 'word')}
aria-label={t('audiobook.lex_word')}
style={{ flex: 1, minWidth: 0 }}
/>
<input
className="input-base"
placeholder={t('audiobook.lex_say')}
value={row.say}
onChange={setLexRow(i, 'say')}
aria-label={t('audiobook.lex_say')}
style={{ flex: 1, minWidth: 0 }}
/>
<Button
variant="icon"
iconSize="sm"
onClick={() => removeLexRow(i)}
aria-label={t('audiobook.lex_remove')}
>
<X size={14} />
</Button>
</div>
))}
<Button
variant="subtle"
onClick={addLexRow}
leading={<Plus size={14} />}
style={{ alignSelf: 'flex-start' }}
>
{t('audiobook.lex_add')}
</Button>
</>
);
}
@@ -0,0 +1,29 @@
import { ChevronDown } from 'lucide-react';
/**
* Collapsible settings group for the Audiobook tab — one consistent affordance
* for the secondary controls (Output, Book details, Pronunciation, Markup) so
* the right-hand column stops sprawling. Native `<details>/<summary>` gives
* keyboard operability + a labelled disclosure for free; the chrome mirrors the
* AudiobookOverrides header (small muted mono label + a chevron that rotates
* when open) so every group header reads as one family.
*
* `defaultOpen` sets the initial state only (uncontrolled, like the overrides
* panel) — the user's toggle is theirs to keep for the session.
*/
export default function Section({ title, icon = null, defaultOpen = false, children }) {
return (
<details className="audiobook-tab__section group flex flex-col gap-[8px]" open={defaultOpen}>
<summary className="flex items-center gap-[6px] px-[4px] py-[4px] cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden [font-family:var(--chrome-font-mono)] [font-size:var(--chrome-label-size)] font-semibold [letter-spacing:var(--chrome-label-track)] uppercase [color:var(--chrome-fg-muted)] hover:[color:var(--chrome-fg)]">
{icon}
<span>{title}</span>
<ChevronDown
size={12}
className="ml-auto transition-transform group-open:rotate-180"
aria-hidden="true"
/>
</summary>
<div className="flex flex-col gap-[8px]">{children}</div>
</details>
);
}
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "تعذر تصدير القاموس."
},
"audiobook": {
"output": "الإخراج",
"title": "كتاب مسموع",
"subtitle": "قم بتحويل البرنامج النصي المحدد بفصل إلى كتاب صوتي m4b مقسم.",
"script": "البرنامج النصي",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "Das Wörterbuch konnte nicht exportiert werden."
},
"audiobook": {
"output": "Ausgabe",
"title": "Hörbuch",
"subtitle": "Verwandeln Sie ein durch Kapitel getrenntes Skript in ein in Kapitel unterteiltes m4b-Hörbuch.",
"script": "Skript",
+1
View File
@@ -155,6 +155,7 @@
"flip_rail": "Flip rail side"
},
"audiobook": {
"output": "Output",
"title": "Audiobook",
"subtitle": "Turn a chapter-delimited script into a chapterized m4b audiobook.",
"script": "Script",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "No se pudo exportar el diccionario."
},
"audiobook": {
"output": "Salida",
"title": "Audiolibro",
"subtitle": "Convierta un guión delimitado por capítulos en un audiolibro m4b dividido en capítulos.",
"script": "Guión",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "Impossible d'exporter le dictionnaire."
},
"audiobook": {
"output": "Sortie",
"title": "Livre audio",
"subtitle": "Transformez un script délimité par des chapitres en un livre audio m4b chapitré.",
"script": "Scénario",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "शब्दकोश निर्यात नहीं हो सका।"
},
"audiobook": {
"output": "आउटपुट",
"title": "ऑडियोबुक",
"subtitle": "एक अध्याय-सीमांकित स्क्रिप्ट को एक अध्यायबद्ध m4b ऑडियोबुक में बदलें।",
"script": "स्क्रिप्ट",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "Kamus tidak dapat diekspor."
},
"audiobook": {
"output": "Keluaran",
"title": "Buku Audio",
"subtitle": "Ubah skrip yang dibatasi bab menjadi buku audio m4b yang diberi bab.",
"script": "naskah",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "Impossibile esportare il dizionario."
},
"audiobook": {
"output": "Uscita",
"title": "Audiolibro",
"subtitle": "Trasforma uno script delimitato da capitoli in un audiolibro m4b suddiviso in capitoli.",
"script": "Copione",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "辞書をエクスポートできませんでした。"
},
"audiobook": {
"output": "出力",
"title": "オーディオブック",
"subtitle": "チャプター区切りのスクリプトをチャプター化された m4b オーディオブックに変換します。",
"script": "スクリプト",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "사전을 내보낼 수 없습니다."
},
"audiobook": {
"output": "출력",
"title": "Audiobook",
"subtitle": "장으로 구분된 스크립트를 장으로 구분된 m4b 오디오북으로 바꿔보세요.",
"script": "스크립트",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "Kan het woordenboek niet exporteren."
},
"audiobook": {
"output": "Uitvoer",
"title": "Audioboek",
"subtitle": "Verander een door hoofdstukken gescheiden script in een m4b-audioboek in hoofdstukken.",
"script": "Script",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "Nie udało się wyeksportować słownika."
},
"audiobook": {
"output": "Wyjście",
"title": "Książka audio",
"subtitle": "Zamień skrypt podzielony na rozdziały w audiobook m4b podzielony na rozdziały.",
"script": "Skrypt",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "Não foi possível exportar o dicionário."
},
"audiobook": {
"output": "Saída",
"title": "Audiolivro",
"subtitle": "Transforme um script delimitado por capítulos em um audiolivro m4b com capítulos.",
"script": "Roteiro",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "Не удалось экспортировать словарь."
},
"audiobook": {
"output": "Вывод",
"title": "Аудиокнига",
"subtitle": "Превратите сценарий с разделением глав в аудиокнигу m4b с разделением на главы.",
"script": "Скрипт",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "Kunde inte exportera ordboken."
},
"audiobook": {
"output": "Utdata",
"title": "Ljudbok",
"subtitle": "Förvandla ett kapitelavgränsat manus till en kapitelinställd m4b-ljudbok.",
"script": "Manus",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "ไม่สามารถส่งออกพจนานุกรมได้"
},
"audiobook": {
"output": "เอาต์พุต",
"title": "หนังสือเสียง",
"subtitle": "เปลี่ยนสคริปต์ที่คั่นด้วยบทให้เป็นหนังสือเสียงแบบแบ่งบท m4b",
"script": "สคริปต์",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "Sözlük dışa aktarılamadı."
},
"audiobook": {
"output": "Çıktı",
"title": "Sesli kitap",
"subtitle": "Bölümle ayrılmış bir komut dosyasını bölümlere ayrılmış bir m4b sesli kitaba dönüştürün.",
"script": "Senaryo",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "Не вдалося експортувати словник."
},
"audiobook": {
"output": "Вивід",
"title": "Аудіокнига",
"subtitle": "Перетворіть розділений розділами сценарій на розділену на розділи аудіокнигу m4b.",
"script": "Сценарій",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "Không thể xuất từ điển."
},
"audiobook": {
"output": "Đầu ra",
"title": "Sách nói",
"subtitle": "Biến tập lệnh được phân cách theo chương thành sách nói m4b được chia theo chương.",
"script": "kịch bản",
+1
View File
@@ -2074,6 +2074,7 @@
"export_error": "无法导出词典。"
},
"audiobook": {
"output": "输出",
"title": "有声读物",
"subtitle": "将章节分隔的脚本转换为章节化的 m4b 有声读物。",
"script": "脚本",
+1
View File
@@ -2067,6 +2067,7 @@
"export_error": "無法匯出字典。"
},
"audiobook": {
"output": "輸出",
"title": "有聲書",
"subtitle": "將章節分隔的腳本轉換為章節化的 m4b 有聲書。",
"script": "腳本",
+62 -178
View File
@@ -3,13 +3,14 @@ import { useTranslation } from 'react-i18next';
import {
BookMarked,
BookOpen,
BookText,
Code,
Download,
Loader,
Image as ImageIcon,
X,
Play,
SlidersHorizontal,
SpellCheck,
Upload,
Plus,
} from 'lucide-react';
import {
@@ -26,6 +27,9 @@ import { useAppStore } from '../store';
import VoiceSelector from '../components/VoiceSelector';
import SearchableSelect from '../components/SearchableSelect';
import AudiobookOverrides, { overridesToRequest } from '../components/audiobook/AudiobookOverrides';
import Section from '../components/audiobook/Section';
import BookDetails from '../components/audiobook/BookDetails';
import LexiconEditor from '../components/audiobook/LexiconEditor';
import { SAMPLE_AUDIOBOOK_SCRIPT } from '../data/sampleAudiobook';
import ALL_LANGUAGES from '../languages.json';
import { POPULAR_LANGS } from '../utils/constants';
@@ -388,187 +392,67 @@ export default function AudiobookTab({ profiles = [] }) {
emotionSupported={emotionSupported}
/>
<div className="audiobook-tab__duo grid grid-cols-[1fr_1fr] gap-[8px]">
<div className="audiobook-tab__field flex flex-col gap-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.format')}</label>
<select
className="input-base"
value={format}
onChange={(e) => setFormat(e.target.value)}
aria-label={t('audiobook.format')}
>
<option value="m4b">{t('audiobook.format_m4b')}</option>
<option value="mp3">{t('audiobook.format_mp3')}</option>
</select>
</div>
<div className="audiobook-tab__field flex flex-col gap-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.loudness')}</label>
<select
className="input-base"
value={loudness}
onChange={(e) => setLoudness(e.target.value)}
aria-label={t('audiobook.loudness')}
>
<option value="off">{t('audiobook.loudness_off')}</option>
<option value="acx">{t('audiobook.loudness_acx')}</option>
<option value="podcast">{t('audiobook.loudness_podcast')}</option>
</select>
</div>
</div>
{/* Cover + metadata */}
<div className="audiobook-tab__field flex flex-col gap-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.details')}</label>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<div style={{ position: 'relative', width: 96, height: 96, flexShrink: 0 }}>
{coverPreview ? (
<>
<img
src={coverPreview}
alt={t('audiobook.cover')}
style={{ width: 96, height: 96, objectFit: 'cover', borderRadius: 6 }}
/>
<Button
variant="icon"
iconSize="sm"
onClick={clearCover}
aria-label={t('audiobook.cover_remove')}
style={{ position: 'absolute', top: 4, right: 4 }}
>
<X size={14} />
</Button>
</>
) : (
<label
className={buttonVariants({ variant: 'subtle', size: 'omniMd' })}
style={{
width: 96,
height: 96,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
cursor: 'pointer',
}}
>
<ImageIcon size={20} />
<span style={{ fontSize: '0.65rem' }}>{t('audiobook.cover_add')}</span>
<input
type="file"
accept="image/png,image/jpeg"
onChange={onCoverPick}
style={{ display: 'none' }}
/>
</label>
)}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 8,
flex: 1,
minWidth: 0,
}}
>
<input
{/* Output — format + loudness. Open by default so a first-timer sees a
tangible setting next to the always-on voice/language above. */}
<Section title={t('audiobook.output')} icon={<SlidersHorizontal size={13} />} defaultOpen>
<div className="grid grid-cols-[1fr_1fr] gap-[8px]">
<div className="flex flex-col gap-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.format')}</label>
<select
className="input-base"
placeholder={t('audiobook.meta_title')}
value={meta.title}
onChange={setMetaField('title')}
aria-label={t('audiobook.meta_title')}
/>
<input
className="input-base"
placeholder={t('audiobook.meta_author')}
value={meta.author}
onChange={setMetaField('author')}
aria-label={t('audiobook.meta_author')}
/>
<input
className="input-base"
placeholder={t('audiobook.meta_narrator')}
value={meta.narrator}
onChange={setMetaField('narrator')}
aria-label={t('audiobook.meta_narrator')}
/>
<input
className="input-base"
placeholder={t('audiobook.meta_year')}
value={meta.year}
onChange={setMetaField('year')}
aria-label={t('audiobook.meta_year')}
/>
<input
className="input-base"
placeholder={t('audiobook.meta_genre')}
value={meta.genre}
onChange={setMetaField('genre')}
aria-label={t('audiobook.meta_genre')}
/>
<input
className="input-base"
placeholder={t('audiobook.meta_description')}
value={meta.description}
onChange={setMetaField('description')}
aria-label={t('audiobook.meta_description')}
style={{ gridColumn: '1 / -1' }}
/>
</div>
</div>
</div>
{/* Pronunciation lexicon */}
<div className="audiobook-tab__field flex flex-col gap-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.lexicon')}</label>
{lex.map((row, i) => (
<div key={i} style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
<input
className="input-base"
placeholder={t('audiobook.lex_word')}
value={row.word}
onChange={setLexRow(i, 'word')}
aria-label={t('audiobook.lex_word')}
style={{ flex: 1, minWidth: 0 }}
/>
<input
className="input-base"
placeholder={t('audiobook.lex_say')}
value={row.say}
onChange={setLexRow(i, 'say')}
aria-label={t('audiobook.lex_say')}
style={{ flex: 1, minWidth: 0 }}
/>
<Button
variant="icon"
iconSize="sm"
onClick={() => removeLexRow(i)}
aria-label={t('audiobook.lex_remove')}
value={format}
onChange={(e) => setFormat(e.target.value)}
aria-label={t('audiobook.format')}
>
<X size={14} />
</Button>
<option value="m4b">{t('audiobook.format_m4b')}</option>
<option value="mp3">{t('audiobook.format_mp3')}</option>
</select>
</div>
))}
<Button
variant="subtle"
onClick={addLexRow}
leading={<Plus size={14} />}
style={{ alignSelf: 'flex-start' }}
>
{t('audiobook.lex_add')}
</Button>
</div>
<div className="flex flex-col gap-[4px]">
<label className={FIELD_LABEL}>{t('audiobook.loudness')}</label>
<select
className="input-base"
value={loudness}
onChange={(e) => setLoudness(e.target.value)}
aria-label={t('audiobook.loudness')}
>
<option value="off">{t('audiobook.loudness_off')}</option>
<option value="acx">{t('audiobook.loudness_acx')}</option>
<option value="podcast">{t('audiobook.loudness_podcast')}</option>
</select>
</div>
</div>
</Section>
{/* Markup quick reference */}
<details className="audiobook-tab__field flex flex-col gap-[4px]">
<summary className={FIELD_LABEL} style={{ cursor: 'pointer' }}>
{t('audiobook.markup_help')}
</summary>
<p className="muted" style={{ fontSize: '0.72rem', lineHeight: 1.6, marginTop: 6 }}>
{/* Book details — cover + embedded metadata. Collapsed by default. */}
<Section title={t('audiobook.details')} icon={<BookText size={13} />}>
<BookDetails
t={t}
coverPreview={coverPreview}
onCoverPick={onCoverPick}
clearCover={clearCover}
meta={meta}
setMetaField={setMetaField}
/>
</Section>
{/* Pronunciation lexicon — collapsed by default. */}
<Section title={t('audiobook.lexicon')} icon={<SpellCheck size={13} />}>
<LexiconEditor
t={t}
lex={lex}
setLexRow={setLexRow}
addLexRow={addLexRow}
removeLexRow={removeLexRow}
/>
</Section>
{/* Markup quick reference — collapsed by default. */}
<Section title={t('audiobook.markup_help')} icon={<Code size={13} />}>
<p className="muted" style={{ fontSize: '0.72rem', lineHeight: 1.6 }}>
{t('audiobook.markup_hint')}
</p>
</details>
</Section>
{error && (
<div className="error-banner" role="alert">
@@ -0,0 +1,84 @@
// Audiobook tab layout — the prod-polish compaction (#1214).
//
// The right-hand settings column was flattened into consistent collapsible
// Sections (Output / Book details / Pronunciation / Markup) with the primary
// inputs (script, default voice, language) always visible. This guards that
// contract so a future refactor can't silently drop a control or un-collapse
// the column: every key control still renders, Output starts open, the long
// groups start collapsed, and a collapsed group opens on click.
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from '../i18n';
import en from '../i18n/locales/en.json';
// listEngines runs once on mount to gate the emotion controls — stub it so the
// tab renders offline and reports "no emotion support".
vi.mock('../api/engines', () => ({
listEngines: vi.fn().mockResolvedValue({ tts: { active: 'x', backends: [] } }),
}));
vi.mock('../api/generate', () => ({ audioUrl: (f) => `http://test.local/audio/${f}` }));
vi.mock('../api/audiobook', () => ({
audiobookPlan: vi.fn(),
audiobookGenerate: vi.fn(),
audiobookUploadCover: vi.fn(),
audiobookPreviewChapter: vi.fn(),
audiobookImport: vi.fn(),
}));
import AudiobookTab from '../pages/AudiobookTab';
import { useAppStore } from '../store';
const withI18n = (node) => <I18nextProvider i18n={i18n}>{node}</I18nextProvider>;
// The <details> element that holds a given section header title.
const sectionFor = (title) => screen.getByText(title).closest('details');
describe('AudiobookTab — compact grouped layout (#1214)', () => {
beforeEach(() => {
localStorage.clear();
useAppStore.getState().setScript('');
});
it('keeps the primary inputs always visible', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
// Script editor, default voice, language — the three always-on controls.
expect(screen.getByLabelText(en.audiobook.script)).toBeTruthy();
expect(screen.getByText(en.audiobook.default_voice)).toBeTruthy();
expect(screen.getByText(en.audiobook.language)).toBeTruthy();
// The action bar keeps the new "Load sample" button + Create.
expect(screen.getByText(en.audiobook.load_sample)).toBeTruthy();
expect(screen.getByText(en.audiobook.create)).toBeTruthy();
});
it('groups the secondary controls into collapsible sections', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
for (const title of [
en.audiobook.output,
en.audiobook.details,
en.audiobook.lexicon,
en.audiobook.markup_help,
]) {
expect(sectionFor(title).tagName).toBe('DETAILS');
}
});
it('opens Output by default and collapses the long groups', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
expect(sectionFor(en.audiobook.output).open).toBe(true);
expect(sectionFor(en.audiobook.details).open).toBe(false);
expect(sectionFor(en.audiobook.lexicon).open).toBe(false);
// Output is open, so its format control is reachable right away.
expect(screen.getByLabelText(en.audiobook.format)).toBeTruthy();
});
it('a collapsed section toggles open on its summary', () => {
render(withI18n(<AudiobookTab profiles={[]} />));
const details = sectionFor(en.audiobook.details);
expect(details.open).toBe(false);
fireEvent.click(within(details).getByText(en.audiobook.details));
expect(details.open).toBe(true);
// Once open, the metadata inputs are present.
expect(screen.getByLabelText(en.audiobook.meta_title)).toBeTruthy();
});
});