refactor(ui): centralize standard select controls

This commit is contained in:
Richard R 2026-06-06 11:05:36 -06:00
parent 112dbc0da9
commit 8071ae1a61
7 changed files with 235 additions and 325 deletions

View file

@ -1,11 +1,11 @@
'use client'; 'use client';
import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react'; 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 { useTimeEstimation } from '@/hooks/useTimeEstimation';
import { ProgressPopup } from '@/components/ProgressPopup'; import { ProgressPopup } from '@/components/ProgressPopup';
import { ProgressCard } from '@/components/ProgressCard'; 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 { ConfirmDialog } from '@/components/ConfirmDialog';
import { useConfig } from '@/contexts/ConfigContext'; import { useConfig } from '@/contexts/ConfigContext';
import { useTTS } from '@/contexts/TTSContext'; import { useTTS } from '@/contexts/TTSContext';
@ -14,7 +14,7 @@ import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { getTtsLanguageCompatibilityWarnings, resolveTtsLanguage } from '@/lib/shared/language'; import { getTtsLanguageCompatibilityWarnings, resolveTtsLanguage } from '@/lib/shared/language';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; 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 { import {
getAudiobookStatus, getAudiobookStatus,
deleteAudiobookChapter, deleteAudiobookChapter,
@ -565,51 +565,25 @@ export function AudiobookExportModal({
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[11px] uppercase tracking-wider font-medium text-soft">Format</label> <label className="text-[11px] uppercase tracking-wider font-medium text-soft">Format</label>
{chapters.length === 0 ? ( {chapters.length === 0 ? (
<Listbox <Select
value={format} value={format}
onChange={(newFormat) => setFormat(newFormat)} onChange={(newFormat) => setFormat(newFormat)}
options={['m4b', 'mp3'] as const}
disabled={chapters.length > 0 || settingsLocked} disabled={chapters.length > 0 || settingsLocked}
> renderValue={(option) => (
<div className="relative"> <span className="text-sm font-medium">{option.toUpperCase()}</span>
<SharedListboxButton className="bg-surface"> )}
<span className="block truncate text-sm font-medium">{format.toUpperCase()}</span> renderOption={(option, { selected }) => (
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
<ChevronUpDownIcon className="h-4 w-4 text-soft" /> {option.toUpperCase()}
</span> </span>
</SharedListboxButton> )}
<Transition buttonClassName="bg-surface"
as={Fragment} chevronClassName="h-4 w-4 text-soft"
leave="transition ease-standard duration-fast" optionInset="none"
leaveFrom="opacity-100" optionItemClassName="py-2"
leaveTo="opacity-0" showCheckmark={false}
> />
<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>
) : ( ) : (
<div className="text-sm font-medium text-foreground py-1.5 pl-3">{format.toUpperCase()}</div> <div className="text-sm font-medium text-foreground py-1.5 pl-3">{format.toUpperCase()}</div>
)} )}

View file

@ -1,14 +1,10 @@
'use client'; 'use client';
import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'; import { Fragment, useState, useEffect, useCallback, useMemo } from 'react';
import {
Transition,
Listbox,
} from '@headlessui/react';
import Link from 'next/link'; import Link from 'next/link';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext'; 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 { useDocuments } from '@/contexts/DocumentContext';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ProgressPopup } from '@/components/ProgressPopup'; import { ProgressPopup } from '@/components/ProgressPopup';
@ -52,10 +48,8 @@ import {
Input, Input,
ModalFrame, ModalFrame,
ModalTitle, ModalTitle,
Select,
inputClass, inputClass,
SharedListboxButton,
SharedListboxOption,
SharedListboxOptions,
} from '@/components/ui'; } from '@/components/ui';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; 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. User API keys are restricted and no shared provider is configured. Ask an admin to add one.
</p> </p>
) : ( ) : (
<Listbox <Select
value={selectedProviderOption!} 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) => { onChange={(provider) => {
const defaults = resolveProviderDefaults({ const defaults = resolveProviderDefaults({
providerRef: provider.id, providerRef: provider.id,
@ -675,46 +677,7 @@ export function SettingsModal({
} }
setCustomModelInput(''); 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> </div>
{restrictUserApiKeys && ( {restrictUserApiKeys && (
@ -768,8 +731,30 @@ export function SettingsModal({
</p> </p>
)} )}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Listbox <Select
value={ttsModels.find(m => m.id === selectedModelId) || ttsModels[0]} 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) => { onChange={(model) => {
if (model.id === 'custom') { if (model.id === 'custom') {
setModelValue(customModelInput); setModelValue(customModelInput);
@ -778,62 +763,7 @@ export function SettingsModal({
setCustomModelInput(''); 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' && ( {supportsCustom && selectedModelId === 'custom' && (
<Input <Input

View file

@ -1,18 +1,14 @@
'use client'; 'use client';
import { Fragment, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Listbox, Transition } from '@headlessui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
import { import {
Badge, Badge,
Section, Section,
ToggleRow, ToggleRow,
inputClass, inputClass,
SharedListboxButton, Select,
SharedListboxOption,
SharedListboxOptions,
Button, Button,
} from '@/components/ui'; } from '@/components/ui';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
@ -204,42 +200,19 @@ export function AdminFeaturesPanel() {
<div className="shrink-0">{renderSource('defaultTtsProvider')}</div> <div className="shrink-0">{renderSource('defaultTtsProvider')}</div>
</div> </div>
{providerOptions.length > 0 ? ( {providerOptions.length > 0 ? (
<Listbox value={selectedProviderOption} onChange={handleProviderChange}> <Select
<SharedListboxButton> value={selectedProviderOption}
<span className="block truncate">{selectedProviderOption?.name ?? 'Select provider'}</span> onChange={handleProviderChange}
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> options={providerOptions}
<ChevronUpDownIcon className="h-4 w-4 text-muted" /> getOptionKey={(option) => option.id}
renderValue={(option) => option.name}
renderOption={(option, { selected }) => (
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{option.name}
</span> </span>
</SharedListboxButton> )}
<Transition chevronClassName="h-4 w-4 text-muted"
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>
) : ( ) : (
<div className="px-0.5 py-2 text-sm text-muted"> <div className="px-0.5 py-2 text-sm text-muted">
No shared providers yet. Add one first. No shared providers yet. Add one first.

View file

@ -1,11 +1,11 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; 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 { Fragment } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast'; 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 { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { import {
@ -14,9 +14,7 @@ import {
Section, Section,
ToggleRow, ToggleRow,
inputClass, inputClass,
SharedListboxButton, Select,
SharedListboxOption,
SharedListboxOptions,
Button, Button,
IconButton, IconButton,
Input, Input,
@ -410,8 +408,17 @@ export function AdminProvidersPanel() {
/> />
</Field> </Field>
<Field label="Provider type"> <Field label="Provider type">
<Listbox <Select
value={selectedProviderType} 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) => { onChange={(opt) => {
const nextModel = providerDefaultModel(opt.value); const nextModel = providerDefaultModel(opt.value);
setForm({ setForm({
@ -423,49 +430,29 @@ export function AdminProvidersPanel() {
}); });
setCustomModelInput(''); 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>
<Field label="Default model" hint="Pre-selected for users picking this provider."> <Field label="Default model" hint="Pre-selected for users picking this provider.">
<div className="space-y-2"> <div className="space-y-2">
<Listbox <Select
value={selectedModelId} value={selectedModelDefinition}
onChange={(modelId: string) => { options={modelDefinitions}
if (modelId === 'custom') { 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(); const nextModel = customModelInput.trim();
setForm({ setForm({
...form, ...form,
@ -476,54 +463,12 @@ export function AdminProvidersPanel() {
} }
setForm({ setForm({
...form, ...form,
defaultModel: modelId, defaultModel: model.id,
defaultInstructions: modelSupportsInstructions(modelId) ? form.defaultInstructions : '', defaultInstructions: modelSupportsInstructions(model.id) ? form.defaultInstructions : '',
}); });
setCustomModelInput(''); 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' && ( {supportsCustomModel && selectedModelId === 'custom' && (
<Input <Input
type="text" type="text"

View file

@ -16,7 +16,15 @@ import {
clampSegmentPreloadSentenceLookahead, clampSegmentPreloadSentenceLookahead,
clampTtsSegmentMaxBlockLength, clampTtsSegmentMaxBlockLength,
} from '@/types/config'; } 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 { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf'; import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse'; import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse';
@ -148,6 +156,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, dete
voice, voice,
documentLanguage: resolvedLanguage, 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 selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
const isPdfMode = !epub && !html && !!pdf; const isPdfMode = !epub && !html && !!pdf;
const [localPreloadDepth, setLocalPreloadDepth] = useState(segmentPreloadDepthPages); 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." subtitle="Controls sentence splitting and synchronized word alignment."
variant="flat" 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"> <span className="block text-[11px] font-semibold uppercase tracking-wide text-muted">
Document language Document language
</span> </span>
<select <Select
value={language} value={selectedLanguage}
onChange={(event) => onLanguageChange(event.target.value)} onChange={(option) => onLanguageChange(option.value)}
className="w-full rounded-md border border-offbase bg-surface-solid px-3 py-2 text-sm text-foreground" options={DOCUMENT_LANGUAGE_OPTIONS}
> />
{DOCUMENT_LANGUAGE_OPTIONS.map((option) => ( </div>
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
{language === 'auto' && detectedLanguage ? ( {language === 'auto' && detectedLanguage ? (
<p className="text-xs text-soft"> <p className="text-xs text-soft">
Detected from document metadata: {getLanguageDisplayName(detectedLanguage)} Detected from document metadata: {getLanguageDisplayName(detectedLanguage)}

View file

@ -1,9 +1,9 @@
'use client'; 'use client';
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'; import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
import type { ComponentProps } from 'react'; import { Fragment, type ComponentProps, type Key, type ReactNode } from 'react';
import { cn } from './cn'; import { cn } from './cn';
import { CheckIcon, ChevronRightIcon } from '@/components/icons/Icons'; import { CheckIcon, ChevronUpDownIcon } from '@/components/icons/Icons';
const listboxButtonClass = 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'; '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, value,
onChange, onChange,
options, options,
getOptionKey,
renderValue,
renderOption,
placeholder = 'Select',
disabled,
buttonClassName,
optionsClassName,
optionInset = 'check',
optionItemClassName,
showCheckmark = true,
chevronClassName = 'h-5 w-5 text-soft',
}: { }: {
value: string; value: T | undefined;
onChange: (value: string) => void; onChange: (value: T) => void;
options: Array<{ value: string; label: string }>; 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 ( return (
<Listbox value={value} onChange={onChange}> <Listbox value={value} onChange={onChange} disabled={disabled}>
<SharedListboxButton> <SharedListboxButton className={buttonClassName}>
<span>{activeOption?.label ?? 'Select'}</span> <span className="block truncate">{value === undefined ? placeholder : valueRenderer(value)}</span>
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-soft"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronRightIcon className="h-4 w-4 rotate-90" aria-hidden="true" /> <ChevronUpDownIcon className={chevronClassName} aria-hidden="true" />
</span> </span>
</SharedListboxButton> </SharedListboxButton>
<SharedListboxOptions anchor="bottom"> <Transition
{options.map((option) => ( as={Fragment}
<SharedListboxOption key={option.value} value={option.value}> leave="transition ease-standard duration-fast"
{({ selected }) => ( leaveFrom="opacity-100"
<> leaveTo="opacity-0"
<span className="absolute left-2 flex items-center text-accent"> >
{selected ? <CheckIcon className="h-4 w-4" aria-hidden="true" /> : null} <SharedListboxOptions anchor="bottom start" className={optionsClassName}>
</span> {options.map((option) => (
<span>{option.label}</span> <SharedListboxOption
</> key={optionKey(option)}
)} value={option}
</SharedListboxOption> inset={optionInset}
))} itemClassName={optionItemClassName}
</SharedListboxOptions> >
{({ 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> </Listbox>
); );
} }

View 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');
});
});