refactor(ui): centralize standard select controls
This commit is contained in:
parent
112dbc0da9
commit
8071ae1a61
7 changed files with 235 additions and 325 deletions
|
|
@ -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({
|
|||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] uppercase tracking-wider font-medium text-soft">Format</label>
|
||||
{chapters.length === 0 ? (
|
||||
<Listbox
|
||||
<Select
|
||||
value={format}
|
||||
onChange={(newFormat) => setFormat(newFormat)}
|
||||
options={['m4b', 'mp3'] as const}
|
||||
disabled={chapters.length > 0 || settingsLocked}
|
||||
>
|
||||
<div className="relative">
|
||||
<SharedListboxButton className="bg-surface">
|
||||
<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">
|
||||
<ChevronUpDownIcon className="h-4 w-4 text-soft" />
|
||||
</span>
|
||||
</SharedListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-standard duration-fast"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<SharedListboxOptions className="absolute left-0 mt-1 w-full">
|
||||
<SharedListboxOption
|
||||
value="m4b"
|
||||
inset="none"
|
||||
itemClassName="py-2"
|
||||
>
|
||||
{({ selected }) => (
|
||||
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
M4B
|
||||
</span>
|
||||
)}
|
||||
</SharedListboxOption>
|
||||
<SharedListboxOption
|
||||
value="mp3"
|
||||
inset="none"
|
||||
itemClassName="py-2"
|
||||
>
|
||||
{({ selected }) => (
|
||||
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
MP3
|
||||
</span>
|
||||
)}
|
||||
</SharedListboxOption>
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
renderValue={(option) => (
|
||||
<span className="text-sm font-medium">{option.toUpperCase()}</span>
|
||||
)}
|
||||
renderOption={(option, { selected }) => (
|
||||
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
{option.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
buttonClassName="bg-surface"
|
||||
chevronClassName="h-4 w-4 text-soft"
|
||||
optionInset="none"
|
||||
optionItemClassName="py-2"
|
||||
showCheckmark={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm font-medium text-foreground py-1.5 pl-3">{format.toUpperCase()}</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
</p>
|
||||
) : (
|
||||
<Listbox
|
||||
<Select
|
||||
value={selectedProviderOption!}
|
||||
options={ttsProviders}
|
||||
getOptionKey={(provider) => provider.id}
|
||||
renderValue={(provider) => provider.name}
|
||||
renderOption={(provider, { selected }) => (
|
||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
{provider.name}
|
||||
</span>
|
||||
)}
|
||||
onChange={(provider) => {
|
||||
const defaults = resolveProviderDefaults({
|
||||
providerRef: provider.id,
|
||||
|
|
@ -675,46 +677,7 @@ export function SettingsModal({
|
|||
}
|
||||
setCustomModelInput('');
|
||||
}}
|
||||
>
|
||||
<SharedListboxButton>
|
||||
<span className="block truncate">
|
||||
{selectedProviderOption?.name || 'Select Provider'}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-soft" />
|
||||
</span>
|
||||
</SharedListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-standard duration-fast"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<SharedListboxOptions
|
||||
anchor="bottom start"
|
||||
>
|
||||
{ttsProviders.map((provider) => (
|
||||
<SharedListboxOption
|
||||
key={provider.id}
|
||||
value={provider}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
{provider.name}
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{restrictUserApiKeys && (
|
||||
|
|
@ -768,8 +731,30 @@ export function SettingsModal({
|
|||
</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Listbox
|
||||
value={ttsModels.find(m => m.id === selectedModelId) || ttsModels[0]}
|
||||
<Select
|
||||
value={selectedModel}
|
||||
options={ttsModels}
|
||||
getOptionKey={(model) => model.id}
|
||||
renderValue={(model) => (
|
||||
<span className="block">
|
||||
<span className="block truncate">{model.name}</span>
|
||||
{selectedModelVersion ? (
|
||||
<span className="block truncate text-xs text-soft">
|
||||
{selectedModelVersion}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
renderOption={(model, { selected }) => (
|
||||
<span className={`block ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
<span className="block truncate">{model.name}</span>
|
||||
{model.id.includes(':') ? (
|
||||
<span className="block truncate text-xs text-soft">
|
||||
{model.id.slice(model.id.indexOf(':'))}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
onChange={(model) => {
|
||||
if (model.id === 'custom') {
|
||||
setModelValue(customModelInput);
|
||||
|
|
@ -778,62 +763,7 @@ export function SettingsModal({
|
|||
setCustomModelInput('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SharedListboxButton>
|
||||
{selectedModel ? (
|
||||
<span className="block">
|
||||
<span className="block truncate">
|
||||
{selectedModel.name}
|
||||
</span>
|
||||
{selectedModelVersion && (
|
||||
<span className="block truncate text-xs text-soft">
|
||||
{selectedModelVersion}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="block truncate">Select Model</span>
|
||||
)}
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-soft" />
|
||||
</span>
|
||||
</SharedListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-standard duration-fast"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<SharedListboxOptions
|
||||
anchor="bottom start"
|
||||
>
|
||||
{ttsModels.map((model) => (
|
||||
<SharedListboxOption
|
||||
key={model.id}
|
||||
value={model}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className={`block ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
<span className="block truncate">{model.name}</span>
|
||||
{model.id.includes(':') && (
|
||||
<span className="block truncate text-xs text-soft">
|
||||
{model.id.slice(model.id.indexOf(':'))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
/>
|
||||
|
||||
{supportsCustom && selectedModelId === 'custom' && (
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
'use client';
|
||||
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { Listbox, Transition } from '@headlessui/react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
|
||||
import {
|
||||
Badge,
|
||||
Section,
|
||||
ToggleRow,
|
||||
inputClass,
|
||||
SharedListboxButton,
|
||||
SharedListboxOption,
|
||||
SharedListboxOptions,
|
||||
Select,
|
||||
Button,
|
||||
} from '@/components/ui';
|
||||
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||
|
|
@ -204,42 +200,19 @@ export function AdminFeaturesPanel() {
|
|||
<div className="shrink-0">{renderSource('defaultTtsProvider')}</div>
|
||||
</div>
|
||||
{providerOptions.length > 0 ? (
|
||||
<Listbox value={selectedProviderOption} onChange={handleProviderChange}>
|
||||
<SharedListboxButton>
|
||||
<span className="block truncate">{selectedProviderOption?.name ?? 'Select provider'}</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
|
||||
<Select
|
||||
value={selectedProviderOption}
|
||||
onChange={handleProviderChange}
|
||||
options={providerOptions}
|
||||
getOptionKey={(option) => option.id}
|
||||
renderValue={(option) => option.name}
|
||||
renderOption={(option, { selected }) => (
|
||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
{option.name}
|
||||
</span>
|
||||
</SharedListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<SharedListboxOptions anchor="bottom start">
|
||||
{providerOptions.map((opt) => (
|
||||
<SharedListboxOption
|
||||
key={opt.id}
|
||||
value={opt}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
{opt.name}
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
)}
|
||||
chevronClassName="h-4 w-4 text-muted"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-0.5 py-2 text-sm text-muted">
|
||||
No shared providers yet. Add one first.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Listbox, Menu, MenuButton, Transition } from '@headlessui/react';
|
||||
import { Menu, MenuButton, Transition } from '@headlessui/react';
|
||||
import { Fragment } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { ChevronUpDownIcon, CheckIcon, DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
|
||||
import { DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
|
||||
import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||
import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||
import {
|
||||
|
|
@ -14,9 +14,7 @@ import {
|
|||
Section,
|
||||
ToggleRow,
|
||||
inputClass,
|
||||
SharedListboxButton,
|
||||
SharedListboxOption,
|
||||
SharedListboxOptions,
|
||||
Select,
|
||||
Button,
|
||||
IconButton,
|
||||
Input,
|
||||
|
|
@ -410,8 +408,17 @@ export function AdminProvidersPanel() {
|
|||
/>
|
||||
</Field>
|
||||
<Field label="Provider type">
|
||||
<Listbox
|
||||
<Select
|
||||
value={selectedProviderType}
|
||||
options={PROVIDER_TYPE_OPTIONS}
|
||||
getOptionKey={(option) => option.value}
|
||||
renderValue={(option) => option.label}
|
||||
renderOption={(option, { selected }) => (
|
||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
)}
|
||||
chevronClassName="h-4 w-4 text-muted"
|
||||
onChange={(opt) => {
|
||||
const nextModel = providerDefaultModel(opt.value);
|
||||
setForm({
|
||||
|
|
@ -423,49 +430,29 @@ export function AdminProvidersPanel() {
|
|||
});
|
||||
setCustomModelInput('');
|
||||
}}
|
||||
>
|
||||
<SharedListboxButton>
|
||||
<span className="block truncate">{selectedProviderType.label}</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
|
||||
</span>
|
||||
</SharedListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<SharedListboxOptions anchor="bottom start">
|
||||
{PROVIDER_TYPE_OPTIONS.map((opt) => (
|
||||
<SharedListboxOption
|
||||
key={opt.value}
|
||||
value={opt}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
{opt.label}
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Default model" hint="Pre-selected for users picking this provider.">
|
||||
<div className="space-y-2">
|
||||
<Listbox
|
||||
value={selectedModelId}
|
||||
onChange={(modelId: string) => {
|
||||
if (modelId === 'custom') {
|
||||
<Select
|
||||
value={selectedModelDefinition}
|
||||
options={modelDefinitions}
|
||||
getOptionKey={(model) => model.id}
|
||||
renderValue={(model) => model.name}
|
||||
renderOption={(model, { selected }) => (
|
||||
<span className={`block ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
<span className="block truncate">{model.name}</span>
|
||||
{model.id.includes(':') ? (
|
||||
<span className="block truncate text-xs text-muted">
|
||||
{model.id.slice(model.id.indexOf(':'))}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
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('');
|
||||
}}
|
||||
>
|
||||
<SharedListboxButton>
|
||||
<span className="block truncate">
|
||||
{selectedModelDefinition?.name ?? 'Select model'}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
|
||||
</span>
|
||||
</SharedListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<SharedListboxOptions anchor="bottom start">
|
||||
{modelDefinitions.map((model) => (
|
||||
<SharedListboxOption
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className={`block ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
<span className="block truncate">{model.name}</span>
|
||||
{model.id.includes(':') && (
|
||||
<span className="block truncate text-xs text-muted">
|
||||
{model.id.slice(model.id.indexOf(':'))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
/>
|
||||
{supportsCustomModel && selectedModelId === 'custom' && (
|
||||
<Input
|
||||
type="text"
|
||||
|
|
|
|||
|
|
@ -16,7 +16,15 @@ import {
|
|||
clampSegmentPreloadSentenceLookahead,
|
||||
clampTtsSegmentMaxBlockLength,
|
||||
} from '@/types/config';
|
||||
import { IconButton, RangeInput, Section, ToggleRow, CheckItem, SegmentedControl } from '@/components/ui';
|
||||
import {
|
||||
IconButton,
|
||||
RangeInput,
|
||||
Section,
|
||||
ToggleRow,
|
||||
CheckItem,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
} from '@/components/ui';
|
||||
import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
|
||||
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
|
||||
import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse';
|
||||
|
|
@ -148,6 +156,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, dete
|
|||
voice,
|
||||
documentLanguage: resolvedLanguage,
|
||||
});
|
||||
const selectedLanguage = DOCUMENT_LANGUAGE_OPTIONS.find((option) => 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"
|
||||
>
|
||||
<label className="block space-y-1.5">
|
||||
<div className="space-y-1.5">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||
Document language
|
||||
</span>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(event) => onLanguageChange(event.target.value)}
|
||||
className="w-full rounded-md border border-offbase bg-surface-solid px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
{DOCUMENT_LANGUAGE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Select
|
||||
value={selectedLanguage}
|
||||
onChange={(option) => onLanguageChange(option.value)}
|
||||
options={DOCUMENT_LANGUAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
{language === 'auto' && detectedLanguage ? (
|
||||
<p className="text-xs text-soft">
|
||||
Detected from document metadata: {getLanguageDisplayName(detectedLanguage)}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
'use client';
|
||||
|
||||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
|
||||
import { Fragment, type ComponentProps, type Key, type ReactNode } from 'react';
|
||||
import { cn } from './cn';
|
||||
import { CheckIcon, ChevronRightIcon } from '@/components/icons/Icons';
|
||||
import { CheckIcon, ChevronUpDownIcon } from '@/components/icons/Icons';
|
||||
|
||||
const listboxButtonClass =
|
||||
'relative w-full cursor-pointer rounded-md bg-surface-sunken border border-line py-1.5 pl-2.5 pr-9 text-left text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent-line hover:bg-accent-wash transition-colors duration-fast ease-standard';
|
||||
|
|
@ -104,39 +104,92 @@ export function SharedListboxOption({
|
|||
);
|
||||
}
|
||||
|
||||
export function Select({
|
||||
export type SelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function Select<T = SelectOption>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
getOptionKey,
|
||||
renderValue,
|
||||
renderOption,
|
||||
placeholder = 'Select',
|
||||
disabled,
|
||||
buttonClassName,
|
||||
optionsClassName,
|
||||
optionInset = 'check',
|
||||
optionItemClassName,
|
||||
showCheckmark = true,
|
||||
chevronClassName = 'h-5 w-5 text-soft',
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
value: T | undefined;
|
||||
onChange: (value: T) => void;
|
||||
options: readonly T[];
|
||||
getOptionKey?: (option: T) => Key;
|
||||
renderValue?: (option: T) => ReactNode;
|
||||
renderOption?: (option: T, state: { selected: boolean }) => ReactNode;
|
||||
placeholder?: ReactNode;
|
||||
disabled?: boolean;
|
||||
buttonClassName?: string;
|
||||
optionsClassName?: string;
|
||||
optionInset?: 'check' | 'none';
|
||||
optionItemClassName?: string;
|
||||
showCheckmark?: boolean;
|
||||
chevronClassName?: string;
|
||||
}) {
|
||||
const activeOption = options.find((option) => option.value === value) ?? options[0];
|
||||
const defaultKey = (option: T): Key => {
|
||||
if (typeof option === 'string' || typeof option === 'number') return option;
|
||||
const record = option as Record<string, unknown>;
|
||||
return String(record.value ?? record.id ?? record.label);
|
||||
};
|
||||
const defaultRender = (option: T): ReactNode => {
|
||||
if (typeof option === 'string' || typeof option === 'number') return String(option);
|
||||
const record = option as Record<string, unknown>;
|
||||
return String(record.label ?? record.name ?? record.value ?? record.id ?? '');
|
||||
};
|
||||
const optionKey = getOptionKey ?? defaultKey;
|
||||
const valueRenderer = renderValue ?? defaultRender;
|
||||
const optionRenderer = renderOption ?? ((option: T) => valueRenderer(option));
|
||||
|
||||
return (
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
<SharedListboxButton>
|
||||
<span>{activeOption?.label ?? 'Select'}</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-soft">
|
||||
<ChevronRightIcon className="h-4 w-4 rotate-90" aria-hidden="true" />
|
||||
<Listbox value={value} onChange={onChange} disabled={disabled}>
|
||||
<SharedListboxButton className={buttonClassName}>
|
||||
<span className="block truncate">{value === undefined ? placeholder : valueRenderer(value)}</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className={chevronClassName} aria-hidden="true" />
|
||||
</span>
|
||||
</SharedListboxButton>
|
||||
<SharedListboxOptions anchor="bottom">
|
||||
{options.map((option) => (
|
||||
<SharedListboxOption key={option.value} value={option.value}>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="absolute left-2 flex items-center text-accent">
|
||||
{selected ? <CheckIcon className="h-4 w-4" aria-hidden="true" /> : null}
|
||||
</span>
|
||||
<span>{option.label}</span>
|
||||
</>
|
||||
)}
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</SharedListboxOptions>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-standard duration-fast"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<SharedListboxOptions anchor="bottom start" className={optionsClassName}>
|
||||
{options.map((option) => (
|
||||
<SharedListboxOption
|
||||
key={optionKey(option)}
|
||||
value={option}
|
||||
inset={optionInset}
|
||||
itemClassName={optionItemClassName}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
{optionRenderer(option, { selected })}
|
||||
{showCheckmark && selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
31
tests/unit/shared-select-consumers.vitest.spec.ts
Normal file
31
tests/unit/shared-select-consumers.vitest.spec.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
const STANDARD_SELECT_CONSUMERS = [
|
||||
'src/components/SettingsModal.tsx',
|
||||
'src/components/documents/DocumentSettings.tsx',
|
||||
'src/components/admin/AdminFeaturesPanel.tsx',
|
||||
'src/components/admin/AdminProvidersPanel.tsx',
|
||||
'src/components/AudiobookExportModal.tsx',
|
||||
];
|
||||
|
||||
describe('shared Select consumers', () => {
|
||||
test('standard settings dropdowns use the high-level UI primitive', () => {
|
||||
for (const relativePath of STANDARD_SELECT_CONSUMERS) {
|
||||
const source = readFileSync(resolve(process.cwd(), relativePath), 'utf8');
|
||||
expect(source, relativePath).toContain('<Select');
|
||||
expect(source, relativePath).not.toContain('<Listbox');
|
||||
expect(source, relativePath).not.toContain('<SharedListboxButton');
|
||||
expect(source, relativePath).not.toContain('<SharedListboxOptions');
|
||||
expect(source, relativePath).not.toContain('<SharedListboxOption');
|
||||
}
|
||||
});
|
||||
|
||||
test('the shared Select owns standard dropdown chrome', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/components/ui/select.tsx'), 'utf8');
|
||||
expect(source).toContain('ChevronUpDownIcon');
|
||||
expect(source).toContain('CheckIcon');
|
||||
expect(source).toContain('<Transition');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue