feat(ui): enhance AudiobookExportModal and ConfirmDialog with improved layout and styling; update VoicesControlBase for better dropdown handling

This commit is contained in:
Richard R 2026-03-19 11:16:24 -06:00
parent 3c37b0c59e
commit b6852da2a2
4 changed files with 250 additions and 168 deletions

View file

@ -491,27 +491,24 @@ export function AudiobookExportModal({
<h3 className="text-lg font-medium text-foreground">Export Audiobook</h3> <h3 className="text-lg font-medium text-foreground">Export Audiobook</h3>
</div> </div>
{!isGenerating && ( {!isGenerating && (
<div className="flex justify-center"> <div className="w-full rounded-xl border border-offbase bg-background">
<div className="w-full rounded-xl border border-offbase bg-background p-4"> {/* Header */}
<div className="flex items-start justify-between gap-3"> <div className="flex items-center justify-between px-4 py-3 border-b border-offbase bg-base rounded-t-xl">
<div> <h4 className="text-sm font-medium text-foreground tracking-tight">Generation settings</h4>
<h4 className="text-sm font-medium text-foreground">Generation settings</h4>
<p className="text-xs text-muted mt-0.5">
These settings are saved per audiobook for consistent resumes across devices.
</p>
</div>
{settingsLocked && ( {settingsLocked && (
<span className="inline-flex items-center rounded-full bg-offbase px-2 py-0.5 text-xs text-muted"> <span className="inline-flex items-center gap-1 rounded-md bg-offbase px-2 py-0.5 text-[11px] font-medium text-muted uppercase tracking-wider">
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor"><path fillRule="evenodd" d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7A1.5 1.5 0 0 0 3 8.5v5A1.5 1.5 0 0 0 4.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 11.5 7V4.5A3.5 3.5 0 0 0 8 1Zm2 6V4.5a2 2 0 1 0-4 0V7h4Z" clipRule="evenodd" /></svg>
Locked Locked
</span> </span>
)} )}
</div> </div>
<div className="p-4">
{isLegacyAudiobookMissingSettings && ( {isLegacyAudiobookMissingSettings && (
<div className="mt-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-xs text-foreground"> <div className="mb-4 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-xs text-foreground">
<div className="font-medium">Saved generation settings not found</div> <div className="font-medium">Saved generation settings not found</div>
<div className="mt-1 text-muted"> <div className="mt-1 text-muted">
This audiobook was likely created before v1 metadata was introduced, so OpenReader cant know This audiobook was likely created before v1 metadata was introduced, so OpenReader can&apos;t know
which voice/speeds/format were used. Consider resetting this audiobook to regenerate it with which voice/speeds/format were used. Consider resetting this audiobook to regenerate it with
v1 metadata (so settings are saved for resumes across devices). v1 metadata (so settings are saved for resumes across devices).
</div> </div>
@ -519,49 +516,61 @@ export function AudiobookExportModal({
)} )}
{settingsLocked && savedSettings ? ( {settingsLocked && savedSettings ? (
<div className="mt-3 text-sm"> <div className="space-y-3">
<div className="text-muted"> <div className="grid grid-cols-2 gap-3">
Voice: <span className="text-foreground">{savedSettings.voice}</span> <div className="rounded-lg bg-base p-3">
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Voice</div>
<div className="text-sm font-medium text-foreground truncate">{savedSettings.voice}</div>
</div> </div>
<div className="text-muted"> <div className="rounded-lg bg-base p-3">
Native speed: <span className="text-foreground">{formatSpeed(savedSettings.nativeSpeed)}x</span> <div className="text-[11px] uppercase tracking-wider text-muted mb-1">Format</div>
<div className="text-sm font-medium text-foreground">{savedSettings.format.toUpperCase()}</div>
</div> </div>
<div className="text-muted">
Post speed: <span className="text-foreground">{formatSpeed(savedSettings.postSpeed)}x</span>
</div> </div>
<div className="text-muted"> <div className="grid grid-cols-2 gap-3">
Format: <span className="text-foreground">{savedSettings.format.toUpperCase()}</span> <div className="rounded-lg bg-base p-3">
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Native speed</div>
<div className="text-sm font-medium text-foreground">{formatSpeed(savedSettings.nativeSpeed)}x</div>
</div> </div>
<p className="text-xs text-muted mt-2"> <div className="rounded-lg bg-base p-3">
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Post speed</div>
<div className="text-sm font-medium text-foreground">{formatSpeed(savedSettings.postSpeed)}x</div>
</div>
</div>
<p className="text-xs text-muted">
Reset the audiobook to change generation settings. Reset the audiobook to change generation settings.
</p> </p>
</div> </div>
) : ( ) : (
<div className="mt-3 space-y-4"> <div className="space-y-4">
<div className="space-y-1"> {/* Voice & Format row */}
<div className="text-xs font-medium text-foreground">Voice</div> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Voice</label>
<VoicesControlBase <VoicesControlBase
availableVoices={availableVoices} availableVoices={availableVoices}
voice={audiobookVoice} voice={audiobookVoice}
onChangeVoice={setAudiobookVoice} onChangeVoice={setAudiobookVoice}
ttsProvider={ttsProvider} ttsProvider={ttsProvider}
ttsModel={ttsModel} ttsModel={ttsModel}
dropdownDirection="down"
variant="field"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1.5">
<div className="text-xs font-medium text-foreground">Output format</div> <label className="text-[11px] uppercase tracking-wider font-medium text-muted">Format</label>
{chapters.length === 0 ? ( {chapters.length === 0 ? (
<Listbox <Listbox
value={format} value={format}
onChange={(newFormat) => setFormat(newFormat)} onChange={(newFormat) => setFormat(newFormat)}
disabled={chapters.length > 0 || settingsLocked} disabled={chapters.length > 0 || settingsLocked}
> >
<div className="relative inline-block"> <div className="relative">
<ListboxButton className="relative cursor-pointer rounded-lg bg-base py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent min-w-[120px]"> <ListboxButton className="relative cursor-pointer rounded-lg bg-base py-1.5 pl-3 pr-10 text-left text-foreground focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent w-full">
<span className="block truncate text-sm font-medium">{format.toUpperCase()}</span> <span className="block truncate text-sm font-medium">{format.toUpperCase()}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" /> <ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span> </span>
</ListboxButton> </ListboxButton>
<Transition <Transition
@ -570,12 +579,11 @@ export function AudiobookExportModal({
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions className="absolute left-0 mt-1 max-h-60 w-full overflow-auto rounded-md bg-base py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-10"> <ListboxOptions className="absolute left-0 mt-1 max-h-60 w-full overflow-auto rounded-lg bg-base py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-10">
<ListboxOption <ListboxOption
value="m4b" value="m4b"
className={({ active }) => className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}`
} }
> >
{({ selected }) => ( {({ selected }) => (
@ -587,8 +595,7 @@ export function AudiobookExportModal({
<ListboxOption <ListboxOption
value="mp3" value="mp3"
className={({ active }) => className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}`
} }
> >
{({ selected }) => ( {({ selected }) => (
@ -602,14 +609,17 @@ export function AudiobookExportModal({
</div> </div>
</Listbox> </Listbox>
) : ( ) : (
<div className="text-sm text-foreground">{format.toUpperCase()}</div> <div className="text-sm font-medium text-foreground py-1.5 pl-3">{format.toUpperCase()}</div>
)} )}
</div> </div>
</div>
<div className="space-y-1"> {/* Speed controls */}
<div className="flex items-center"> <div className="rounded-lg bg-base p-3 space-y-3">
<div className="text-xs font-medium text-foreground mr-1">Native model speed</div> <div className="space-y-2">
<div className="text-xs text-muted"> {formatSpeed(nativeSpeed)}x</div> <div className="flex items-center justify-between">
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Native model speed</label>
<span className="text-xs font-medium text-accent tabular-nums">{formatSpeed(nativeSpeed)}x</span>
</div> </div>
<input <input
type="range" type="range"
@ -618,14 +628,20 @@ export function AudiobookExportModal({
step="0.1" step="0.1"
value={nativeSpeed} value={nativeSpeed}
onChange={(e) => setNativeSpeed(parseFloat(e.target.value))} onChange={(e) => setNativeSpeed(parseFloat(e.target.value))}
className="w-full max-w-xs bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent" className="w-full h-1.5 bg-offbase rounded-full appearance-none cursor-pointer [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-1.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent [&::-moz-range-thumb]:border-0"
/> />
<div className="flex justify-between text-[10px] text-muted">
<span>0.5x</span>
<span>3x</span>
</div>
</div> </div>
<div className="space-y-1"> <div className="border-t border-offbase" />
<div className="flex items-center">
<div className="text-xs font-medium text-foreground mr-1">Post-generation speed</div> <div className="space-y-2">
<div className="text-xs text-muted"> {formatSpeed(postSpeed)}x</div> <div className="flex items-center justify-between">
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Post-generation speed</label>
<span className="text-xs font-medium text-accent tabular-nums">{formatSpeed(postSpeed)}x</span>
</div> </div>
<input <input
type="range" type="range"
@ -634,18 +650,24 @@ export function AudiobookExportModal({
step="0.1" step="0.1"
value={postSpeed} value={postSpeed}
onChange={(e) => setPostSpeed(parseFloat(e.target.value))} onChange={(e) => setPostSpeed(parseFloat(e.target.value))}
className="w-full max-w-xs bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent" className="w-full h-1.5 bg-offbase rounded-full appearance-none cursor-pointer [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-1.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent [&::-moz-range-thumb]:border-0"
/> />
<div className="flex justify-between text-[10px] text-muted">
<span>0.5x</span>
<span>3x</span>
</div>
</div>
</div> </div>
</div> </div>
)} )}
<div className="mt-4 space-y-2"> {/* Action buttons */}
<div className="mt-4 flex items-center gap-2">
{chapters.length === 0 && ( {chapters.length === 0 && (
<Button <Button
onClick={handleStartGeneration} onClick={handleStartGeneration}
disabled={!canGenerate} disabled={!canGenerate}
className="w-full inline-flex justify-center rounded-lg bg-accent px-3 py-2 text-sm className="flex-1 inline-flex justify-center rounded-lg bg-accent px-3 py-2 text-sm
font-medium text-background hover:bg-secondary-accent focus:outline-none font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100" transform transition-transform duration-200 ease-in-out hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
@ -657,7 +679,7 @@ export function AudiobookExportModal({
<Button <Button
onClick={handleStartGeneration} onClick={handleStartGeneration}
disabled={!canGenerate} disabled={!canGenerate}
className="w-full inline-flex justify-center rounded-lg bg-accent px-3 py-2 text-sm className="flex-1 inline-flex justify-center rounded-lg bg-accent px-3 py-2 text-sm
font-medium text-background hover:bg-secondary-accent focus:outline-none font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100" transform transition-transform duration-200 ease-in-out hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
@ -669,9 +691,9 @@ export function AudiobookExportModal({
<Button <Button
onClick={() => setShowResetConfirm(true)} onClick={() => setShowResetConfirm(true)}
disabled={isGenerating} disabled={isGenerating}
className="w-full justify-center rounded-lg bg-red-500 px-3 py-2 text-sm className="inline-flex justify-center rounded-lg border border-red-500 bg-transparent px-3 py-2 text-sm
font-medium text-background hover:bg-red-500/90 focus:outline-none font-medium text-red-500 hover:bg-red-500 hover:text-background focus:outline-none
focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2 focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.01]" transform transition-transform duration-200 ease-in-out hover:scale-[1.01]"
title="Delete all generated chapters/pages for this document" title="Delete all generated chapters/pages for this document"
> >
@ -716,7 +738,7 @@ export function AudiobookExportModal({
{chapters.length > 0 && ( {chapters.length > 0 && (
<> <>
<div <div
className={`space-y-2 max-h-96 overflow-scroll pr-1 ${isRefreshingChapters ? 'opacity-70 transition-opacity' : ''}`} className={`w-full space-y-2 max-h-96 overflow-y-auto ${isRefreshingChapters ? 'opacity-70 transition-opacity' : ''}`}
aria-busy={isRefreshingChapters} aria-busy={isRefreshingChapters}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -69,7 +69,7 @@ export function ConfirmDialog({
{title} {title}
</DialogTitle> </DialogTitle>
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-foreground/90">{message}</p> <p className="text-sm text-foreground/90 break-words">{message}</p>
</div> </div>
<div className="mt-6 flex justify-end space-x-3"> <div className="mt-6 flex justify-end space-x-3">
@ -85,7 +85,7 @@ export function ConfirmDialog({
</button> </button>
<button <button
type="button" type="button"
className={`inline-flex justify-center rounded-lg px-3 py-1.5 text-sm className={`inline-flex justify-center rounded-lg px-3 py-1.5 text-sm text-wrap
font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] transform transition-transform duration-200 ease-in-out hover:scale-[1.04]
${isDangerous ${isDangerous

View file

@ -52,7 +52,7 @@ export function ProgressCard({
</div> </div>
<button <button
type="button" type="button"
className="shrink-0 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium text-foreground hover:text-accent hover:bg-background/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transition-colors" className="shrink-0 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium text-foreground hover:text-accent hover:bg-background/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
onClick={(e) => onCancel(e)} onClick={(e) => onCancel(e)}
> >
<span>{cancelText}</span> <span>{cancelText}</span>

View file

@ -6,7 +6,7 @@ import {
ListboxOption, ListboxOption,
ListboxOptions, ListboxOptions,
} from '@headlessui/react'; } from '@headlessui/react';
import { ChevronUpDownIcon, AudioWaveIcon } from '@/components/icons/Icons'; import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { buildKokoroVoiceString, getMaxVoicesForProvider, isKokoroModel, parseKokoroVoiceNames } from '@/lib/shared/kokoro'; import { buildKokoroVoiceString, getMaxVoicesForProvider, isKokoroModel, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
@ -16,13 +16,36 @@ export function VoicesControlBase({
onChangeVoice, onChangeVoice,
ttsProvider, ttsProvider,
ttsModel, ttsModel,
dropdownDirection = 'up',
variant = 'compact',
}: { }: {
availableVoices: string[]; availableVoices: string[];
voice: string; voice: string;
onChangeVoice: (voice: string) => void; onChangeVoice: (voice: string) => void;
ttsProvider: string; ttsProvider: string;
ttsModel: string; ttsModel: string;
dropdownDirection?: 'up' | 'down';
variant?: 'compact' | 'field';
}) { }) {
const dropdownPosition = dropdownDirection === 'down'
? 'top-full left-0 mt-1'
: 'bottom-full right-0 mb-1';
const buttonClass = variant === 'field'
? 'relative cursor-pointer rounded-lg bg-base py-1.5 pl-3 pr-10 text-left text-foreground focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent w-full'
: 'flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent';
const iconClass = variant === 'field'
? 'h-3.5 w-3.5 shrink-0'
: 'h-3 w-3 sm:h-3.5 sm:w-3.5';
const chevronClass = variant === 'field'
? 'h-4 w-4 text-muted'
: 'h-2.5 w-2.5 sm:h-3 sm:w-3';
const dropdownWidth = variant === 'field'
? 'w-full'
: isKokoroModel(ttsModel) && getMaxVoicesForProvider(ttsProvider, ttsModel) > 1 ? 'w-40 sm:w-44' : 'w-28 sm:w-32';
const isKokoro = isKokoroModel(ttsModel); const isKokoro = isKokoroModel(ttsModel);
const maxVoices = getMaxVoicesForProvider(ttsProvider, ttsModel); const maxVoices = getMaxVoicesForProvider(ttsProvider, ttsModel);
@ -91,33 +114,70 @@ export function VoicesControlBase({
} }
}} }}
> >
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"> <ListboxButton className={buttonClass}>
<AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" /> {variant === 'field' ? (
<>
<span className="flex items-center gap-2 truncate text-sm font-medium">
<AudioWaveIcon className={iconClass} />
{selectedVoices.length > 1 ? selectedVoices.join(' + ') : selectedVoices[0] || currentVoice}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className={chevronClass} />
</span>
</>
) : (
<>
<AudioWaveIcon className={iconClass} />
<span>{selectedVoices.length > 1 ? selectedVoices.join(' + ') : selectedVoices[0] || currentVoice}</span> <span>{selectedVoices.length > 1 ? selectedVoices.join(' + ') : selectedVoices[0] || currentVoice}</span>
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" /> <ChevronUpDownIcon className={chevronClass} />
</>
)}
</ListboxButton> </ListboxButton>
<ListboxOptions className="absolute bottom-full right-0 mb-1 z-50 w-40 sm:w-44 !h-auto !min-h-0 !max-h-[50vh] overflow-y-auto overscroll-contain rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> <ListboxOptions className={`absolute ${dropdownPosition} z-50 ${dropdownWidth} !h-auto !min-h-0 !max-h-[50vh] overflow-y-auto overscroll-contain rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none`}>
{availableVoices.map((voiceId) => ( {availableVoices.map((voiceId) => (
<ListboxOption <ListboxOption
key={voiceId} key={voiceId}
value={voiceId} value={voiceId}
className={({ active, selected }) => className={({ active, selected }) =>
`relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}` `relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 flex items-center gap-2 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}`
} }
> >
{({ selected }) => (
<>
{selected ? (
<CheckIcon className="h-3.5 w-3.5 shrink-0" />
) : (
<span className="h-3.5 w-3.5 shrink-0" />
)}
<span className="text-xs sm:text-sm">{voiceId}</span> <span className="text-xs sm:text-sm">{voiceId}</span>
</>
)}
</ListboxOption> </ListboxOption>
))} ))}
</ListboxOptions> </ListboxOptions>
</Listbox> </Listbox>
) : ( ) : (
<Listbox value={currentVoice} onChange={onChangeVoice}> <Listbox value={currentVoice} onChange={onChangeVoice}>
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"> <ListboxButton className={buttonClass}>
<AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" /> {variant === 'field' ? (
<>
<span className="flex items-center gap-2 truncate text-sm font-medium">
<AudioWaveIcon className={iconClass} />
{currentVoice}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className={chevronClass} />
</span>
</>
) : (
<>
<AudioWaveIcon className={iconClass} />
<span>{currentVoice}</span> <span>{currentVoice}</span>
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" /> <ChevronUpDownIcon className={chevronClass} />
</>
)}
</ListboxButton> </ListboxButton>
<ListboxOptions className="absolute bottom-full right-0 mb-1 z-50 w-28 sm:w-32 !h-auto !min-h-0 !max-h-[50vh] overflow-y-auto overscroll-contain rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> <ListboxOptions className={`absolute ${dropdownPosition} z-50 ${dropdownWidth} !h-auto !min-h-0 !max-h-[50vh] overflow-y-auto overscroll-contain rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none`}>
{availableVoices.map((voiceId) => ( {availableVoices.map((voiceId) => (
<ListboxOption <ListboxOption
key={voiceId} key={voiceId}