Refactor UI components to use consistent button and icon styles across the application

This commit is contained in:
Richard R 2026-06-01 11:33:25 -06:00
parent fdceed6067
commit 4f1d8feda5
12 changed files with 233 additions and 87 deletions

View file

@ -176,7 +176,7 @@ export default function EPUBPage() {
href="/app"
className={buttonClass({ variant: 'secondary', size: 'md', className: 'gap-2' })}
>
<svg className="w-4 h-4 text-soft" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Documents

View file

@ -258,7 +258,7 @@ export default function PDFViewerPage() {
onClick={handleBackToDocuments}
className={buttonClass({ variant: 'secondary', size: 'md', className: 'gap-2' })}
>
<svg className="w-4 h-4 text-soft" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Documents
@ -346,7 +346,7 @@ export default function PDFViewerPage() {
<button
type="button"
onClick={requestForceReparse}
className="inline-flex items-center rounded-md border border-line bg-surface px-3 py-1.5 text-xs font-medium text-foreground hover:text-accent transition-colors"
className={buttonClass({ variant: 'secondary', size: 'sm' })}
>
Retry Parse
</button>

View file

@ -110,7 +110,7 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
size="xs"
aria-label="Open system color picker"
>
<PaletteIcon className="w-4 h-4 text-soft transform transition-transform duration-base ease-standard hover:text-accent" />
<PaletteIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
</IconButton>
<input
ref={nativeRef}

View file

@ -21,8 +21,8 @@ export function HomeContent() {
const appActions = (
<div className="flex flex-col gap-0.5 w-full">
<SettingsTrigger
variant="sidebar"
triggerLabel="Settings"
className="w-full justify-start gap-2 px-2 py-1 text-[12px] border-transparent hover:border-accent"
onOpen={() => setSettingsOpen(true)}
/>
<UserMenu variant="sidebar" />

View file

@ -181,12 +181,27 @@ type AdminSubTab = 'providers' | 'features';
export function SettingsTrigger({
className = '',
triggerLabel,
variant = 'button',
onOpen,
}: {
className?: string;
triggerLabel?: string;
variant?: 'button' | 'sidebar';
onOpen: () => void;
}) {
if (variant === 'sidebar') {
return (
<SidebarNavItem
compact
onClick={onOpen}
className={className}
aria-label="Settings"
icon={<SettingsIcon className="w-3.5 h-3.5" />}
label={triggerLabel ?? 'Settings'}
/>
);
}
return (
<Button
variant="secondary"
@ -959,16 +974,18 @@ export function SettingsModal({
}}
className="flex-1"
/>
<button
<IconButton
onClick={() => setIsCustomExpanded(!isCustomExpanded)}
className="shrink-0 p-1.5 rounded-lg border border-line hover:border-accent-line transition-colors"
tone="surface"
size="sm"
className="shrink-0"
style={{ color: colors.muted, backgroundColor: colors.base }}
aria-label={isCustomExpanded ? 'Collapse color picker' : 'Expand color picker'}
>
<svg className={`w-3.5 h-3.5 transition-transform duration-base ${isCustomExpanded ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</IconButton>
</div>
{isCustomExpanded && (

View file

@ -7,7 +7,7 @@ import { useAuthSession } from '@/hooks/useAuthSession';
import { getAuthClient } from '@/lib/client/auth-client';
import { useRouter } from 'next/navigation';
import { UserIcon } from '@/components/icons/Icons';
import { IconButton, buttonClass } from '@/components/ui';
import { IconButton, SidebarNavItem, SidebarNavLink, buttonClass } from '@/components/ui';
type UserMenuVariant = 'toolbar' | 'sidebar';
@ -31,21 +31,24 @@ export function UserMenu({
router.push('/signin');
};
const rowClass =
'w-full inline-flex items-center gap-2 px-2 py-1 rounded-md text-[12px] border border-transparent transition duration-base ease-standard text-left hover:border-accent hover:bg-accent-wash hover:text-accent';
if (!session || session.user.isAnonymous) {
if (variant === 'sidebar') {
return (
<div className={`flex w-full flex-col gap-0.5 ${className}`}>
<Link href="/signin" className={rowClass}>
<UserIcon className="h-3.5 w-3.5 text-soft" />
<span className="truncate">Connect</span>
<Link href="/signin" legacyBehavior passHref>
<SidebarNavLink
compact
icon={<UserIcon className="h-3.5 w-3.5" />}
label="Connect"
/>
</Link>
{enableUserSignups && (
<Link href="/signup" className={rowClass}>
<UserIcon className="h-3.5 w-3.5 text-soft" />
<span className="truncate">Create account</span>
<Link href="/signup" legacyBehavior passHref>
<SidebarNavLink
compact
icon={<UserIcon className="h-3.5 w-3.5" />}
label="Create account"
/>
</Link>
)}
</div>
@ -72,20 +75,24 @@ export function UserMenu({
if (variant === 'sidebar') {
return (
<button
<SidebarNavItem
compact
onClick={handleDisconnectAccount}
className={`${rowClass} ${className}`}
className={className}
title="Disconnect account"
aria-label="Disconnect account"
icon={<UserIcon className="h-3.5 w-3.5" />}
label={session.user.email || 'Account'}
trailing={(
<svg className="h-3.5 w-3.5 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
)}
>
<UserIcon className="h-3.5 w-3.5 text-soft" />
<span className="truncate flex-1">{session.user.email || 'Account'}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
</button>
{session.user.email || 'Account'}
</SidebarNavItem>
);
}

View file

@ -182,6 +182,16 @@ function SidebarUploadLoader({
);
}
function DocumentListStateLoader() {
return (
<div
className="h-full w-full min-h-0 bg-surface-sunken animate-pulse"
aria-label="Loading documents"
aria-busy="true"
/>
);
}
function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
const cachedState = cachedDocumentListState;
const [sortBy, setSortBy] = useState<SortBy>(cachedState?.sortBy ?? DEFAULT_STATE.sortBy);
@ -670,7 +680,11 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
{isLoading ? (
<div className="flex-1 min-h-0 overflow-hidden">
<DocumentListSkeleton viewMode={fallbackViewMode} iconSize={iconSize} />
{isInitialized ? (
<DocumentListSkeleton viewMode={fallbackViewMode} iconSize={iconSize} />
) : (
<DocumentListStateLoader />
)}
</div>
) : allDocuments.length === 0 ? (
<div className="flex-1 min-h-0 flex items-center justify-center p-6">

View file

@ -79,20 +79,20 @@ function FolderRow({
countClassName="group-hover/folder:-translate-x-6 group-focus-within/folder:-translate-x-6"
isDropTarget={isDropTarget}
/>
<button
type="button"
<IconButton
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="absolute right-1 top-1/2 -translate-y-1/2 h-5 w-5 inline-flex items-center justify-center rounded text-soft opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100 hover:text-accent hover:bg-accent-wash transition"
size="xs"
className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100"
aria-label={`Delete ${folder.name}`}
title={`Delete ${folder.name}`}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</IconButton>
</div>
);
}

View file

@ -10,7 +10,7 @@ import {
HamburgerIcon,
} from './finderIcons';
import { ChevronUpDownIcon } from '@/components/icons/Icons';
import { SearchField, Toolbar, ToolbarButton, ToolbarGroup, ToolbarSegment, listboxOptionClass, listboxOptionsClass, toolbarButtonStyles } from '@/components/ui';
import { SearchField, Toolbar, ToolbarButton, ToolbarGroup, ToolbarSegment, listboxCompactOptionClass, listboxCompactOptionsClass, toolbarButtonStyles } from '@/components/ui';
import type { ReactNode } from 'react';
interface FinderToolbarProps {
@ -146,17 +146,17 @@ export function FinderToolbar({
</ToolbarButton>
<Listbox value={sortBy} onChange={onSortByChange}>
<ListboxButton
className={toolbarButtonStyles({ className: 'gap-1 min-w-[90px] justify-between' })}
className={toolbarButtonStyles({ 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={listboxOptionsClass}>
<ListboxOptions anchor="bottom end" className={listboxCompactOptionsClass}>
{SORT_OPTIONS.map((opt) => (
<ListboxOption
key={opt.value}
value={opt.value}
className={({ active, selected }) => listboxOptionClass(active || selected)}
className={({ active, selected }) => listboxCompactOptionClass(active, selected)}
>
{opt.label}
</ListboxOption>

View file

@ -8,7 +8,7 @@ import toast from 'react-hot-toast';
import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext';
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
import { buttonClass } from '@/components/ui';
import { Button, IconButton, popoverPanelClass } 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';
@ -632,33 +632,27 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
const headerActions = (
<>
<button
type="button"
<Button
onClick={() => void handleClearCache()}
aria-label="Clear segments cache"
title="Clear cache for listed segments"
disabled={isClearingSegments}
className={buttonClass({
variant: 'secondary',
size: 'xs',
className: 'h-8 px-2 text-soft',
})}
variant="secondary"
size="xs"
className="h-8 px-2 text-soft"
>
{isClearingSegments ? 'Clearing…' : 'Clear'}
</button>
<button
type="button"
</Button>
<IconButton
onClick={handleRefresh}
aria-label="Refresh segments"
title="Refresh"
className={buttonClass({
variant: 'secondary',
size: 'icon',
className: 'h-8 w-8 text-soft',
})}
tone="surface"
size="md"
className="h-8 w-8 text-soft"
>
<RefreshIcon className="w-3.5 h-3.5" />
</button>
</IconButton>
</>
);
@ -893,9 +887,10 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
return (
<Popover className="relative shrink-0">
<PopoverButton
as={IconButton}
size="sm"
aria-label="Segment metadata"
title="Metadata"
className="inline-flex items-center justify-center w-7 h-7 rounded-md border border-transparent text-soft hover:bg-accent-wash hover:border-line hover:text-accent transition-colors"
>
<InfoIcon className="w-3.5 h-3.5" />
</PopoverButton>
@ -910,7 +905,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
>
<PopoverPanel
anchor="bottom end"
className="z-[60] w-[300px] mt-1 rounded-lg border border-line bg-surface shadow-elev-3 p-3"
className={`${popoverPanelClass} z-[60] w-[300px] mt-1`}
>
<dl className="space-y-2">
<Row label="locator">

View file

@ -13,6 +13,9 @@ export const listboxPanelClass =
export const listboxOptionsClass =
cn(listboxPanelClass, 'w-[var(--button-width)] [--anchor-gap:0.25rem]');
export const listboxCompactOptionsClass =
'z-50 min-w-[8rem] rounded-md bg-surface p-1 shadow-elev-2 ring-1 ring-line focus:outline-none [--anchor-gap:0.25rem]';
export const listboxOptionClass = (active: boolean, selected = false, inset: 'check' | 'none' = 'check') =>
cn(
'relative cursor-pointer select-none rounded-sm py-1.5 text-sm',
@ -20,6 +23,16 @@ export const listboxOptionClass = (active: boolean, selected = false, inset: 'ch
selected ? 'bg-accent text-background font-medium' : active ? 'bg-accent-wash text-foreground' : 'text-foreground',
);
export const listboxCompactOptionClass = (active: boolean, selected = false) =>
cn(
'relative cursor-pointer select-none rounded-sm px-2 py-1 text-xs',
active
? 'bg-accent-wash text-accent'
: selected
? 'bg-surface-sunken text-accent font-medium'
: 'text-foreground',
);
export function Select({
value,
onChange,

View file

@ -1,4 +1,4 @@
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
import { focusRing, motionColors } from './tokens';
@ -50,6 +50,82 @@ export function SidebarNavGroup({
);
}
export function sidebarNavItemClass({
active = false,
compact = false,
isDropTarget = false,
className,
}: {
active?: boolean;
compact?: boolean;
isDropTarget?: boolean;
className?: string;
} = {}) {
return cn(
'group w-full min-w-0 border text-left font-medium',
'inline-flex items-center transition duration-base ease-standard',
focusRing,
motionColors,
compact ? 'gap-1.5 rounded-md px-2 py-1 text-xs' : 'gap-2 rounded-md px-2.5 py-1.5 text-sm',
active
? 'border-accent-line bg-surface-sunken text-accent'
: 'border-transparent bg-transparent text-foreground hover:border-accent-line hover:bg-accent-wash hover:text-accent',
isDropTarget ? 'ring-1 ring-accent-line' : '',
className,
);
}
export function sidebarNavIconClass({
active = false,
compact = false,
}: {
active?: boolean;
compact?: boolean;
} = {}) {
return cn(
'shrink-0 inline-flex items-center justify-center transition-colors duration-base',
compact ? 'h-4 w-4' : 'h-5 w-5',
active ? 'text-accent' : 'text-soft group-hover:text-accent',
);
}
function SidebarNavItemContent({
active = false,
icon,
label,
count,
countClassName,
trailing,
compact = false,
children,
}: {
active?: boolean;
icon?: ReactNode;
label?: ReactNode;
count?: number;
countClassName?: string;
trailing?: ReactNode;
compact?: boolean;
children?: ReactNode;
}) {
return (
<>
{icon ? (
<span className={sidebarNavIconClass({ active, compact })}>
{icon}
</span>
) : null}
{label ?? children ? <span className="min-w-0 flex-1 truncate">{label ?? children}</span> : null}
{typeof count === 'number' && count > 0 ? (
<span className={cn('text-[10px] text-soft tabular-nums transition-transform duration-base ease-standard', countClassName)}>
{count}
</span>
) : null}
{trailing}
</>
);
}
export function SidebarNavItem({
active = false,
icon,
@ -76,38 +152,62 @@ export function SidebarNavItem({
return (
<button
type={type}
className={cn(
'group w-full min-w-0 border text-left font-medium',
'inline-flex items-center transition duration-base ease-standard',
focusRing,
motionColors,
compact ? 'gap-1.5 rounded-md px-2 py-1 text-xs' : 'gap-2 rounded-md px-2.5 py-1.5 text-sm',
active
? 'border-accent-line bg-surface-sunken text-accent'
: 'border-transparent bg-transparent text-foreground hover:border-accent-line hover:bg-accent-wash hover:text-accent',
isDropTarget ? 'ring-1 ring-accent-line' : '',
className,
)}
className={sidebarNavItemClass({ active, compact, isDropTarget, className })}
{...props}
>
{icon ? (
<span
className={cn(
'shrink-0 inline-flex items-center justify-center transition-colors duration-base',
compact ? 'h-4 w-4' : 'h-5 w-5',
active ? 'text-accent' : 'text-soft group-hover:text-accent',
)}
>
{icon}
</span>
) : null}
{label ?? children ? <span className="min-w-0 flex-1 truncate">{label ?? children}</span> : null}
{typeof count === 'number' && count > 0 ? (
<span className={cn('text-[10px] text-soft tabular-nums transition-transform duration-base ease-standard', countClassName)}>
{count}
</span>
) : null}
{trailing}
<SidebarNavItemContent
active={active}
compact={compact}
icon={icon}
label={label}
count={count}
countClassName={countClassName}
trailing={trailing}
>
{children}
</SidebarNavItemContent>
</button>
);
}
export function SidebarNavLink({
active = false,
icon,
label,
count,
countClassName,
trailing,
isDropTarget = false,
className,
compact = false,
children,
...props
}: AnchorHTMLAttributes<HTMLAnchorElement> & {
active?: boolean;
icon?: ReactNode;
label?: ReactNode;
count?: number;
countClassName?: string;
trailing?: ReactNode;
isDropTarget?: boolean;
compact?: boolean;
}) {
return (
<a
className={sidebarNavItemClass({ active, compact, isDropTarget, className })}
{...props}
>
<SidebarNavItemContent
active={active}
compact={compact}
icon={icon}
label={label}
count={count}
countClassName={countClassName}
trailing={trailing}
>
{children}
</SidebarNavItemContent>
</a>
);
}