feat: add design system platform
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user