sh-ui-cli 0.49.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/data/changelog/versions.json +14 -0
  2. package/data/registry/react/components/accordion/index.vanilla-extract.tsx +97 -0
  3. package/data/registry/react/components/accordion/styles.css.ts +131 -0
  4. package/data/registry/react/components/avatar/index.vanilla-extract.tsx +73 -0
  5. package/data/registry/react/components/avatar/styles.css.ts +68 -0
  6. package/data/registry/react/components/badge/index.vanilla-extract.tsx +40 -0
  7. package/data/registry/react/components/badge/styles.css.ts +71 -0
  8. package/data/registry/react/components/breadcrumb/index.vanilla-extract.tsx +152 -0
  9. package/data/registry/react/components/breadcrumb/styles.css.ts +95 -0
  10. package/data/registry/react/components/calendar/index.vanilla-extract.tsx +806 -0
  11. package/data/registry/react/components/calendar/styles.css.ts +250 -0
  12. package/data/registry/react/components/carousel/index.vanilla-extract.tsx +430 -0
  13. package/data/registry/react/components/carousel/styles.css.ts +169 -0
  14. package/data/registry/react/components/checkbox/index.vanilla-extract.tsx +96 -0
  15. package/data/registry/react/components/checkbox/styles.css.ts +74 -0
  16. package/data/registry/react/components/code-editor/index.vanilla-extract.tsx +230 -0
  17. package/data/registry/react/components/code-editor/styles.css.ts +97 -0
  18. package/data/registry/react/components/code-panel/index.vanilla-extract.tsx +191 -0
  19. package/data/registry/react/components/code-panel/styles.css.ts +151 -0
  20. package/data/registry/react/components/color-picker/index.vanilla-extract.tsx +467 -0
  21. package/data/registry/react/components/color-picker/styles.css.ts +169 -0
  22. package/data/registry/react/components/combobox/index.vanilla-extract.tsx +165 -0
  23. package/data/registry/react/components/combobox/styles.css.ts +174 -0
  24. package/data/registry/react/components/context-menu/index.vanilla-extract.tsx +251 -0
  25. package/data/registry/react/components/context-menu/styles.css.ts +167 -0
  26. package/data/registry/react/components/date-picker/index.vanilla-extract.tsx +520 -0
  27. package/data/registry/react/components/date-picker/styles.css.ts +111 -0
  28. package/data/registry/react/components/dialog/index.vanilla-extract.tsx +95 -0
  29. package/data/registry/react/components/dialog/styles.css.ts +140 -0
  30. package/data/registry/react/components/dropdown-menu/index.vanilla-extract.tsx +255 -0
  31. package/data/registry/react/components/dropdown-menu/styles.css.ts +175 -0
  32. package/data/registry/react/components/file-upload/index.vanilla-extract.tsx +487 -0
  33. package/data/registry/react/components/file-upload/styles.css.ts +193 -0
  34. package/data/registry/react/components/form/index.vanilla-extract.tsx +61 -0
  35. package/data/registry/react/components/form/styles.css.ts +56 -0
  36. package/data/registry/react/components/header/index.vanilla-extract.tsx +805 -0
  37. package/data/registry/react/components/header/styles.css.ts +413 -0
  38. package/data/registry/react/components/label/index.vanilla-extract.tsx +52 -0
  39. package/data/registry/react/components/label/styles.css.ts +141 -0
  40. package/data/registry/react/components/markdown-editor/index.vanilla-extract.tsx +119 -0
  41. package/data/registry/react/components/markdown-editor/styles.css.ts +231 -0
  42. package/data/registry/react/components/menubar/index.vanilla-extract.tsx +32 -0
  43. package/data/registry/react/components/menubar/styles.css.ts +53 -0
  44. package/data/registry/react/components/numeric-input/index.vanilla-extract.tsx +148 -0
  45. package/data/registry/react/components/numeric-input/styles.css.ts +65 -0
  46. package/data/registry/react/components/page-toc/index.vanilla-extract.tsx +174 -0
  47. package/data/registry/react/components/page-toc/styles.css.ts +97 -0
  48. package/data/registry/react/components/pagination/index.vanilla-extract.tsx +269 -0
  49. package/data/registry/react/components/pagination/styles.css.ts +113 -0
  50. package/data/registry/react/components/popover/index.vanilla-extract.tsx +113 -0
  51. package/data/registry/react/components/popover/styles.css.ts +78 -0
  52. package/data/registry/react/components/progress/index.vanilla-extract.tsx +54 -0
  53. package/data/registry/react/components/progress/styles.css.ts +53 -0
  54. package/data/registry/react/components/radio/index.vanilla-extract.tsx +65 -0
  55. package/data/registry/react/components/radio/styles.css.ts +79 -0
  56. package/data/registry/react/components/rich-text-editor/index.vanilla-extract.tsx +348 -0
  57. package/data/registry/react/components/rich-text-editor/styles.css.ts +243 -0
  58. package/data/registry/react/components/select/index.vanilla-extract.tsx +234 -0
  59. package/data/registry/react/components/select/styles.css.ts +225 -0
  60. package/data/registry/react/components/separator/index.vanilla-extract.tsx +46 -0
  61. package/data/registry/react/components/separator/styles.css.ts +24 -0
  62. package/data/registry/react/components/sidebar/index.vanilla-extract.tsx +1067 -0
  63. package/data/registry/react/components/sidebar/styles.css.ts +578 -0
  64. package/data/registry/react/components/skeleton/index.vanilla-extract.tsx +22 -0
  65. package/data/registry/react/components/skeleton/styles.css.ts +30 -0
  66. package/data/registry/react/components/slider/index.vanilla-extract.tsx +298 -0
  67. package/data/registry/react/components/slider/styles.css.ts +75 -0
  68. package/data/registry/react/components/spinner/index.vanilla-extract.tsx +38 -0
  69. package/data/registry/react/components/spinner/styles.css.ts +60 -0
  70. package/data/registry/react/components/switch/index.vanilla-extract.tsx +39 -0
  71. package/data/registry/react/components/switch/styles.css.ts +87 -0
  72. package/data/registry/react/components/tabs/index.vanilla-extract.tsx +91 -0
  73. package/data/registry/react/components/tabs/styles.css.ts +145 -0
  74. package/data/registry/react/components/textarea/index.vanilla-extract.tsx +23 -0
  75. package/data/registry/react/components/textarea/styles.css.ts +55 -0
  76. package/data/registry/react/components/toast/index.vanilla-extract.tsx +258 -0
  77. package/data/registry/react/components/toast/styles.css.ts +307 -0
  78. package/data/registry/react/components/toggle/index.vanilla-extract.tsx +131 -0
  79. package/data/registry/react/components/toggle/styles.css.ts +109 -0
  80. package/data/registry/react/components/tooltip/index.vanilla-extract.tsx +83 -0
  81. package/data/registry/react/components/tooltip/styles.css.ts +59 -0
  82. package/data/registry/react/registry.json +853 -36
  83. package/package.json +1 -1
  84. package/src/api.d.ts +4 -3
  85. package/src/constants.js +4 -3
