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';
|
||||
|
||||
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';
|
||||
|
|
|
|||
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 =
|
||||
'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
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