From 5d0d3d313d9fbe4c6fc152429c4606f16343d601 Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 1 Jun 2026 12:31:00 -0600 Subject: [PATCH] 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. --- src/components/AudiobookExportModal.tsx | 26 ++++--- src/components/auth/UserMenu.tsx | 12 +-- .../doclist/window/FinderToolbar.tsx | 20 +++-- src/components/player/VoicesControlBase.tsx | 38 ++++----- src/components/ui/select.tsx | 78 +++++++++++++++++-- 5 files changed, 118 insertions(+), 56 deletions(-) diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index 71ceeca..ca378e7 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -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} >
- + {format.toUpperCase()} - + - - + cn(listboxOptionClass(active, selected, 'none'), 'py-2')} + inset="none" + itemClassName="py-2" > {({ selected }) => ( M4B )} - - + cn(listboxOptionClass(active, selected, 'none'), 'py-2')} + inset="none" + itemClassName="py-2" > {({ selected }) => ( MP3 )} - - + +
diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx index ea437a7..c459eb1 100644 --- a/src/components/auth/UserMenu.tsx +++ b/src/components/auth/UserMenu.tsx @@ -57,16 +57,12 @@ export function UserMenu({ return (
- - - Connect - + + Connect {enableUserSignups && ( - - - Create account - + + Create account )}
diff --git a/src/components/doclist/window/FinderToolbar.tsx b/src/components/doclist/window/FinderToolbar.tsx index 811090a..e403754 100644 --- a/src/components/doclist/window/FinderToolbar.tsx +++ b/src/components/doclist/window/FinderToolbar.tsx @@ -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} - + {currentSort.label} - - + + {SORT_OPTIONS.map((opt) => ( - listboxCompactOptionClass(active, selected)} + tone="compact" > {opt.label} - + ))} - + )} diff --git a/src/components/player/VoicesControlBase.tsx b/src/components/player/VoicesControlBase.tsx index 75aaf57..b415744 100644 --- a/src/components/player/VoicesControlBase.tsx +++ b/src/components/player/VoicesControlBase.tsx @@ -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({ } }} > - + {variant === 'field' ? ( <> @@ -140,13 +138,14 @@ export function VoicesControlBase({ )} - - + + {availableVoices.map((voiceId) => ( - 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({ {voiceId} )} - + ))} - + ) : ( - + {variant === 'field' ? ( <> @@ -182,18 +181,19 @@ export function VoicesControlBase({ )} - - + + {availableVoices.map((voiceId) => ( - cn(listboxOptionClass(active, selected, 'none'), 'py-1 sm:py-2')} + inset="none" + itemClassName="py-1 sm:py-2" > {voiceId} - + ))} - + )} diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 1fca5df..fb9e8ec 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -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 & { + tone?: 'default' | 'toolbar' | 'unstyled'; +}) { + const baseClass = tone === 'toolbar' + ? listboxToolbarButtonClass + : tone === 'unstyled' + ? '' + : listboxButtonClass; + return ( + + {children} + + ); +} + +export function SharedListboxOptions({ + tone = 'default', + className, + children, + ...props +}: ComponentProps & { + tone?: 'default' | 'compact'; +}) { + const baseClass = tone === 'compact' ? listboxCompactOptionsClass : listboxOptionsClass; + return ( + + {children} + + ); +} + +export function SharedListboxOption({ + tone = 'default', + inset = 'check', + itemClassName, + children, + ...props +}: Omit, 'className'> & { + tone?: 'default' | 'compact'; + inset?: 'check' | 'none'; + itemClassName?: string; +}) { + return ( + cn( + tone === 'compact' + ? listboxCompactOptionClass(active, selected) + : listboxOptionClass(active, selected, inset), + itemClassName, + )} + {...props} + > + {children} + + ); +} + export function Select({ value, onChange, @@ -46,22 +112,22 @@ export function Select({ return ( - + {activeOption?.label ?? 'Select'} v - - + + {options.map((option) => ( - listboxOptionClass(active, selected)}> + {({ selected }) => ( <> {selected ? '*' : ''} {option.label} )} - + ))} - + ); }