feat: add design system platform

This commit is contained in:
donghyeon-ka
2026-07-26 15:49:07 +09:00
parent e49d90f713
commit 13f28ef811
53 changed files with 3477 additions and 410 deletions
@@ -1,7 +1,9 @@
import { Link } from "react-router-dom";
import { AsyncSurface } from "../../../presentation/components/async-surface.jsx";
import { DetailPage } from "../../../presentation/templates/index.js";
import {
AsyncSurface,
DetailPage,
} from "../../../presentation/design-system/index.js";
import { useRouteInput } from "../../../presentation/routes/app-router.js";
import { useReferenceDetail } from "./use-reference-feature.js";
@@ -1,17 +1,17 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "../../../presentation/components/ui/button.jsx";
import {
Button,
DirtyNavigationDialog,
ErrorSummary,
Form,
FormActions,
FormPage,
FormField,
useAppForm,
useDirtyNavigationGuard,
} from "../../../presentation/forms/index.js";
import { FormPage } from "../../../presentation/templates/index.js";
} from "../../../presentation/design-system/index.js";
import {
REFERENCE_FORM_DEFAULTS,
referenceResourceFormSchema,
@@ -1,8 +1,10 @@
import { Link, useNavigate } from "react-router-dom";
import { AsyncSurface } from "../../../presentation/components/async-surface.jsx";
import { Button } from "../../../presentation/components/ui/button.jsx";
import { CollectionPage } from "../../../presentation/templates/index.js";
import {
AsyncSurface,
Button,
CollectionPage,
} from "../../../presentation/design-system/index.js";
import { useReferenceFeature } from "./use-reference-feature.js";
export default function ReferenceResourcePage() {
@@ -1,6 +1,6 @@
import { useNavigate } from "react-router-dom";
import { StatusPage } from "../../../presentation/templates/index.js";
import { StatusPage } from "../../../presentation/design-system/index.js";
export default function ReferenceResourceStatusPage() {
const navigate = useNavigate();
+1 -36
View File
@@ -1,36 +1 @@
/**
* @param {{
* title: string,
* children?: React.ReactNode,
* variant?: "info" | "success" | "warning" | "danger",
* onDismiss?: () => void
* }} props
*/
export function Alert({
title,
children,
variant = "info",
onDismiss,
}) {
return (
<section
className={`ui-alert ui-alert--${variant}`}
role={variant === "danger" ? "alert" : "status"}
>
<div>
<strong>{title}</strong>
{children ? <div className="ui-alert__content">{children}</div> : null}
</div>
{onDismiss ? (
<button
className="ui-alert__dismiss"
type="button"
aria-label={`${title} 알림 닫기`}
onClick={onDismiss}
>
×
</button>
) : null}
</section>
);
}
export { Alert } from "../../design-system/primitives/core.js";
+1 -9
View File
@@ -1,9 +1 @@
/**
* @param {{
* children: React.ReactNode,
* variant?: "neutral" | "info" | "success" | "warning" | "danger"
* }} props
*/
export function Badge({ children, variant = "neutral" }) {
return <span className={`ui-badge ui-badge--${variant}`}>{children}</span>;
}
export { Badge } from "../../design-system/primitives/core.js";
+1 -24
View File
@@ -1,24 +1 @@
/**
* @param {React.ButtonHTMLAttributes<HTMLButtonElement> & {
* variant?: "primary" | "secondary" | "danger" | "ghost",
* size?: "default" | "compact"
* }} props
*/
export function Button({
variant = "primary",
size = "default",
className = "",
type = "button",
...props
}) {
const classes = [
"ui-button",
`ui-button--${variant}`,
size === "compact" ? "ui-button--compact" : "",
className,
]
.filter(Boolean)
.join(" ");
return <button {...props} className={classes} type={type} />;
}
export { Button } from "../../design-system/primitives/core.js";
+1 -28
View File
@@ -1,28 +1 @@
import { useId } from "react";
/**
* @param {{
* title: string,
* description?: string,
* children?: React.ReactNode,
* footer?: React.ReactNode,
* className?: string
* }} props
*/
export function Card({ title, description, children, footer, className = "" }) {
const titleId = useId();
return (
<article
className={`ui-card ${className}`.trim()}
aria-labelledby={titleId}
>
<div className="ui-card__header">
<h3 id={titleId}>{title}</h3>
{description ? <p>{description}</p> : null}
</div>
{children ? <div className="ui-card__content">{children}</div> : null}
{footer ? <footer className="ui-card__footer">{footer}</footer> : null}
</article>
);
}
export { Card } from "../../design-system/primitives/core.js";
+1 -88
View File
@@ -1,88 +1 @@
import { useEffect, useId, useRef } from "react";
/**
* @param {{
* open: boolean,
* onClose: () => void,
* title: string,
* description?: string,
* children?: React.ReactNode,
* actions?: React.ReactNode
* }} props
*/
export function Dialog({
open,
onClose,
title,
description,
children,
actions,
}) {
const dialogRef = useRef(/** @type {HTMLDialogElement | null} */ (null));
const previousFocusRef = useRef(/** @type {HTMLElement | null} */ (null));
const titleId = useId();
const descriptionId = useId();
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
previousFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
if (!dialog.open) {
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
}
const firstFocusable = dialog.querySelector(
"[autofocus], button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
);
if (firstFocusable instanceof HTMLElement) firstFocusable.focus();
return;
}
if (dialog.open) {
if (typeof dialog.close === "function") dialog.close();
else dialog.removeAttribute("open");
}
previousFocusRef.current?.focus();
previousFocusRef.current = null;
}, [open]);
return (
<dialog
className="ui-dialog"
ref={dialogRef}
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div className="ui-dialog__surface">
<header className="ui-dialog__header">
<div>
<h2 id={titleId}>{title}</h2>
{description ? <p id={descriptionId}>{description}</p> : null}
</div>
<button
className="ui-dialog__close"
type="button"
aria-label={`${title} 닫기`}
onClick={onClose}
>
×
</button>
</header>
{children ? <div className="ui-dialog__content">{children}</div> : null}
{actions ? <footer className="ui-dialog__actions">{actions}</footer> : null}
</div>
</dialog>
);
}
export { Dialog } from "../../design-system/primitives/core.js";
+1 -52
View File
@@ -1,52 +1 @@
import { useId } from "react";
/**
* @param {Omit<React.InputHTMLAttributes<HTMLInputElement>, "id"> & {
* id?: string,
* label: string,
* description?: string,
* error?: string
* }} props
*/
export function TextField({
id,
label,
description,
error,
className = "",
required,
...inputProps
}) {
const generatedId = useId();
const inputId = id ?? `field-${generatedId}`;
const descriptionId = description ? `${inputId}-description` : undefined;
const errorId = error ? `${inputId}-error` : undefined;
const describedBy = [descriptionId, errorId].filter(Boolean).join(" ");
return (
<div className={`ui-field ${className}`.trim()}>
<label className="ui-field__label" htmlFor={inputId}>
{label}
{required ? <span aria-hidden="true"> *</span> : null}
</label>
{description ? (
<p className="ui-field__description" id={descriptionId}>
{description}
</p>
) : null}
<input
{...inputProps}
className="ui-field__input"
id={inputId}
required={required}
aria-describedby={describedBy || undefined}
aria-invalid={error ? "true" : undefined}
/>
{error ? (
<p className="ui-field__error" id={errorId}>
{error}
</p>
) : null}
</div>
);
}
export { TextField } from "../../design-system/primitives/core.js";
@@ -0,0 +1,47 @@
import type { ComponentType, SVGProps } from "react";
import {
CloseGlyph,
ErrorGlyph,
InfoGlyph,
MenuGlyph,
NextGlyph,
PreviousGlyph,
SearchGlyph,
SuccessGlyph,
WarningGlyph,
} from "./vendors/lucide.js";
export type SemanticIconProps = Readonly<{
label?: string;
size?: "small" | "medium" | "large";
}>;
function createSemanticIcon(
Glyph: ComponentType<SVGProps<SVGSVGElement>>,
) {
return function SemanticIcon({
label,
size = "medium",
}: SemanticIconProps) {
return (
<Glyph
aria-hidden={label ? undefined : "true"}
aria-label={label}
className={`ui-icon ui-icon--${size}`}
focusable="false"
role={label ? "img" : undefined}
/>
);
};
}
export const MenuIcon = createSemanticIcon(MenuGlyph);
export const CloseIcon = createSemanticIcon(CloseGlyph);
export const WarningIcon = createSemanticIcon(WarningGlyph);
export const SuccessIcon = createSemanticIcon(SuccessGlyph);
export const ErrorIcon = createSemanticIcon(ErrorGlyph);
export const InfoIcon = createSemanticIcon(InfoGlyph);
export const SearchIcon = createSemanticIcon(SearchGlyph);
export const PreviousIcon = createSemanticIcon(PreviousGlyph);
export const NextIcon = createSemanticIcon(NextGlyph);
+21
View File
@@ -0,0 +1,21 @@
import {
AlertTriangle,
Check,
ChevronLeft,
ChevronRight,
CircleX,
Info,
Menu,
Search,
X,
} from "lucide-react";
export const MenuGlyph = Menu;
export const CloseGlyph = X;
export const WarningGlyph = AlertTriangle;
export const SuccessGlyph = Check;
export const ErrorGlyph = CircleX;
export const InfoGlyph = Info;
export const SearchGlyph = Search;
export const PreviousGlyph = ChevronLeft;
export const NextGlyph = ChevronRight;
+146
View File
@@ -0,0 +1,146 @@
export {
Alert,
Badge,
Button,
Card,
Dialog,
Field,
FocusRing,
IconButton,
LinkButton,
Portal,
Spinner,
TextField,
VisuallyHidden,
} from "./primitives/core.js";
export type {
AlertProps,
BadgeProps,
ButtonProps,
ButtonSize,
ButtonVariant,
CardProps,
DialogProps,
IconButtonProps,
LinkButtonProps,
SpinnerProps,
TextFieldProps,
} from "./primitives/core.js";
export {
Checkbox,
RadioGroup,
SearchField,
Select,
Switch,
TextArea,
} from "./primitives/forms.js";
export type {
CheckboxProps,
RadioGroupProps,
RadioOption,
SearchFieldProps,
SelectOption,
SelectProps,
SwitchProps,
TextAreaProps,
} from "./primitives/forms.js";
export {
ProgressBar,
Separator,
Skeleton,
} from "./primitives/feedback.js";
export type {
ProgressBarProps,
SkeletonProps,
} from "./primitives/feedback.js";
export {
ConfirmationDialog,
Drawer,
Menu,
Popover,
ToastProvider,
Tooltip,
useToast,
} from "./primitives/overlays.js";
export type {
DrawerProps,
MenuItemDefinition,
MenuProps,
PopoverProps,
TooltipProps,
} from "./primitives/overlays.js";
export {
Breadcrumbs,
Pagination,
Tabs,
} from "./primitives/navigation.js";
export type {
BreadcrumbItem,
TabDefinition,
} from "./primitives/navigation.js";
export {
CloseIcon,
ErrorIcon,
InfoIcon,
MenuIcon,
NextIcon,
PreviousIcon,
SearchIcon,
SuccessIcon,
WarningIcon,
} from "./icons/semantic-icons.js";
export type {
SemanticIconProps,
} from "./icons/semantic-icons.js";
export {
DESIGN_TOKEN_CONTRACT,
REQUIRED_COMPONENT_TOKENS,
REQUIRED_PRIMITIVE_TOKENS,
REQUIRED_SEMANTIC_TOKENS,
} from "./tokens/token-contract.js";
export {
AccessSurface,
DataTable,
DisclosureGroup,
PaginationBar,
SearchFilterToolbar,
} from "./patterns/common-patterns.js";
export type {
AccessSurfaceProps,
DataTableColumn,
DisclosureDefinition,
} from "./patterns/common-patterns.js";
export {
AsyncSurface,
EmptySurface,
LoadingSurface,
TerminalErrorSurface,
} from "../components/async-surface.jsx";
export { PageHeader } from "../components/page-header.jsx";
export {
AuthRequiredSurface,
ForbiddenSurface,
NotFoundSurface,
} from "../components/state-surfaces.jsx";
export {
DetailPage,
CollectionPage,
FormPage,
StandardPage,
StatusPage,
} from "../templates/index.js";
export {
DirtyNavigationDialog,
ErrorSummary,
Form,
FormActions,
FormField,
useAppForm,
useDirtyNavigationGuard,
} from "../forms/index.js";
export type {
PageActionDefinition,
PageHeading,
} from "../templates/page-templates.js";
export type { FormResult } from "../forms/form-contracts.js";
@@ -0,0 +1,139 @@
import { useId } from "react";
import {
AuthRequiredSurface,
ForbiddenSurface,
NotFoundSurface,
} from "../../components/state-surfaces.jsx";
import { Button } from "../primitives/core.js";
import { Pagination } from "../primitives/navigation.js";
export type DataTableColumn<Row> = Readonly<{
id: string;
header: string;
cell(row: Row): React.ReactNode;
}>;
export function DataTable<Row>({
caption,
columns,
rows,
rowKey,
empty,
}: Readonly<{
caption: string;
columns: readonly DataTableColumn<Row>[];
rows: readonly Row[];
rowKey(row: Row): string;
empty: React.ReactNode;
}>) {
if (rows.length === 0) return <>{empty}</>;
return (
<div className="ui-data-table__scroll" tabIndex={0}>
<table className="ui-data-table">
<caption>{caption}</caption>
<thead>
<tr>
{columns.map((column) => (
<th key={column.id} scope="col">
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={rowKey(row)}>
{columns.map((column) => (
<td key={column.id}>{column.cell(row)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export function SearchFilterToolbar({
label,
search,
filters,
resetLabel,
onReset,
resultCount,
}: Readonly<{
label: string;
search: React.ReactNode;
filters?: React.ReactNode;
resetLabel: string;
onReset(): void;
resultCount: React.ReactNode;
}>) {
return (
<section aria-label={label} className="ui-search-filter-toolbar">
<div>{search}</div>
{filters ? <div>{filters}</div> : null}
<Button onClick={onReset} variant="ghost">
{resetLabel}
</Button>
<output>{resultCount}</output>
</section>
);
}
export function PaginationBar({
range,
...pagination
}: React.ComponentProps<typeof Pagination> & Readonly<{ range: string }>) {
return (
<div className="ui-pagination-bar">
<p>{range}</p>
<Pagination {...pagination} />
</div>
);
}
export type DisclosureDefinition = Readonly<{
id: string;
title: string;
content: React.ReactNode;
}>;
export function DisclosureGroup({
label,
items,
}: Readonly<{
label: string;
items: readonly DisclosureDefinition[];
}>) {
const groupId = useId();
return (
<section aria-labelledby={groupId} className="ui-disclosure-group">
<h2 className="visually-hidden" id={groupId}>
{label}
</h2>
{items.map((item) => (
<details key={item.id}>
<summary>{item.title}</summary>
<div>{item.content}</div>
</details>
))}
</section>
);
}
export type AccessSurfaceProps =
| Readonly<{ kind: "auth-required"; onAction(): void }>
| Readonly<{ kind: "forbidden"; onAction(): void }>
| Readonly<{ kind: "not-found"; onAction(): void }>;
export function AccessSurface(props: AccessSurfaceProps) {
if (props.kind === "auth-required") {
return <AuthRequiredSurface onSignIn={props.onAction} />;
}
if (props.kind === "forbidden") {
return <ForbiddenSurface onNavigate={props.onAction} />;
}
return <NotFoundSurface onNavigate={props.onAction} />;
}
@@ -0,0 +1,446 @@
import {
forwardRef,
useEffect,
useId,
useImperativeHandle,
useRef,
} from "react";
import { createPortal } from "react-dom";
import { CloseIcon } from "../icons/semantic-icons.js";
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
export type ButtonSize = "default" | "compact";
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
Readonly<{
variant?: ButtonVariant;
size?: ButtonSize;
pending?: boolean;
pendingLabel?: string;
}>;
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "default",
pending = false,
pendingLabel,
className = "",
type = "button",
children,
disabled,
...props
},
ref,
) {
const classes = [
"ui-button",
`ui-button--${variant}`,
size === "compact" ? "ui-button--compact" : "",
pending ? "ui-button--pending" : "",
className,
]
.filter(Boolean)
.join(" ");
return (
<button
{...props}
aria-busy={pending || undefined}
className={classes}
disabled={disabled}
ref={ref}
type={type}
>
{pending ? (
<>
<Spinner decorative />
{pendingLabel ?? children}
</>
) : (
children
)}
</button>
);
},
);
export type LinkButtonProps = React.AnchorHTMLAttributes<HTMLAnchorElement> &
Readonly<{
href: string;
variant?: ButtonVariant;
size?: ButtonSize;
}>;
export const LinkButton = forwardRef<HTMLAnchorElement, LinkButtonProps>(
function LinkButton(
{
href,
variant = "primary",
size = "default",
className = "",
...props
},
ref,
) {
return (
<a
{...props}
className={[
"ui-button",
`ui-button--${variant}`,
size === "compact" ? "ui-button--compact" : "",
className,
]
.filter(Boolean)
.join(" ")}
href={href}
ref={ref}
/>
);
},
);
export type IconButtonProps = Omit<ButtonProps, "aria-label" | "children"> &
Readonly<{
accessibleName: string;
children: React.ReactElement;
}>;
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
function IconButton({ accessibleName, className = "", ...props }, ref) {
return (
<Button
{...props}
aria-label={accessibleName}
className={`ui-icon-button ${className}`.trim()}
ref={ref}
/>
);
},
);
type FieldFrameProps = Readonly<{
id?: string;
label: string;
description?: string;
error?: string;
required?: boolean;
className?: string;
children(
contract: Readonly<{
controlId: string;
describedBy: string | undefined;
invalid: boolean;
}>,
): React.ReactNode;
}>;
export function Field({
id,
label,
description,
error,
required,
className = "",
children,
}: FieldFrameProps) {
const generatedId = useId();
const controlId = id ?? `field-${generatedId}`;
const descriptionId = description ? `${controlId}-description` : undefined;
const errorId = error ? `${controlId}-error` : undefined;
const describedBy = [descriptionId, errorId].filter(Boolean).join(" ");
return (
<div className={`ui-field ${className}`.trim()}>
<label className="ui-field__label" htmlFor={controlId}>
{label}
{required ? <span aria-hidden="true"> *</span> : null}
</label>
{description ? (
<p className="ui-field__description" id={descriptionId}>
{description}
</p>
) : null}
{children({
controlId,
describedBy: describedBy || undefined,
invalid: Boolean(error),
})}
{error ? (
<p className="ui-field__error" id={errorId}>
{error}
</p>
) : null}
</div>
);
}
export type TextFieldProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"id"
> &
Readonly<{
id?: string;
label: string;
description?: string;
error?: string;
}>;
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
function TextField(
{
id,
label,
description,
error,
className = "",
required,
...inputProps
},
ref,
) {
return (
<Field
className={className}
description={description}
error={error}
id={id}
label={label}
required={required}
>
{({ controlId, describedBy, invalid }) => (
<input
{...inputProps}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
className="ui-field__input"
id={controlId}
ref={ref}
required={required}
/>
)}
</Field>
);
},
);
export type CardProps = Readonly<{
title: string;
headingLevel?: 2 | 3 | 4;
description?: string;
children?: React.ReactNode;
footer?: React.ReactNode;
className?: string;
}>;
export function Card({
title,
headingLevel = 3,
description,
children,
footer,
className = "",
}: CardProps) {
const titleId = useId();
const Heading = `h${headingLevel}` as "h2" | "h3" | "h4";
return (
<article
aria-labelledby={titleId}
className={`ui-card ${className}`.trim()}
>
<div className="ui-card__header">
<Heading id={titleId}>{title}</Heading>
{description ? <p>{description}</p> : null}
</div>
{children ? <div className="ui-card__content">{children}</div> : null}
{footer ? <footer className="ui-card__footer">{footer}</footer> : null}
</article>
);
}
export type AlertProps = Readonly<{
title: string;
children?: React.ReactNode;
variant?: "info" | "success" | "warning" | "danger";
dismissLabel?: string;
onDismiss?: () => void;
}>;
export function Alert({
title,
children,
variant = "info",
dismissLabel,
onDismiss,
}: AlertProps) {
return (
<section
className={`ui-alert ui-alert--${variant}`}
role={variant === "danger" ? "alert" : "status"}
>
<div>
<strong>{title}</strong>
{children ? <div className="ui-alert__content">{children}</div> : null}
</div>
{onDismiss ? (
<IconButton
accessibleName={dismissLabel ?? `${title} 알림 닫기`}
className="ui-alert__dismiss"
onClick={onDismiss}
variant="ghost"
>
<CloseIcon />
</IconButton>
) : null}
</section>
);
}
export type BadgeProps = Readonly<{
children: React.ReactNode;
variant?: "neutral" | "info" | "success" | "warning" | "danger";
}>;
export function Badge({ children, variant = "neutral" }: BadgeProps) {
return <span className={`ui-badge ui-badge--${variant}`}>{children}</span>;
}
export type DialogProps = Readonly<{
open: boolean;
onClose(): void;
title: string;
description?: string;
closeLabel?: string;
children?: React.ReactNode;
actions?: React.ReactNode;
className?: string;
}>;
export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
function Dialog(
{
open,
onClose,
title,
description,
closeLabel,
children,
actions,
className = "",
},
forwardedRef,
) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const descriptionId = useId();
useImperativeHandle(forwardedRef, () => dialogRef.current!, []);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
previousFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
if (!dialog.open) {
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
}
const firstFocusable = dialog.querySelector<HTMLElement>(
"[autofocus], button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
);
firstFocusable?.focus();
return;
}
if (dialog.open) {
if (typeof dialog.close === "function") dialog.close();
else dialog.removeAttribute("open");
}
previousFocusRef.current?.focus();
previousFocusRef.current = null;
}, [open]);
return (
<dialog
aria-describedby={description ? descriptionId : undefined}
aria-labelledby={titleId}
className={`ui-dialog ${className}`.trim()}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
}}
ref={dialogRef}
>
<div className="ui-dialog__surface">
<header className="ui-dialog__header">
<div>
<h2 id={titleId}>{title}</h2>
{description ? <p id={descriptionId}>{description}</p> : null}
</div>
<IconButton
accessibleName={closeLabel ?? `${title} 닫기`}
className="ui-dialog__close"
onClick={onClose}
variant="ghost"
>
<CloseIcon />
</IconButton>
</header>
{children ? (
<div className="ui-dialog__content">{children}</div>
) : null}
{actions ? (
<footer className="ui-dialog__actions">{actions}</footer>
) : null}
</div>
</dialog>
);
},
);
export type SpinnerProps =
| Readonly<{ decorative: true; label?: never }>
| Readonly<{ decorative?: false; label: string }>;
export function Spinner(props: SpinnerProps) {
const accessibility = props.decorative
? { "aria-hidden": true as const }
: { "aria-label": props.label, role: "status" };
return <span {...accessibility} className="ui-spinner" />;
}
export function VisuallyHidden({
children,
}: Readonly<{ children: React.ReactNode }>) {
return <span className="visually-hidden">{children}</span>;
}
export function Portal({
children,
}: Readonly<{ children: React.ReactNode }>) {
if (typeof document === "undefined") return <>{children}</>;
return createPortal(children, document.body);
}
export function FocusRing({
children,
}: Readonly<{ children: React.ReactNode }>) {
return <span className="ui-focus-ring">{children}</span>;
}
@@ -0,0 +1,55 @@
export type ProgressBarProps = Readonly<{
label: string;
value?: number;
max?: number;
}>;
export function ProgressBar({ label, value, max = 100 }: ProgressBarProps) {
const determinate = typeof value === "number";
return (
<div className="ui-progress">
<div className="ui-progress__label">
<span>{label}</span>
{determinate ? <span>{Math.round((value / max) * 100)}%</span> : null}
</div>
<progress
aria-label={label}
className="ui-progress__bar"
max={max}
value={determinate ? Math.min(Math.max(value, 0), max) : undefined}
/>
</div>
);
}
export type SkeletonProps = Readonly<{
label?: string;
height?: "text" | "control" | "surface";
}>;
export function Skeleton({
label,
height = "surface",
}: SkeletonProps) {
return (
<span
aria-label={label}
aria-hidden={label ? undefined : true}
className={`ui-skeleton ui-skeleton--${height}`}
role={label ? "status" : undefined}
/>
);
}
export function Separator({
label,
decorative = true,
}: Readonly<{ label?: string; decorative?: boolean }>) {
return (
<hr
aria-label={decorative ? undefined : label}
aria-hidden={decorative || undefined}
className="ui-separator"
/>
);
}
@@ -0,0 +1,409 @@
import {
forwardRef,
useEffect,
useId,
useRef,
useState,
} from "react";
import { CloseIcon, SearchIcon } from "../icons/semantic-icons.js";
import { Field, IconButton } from "./core.js";
import type { TextFieldProps } from "./core.js";
type FieldCopy = Readonly<{
id?: string;
label: string;
description?: string;
error?: string;
}>;
export type TextAreaProps = Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
"id"
> &
FieldCopy &
Readonly<{ maxLengthMessage?: (remaining: number) => string }>;
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
function TextArea(
{
id,
label,
description,
error,
required,
className = "",
maxLength,
maxLengthMessage,
value,
defaultValue,
...props
},
ref,
) {
const [uncontrolledValue, setUncontrolledValue] = useState(
String(defaultValue ?? ""),
);
const currentValue =
value === undefined ? uncontrolledValue : String(value ?? "");
const remaining =
typeof maxLength === "number" ? maxLength - currentValue.length : null;
return (
<Field
className={className}
description={description}
error={error}
id={id}
label={label}
required={required}
>
{({ controlId, describedBy, invalid }) => (
<>
<textarea
{...props}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
className="ui-field__input ui-field__textarea"
defaultValue={value === undefined ? defaultValue : undefined}
id={controlId}
maxLength={maxLength}
onChange={(event) => {
if (value === undefined) setUncontrolledValue(event.currentTarget.value);
props.onChange?.(event);
}}
ref={ref}
required={required}
value={value}
/>
{remaining !== null ? (
<output className="ui-field__counter">
{maxLengthMessage
? maxLengthMessage(remaining)
: `${remaining}자 남음`}
</output>
) : null}
</>
)}
</Field>
);
},
);
export type SelectOption = Readonly<{
value: string;
label: string;
disabled?: boolean;
}>;
export type SelectProps = Omit<
React.SelectHTMLAttributes<HTMLSelectElement>,
"id" | "children"
> &
FieldCopy &
Readonly<{
options: readonly SelectOption[];
placeholder?: string;
}>;
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
function Select(
{
id,
label,
description,
error,
options,
placeholder,
required,
className = "",
...props
},
ref,
) {
return (
<Field
className={className}
description={description}
error={error}
id={id}
label={label}
required={required}
>
{({ controlId, describedBy, invalid }) => (
<select
{...props}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
className="ui-field__input ui-field__select"
id={controlId}
ref={ref}
required={required}
>
{placeholder ? (
<option disabled value="">
{placeholder}
</option>
) : null}
{options.map((option) => (
<option
disabled={option.disabled}
key={option.value}
value={option.value}
>
{option.label}
</option>
))}
</select>
)}
</Field>
);
},
);
export type CheckboxProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"type"
> &
Readonly<{
label: string;
description?: string;
error?: string;
indeterminate?: boolean;
}>;
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
function Checkbox(
{ label, description, error, indeterminate = false, id, ...props },
forwardedRef,
) {
const generatedId = useId();
const controlId = id ?? `checkbox-${generatedId}`;
const inputRef = useRef<HTMLInputElement | null>(null);
const descriptionId = description ? `${controlId}-description` : undefined;
const errorId = error ? `${controlId}-error` : undefined;
useEffect(() => {
if (inputRef.current) inputRef.current.indeterminate = indeterminate;
}, [indeterminate]);
return (
<div className="ui-choice-field">
<label className="ui-choice-field__control" htmlFor={controlId}>
<input
{...props}
aria-describedby={
[descriptionId, errorId].filter(Boolean).join(" ") || undefined
}
aria-invalid={error ? true : undefined}
id={controlId}
ref={(node) => {
inputRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
}}
type="checkbox"
/>
<span>{label}</span>
</label>
{description ? (
<p className="ui-field__description" id={descriptionId}>
{description}
</p>
) : null}
{error ? (
<p className="ui-field__error" id={errorId}>
{error}
</p>
) : null}
</div>
);
},
);
export type RadioOption = Readonly<{
value: string;
label: string;
description?: string;
disabled?: boolean;
}>;
export type RadioGroupProps = Readonly<{
name: string;
label: string;
options: readonly RadioOption[];
value?: string;
defaultValue?: string;
onChange?(value: string): void;
disabled?: boolean;
error?: string;
}>;
export function RadioGroup({
name,
label,
options,
value,
defaultValue,
onChange,
disabled,
error,
}: RadioGroupProps) {
const [internalValue, setInternalValue] = useState(defaultValue ?? "");
const selected = value ?? internalValue;
const errorId = useId();
const refs = useRef<Array<HTMLInputElement | null>>([]);
function choose(nextValue: string) {
if (value === undefined) setInternalValue(nextValue);
onChange?.(nextValue);
}
return (
<fieldset
aria-describedby={error ? errorId : undefined}
className="ui-radio-group"
disabled={disabled}
>
<legend>{label}</legend>
{options.map((option, index) => (
<label className="ui-choice-field__control" key={option.value}>
<input
checked={selected === option.value}
disabled={option.disabled}
name={name}
onChange={() => choose(option.value)}
onKeyDown={(event) => {
if (!["ArrowDown", "ArrowRight", "ArrowUp", "ArrowLeft"].includes(event.key)) {
return;
}
event.preventDefault();
const direction =
event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : -1;
let next = index;
do {
next = (next + direction + options.length) % options.length;
} while (options[next]?.disabled && next !== index);
const nextOption = options[next];
if (nextOption && !nextOption.disabled) {
choose(nextOption.value);
refs.current[next]?.focus();
}
}}
ref={(node) => {
refs.current[index] = node;
}}
type="radio"
value={option.value}
/>
<span>
{option.label}
{option.description ? <small>{option.description}</small> : null}
</span>
</label>
))}
{error ? (
<p className="ui-field__error" id={errorId}>
{error}
</p>
) : null}
</fieldset>
);
}
export type SwitchProps = Readonly<{
label: string;
checked: boolean;
onChange(checked: boolean): void;
disabled?: boolean;
description?: string;
}>;
export function Switch({
label,
checked,
onChange,
disabled,
description,
}: SwitchProps) {
const descriptionId = useId();
return (
<div className="ui-switch-field">
<button
aria-checked={checked}
aria-describedby={description ? descriptionId : undefined}
className="ui-switch"
disabled={disabled}
onClick={() => onChange(!checked)}
role="switch"
type="button"
>
<span aria-hidden="true" className="ui-switch__thumb" />
<span>{label}</span>
</button>
{description ? (
<p className="ui-field__description" id={descriptionId}>
{description}
</p>
) : null}
</div>
);
}
export type SearchFieldProps = Omit<TextFieldProps, "type"> &
Readonly<{
clearLabel: string;
onClear(): void;
}>;
export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(
function SearchField(
{
clearLabel,
onClear,
value,
className = "",
id,
label,
description,
error,
required,
...inputProps
},
ref,
) {
return (
<div className={`ui-search-field ${className}`.trim()}>
<SearchIcon />
<Field
description={description}
error={error}
id={id}
label={label}
required={required}
>
{({ controlId, describedBy, invalid }) => (
<input
{...inputProps}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
className="ui-field__input"
id={controlId}
ref={ref}
type="search"
value={value}
/>
)}
</Field>
{String(value ?? "").length > 0 ? (
<IconButton
accessibleName={clearLabel}
onClick={onClear}
variant="ghost"
>
<CloseIcon />
</IconButton>
) : null}
</div>
);
},
);
@@ -0,0 +1,193 @@
import { useId, useRef, useState } from "react";
import { NextIcon, PreviousIcon } from "../icons/semantic-icons.js";
import { IconButton, LinkButton } from "./core.js";
export type BreadcrumbItem = Readonly<{
label: string;
href?: string;
}>;
export function Breadcrumbs({
label,
items,
}: Readonly<{ label: string; items: readonly BreadcrumbItem[] }>) {
return (
<nav aria-label={label} className="ui-breadcrumbs">
<ol>
{items.map((item, index) => {
const current = index === items.length - 1;
return (
<li key={`${item.label}-${index}`}>
{item.href && !current ? (
<a href={item.href}>{item.label}</a>
) : (
<span aria-current={current ? "page" : undefined}>
{item.label}
</span>
)}
</li>
);
})}
</ol>
</nav>
);
}
export type TabDefinition = Readonly<{
id: string;
label: string;
panel: React.ReactNode;
disabled?: boolean;
}>;
export function Tabs({
label,
tabs,
value,
defaultValue,
activation = "automatic",
onChange,
}: Readonly<{
label: string;
tabs: readonly TabDefinition[];
value?: string;
defaultValue?: string;
activation?: "automatic" | "manual";
onChange?(id: string): void;
}>) {
const fallback = tabs.find((tab) => !tab.disabled)?.id ?? "";
const [internalValue, setInternalValue] = useState(defaultValue ?? fallback);
const [focusValue, setFocusValue] = useState(value ?? internalValue);
const selected = value ?? internalValue;
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const baseId = useId();
function select(id: string) {
if (value === undefined) setInternalValue(id);
onChange?.(id);
}
function move(currentIndex: number, direction: 1 | -1) {
let next = currentIndex;
do {
next = (next + direction + tabs.length) % tabs.length;
} while (tabs[next]?.disabled && next !== currentIndex);
const nextTab = tabs[next];
if (!nextTab || nextTab.disabled) return;
setFocusValue(nextTab.id);
if (activation === "automatic") select(nextTab.id);
tabRefs.current[next]?.focus();
}
return (
<div className="ui-tabs">
<div aria-label={label} className="ui-tabs__list" role="tablist">
{tabs.map((tab, index) => (
<button
aria-controls={`${baseId}-${tab.id}-panel`}
aria-selected={selected === tab.id}
className="ui-tabs__tab"
disabled={tab.disabled}
id={`${baseId}-${tab.id}-tab`}
key={tab.id}
onClick={() => {
setFocusValue(tab.id);
select(tab.id);
}}
onKeyDown={(event) => {
if (event.key === "ArrowRight") {
event.preventDefault();
move(index, 1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
move(index, -1);
} else if (
activation === "manual" &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
select(tab.id);
}
}}
ref={(node) => {
tabRefs.current[index] = node;
}}
role="tab"
tabIndex={focusValue === tab.id ? 0 : -1}
type="button"
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab) => (
<div
aria-labelledby={`${baseId}-${tab.id}-tab`}
className="ui-tabs__panel"
hidden={selected !== tab.id}
id={`${baseId}-${tab.id}-panel`}
key={tab.id}
role="tabpanel"
tabIndex={0}
>
{tab.panel}
</div>
))}
</div>
);
}
export function Pagination({
label,
page,
pageCount,
previousLabel,
nextLabel,
pageLabel,
onChange,
}: Readonly<{
label: string;
page: number;
pageCount: number;
previousLabel: string;
nextLabel: string;
pageLabel(page: number): string;
onChange(page: number): void;
}>) {
const pages = Array.from({ length: pageCount }, (_, index) => index + 1);
return (
<nav aria-label={label} className="ui-pagination">
<IconButton
accessibleName={previousLabel}
disabled={page <= 1}
onClick={() => onChange(page - 1)}
variant="secondary"
>
<PreviousIcon />
</IconButton>
{pages.map((candidate) => (
<LinkButton
aria-current={candidate === page ? "page" : undefined}
href={`?page=${candidate}`}
key={candidate}
onClick={(event) => {
event.preventDefault();
onChange(candidate);
}}
variant={candidate === page ? "primary" : "ghost"}
>
{pageLabel(candidate)}
</LinkButton>
))}
<IconButton
accessibleName={nextLabel}
disabled={page >= pageCount}
onClick={() => onChange(page + 1)}
variant="secondary"
>
<NextIcon />
</IconButton>
</nav>
);
}
@@ -0,0 +1,441 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { CloseIcon } from "../icons/semantic-icons.js";
import { Button, Dialog, IconButton } from "./core.js";
export type DrawerProps = Readonly<{
open: boolean;
onClose(): void;
title: string;
closeLabel?: string;
placement?: "start" | "end";
children: React.ReactNode;
}>;
export function Drawer({
open,
onClose,
title,
closeLabel,
placement = "start",
children,
}: DrawerProps) {
return (
<Dialog
className={`ui-drawer ui-drawer--${placement}`}
closeLabel={closeLabel}
onClose={onClose}
open={open}
title={title}
>
{children}
</Dialog>
);
}
export type PopoverProps = Readonly<{
triggerLabel: string;
children: React.ReactNode;
}>;
export function Popover({ triggerLabel, children }: PopoverProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const contentId = useId();
useEffect(() => {
if (!open) return;
function closeOutside(event: PointerEvent) {
if (
event.target instanceof Node &&
!rootRef.current?.contains(event.target)
) {
setOpen(false);
triggerRef.current?.focus();
}
}
function closeOnEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
}
}
document.addEventListener("pointerdown", closeOutside);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("pointerdown", closeOutside);
document.removeEventListener("keydown", closeOnEscape);
};
}, [open]);
return (
<div className="ui-popover" ref={rootRef}>
<Button
aria-controls={contentId}
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
ref={triggerRef}
variant="secondary"
>
{triggerLabel}
</Button>
{open ? (
<div className="ui-popover__content" id={contentId} role="dialog">
{children}
</div>
) : null}
</div>
);
}
export type TooltipProps = Readonly<{
content: string;
children: React.ReactElement;
}>;
export function Tooltip({ content, children }: TooltipProps) {
const tooltipId = useId();
return (
<span className="ui-tooltip">
<span aria-describedby={tooltipId} className="ui-tooltip__trigger">
{children}
</span>
<span className="ui-tooltip__content" id={tooltipId} role="tooltip">
{content}
</span>
</span>
);
}
export type MenuItemDefinition = Readonly<{
id: string;
label: string;
disabled?: boolean;
onSelect(): void;
}>;
export type MenuProps = Readonly<{
triggerLabel: string;
items: readonly MenuItemDefinition[];
}>;
export function Menu({ triggerLabel, items }: MenuProps) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const searchRef = useRef("");
const resetSearchRef = useRef<number | undefined>(undefined);
const enabledIndexes = useMemo(
() => items.flatMap((item, index) => (item.disabled ? [] : [index])),
[items],
);
const focusIndex = useCallback(
(index: number) => {
setActiveIndex(index);
queueMicrotask(() => itemRefs.current[index]?.focus());
},
[],
);
function openAt(index: number) {
setOpen(true);
focusIndex(index);
}
function dismiss() {
setOpen(false);
queueMicrotask(() => triggerRef.current?.focus());
}
function move(direction: 1 | -1) {
const currentPosition = Math.max(enabledIndexes.indexOf(activeIndex), 0);
const nextPosition =
(currentPosition + direction + enabledIndexes.length) %
enabledIndexes.length;
const nextIndex = enabledIndexes[nextPosition];
if (nextIndex !== undefined) focusIndex(nextIndex);
}
useEffect(() => {
if (!open) return;
function closeOutside(event: PointerEvent) {
if (
event.target instanceof Node &&
!rootRef.current?.contains(event.target)
) {
setOpen(false);
queueMicrotask(() => triggerRef.current?.focus());
}
}
document.addEventListener("pointerdown", closeOutside);
return () => document.removeEventListener("pointerdown", closeOutside);
}, [open]);
return (
<div className="ui-menu" ref={rootRef}>
<Button
aria-expanded={open}
aria-haspopup="menu"
onClick={() => {
if (open) dismiss();
else openAt(enabledIndexes[0] ?? 0);
}}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
openAt(enabledIndexes[0] ?? 0);
}
if (event.key === "ArrowUp") {
event.preventDefault();
openAt(enabledIndexes.at(-1) ?? 0);
}
}}
ref={triggerRef}
variant="secondary"
>
{triggerLabel}
</Button>
{open ? (
<div
className="ui-menu__content"
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
dismiss();
} else if (event.key === "ArrowDown") {
event.preventDefault();
move(1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
move(-1);
} else if (event.key === "Home") {
event.preventDefault();
focusIndex(enabledIndexes[0] ?? 0);
} else if (event.key === "End") {
event.preventDefault();
focusIndex(enabledIndexes.at(-1) ?? 0);
} else if (
event.key.length === 1 &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
) {
searchRef.current += event.key.toLocaleLowerCase();
window.clearTimeout(resetSearchRef.current);
resetSearchRef.current = window.setTimeout(() => {
searchRef.current = "";
}, 500);
const match = items.findIndex(
(item) =>
!item.disabled &&
item.label
.toLocaleLowerCase()
.startsWith(searchRef.current),
);
if (match >= 0) focusIndex(match);
}
}}
role="menu"
>
{items.map((item, index) => (
<button
className="ui-menu__item"
disabled={item.disabled}
key={item.id}
onClick={() => {
item.onSelect();
dismiss();
}}
ref={(node) => {
itemRefs.current[index] = node;
}}
role="menuitem"
tabIndex={index === activeIndex ? 0 : -1}
type="button"
>
{item.label}
</button>
))}
</div>
) : null}
</div>
);
}
type ToastTone = "info" | "success" | "warning" | "danger";
type ToastInput = Readonly<{
id: string;
title: string;
description?: string;
tone?: ToastTone;
durationMs?: number;
}>;
type ToastEntry = ToastInput & Readonly<{ count: number }>;
type ToastContextValue = Readonly<{
push(toast: ToastInput): void;
dismiss(id: string): void;
}>;
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({
children,
limit = 3,
}: Readonly<{ children: React.ReactNode; limit?: number }>) {
const [toasts, setToasts] = useState<readonly ToastEntry[]>([]);
const dismiss = useCallback((id: string) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const push = useCallback(
(toast: ToastInput) => {
setToasts((current) => {
const existing = current.find((entry) => entry.id === toast.id);
if (existing) {
return current.map((entry) =>
entry.id === toast.id
? { ...entry, ...toast, count: entry.count + 1 }
: entry,
);
}
return [...current, { ...toast, count: 1 }].slice(-limit);
});
},
[limit],
);
const value = useMemo(() => ({ push, dismiss }), [dismiss, push]);
return (
<ToastContext.Provider value={value}>
{children}
<ToastRegion dismiss={dismiss} toasts={toasts} />
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error("ToastProvider is required.");
}
return context;
}
function ToastRegion({
toasts,
dismiss,
}: Readonly<{
toasts: readonly ToastEntry[];
dismiss(id: string): void;
}>) {
return (
<section
aria-label="Notifications"
aria-live="polite"
className="ui-toast-region"
>
{toasts.map((toast) => (
<ToastItem dismiss={dismiss} key={toast.id} toast={toast} />
))}
</section>
);
}
function ToastItem({
toast,
dismiss,
}: Readonly<{
toast: ToastEntry;
dismiss(id: string): void;
}>) {
const [paused, setPaused] = useState(false);
useEffect(() => {
if (paused) return;
const timeout = window.setTimeout(
() => dismiss(toast.id),
toast.durationMs ?? 5000,
);
return () => window.clearTimeout(timeout);
}, [dismiss, paused, toast.durationMs, toast.id, toast.count]);
return (
<article
className={`ui-toast ui-toast--${toast.tone ?? "info"}`}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setPaused(false);
}}
onFocus={() => setPaused(true)}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div>
<strong>{toast.title}</strong>
{toast.count > 1 ? (
<span className="ui-toast__count"> ×{toast.count}</span>
) : null}
{toast.description ? <p>{toast.description}</p> : null}
</div>
<IconButton
accessibleName={`${toast.title} 닫기`}
onClick={() => dismiss(toast.id)}
variant="ghost"
>
<CloseIcon />
</IconButton>
</article>
);
}
export function ConfirmationDialog({
open,
title,
description,
confirmLabel,
cancelLabel,
danger = false,
onConfirm,
onCancel,
}: Readonly<{
open: boolean;
title: string;
description: string;
confirmLabel: string;
cancelLabel: string;
danger?: boolean;
onConfirm(): void;
onCancel(): void;
}>) {
return (
<Dialog
actions={
<>
<Button onClick={onCancel} variant="secondary">
{cancelLabel}
</Button>
<Button onClick={onConfirm} variant={danger ? "danger" : "primary"}>
{confirmLabel}
</Button>
</>
}
description={description}
onClose={onCancel}
open={open}
title={title}
/>
);
}
@@ -0,0 +1,26 @@
@theme {
--button-primary-background: var(--color-action);
--button-primary-content: var(--color-on-action);
--button-primary-hover: var(--color-action-hover);
--field-border: var(--color-border-strong);
--field-border-invalid: var(--color-danger);
--dialog-elevation: var(--elevation-dialog);
--navigation-active-background: color-mix(
in oklch,
var(--color-action) 14%,
var(--color-panel)
);
--drawer-width: min(20rem, 88vw);
--toast-width: min(24rem, calc(100vw - 2rem));
}
@media (forced-colors: active) {
:root {
--color-border: CanvasText;
--color-border-strong: CanvasText;
--color-focus: Highlight;
--color-action: LinkText;
--color-danger: MarkText;
--color-danger-surface: Mark;
}
}
@@ -0,0 +1,63 @@
@theme {
--palette-white: oklch(1 0 0);
--palette-slate-50: oklch(0.985 0.003 247);
--palette-slate-100: oklch(0.94 0.01 247);
--palette-slate-300: oklch(0.87 0.015 247);
--palette-slate-500: oklch(0.48 0.025 247);
--palette-slate-800: oklch(0.25 0.025 247);
--palette-slate-950: oklch(0.16 0.02 255);
--palette-blue-500: oklch(0.7 0.14 250);
--palette-blue-600: oklch(0.55 0.18 255);
--palette-blue-700: oklch(0.48 0.2 255);
--palette-red-500: oklch(0.68 0.19 25);
--palette-red-600: oklch(0.55 0.2 25);
--palette-red-700: oklch(0.47 0.2 25);
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-md: 1rem;
--font-size-lg: 1.125rem;
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.7;
--font-weight-regular: 400;
--font-weight-semibold: 650;
--font-weight-bold: 750;
--radius-xs: 0.25rem;
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-full: 999px;
--shadow-panel: 0 8px 28px rgb(15 23 42 / 8%);
--shadow-popover: 0 14px 40px rgb(15 23 42 / 16%);
--shadow-dialog: 0 24px 70px rgb(15 23 42 / 25%);
--duration-fast: 100ms;
--duration-normal: 180ms;
--duration-slow: 300ms;
--easing-standard: cubic-bezier(0.2, 0, 0, 1);
--easing-emphasized: cubic-bezier(0.2, 0, 0, 1.2);
--size-control-sm: 2.25rem;
--size-control-md: 2.75rem;
--size-touch-target: 2.75rem;
--size-icon-sm: 1rem;
--size-icon-md: 1.25rem;
--size-icon-lg: 1.5rem;
--icon-stroke-default: 2;
--breakpoint-compact: 48rem;
--breakpoint-medium: 64rem;
--breakpoint-wide: 80rem;
--container-content: 72rem;
}
@@ -0,0 +1,83 @@
@theme {
--color-surface: var(--palette-slate-50);
--color-surface-muted: var(--palette-slate-100);
--color-panel: var(--palette-white);
--color-surface-elevated: var(--palette-white);
--color-border: var(--palette-slate-300);
--color-border-strong: var(--palette-slate-500);
--color-content: var(--palette-slate-800);
--color-content-muted: var(--palette-slate-500);
--color-content-inverse: var(--palette-white);
--color-action: var(--palette-blue-600);
--color-action-hover: var(--palette-blue-700);
--color-action-pressed: var(--palette-blue-700);
--color-danger: var(--palette-red-600);
--color-danger-hover: var(--palette-red-700);
--color-on-action: var(--palette-white);
--color-focus: oklch(0.72 0.16 225);
--color-disabled-content: color-mix(in oklch, var(--color-content) 55%, transparent);
--color-disabled-surface: var(--color-surface-muted);
--color-info-content: oklch(0.38 0.16 255);
--color-info-surface: oklch(0.95 0.03 255);
--color-info-border: oklch(0.75 0.08 250);
--color-success-content: oklch(0.35 0.12 155);
--color-success-surface: oklch(0.95 0.04 155);
--color-success-border: oklch(0.72 0.1 155);
--color-warning-content: oklch(0.38 0.12 70);
--color-warning-surface: oklch(0.96 0.05 80);
--color-warning-border: oklch(0.75 0.12 80);
--color-danger-content: oklch(0.42 0.18 25);
--color-danger-surface: oklch(0.96 0.035 25);
--color-danger-border: oklch(0.72 0.12 25);
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
--spacing-page: var(--space-6);
--radius-control: var(--radius-sm);
--radius-surface: var(--radius-md);
--radius-modal: var(--radius-md);
--elevation-panel: var(--shadow-panel);
--elevation-popover: var(--shadow-popover);
--elevation-dialog: var(--shadow-dialog);
--layer-base: 0;
--layer-sticky: 30;
--layer-navigation: 50;
--layer-popover: 60;
--layer-modal: 70;
--layer-toast: 80;
--opacity-disabled: 0.55;
--opacity-scrim: 0.55;
--opacity-skeleton: 0.7;
}
:root[data-theme="dark"] {
--color-surface: var(--palette-slate-950);
--color-surface-muted: oklch(0.25 0.025 255);
--color-panel: oklch(0.205 0.022 255);
--color-surface-elevated: oklch(0.235 0.025 255);
--color-border: oklch(0.36 0.025 255);
--color-border-strong: oklch(0.58 0.025 255);
--color-content: oklch(0.94 0.012 255);
--color-content-muted: oklch(0.74 0.025 255);
--color-content-inverse: var(--palette-slate-950);
--color-action: var(--palette-blue-500);
--color-action-hover: oklch(0.79 0.12 245);
--color-action-pressed: oklch(0.83 0.1 245);
--color-danger: var(--palette-red-500);
--color-danger-hover: oklch(0.76 0.16 25);
--color-on-action: var(--palette-slate-950);
--color-focus: oklch(0.82 0.15 220);
--color-disabled-content: color-mix(in oklch, var(--color-content) 55%, transparent);
--color-disabled-surface: var(--color-surface-muted);
--color-info-content: oklch(0.83 0.09 250);
--color-info-surface: oklch(0.27 0.045 255);
--color-info-border: oklch(0.55 0.09 250);
--color-success-content: oklch(0.83 0.1 155);
--color-success-surface: oklch(0.27 0.045 155);
--color-success-border: oklch(0.53 0.1 155);
--color-warning-content: oklch(0.88 0.1 80);
--color-warning-surface: oklch(0.29 0.045 80);
--color-warning-border: oklch(0.58 0.11 80);
--color-danger-content: oklch(0.84 0.11 25);
--color-danger-surface: oklch(0.28 0.055 25);
--color-danger-border: oklch(0.56 0.13 25);
}
@@ -0,0 +1,62 @@
export const REQUIRED_PRIMITIVE_TOKENS = Object.freeze([
"--space-1",
"--space-2",
"--space-4",
"--font-size-sm",
"--line-height-normal",
"--radius-sm",
"--shadow-dialog",
"--duration-fast",
"--easing-standard",
"--size-control-md",
"--size-touch-target",
"--size-icon-md",
"--breakpoint-compact",
] as const);
export const REQUIRED_SEMANTIC_TOKENS = Object.freeze([
"--color-surface",
"--color-surface-muted",
"--color-surface-elevated",
"--color-content",
"--color-content-muted",
"--color-content-inverse",
"--color-border",
"--color-border-strong",
"--color-action",
"--color-action-hover",
"--color-action-pressed",
"--color-danger",
"--color-warning-content",
"--color-success-content",
"--color-info-content",
"--color-focus",
"--color-disabled-content",
"--color-disabled-surface",
"--elevation-panel",
"--layer-navigation",
"--layer-popover",
"--layer-modal",
"--layer-toast",
"--opacity-disabled",
"--opacity-scrim",
"--opacity-skeleton",
] as const);
export const REQUIRED_COMPONENT_TOKENS = Object.freeze([
"--button-primary-background",
"--button-primary-content",
"--button-primary-hover",
"--field-border",
"--field-border-invalid",
"--dialog-elevation",
"--navigation-active-background",
"--drawer-width",
"--toast-width",
] as const);
export const DESIGN_TOKEN_CONTRACT = Object.freeze({
primitive: REQUIRED_PRIMITIVE_TOKENS,
semantic: REQUIRED_SEMANTIC_TOKENS,
component: REQUIRED_COMPONENT_TOKENS,
});
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useLocation } from "react-router-dom";
import { PageHeader } from "../components/page-header.jsx";
import { PageHeader } from "../design-system/index.js";
import { useSession } from "../providers/session-provider.jsx";
export default function AuthExamplePage() {
@@ -7,15 +7,13 @@ import {
EmptySurface,
LoadingSurface,
TerminalErrorSurface,
} from "../components/async-surface.jsx";
import { PageHeader } from "../components/page-header.jsx";
import {
AuthRequiredSurface,
ForbiddenSurface,
NotFoundSurface,
} from "../components/state-surfaces.jsx";
import { Button } from "../components/ui/button.jsx";
import { Card } from "../components/ui/card.jsx";
Button,
Card,
PageHeader,
} from "../design-system/index.js";
export default function StateGalleryPage() {
const [lastAction, setLastAction] = useState(
+142 -8
View File
@@ -1,12 +1,25 @@
import { useState } from "react";
import { PageHeader } from "../components/page-header.jsx";
import { Alert } from "../components/ui/alert.jsx";
import { Badge } from "../components/ui/badge.jsx";
import { Button } from "../components/ui/button.jsx";
import { Card } from "../components/ui/card.jsx";
import { Dialog } from "../components/ui/dialog.jsx";
import { TextField } from "../components/ui/text-field.jsx";
import {
Alert,
Badge,
Button,
Card,
Checkbox,
Dialog,
Menu,
PageHeader,
ProgressBar,
RadioGroup,
Select,
Switch,
Tabs,
TextArea,
TextField,
ToastProvider,
Tooltip,
useToast,
} from "../design-system/index.js";
const COLOR_TOKENS = Object.freeze([
["Surface", "--color-surface"],
@@ -19,7 +32,21 @@ const COLOR_TOKENS = Object.freeze([
]);
export default function UiGalleryPage() {
return (
<ToastProvider>
<UiGalleryContent />
</ToastProvider>
);
}
function UiGalleryContent() {
const toast = useToast();
const [projectName, setProjectName] = useState("");
const [description, setDescription] = useState("");
const [template, setTemplate] = useState("application");
const [reviewed, setReviewed] = useState(false);
const [notifications, setNotifications] = useState(true);
const [density, setDensity] = useState("comfortable");
const [fieldTouched, setFieldTouched] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [notice, setNotice] = useState(
@@ -92,6 +119,60 @@ export default function UiGalleryPage() {
</div>
</section>
<section className="gallery-section" aria-labelledby="form-controls-title">
<header className="gallery-section__header">
<h2 id="form-controls-title">폼과 선택 컨트롤</h2>
<p>native semantics, 설명·오류 연결과 controlled 상태를 제공합니다.</p>
</header>
<div className="component-grid component-grid--two">
<Card title="긴 입력과 선택">
<div className="component-stack">
<TextArea
label="설명"
maxLength={120}
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
/>
<Select
label="시작 템플릿"
options={[
{ value: "application", label: "Application" },
{ value: "library", label: "Library" },
]}
value={template}
onChange={(event) => setTemplate(event.currentTarget.value)}
/>
<Checkbox
checked={reviewed}
label="접근성 계약을 확인했습니다."
onChange={(event) => setReviewed(event.currentTarget.checked)}
/>
</div>
</Card>
<Card title="단일 선택과 설정">
<div className="component-stack">
<RadioGroup
label="화면 밀도"
name="density"
onChange={setDensity}
options={[
{ value: "comfortable", label: "여유롭게" },
{ value: "compact", label: "조밀하게" },
]}
value={density}
/>
<Switch
checked={notifications}
description="boolean 설정에만 switch를 사용합니다."
label="알림 사용"
onChange={setNotifications}
/>
<ProgressBar label="설정 준비도" max={4} value={3} />
</div>
</Card>
</div>
</section>
<section className="gallery-section" aria-labelledby="feedback-title">
<header className="gallery-section__header">
<h2 id="feedback-title">피드백과 모달</h2>
@@ -126,7 +207,32 @@ export default function UiGalleryPage() {
</div>
</Card>
<Card title="모달" description="배경과 키보드 Esc로 닫고 포커스를 복원합니다.">
<Button onClick={() => setDialogOpen(true)}>모달 열기</Button>
<div className="button-row">
<Button onClick={() => setDialogOpen(true)}>모달 열기</Button>
<Menu
items={[
{
id: "inspect",
label: "상태 확인",
onSelect: () => setNotice("메뉴 작업을 실행했습니다."),
},
{
id: "notify",
label: "Toast 표시",
onSelect: () =>
toast.push({
id: "gallery-saved",
title: "예제가 저장되었습니다.",
tone: "success",
}),
},
]}
triggerLabel="작업 메뉴"
/>
<Tooltip content="이 설명은 필수 정보가 아닙니다.">
<Button variant="ghost">도움말</Button>
</Tooltip>
</div>
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
@@ -157,6 +263,34 @@ export default function UiGalleryPage() {
</div>
</section>
<section className="gallery-section" aria-labelledby="navigation-title">
<header className="gallery-section__header">
<h2 id="navigation-title">탐색 패턴</h2>
<p>화살표 키와 명시적인 활성화 정책을 갖는 예제입니다.</p>
</header>
<Tabs
activation="manual"
label="디자인 시스템 계층"
tabs={[
{
id: "tokens",
label: "토큰",
panel: <p>원시 값에서 의미와 컴포넌트 토큰을 파생합니다.</p>,
},
{
id: "primitives",
label: "프리미티브",
panel: <p>native semantics와 interaction을 닫습니다.</p>,
},
{
id: "patterns",
label: "패턴",
panel: <p>반복되는 사용자 문제를 조합으로 해결합니다.</p>,
},
]}
/>
</section>
<section className="gallery-section" aria-labelledby="tokens-title">
<header className="gallery-section__header">
<h2 id="tokens-title">디자인 토큰</h2>
+59 -59
View File
@@ -5,6 +5,13 @@ import {
NAVIGATION_ROUTES,
routePath,
} from "../../features/installed-feature-contracts.js";
import {
Button,
Drawer,
IconButton,
MenuIcon,
Select,
} from "../design-system/index.js";
import { useSession } from "../providers/session-provider.jsx";
import { useTheme } from "../providers/theme-provider.jsx";
@@ -27,16 +34,6 @@ export function AppShell() {
setNavigationOpen(false);
}, [location.pathname]);
useEffect(() => {
if (!navigationOpen) return undefined;
/** @param {KeyboardEvent} event */
const closeOnEscape = (event) => {
if (event.key === "Escape") setNavigationOpen(false);
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [navigationOpen]);
async function runSessionAction() {
setSessionActionPending(true);
setSessionActionFailed(false);
@@ -71,26 +68,29 @@ export function AppShell() {
본문으로 건너뛰기
</a>
<header className="app-shell__header">
<button
<IconButton
accessibleName="메뉴"
className="app-shell__menu-button"
type="button"
aria-controls="primary-navigation"
aria-controls="mobile-primary-navigation"
aria-expanded={navigationOpen}
onClick={() => setNavigationOpen((open) => !open)}
variant="secondary"
>
<span aria-hidden="true"></span>
<span>메뉴</span>
</button>
<MenuIcon />
</IconButton>
<NavLink className="app-shell__brand" to={routePath("APP_HOME")}>
Frontend Skeleton
</NavLink>
<div className="app-shell__session">
<label className="visually-hidden" htmlFor="theme-preference">
색상 테마
</label>
<select
<Select
className="theme-selector"
id="theme-preference"
label="색상 테마"
options={[
{ value: "system", label: "시스템 테마" },
{ value: "light", label: "라이트 테마" },
{ value: "dark", label: "다크 테마" },
]}
value={preference}
onChange={(event) =>
setPreference(
@@ -99,23 +99,18 @@ export function AppShell() {
),
)
}
>
<option value="system">시스템 테마</option>
<option value="light">라이트 테마</option>
<option value="dark">다크 테마</option>
</select>
/>
<span className="session-status" data-state={sessionState}>
{SESSION_LABELS[sessionState]}
</span>
{integrationAvailable ? (
<button
className="ui-button ui-button--compact"
type="button"
<Button
disabled={sessionActionPending}
onClick={() => void runSessionAction()}
size="compact"
>
{sessionActionPending ? "처리 중…" : sessionActionLabel}
</button>
</Button>
) : null}
</div>
{sessionActionFailed ? (
@@ -124,40 +119,45 @@ export function AppShell() {
</p>
) : null}
</header>
<aside
className="app-shell__sidebar"
data-open={navigationOpen}
aria-label="사이드바"
>
<nav id="primary-navigation" aria-label="주요 탐색">
<ul className="app-navigation">
{NAVIGATION_ROUTES.map((definition) => (
<li key={definition.routeId}>
<NavLink
className={({ isActive }) =>
`app-navigation__link${isActive ? " is-active" : ""}`
}
end={definition.path === "/"}
to={definition.path}
>
{definition.navigationLabel}
</NavLink>
</li>
))}
</ul>
</nav>
<aside className="app-shell__sidebar" aria-label="사이드바">
<PrimaryNavigation id="primary-navigation" />
</aside>
{navigationOpen ? (
<button
className="app-shell__scrim"
type="button"
aria-label="메뉴 닫기"
onClick={() => setNavigationOpen(false)}
/>
) : null}
<Drawer
closeLabel="메뉴 닫기"
onClose={() => setNavigationOpen(false)}
open={navigationOpen}
title="메뉴"
>
<PrimaryNavigation id="mobile-primary-navigation" />
</Drawer>
<main className="app-shell__content" id="main-content" tabIndex={-1}>
<Outlet />
</main>
</div>
);
}
/**
* @param {{ id: string }} props
*/
function PrimaryNavigation({ id }) {
return (
<nav id={id} aria-label="주요 탐색">
<ul className="app-navigation">
{NAVIGATION_ROUTES.map((definition) => (
<li key={definition.routeId}>
<NavLink
className={({ isActive }) =>
`app-navigation__link${isActive ? " is-active" : ""}`
}
end={definition.path === "/"}
to={definition.path}
>
{definition.navigationLabel}
</NavLink>
</li>
))}
</ul>
</nav>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { routePath } from "../../features/installed-feature-contracts.js";
import { PageHeader } from "../components/page-header.jsx";
import { PageHeader } from "../design-system/index.js";
import { useApplication } from "../providers/application-provider.js";
const READINESS_ITEMS = Object.freeze([
+1 -1
View File
@@ -1,7 +1,7 @@
import { Link } from "react-router-dom";
import { routePath } from "../../features/installed-feature-contracts.js";
import { PageHeader } from "../components/page-header.jsx";
import { PageHeader } from "../design-system/index.js";
export default function NotFoundPage() {
return (
+1 -1
View File
@@ -28,7 +28,7 @@ import {
RouteBoundary,
} from "../boundaries/render-error-boundary.jsx";
import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.js";
import { PageHeader } from "../components/page-header.jsx";
import { PageHeader } from "../design-system/index.js";
import { AppShell } from "../layouts/app-shell.jsx";
import { useApplication } from "../providers/application-provider.js";
import { SessionProvider, useSession } from "../providers/session-provider.jsx";
+454 -69
View File
@@ -1,35 +1,7 @@
@import "tailwindcss";
@theme {
--color-surface: oklch(0.985 0.003 247);
--color-surface-muted: oklch(0.94 0.01 247);
--color-panel: oklch(1 0 0);
--color-border: oklch(0.87 0.015 247);
--color-content: oklch(0.25 0.025 247);
--color-content-muted: oklch(0.48 0.025 247);
--color-action: oklch(0.55 0.18 255);
--color-action-hover: oklch(0.48 0.2 255);
--color-danger: oklch(0.55 0.2 25);
--color-danger-hover: oklch(0.47 0.2 25);
--color-on-action: oklch(1 0 0);
--color-focus: oklch(0.72 0.16 225);
--color-info-content: oklch(0.38 0.16 255);
--color-info-surface: oklch(0.95 0.03 255);
--color-info-border: oklch(0.75 0.08 250);
--color-success-content: oklch(0.35 0.12 155);
--color-success-surface: oklch(0.95 0.04 155);
--color-success-border: oklch(0.72 0.1 155);
--color-warning-content: oklch(0.38 0.12 70);
--color-warning-surface: oklch(0.96 0.05 80);
--color-warning-border: oklch(0.75 0.12 80);
--color-danger-content: oklch(0.42 0.18 25);
--color-danger-surface: oklch(0.96 0.035 25);
--color-danger-border: oklch(0.72 0.12 25);
--radius-control: 0.5rem;
--radius-surface: 0.75rem;
--spacing-page: 1.5rem;
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
}
@import "../design-system/tokens/primitive.css";
@import "../design-system/tokens/semantic.css";
@import "../design-system/tokens/component.css";
@layer base {
:root {
@@ -38,33 +10,6 @@
font-family: var(--font-sans);
}
:root[data-theme="dark"] {
--color-surface: oklch(0.16 0.02 255);
--color-surface-muted: oklch(0.25 0.025 255);
--color-panel: oklch(0.205 0.022 255);
--color-border: oklch(0.36 0.025 255);
--color-content: oklch(0.94 0.012 255);
--color-content-muted: oklch(0.74 0.025 255);
--color-action: oklch(0.7 0.14 250);
--color-action-hover: oklch(0.79 0.12 245);
--color-danger: oklch(0.68 0.19 25);
--color-danger-hover: oklch(0.76 0.16 25);
--color-on-action: oklch(0.16 0.02 255);
--color-focus: oklch(0.82 0.15 220);
--color-info-content: oklch(0.83 0.09 250);
--color-info-surface: oklch(0.27 0.045 255);
--color-info-border: oklch(0.55 0.09 250);
--color-success-content: oklch(0.83 0.1 155);
--color-success-surface: oklch(0.27 0.045 155);
--color-success-border: oklch(0.53 0.1 155);
--color-warning-content: oklch(0.88 0.1 80);
--color-warning-surface: oklch(0.29 0.045 80);
--color-warning-border: oklch(0.58 0.11 80);
--color-danger-content: oklch(0.84 0.11 25);
--color-danger-surface: oklch(0.28 0.055 25);
--color-danger-border: oklch(0.56 0.13 25);
}
body {
margin: 0;
min-width: 20rem;
@@ -187,13 +132,25 @@
}
.theme-selector {
min-height: 2.25rem;
display: block;
}
.theme-selector .ui-field__label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.theme-selector .ui-field__input {
min-height: var(--size-control-sm);
max-width: 8.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
padding: 0.35rem 2rem 0.35rem 0.65rem;
color: var(--color-content);
background: var(--color-panel);
cursor: pointer;
}
@@ -305,7 +262,12 @@
.ui-button:disabled {
cursor: not-allowed;
opacity: 0.55;
opacity: var(--opacity-disabled);
}
.ui-button--pending {
gap: var(--space-2);
cursor: progress;
}
.ui-button--secondary {
@@ -339,11 +301,59 @@
}
.ui-button--compact {
min-height: 2.25rem;
min-height: var(--size-control-sm);
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
}
.ui-icon-button {
width: var(--size-touch-target);
min-width: var(--size-touch-target);
padding: 0;
}
.app-shell__menu-button.ui-icon-button {
display: none;
}
.ui-icon {
display: inline-block;
flex: 0 0 auto;
stroke-width: var(--icon-stroke-default);
}
.ui-icon--small {
width: var(--size-icon-sm);
height: var(--size-icon-sm);
}
.ui-icon--medium {
width: var(--size-icon-md);
height: var(--size-icon-md);
}
.ui-icon--large {
width: var(--size-icon-lg);
height: var(--size-icon-lg);
}
.ui-spinner {
display: inline-block;
width: var(--size-icon-sm);
height: var(--size-icon-sm);
box-sizing: border-box;
border: 2px solid currentColor;
border-inline-end-color: transparent;
border-radius: var(--radius-full);
animation: ui-spin var(--duration-slow) linear infinite;
}
@keyframes ui-spin {
to {
transform: rotate(360deg);
}
}
.ui-skeleton {
@apply h-24 animate-pulse rounded-surface bg-surface-muted;
}
@@ -391,7 +401,110 @@
}
.ui-field__input[aria-invalid="true"] {
border-color: var(--color-danger);
border-color: var(--field-border-invalid);
}
.ui-field__textarea {
min-height: 7rem;
resize: vertical;
}
.ui-field__counter {
justify-self: end;
color: var(--color-content-muted);
font-size: var(--font-size-sm);
}
.ui-choice-field,
.ui-radio-group,
.ui-switch-field {
display: grid;
gap: var(--space-2);
}
.ui-choice-field__control {
display: flex;
min-height: var(--size-touch-target);
align-items: center;
gap: var(--space-2);
cursor: pointer;
}
.ui-choice-field__control input {
width: 1.15rem;
height: 1.15rem;
accent-color: var(--color-action);
}
.ui-choice-field__control small {
display: block;
color: var(--color-content-muted);
}
.ui-radio-group {
min-width: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
padding: var(--space-3);
}
.ui-radio-group legend {
padding-inline: var(--space-1);
font-weight: var(--font-weight-bold);
}
.ui-switch {
display: inline-flex;
width: fit-content;
min-height: var(--size-touch-target);
align-items: center;
gap: var(--space-2);
border: 0;
color: var(--color-content);
background: transparent;
cursor: pointer;
}
.ui-switch__thumb {
display: inline-block;
width: 2.5rem;
height: 1.5rem;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-full);
background: var(--color-disabled-surface);
}
.ui-switch__thumb::after {
display: block;
width: 1rem;
height: 1rem;
margin: 0.2rem;
border-radius: var(--radius-full);
background: var(--color-content-muted);
content: "";
transition: transform var(--duration-normal) var(--easing-standard);
}
.ui-switch[aria-checked="true"] .ui-switch__thumb {
border-color: var(--color-action);
background: var(--color-action);
}
.ui-switch[aria-checked="true"] .ui-switch__thumb::after {
background: var(--color-on-action);
transform: translateX(1rem);
}
[dir="rtl"] .ui-switch[aria-checked="true"] .ui-switch__thumb::after {
transform: translateX(-1rem);
}
.ui-search-field {
position: relative;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: end;
gap: var(--space-2);
}
.ui-card {
@@ -406,7 +519,7 @@
box-shadow: 0 8px 28px color-mix(in oklch, var(--color-content) 6%, transparent);
}
.ui-card__header h3,
.ui-card__header :is(h2, h3, h4),
.ui-card__header p {
margin-block-end: 0;
}
@@ -478,6 +591,13 @@
cursor: pointer;
}
.ui-alert__dismiss.ui-icon-button,
.ui-dialog__close.ui-icon-button {
min-width: 2rem;
min-height: 2rem;
padding: 0;
}
.ui-alert__dismiss:hover,
.ui-dialog__close:hover {
background: color-mix(in oklch, var(--color-content) 8%, transparent);
@@ -523,7 +643,7 @@
padding: 0;
color: var(--color-content);
background: var(--color-panel);
box-shadow: 0 24px 70px color-mix(in oklch, var(--color-content) 25%, transparent);
box-shadow: var(--dialog-elevation);
}
.ui-dialog::backdrop {
@@ -531,6 +651,271 @@
backdrop-filter: blur(2px);
}
.ui-drawer {
width: var(--drawer-width);
max-width: none;
height: 100dvh;
max-height: none;
margin-block: 0;
border-radius: 0;
}
.ui-drawer--start {
margin-inline: 0 auto;
}
.ui-drawer--end {
margin-inline: auto 0;
}
.ui-drawer .ui-dialog__surface {
min-height: 100%;
box-sizing: border-box;
}
.ui-popover,
.ui-menu,
.ui-tooltip {
position: relative;
display: inline-block;
}
.ui-popover__content,
.ui-menu__content {
position: absolute;
z-index: var(--layer-popover);
top: calc(100% + var(--space-2));
inset-inline-start: 0;
min-width: 12rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
padding: var(--space-2);
background: var(--color-surface-elevated);
box-shadow: var(--elevation-popover);
}
.ui-menu__content {
display: grid;
gap: var(--space-1);
}
.ui-menu__item {
min-height: var(--size-control-sm);
border: 0;
border-radius: var(--radius-control);
padding: var(--space-2) var(--space-3);
color: var(--color-content);
background: transparent;
text-align: start;
cursor: pointer;
}
.ui-menu__item:hover,
.ui-menu__item:focus-visible {
background: var(--color-surface-muted);
}
.ui-tooltip__content {
position: absolute;
z-index: var(--layer-popover);
inset-block-end: calc(100% + var(--space-2));
inset-inline-start: 50%;
width: max-content;
max-width: 16rem;
padding: var(--space-2);
border-radius: var(--radius-control);
color: var(--color-content-inverse);
background: var(--color-content);
opacity: 0;
pointer-events: none;
transform: translateX(-50%);
transition: opacity var(--duration-fast) var(--easing-standard);
}
.ui-tooltip:focus-within .ui-tooltip__content,
.ui-tooltip:hover .ui-tooltip__content {
opacity: 1;
}
.ui-progress,
.ui-progress__label {
display: grid;
gap: var(--space-2);
}
.ui-progress__label {
grid-template-columns: 1fr auto;
}
.ui-progress__bar {
width: 100%;
accent-color: var(--color-action);
}
.ui-skeleton--text {
height: 1em;
}
.ui-skeleton--control {
height: var(--size-control-md);
}
.ui-skeleton--surface {
height: 6rem;
}
.ui-separator {
width: 100%;
border: 0;
border-block-start: 1px solid var(--color-border);
}
.ui-breadcrumbs ol,
.ui-pagination,
.ui-tabs__list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin: 0;
padding: 0;
list-style: none;
}
.ui-breadcrumbs li:not(:last-child)::after {
margin-inline-start: var(--space-2);
color: var(--color-content-muted);
content: "/";
}
.ui-tabs {
display: grid;
gap: var(--space-3);
}
.ui-tabs__list {
border-block-end: 1px solid var(--color-border);
}
.ui-tabs__tab {
min-height: var(--size-touch-target);
border: 0;
border-block-end: 0.2rem solid transparent;
padding: var(--space-2) var(--space-3);
color: var(--color-content-muted);
background: transparent;
cursor: pointer;
}
.ui-tabs__tab[aria-selected="true"] {
border-block-end-color: var(--color-action);
color: var(--color-content);
font-weight: var(--font-weight-bold);
}
.ui-tabs__panel {
padding: var(--space-3);
}
.ui-toast-region {
position: fixed;
z-index: var(--layer-toast);
inset-block-end: var(--space-4);
inset-inline-end: var(--space-4);
display: grid;
width: var(--toast-width);
gap: var(--space-2);
}
.ui-toast {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
border: 1px solid var(--color-border);
border-inline-start: 0.25rem solid var(--color-info-border);
border-radius: var(--radius-control);
padding: var(--space-3);
background: var(--color-surface-elevated);
box-shadow: var(--elevation-popover);
}
.ui-toast--success {
border-inline-start-color: var(--color-success-border);
}
.ui-toast--warning {
border-inline-start-color: var(--color-warning-border);
}
.ui-toast--danger {
border-inline-start-color: var(--color-danger-border);
}
.ui-toast p {
margin: var(--space-1) 0 0;
color: var(--color-content-muted);
}
.ui-data-table__scroll {
max-width: 100%;
overflow-x: auto;
}
.ui-data-table {
width: 100%;
border-collapse: collapse;
}
.ui-data-table caption {
margin-block-end: var(--space-2);
font-weight: var(--font-weight-bold);
text-align: start;
}
.ui-data-table th,
.ui-data-table td {
border-block-end: 1px solid var(--color-border);
padding: var(--space-3);
text-align: start;
}
.ui-search-filter-toolbar,
.ui-pagination-bar {
display: flex;
flex-wrap: wrap;
align-items: end;
justify-content: space-between;
gap: var(--space-3);
}
.ui-disclosure-group {
display: grid;
gap: var(--space-2);
}
.ui-disclosure-group details {
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
padding: var(--space-3);
}
.ui-disclosure-group summary {
min-height: var(--size-control-sm);
cursor: pointer;
font-weight: var(--font-weight-bold);
}
.ui-focus-ring {
display: inline-flex;
border-radius: var(--radius-control);
}
.ui-focus-ring:focus-within {
outline: 0.1875rem solid var(--color-focus);
outline-offset: 0.1875rem;
}
.ui-dialog__surface {
padding: 1.5rem;
}
@@ -920,7 +1305,7 @@
padding-inline: 0.75rem;
}
.app-shell__menu-button {
.app-shell__menu-button.ui-icon-button {
display: inline-flex;
}