phase 5: split ui primitives into modules

This commit is contained in:
Richard R 2026-05-31 22:02:28 -06:00
parent 4cb9331abb
commit c56c3fa902
20 changed files with 784 additions and 413 deletions

View file

@ -1,24 +1,24 @@
'use client';
import { useId, type ButtonHTMLAttributes, type ReactNode } from 'react';
import {
btnBase,
btnDanger,
btnGhost,
btnOutline,
btnPrimary,
btnSecondary,
buttonClass,
type ButtonSize,
type ButtonVariant,
} from '@/components/ui/buttonPrimitives';
/**
* Shared compact form primitives used by settings-like surfaces across
* the app (settings modal, document settings, and admin panels).
* Compatibility shim for older settings/admin imports.
* New UI work should import from `@/components/ui/*` or `@/components/ui`.
*/
export {
Badge,
Button,
Card,
CheckItem,
Divider,
Field,
IconButton,
Menu,
Panel,
Section,
Sidebar,
Surface,
Switch,
ToggleRow,
btnBase,
btnDanger,
btnGhost,
@ -26,364 +26,13 @@ export {
btnPrimary,
btnSecondary,
buttonClass,
inputClass,
listboxButtonClass,
listboxOptionClass,
listboxOptionsClass,
segmentedButtonClass,
segmentedGroupClass,
type ButtonSize,
type ButtonVariant,
};
// Inputs use `bg-surface` so they remain visible regardless of whether the
// surrounding container is `bg-background` (Card) or `bg-surface` (Section).
// Using the same `bg-background` as the Card would make the input blend in.
// (Note: never use Tailwind alpha modifiers on these theme variables — they
// resolve to CSS custom properties that don't accept opacity suffixes.)
export const inputClass =
'w-full rounded-md bg-surface-sunken border border-line px-2.5 py-1.5 text-sm text-foreground transition-colors duration-fast ease-standard focus:outline-none focus:ring-2 focus:ring-accent-line focus:border-accent-line';
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 listboxOptionsClass =
'z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain 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) =>
`relative cursor-pointer select-none rounded-sm py-1.5 pl-9 pr-3 text-sm ${active ? 'bg-accent-wash text-foreground' : 'text-foreground'}`;
export const segmentedGroupClass =
'grid gap-1 rounded-full border border-line bg-surface-sunken p-1';
export const segmentedButtonClass = (active: boolean) =>
`rounded-full px-2.5 py-1.5 text-xs font-medium transition-colors duration-fast ease-standard focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
active
? 'bg-accent text-background'
: 'text-soft hover:bg-accent-wash hover:text-foreground'
}`;
type SurfaceElevation = 'none' | '1' | '2' | '3';
const SURFACE_ELEVATION_CLASS: Record<SurfaceElevation, string> = {
none: '',
'1': 'shadow-elev-1',
'2': 'shadow-elev-2',
'3': 'shadow-elev-3',
};
export function Surface({
children,
className = '',
elevation = 'none',
}: {
children: ReactNode;
className?: string;
elevation?: SurfaceElevation;
}) {
return (
<div className={`rounded-lg border border-line bg-surface text-foreground ${SURFACE_ELEVATION_CLASS[elevation]} ${className}`}>
{children}
</div>
);
}
export function Panel({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<Surface elevation="1" className={`overflow-hidden ${className}`}>
{children}
</Surface>
);
}
export function Sidebar({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<aside className={`rounded-lg border border-line bg-surface text-foreground shadow-elev-2 ${className}`}>
{children}
</aside>
);
}
export function Divider({ className = '' }: { className?: string }) {
return <div aria-hidden="true" className={`border-t border-line-soft ${className}`} />;
}
export function IconButton({
className = '',
children,
type = 'button',
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type={type}
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-soft transition-colors duration-fast ease-standard hover:bg-accent-wash hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
{...props}
>
{children}
</button>
);
}
export function Menu({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={`rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft ${className}`}>
{children}
</div>
);
}
export function Section({
title,
subtitle,
children,
action,
variant = 'panel',
}: {
title: string;
subtitle?: ReactNode;
children: ReactNode;
action?: ReactNode;
variant?: 'panel' | 'flat';
}) {
if (variant === 'flat') {
return (
<section className="space-y-2 pb-3 border-b border-line-soft last:border-b-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{subtitle ? <p className="text-xs text-soft mt-0.5">{subtitle}</p> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
{children}
</section>
);
}
return (
<section className="rounded-lg border border-line bg-surface overflow-hidden">
<div className="px-3 py-2 bg-surface-solid border-b border-line-soft">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{subtitle ? <p className="text-xs text-soft mt-0.5">{subtitle}</p> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
</div>
<div className="px-3 py-2 space-y-2">
{children}
</div>
</section>
);
}
export function Card({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={`rounded-lg border border-line bg-surface px-3 py-2 transition-colors duration-fast ease-standard ${className}`}>
{children}
</div>
);
}
export type SwitchSize = 'sm' | 'md';
const SWITCH_SIZE: Record<SwitchSize, { track: string; thumb: string; on: string; off: string }> = {
sm: {
track: 'h-4 w-7',
thumb: 'h-3 w-3',
on: 'translate-x-3',
off: 'translate-x-0.5',
},
md: {
track: 'h-5 w-9',
thumb: 'h-4 w-4',
on: 'translate-x-4',
off: 'translate-x-0.5',
},
};
export function Switch({
checked,
onChange,
disabled = false,
size = 'md',
ariaLabel,
ariaLabelledBy,
ariaDescribedBy,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
size?: SwitchSize;
ariaLabel?: string;
ariaLabelledBy?: string;
ariaDescribedBy?: string;
}) {
const s = SWITCH_SIZE[size];
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative inline-flex shrink-0 cursor-pointer items-center rounded-full border border-line transition-colors duration-fast ease-standard focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${s.track} ${
checked ? 'bg-accent' : 'bg-line-strong'
}`}
>
<span
aria-hidden="true"
className={`pointer-events-none inline-block rounded-full bg-surface shadow-elev-1 ring-0 transition-transform duration-fast ease-standard ${s.thumb} ${
checked ? s.on : s.off
}`}
/>
</button>
);
}
export function ToggleRow({
label,
description,
checked,
onChange,
disabled = false,
right,
variant = 'card',
}: {
label: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
right?: ReactNode;
variant?: 'card' | 'flat';
}) {
const labelId = useId();
const descId = useId();
const rowClass =
variant === 'flat'
? 'px-0.5 pt-1 pb-2 border-b border-line-soft last:border-b-0 transition-colors duration-fast ease-standard'
: 'rounded-md border border-line bg-surface px-2.5 py-1.5 transition-colors duration-fast ease-standard';
const handleTextToggle = () => {
if (!disabled) onChange(!checked);
};
return (
<div className={rowClass}>
<div className="flex items-start gap-2.5">
<div
className={`flex-1 min-w-0 space-y-0.5 ${disabled ? '' : 'cursor-pointer'}`}
onClick={handleTextToggle}
>
<span id={labelId} className="block text-sm font-medium leading-5 text-foreground">{label}</span>
<span id={descId} className="block text-xs leading-4 text-soft">{description}</span>
</div>
{right ? <div className="shrink-0 self-start pl-1.5">{right}</div> : null}
<Switch
checked={checked}
onChange={onChange}
disabled={disabled}
size="md"
ariaLabelledBy={labelId}
ariaDescribedBy={descId}
/>
</div>
</div>
);
}
export function CheckItem({
label,
checked,
onChange,
disabled = false,
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) {
const labelId = useId();
const handleTextToggle = () => {
if (!disabled) onChange(!checked);
};
return (
<div className="flex items-center justify-between gap-2 py-0.5 group">
<span
id={labelId}
onClick={handleTextToggle}
className={`flex-1 min-w-0 truncate text-xs leading-4 text-foreground select-none transition-colors duration-fast ease-standard group-hover:text-accent ${
disabled ? '' : 'cursor-pointer'
}`}
>
{label}
</span>
<Switch
checked={checked}
onChange={onChange}
disabled={disabled}
size="sm"
ariaLabelledBy={labelId}
/>
</div>
);
}
export function Field({
label,
hint,
className = '',
children,
}: {
label?: string;
hint?: string;
className?: string;
children: ReactNode;
}) {
return (
<div className={`space-y-1 ${className}`}>
{label ? <label className="block text-[11px] font-semibold uppercase tracking-wide text-faint">{label}</label> : null}
{children}
{hint ? <p className="text-[11px] text-faint">{hint}</p> : null}
</div>
);
}
export function Badge({
tone = 'muted',
children,
}: {
tone?: 'muted' | 'accent' | 'foreground';
children: ReactNode;
}) {
const toneClass =
tone === 'accent'
? 'text-accent bg-accent-wash'
: tone === 'foreground'
? 'text-foreground bg-surface-sunken'
: 'text-soft bg-surface-sunken';
return (
<span className={`inline-flex items-center text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 ${toneClass}`}>
{children}
</span>
);
}
type SwitchSize,
} from '@/components/ui';

View file

@ -0,0 +1,35 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { variants } from './variants';
export type BadgeTone = 'muted' | 'accent' | 'foreground' | 'danger';
export const badgeStyles = variants({
base: 'inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
variants: {
tone: {
muted: 'bg-surface-sunken text-soft',
accent: 'bg-accent-wash text-accent',
foreground: 'bg-surface-sunken text-foreground',
danger: 'bg-danger-wash text-danger',
},
},
defaults: {
tone: 'muted',
},
});
export function Badge({
tone = 'muted',
className,
children,
...props
}: HTMLAttributes<HTMLSpanElement> & {
tone?: BadgeTone;
children: ReactNode;
}) {
return (
<span className={badgeStyles({ tone, className })} {...props}>
{children}
</span>
);
}

View file

@ -0,0 +1,77 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
import { variants } from './variants';
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({
base: cn(
'inline-flex items-center justify-center font-medium disabled:cursor-not-allowed disabled:opacity-50',
focusRing,
motionColors,
),
variants: {
variant: {
primary: 'bg-accent text-background hover:bg-secondary-accent',
secondary: 'border border-line bg-surface text-foreground hover:bg-accent-wash',
outline: 'border border-line bg-surface-sunken text-foreground hover:bg-accent-wash hover:text-accent',
danger: 'border border-danger bg-danger text-background hover:bg-danger',
ghost: 'bg-transparent text-foreground hover:bg-accent-wash hover:text-accent',
},
size: {
xs: 'h-6 rounded-md px-2 text-xs',
sm: 'h-7 rounded-md px-2.5 text-xs',
md: 'h-8 rounded-md px-3 text-sm',
lg: 'h-10 rounded-lg px-4 text-base',
icon: 'h-8 w-8 rounded-md p-0 text-sm',
},
},
defaults: {
variant: 'secondary',
size: 'md',
},
});
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' });
export function buttonClass({
variant = 'secondary',
size = 'md',
className = '',
}: {
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
} = {}) {
return buttonStyles({ variant, size, className });
}
export function Button({
variant = 'secondary',
size = 'md',
className,
children,
type = 'button',
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
children: ReactNode;
}) {
return (
<button type={type} className={buttonClass({ variant, size, className })} {...props}>
{children}
</button>
);
}

View file

@ -1,38 +1,13 @@
export const btnBase =
'inline-flex items-center justify-center rounded-md text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transition-colors duration-fast ease-standard disabled:opacity-50 disabled:cursor-not-allowed';
export const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent`;
export const btnSecondary = `${btnBase} bg-base text-foreground border border-offbase hover:bg-offbase`;
export const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-base hover:text-accent`;
export const btnDanger = `${btnBase} bg-danger text-background border border-danger hover:bg-danger`;
export const btnGhost = `${btnBase} bg-transparent text-foreground hover:bg-base hover:text-accent`;
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon';
const BUTTON_VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: btnPrimary,
secondary: btnSecondary,
outline: btnOutline,
danger: btnDanger,
ghost: btnGhost,
};
const BUTTON_SIZE_CLASS: Record<ButtonSize, string> = {
xs: 'h-6 px-2 text-xs rounded-md',
sm: 'h-7 px-2.5 text-xs rounded-md',
md: 'h-8 px-3 text-sm rounded-md',
lg: 'h-10 px-4 text-base rounded-lg',
icon: 'h-8 w-8 rounded-md p-0',
};
export function buttonClass({
variant = 'secondary',
size = 'md',
className = '',
}: {
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
} = {}) {
return [BUTTON_VARIANT_CLASS[variant], BUTTON_SIZE_CLASS[size], className].filter(Boolean).join(' ');
}
export {
Button,
btnBase,
btnDanger,
btnGhost,
btnOutline,
btnPrimary,
btnSecondary,
buttonClass,
buttonStyles,
type ButtonSize,
type ButtonVariant,
} from './button';

5
src/components/ui/cn.ts Normal file
View file

@ -0,0 +1,5 @@
export type ClassValue = string | false | null | undefined;
export function cn(...classes: ClassValue[]) {
return classes.filter(Boolean).join(' ');
}

View file

@ -0,0 +1,31 @@
import type { ReactNode } from 'react';
import { variants } from './variants';
export type DialogSize = 'sm' | 'md' | 'lg' | 'xl';
export const dialogPanelStyles = variants({
base: 'w-full transform rounded-lg border border-line bg-surface text-left align-middle shadow-elev-3 transition',
variants: {
size: {
sm: 'max-w-md p-5',
md: 'max-w-md p-6',
lg: 'max-w-2xl p-6',
xl: 'max-w-4xl overflow-hidden',
},
},
defaults: {
size: 'md',
},
});
export function DialogShell({
children,
className,
size = 'md',
}: {
children: ReactNode;
className?: string;
size?: DialogSize;
}) {
return <div className={dialogPanelStyles({ size, className })}>{children}</div>;
}

View file

@ -0,0 +1,6 @@
import type { HTMLAttributes } from 'react';
import { cn } from './cn';
export function Divider({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div aria-hidden="true" className={cn('border-t border-line-soft', className)} {...props} />;
}

107
src/components/ui/field.tsx Normal file
View file

@ -0,0 +1,107 @@
'use client';
import { useId, type ReactNode } from 'react';
import { cn } from './cn';
import { Switch } from './switch';
export function Field({
label,
hint,
className,
children,
}: {
label?: string;
hint?: string;
className?: string;
children: ReactNode;
}) {
return (
<div className={cn('space-y-1', className)}>
{label ? <label className="block text-[11px] font-semibold uppercase tracking-wide text-faint">{label}</label> : null}
{children}
{hint ? <p className="text-[11px] text-faint">{hint}</p> : null}
</div>
);
}
export function ToggleRow({
label,
description,
checked,
onChange,
disabled = false,
right,
variant = 'card',
}: {
label: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
right?: ReactNode;
variant?: 'card' | 'flat';
}) {
const labelId = useId();
const descId = useId();
const rowClass =
variant === 'flat'
? 'px-0.5 pt-1 pb-2 border-b border-line-soft last:border-b-0 transition-colors duration-fast ease-standard'
: 'rounded-md border border-line bg-surface px-2.5 py-1.5 transition-colors duration-fast ease-standard';
const handleTextToggle = () => {
if (!disabled) onChange(!checked);
};
return (
<div className={rowClass}>
<div className="flex items-start gap-2.5">
<div
className={cn('flex-1 min-w-0 space-y-0.5', disabled ? '' : 'cursor-pointer')}
onClick={handleTextToggle}
>
<span id={labelId} className="block text-sm font-medium leading-5 text-foreground">{label}</span>
<span id={descId} className="block text-xs leading-4 text-soft">{description}</span>
</div>
{right ? <div className="shrink-0 self-start pl-1.5">{right}</div> : null}
<Switch
checked={checked}
onChange={onChange}
disabled={disabled}
size="md"
ariaLabelledBy={labelId}
ariaDescribedBy={descId}
/>
</div>
</div>
);
}
export function CheckItem({
label,
checked,
onChange,
disabled = false,
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) {
const labelId = useId();
const handleTextToggle = () => {
if (!disabled) onChange(!checked);
};
return (
<div className="flex items-center justify-between gap-2 py-0.5 group">
<span
id={labelId}
onClick={handleTextToggle}
className={cn(
'flex-1 min-w-0 truncate text-xs leading-4 text-foreground select-none transition-colors duration-fast ease-standard group-hover:text-accent',
disabled ? '' : 'cursor-pointer',
)}
>
{label}
</span>
<Switch checked={checked} onChange={onChange} disabled={disabled} size="sm" ariaLabelledBy={labelId} />
</div>
);
}

View file

@ -0,0 +1,47 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
import { variants } from './variants';
import { focusRing, motionColors } from './tokens';
export type IconButtonTone = 'ghost' | 'surface' | 'danger';
export type IconButtonSize = 'xs' | 'sm' | 'md' | 'lg';
export const iconButtonStyles = variants({
base: cn('inline-flex items-center justify-center disabled:cursor-not-allowed disabled:opacity-50', focusRing, motionColors),
variants: {
tone: {
ghost: 'text-soft hover:bg-accent-wash hover:text-accent',
surface: 'border border-line bg-surface text-foreground hover:bg-accent-wash hover:text-accent',
danger: 'text-danger hover:bg-danger-wash',
},
size: {
xs: 'h-5 w-5 rounded-sm text-xs',
sm: 'h-7 w-7 rounded-md text-xs',
md: 'h-8 w-8 rounded-md text-sm',
lg: 'h-10 w-10 rounded-lg text-base',
},
},
defaults: {
tone: 'ghost',
size: 'md',
},
});
export function IconButton({
className,
children,
tone = 'ghost',
size = 'md',
type = 'button',
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
children: ReactNode;
tone?: IconButtonTone;
size?: IconButtonSize;
}) {
return (
<button type={type} className={iconButtonStyles({ tone, size, className })} {...props}>
{children}
</button>
);
}

View file

@ -0,0 +1,17 @@
export * from './badge';
export * from './button';
export * from './cn';
export * from './dialog';
export * from './divider';
export * from './field';
export * from './icon-button';
export * from './input';
export * from './menu';
export * from './section';
export * from './select';
export * from './sidebar';
export * from './surface';
export * from './switch';
export * from './tokens';
export * from './toolbar';
export * from './variants';

View file

@ -0,0 +1,38 @@
import type { InputHTMLAttributes, TextareaHTMLAttributes } from 'react';
import { cn } from './cn';
import { variants } from './variants';
import { motionColors } from './tokens';
export type InputControlSize = 'sm' | 'md' | 'lg';
export 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: {
sm: 'rounded-md px-2 py-1 text-xs',
md: 'rounded-md px-2.5 py-1.5 text-sm',
lg: 'rounded-lg px-3 py-2 text-sm',
},
},
defaults: {
size: 'md',
},
});
export const inputClass = inputStyles();
export function Input({
className,
controlSize = 'md',
...props
}: InputHTMLAttributes<HTMLInputElement> & { controlSize?: InputControlSize }) {
return <input className={inputStyles({ size: controlSize, className })} {...props} />;
}
export function Textarea({
className,
controlSize = 'md',
...props
}: TextareaHTMLAttributes<HTMLTextAreaElement> & { controlSize?: InputControlSize }) {
return <textarea className={inputStyles({ size: controlSize, className })} {...props} />;
}

View file

@ -0,0 +1,21 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
export function Menu({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
return (
<div className={cn('rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft', className)} {...props}>
{children}
</div>
);
}
export function MenuItemClass(active: boolean, tone: 'default' | 'danger' = 'default') {
if (tone === 'danger') {
return cn('flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs text-danger', active && 'bg-danger-wash');
}
return cn('flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs', active ? 'bg-accent-wash text-accent' : 'text-foreground');
}

View file

@ -0,0 +1,53 @@
import type { ReactNode } from 'react';
export function Section({
title,
subtitle,
children,
action,
variant = 'panel',
}: {
title: string;
subtitle?: ReactNode;
children: ReactNode;
action?: ReactNode;
variant?: 'panel' | 'flat';
}) {
if (variant === 'flat') {
return (
<section className="space-y-2 pb-3 border-b border-line-soft last:border-b-0">
<SectionHeading title={title} subtitle={subtitle} action={action} />
{children}
</section>
);
}
return (
<section className="rounded-lg border border-line bg-surface overflow-hidden">
<div className="px-3 py-2 bg-surface-solid border-b border-line-soft">
<SectionHeading title={title} subtitle={subtitle} action={action} />
</div>
<div className="px-3 py-2 space-y-2">{children}</div>
</section>
);
}
function SectionHeading({
title,
subtitle,
action,
}: {
title: string;
subtitle?: ReactNode;
action?: ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{subtitle ? <p className="text-xs text-soft mt-0.5">{subtitle}</p> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
);
}

View file

@ -0,0 +1,18 @@
import { cn } from './cn';
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 listboxOptionsClass =
'z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain 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) =>
cn('relative cursor-pointer select-none rounded-sm py-1.5 pl-9 pr-3 text-sm', active ? 'bg-accent-wash text-foreground' : 'text-foreground');
export const segmentedGroupClass = 'grid gap-1 rounded-full border border-line bg-surface-sunken p-1';
export const segmentedButtonClass = (active: boolean) =>
cn(
'rounded-full px-2.5 py-1.5 text-xs font-medium transition-colors duration-fast ease-standard focus:outline-none focus-visible:ring-2 focus-visible:ring-accent',
active ? 'bg-accent text-background' : 'text-soft hover:bg-accent-wash hover:text-foreground',
);

View file

@ -0,0 +1,17 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
export function Sidebar({
children,
className,
...props
}: HTMLAttributes<HTMLElement> & { children: ReactNode }) {
return (
<aside
className={cn('rounded-lg border border-line bg-surface text-foreground shadow-elev-2', className)}
{...props}
>
{children}
</aside>
);
}

View file

@ -0,0 +1,92 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
import { variants } from './variants';
import { motionSurface } from './tokens';
export type SurfaceTone = 'default' | 'solid' | 'sunken' | 'transparent';
export type SurfaceElevation = 'none' | '1' | '2' | '3';
export type SurfaceRadius = 'sm' | 'md' | 'lg' | 'pill';
export const surfaceStyles = variants({
base: 'border text-foreground',
variants: {
tone: {
default: 'border-line bg-surface',
solid: 'border-line bg-surface-solid',
sunken: 'border-line bg-surface-sunken',
transparent: 'border-transparent bg-transparent',
},
elevation: {
none: '',
'1': 'shadow-elev-1',
'2': 'shadow-elev-2',
'3': 'shadow-elev-3',
},
radius: {
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
pill: 'rounded-pill',
},
},
defaults: {
tone: 'default',
elevation: 'none',
radius: 'lg',
},
});
export function Surface({
children,
className,
tone = 'default',
elevation = 'none',
radius = 'lg',
...props
}: HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
tone?: SurfaceTone;
elevation?: SurfaceElevation;
radius?: SurfaceRadius;
}) {
return (
<div className={surfaceStyles({ tone, elevation, radius, className })} {...props}>
{children}
</div>
);
}
export function Panel({
children,
className,
elevation = '1',
...props
}: HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
elevation?: SurfaceElevation;
}) {
return (
<Surface elevation={elevation} className={cn('overflow-hidden', className)} {...props}>
{children}
</Surface>
);
}
export function Card({
children,
className,
interactive = false,
...props
}: HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
interactive?: boolean;
}) {
return (
<Surface
className={cn('px-3 py-2', interactive && motionSurface, className)}
{...props}
>
{children}
</Surface>
);
}

View file

@ -0,0 +1,72 @@
'use client';
import { cn } from './cn';
import { focusRing, motionColors } from './tokens';
export type SwitchSize = 'sm' | 'md';
const SWITCH_SIZE: Record<SwitchSize, { track: string; thumb: string; on: string; off: string }> = {
sm: {
track: 'h-4 w-7',
thumb: 'h-3 w-3',
on: 'translate-x-3',
off: 'translate-x-0.5',
},
md: {
track: 'h-5 w-9',
thumb: 'h-4 w-4',
on: 'translate-x-4',
off: 'translate-x-0.5',
},
};
export function Switch({
checked,
onChange,
disabled = false,
size = 'md',
ariaLabel,
ariaLabelledBy,
ariaDescribedBy,
className,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
size?: SwitchSize;
ariaLabel?: string;
ariaLabelledBy?: string;
ariaDescribedBy?: string;
className?: string;
}) {
const s = SWITCH_SIZE[size];
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
'relative inline-flex shrink-0 cursor-pointer items-center rounded-full border border-line disabled:cursor-not-allowed disabled:opacity-50',
checked ? 'bg-accent' : 'bg-line-strong',
s.track,
focusRing,
motionColors,
className,
)}
>
<span
aria-hidden="true"
className={cn(
'pointer-events-none inline-block rounded-full bg-surface shadow-elev-1 ring-0 transition-transform duration-fast ease-standard',
checked ? s.on : s.off,
s.thumb,
)}
/>
</button>
);
}

View file

@ -0,0 +1,3 @@
export const focusRing = 'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2';
export const motionColors = 'transition-colors duration-fast ease-standard';
export const motionSurface = 'transition-[transform,box-shadow,border-color,background-color] duration-base ease-standard';

View file

@ -0,0 +1,83 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
import { variants } from './variants';
import { motionColors } from './tokens';
export const toolbarButtonStyles = variants({
base: cn('inline-flex items-center rounded-md border px-2 py-1 text-xs', motionColors),
variants: {
active: {
true: 'border-accent-line bg-surface-sunken text-accent',
false: 'border-line bg-surface text-foreground hover:border-accent-line hover:bg-accent-wash hover:text-accent',
},
},
defaults: {
active: 'false',
},
});
export function Toolbar({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
return (
<div className={cn('sticky top-0 z-40 w-full border-b border-line-soft bg-surface', className)} {...props}>
<div className="px-2 sm:px-3 py-1 min-h-10 flex items-center gap-1.5 sm:gap-2">{children}</div>
</div>
);
}
export function ToolbarButton({
active = false,
className,
children,
type = 'button',
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
active?: boolean;
children: ReactNode;
}) {
return (
<button type={type} className={toolbarButtonStyles({ active: active ? 'true' : 'false', className })} {...props}>
{children}
</button>
);
}
export function ToolbarGroup({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
return (
<div className={cn('inline-flex shrink-0 items-center gap-0.5 rounded-md border border-line bg-surface p-0.5', className)} {...props}>
{children}
</div>
);
}
export function ToolbarSegment({
active = false,
className,
children,
type = 'button',
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
active?: boolean;
children: ReactNode;
}) {
return (
<button
type={type}
className={cn(
'inline-flex h-6 items-center justify-center rounded-sm text-xs transition-colors duration-base ease-standard',
active ? 'bg-surface-sunken text-accent' : 'text-soft hover:bg-accent-wash hover:text-accent',
className,
)}
{...props}
>
{children}
</button>
);
}

View file

@ -0,0 +1,25 @@
import { cn, type ClassValue } from './cn';
type VariantGroups = Record<string, Record<string, string>>;
type VariantSelection<T extends VariantGroups> = {
[K in keyof T]?: keyof T[K] | null | undefined;
};
export function variants<T extends VariantGroups>({
base,
variants: groups,
defaults,
}: {
base?: string;
variants: T;
defaults?: VariantSelection<T>;
}) {
return (selection: VariantSelection<T> & { className?: ClassValue } = {}) => {
const resolved = Object.keys(groups).map((key) => {
const groupKey = key as keyof T;
const value = selection[groupKey] ?? defaults?.[groupKey];
return value ? groups[groupKey][value as keyof T[typeof groupKey]] : '';
});
return cn(base, ...resolved, selection.className);
};
}