From 922bc3246241046ab4b4bef55bccfb2fc7a8e850 Mon Sep 17 00:00:00 2001
From: Richard R
Date: Sat, 6 Jun 2026 11:15:48 -0600
Subject: [PATCH] refactor(ui): enforce shared control primitives
---
src/app/(app)/signin/page.tsx | 13 ++----
src/app/(app)/signup/page.tsx | 9 ++--
src/components/AudiobookExportModal.tsx | 25 ++++-------
src/components/ColorPicker.tsx | 11 +++--
src/components/PrivacyModal.tsx | 6 +--
src/components/SettingsModal.tsx | 9 ++--
src/components/admin/AdminFeaturesPanel.tsx | 45 +++++++------------
src/components/admin/AdminProvidersPanel.tsx | 36 +++++----------
.../doclist/window/FinderSidebar.tsx | 25 ++++-------
.../documents/DocumentHeaderMenu.tsx | 24 +++-------
.../documents/DocumentSelectionModal.tsx | 10 ++---
src/components/player/Navigator.tsx | 7 ++-
src/components/player/SpeedControl.tsx | 7 ++-
src/components/reader/SegmentsSidebar.tsx | 13 +++---
src/components/ui/button.tsx | 32 ++++++++-----
src/components/ui/checkbox.tsx | 16 +++++++
src/components/ui/index.ts | 1 +
src/components/ui/input.tsx | 4 +-
src/components/ui/menu.tsx | 32 +++++++++----
src/components/ui/popover.tsx | 18 +++++++-
...ared-form-control-consumers.vitest.spec.ts | 43 ++++++++++++++++++
.../shared-overlay-consumers.vitest.spec.ts | 31 +++++++++++++
22 files changed, 234 insertions(+), 183 deletions(-)
create mode 100644 src/components/ui/checkbox.tsx
create mode 100644 tests/unit/shared-form-control-consumers.vitest.spec.ts
create mode 100644 tests/unit/shared-overlay-consumers.vitest.spec.ts
diff --git a/src/app/(app)/signin/page.tsx b/src/app/(app)/signin/page.tsx
index b5fb29b..853f246 100644
--- a/src/app/(app)/signin/page.tsx
+++ b/src/app/(app)/signin/page.tsx
@@ -9,7 +9,7 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { showPrivacyModal } from '@/components/PrivacyModal';
import { GithubIcon } from '@/components/icons/Icons';
import { LoadingSpinner } from '@/components/Spinner';
-import { Button, Field, Input, Surface } from '@/components/ui';
+import { Button, Checkbox, Field, InlineButton, Input, Surface } from '@/components/ui';
function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) {
const searchParams = useSearchParams();
@@ -169,11 +169,9 @@ function SignInContent() {
{/* Remember Me */}
@@ -238,12 +236,9 @@ function SignInContent() {
)}
By signing in, you agree to our{' '}
-
+
diff --git a/src/app/(app)/signup/page.tsx b/src/app/(app)/signup/page.tsx
index f2b1ad8..f5833cc 100644
--- a/src/app/(app)/signup/page.tsx
+++ b/src/app/(app)/signup/page.tsx
@@ -8,7 +8,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { showPrivacyModal } from '@/components/PrivacyModal';
import { LoadingSpinner } from '@/components/Spinner';
-import { Button, Field, IconButton, Input, Surface } from '@/components/ui';
+import { Button, Field, IconButton, InlineButton, Input, Surface } from '@/components/ui';
import toast from 'react-hot-toast';
export default function SignUpPage() {
@@ -241,12 +241,9 @@ export default function SignUpPage() {
By creating an account, you agree to our{' '}
-
+
diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx
index 254ac1f..a50a928 100644
--- a/src/components/AudiobookExportModal.tsx
+++ b/src/components/AudiobookExportModal.tsx
@@ -1,7 +1,6 @@
'use client';
-import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react';
-import { Transition, Menu, MenuButton } from '@headlessui/react';
+import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
import { ProgressPopup } from '@/components/ProgressPopup';
import { ProgressCard } from '@/components/ProgressCard';
@@ -14,7 +13,7 @@ import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { getTtsLanguageCompatibilityWarnings, resolveTtsLanguage } from '@/lib/shared/language';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
-import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, RangeInput, Select } from '@/components/ui';
+import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, MenuRoot, MenuTransition, MenuTrigger, RangeInput, Select } from '@/components/ui';
import {
getAudiobookStatus,
deleteAudiobookChapter,
@@ -762,23 +761,15 @@ export function AudiobookExportModal({
{((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && (
-
+
+
)}
diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx
index 374ec34..8a700d1 100644
--- a/src/components/ColorPicker.tsx
+++ b/src/components/ColorPicker.tsx
@@ -1,10 +1,9 @@
'use client';
import { useRef, useState, useEffect, useCallback } from 'react';
-import { Popover, PopoverButton } from '@headlessui/react';
import { isLightColor, type CustomThemeColors } from '@/contexts/ThemeContext';
import { PaletteIcon } from '@/components/icons/Icons';
-import { IconButton, Input, PopoverSurface } from '@/components/ui';
+import { IconButton, Input, PopoverIconTrigger, PopoverRoot, PopoverSurface } from '@/components/ui';
/**
* Curated swatch palettes per color role, sourced from existing themes
@@ -78,8 +77,8 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
const swatches = ROLE_SWATCHES[field];
return (
-
-
+
+
-
+
-
+
);
}
diff --git a/src/components/PrivacyModal.tsx b/src/components/PrivacyModal.tsx
index 600c7c7..a82e172 100644
--- a/src/components/PrivacyModal.tsx
+++ b/src/components/PrivacyModal.tsx
@@ -2,7 +2,7 @@
import { useState, useEffect } from 'react';
import { updateAppConfig } from '@/lib/client/dexie';
-import { Button, ModalFrame, ModalTitle } from '@/components/ui';
+import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui';
interface PrivacyModalProps {
isOpen: boolean;
@@ -78,13 +78,11 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
- setAgreed(e.target.checked)}
- className="h-4 w-4 rounded border-line text-accent focus:ring-accent-line bg-surface"
/>
diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx
index c15bec2..b9465ea 100644
--- a/src/components/SettingsModal.tsx
+++ b/src/components/SettingsModal.tsx
@@ -46,10 +46,10 @@ import {
ChoiceTile,
IconButton,
Input,
+ Textarea,
ModalFrame,
ModalTitle,
Select,
- inputClass,
} from '@/components/ui';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -697,7 +697,6 @@ export function SettingsModal({
value={localBaseUrl}
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
placeholder="Using environment variable"
- className={inputClass}
/>
)}
@@ -713,7 +712,6 @@ export function SettingsModal({
value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)}
placeholder="Using environment variable"
- className={inputClass}
/>
)}
@@ -774,7 +772,6 @@ export function SettingsModal({
setModelValue(e.target.value);
}}
placeholder="Enter custom model name"
- className={inputClass}
/>
)}
@@ -783,11 +780,11 @@ export function SettingsModal({
{providerModelPolicy.supportsInstructions && (
-
)}
diff --git a/src/components/admin/AdminFeaturesPanel.tsx b/src/components/admin/AdminFeaturesPanel.tsx
index 8c299a5..118c594 100644
--- a/src/components/admin/AdminFeaturesPanel.tsx
+++ b/src/components/admin/AdminFeaturesPanel.tsx
@@ -7,9 +7,9 @@ import {
Badge,
Section,
ToggleRow,
- inputClass,
Select,
Button,
+ Input,
} from '@/components/ui';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
@@ -274,11 +274,10 @@ export function AdminFeaturesPanel() {
{renderSource('ttsDailyLimitAnonymous')}
- updatePositiveIntDraft('ttsDailyLimitAnonymous', event.target.value)}
/>
@@ -288,11 +287,10 @@ export function AdminFeaturesPanel() {
{renderSource('ttsDailyLimitAuthenticated')}
- updatePositiveIntDraft('ttsDailyLimitAuthenticated', event.target.value)}
/>
@@ -302,11 +300,10 @@ export function AdminFeaturesPanel() {
{renderSource('ttsIpDailyLimitAnonymous')}
- updatePositiveIntDraft('ttsIpDailyLimitAnonymous', event.target.value)}
/>
@@ -316,11 +313,10 @@ export function AdminFeaturesPanel() {
{renderSource('ttsIpDailyLimitAuthenticated')}
- updatePositiveIntDraft('ttsIpDailyLimitAuthenticated', event.target.value)}
/>
@@ -343,11 +339,10 @@ export function AdminFeaturesPanel() {
{renderSource('computeParseBurstMax')}
- updatePositiveIntDraft('computeParseBurstMax', event.target.value)}
/>
@@ -357,11 +352,10 @@ export function AdminFeaturesPanel() {
{renderSource('computeParseBurstWindowSec')}
- updatePositiveIntDraft('computeParseBurstWindowSec', event.target.value)}
/>
@@ -371,11 +365,10 @@ export function AdminFeaturesPanel() {
{renderSource('computeParseSustainedMax')}
- updatePositiveIntDraft('computeParseSustainedMax', event.target.value)}
/>
@@ -385,11 +378,10 @@ export function AdminFeaturesPanel() {
{renderSource('computeParseSustainedWindowSec')}
- updatePositiveIntDraft('computeParseSustainedWindowSec', event.target.value)}
/>
@@ -405,13 +397,13 @@ export function AdminFeaturesPanel() {
{renderSource('maxUploadMb')}
- updatePositiveIntDraft('maxUploadMb', event.target.value)}
/>
@@ -436,9 +428,8 @@ export function AdminFeaturesPanel() {
{renderSource('changelogFeedUrl')}
- updateDraft('changelogFeedUrl', event.target.value)}
placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json"
@@ -481,11 +472,10 @@ export function AdminFeaturesPanel() {
{renderSource('ttsUpstreamMaxRetries')}
- updatePositiveIntDraft('ttsUpstreamMaxRetries', event.target.value)}
/>
@@ -495,11 +485,10 @@ export function AdminFeaturesPanel() {
{renderSource('ttsUpstreamTimeoutMs')}
- updatePositiveIntDraft('ttsUpstreamTimeoutMs', event.target.value)}
/>
@@ -509,11 +498,10 @@ export function AdminFeaturesPanel() {
{renderSource('ttsCacheMaxSizeBytes')}
- updatePositiveIntDraft('ttsCacheMaxSizeBytes', event.target.value)}
/>
@@ -523,11 +511,10 @@ export function AdminFeaturesPanel() {
{renderSource('ttsCacheTtlMs')}
- updatePositiveIntDraft('ttsCacheTtlMs', event.target.value)}
/>
diff --git a/src/components/admin/AdminProvidersPanel.tsx b/src/components/admin/AdminProvidersPanel.tsx
index f549790..ef92a1d 100644
--- a/src/components/admin/AdminProvidersPanel.tsx
+++ b/src/components/admin/AdminProvidersPanel.tsx
@@ -1,8 +1,6 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
-import { Menu, MenuButton, Transition } from '@headlessui/react';
-import { Fragment } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
@@ -13,13 +11,16 @@ import {
Field,
Section,
ToggleRow,
- inputClass,
Select,
Button,
IconButton,
Input,
+ Textarea,
MenuItemsSurface,
MenuActionItem,
+ MenuRoot,
+ MenuTrigger,
+ MenuTransition,
} from '@/components/ui';
type ProviderType = TtsProviderId;
@@ -394,7 +395,6 @@ export function AdminProvidersPanel() {
value={form.slug}
onChange={(e) => setForm({ ...form, slug: e.target.value })}
placeholder="kokoro-prod"
- className={inputClass}
disabled={isEditingExisting}
/>
@@ -404,7 +404,6 @@ export function AdminProvidersPanel() {
value={form.displayName}
onChange={(e) => setForm({ ...form, displayName: e.target.value })}
placeholder="Kokoro (production)"
- className={inputClass}
/>
@@ -483,7 +482,6 @@ export function AdminProvidersPanel() {
});
}}
placeholder="Enter custom model id"
- className={inputClass}
/>
)}
@@ -494,11 +492,11 @@ export function AdminProvidersPanel() {
className="sm:col-span-2"
hint="Optional. Applied by default when this shared provider is selected."
>
-
)}
@@ -509,7 +507,6 @@ export function AdminProvidersPanel() {
value={form.baseUrl}
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
placeholder={baseUrlPlaceholder}
- className={inputClass}
/>
)}
@@ -523,7 +520,6 @@ export function AdminProvidersPanel() {
value={form.apiKey}
onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
placeholder={isEditingExisting ? `keep existing (${editingProvider?.apiKeyMask ?? ''})` : 'Optional'}
- className={inputClass}
/>
@@ -584,8 +580,8 @@ export function AdminProvidersPanel() {
{p.baseUrl ? p.baseUrl : 'provider base URL default'} ยท key {p.apiKeyMask}
-
+
+
))
diff --git a/src/components/doclist/window/FinderSidebar.tsx b/src/components/doclist/window/FinderSidebar.tsx
index 20ee1c3..0b0d0ed 100644
--- a/src/components/doclist/window/FinderSidebar.tsx
+++ b/src/components/doclist/window/FinderSidebar.tsx
@@ -1,13 +1,12 @@
'use client';
-import { Menu, MenuButton, Transition } from '@headlessui/react';
-import { Fragment, useRef, type CSSProperties, type ReactNode } from 'react';
+import { useRef, type CSSProperties, type ReactNode } from 'react';
import { useDrop } from 'react-dnd';
import type { Folder, SidebarFilter } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons';
import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
-import { IconButton, MenuActionItem, MenuItemsSurface, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem } from '@/components/ui';
+import { IconButton, MenuActionItem, MenuItemsSurface, MenuRoot, MenuTransition, MenuTrigger, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem } from '@/components/ui';
interface FinderSidebarProps {
filter: SidebarFilter;
@@ -201,8 +200,8 @@ export function FinderSidebar({
-
+
-
-
+
+
-
-
+
+
)}
>
Folders
diff --git a/src/components/documents/DocumentHeaderMenu.tsx b/src/components/documents/DocumentHeaderMenu.tsx
index b44a479..e8a1f61 100644
--- a/src/components/documents/DocumentHeaderMenu.tsx
+++ b/src/components/documents/DocumentHeaderMenu.tsx
@@ -1,11 +1,9 @@
'use client';
-import { Menu, MenuButton, Transition } from '@headlessui/react';
-import { Fragment } from 'react';
import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon, ListIcon } from '@/components/icons/Icons';
import { ZoomControl } from '@/components/documents/ZoomControl';
import { UserMenu } from '@/components/auth/UserMenu';
-import { IconButton, MenuActionItem, MenuItemsSurface, ToolbarButton } from '@/components/ui';
+import { IconButton, MenuActionItem, MenuItemsSurface, MenuRoot, MenuTransition, MenuTrigger, ToolbarButton } from '@/components/ui';
interface DocumentHeaderMenuProps {
zoomLevel: number;
@@ -82,24 +80,16 @@ export function DocumentHeaderMenu({
// --- Mobile View ---
const MobileView = (
-
+
+
);
diff --git a/src/components/documents/DocumentSelectionModal.tsx b/src/components/documents/DocumentSelectionModal.tsx
index a89b152..a930cd9 100644
--- a/src/components/documents/DocumentSelectionModal.tsx
+++ b/src/components/documents/DocumentSelectionModal.tsx
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import { BaseDocument } from '@/types/documents';
-import { Button, ModalFrame, ModalTitle } from '@/components/ui';
+import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui';
interface DocumentSelectionModalProps {
isOpen: boolean;
@@ -119,9 +119,7 @@ export function DocumentSelectionModal({
{files.length > 0 && (
-
+
);
};
diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx
index 34b6f3b..835fe98 100644
--- a/src/components/reader/SegmentsSidebar.tsx
+++ b/src/components/reader/SegmentsSidebar.tsx
@@ -1,14 +1,14 @@
'use client';
import { Fragment, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Popover, PopoverButton, Transition } from '@headlessui/react';
+import { Transition } from '@headlessui/react';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { Book } from 'epubjs';
import toast from 'react-hot-toast';
import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext';
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
-import { Button, IconButton, PopoverSurface } from '@/components/ui';
+import { Button, IconButton, PopoverIconTrigger, PopoverRoot, PopoverSurface } from '@/components/ui';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
@@ -888,15 +888,14 @@ function SegmentsListSkeletonRows() {
function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
return (
-
-
+
-
+
-
+
);
}
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index d31b6b0..e88a3e5 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -7,7 +7,7 @@ import { focusRing, motionColors } from './tokens';
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon';
-export const buttonStyles = variants({
+const buttonStyles = variants({
base: cn(
'inline-flex items-center justify-center font-medium disabled:cursor-not-allowed disabled:opacity-50',
focusRing,
@@ -35,17 +35,6 @@ export const buttonStyles = variants({
},
});
-export const btnBase = cn(
- 'inline-flex items-center justify-center rounded-md text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50',
- focusRing,
- motionColors,
-);
-export const btnPrimary = buttonStyles({ variant: 'primary', size: 'md' });
-export const btnSecondary = buttonStyles({ variant: 'secondary', size: 'md' });
-export const btnOutline = buttonStyles({ variant: 'outline', size: 'md' });
-export const btnDanger = buttonStyles({ variant: 'danger', size: 'md' });
-export const btnGhost = buttonStyles({ variant: 'ghost', size: 'md' });
-
function buttonClass({
variant = 'secondary',
size = 'md',
@@ -112,3 +101,22 @@ export function ButtonAnchor({
);
}
+
+export function InlineButton({
+ className,
+ children,
+ type = 'button',
+ ...props
+}: ButtonHTMLAttributes & {
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..1433ee5
--- /dev/null
+++ b/src/components/ui/checkbox.tsx
@@ -0,0 +1,16 @@
+import { forwardRef, type InputHTMLAttributes } from 'react';
+import { cn } from './cn';
+import { focusRing, motionColors } from './tokens';
+
+export const checkboxClass = cn(
+ 'h-4 w-4 rounded border-line bg-surface text-accent disabled:cursor-not-allowed disabled:opacity-50',
+ focusRing,
+ motionColors,
+);
+
+export const Checkbox = forwardRef<
+ HTMLInputElement,
+ Omit, 'type'>
+>(function Checkbox({ className, ...props }, ref) {
+ return ;
+});
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index 08926c5..0e9dda5 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -1,5 +1,6 @@
export * from './badge';
export * from './button';
+export * from './checkbox';
export * from './choice-tile';
export * from './cn';
export * from './dialog';
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
index 9d59894..20fc557 100644
--- a/src/components/ui/input.tsx
+++ b/src/components/ui/input.tsx
@@ -5,7 +5,7 @@ import { motionColors } from './tokens';
export type InputControlSize = 'sm' | 'md' | 'lg';
-export const inputStyles = variants({
+const inputStyles = variants({
base: cn('w-full border border-line bg-surface-sunken text-foreground placeholder:text-soft focus:border-accent-line focus:outline-none focus:ring-2 focus:ring-accent-line', motionColors),
variants: {
size: {
@@ -19,8 +19,6 @@ export const inputStyles = variants({
},
});
-export const inputClass = inputStyles();
-
export const Input = forwardRef & { controlSize?: InputControlSize }>(function Input({
className,
controlSize = 'md',
diff --git a/src/components/ui/menu.tsx b/src/components/ui/menu.tsx
index 583fe4b..8917a36 100644
--- a/src/components/ui/menu.tsx
+++ b/src/components/ui/menu.tsx
@@ -1,19 +1,33 @@
-import { MenuItem, MenuItems } from '@headlessui/react';
+import {
+ Menu as HeadlessMenu,
+ MenuButton as HeadlessMenuButton,
+ MenuItem,
+ MenuItems,
+ Transition,
+} from '@headlessui/react';
+import { Fragment } from 'react';
import type { ComponentProps } from 'react';
-import type { HTMLAttributes, ReactNode } from 'react';
+import type { ReactNode } from 'react';
import { cn } from './cn';
const menuPanelClass = 'rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft';
-export function Menu({
- children,
- className,
- ...props
-}: HTMLAttributes & { children: ReactNode }) {
+export const MenuRoot = HeadlessMenu;
+export const MenuTrigger = HeadlessMenuButton;
+
+export function MenuTransition({ children }: { children: ReactNode }) {
return (
-
+
{children}
-
+
);
}
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
index f18cfba..eae5ce2 100644
--- a/src/components/ui/popover.tsx
+++ b/src/components/ui/popover.tsx
@@ -1,12 +1,15 @@
-import { PopoverButton, PopoverPanel } from '@headlessui/react';
+import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
import type { ComponentProps } from 'react';
import { cn } from './cn';
+import { IconButton } from './icon-button';
const popoverPanelClass = cn(
'z-50 rounded-md border border-line bg-surface p-3 shadow-elev-2 focus:outline-none',
);
-export const popoverTriggerClass = cn(
+export const PopoverRoot = Popover;
+
+const popoverTriggerClass = cn(
'inline-flex items-center rounded-md text-foreground hover:bg-accent-wash hover:text-accent focus:outline-none transition-colors duration-fast ease-standard',
);
@@ -22,6 +25,17 @@ export function PopoverTrigger({
);
}
+export function PopoverIconTrigger({
+ children,
+ ...props
+}: ComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
+
export function PopoverSurface({
className,
children,
diff --git a/tests/unit/shared-form-control-consumers.vitest.spec.ts b/tests/unit/shared-form-control-consumers.vitest.spec.ts
new file mode 100644
index 0000000..10d0004
--- /dev/null
+++ b/tests/unit/shared-form-control-consumers.vitest.spec.ts
@@ -0,0 +1,43 @@
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+import { describe, expect, test } from 'vitest';
+
+const STANDARD_FORM_CONSUMERS = [
+ 'src/app/(app)/signin/page.tsx',
+ 'src/app/(app)/signup/page.tsx',
+ 'src/components/PrivacyModal.tsx',
+ 'src/components/SettingsModal.tsx',
+ 'src/components/admin/AdminFeaturesPanel.tsx',
+ 'src/components/admin/AdminProvidersPanel.tsx',
+ 'src/components/documents/DocumentSelectionModal.tsx',
+];
+
+describe('shared form-control consumers', () => {
+ test('standard forms use shared inputs, textareas, and checkboxes', () => {
+ for (const relativePath of STANDARD_FORM_CONSUMERS) {
+ const source = readFileSync(resolve(process.cwd(), relativePath), 'utf8');
+ expect(source, relativePath).not.toMatch(/<(input|textarea)\b/);
+ expect(source, relativePath).not.toContain('inputClass');
+ }
+ });
+
+ test('auth pages use the shared inline button', () => {
+ for (const relativePath of [
+ 'src/app/(app)/signin/page.tsx',
+ 'src/app/(app)/signup/page.tsx',
+ ]) {
+ const source = readFileSync(resolve(process.cwd(), relativePath), 'utf8');
+ expect(source, relativePath).toContain(' {
+ const inputSource = readFileSync(resolve(process.cwd(), 'src/components/ui/input.tsx'), 'utf8');
+ const checkboxSource = readFileSync(resolve(process.cwd(), 'src/components/ui/checkbox.tsx'), 'utf8');
+ expect(inputSource).toContain('inputStyles');
+ expect(inputSource).toContain('export function Textarea');
+ expect(checkboxSource).toContain('checkboxClass');
+ expect(checkboxSource).toContain('export const Checkbox');
+ });
+});
diff --git a/tests/unit/shared-overlay-consumers.vitest.spec.ts b/tests/unit/shared-overlay-consumers.vitest.spec.ts
new file mode 100644
index 0000000..035dc72
--- /dev/null
+++ b/tests/unit/shared-overlay-consumers.vitest.spec.ts
@@ -0,0 +1,31 @@
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+import { describe, expect, test } from 'vitest';
+
+const STANDARD_OVERLAY_CONSUMERS = [
+ 'src/components/admin/AdminProvidersPanel.tsx',
+ 'src/components/AudiobookExportModal.tsx',
+ 'src/components/ColorPicker.tsx',
+ 'src/components/doclist/window/FinderSidebar.tsx',
+ 'src/components/documents/DocumentHeaderMenu.tsx',
+ 'src/components/player/Navigator.tsx',
+ 'src/components/player/SpeedControl.tsx',
+];
+
+describe('shared overlay consumers', () => {
+ test('standard menus and popovers do not compose Headless UI directly', () => {
+ for (const relativePath of STANDARD_OVERLAY_CONSUMERS) {
+ const source = readFileSync(resolve(process.cwd(), relativePath), 'utf8');
+ expect(source, relativePath).not.toContain("from '@headlessui/react'");
+ }
+ });
+
+ test('shared overlay primitives own standard composition', () => {
+ const menuSource = readFileSync(resolve(process.cwd(), 'src/components/ui/menu.tsx'), 'utf8');
+ const popoverSource = readFileSync(resolve(process.cwd(), 'src/components/ui/popover.tsx'), 'utf8');
+ expect(menuSource).toContain('export const MenuRoot');
+ expect(menuSource).toContain('export function MenuTransition');
+ expect(popoverSource).toContain('export const PopoverRoot');
+ expect(popoverSource).toContain('export function PopoverIconTrigger');
+ });
+});