moru-krds-react 0.1.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 (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/dist/accordion/index.d.ts +63 -0
  4. package/dist/accordion/index.d.ts.map +1 -0
  5. package/dist/accordion/index.js +145 -0
  6. package/dist/accordion/index.js.map +1 -0
  7. package/dist/alert/index.d.ts +44 -0
  8. package/dist/alert/index.d.ts.map +1 -0
  9. package/dist/alert/index.js +66 -0
  10. package/dist/alert/index.js.map +1 -0
  11. package/dist/avatar/index.d.ts +102 -0
  12. package/dist/avatar/index.d.ts.map +1 -0
  13. package/dist/avatar/index.js +146 -0
  14. package/dist/avatar/index.js.map +1 -0
  15. package/dist/badge/index.d.ts +49 -0
  16. package/dist/badge/index.d.ts.map +1 -0
  17. package/dist/badge/index.js +41 -0
  18. package/dist/badge/index.js.map +1 -0
  19. package/dist/breadcrumb/index.d.ts +43 -0
  20. package/dist/breadcrumb/index.d.ts.map +1 -0
  21. package/dist/breadcrumb/index.js +43 -0
  22. package/dist/breadcrumb/index.js.map +1 -0
  23. package/dist/button/index.d.ts +78 -0
  24. package/dist/button/index.d.ts.map +1 -0
  25. package/dist/button/index.js +83 -0
  26. package/dist/button/index.js.map +1 -0
  27. package/dist/checkbox/index.d.ts +97 -0
  28. package/dist/checkbox/index.d.ts.map +1 -0
  29. package/dist/checkbox/index.js +110 -0
  30. package/dist/checkbox/index.js.map +1 -0
  31. package/dist/combobox/index.d.ts +135 -0
  32. package/dist/combobox/index.d.ts.map +1 -0
  33. package/dist/combobox/index.js +416 -0
  34. package/dist/combobox/index.js.map +1 -0
  35. package/dist/context-menu/index.d.ts +94 -0
  36. package/dist/context-menu/index.d.ts.map +1 -0
  37. package/dist/context-menu/index.js +399 -0
  38. package/dist/context-menu/index.js.map +1 -0
  39. package/dist/critical-alert/index.d.ts +39 -0
  40. package/dist/critical-alert/index.d.ts.map +1 -0
  41. package/dist/critical-alert/index.js +50 -0
  42. package/dist/critical-alert/index.js.map +1 -0
  43. package/dist/data-table/index.d.ts +94 -0
  44. package/dist/data-table/index.d.ts.map +1 -0
  45. package/dist/data-table/index.js +128 -0
  46. package/dist/data-table/index.js.map +1 -0
  47. package/dist/disclosure/index.d.ts +35 -0
  48. package/dist/disclosure/index.d.ts.map +1 -0
  49. package/dist/disclosure/index.js +54 -0
  50. package/dist/disclosure/index.js.map +1 -0
  51. package/dist/drawer/index.d.ts +106 -0
  52. package/dist/drawer/index.d.ts.map +1 -0
  53. package/dist/drawer/index.js +352 -0
  54. package/dist/drawer/index.js.map +1 -0
  55. package/dist/empty-state/index.d.ts +86 -0
  56. package/dist/empty-state/index.d.ts.map +1 -0
  57. package/dist/empty-state/index.js +138 -0
  58. package/dist/empty-state/index.js.map +1 -0
  59. package/dist/env.d.js +1 -0
  60. package/dist/env.d.js.map +1 -0
  61. package/dist/field/index.d.ts +149 -0
  62. package/dist/field/index.d.ts.map +1 -0
  63. package/dist/field/index.js +121 -0
  64. package/dist/field/index.js.map +1 -0
  65. package/dist/file-upload/index.d.ts +140 -0
  66. package/dist/file-upload/index.d.ts.map +1 -0
  67. package/dist/file-upload/index.js +249 -0
  68. package/dist/file-upload/index.js.map +1 -0
  69. package/dist/footer/index.d.ts +155 -0
  70. package/dist/footer/index.d.ts.map +1 -0
  71. package/dist/footer/index.js +186 -0
  72. package/dist/footer/index.js.map +1 -0
  73. package/dist/header/index.d.ts +170 -0
  74. package/dist/header/index.d.ts.map +1 -0
  75. package/dist/header/index.js +258 -0
  76. package/dist/header/index.js.map +1 -0
  77. package/dist/identifier/index.d.ts +38 -0
  78. package/dist/identifier/index.d.ts.map +1 -0
  79. package/dist/identifier/index.js +55 -0
  80. package/dist/identifier/index.js.map +1 -0
  81. package/dist/in-page-navigation/index.d.ts +71 -0
  82. package/dist/in-page-navigation/index.d.ts.map +1 -0
  83. package/dist/in-page-navigation/index.js +137 -0
  84. package/dist/in-page-navigation/index.js.map +1 -0
  85. package/dist/index.d.ts +57 -0
  86. package/dist/index.d.ts.map +1 -0
  87. package/dist/index.js +47 -0
  88. package/dist/index.js.map +1 -0
  89. package/dist/input-group/index.d.ts +66 -0
  90. package/dist/input-group/index.d.ts.map +1 -0
  91. package/dist/input-group/index.js +85 -0
  92. package/dist/input-group/index.js.map +1 -0
  93. package/dist/lib/krds.d.ts +45 -0
  94. package/dist/lib/krds.d.ts.map +1 -0
  95. package/dist/lib/krds.js +11 -0
  96. package/dist/lib/krds.js.map +1 -0
  97. package/dist/lib/link.d.ts +34 -0
  98. package/dist/lib/link.d.ts.map +1 -0
  99. package/dist/lib/link.js +8 -0
  100. package/dist/lib/link.js.map +1 -0
  101. package/dist/main-menu/index.d.ts +123 -0
  102. package/dist/main-menu/index.d.ts.map +1 -0
  103. package/dist/main-menu/index.js +613 -0
  104. package/dist/main-menu/index.js.map +1 -0
  105. package/dist/masthead/index.d.ts +25 -0
  106. package/dist/masthead/index.d.ts.map +1 -0
  107. package/dist/masthead/index.js +21 -0
  108. package/dist/masthead/index.js.map +1 -0
  109. package/dist/modal/index.d.ts +105 -0
  110. package/dist/modal/index.d.ts.map +1 -0
  111. package/dist/modal/index.js +285 -0
  112. package/dist/modal/index.js.map +1 -0
  113. package/dist/pagination/index.d.ts +58 -0
  114. package/dist/pagination/index.d.ts.map +1 -0
  115. package/dist/pagination/index.js +82 -0
  116. package/dist/pagination/index.js.map +1 -0
  117. package/dist/progress/index.d.ts +58 -0
  118. package/dist/progress/index.d.ts.map +1 -0
  119. package/dist/progress/index.js +125 -0
  120. package/dist/progress/index.js.map +1 -0
  121. package/dist/radio/index.d.ts +111 -0
  122. package/dist/radio/index.d.ts.map +1 -0
  123. package/dist/radio/index.js +136 -0
  124. package/dist/radio/index.js.map +1 -0
  125. package/dist/resize/index.d.ts +118 -0
  126. package/dist/resize/index.d.ts.map +1 -0
  127. package/dist/resize/index.js +342 -0
  128. package/dist/resize/index.js.map +1 -0
  129. package/dist/scroll-area/index.d.ts +40 -0
  130. package/dist/scroll-area/index.d.ts.map +1 -0
  131. package/dist/scroll-area/index.js +52 -0
  132. package/dist/scroll-area/index.js.map +1 -0
  133. package/dist/select/index.d.ts +67 -0
  134. package/dist/select/index.d.ts.map +1 -0
  135. package/dist/select/index.js +61 -0
  136. package/dist/select/index.js.map +1 -0
  137. package/dist/separator/index.d.ts +47 -0
  138. package/dist/separator/index.d.ts.map +1 -0
  139. package/dist/separator/index.js +61 -0
  140. package/dist/separator/index.js.map +1 -0
  141. package/dist/side-navigation/index.d.ts +78 -0
  142. package/dist/side-navigation/index.d.ts.map +1 -0
  143. package/dist/side-navigation/index.js +188 -0
  144. package/dist/side-navigation/index.js.map +1 -0
  145. package/dist/skeleton/index.d.ts +76 -0
  146. package/dist/skeleton/index.d.ts.map +1 -0
  147. package/dist/skeleton/index.js +78 -0
  148. package/dist/skeleton/index.js.map +1 -0
  149. package/dist/skip-link/index.d.ts +46 -0
  150. package/dist/skip-link/index.d.ts.map +1 -0
  151. package/dist/skip-link/index.js +37 -0
  152. package/dist/skip-link/index.js.map +1 -0
  153. package/dist/spinner/index.d.ts +58 -0
  154. package/dist/spinner/index.d.ts.map +1 -0
  155. package/dist/spinner/index.js +36 -0
  156. package/dist/spinner/index.js.map +1 -0
  157. package/dist/step-indicator/index.d.ts +41 -0
  158. package/dist/step-indicator/index.d.ts.map +1 -0
  159. package/dist/step-indicator/index.js +63 -0
  160. package/dist/step-indicator/index.js.map +1 -0
  161. package/dist/structured-list/index.d.ts +150 -0
  162. package/dist/structured-list/index.d.ts.map +1 -0
  163. package/dist/structured-list/index.js +194 -0
  164. package/dist/structured-list/index.js.map +1 -0
  165. package/dist/tab/index.d.ts +108 -0
  166. package/dist/tab/index.d.ts.map +1 -0
  167. package/dist/tab/index.js +198 -0
  168. package/dist/tab/index.js.map +1 -0
  169. package/dist/table/index.d.ts +75 -0
  170. package/dist/table/index.d.ts.map +1 -0
  171. package/dist/table/index.js +91 -0
  172. package/dist/table/index.js.map +1 -0
  173. package/dist/tag/index.d.ts +71 -0
  174. package/dist/tag/index.d.ts.map +1 -0
  175. package/dist/tag/index.js +45 -0
  176. package/dist/tag/index.js.map +1 -0
  177. package/dist/text-input/index.d.ts +69 -0
  178. package/dist/text-input/index.d.ts.map +1 -0
  179. package/dist/text-input/index.js +70 -0
  180. package/dist/text-input/index.js.map +1 -0
  181. package/dist/text-list/index.d.ts +71 -0
  182. package/dist/text-list/index.d.ts.map +1 -0
  183. package/dist/text-list/index.js +79 -0
  184. package/dist/text-list/index.js.map +1 -0
  185. package/dist/textarea/index.d.ts +29 -0
  186. package/dist/textarea/index.d.ts.map +1 -0
  187. package/dist/textarea/index.js +69 -0
  188. package/dist/textarea/index.js.map +1 -0
  189. package/dist/toast/index.d.ts +76 -0
  190. package/dist/toast/index.d.ts.map +1 -0
  191. package/dist/toast/index.js +183 -0
  192. package/dist/toast/index.js.map +1 -0
  193. package/dist/tooltip/index.d.ts +84 -0
  194. package/dist/tooltip/index.d.ts.map +1 -0
  195. package/dist/tooltip/index.js +329 -0
  196. package/dist/tooltip/index.js.map +1 -0
  197. package/package.json +57 -0
  198. package/src/accordion/index.tsx +251 -0
  199. package/src/alert/index.tsx +111 -0
  200. package/src/avatar/index.tsx +272 -0
  201. package/src/badge/index.tsx +91 -0
  202. package/src/breadcrumb/index.tsx +125 -0
  203. package/src/button/index.tsx +190 -0
  204. package/src/checkbox/index.tsx +247 -0
  205. package/src/combobox/index.tsx +700 -0
  206. package/src/context-menu/index.tsx +623 -0
  207. package/src/critical-alert/index.tsx +102 -0
  208. package/src/data-table/index.tsx +289 -0
  209. package/src/disclosure/index.tsx +87 -0
  210. package/src/drawer/index.tsx +516 -0
  211. package/src/empty-state/index.tsx +256 -0
  212. package/src/env.d.ts +10 -0
  213. package/src/field/index.tsx +307 -0
  214. package/src/file-upload/index.tsx +492 -0
  215. package/src/footer/index.tsx +393 -0
  216. package/src/header/index.tsx +498 -0
  217. package/src/identifier/index.tsx +96 -0
  218. package/src/in-page-navigation/index.tsx +258 -0
  219. package/src/index.ts +72 -0
  220. package/src/input-group/index.tsx +197 -0
  221. package/src/lib/krds.ts +75 -0
  222. package/src/lib/link.tsx +35 -0
  223. package/src/main-menu/index.tsx +912 -0
  224. package/src/masthead/index.tsx +50 -0
  225. package/src/modal/index.tsx +457 -0
  226. package/src/pagination/index.tsx +198 -0
  227. package/src/progress/index.tsx +198 -0
  228. package/src/radio/index.tsx +283 -0
  229. package/src/resize/index.tsx +541 -0
  230. package/src/scroll-area/index.tsx +96 -0
  231. package/src/select/index.tsx +136 -0
  232. package/src/separator/index.tsx +107 -0
  233. package/src/side-navigation/index.tsx +336 -0
  234. package/src/skeleton/index.tsx +172 -0
  235. package/src/skip-link/index.tsx +89 -0
  236. package/src/spinner/index.tsx +98 -0
  237. package/src/step-indicator/index.tsx +122 -0
  238. package/src/structured-list/index.tsx +395 -0
  239. package/src/tab/index.tsx +348 -0
  240. package/src/table/index.tsx +186 -0
  241. package/src/tag/index.tsx +127 -0
  242. package/src/text-input/index.tsx +156 -0
  243. package/src/text-list/index.tsx +177 -0
  244. package/src/textarea/index.tsx +113 -0
  245. package/src/toast/index.tsx +311 -0
  246. package/src/tooltip/index.tsx +496 -0
  247. package/styles/krds-tailwind.css +335 -0
@@ -0,0 +1,311 @@
1
+ 'use client';
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type ComponentPropsWithRef,
12
+ type ReactNode,
13
+ } from 'react';
14
+ import { createPortal } from 'react-dom';
15
+ import { cx } from '../lib/krds';
16
+
17
+ /**
18
+ * 토스트 — 킷에 없다. KRDS 토큰으로 그리는 보충 부품이다.
19
+ *
20
+ * 저장·삭제 결과를 한 줄로 알리고 스스로 사라진다. `ToastProvider` 를 **레이아웃 한 곳에** 두고
21
+ * 화면에서는 `useToast()` 로 띄운다.
22
+ * **한 줄 짧은 문장만** 담는다 — 두 줄 이상 설명은 `ui/modal`, 되돌리기 같은 행동이 붙으면 스낵바다.
23
+ * 폼 필드의 오류·성공은 토스트가 아니라 `ui/field` 의 인라인 문구다.
24
+ * 재난·장애는 `ui/critical-alert`, 늘 떠 있는 안내는 `ui/alert` 다.
25
+ * `duration: 0` 이면 자동으로 사라지지 않는다(닫기 단추로만 닫힌다).
26
+ *
27
+ * @example
28
+ * // 레이아웃
29
+ * <ToastProvider><App /></ToastProvider>
30
+ * // 화면
31
+ * const toast = useToast();
32
+ * toast.success('저장했습니다.');
33
+ * toast.error('저장하지 못했습니다.');
34
+ *
35
+ * 자세히: docs/krds/09-부품-노트.md#토스트
36
+ */
37
+
38
+ /** 색조. 킷 상태 아이콘이 셋뿐이라 세 가지다 — 「주의·경고형」은 `danger` 를 쓴다. */
39
+ export type ToastTone = 'information' | 'success' | 'danger';
40
+
41
+ /** 색조 → 킷 아이콘. 킷의 `form-hint-*` 가 쓰는 짝 그대로다. */
42
+ const TONE_ICON: Record<ToastTone, string> = {
43
+ information: 'ico-information-fill',
44
+ success: 'ico-success-fill',
45
+ danger: 'ico-error-fill',
46
+ };
47
+
48
+ /** 색조 → 테두리색. 의미 토큰이라 선명한 화면 모드도 따라간다. */
49
+ const TONE_BORDER: Record<ToastTone, string> = {
50
+ information: 'border-information-line',
51
+ success: 'border-success-line',
52
+ danger: 'border-danger-line',
53
+ };
54
+
55
+ /** 가이드의 권장 노출 시간(ms). 정보형 2~3초 · 주의·경고형 3~4초 중 상한이다. */
56
+ const DEFAULT_DURATION: Record<ToastTone, number> = {
57
+ information: 3000,
58
+ success: 3000,
59
+ danger: 4000,
60
+ };
61
+
62
+ /** 사라지는 동안의 시간(ms). 아래 `duration-200` 과 짝이라 한쪽만 바꾸면 어긋난다. */
63
+ const LEAVE_MS = 200;
64
+
65
+ /* ────────────────────────────────────────────────────────────────────────
66
+ * 토스트 한 줄
67
+ * ──────────────────────────────────────────────────────────────────── */
68
+
69
+ export type ToastProps = Omit<ComponentPropsWithRef<'div'>, 'children'> & {
70
+ /** 색조. 기본 `information`. */
71
+ tone?: ToastTone;
72
+ /** 한 줄 메시지. **일부러 `string` 이다** — 더 길거나 링크가 붙으면 토스트가 아니다. */
73
+ children: string;
74
+ /** 닫기 단추를 누름. 주지 않으면 닫기 단추를 그리지 않는다. */
75
+ onClose?: () => void;
76
+ /** 닫기 단추의 스크린리더 이름. 아이콘뿐이라 이 문구가 없으면 읽을 것이 없다. */
77
+ closeLabel?: string;
78
+ };
79
+
80
+ /**
81
+ * 토스트 막대 하나. 보통은 `useToast()` 로 띄우고 이것을 직접 쓰지 않는다.
82
+ * **직접 쓰면 라이브 영역이 아니다** — 소리로는 알려지지 않는다.
83
+ */
84
+ export function Toast({
85
+ tone = 'information',
86
+ children,
87
+ onClose,
88
+ closeLabel = '알림 닫기',
89
+ className,
90
+ ...rest
91
+ }: ToastProps) {
92
+ return (
93
+ <div
94
+ data-tone={tone}
95
+ className={cx(
96
+ 'flex max-w-full items-center gap-2 rounded-lg border bg-surface px-4 py-3 text-sm text-fg',
97
+ // 킷 모달의 그림자 토큰 그대로(`--krds-modal--wrap-shadow`). 우리 눈금에 그림자 단계가 없어 값으로 적는다.
98
+ 'shadow-[0_0_0.2rem_0_var(--krds-light-color-alpha-shadow2),0_1.6rem_2.4rem_0_var(--krds-light-color-alpha-shadow3)]',
99
+ TONE_BORDER[tone],
100
+ className,
101
+ )}
102
+ {...rest}
103
+ >
104
+ {/* 색조는 아이콘과 테두리가 나른다. 글자는 본문색 그대로 둔다 — 한 줄 문장이라 색까지 입히면 읽기 나빠진다. */}
105
+ <i className={cx('svg-icon shrink-0', TONE_ICON[tone])} aria-hidden="true" />
106
+ <p className="m-0 min-w-0 flex-1">{children}</p>
107
+ {onClose && (
108
+ // 킷 아이콘 버튼 규격 그대로: `krds-btn icon` + sr-only 이름 + `ico-popup-close`(24px 정사각).
109
+ <button type="button" className="krds-btn medium icon shrink-0" onClick={onClose}>
110
+ <span className="sr-only">{closeLabel}</span>
111
+ <i className="svg-icon ico-popup-close" aria-hidden="true" />
112
+ </button>
113
+ )}
114
+ </div>
115
+ );
116
+ }
117
+
118
+ /* ────────────────────────────────────────────────────────────────────────
119
+ * 띄우고 지우는 쪽
120
+ * ──────────────────────────────────────────────────────────────────── */
121
+
122
+ export type ToastOptions = {
123
+ /** 색조. 기본 `information`. */
124
+ tone?: ToastTone;
125
+ /** 노출 시간(ms). 생략하면 색조별 권장값. **`0` 이면 자동으로 사라지지 않는다.** */
126
+ duration?: number;
127
+ /** 닫기 단추의 스크린리더 이름. */
128
+ closeLabel?: string;
129
+ };
130
+
131
+ type ToastEntry = Required<Pick<ToastOptions, 'tone' | 'duration'>> & {
132
+ id: string;
133
+ message: string;
134
+ closeLabel?: string;
135
+ /** 사라지는 중. 화면에서 지우기 전 `LEAVE_MS` 동안만 참이다. */
136
+ leaving?: boolean;
137
+ };
138
+
139
+ export type ToastApi = {
140
+ /** 토스트를 띄우고 식별자를 돌려준다. */
141
+ show(message: string, options?: ToastOptions): string;
142
+ /** `show(message, { tone: 'information' })` */
143
+ info(message: string, options?: Omit<ToastOptions, 'tone'>): string;
144
+ /** `show(message, { tone: 'success' })` — 저장·삭제 성공 */
145
+ success(message: string, options?: Omit<ToastOptions, 'tone'>): string;
146
+ /** `show(message, { tone: 'danger' })` — 실패. 되풀이되면 토스트 말고 모달로 낸다. */
147
+ error(message: string, options?: Omit<ToastOptions, 'tone'>): string;
148
+ /** 하나를 지운다. */
149
+ dismiss(id: string): void;
150
+ /** 전부 즉시 지운다(사라지는 동작 없음). 화면을 옮길 때 쓴다. */
151
+ dismissAll(): void;
152
+ };
153
+
154
+ const ToastContext = createContext<ToastApi | null>(null);
155
+
156
+ /** 토스트를 띄운다. `<ToastProvider>` 안에서만 쓸 수 있다. */
157
+ export function useToast(): ToastApi {
158
+ const api = useContext(ToastContext);
159
+ if (!api) {
160
+ throw new Error('useToast() 는 <ToastProvider> 안에서만 쓸 수 있다. 레이아웃에 넣었는지 확인하라.');
161
+ }
162
+ return api;
163
+ }
164
+
165
+ export type ToastProviderProps = {
166
+ /** 앱 전체. */
167
+ children?: ReactNode;
168
+ /** 한 번에 띄울 최대 개수. 기본 3 — 넘으면 오래된 것부터 사라진다. */
169
+ max?: number;
170
+ /** 뷰포트에 붙는다. 하단 고정 요소와 겹칠 때 `bottom-*` 을 올리는 자리. */
171
+ className?: string;
172
+ };
173
+
174
+ /**
175
+ * 토스트 뷰포트 + 띄우는 함수를 공급한다. **레이아웃 한 곳에만 둔다.**
176
+ * 뷰포트는 비어 있어도 그려진다 — 라이브 영역이 메시지보다 먼저 있어야 낭독된다.
177
+ */
178
+ export function ToastProvider({ children, max = 3, className }: ToastProviderProps) {
179
+ const [items, setItems] = useState<ToastEntry[]>([]);
180
+ /** 포인터가 올라가 있거나 안에 초점이 있으면 참. 이때 자동 사라짐을 멈춘다(WCAG 2.2.1). */
181
+ const [paused, setPaused] = useState(false);
182
+ const [mounted, setMounted] = useState(false);
183
+ const seq = useRef(0);
184
+ const leaveTimers = useRef(new Map<string, number>());
185
+
186
+ useEffect(() => setMounted(true), []);
187
+
188
+ // 화면을 떠날 때 남은 타이머를 정리한다.
189
+ useEffect(() => {
190
+ const timers = leaveTimers.current;
191
+ return () => {
192
+ timers.forEach((t) => window.clearTimeout(t));
193
+ timers.clear();
194
+ };
195
+ }, []);
196
+
197
+ const dismiss = useCallback((id: string) => {
198
+ if (leaveTimers.current.has(id)) return; // 이미 사라지는 중
199
+ setItems((list) => list.map((it) => (it.id === id ? { ...it, leaving: true } : it)));
200
+ const timer = window.setTimeout(() => {
201
+ leaveTimers.current.delete(id);
202
+ setItems((list) => list.filter((it) => it.id !== id));
203
+ }, LEAVE_MS);
204
+ leaveTimers.current.set(id, timer);
205
+ }, []);
206
+
207
+ const api = useMemo<ToastApi>(() => {
208
+ const show = (message: string, options: ToastOptions = {}) => {
209
+ const tone = options.tone ?? 'information';
210
+ seq.current += 1;
211
+ const id = `toast-${seq.current}`;
212
+ const entry: ToastEntry = {
213
+ id,
214
+ message,
215
+ tone,
216
+ duration: options.duration ?? DEFAULT_DURATION[tone],
217
+ closeLabel: options.closeLabel,
218
+ };
219
+ // 넘치면 오래된 것부터 버린다.
220
+ setItems((list) => [...list, entry].slice(-max));
221
+ return id;
222
+ };
223
+ return {
224
+ show,
225
+ info: (message, options) => show(message, { ...options, tone: 'information' }),
226
+ success: (message, options) => show(message, { ...options, tone: 'success' }),
227
+ error: (message, options) => show(message, { ...options, tone: 'danger' }),
228
+ dismiss,
229
+ dismissAll: () => {
230
+ leaveTimers.current.forEach((t) => window.clearTimeout(t));
231
+ leaveTimers.current.clear();
232
+ setItems([]);
233
+ },
234
+ };
235
+ }, [dismiss, max]);
236
+
237
+ return (
238
+ <ToastContext.Provider value={api}>
239
+ {children}
240
+ {mounted &&
241
+ createPortal(
242
+ <div
243
+ role="status"
244
+ aria-live="polite"
245
+ // 기본값이 true 라 토스트가 하나 늘 때마다 영역 전체가 다시 낭독된다.
246
+ aria-atomic="false"
247
+ className={cx(
248
+ // 가이드: 「토스트는 화면 중앙 하단에 표시한다」.
249
+ 'pointer-events-none fixed bottom-10 left-1/2 z-[1030] -translate-x-1/2',
250
+ 'flex w-full max-w-[48rem] flex-col items-center gap-2 px-4',
251
+ className,
252
+ )}
253
+ // 포인터가 올라가거나 초점이 들어오면 자동 사라짐을 멈춘다(WCAG 2.2.1).
254
+ onMouseEnter={() => setPaused(true)}
255
+ onMouseLeave={() => setPaused(false)}
256
+ onFocus={() => setPaused(true)}
257
+ onBlur={() => setPaused(false)}
258
+ >
259
+ {items.map((item) => (
260
+ <ToastRow key={item.id} item={item} paused={paused} onDismiss={dismiss} />
261
+ ))}
262
+ </div>,
263
+ document.body,
264
+ )}
265
+ </ToastContext.Provider>
266
+ );
267
+ }
268
+
269
+ /** 뷰포트 안의 토스트 한 줄. 자기 타이머와 나타남/사라짐만 맡는다. */
270
+ function ToastRow({
271
+ item,
272
+ paused,
273
+ onDismiss,
274
+ }: {
275
+ item: ToastEntry;
276
+ paused: boolean;
277
+ onDismiss: (id: string) => void;
278
+ }) {
279
+ const [entered, setEntered] = useState(false);
280
+
281
+ // 붙자마자 한 프레임 뒤에 켠다 — 같은 프레임에 바꾸면 전이가 일어나지 않는다.
282
+ useEffect(() => {
283
+ const frame = window.requestAnimationFrame(() => setEntered(true));
284
+ return () => window.cancelAnimationFrame(frame);
285
+ }, []);
286
+
287
+ useEffect(() => {
288
+ if (paused || item.leaving || !item.duration) return;
289
+ const timer = window.setTimeout(() => onDismiss(item.id), item.duration);
290
+ return () => window.clearTimeout(timer);
291
+ // 멈췄다 풀리면 시간을 처음부터 다시 센다 — 남은 시간을 이어 세지 않는다.
292
+ // 읽던 사람에게 더 넉넉한 쪽이라 그대로 뒀다.
293
+ }, [paused, item.leaving, item.duration, item.id, onDismiss]);
294
+
295
+ const shown = entered && !item.leaving;
296
+
297
+ return (
298
+ <Toast
299
+ tone={item.tone}
300
+ closeLabel={item.closeLabel}
301
+ onClose={() => onDismiss(item.id)}
302
+ className={cx(
303
+ // 뷰포트가 pointer-events-none 이라 막대에서 되살린다 — 아래 화면을 가리지 않기 위해서다.
304
+ 'pointer-events-auto transition duration-200 ease-out motion-reduce:transition-none',
305
+ shown ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0',
306
+ )}
307
+ >
308
+ {item.message}
309
+ </Toast>
310
+ );
311
+ }