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:
parent
d4afa43abf
commit
5d0d3d313d
5 changed files with 118 additions and 56 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue