phase 5: split ui primitives into modules
This commit is contained in:
parent
4cb9331abb
commit
c56c3fa902
20 changed files with 784 additions and 413 deletions
|
|
@ -1,24 +1,24 @@
|
||||||
'use client';
|
'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
|
* Compatibility shim for older settings/admin imports.
|
||||||
* the app (settings modal, document settings, and admin panels).
|
* New UI work should import from `@/components/ui/*` or `@/components/ui`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CheckItem,
|
||||||
|
Divider,
|
||||||
|
Field,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
Panel,
|
||||||
|
Section,
|
||||||
|
Sidebar,
|
||||||
|
Surface,
|
||||||
|
Switch,
|
||||||
|
ToggleRow,
|
||||||
btnBase,
|
btnBase,
|
||||||
btnDanger,
|
btnDanger,
|
||||||
btnGhost,
|
btnGhost,
|
||||||
|
|
@ -26,364 +26,13 @@ export {
|
||||||
btnPrimary,
|
btnPrimary,
|
||||||
btnSecondary,
|
btnSecondary,
|
||||||
buttonClass,
|
buttonClass,
|
||||||
|
inputClass,
|
||||||
|
listboxButtonClass,
|
||||||
|
listboxOptionClass,
|
||||||
|
listboxOptionsClass,
|
||||||
|
segmentedButtonClass,
|
||||||
|
segmentedGroupClass,
|
||||||
type ButtonSize,
|
type ButtonSize,
|
||||||
type ButtonVariant,
|
type ButtonVariant,
|
||||||
};
|
type SwitchSize,
|
||||||
|
} from '@/components/ui';
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
35
src/components/ui/badge.tsx
Normal file
35
src/components/ui/badge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/components/ui/button.tsx
Normal file
77
src/components/ui/button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,38 +1,13 @@
|
||||||
export const btnBase =
|
export {
|
||||||
'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';
|
Button,
|
||||||
export const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent`;
|
btnBase,
|
||||||
export const btnSecondary = `${btnBase} bg-base text-foreground border border-offbase hover:bg-offbase`;
|
btnDanger,
|
||||||
export const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-base hover:text-accent`;
|
btnGhost,
|
||||||
export const btnDanger = `${btnBase} bg-danger text-background border border-danger hover:bg-danger`;
|
btnOutline,
|
||||||
export const btnGhost = `${btnBase} bg-transparent text-foreground hover:bg-base hover:text-accent`;
|
btnPrimary,
|
||||||
|
btnSecondary,
|
||||||
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
|
buttonClass,
|
||||||
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon';
|
buttonStyles,
|
||||||
|
type ButtonSize,
|
||||||
const BUTTON_VARIANT_CLASS: Record<ButtonVariant, string> = {
|
type ButtonVariant,
|
||||||
primary: btnPrimary,
|
} from './button';
|
||||||
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(' ');
|
|
||||||
}
|
|
||||||
|
|
|
||||||
5
src/components/ui/cn.ts
Normal file
5
src/components/ui/cn.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export type ClassValue = string | false | null | undefined;
|
||||||
|
|
||||||
|
export function cn(...classes: ClassValue[]) {
|
||||||
|
return classes.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
31
src/components/ui/dialog.tsx
Normal file
31
src/components/ui/dialog.tsx
Normal 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>;
|
||||||
|
}
|
||||||
6
src/components/ui/divider.tsx
Normal file
6
src/components/ui/divider.tsx
Normal 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
107
src/components/ui/field.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/ui/icon-button.tsx
Normal file
47
src/components/ui/icon-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/components/ui/index.ts
Normal file
17
src/components/ui/index.ts
Normal 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';
|
||||||
38
src/components/ui/input.tsx
Normal file
38
src/components/ui/input.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
21
src/components/ui/menu.tsx
Normal file
21
src/components/ui/menu.tsx
Normal 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');
|
||||||
|
}
|
||||||
53
src/components/ui/section.tsx
Normal file
53
src/components/ui/section.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/components/ui/select.tsx
Normal file
18
src/components/ui/select.tsx
Normal 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',
|
||||||
|
);
|
||||||
17
src/components/ui/sidebar.tsx
Normal file
17
src/components/ui/sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/components/ui/surface.tsx
Normal file
92
src/components/ui/surface.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/components/ui/switch.tsx
Normal file
72
src/components/ui/switch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/components/ui/tokens.ts
Normal file
3
src/components/ui/tokens.ts
Normal 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';
|
||||||
83
src/components/ui/toolbar.tsx
Normal file
83
src/components/ui/toolbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/ui/variants.ts
Normal file
25
src/components/ui/variants.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue