sh-ui-cli 0.15.0 → 0.21.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 (162) hide show
  1. package/bin/sh-ui.mjs +6 -0
  2. package/data/changelog/versions.json +354 -0
  3. package/data/registry/flutter/foundation/sh_ui_tokens.dart +385 -0
  4. package/data/registry/flutter/registry.json +336 -0
  5. package/data/registry/flutter/widgets/sh_ui_accordion.dart +255 -0
  6. package/data/registry/flutter/widgets/sh_ui_app_shell.dart +267 -0
  7. package/data/registry/flutter/widgets/sh_ui_avatar.dart +95 -0
  8. package/data/registry/flutter/widgets/sh_ui_badge.dart +82 -0
  9. package/data/registry/flutter/widgets/sh_ui_breadcrumb.dart +107 -0
  10. package/data/registry/flutter/widgets/sh_ui_button.dart +201 -0
  11. package/data/registry/flutter/widgets/sh_ui_card.dart +159 -0
  12. package/data/registry/flutter/widgets/sh_ui_carousel.dart +204 -0
  13. package/data/registry/flutter/widgets/sh_ui_checkbox.dart +154 -0
  14. package/data/registry/flutter/widgets/sh_ui_color_picker.dart +264 -0
  15. package/data/registry/flutter/widgets/sh_ui_combobox.dart +614 -0
  16. package/data/registry/flutter/widgets/sh_ui_context_menu.dart +71 -0
  17. package/data/registry/flutter/widgets/sh_ui_date_picker.dart +648 -0
  18. package/data/registry/flutter/widgets/sh_ui_dialog.dart +567 -0
  19. package/data/registry/flutter/widgets/sh_ui_dropdown_menu.dart +251 -0
  20. package/data/registry/flutter/widgets/sh_ui_file_upload.dart +200 -0
  21. package/data/registry/flutter/widgets/sh_ui_header.dart +488 -0
  22. package/data/registry/flutter/widgets/sh_ui_input.dart +664 -0
  23. package/data/registry/flutter/widgets/sh_ui_label.dart +145 -0
  24. package/data/registry/flutter/widgets/sh_ui_menubar.dart +98 -0
  25. package/data/registry/flutter/widgets/sh_ui_pagination.dart +276 -0
  26. package/data/registry/flutter/widgets/sh_ui_popover.dart +248 -0
  27. package/data/registry/flutter/widgets/sh_ui_progress.dart +47 -0
  28. package/data/registry/flutter/widgets/sh_ui_radio.dart +108 -0
  29. package/data/registry/flutter/widgets/sh_ui_select.dart +904 -0
  30. package/data/registry/flutter/widgets/sh_ui_separator.dart +42 -0
  31. package/data/registry/flutter/widgets/sh_ui_sidebar.dart +1116 -0
  32. package/data/registry/flutter/widgets/sh_ui_skeleton.dart +129 -0
  33. package/data/registry/flutter/widgets/sh_ui_slider.dart +147 -0
  34. package/data/registry/flutter/widgets/sh_ui_spinner.dart +56 -0
  35. package/data/registry/flutter/widgets/sh_ui_switch.dart +109 -0
  36. package/data/registry/flutter/widgets/sh_ui_tabs.dart +329 -0
  37. package/data/registry/flutter/widgets/sh_ui_textarea.dart +126 -0
  38. package/data/registry/flutter/widgets/sh_ui_toast.dart +362 -0
  39. package/data/registry/flutter/widgets/sh_ui_toggle.dart +229 -0
  40. package/data/registry/flutter/widgets/sh_ui_tooltip.dart +62 -0
  41. package/data/registry/react/components/accordion/index.tsx +85 -0
  42. package/data/registry/react/components/accordion/styles.css +94 -0
  43. package/data/registry/react/components/animations/animations.css +51 -0
  44. package/data/registry/react/components/avatar/index.tsx +75 -0
  45. package/data/registry/react/components/avatar/styles.css +36 -0
  46. package/data/registry/react/components/badge/index.tsx +42 -0
  47. package/data/registry/react/components/badge/styles.css +57 -0
  48. package/data/registry/react/components/base/base.css +102 -0
  49. package/data/registry/react/components/breadcrumb/index.tsx +154 -0
  50. package/data/registry/react/components/breadcrumb/styles.css +82 -0
  51. package/data/registry/react/components/breakpoints/breakpoints.css +17 -0
  52. package/data/registry/react/components/button/index.tsx +47 -0
  53. package/data/registry/react/components/button/styles.css +93 -0
  54. package/data/registry/react/components/card/index.tsx +86 -0
  55. package/data/registry/react/components/card/styles.css +73 -0
  56. package/data/registry/react/components/carousel/index.tsx +432 -0
  57. package/data/registry/react/components/carousel/styles.css +155 -0
  58. package/data/registry/react/components/checkbox/index.tsx +98 -0
  59. package/data/registry/react/components/checkbox/styles.css +75 -0
  60. package/data/registry/react/components/code-panel/copy.tsx +56 -0
  61. package/data/registry/react/components/code-panel/index.tsx +193 -0
  62. package/data/registry/react/components/code-panel/styles.css +124 -0
  63. package/data/registry/react/components/color-picker/index.tsx +466 -0
  64. package/data/registry/react/components/color-picker/styles.css +166 -0
  65. package/data/registry/react/components/combobox/index.tsx +167 -0
  66. package/data/registry/react/components/combobox/styles.css +151 -0
  67. package/data/registry/react/components/context-menu/index.tsx +253 -0
  68. package/data/registry/react/components/context-menu/styles.css +140 -0
  69. package/data/registry/react/components/date-picker/index.tsx +757 -0
  70. package/data/registry/react/components/date-picker/styles.css +279 -0
  71. package/data/registry/react/components/dialog/index.tsx +97 -0
  72. package/data/registry/react/components/dialog/styles.css +127 -0
  73. package/data/registry/react/components/dropdown-menu/index.tsx +257 -0
  74. package/data/registry/react/components/dropdown-menu/styles.css +150 -0
  75. package/data/registry/react/components/file-upload/index.tsx +489 -0
  76. package/data/registry/react/components/file-upload/styles.css +170 -0
  77. package/data/registry/react/components/focus-ring/focus-ring.css +23 -0
  78. package/data/registry/react/components/form/context.ts +92 -0
  79. package/data/registry/react/components/form/field.test.tsx +230 -0
  80. package/data/registry/react/components/form/field.tsx +236 -0
  81. package/data/registry/react/components/form/focus-first-error.ts +54 -0
  82. package/data/registry/react/components/form/form.section.test.tsx +58 -0
  83. package/data/registry/react/components/form/form.test.tsx +146 -0
  84. package/data/registry/react/components/form/form.tsx +180 -0
  85. package/data/registry/react/components/form/index.tsx +61 -0
  86. package/data/registry/react/components/form/steps.test.tsx +106 -0
  87. package/data/registry/react/components/form/steps.tsx +193 -0
  88. package/data/registry/react/components/form/store.test.ts +206 -0
  89. package/data/registry/react/components/form/store.ts +318 -0
  90. package/data/registry/react/components/form/styles.css +47 -0
  91. package/data/registry/react/components/form/types.ts +104 -0
  92. package/data/registry/react/components/form/use-sh-ui-form.ts +15 -0
  93. package/data/registry/react/components/form/utils.test.ts +44 -0
  94. package/data/registry/react/components/form/utils.ts +49 -0
  95. package/data/registry/react/components/form/validation.test.ts +67 -0
  96. package/data/registry/react/components/form/validation.ts +64 -0
  97. package/data/registry/react/components/form-rhf/README.md +27 -0
  98. package/data/registry/react/components/form-rhf/index.tsx +289 -0
  99. package/data/registry/react/components/form-rhf/rhf.test.tsx +42 -0
  100. package/data/registry/react/components/form-tanstack/README.md +27 -0
  101. package/data/registry/react/components/form-tanstack/index.tsx +352 -0
  102. package/data/registry/react/components/form-tanstack/tanstack.test.tsx +45 -0
  103. package/data/registry/react/components/form-yup/README.md +22 -0
  104. package/data/registry/react/components/form-yup/index.tsx +50 -0
  105. package/data/registry/react/components/form-yup/yup.test.ts +27 -0
  106. package/data/registry/react/components/header/index.tsx +257 -0
  107. package/data/registry/react/components/header/styles.css +190 -0
  108. package/data/registry/react/components/input/index.tsx +517 -0
  109. package/data/registry/react/components/input/styles.css +203 -0
  110. package/data/registry/react/components/label/index.tsx +54 -0
  111. package/data/registry/react/components/label/styles.css +90 -0
  112. package/data/registry/react/components/menubar/index.tsx +34 -0
  113. package/data/registry/react/components/menubar/styles.css +45 -0
  114. package/data/registry/react/components/pagination/index.tsx +271 -0
  115. package/data/registry/react/components/pagination/styles.css +105 -0
  116. package/data/registry/react/components/popover/index.tsx +115 -0
  117. package/data/registry/react/components/popover/styles.css +65 -0
  118. package/data/registry/react/components/progress/index.tsx +56 -0
  119. package/data/registry/react/components/progress/styles.css +41 -0
  120. package/data/registry/react/components/radio/index.tsx +67 -0
  121. package/data/registry/react/components/radio/styles.css +80 -0
  122. package/data/registry/react/components/select/index.tsx +236 -0
  123. package/data/registry/react/components/select/styles.css +193 -0
  124. package/data/registry/react/components/separator/index.tsx +48 -0
  125. package/data/registry/react/components/separator/styles.css +15 -0
  126. package/data/registry/react/components/sidebar/index.tsx +1084 -0
  127. package/data/registry/react/components/sidebar/styles.css +502 -0
  128. package/data/registry/react/components/skeleton/index.tsx +24 -0
  129. package/data/registry/react/components/skeleton/styles.css +24 -0
  130. package/data/registry/react/components/slider/index.tsx +300 -0
  131. package/data/registry/react/components/slider/styles.css +64 -0
  132. package/data/registry/react/components/spinner/index.tsx +40 -0
  133. package/data/registry/react/components/spinner/styles.css +37 -0
  134. package/data/registry/react/components/switch/index.tsx +41 -0
  135. package/data/registry/react/components/switch/styles.css +83 -0
  136. package/data/registry/react/components/tabs/index.tsx +93 -0
  137. package/data/registry/react/components/tabs/styles.css +148 -0
  138. package/data/registry/react/components/textarea/index.tsx +25 -0
  139. package/data/registry/react/components/textarea/styles.css +54 -0
  140. package/data/registry/react/components/theme/index.tsx +91 -0
  141. package/data/registry/react/components/toast/index.tsx +257 -0
  142. package/data/registry/react/components/toast/styles.css +290 -0
  143. package/data/registry/react/components/toggle/index.tsx +133 -0
  144. package/data/registry/react/components/toggle/styles.css +85 -0
  145. package/data/registry/react/components/tooltip/index.tsx +85 -0
  146. package/data/registry/react/components/tooltip/styles.css +44 -0
  147. package/data/registry/react/components/z-index/z-index.css +16 -0
  148. package/data/registry/react/hooks/use-active-section.ts +104 -0
  149. package/data/registry/react/hooks/use-media-query.ts +27 -0
  150. package/data/registry/react/lib/cn.ts +39 -0
  151. package/data/registry/react/registry.json +835 -0
  152. package/data/summaries/flutter.json +42 -0
  153. package/data/summaries/react.json +50 -0
  154. package/data/tokens/build.mjs +553 -0
  155. package/data/tokens/src/primitives.json +146 -0
  156. package/data/tokens/src/semantic.json +146 -0
  157. package/package.json +9 -2
  158. package/src/add.mjs +13 -12
  159. package/src/list.mjs +3 -11
  160. package/src/mcp.mjs +308 -0
  161. package/src/paths.mjs +52 -0
  162. package/src/remove.mjs +4 -11
@@ -0,0 +1,148 @@
1
+ .sh-ui-tabs {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--space-3);
5
+ width: 100%;
6
+ }
7
+
8
+ .sh-ui-tabs[data-orientation="vertical"] {
9
+ flex-direction: row;
10
+ }
11
+
12
+ .sh-ui-tabs__list {
13
+ position: relative;
14
+ display: inline-flex;
15
+ align-items: center;
16
+ gap: var(--space-1);
17
+ width: fit-content;
18
+ }
19
+
20
+ .sh-ui-tabs[data-orientation="vertical"] > .sh-ui-tabs__list {
21
+ flex-direction: column;
22
+ align-items: stretch;
23
+ }
24
+
25
+ .sh-ui-tabs__trigger {
26
+ position: relative;
27
+ z-index: 1;
28
+ display: inline-flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ gap: 0.375rem;
32
+ padding: var(--space-2) var(--space-3);
33
+ background: transparent;
34
+ color: var(--foreground-muted, var(--foreground));
35
+ border: 0;
36
+ font-size: var(--text-sm);
37
+ font-weight: var(--weight-medium);
38
+ line-height: 1;
39
+ cursor: pointer;
40
+ transition: color var(--duration-fast), background-color var(--duration-fast);
41
+ user-select: none;
42
+ -webkit-tap-highlight-color: transparent;
43
+ white-space: nowrap;
44
+ }
45
+
46
+ .sh-ui-tabs__trigger:hover:not(:disabled):not([data-selected]) {
47
+ color: var(--foreground);
48
+ }
49
+
50
+ .sh-ui-tabs__trigger:focus-visible {
51
+ outline: var(--border-width-strong) solid var(--foreground);
52
+ outline-offset: 2px;
53
+ }
54
+
55
+ .sh-ui-tabs__trigger[data-selected] {
56
+ color: var(--foreground);
57
+ }
58
+
59
+ .sh-ui-tabs__trigger:disabled {
60
+ opacity: var(--opacity-disabled);
61
+ cursor: not-allowed;
62
+ }
63
+
64
+ .sh-ui-tabs__indicator {
65
+ position: absolute;
66
+ transition: top 180ms, left 180ms, width 180ms, height 180ms;
67
+ z-index: 0;
68
+ pointer-events: none;
69
+ }
70
+
71
+ .sh-ui-tabs__indicator[data-activation-direction="none"] {
72
+ transition: none;
73
+ }
74
+
75
+ .sh-ui-tabs__content {
76
+ outline: none;
77
+ }
78
+
79
+ .sh-ui-tabs__content:focus-visible {
80
+ outline: var(--border-width-strong) solid var(--foreground);
81
+ outline-offset: 2px;
82
+ border-radius: var(--radius);
83
+ }
84
+
85
+ /* ─────────────── variant: underline (기본) ─────────────── */
86
+ .sh-ui-tabs[data-variant="underline"] > .sh-ui-tabs__list {
87
+ width: 100%;
88
+ gap: 0;
89
+ box-shadow: inset 0 -1px 0 var(--border);
90
+ }
91
+
92
+ .sh-ui-tabs[data-variant="underline"] .sh-ui-tabs__trigger {
93
+ padding: 0.625rem var(--space-4);
94
+ }
95
+
96
+ .sh-ui-tabs[data-variant="underline"] .sh-ui-tabs__indicator {
97
+ top: var(--active-tab-top);
98
+ left: var(--active-tab-left);
99
+ width: var(--active-tab-width);
100
+ height: var(--active-tab-height);
101
+ box-shadow: inset 0 -2px 0 var(--foreground);
102
+ }
103
+
104
+ .sh-ui-tabs[data-variant="underline"][data-orientation="vertical"] > .sh-ui-tabs__list {
105
+ width: auto;
106
+ box-shadow: inset -1px 0 0 var(--border);
107
+ }
108
+
109
+ .sh-ui-tabs[data-variant="underline"][data-orientation="vertical"] .sh-ui-tabs__indicator {
110
+ box-shadow: inset -2px 0 0 var(--foreground);
111
+ }
112
+
113
+ /* ─────────────── variant: pill (세그먼트) ─────────────── */
114
+ .sh-ui-tabs[data-variant="pill"] > .sh-ui-tabs__list {
115
+ padding: var(--space-1);
116
+ background: var(--background-muted, var(--background));
117
+ border: 1px solid var(--border);
118
+ border-radius: var(--radius);
119
+ }
120
+
121
+ .sh-ui-tabs[data-variant="pill"] .sh-ui-tabs__trigger {
122
+ padding: 0.375rem var(--space-3);
123
+ border-radius: calc(var(--radius) - 2px);
124
+ }
125
+
126
+ .sh-ui-tabs[data-variant="pill"] .sh-ui-tabs__indicator {
127
+ top: var(--active-tab-top);
128
+ left: var(--active-tab-left);
129
+ width: var(--active-tab-width);
130
+ height: var(--active-tab-height);
131
+ background: var(--background);
132
+ border-radius: calc(var(--radius) - 2px);
133
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
134
+ }
135
+
136
+ /* ─────────────── variant: plain (컨테이너 없음) ─────────────── */
137
+ .sh-ui-tabs[data-variant="plain"] .sh-ui-tabs__trigger {
138
+ padding: 0.375rem var(--space-2);
139
+ border-radius: calc(var(--radius) - 2px);
140
+ }
141
+
142
+ .sh-ui-tabs[data-variant="plain"] .sh-ui-tabs__trigger[data-selected] {
143
+ background: var(--background-muted, transparent);
144
+ }
145
+
146
+ .sh-ui-tabs[data-variant="plain"] .sh-ui-tabs__indicator {
147
+ display: none;
148
+ }
@@ -0,0 +1,25 @@
1
+ import * as React from "react";
2
+ import "./styles.css";
3
+
4
+ export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
5
+
6
+ function cx(...args: (string | undefined | false)[]) {
7
+ return args.filter(Boolean).join(" ");
8
+ }
9
+
10
+ /**
11
+ * 여러 줄 텍스트 입력. 네이티브 `<textarea>` 위에 sh-ui 토큰 스타일만 입혔다.
12
+ * 라벨/오류 메시지는 외부에서 조립한다 — 단독 사용 시 `<Label>`과 `aria-describedby` 연결 권장.
13
+ */
14
+ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
15
+ ({ className, ...props }, ref) => {
16
+ return (
17
+ <textarea
18
+ ref={ref}
19
+ className={cx("sh-ui-textarea", className)}
20
+ {...props}
21
+ />
22
+ );
23
+ },
24
+ );
25
+ Textarea.displayName = "Textarea";
@@ -0,0 +1,54 @@
1
+ .sh-ui-textarea {
2
+ display: block;
3
+ width: 100%;
4
+ min-height: 5rem;
5
+ padding: var(--space-2) var(--space-3);
6
+ background: var(--background);
7
+ color: var(--foreground);
8
+ border: 1px solid var(--border);
9
+ border-radius: var(--radius);
10
+ font-family: inherit;
11
+ font-size: var(--text-sm);
12
+ line-height: 1.5;
13
+ resize: vertical;
14
+ transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
15
+ -webkit-tap-highlight-color: transparent;
16
+ }
17
+
18
+ @media (hover: none) and (pointer: coarse) {
19
+ .sh-ui-textarea {
20
+ font-size: var(--text-base);
21
+ }
22
+ }
23
+
24
+ .sh-ui-textarea::placeholder {
25
+ color: var(--foreground-subtle);
26
+ }
27
+
28
+ .sh-ui-textarea:hover:not(:disabled):not(:focus) {
29
+ border-color: var(--border-strong);
30
+ }
31
+
32
+ .sh-ui-textarea:focus {
33
+ outline: none;
34
+ border-color: var(--foreground);
35
+ box-shadow: 0 0 0 1px var(--foreground);
36
+ }
37
+
38
+ .sh-ui-textarea:disabled {
39
+ opacity: var(--opacity-disabled);
40
+ cursor: not-allowed;
41
+ background: var(--background-subtle);
42
+ }
43
+
44
+ .sh-ui-textarea:read-only {
45
+ background: var(--background-subtle);
46
+ }
47
+
48
+ /* 에러 상태 — aria-invalid="true" 기반 */
49
+ .sh-ui-textarea[aria-invalid="true"] {
50
+ border-color: var(--danger);
51
+ }
52
+ .sh-ui-textarea[aria-invalid="true"]:focus {
53
+ box-shadow: 0 0 0 1px var(--danger);
54
+ }
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ type Theme = "light" | "dark";
6
+
7
+ const THEME_COOKIE_NAME = "sh-ui-theme";
8
+ const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
9
+
10
+ /* ───────────── Context ───────────── */
11
+
12
+ type ThemeContextValue = {
13
+ theme: Theme;
14
+ setTheme: (theme: Theme) => void;
15
+ toggleTheme: () => void;
16
+ };
17
+
18
+ const ThemeContext = React.createContext<ThemeContextValue | null>(null);
19
+
20
+ /** 현재 테마와 setter를 반환한다. ThemeProvider 안에서만 호출 가능. */
21
+ export function useTheme() {
22
+ const ctx = React.useContext(ThemeContext);
23
+ if (!ctx) throw new Error("useTheme must be used within a ThemeProvider.");
24
+ return ctx;
25
+ }
26
+
27
+ /* ───────────── Provider ───────────── */
28
+
29
+ export interface ThemeProviderProps {
30
+ /**
31
+ * SSR 시 쿠키에서 읽은 초기 테마. 클라이언트 첫 렌더와 SSR 마크업이 일치하도록
32
+ * 서버에서 쿠키를 읽어 주입해야 hydration mismatch가 없다.
33
+ *
34
+ * @default "light"
35
+ * @example
36
+ * // Next.js App Router
37
+ * const t = (await cookies()).get("sh-ui-theme")?.value;
38
+ * <ThemeProvider defaultTheme={t === "dark" ? "dark" : "light"}>
39
+ */
40
+ defaultTheme?: Theme;
41
+ /**
42
+ * 외부에서 테마를 제어할 때 사용. 지정하면 내부 state 대신 이 값이 우선한다.
43
+ * 보통 `defaultTheme` 비제어 모드로 충분.
44
+ */
45
+ theme?: Theme;
46
+ /** 테마 변경 콜백. 제어 모드에서는 이 콜백 안에서 외부 상태를 업데이트해야 한다. */
47
+ onThemeChange?: (theme: Theme) => void;
48
+ children: React.ReactNode;
49
+ }
50
+
51
+ /**
52
+ * 다크/라이트 테마 컨텍스트와 `<html class="dark">` 토글, 쿠키 영속화를 담당하는 Provider.
53
+ * SSR 하이드레이션 시프트를 피하려면 서버에서 쿠키를 읽어 `defaultTheme`로 주입할 것.
54
+ */
55
+ export function ThemeProvider({
56
+ defaultTheme = "light",
57
+ theme: themeProp,
58
+ onThemeChange,
59
+ children,
60
+ }: ThemeProviderProps) {
61
+ const [_theme, _setTheme] = React.useState<Theme>(defaultTheme);
62
+ const theme = themeProp ?? _theme;
63
+
64
+ const setTheme = React.useCallback(
65
+ (next: Theme) => {
66
+ if (onThemeChange) onThemeChange(next);
67
+ else _setTheme(next);
68
+
69
+ if (typeof document !== "undefined") {
70
+ document.documentElement.classList.toggle("dark", next === "dark");
71
+ document.cookie = `${THEME_COOKIE_NAME}=${next}; path=/; max-age=${THEME_COOKIE_MAX_AGE}`;
72
+ }
73
+ },
74
+ [onThemeChange],
75
+ );
76
+
77
+ const toggleTheme = React.useCallback(() => {
78
+ setTheme(theme === "dark" ? "light" : "dark");
79
+ }, [theme, setTheme]);
80
+
81
+ const value = React.useMemo<ThemeContextValue>(
82
+ () => ({ theme, setTheme, toggleTheme }),
83
+ [theme, setTheme, toggleTheme],
84
+ );
85
+
86
+ return (
87
+ <ThemeContext.Provider value={value}>
88
+ {children}
89
+ </ThemeContext.Provider>
90
+ );
91
+ }
@@ -0,0 +1,257 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { createPortal } from "react-dom";
5
+ import "./styles.css";
6
+
7
+ /* ───────── Types ───────── */
8
+
9
+ type ToastVariant = "default" | "success" | "danger" | "warning";
10
+
11
+ interface ToastItem {
12
+ id: string;
13
+ /** 짧은 제목. 한 줄 굵게 표시. */
14
+ title?: React.ReactNode;
15
+ /** 본문 설명. 줄바꿈 가능. */
16
+ description?: React.ReactNode;
17
+ variant: ToastVariant;
18
+ duration: number;
19
+ /** 우측에 표시될 액션 노드(예: "다시 시도" 버튼). */
20
+ action?: React.ReactNode;
21
+ }
22
+
23
+ type ToastInput = Omit<ToastItem, "id" | "variant" | "duration"> & {
24
+ /**
25
+ * 토스트 종류.
26
+ * - `default` — 정보성 알림
27
+ * - `success` — 성공
28
+ * - `warning` — 주의
29
+ * - `danger` — 오류 (스크린리더에 즉시 읽힘)
30
+ *
31
+ * @default "default"
32
+ */
33
+ variant?: ToastVariant;
34
+ /**
35
+ * 자동 닫힘 시간(ms). `0` 이하면 자동 닫힘 비활성.
36
+ * @default 4000
37
+ */
38
+ duration?: number;
39
+ };
40
+
41
+ /* ───────── Store (외부 상태) ───────── */
42
+
43
+ type Listener = () => void;
44
+
45
+ let toasts: ToastItem[] = [];
46
+ const listeners = new Set<Listener>();
47
+
48
+ const notify = () => listeners.forEach((l) => l());
49
+
50
+ let counter = 0;
51
+ const genId = () => `sh-toast-${++counter}`;
52
+
53
+ function addToast(input: ToastInput): string {
54
+ const id = genId();
55
+ toasts = [
56
+ ...toasts,
57
+ {
58
+ id,
59
+ variant: input.variant ?? "default",
60
+ duration: input.duration ?? 4000,
61
+ title: input.title,
62
+ description: input.description,
63
+ action: input.action,
64
+ },
65
+ ];
66
+ notify();
67
+ return id;
68
+ }
69
+
70
+ function removeToast(id: string) {
71
+ toasts = toasts.filter((t) => t.id !== id);
72
+ notify();
73
+ }
74
+
75
+ function useToastStore() {
76
+ return React.useSyncExternalStore(
77
+ (cb) => {
78
+ listeners.add(cb);
79
+ return () => listeners.delete(cb);
80
+ },
81
+ () => toasts,
82
+ () => toasts,
83
+ );
84
+ }
85
+
86
+ /* ───────── Public API ───────── */
87
+
88
+ /**
89
+ * 토스트 알림을 발생시키는 명령형 API. 문자열만 전달하면 description으로 사용된다.
90
+ * variant별 헬퍼(`toast.success` 등)와 `toast.dismiss(id)`도 함께 노출된다.
91
+ * 사용 전에 앱 어딘가에 `<Toaster />`가 마운트되어 있어야 화면에 보인다.
92
+ *
93
+ * @param input - 문자열(빠른 알림) 또는 `{ title, description, variant, duration, action }`.
94
+ * @returns 생성된 토스트 id. `toast.dismiss(id)`로 수동 닫을 때 사용.
95
+ * @example
96
+ * toast.success("저장 완료");
97
+ * toast({ title: "오류", description: e.message, variant: "danger" });
98
+ */
99
+ export function toast(input: ToastInput | string): string {
100
+ if (typeof input === "string") return addToast({ description: input });
101
+ return addToast(input);
102
+ }
103
+
104
+ toast.success = (input: ToastInput | string) =>
105
+ toast(typeof input === "string" ? { description: input, variant: "success" } : { ...input, variant: "success" });
106
+
107
+ toast.danger = (input: ToastInput | string) =>
108
+ toast(typeof input === "string" ? { description: input, variant: "danger" } : { ...input, variant: "danger" });
109
+
110
+ toast.warning = (input: ToastInput | string) =>
111
+ toast(typeof input === "string" ? { description: input, variant: "warning" } : { ...input, variant: "warning" });
112
+
113
+ toast.dismiss = removeToast;
114
+
115
+ /* ───────── Icons ───────── */
116
+
117
+ function CheckIcon() {
118
+ return (
119
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden>
120
+ <circle cx="8" cy="8" r="7.25" stroke="currentColor" strokeWidth="1.5" />
121
+ <path d="M5 8.5 7 10.5 11 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
122
+ </svg>
123
+ );
124
+ }
125
+
126
+ function AlertIcon() {
127
+ return (
128
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden>
129
+ <circle cx="8" cy="8" r="7.25" stroke="currentColor" strokeWidth="1.5" />
130
+ <path d="M8 5v3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
131
+ <circle cx="8" cy="11" r="0.75" fill="currentColor" />
132
+ </svg>
133
+ );
134
+ }
135
+
136
+ function XIcon() {
137
+ return (
138
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden>
139
+ <circle cx="8" cy="8" r="7.25" stroke="currentColor" strokeWidth="1.5" />
140
+ <path d="M6 6l4 4M10 6l-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
141
+ </svg>
142
+ );
143
+ }
144
+
145
+ const VARIANT_ICON: Record<ToastVariant, React.ReactNode> = {
146
+ default: null,
147
+ success: <CheckIcon />,
148
+ danger: <XIcon />,
149
+ warning: <AlertIcon />,
150
+ };
151
+
152
+ /* ───────── Single Toast ───────── */
153
+
154
+ function ToastCard({ item, onDismiss }: { item: ToastItem; onDismiss: () => void }) {
155
+ const [exiting, setExiting] = React.useState(false);
156
+
157
+ React.useEffect(() => {
158
+ if (item.duration <= 0) return;
159
+ const timer = setTimeout(() => setExiting(true), item.duration);
160
+ return () => clearTimeout(timer);
161
+ }, [item.duration]);
162
+
163
+ const handleAnimationEnd = () => {
164
+ if (exiting) onDismiss();
165
+ };
166
+
167
+ const icon = VARIANT_ICON[item.variant];
168
+
169
+ return (
170
+ <div
171
+ className={`sh-ui-toast sh-ui-toast--${item.variant}`}
172
+ role={item.variant === "danger" ? "alert" : "status"}
173
+ aria-live={item.variant === "danger" ? "assertive" : "polite"}
174
+ data-exiting={exiting || undefined}
175
+ onAnimationEnd={handleAnimationEnd}
176
+ >
177
+ {icon && <span className="sh-ui-toast__icon">{icon}</span>}
178
+ <div className="sh-ui-toast__body">
179
+ {item.title && <p className="sh-ui-toast__title">{item.title}</p>}
180
+ {item.description && <p className="sh-ui-toast__description">{item.description}</p>}
181
+ </div>
182
+ {item.action && <div className="sh-ui-toast__action">{item.action}</div>}
183
+ <button
184
+ type="button"
185
+ className="sh-ui-toast__close"
186
+ onClick={() => setExiting(true)}
187
+ aria-label="닫기"
188
+ >
189
+ ×
190
+ </button>
191
+ </div>
192
+ );
193
+ }
194
+
195
+ /* ───────── Toaster (Provider) ───────── */
196
+
197
+ export type ToastPosition =
198
+ | "top-left"
199
+ | "top-right"
200
+ | "top-center"
201
+ | "bottom-left"
202
+ | "bottom-right"
203
+ | "bottom-center";
204
+
205
+ export interface ToasterProps {
206
+ /**
207
+ * 토스트 표시 위치.
208
+ * `top-*` 위치는 새 토스트가 위에 쌓이고, `bottom-*` 위치는 아래에 쌓인다.
209
+ *
210
+ * @default "bottom-right"
211
+ */
212
+ position?: ToastPosition;
213
+ /**
214
+ * Portal이 마운트될 DOM 노드.
215
+ * @default document.body
216
+ */
217
+ container?: Element | null;
218
+ }
219
+
220
+ /**
221
+ * `toast()`로 발생한 알림을 그리는 viewport. 앱 루트에 한 번만 마운트한다.
222
+ * `danger` variant는 `role="alert"`+`aria-live="assertive"`로 즉시 읽히고, 그 외는 `polite`로 큐잉된다.
223
+ */
224
+ export function Toaster({ position = "bottom-right", container }: ToasterProps) {
225
+ const items = useToastStore();
226
+ const [mounted, setMounted] = React.useState(false);
227
+
228
+ React.useEffect(() => setMounted(true), []);
229
+
230
+ if (!mounted || items.length === 0) return null;
231
+
232
+ const isBottom = position.startsWith("bottom");
233
+
234
+ const el = (
235
+ <div
236
+ className="sh-ui-toast-viewport"
237
+ data-position={position}
238
+ aria-label="알림"
239
+ >
240
+ {isBottom ? items.map((item) => (
241
+ <ToastCard
242
+ key={item.id}
243
+ item={item}
244
+ onDismiss={() => removeToast(item.id)}
245
+ />
246
+ )) : [...items].reverse().map((item) => (
247
+ <ToastCard
248
+ key={item.id}
249
+ item={item}
250
+ onDismiss={() => removeToast(item.id)}
251
+ />
252
+ ))}
253
+ </div>
254
+ );
255
+
256
+ return createPortal(el, container ?? document.body);
257
+ }