From 8071ae1a61ca3ec4919a202f9d11a5daa21c5f66 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 6 Jun 2026 11:05:36 -0600 Subject: [PATCH] refactor(ui): centralize standard select controls --- src/components/AudiobookExportModal.tsx | 64 +++----- src/components/SettingsModal.tsx | 144 +++++------------- src/components/admin/AdminFeaturesPanel.tsx | 55 ++----- src/components/admin/AdminProvidersPanel.tsx | 127 +++++---------- src/components/documents/DocumentSettings.tsx | 32 ++-- src/components/ui/select.tsx | 107 +++++++++---- .../shared-select-consumers.vitest.spec.ts | 31 ++++ 7 files changed, 235 insertions(+), 325 deletions(-) create mode 100644 tests/unit/shared-select-consumers.vitest.spec.ts diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index b88efda..254ac1f 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -1,11 +1,11 @@ 'use client'; import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react'; -import { Transition, Listbox, Menu, MenuButton } from '@headlessui/react'; +import { Transition, Menu, MenuButton } from '@headlessui/react'; import { useTimeEstimation } from '@/hooks/useTimeEstimation'; import { ProgressPopup } from '@/components/ProgressPopup'; import { ProgressCard } from '@/components/ProgressCard'; -import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIcon, RefreshIcon, DotsVerticalIcon } from '@/components/icons/Icons'; +import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, RefreshIcon, DotsVerticalIcon } from '@/components/icons/Icons'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { useConfig } from '@/contexts/ConfigContext'; import { useTTS } from '@/contexts/TTSContext'; @@ -14,7 +14,7 @@ import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import { getTtsLanguageCompatibilityWarnings, resolveTtsLanguage } from '@/lib/shared/language'; import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; -import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, RangeInput, SharedListboxButton, SharedListboxOption, SharedListboxOptions } from '@/components/ui'; +import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, RangeInput, Select } from '@/components/ui'; import { getAudiobookStatus, deleteAudiobookChapter, @@ -565,51 +565,25 @@ export function AudiobookExportModal({
{chapters.length === 0 ? ( - setFormat(newFormat)} + options={['m4b', 'mp3'] as const} disabled={chapters.length > 0 || settingsLocked} - > -
- - {format.toUpperCase()} - - - - - - - - {({ selected }) => ( - - M4B - - )} - - - {({ selected }) => ( - - MP3 - - )} - - - -
-
+ renderValue={(option) => ( + {option.toUpperCase()} + )} + renderOption={(option, { selected }) => ( + + {option.toUpperCase()} + + )} + buttonClassName="bg-surface" + chevronClassName="h-4 w-4 text-soft" + optionInset="none" + optionItemClassName="py-2" + showCheckmark={false} + /> ) : (
{format.toUpperCase()}
)} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index a5ad457..c15bec2 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,14 +1,10 @@ 'use client'; import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'; -import { - Transition, - Listbox, -} from '@headlessui/react'; import Link from 'next/link'; import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; -import { ChevronUpDownIcon, CheckIcon, SettingsIcon, KeyIcon, PaletteIcon, DocumentIcon, UserIcon, DownloadIcon, ChevronRightIcon } from '@/components/icons/Icons'; +import { CheckIcon, SettingsIcon, KeyIcon, PaletteIcon, DocumentIcon, UserIcon, DownloadIcon, ChevronRightIcon } from '@/components/icons/Icons'; import { useDocuments } from '@/contexts/DocumentContext'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ProgressPopup } from '@/components/ProgressPopup'; @@ -52,10 +48,8 @@ import { Input, ModalFrame, ModalTitle, + Select, inputClass, - SharedListboxButton, - SharedListboxOption, - SharedListboxOptions, } from '@/components/ui'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -654,8 +648,16 @@ export function SettingsModal({ User API keys are restricted and no shared provider is configured. Ask an admin to add one.

) : ( - provider.id} + renderValue={(provider) => provider.name} + renderOption={(provider, { selected }) => ( + + {provider.name} + + )} onChange={(provider) => { const defaults = resolveProviderDefaults({ providerRef: provider.id, @@ -675,46 +677,7 @@ export function SettingsModal({ } setCustomModelInput(''); }} - > - - - {selectedProviderOption?.name || 'Select Provider'} - - - - - - - - {ttsProviders.map((provider) => ( - - {({ selected }) => ( - <> - - {provider.name} - - {selected && ( - - - - )} - - )} - - ))} - - - + /> )}
{restrictUserApiKeys && ( @@ -768,8 +731,30 @@ export function SettingsModal({

)}
- m.id === selectedModelId) || ttsModels[0]} + {renderSource('defaultTtsProvider')}
{providerOptions.length > 0 ? ( - - - {selectedProviderOption?.name ?? 'Select provider'} - - + model.id} + renderValue={(model) => model.name} + renderOption={(model, { selected }) => ( + + {model.name} + {model.id.includes(':') ? ( + + {model.id.slice(model.id.indexOf(':'))} + + ) : null} + + )} + placeholder="Select model" + chevronClassName="h-4 w-4 text-muted" + onChange={(model) => { + if (model.id === 'custom') { const nextModel = customModelInput.trim(); setForm({ ...form, @@ -476,54 +463,12 @@ export function AdminProvidersPanel() { } setForm({ ...form, - defaultModel: modelId, - defaultInstructions: modelSupportsInstructions(modelId) ? form.defaultInstructions : '', + defaultModel: model.id, + defaultInstructions: modelSupportsInstructions(model.id) ? form.defaultInstructions : '', }); setCustomModelInput(''); }} - > - - - {selectedModelDefinition?.name ?? 'Select model'} - - - - - - - - {modelDefinitions.map((model) => ( - - {({ selected }) => ( - <> - - {model.name} - {model.id.includes(':') && ( - - {model.id.slice(model.id.indexOf(':'))} - - )} - - {selected && ( - - - - )} - - )} - - ))} - - - + /> {supportsCustomModel && selectedModelId === 'custom' && ( option.value === language) + ?? DOCUMENT_LANGUAGE_OPTIONS[0]; const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0]; const isPdfMode = !epub && !html && !!pdf; const [localPreloadDepth, setLocalPreloadDepth] = useState(segmentPreloadDepthPages); @@ -183,22 +193,16 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, dete subtitle="Controls sentence splitting and synchronized word alignment." variant="flat" > -