@@ -0,0 +1,145 @@
1
+ import { style } from "@vanilla-extract/css";
2
+
3
+ export const tabs = style({
4
+ display: "flex",
5
+ flexDirection: "column",
6
+ gap: "var(--space-3)",
7
+ width: "100%",
8
+ selectors: {
9
+ "&[data-orientation="vertical"]": {
10
+ flexDirection: "row",
11
+ },
12
+ [`&[data-orientation="vertical"] > ${tabs__list}`]: {
13
+ flexDirection: "column",
14
+ alignItems: "stretch",
15
+ },
16
+ [`&[data-variant="underline"] > ${tabs__list}`]: {
17
+ width: "100%",
18
+ gap: 0,
19
+ boxShadow: "inset 0 -1px 0 var(--border)",
20
+ },
21
+ [`&[data-variant="underline"] ${tabs__trigger}`]: {
22
+ padding: "0.625rem var(--space-4)",
23
+ },
24
+ [`&[data-variant="underline"] ${tabs__indicator}`]: {
25
+ top: "var(--active-tab-top)",
26
+ left: "var(--active-tab-left)",
27
+ width: "var(--active-tab-width)",
28
+ height: "var(--active-tab-height)",
29
+ boxShadow: "inset 0 -2px 0 var(--foreground)",
30
+ },
31
+ [`&[data-variant="underline"][data-orientation="vertical"] > ${tabs__list}`]: {
32
+ width: "auto",
33
+ boxShadow: "inset -1px 0 0 var(--border)",
34
+ },
35
+ [`&[data-variant="underline"][data-orientation="vertical"] ${tabs__indicator}`]: {
36
+ boxShadow: "inset -2px 0 0 var(--foreground)",
37
+ },
38
+ [`&[data-variant="pill"] > ${tabs__list}`]: {
39
+ padding: "var(--space-1)",
40
+ background: "var(--background-muted, var(--background))",
41
+ border: "1px solid var(--border)",
42
+ borderRadius: "var(--radius)",
43
+ },
44
+ [`&[data-variant="pill"] ${tabs__trigger}`]: {
45
+ padding: "0.375rem var(--space-3)",
46
+ borderRadius: "calc(var(--radius) - 2px)",
47
+ },
48
+ [`&[data-variant="pill"] ${tabs__indicator}`]: {
49
+ top: "var(--active-tab-top)",
50
+ left: "var(--active-tab-left)",
51
+ width: "var(--active-tab-width)",
52
+ height: "var(--active-tab-height)",
53
+ background: "var(--background)",
54
+ borderRadius: "calc(var(--radius) - 2px)",
55
+ boxShadow: "0 1px 2px rgba(0, 0, 0, 0.06)",
56
+ },
57
+ [`&[data-variant="plain"] ${tabs__trigger}`]: {
58
+ padding: "0.375rem var(--space-2)",
59
+ borderRadius: "calc(var(--radius) - 2px)",
60
+ },
61
+ [`&[data-variant="plain"] ${tabs__trigger}[data-selected]`]: {
62
+ background: "var(--background-muted, transparent)",
63
+ },
64
+ [`&[data-variant="plain"] ${tabs__indicator}`]: {
65
+ display: "none",
66
+ },
67
+ },
68
+ });
69
+
70
+ export const tabs__list = style({
71
+ position: "relative",
72
+ display: "inline-flex",
73
+ alignItems: "center",
74
+ gap: "var(--space-1)",
75
+ width: "fit-content",
76
+ });
77
+
78
+ export const tabs__trigger = style({
79
+ position: "relative",
80
+ zIndex: 1,
81
+ display: "inline-flex",
82
+ alignItems: "center",
83
+ justifyContent: "center",
84
+ gap: "0.375rem",
85
+ padding: "var(--space-2) var(--space-3)",
86
+ background: "transparent",
87
+ color: "var(--foreground-muted, var(--foreground))",
88
+ border: 0,
89
+ fontSize: "var(--text-sm)",
90
+ fontWeight: "var(--weight-medium)",
91
+ lineHeight: 1,
92
+ cursor: "pointer",
93
+ transition: "color var(--duration-fast), background-color var(--duration-fast)",
94
+ userSelect: "none",
95
+ WebkitTapHighlightColor: "transparent",
96
+ whiteSpace: "nowrap",
97
+ selectors: {
98
+ "&:hover:not(:disabled):not([data-selected])": {
99
+ color: "var(--foreground)",
100
+ },
101
+ "&:focus-visible": {
102
+ outline: "var(--border-width-strong) solid var(--foreground)",
103
+ outlineOffset: "2px",
104
+ },
105
+ "&[data-selected]": {
106
+ color: "var(--foreground)",
107
+ },
108
+ "&:disabled": {
109
+ opacity: "var(--opacity-disabled)",
110
+ cursor: "not-allowed",
111
+ },
112
+ },
113
+ });
114
+
115
+ export const tabs__indicator = style({
116
+ position: "absolute",
117
+ transition: "top 180ms, left 180ms, width 180ms, height 180ms",
118
+ zIndex: 0,
119
+ pointerEvents: "none",
120
+ selectors: {
121
+ "&[data-activation-direction="none"]": {
122
+ transition: "none",
123
+ },
124
+ },
125
+ });
126
+
127
+ export const tabs__content = style({
128
+ outline: "none",
129
+ selectors: {
130
+ "&:focus-visible": {
131
+ outline: "var(--border-width-strong) solid var(--foreground)",
132
+ outlineOffset: "2px",
133
+ borderRadius: "var(--radius)",
134
+ },
135
+ },
136
+ });
137
+
138
+ /** 동적 키로 클래스 참조용 — `byKey[\`badge--${variant}\`]` 같은 패턴 지원. */
139
+ export const byKey: Record<string, string> = {
140
+ "tabs": tabs,
141
+ "tabs__list": tabs__list,
142
+ "tabs__trigger": tabs__trigger,
143
+ "tabs__indicator": tabs__indicator,
144
+ "tabs__content": tabs__content,
145
+ };
@@ -0,0 +1,23 @@
1
+ import * as React from "react";
2
+ import { byKey, textarea } from "./styles.css";
3
+
4
+ import { cn } from "@SH_UI_UTILS@";
5
+ export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
6
+
7
+
8
+ /**
9
+ * 여러 줄 텍스트 입력. 네이티브 `<textarea>` 위에 sh-ui 토큰 스타일만 입혔다.
10
+ * 라벨/오류 메시지는 외부에서 조립한다 — 단독 사용 시 `<Label>`과 `aria-describedby` 연결 권장.
11
+ */
12
+ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
13
+ ({ className, ...props }, ref) => {
14
+ return (
15
+ <textarea
16
+ ref={ref}
17
+ className={cn(textarea, className)}
18
+ {...props}
19
+ />
20
+ );
21
+ },
22
+ );
23
+ Textarea.displayName = "Textarea";
@@ -0,0 +1,55 @@
1
+ import { style } from "@vanilla-extract/css";
2
+
3
+ export const textarea = style({
4
+ display: "block",
5
+ width: "100%",
6
+ minHeight: "5rem",
7
+ padding: "var(--space-2) var(--space-3)",
8
+ background: "var(--background)",
9
+ color: "var(--foreground)",
10
+ border: "1px solid var(--border)",
11
+ borderRadius: "var(--radius)",
12
+ fontFamily: "inherit",
13
+ fontSize: "var(--text-sm)",
14
+ lineHeight: 1.5,
15
+ resize: "vertical",
16
+ transition: "border-color var(--duration-fast), box-shadow var(--duration-fast)",
17
+ WebkitTapHighlightColor: "transparent",
18
+ selectors: {
19
+ "&::placeholder": {
20
+ color: "var(--foreground-subtle)",
21
+ },
22
+ "&:hover:not(:disabled):not(:focus)": {
23
+ borderColor: "var(--border-strong)",
24
+ },
25
+ "&:focus": {
26
+ outline: "none",
27
+ borderColor: "var(--foreground)",
28
+ boxShadow: "0 0 0 1px var(--foreground)",
29
+ },
30
+ "&:disabled": {
31
+ opacity: "var(--opacity-disabled)",
32
+ cursor: "not-allowed",
33
+ background: "var(--background-subtle)",
34
+ },
35
+ "&:read-only": {
36
+ background: "var(--background-subtle)",
37
+ },
38
+ "&[aria-invalid="true"]": {
39
+ borderColor: "var(--danger)",
40
+ },
41
+ "&[aria-invalid="true"]:focus": {
42
+ boxShadow: "0 0 0 1px var(--danger)",
43
+ },
44
+ },
45
+ "@media": {
46
+ "(hover: none) and (pointer: coarse)": {
47
+ fontSize: "var(--text-base)",
48
+ },
49
+ },
50
+ });
51
+
52
+ /** 동적 키로 클래스 참조용 — `byKey[\`badge--${variant}\`]` 같은 패턴 지원. */
53
+ export const byKey: Record<string, string> = {
54
+ "textarea": textarea,
55
+ };
@@ -0,0 +1,258 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { cn } from "@SH_UI_UTILS@";
6
+ import { byKey, toastViewport, toast, toast__icon, toastSuccess, toastDanger, toastWarning, toast__body, toast__title, toast__description, toast__action, toast__close } from "./styles.css";
7
+
8
+ /* ───────── Types ───────── */
9
+
10
+ type ToastVariant = "default" | "success" | "danger" | "warning";
11
+
12
+ interface ToastItem {
13
+ id: string;
14
+ /** 짧은 제목. 한 줄 굵게 표시. */
15
+ title?: React.ReactNode;
16
+ /** 본문 설명. 줄바꿈 가능. */
17
+ description?: React.ReactNode;
18
+ variant: ToastVariant;
19
+ duration: number;
20
+ /** 우측에 표시될 액션 노드(예: "다시 시도" 버튼). */
21
+ action?: React.ReactNode;
22
+ }
23
+
24
+ type ToastInput = Omit<ToastItem, "id" | "variant" | "duration"> & {
25
+ /**
26
+ * 토스트 종류.
27
+ * - `default` — 정보성 알림
28
+ * - `success` — 성공
29
+ * - `warning` — 주의
30
+ * - `danger` — 오류 (스크린리더에 즉시 읽힘)
31
+ *
32
+ * @default "default"
33
+ */
34
+ variant?: ToastVariant;
35
+ /**
36
+ * 자동 닫힘 시간(ms). `0` 이하면 자동 닫힘 비활성.
37
+ * @default 4000
38
+ */
39
+ duration?: number;
40
+ };
41
+
42
+ /* ───────── Store (외부 상태) ───────── */
43
+
44
+ type Listener = () => void;
45
+
46
+ let toasts: ToastItem[] = [];
47
+ const listeners = new Set<Listener>();
48
+
49
+ const notify = () => listeners.forEach((l) => l());
50
+
51
+ let counter = 0;
52
+ const genId = () => `sh-toast-${++counter}`;
53
+
54
+ function addToast(input: ToastInput): string {
55
+ const id = genId();
56
+ toasts = [
57
+ ...toasts,
58
+ {
59
+ id,
60
+ variant: input.variant ?? "default",
61
+ duration: input.duration ?? 4000,
62
+ title: input.title,
63
+ description: input.description,
64
+ action: input.action,
65
+ },
66
+ ];
67
+ notify();
68
+ return id;
69
+ }
70
+
71
+ function removeToast(id: string) {
72
+ toasts = toasts.filter((t) => t.id !== id);
73
+ notify();
74
+ }
75
+
76
+ function useToastStore() {
77
+ return React.useSyncExternalStore(
78
+ (cb) => {
79
+ listeners.add(cb);
80
+ return () => listeners.delete(cb);
81
+ },
82
+ () => toasts,
83
+ () => toasts,
84
+ );
85
+ }
86
+
87
+ /* ───────── Public API ───────── */
88
+
89
+ /**
90
+ * 토스트 알림을 발생시키는 명령형 API. 문자열만 전달하면 description으로 사용된다.
91
+ * variant별 헬퍼(`toast.success` 등)와 `toast.dismiss(id)`도 함께 노출된다.
92
+ * 사용 전에 앱 어딘가에 `<Toaster />`가 마운트되어 있어야 화면에 보인다.
93
+ *
94
+ * @param input - 문자열(빠른 알림) 또는 `{ title, description, variant, duration, action }`.
95
+ * @returns 생성된 토스트 id. `toast.dismiss(id)`로 수동 닫을 때 사용.
96
+ * @example
97
+ * toast.success("저장 완료");
98
+ * toast({ title: "오류", description: e.message, variant: "danger" });
99
+ */
100
+ export function toast(input: ToastInput | string): string {
101
+ if (typeof input === "string") return addToast({ description: input });
102
+ return addToast(input);
103
+ }
104
+
105
+ toast.success = (input: ToastInput | string) =>
106
+ toast(typeof input === "string" ? { description: input, variant: "success" } : { ...input, variant: "success" });
107
+
108
+ toast.danger = (input: ToastInput | string) =>
109
+ toast(typeof input === "string" ? { description: input, variant: "danger" } : { ...input, variant: "danger" });
110
+
111
+ toast.warning = (input: ToastInput | string) =>
112
+ toast(typeof input === "string" ? { description: input, variant: "warning" } : { ...input, variant: "warning" });
113
+
114
+ toast.dismiss = removeToast;
115
+
116
+ /* ───────── Icons ───────── */
117
+
118
+ function CheckIcon() {
119
+ return (
120
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden>
121
+ <circle cx="8" cy="8" r="7.25" stroke="currentColor" strokeWidth="1.5" />
122
+ <path d="M5 8.5 7 10.5 11 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
123
+ </svg>
124
+ );
125
+ }
126
+
127
+ function AlertIcon() {
128
+ return (
129
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden>
130
+ <circle cx="8" cy="8" r="7.25" stroke="currentColor" strokeWidth="1.5" />
131
+ <path d="M8 5v3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
132
+ <circle cx="8" cy="11" r="0.75" fill="currentColor" />
133
+ </svg>
134
+ );
135
+ }
136
+
137
+ function XIcon() {
138
+ return (
139
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden>
140
+ <circle cx="8" cy="8" r="7.25" stroke="currentColor" strokeWidth="1.5" />
141
+ <path d="M6 6l4 4M10 6l-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
142
+ </svg>
143
+ );
144
+ }
145
+
146
+ const VARIANT_ICON: Record<ToastVariant, React.ReactNode> = {
147
+ default: null,
148
+ success: <CheckIcon />,
149
+ danger: <XIcon />,
150
+ warning: <AlertIcon />,
151
+ };
152
+
153
+ /* ───────── Single Toast ───────── */
154
+
155
+ function ToastCard({ item, onDismiss }: { item: ToastItem; onDismiss: () => void }) {
156
+ const [exiting, setExiting] = React.useState(false);
157
+
158
+ React.useEffect(() => {
159
+ if (item.duration <= 0) return;
160
+ const timer = setTimeout(() => setExiting(true), item.duration);
161
+ return () => clearTimeout(timer);
162
+ }, [item.duration]);
163
+
164
+ const handleAnimationEnd = () => {
165
+ if (exiting) onDismiss();
166
+ };
167
+
168
+ const icon = VARIANT_ICON[item.variant];
169
+
170
+ return (
171
+ <div
172
+ className={cn(toast, byKey[`toast--${item.variant}`])}
173
+ role={item.variant === "danger" ? "alert" : "status"}
174
+ aria-live={item.variant === "danger" ? "assertive" : "polite"}
175
+ data-exiting={exiting || undefined}
176
+ onAnimationEnd={handleAnimationEnd}
177
+ >
178
+ {icon && <span className={toast__icon}>{icon}</span>}
179
+ <div className={toast__body}>
180
+ {item.title && <p className={toast__title}>{item.title}</p>}
181
+ {item.description && <p className={toast__description}>{item.description}</p>}
182
+ </div>
183
+ {item.action && <div className={toast__action}>{item.action}</div>}
184
+ <button
185
+ type="button"
186
+ className={toast__close}
187
+ onClick={() => setExiting(true)}
188
+ aria-label="닫기"
189
+ >
190
+ ×
191
+ </button>
192
+ </div>
193
+ );
194
+ }
195
+
196
+ /* ───────── Toaster (Provider) ───────── */
197
+
198
+ export type ToastPosition =
199
+ | "top-left"
200
+ | "top-right"
201
+ | "top-center"
202
+ | "bottom-left"
203
+ | "bottom-right"
204
+ | "bottom-center";
205
+
206
+ export interface ToasterProps {
207
+ /**
208
+ * 토스트 표시 위치.
209
+ * `top-*` 위치는 새 토스트가 위에 쌓이고, `bottom-*` 위치는 아래에 쌓인다.
210
+ *
211
+ * @default "bottom-right"
212
+ */
213
+ position?: ToastPosition;
214
+ /**
215
+ * Portal이 마운트될 DOM 노드.
216
+ * @default document.body
217
+ */
218
+ container?: Element | null;
219
+ }
220
+
221
+ /**
222
+ * `toast()`로 발생한 알림을 그리는 viewport. 앱 루트에 한 번만 마운트한다.
223
+ * `danger` variant는 `role="alert"`+`aria-live="assertive"`로 즉시 읽히고, 그 외는 `polite`로 큐잉된다.
224
+ */
225
+ export function Toaster({ position = "bottom-right", container }: ToasterProps) {
226
+ const items = useToastStore();
227
+ const [mounted, setMounted] = React.useState(false);
228
+
229
+ React.useEffect(() => setMounted(true), []);
230
+
231
+ if (!mounted || items.length === 0) return null;
232
+
233
+ const isBottom = position.startsWith("bottom");
234
+
235
+ const el = (
236
+ <div
237
+ className={toastViewport}
238
+ data-position={position}
239
+ aria-label="알림"
240
+ >
241
+ {isBottom ? items.map((item) => (
242
+ <ToastCard
243
+ key={item.id}
244
+ item={item}
245
+ onDismiss={() => removeToast(item.id)}
246
+ />
247
+ )) : [...items].reverse().map((item) => (
248
+ <ToastCard
249
+ key={item.id}
250
+ item={item}
251
+ onDismiss={() => removeToast(item.id)}
252
+ />
253
+ ))}
254
+ </div>
255
+ );
256
+
257
+ return createPortal(el, container ?? document.body);
258
+ }