refactor(ui): enforce shared control primitives
This commit is contained in:
parent
8071ae1a61
commit
922bc32462
22 changed files with 234 additions and 183 deletions
|
|
@ -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 */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="rounded border-muted text-accent focus:ring-accent-line"
|
||||
/>
|
||||
<span className="text-sm text-foreground">Remember me</span>
|
||||
</label>
|
||||
|
|
@ -238,12 +236,9 @@ function SignInContent() {
|
|||
)}
|
||||
<p className="text-xs text-soft">
|
||||
By signing in, you agree to our{' '}
|
||||
<button
|
||||
onClick={() => showPrivacyModal()}
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
<InlineButton onClick={() => showPrivacyModal()}>
|
||||
Privacy Policy
|
||||
</button>
|
||||
</InlineButton>
|
||||
</p>
|
||||
</div>
|
||||
</Surface>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</p>
|
||||
<p className="text-xs text-soft">
|
||||
By creating an account, you agree to our{' '}
|
||||
<button
|
||||
onClick={() => showPrivacyModal()}
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
<InlineButton onClick={() => showPrivacyModal()}>
|
||||
Privacy Policy
|
||||
</button>
|
||||
</InlineButton>
|
||||
</p>
|
||||
</div>
|
||||
</Surface>
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</div>
|
||||
<div className="flex items-center">
|
||||
{((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && (
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<MenuButton
|
||||
<MenuRoot as="div" className="relative inline-block text-left">
|
||||
<MenuTrigger
|
||||
as={IconButton}
|
||||
size="sm"
|
||||
title="Chapter actions"
|
||||
>
|
||||
<DotsVerticalIcon className="h-5 w-5" />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-standard duration-fast"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-standard duration-fast"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
</MenuTrigger>
|
||||
<MenuTransition>
|
||||
<MenuItemsSurface
|
||||
anchor={{ to: 'bottom end', gap: '8px', padding: '12px' }}
|
||||
portal
|
||||
|
|
@ -822,8 +813,8 @@ export function AudiobookExportModal({
|
|||
)}
|
||||
</MenuItemsSurface>
|
||||
{/* end of menu items */}
|
||||
</Transition>
|
||||
</Menu>
|
||||
</MenuTransition>
|
||||
</MenuRoot>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Popover className="relative flex items-center">
|
||||
<PopoverButton as={IconButton} size="sm" className="group rounded-full p-0" aria-label={`Pick ${label} color`}>
|
||||
<PopoverRoot className="relative flex items-center">
|
||||
<PopoverIconTrigger size="sm" className="group rounded-full p-0" aria-label={`Pick ${label} color`}>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border-2 transition duration-fast group-focus-visible:ring-2 group-focus-visible:ring-offset-1"
|
||||
style={{
|
||||
|
|
@ -87,7 +86,7 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
|
|||
borderColor: isLightColor(value) ? '#00000022' : '#ffffff22',
|
||||
}}
|
||||
/>
|
||||
</PopoverButton>
|
||||
</PopoverIconTrigger>
|
||||
|
||||
<PopoverSurface
|
||||
anchor="bottom start"
|
||||
|
|
@ -171,6 +170,6 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
|
|||
/>
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</Popover>
|
||||
</PopoverRoot>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
|||
<div className="mt-6 space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-line p-3 bg-surface-sunken">
|
||||
<div className="flex h-6 items-center">
|
||||
<input
|
||||
<Checkbox
|
||||
data-testid="privacy-agree-checkbox"
|
||||
id="privacy-agree"
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
onChange={(e) => setAgreed(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-line text-accent focus:ring-accent-line bg-surface"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm leading-6">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -713,7 +712,6 @@ export function SettingsModal({
|
|||
value={localApiKey}
|
||||
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
||||
placeholder="Using environment variable"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -774,7 +772,6 @@ export function SettingsModal({
|
|||
setModelValue(e.target.value);
|
||||
}}
|
||||
placeholder="Enter custom model name"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -783,11 +780,11 @@ export function SettingsModal({
|
|||
{providerModelPolicy.supportsInstructions && (
|
||||
<div className="space-y-1.5">
|
||||
<label className={fieldLabelClass}>TTS Instructions</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
value={localTTSInstructions}
|
||||
onChange={(e) => setLocalTTSInstructions(e.target.value)}
|
||||
placeholder="Enter instructions for the TTS model"
|
||||
className={`${inputClass} h-24 resize-none`}
|
||||
className="h-24 resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<label className="text-xs font-medium text-foreground">Anonymous per-user daily limit</label>
|
||||
{renderSource('ttsDailyLimitAnonymous')}
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className={inputClass}
|
||||
value={String(draft.ttsDailyLimitAnonymous ?? '')}
|
||||
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAnonymous', event.target.value)}
|
||||
/>
|
||||
|
|
@ -288,11 +287,10 @@ export function AdminFeaturesPanel() {
|
|||
<label className="text-xs font-medium text-foreground">Authenticated per-user daily limit</label>
|
||||
{renderSource('ttsDailyLimitAuthenticated')}
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className={inputClass}
|
||||
value={String(draft.ttsDailyLimitAuthenticated ?? '')}
|
||||
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAuthenticated', event.target.value)}
|
||||
/>
|
||||
|
|
@ -302,11 +300,10 @@ export function AdminFeaturesPanel() {
|
|||
<label className="text-xs font-medium text-foreground">Anonymous IP daily backstop</label>
|
||||
{renderSource('ttsIpDailyLimitAnonymous')}
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className={inputClass}
|
||||
value={String(draft.ttsIpDailyLimitAnonymous ?? '')}
|
||||
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAnonymous', event.target.value)}
|
||||
/>
|
||||
|
|
@ -316,11 +313,10 @@ export function AdminFeaturesPanel() {
|
|||
<label className="text-xs font-medium text-foreground">Authenticated IP daily backstop</label>
|
||||
{renderSource('ttsIpDailyLimitAuthenticated')}
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className={inputClass}
|
||||
value={String(draft.ttsIpDailyLimitAuthenticated ?? '')}
|
||||
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAuthenticated', event.target.value)}
|
||||
/>
|
||||
|
|
@ -343,11 +339,10 @@ export function AdminFeaturesPanel() {
|
|||
<label className="text-xs font-medium text-foreground">Burst limit (parses)</label>
|
||||
{renderSource('computeParseBurstMax')}
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className={inputClass}
|
||||
value={String(draft.computeParseBurstMax ?? '')}
|
||||
onChange={(event) => updatePositiveIntDraft('computeParseBurstMax', event.target.value)}
|
||||
/>
|
||||
|
|
@ -357,11 +352,10 @@ export function AdminFeaturesPanel() {
|
|||
<label className="text-xs font-medium text-foreground">Burst window (seconds)</label>
|
||||
{renderSource('computeParseBurstWindowSec')}
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className={inputClass}
|
||||
value={String(draft.computeParseBurstWindowSec ?? '')}
|
||||
onChange={(event) => updatePositiveIntDraft('computeParseBurstWindowSec', event.target.value)}
|
||||
/>
|
||||
|
|
@ -371,11 +365,10 @@ export function AdminFeaturesPanel() {
|
|||
<label className="text-xs font-medium text-foreground">Sustained limit (parses)</label>
|
||||
{renderSource('computeParseSustainedMax')}
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className={inputClass}
|
||||
value={String(draft.computeParseSustainedMax ?? '')}
|
||||
onChange={(event) => updatePositiveIntDraft('computeParseSustainedMax', event.target.value)}
|
||||
/>
|
||||
|
|
@ -385,11 +378,10 @@ export function AdminFeaturesPanel() {
|
|||
<label className="text-xs font-medium text-foreground">Sustained window (seconds)</label>
|
||||
{renderSource('computeParseSustainedWindowSec')}
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className={inputClass}
|
||||
value={String(draft.computeParseSustainedWindowSec ?? '')}
|
||||
onChange={(event) => updatePositiveIntDraft('computeParseSustainedWindowSec', event.target.value)}
|
||||
/>
|
||||
|
|
@ -405,13 +397,13 @@ export function AdminFeaturesPanel() {
|
|||
</div>
|
||||
<div className="shrink-0 self-start pl-1.5">{renderSource('maxUploadMb')}</div>
|
||||
<div className="shrink-0 flex items-center gap-1.5">
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
aria-label="Max upload size in megabytes"
|
||||
className="w-20 rounded-md bg-background border border-offbase px-2.5 py-1.5 text-sm text-foreground text-right focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
className="w-20 text-right"
|
||||
value={String(draft.maxUploadMb ?? '')}
|
||||
onChange={(event) => updatePositiveIntDraft('maxUploadMb', event.target.value)}
|
||||
/>
|
||||
|
|
@ -436,9 +428,8 @@ export function AdminFeaturesPanel() {
|
|||
</div>
|
||||
<div className="shrink-0">{renderSource('changelogFeedUrl')}</div>
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
value={String(draft.changelogFeedUrl ?? '')}
|
||||
onChange={(event) => updateDraft('changelogFeedUrl', event.target.value)}
|
||||
placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json"
|
||||
|
|
@ -481,11 +472,10 @@ export function AdminFeaturesPanel() {
|
|||
<label className="text-xs font-medium text-foreground">Retry attempts</label>
|
||||
{renderSource('ttsUpstreamMaxRetries')}
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className={inputClass}
|
||||
value={String(draft.ttsUpstreamMaxRetries ?? '')}
|
||||
onChange={(event) => updatePositiveIntDraft('ttsUpstreamMaxRetries', event.target.value)}
|
||||
/>
|
||||
|
|
@ -495,11 +485,10 @@ export function AdminFeaturesPanel() {
|
|||
<label className="text-xs font-medium text-foreground">Upstream timeout (ms)</label>
|
||||
{renderSource('ttsUpstreamTimeoutMs')}
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className={inputClass}
|
||||
value={String(draft.ttsUpstreamTimeoutMs ?? '')}
|
||||
onChange={(event) => updatePositiveIntDraft('ttsUpstreamTimeoutMs', event.target.value)}
|
||||
/>
|
||||
|
|
@ -509,11 +498,10 @@ export function AdminFeaturesPanel() {
|
|||
<label className="text-xs font-medium text-foreground">Audio cache size (bytes)</label>
|
||||
{renderSource('ttsCacheMaxSizeBytes')}
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className={inputClass}
|
||||
value={String(draft.ttsCacheMaxSizeBytes ?? '')}
|
||||
onChange={(event) => updatePositiveIntDraft('ttsCacheMaxSizeBytes', event.target.value)}
|
||||
/>
|
||||
|
|
@ -523,11 +511,10 @@ export function AdminFeaturesPanel() {
|
|||
<label className="text-xs font-medium text-foreground">Audio cache TTL (ms)</label>
|
||||
{renderSource('ttsCacheTtlMs')}
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className={inputClass}
|
||||
value={String(draft.ttsCacheTtlMs ?? '')}
|
||||
onChange={(event) => updatePositiveIntDraft('ttsCacheTtlMs', event.target.value)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</Field>
|
||||
|
|
@ -404,7 +404,6 @@ export function AdminProvidersPanel() {
|
|||
value={form.displayName}
|
||||
onChange={(e) => setForm({ ...form, displayName: e.target.value })}
|
||||
placeholder="Kokoro (production)"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Provider type">
|
||||
|
|
@ -483,7 +482,6 @@ export function AdminProvidersPanel() {
|
|||
});
|
||||
}}
|
||||
placeholder="Enter custom model id"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -494,11 +492,11 @@ export function AdminProvidersPanel() {
|
|||
className="sm:col-span-2"
|
||||
hint="Optional. Applied by default when this shared provider is selected."
|
||||
>
|
||||
<textarea
|
||||
<Textarea
|
||||
value={form.defaultInstructions}
|
||||
onChange={(e) => setForm({ ...form, defaultInstructions: e.target.value })}
|
||||
placeholder="Enter instructions for this model"
|
||||
className={`${inputClass} min-h-24 resize-y`}
|
||||
className="min-h-24 resize-y"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
|
@ -509,7 +507,6 @@ export function AdminProvidersPanel() {
|
|||
value={form.baseUrl}
|
||||
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
|
||||
placeholder={baseUrlPlaceholder}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
|
@ -584,8 +580,8 @@ export function AdminProvidersPanel() {
|
|||
{p.baseUrl ? p.baseUrl : 'provider base URL default'} · key {p.apiKeyMask}
|
||||
</div>
|
||||
</div>
|
||||
<Menu as="div" className="relative shrink-0">
|
||||
<MenuButton
|
||||
<MenuRoot as="div" className="relative shrink-0">
|
||||
<MenuTrigger
|
||||
as={IconButton}
|
||||
tone="surface"
|
||||
size="sm"
|
||||
|
|
@ -594,16 +590,8 @@ export function AdminProvidersPanel() {
|
|||
disabled={!!editingId || deleteMutation.isPending || toggleEnabledMutation.isPending || setDefaultMutation.isPending}
|
||||
>
|
||||
<DotsHorizontalIcon className="h-3 w-4" />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
</MenuTrigger>
|
||||
<MenuTransition>
|
||||
<MenuItemsSurface
|
||||
anchor="bottom end"
|
||||
className="z-50 mt-2 min-w-[170px] bg-base focus:outline-none"
|
||||
|
|
@ -624,8 +612,8 @@ export function AdminProvidersPanel() {
|
|||
Delete
|
||||
</MenuActionItem>
|
||||
</MenuItemsSurface>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</MenuTransition>
|
||||
</MenuRoot>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
|
||||
<SidebarNavGroup
|
||||
action={(
|
||||
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
|
||||
<MenuButton
|
||||
<MenuRoot as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
|
||||
<MenuTrigger
|
||||
as={IconButton}
|
||||
size="xs"
|
||||
className="h-3.5 w-5"
|
||||
|
|
@ -210,16 +209,8 @@ export function FinderSidebar({
|
|||
aria-label="Folder actions"
|
||||
>
|
||||
<DotsHorizontalIcon className="w-4 h-2.5" />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-standard duration-fast"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-standard duration-fast"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
</MenuTrigger>
|
||||
<MenuTransition>
|
||||
<MenuItemsSurface
|
||||
anchor="bottom start"
|
||||
className="z-50 mt-2 min-w-[180px] focus:outline-none normal-case tracking-normal font-normal"
|
||||
|
|
@ -246,8 +237,8 @@ export function FinderSidebar({
|
|||
Remove All Folders
|
||||
</MenuActionItem>
|
||||
</MenuItemsSurface>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</MenuTransition>
|
||||
</MenuRoot>
|
||||
)}
|
||||
>
|
||||
Folders
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
<div className="sm:hidden flex items-center">
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<MenuButton
|
||||
<MenuRoot as="div" className="relative inline-block text-left">
|
||||
<MenuTrigger
|
||||
as={IconButton}
|
||||
tone="surface"
|
||||
size="sm"
|
||||
title="Menu"
|
||||
>
|
||||
<DotsVerticalIcon className="w-4 h-4 transform transition-transform duration-base ease-standard hover:text-accent" />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-standard duration-fast"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-standard duration-fast"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
</MenuTrigger>
|
||||
<MenuTransition>
|
||||
<MenuItemsSurface className="absolute right-0 z-50 mt-2 min-w-max origin-top-right divide-y divide-line-soft focus:outline-none">
|
||||
{/* Zoom Controls Section */}
|
||||
<div className="px-4 py-3">
|
||||
|
|
@ -140,8 +130,8 @@ export function DocumentHeaderMenu({
|
|||
<UserMenu />
|
||||
</div>
|
||||
</MenuItemsSurface>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</MenuTransition>
|
||||
</MenuRoot>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<div className="flex items-center text-sm font-normal">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none text-soft hover:text-foreground transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-muted text-accent focus:ring-accent-line"
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
ref={input => {
|
||||
if (input) input.indeterminate = isIndeterminate;
|
||||
|
|
@ -154,11 +152,9 @@ export function DocumentSelectionModal({
|
|||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => handleCheckboxChange(file.id, e.target.checked)}
|
||||
className="rounded border-muted text-accent focus:ring-accent-line"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { Popover } from '@headlessui/react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { IconButton, Input, PopoverSurface, PopoverTrigger } from '@/components/ui';
|
||||
import { IconButton, Input, PopoverRoot, PopoverSurface, PopoverTrigger } from '@/components/ui';
|
||||
|
||||
export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||
currentPage: number;
|
||||
|
|
@ -66,7 +65,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
|||
</IconButton>
|
||||
|
||||
{/* Page number popup */}
|
||||
<Popover className="relative mb-1">
|
||||
<PopoverRoot className="relative mb-1">
|
||||
<PopoverTrigger className="rounded-full bg-surface-sunken px-2 py-0.5 text-xs" onClick={handlePopoverOpen}>
|
||||
<p className="text-xs whitespace-nowrap">
|
||||
{currentPage} / {numPages || 1}
|
||||
|
|
@ -92,7 +91,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
|||
<div className="text-xs text-soft text-center">of {numPages || 1}</div>
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</Popover>
|
||||
</PopoverRoot>
|
||||
|
||||
{/* Page forward */}
|
||||
<IconButton
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
'use client';
|
||||
|
||||
import { Popover } from '@headlessui/react';
|
||||
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||
import { PopoverSurface, PopoverTrigger, RangeInput } from '@/components/ui';
|
||||
import { PopoverRoot, PopoverSurface, PopoverTrigger, RangeInput } from '@/components/ui';
|
||||
|
||||
export const SpeedControl = ({
|
||||
setSpeedAndRestart,
|
||||
|
|
@ -88,7 +87,7 @@ export const SpeedControl = ({
|
|||
const step = 0.1;
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
<PopoverRoot className="relative">
|
||||
<PopoverTrigger className="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">
|
||||
<SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
<span className="sm:hidden">{compactTriggerLabel}</span>
|
||||
|
|
@ -148,6 +147,6 @@ export const SpeedControl = ({
|
|||
</div>
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</Popover>
|
||||
</PopoverRoot>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Popover className="relative shrink-0">
|
||||
<PopoverButton
|
||||
as={IconButton}
|
||||
<PopoverRoot className="relative shrink-0">
|
||||
<PopoverIconTrigger
|
||||
size="sm"
|
||||
aria-label="Segment metadata"
|
||||
title="Metadata"
|
||||
>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</PopoverButton>
|
||||
</PopoverIconTrigger>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-standard duration-fast"
|
||||
|
|
@ -963,7 +962,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
|||
</dl>
|
||||
</PopoverSurface>
|
||||
</Transition>
|
||||
</Popover>
|
||||
</PopoverRoot>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineButton({
|
||||
className,
|
||||
children,
|
||||
type = 'button',
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={cn('underline hover:text-foreground', focusRing, motionColors, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
16
src/components/ui/checkbox.tsx
Normal file
16
src/components/ui/checkbox.tsx
Normal file
|
|
@ -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<InputHTMLAttributes<HTMLInputElement>, 'type'>
|
||||
>(function Checkbox({ className, ...props }, ref) {
|
||||
return <input ref={ref} type="checkbox" className={cn(checkboxClass, className)} {...props} />;
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
export * from './badge';
|
||||
export * from './button';
|
||||
export * from './checkbox';
|
||||
export * from './choice-tile';
|
||||
export * from './cn';
|
||||
export * from './dialog';
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement, InputHTMLAttributes<HTMLInputElement> & { controlSize?: InputControlSize }>(function Input({
|
||||
className,
|
||||
controlSize = 'md',
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement> & { children: ReactNode }) {
|
||||
export const MenuRoot = HeadlessMenu;
|
||||
export const MenuTrigger = HeadlessMenuButton;
|
||||
|
||||
export function MenuTransition({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className={cn(menuPanelClass, className)} {...props}>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-standard duration-fast"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-standard duration-fast"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof IconButton>) {
|
||||
return (
|
||||
<PopoverButton as={IconButton} {...props}>
|
||||
{children}
|
||||
</PopoverButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function PopoverSurface({
|
||||
className,
|
||||
children,
|
||||
|
|
|
|||
43
tests/unit/shared-form-control-consumers.vitest.spec.ts
Normal file
43
tests/unit/shared-form-control-consumers.vitest.spec.ts
Normal file
|
|
@ -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('<InlineButton');
|
||||
expect(source, relativePath).not.toMatch(/<button\b/);
|
||||
}
|
||||
});
|
||||
|
||||
test('shared primitives own standard form-control chrome', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
31
tests/unit/shared-overlay-consumers.vitest.spec.ts
Normal file
31
tests/unit/shared-overlay-consumers.vitest.spec.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue