feat(ui): introduce shared Listbox components for unified select and dropdown styling

Replace direct usage of Headless UI Listbox primitives with new SharedListboxButton,
SharedListboxOption, and SharedListboxOptions components across AudiobookExportModal,
FinderToolbar, VoicesControlBase, and select UI. Refactor related imports and classNames
to centralize dropdown styling and logic. Simplify UserMenu button markup for improved
consistency.

This change consolidates dropdown/select UI patterns, reduces duplication, and
improves maintainability by providing a single source of truth for Listbox styling
and behavior.
This commit is contained in:
Richard R 2026-06-01 12:31:00 -06:00
parent d4afa43abf
commit 5d0d3d313d
5 changed files with 118 additions and 56 deletions

View file

@ -1,7 +1,7 @@
'use client';
import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { Transition, Listbox, ListboxButton, ListboxOptions, ListboxOption, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react';
import { Transition, Listbox, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react';
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
import { ProgressPopup } from '@/components/ProgressPopup';
import { ProgressCard } from '@/components/ProgressCard';
@ -13,7 +13,7 @@ import { VoicesControlBase } from '@/components/player/VoicesControlBase';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
import { Button, Card, IconButton, MenuItemClass, cn, listboxButtonClass, listboxOptionClass, listboxPanelClass, menuPanelClass, rangeInputClass } from '@/components/ui';
import { Button, Card, IconButton, MenuItemClass, SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn, menuPanelClass, rangeInputClass } from '@/components/ui';
import {
getAudiobookStatus,
deleteAudiobookChapter,
@ -561,40 +561,42 @@ export function AudiobookExportModal({
disabled={chapters.length > 0 || settingsLocked}
>
<div className="relative">
<ListboxButton className={cn(listboxButtonClass, 'bg-surface')}>
<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>
</ListboxButton>
</SharedListboxButton>
<Transition
as={Fragment}
leave="transition ease-standard duration-fast"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className={cn(listboxPanelClass, 'absolute left-0 mt-1 w-full')}>
<ListboxOption
<SharedListboxOptions className="absolute left-0 mt-1 w-full">
<SharedListboxOption
value="m4b"
className={({ active, selected }) => cn(listboxOptionClass(active, selected, 'none'), 'py-2')}
inset="none"
itemClassName="py-2"
>
{({ selected }) => (
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
M4B
</span>
)}
</ListboxOption>
<ListboxOption
</SharedListboxOption>
<SharedListboxOption
value="mp3"
className={({ active, selected }) => cn(listboxOptionClass(active, selected, 'none'), 'py-2')}
inset="none"
itemClassName="py-2"
>
{({ selected }) => (
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
MP3
</span>
)}
</ListboxOption>
</ListboxOptions>
</SharedListboxOption>
</SharedListboxOptions>
</Transition>
</div>
</Listbox>

View file

@ -57,16 +57,12 @@ export function UserMenu({
return (
<div className={`flex gap-2 ${className}`}>
<Link href="/signin">
<span className={buttonClass({ variant: 'secondary', size: 'sm' })}>
Connect
</span>
<Link href="/signin" className={buttonClass({ variant: 'secondary', size: 'sm' })}>
Connect
</Link>
{enableUserSignups && (
<Link href="/signup">
<span className={buttonClass({ variant: 'primary', size: 'sm' })}>
Create account
</span>
<Link href="/signup" className={buttonClass({ variant: 'primary', size: 'sm' })}>
Create account
</Link>
)}
</div>

View file

@ -1,6 +1,6 @@
'use client';
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
import { Listbox } from '@headlessui/react';
import type { IconSize, SortBy, SortDirection, ViewMode } from '@/types/documents';
import {
IconsViewIcon,
@ -10,7 +10,7 @@ import {
HamburgerIcon,
} from './finderIcons';
import { ChevronUpDownIcon } from '@/components/icons/Icons';
import { SearchField, Toolbar, ToolbarButton, ToolbarGroup, ToolbarSegment, listboxCompactOptionClass, listboxCompactOptionsClass, toolbarButtonStyles } from '@/components/ui';
import { SearchField, SharedListboxButton, SharedListboxOption, SharedListboxOptions, Toolbar, ToolbarButton, ToolbarGroup, ToolbarSegment } from '@/components/ui';
import type { ReactNode } from 'react';
interface FinderToolbarProps {
@ -145,23 +145,21 @@ export function FinderToolbar({
{directionLabel}
</ToolbarButton>
<Listbox value={sortBy} onChange={onSortByChange}>
<ListboxButton
className={toolbarButtonStyles({ className: 'gap-1 min-w-[86px] justify-between' })}
>
<SharedListboxButton tone="toolbar" className="gap-1 min-w-[86px] justify-between">
<span>{currentSort.label}</span>
<ChevronUpDownIcon className="h-3 w-3 opacity-60" />
</ListboxButton>
<ListboxOptions anchor="bottom end" className={listboxCompactOptionsClass}>
</SharedListboxButton>
<SharedListboxOptions anchor="bottom end" tone="compact">
{SORT_OPTIONS.map((opt) => (
<ListboxOption
<SharedListboxOption
key={opt.value}
value={opt.value}
className={({ active, selected }) => listboxCompactOptionClass(active, selected)}
tone="compact"
>
{opt.label}
</ListboxOption>
</SharedListboxOption>
))}
</ListboxOptions>
</SharedListboxOptions>
</Listbox>
</div>
)}

View file

@ -2,16 +2,13 @@
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from '@headlessui/react';
import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons';
import { useEffect, useMemo, useState } from 'react';
import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { cn, listboxButtonClass, listboxOptionClass, listboxPanelClass, popoverTriggerClass } from '@/components/ui';
import { SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn, popoverTriggerClass } from '@/components/ui';
export function VoicesControlBase({
availableVoices,
@ -35,8 +32,9 @@ export function VoicesControlBase({
: 'bottom-full right-0 mb-1';
const buttonClass = variant === 'field'
? cn(listboxButtonClass, 'bg-surface pr-10')
? 'bg-surface pr-10'
: cn(popoverTriggerClass, 'space-x-0.5 px-1.5 py-0.5 text-xs sm:space-x-1 sm:px-2 sm:py-1 sm:text-sm');
const buttonTone = variant === 'field' ? 'default' : 'unstyled';
const iconClass = variant === 'field'
? 'h-3.5 w-3.5 shrink-0'
@ -122,7 +120,7 @@ export function VoicesControlBase({
}
}}
>
<ListboxButton className={buttonClass}>
<SharedListboxButton tone={buttonTone} className={buttonClass}>
{variant === 'field' ? (
<>
<span className="flex items-center gap-2 truncate text-sm font-medium">
@ -140,13 +138,14 @@ export function VoicesControlBase({
<ChevronUpDownIcon className={chevronClass} />
</>
)}
</ListboxButton>
<ListboxOptions className={cn(listboxPanelClass, 'absolute !h-auto !min-h-0 !max-h-[50vh]', dropdownPosition, dropdownWidth)}>
</SharedListboxButton>
<SharedListboxOptions tone="default" className={cn('absolute !h-auto !min-h-0 !max-h-[50vh]', dropdownPosition, dropdownWidth)}>
{availableVoices.map((voiceId) => (
<ListboxOption
<SharedListboxOption
key={voiceId}
value={voiceId}
className={({ active, selected }) => cn(listboxOptionClass(active, selected, 'none'), 'flex items-center gap-2 py-1 sm:py-2')}
inset="none"
itemClassName="flex items-center gap-2 py-1 sm:py-2"
>
{({ selected }) => (
<>
@ -158,13 +157,13 @@ export function VoicesControlBase({
<span className="text-xs sm:text-sm">{voiceId}</span>
</>
)}
</ListboxOption>
</SharedListboxOption>
))}
</ListboxOptions>
</SharedListboxOptions>
</Listbox>
) : (
<Listbox value={currentVoice} onChange={onChangeVoice}>
<ListboxButton className={buttonClass}>
<SharedListboxButton tone={buttonTone} className={buttonClass}>
{variant === 'field' ? (
<>
<span className="flex items-center gap-2 truncate text-sm font-medium">
@ -182,18 +181,19 @@ export function VoicesControlBase({
<ChevronUpDownIcon className={chevronClass} />
</>
)}
</ListboxButton>
<ListboxOptions className={cn(listboxPanelClass, 'absolute !h-auto !min-h-0 !max-h-[50vh]', dropdownPosition, dropdownWidth)}>
</SharedListboxButton>
<SharedListboxOptions tone="default" className={cn('absolute !h-auto !min-h-0 !max-h-[50vh]', dropdownPosition, dropdownWidth)}>
{availableVoices.map((voiceId) => (
<ListboxOption
<SharedListboxOption
key={voiceId}
value={voiceId}
className={({ active, selected }) => cn(listboxOptionClass(active, selected, 'none'), 'py-1 sm:py-2')}
inset="none"
itemClassName="py-1 sm:py-2"
>
<span className="text-xs sm:text-sm">{voiceId}</span>
</ListboxOption>
</SharedListboxOption>
))}
</ListboxOptions>
</SharedListboxOptions>
</Listbox>
)}
</div>

View file

@ -1,12 +1,16 @@
'use client';
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
import type { ComponentProps } from 'react';
import { cn } from './cn';
export { segmentedButtonClass, segmentedGroupClass } from './segmented-control';
export 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';
export const listboxToolbarButtonClass =
'inline-flex items-center rounded-md border border-line bg-surface px-2 py-1 text-xs text-foreground hover:border-accent-line hover:bg-accent-wash hover:text-accent transition-colors duration-fast ease-standard';
export const listboxPanelClass =
'z-50 max-h-60 overflow-y-auto overscroll-contain rounded-md bg-surface p-1 shadow-elev-2 ring-1 ring-line focus:outline-none';
@ -33,6 +37,68 @@ export const listboxCompactOptionClass = (active: boolean, selected = false) =>
: 'text-foreground',
);
export function SharedListboxButton({
tone = 'default',
className,
children,
...props
}: ComponentProps<typeof ListboxButton> & {
tone?: 'default' | 'toolbar' | 'unstyled';
}) {
const baseClass = tone === 'toolbar'
? listboxToolbarButtonClass
: tone === 'unstyled'
? ''
: listboxButtonClass;
return (
<ListboxButton className={cn(baseClass, className)} {...props}>
{children}
</ListboxButton>
);
}
export function SharedListboxOptions({
tone = 'default',
className,
children,
...props
}: ComponentProps<typeof ListboxOptions> & {
tone?: 'default' | 'compact';
}) {
const baseClass = tone === 'compact' ? listboxCompactOptionsClass : listboxOptionsClass;
return (
<ListboxOptions className={cn(baseClass, className)} {...props}>
{children}
</ListboxOptions>
);
}
export function SharedListboxOption({
tone = 'default',
inset = 'check',
itemClassName,
children,
...props
}: Omit<ComponentProps<typeof ListboxOption>, 'className'> & {
tone?: 'default' | 'compact';
inset?: 'check' | 'none';
itemClassName?: string;
}) {
return (
<ListboxOption
className={({ active, selected }: { active: boolean; selected: boolean }) => cn(
tone === 'compact'
? listboxCompactOptionClass(active, selected)
: listboxOptionClass(active, selected, inset),
itemClassName,
)}
{...props}
>
{children}
</ListboxOption>
);
}
export function Select({
value,
onChange,
@ -46,22 +112,22 @@ export function Select({
return (
<Listbox value={value} onChange={onChange}>
<ListboxButton className={listboxButtonClass}>
<SharedListboxButton>
<span>{activeOption?.label ?? 'Select'}</span>
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-soft">v</span>
</ListboxButton>
<ListboxOptions anchor="bottom" className={listboxOptionsClass}>
</SharedListboxButton>
<SharedListboxOptions anchor="bottom">
{options.map((option) => (
<ListboxOption key={option.value} value={option.value} className={({ active, selected }) => listboxOptionClass(active, selected)}>
<SharedListboxOption key={option.value} value={option.value}>
{({ selected }) => (
<>
<span className="absolute left-2 text-accent">{selected ? '*' : ''}</span>
<span>{option.label}</span>
</>
)}
</ListboxOption>
</SharedListboxOption>
))}
</ListboxOptions>
</SharedListboxOptions>
</Listbox>
);
}