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}
>
)}
-
+
))}
-
+
);
}