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,623 @@
1
+ 'use client';
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ useRef,
8
+ useState,
9
+ type ComponentPropsWithRef,
10
+ type KeyboardEvent as ReactKeyboardEvent,
11
+ type MouseEvent as ReactMouseEvent,
12
+ type ReactNode,
13
+ type Ref,
14
+ } from 'react';
15
+ import { Button } from '../button';
16
+ import { cx, type KrdsIcon, type KrdsSize } from '../lib/krds';
17
+
18
+ /**
19
+ * 상황 메뉴(더보기 · 우클릭) — KRDS 에 없는 보충 부품이다.
20
+ *
21
+ * 모양은 킷 드롭다운(`krds-drop-wrap`), 동작은 WAI-ARIA APG 의 Menu Button 패턴이다.
22
+ * 표 한 칸에 늘어선 행 동작을 하나로 접을 때 쓴다 — 「삭제」는 `danger` 로 표시하고
23
+ * 구분선(`{ separator: true }`)으로 나머지와 떼어 놓는다.
24
+ * **동작 목록에만 쓴다.** 메뉴는 Tab 을 가로막으므로 언어 전환·화면 크기처럼 Tab 으로 닿아야
25
+ * 하는 목록에는 `ui/resize` 의 드롭다운을 쓴다. 하위 메뉴(2단)는 지원하지 않는다.
26
+ *
27
+ * @example
28
+ * <ContextMenu
29
+ * label={`${row.name} 행 더보기`}
30
+ * items={[
31
+ * { id: 'edit', label: '수정', icon: 'setting', onSelect: () => edit(row) },
32
+ * { separator: true },
33
+ * { id: 'delete', label: '삭제', icon: 'delete', danger: true, onSelect: () => remove(row) },
34
+ * ]}
35
+ * />
36
+ *
37
+ * 자세히: docs/krds/09-부품-노트.md#컨텍스트메뉴
38
+ */
39
+
40
+ /* ────────────────────────────────────────────────────────────────────────
41
+ * 1. 항목
42
+ * ──────────────────────────────────────────────────────────────────────── */
43
+
44
+ /** 실행 항목. `href` 가 있으면 링크(`<a>`), 없으면 단추(`<button>`)로 그린다. */
45
+ export type ContextMenuAction = {
46
+ id: string;
47
+ label: ReactNode;
48
+ /** 글자 탐색에 쓸 문자열. `label` 이 문자열이면 필요 없다. */
49
+ text?: string;
50
+ /** 킷 아이콘 이름(`ico-` 뺀 것). 예: `delete` · `setting` · `download` */
51
+ icon?: KrdsIcon;
52
+ /** 이동이면 링크로 그린다. 동작에는 주지 않는다. */
53
+ href?: string;
54
+ target?: string;
55
+ /** 초점은 받되 실행되지 않는다(`aria-disabled`). */
56
+ disabled?: boolean;
57
+ /** 되돌릴 수 없는 동작(삭제 등). 글자를 위험색으로 물들인다. */
58
+ danger?: boolean;
59
+ /** 현재 상태 표시(킷 `.active`). 정렬 기준처럼 「지금 이것」을 보일 때. */
60
+ active?: boolean;
61
+ onSelect?: (id: string) => void;
62
+ separator?: never;
63
+ };
64
+
65
+ /** 구분선. 「삭제」처럼 되돌릴 수 없는 항목을 나머지와 떼어 놓는다. */
66
+ export type ContextMenuSeparatorItem = {
67
+ separator: true;
68
+ /** 목록 key 용. 없으면 순번으로 만든다. */
69
+ id?: string;
70
+ };
71
+
72
+ export type ContextMenuItem = ContextMenuAction | ContextMenuSeparatorItem;
73
+
74
+ /** 킷의 드롭다운 정렬 클래스(`.drop-left` · `.drop-right`). 생략하면 가운데(킷 기본). */
75
+ export type ContextMenuAlign = 'left' | 'center' | 'right';
76
+
77
+ /** 정렬 → 킷 클래스. `center` 는 킷 기본값이라 클래스가 없다. */
78
+ function alignClass(align: ContextMenuAlign | undefined): string | undefined {
79
+ return align === 'left' || align === 'right' ? `drop-${align}` : undefined;
80
+ }
81
+
82
+ /** 글자 탐색에 쓸 문자열을 뽑는다. */
83
+ function itemText(item: ContextMenuAction): string {
84
+ return (item.text ?? (typeof item.label === 'string' ? item.label : '')).trim().toLowerCase();
85
+ }
86
+
87
+ /* ────────────────────────────────────────────────────────────────────────
88
+ * 2. 여닫기·초점 — 두 부품이 함께 쓴다
89
+ * ──────────────────────────────────────────────────────────────────────── */
90
+
91
+ /** 열 때 어느 항목에 초점을 둘지. ↑ 로 열면 마지막 항목이다(APG). */
92
+ type InitialFocus = 'first' | 'last';
93
+
94
+ /** 글자 탐색 버퍼가 살아 있는 시간(ms). APG 예제가 쓰는 값이다. */
95
+ const TYPEAHEAD_RESET_MS = 500;
96
+
97
+ /** 커서 메뉴가 화면 아래로 넘칠 때 남길 여유(px). KRDS 눈금에 없어 그림자가 잘리지 않을 값으로 정했다. */
98
+ const VIEWPORT_GUTTER_PX = 8;
99
+
100
+ function useMenuController() {
101
+ const [open, setOpen] = useState(false);
102
+ const wrapRef = useRef<HTMLDivElement>(null);
103
+ const menuRef = useRef<HTMLUListElement>(null);
104
+ /** 닫을 때 초점을 돌려줄 곳. 열기 직전에 채운다. */
105
+ const returnFocusRef = useRef<HTMLElement | null>(null);
106
+ /** 열린 직후 초점을 둘 자리. */
107
+ const initialFocusRef = useRef<InitialFocus>('first');
108
+
109
+ /** 지금 메뉴 안에 있는 항목들. 구분선은 걸리지 않는다. */
110
+ const items = useCallback(
111
+ () => Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []),
112
+ [],
113
+ );
114
+
115
+ /** 연다. `returnTo` 를 주지 않으면 열기 직전 초점이 있던 곳으로 돌아간다. */
116
+ const openMenu = useCallback((initial: InitialFocus = 'first', returnTo?: HTMLElement | null) => {
117
+ initialFocusRef.current = initial;
118
+ returnFocusRef.current = returnTo ?? (document.activeElement as HTMLElement | null) ?? null;
119
+ setOpen(true);
120
+ }, []);
121
+
122
+ const close = useCallback((refocus = false) => {
123
+ setOpen(false);
124
+ if (refocus) returnFocusRef.current?.focus();
125
+ }, []);
126
+
127
+ // 열린 직후 첫(또는 마지막) 항목으로 초점을 옮긴다 — APG 의 Menu Button 규정이다.
128
+ useEffect(() => {
129
+ if (!open) return;
130
+ const list = items();
131
+ if (!list.length) return;
132
+ (initialFocusRef.current === 'last' ? list[list.length - 1] : list[0]).focus();
133
+ }, [open, items]);
134
+
135
+ // 바깥을 누르면 닫는다. 초점은 돌려주지 않는다(누른 곳으로 가는 게 자연스럽다).
136
+ useEffect(() => {
137
+ if (!open) return;
138
+ const onPointerDown = (e: PointerEvent) => {
139
+ if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
140
+ };
141
+ document.addEventListener('pointerdown', onPointerDown);
142
+ return () => document.removeEventListener('pointerdown', onPointerDown);
143
+ }, [open]);
144
+
145
+ return { open, openMenu, close, wrapRef, menuRef, items };
146
+ }
147
+
148
+ /** 밖에서 온 ref 와 부품 내부 ref 를 함께 채운다. 감싸개 ref 를 삼키지 않으려고 둔다. */
149
+ function mergeRef<T>(inner: { current: T | null }, outer: Ref<T> | undefined) {
150
+ return (node: T | null) => {
151
+ inner.current = node;
152
+ if (typeof outer === 'function') outer(node);
153
+ else if (outer) outer.current = node;
154
+ };
155
+ }
156
+
157
+ /** 열림이 바뀔 때만 알린다. 인라인 콜백이 매 렌더 새 함수라 ref 에 담아 의존성에서 뺀다. */
158
+ function useOpenChange(open: boolean, onOpenChange?: (open: boolean) => void) {
159
+ const latest = useRef(onOpenChange);
160
+ const mounted = useRef(false);
161
+ useEffect(() => {
162
+ latest.current = onOpenChange;
163
+ });
164
+ useEffect(() => {
165
+ // 붙자마자 `false` 를 알리지 않는다 — 바뀐 것이 없다.
166
+ if (!mounted.current) {
167
+ mounted.current = true;
168
+ return;
169
+ }
170
+ latest.current?.(open);
171
+ }, [open]);
172
+ }
173
+
174
+ /* ────────────────────────────────────────────────────────────────────────
175
+ * 3. 목록 그리기
176
+ * ──────────────────────────────────────────────────────────────────────── */
177
+
178
+ type MenuListProps = {
179
+ id: string;
180
+ items: ReadonlyArray<ContextMenuItem>;
181
+ /** 메뉴 이름. 여는 단추가 있으면 그 id 를 `labelledBy` 로 준다. */
182
+ label?: string;
183
+ labelledBy?: string;
184
+ menuRef: Ref<HTMLUListElement>;
185
+ onKeyDown: (e: ReactKeyboardEvent<HTMLUListElement>) => void;
186
+ onActivate: (item: ContextMenuAction) => void;
187
+ };
188
+
189
+ function MenuList({ id, items, label, labelledBy, menuRef, onKeyDown, onActivate }: MenuListProps) {
190
+ return (
191
+ <ul
192
+ ref={menuRef}
193
+ id={id}
194
+ // ★ role="menu" 는 이 <ul> 에 준다. 바깥 .drop-menu 에 주면 ul/li 가 허용되지 않는 자식이 된다.
195
+ role="menu"
196
+ aria-label={labelledBy ? undefined : label}
197
+ aria-labelledby={labelledBy}
198
+ className="drop-list"
199
+ onKeyDown={onKeyDown}
200
+ >
201
+ {items.map((item, index) => {
202
+ if (item.separator) {
203
+ // 킷에 구분선 클래스가 없다 — 유틸리티로 그린다. 킷 목록 간격(--krds-gap-3=8px)에 맞춰 my-1(4px)씩.
204
+ return (
205
+ <li
206
+ key={item.id ?? `separator-${index}`}
207
+ role="separator"
208
+ className="my-1 h-px bg-divider"
209
+ />
210
+ );
211
+ }
212
+
213
+ const { id: itemId, label: itemLabel, icon, href, target, disabled, danger, active } = item;
214
+ const cls = cx(
215
+ 'item-link',
216
+ active && 'active',
217
+ // 킷에 위험·비활성 항목 모양이 없다 — 유틸리티로 보충한다.
218
+ danger && !disabled && 'text-danger',
219
+ disabled && 'text-fg-disabled cursor-not-allowed hover:bg-transparent',
220
+ );
221
+ const iconNode = icon ? (
222
+ <i
223
+ // .svg-icon 은 background-color 를 mask 색으로 쓴다 — 글자색과 맞추려면 bg-* 를 얹는다.
224
+ className={cx(
225
+ 'svg-icon',
226
+ `ico-${icon}`,
227
+ danger && !disabled && 'bg-danger',
228
+ disabled && 'bg-fg-disabled',
229
+ )}
230
+ aria-hidden="true"
231
+ />
232
+ ) : null;
233
+
234
+ const shared = {
235
+ role: 'menuitem' as const,
236
+ // 메뉴 안에서는 ↑↓ 로만 오간다(roving tabindex). 초점은 부품이 옮긴다.
237
+ tabIndex: -1,
238
+ className: cls,
239
+ // 비활성은 disabled 속성이 아니라 aria-disabled 다 — 초점은 받아야 한다(APG).
240
+ 'aria-disabled': disabled || undefined,
241
+ 'aria-current': active ? ('true' as const) : undefined,
242
+ };
243
+
244
+ return (
245
+ <li key={itemId} role="none">
246
+ {href !== undefined ? (
247
+ <a
248
+ {...shared}
249
+ // 비활성 링크는 href 를 떼어 이동을 막는다. tabIndex=-1 이라 초점은 그대로 받는다.
250
+ href={disabled ? undefined : href}
251
+ target={target}
252
+ // 새 창으로 열리면 미리 알린다 — KRDS 접근성 요구.
253
+ title={target === '_blank' ? '새 창 열림' : undefined}
254
+ onClick={(e) => {
255
+ if (disabled) {
256
+ e.preventDefault();
257
+ return;
258
+ }
259
+ onActivate(item);
260
+ }}
261
+ >
262
+ {iconNode}
263
+ {itemLabel}
264
+ </a>
265
+ ) : (
266
+ <button
267
+ {...shared}
268
+ type="button"
269
+ onClick={() => {
270
+ if (disabled) return;
271
+ onActivate(item);
272
+ }}
273
+ >
274
+ {iconNode}
275
+ {itemLabel}
276
+ </button>
277
+ )}
278
+ </li>
279
+ );
280
+ })}
281
+ </ul>
282
+ );
283
+ }
284
+
285
+ /* ────────────────────────────────────────────────────────────────────────
286
+ * 4. 키보드 — APG Menu Button
287
+ * ──────────────────────────────────────────────────────────────────────── */
288
+
289
+ type Nav = {
290
+ items: () => HTMLElement[];
291
+ close: (refocus?: boolean) => void;
292
+ };
293
+
294
+ /**
295
+ * 메뉴 안 키 처리(APG) — ↓↑ 는 끝에서 돌고, Home/End 는 양 끝, Esc 는 닫고 초점 복귀,
296
+ * Tab 은 닫고 흘려보내며, 글자를 치면 그 글자로 시작하는 다음 항목으로 간다.
297
+ */
298
+ function useMenuKeyDown({ items, close }: Nav, entries: ReadonlyArray<ContextMenuItem>) {
299
+ const buffer = useRef('');
300
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
301
+
302
+ // 딸린 타이머는 부품이 사라질 때 반드시 끈다.
303
+ useEffect(
304
+ () => () => {
305
+ if (timer.current) clearTimeout(timer.current);
306
+ },
307
+ [],
308
+ );
309
+
310
+ const focusAt = (list: HTMLElement[], index: number) => {
311
+ if (!list.length) return;
312
+ list[(index + list.length) % list.length].focus();
313
+ };
314
+
315
+ return (e: ReactKeyboardEvent<HTMLUListElement>) => {
316
+ const list = items();
317
+ const at = list.indexOf(document.activeElement as HTMLElement);
318
+
319
+ switch (e.key) {
320
+ case 'ArrowDown':
321
+ e.preventDefault();
322
+ focusAt(list, at + 1);
323
+ return;
324
+ case 'ArrowUp':
325
+ e.preventDefault();
326
+ focusAt(list, at - 1);
327
+ return;
328
+ case 'Home':
329
+ e.preventDefault();
330
+ focusAt(list, 0);
331
+ return;
332
+ case 'End':
333
+ e.preventDefault();
334
+ focusAt(list, list.length - 1);
335
+ return;
336
+ case 'Escape':
337
+ // 바깥(모달 등)이 같은 Esc 를 받지 않도록 여기서 멈춘다.
338
+ e.stopPropagation();
339
+ e.preventDefault();
340
+ close(true);
341
+ return;
342
+ case 'Tab':
343
+ // APG: Tab 은 메뉴를 닫고 초점을 다음 요소로 넘긴다. preventDefault 하지 않는다.
344
+ close();
345
+ return;
346
+ default:
347
+ break;
348
+ }
349
+
350
+ // 글자 탐색. 조합키가 걸린 입력은 단축키이므로 건드리지 않는다.
351
+ if (e.key.length !== 1 || e.altKey || e.ctrlKey || e.metaKey) return;
352
+ const key = e.key.toLowerCase();
353
+ if (key === ' ' && !buffer.current) return; // Space 는 실행(기본 동작)이다
354
+
355
+ e.preventDefault();
356
+ buffer.current += key;
357
+ if (timer.current) clearTimeout(timer.current);
358
+ timer.current = setTimeout(() => {
359
+ buffer.current = '';
360
+ }, TYPEAHEAD_RESET_MS);
361
+
362
+ // 구분선을 뺀 실제 항목 순서로 견준다 — DOM 순서(list)와 짝이 맞는다.
363
+ const actions = entries.filter((i): i is ContextMenuAction => !i.separator);
364
+ const from = at < 0 ? 0 : at + (buffer.current.length > 1 ? 0 : 1);
365
+ for (let n = 0; n < actions.length; n += 1) {
366
+ const i = (from + n) % actions.length;
367
+ if (itemText(actions[i]).startsWith(buffer.current)) {
368
+ focusAt(list, i);
369
+ return;
370
+ }
371
+ }
372
+ };
373
+ }
374
+
375
+ /* ────────────────────────────────────────────────────────────────────────
376
+ * 5. 더보기 단추 — ContextMenu
377
+ * ──────────────────────────────────────────────────────────────────────── */
378
+
379
+ export type ContextMenuProps = Omit<ComponentPropsWithRef<'div'>, 'onSelect'> & {
380
+ items: ReadonlyArray<ContextMenuItem>;
381
+ /** 단추의 이름(`sr-only`). 표 안에서는 어느 행인지까지 담는다 — 「홍길동 행 더보기」. */
382
+ label: string;
383
+ /** 글자가 있는 단추로 그린다. 주면 아이콘 대신 「글자 + ico-toggle」이 된다. */
384
+ triggerLabel?: ReactNode;
385
+ /** 아이콘만인 단추의 아이콘. 기본 `more`(킷 `ico_more.svg`). */
386
+ icon?: KrdsIcon;
387
+ /** 단추 크기. 기본 small(40px) — 표 안 행 동작 자리에 맞춘 값이다. */
388
+ size?: KrdsSize;
389
+ /** 메뉴가 펼쳐지는 쪽. 기본 `right`(오른쪽 끝 열에 놓이는 자리라 왼쪽으로 편다). */
390
+ align?: ContextMenuAlign;
391
+ /** 항목별 `onSelect` 와 별개로, 무엇을 골랐든 한 번 부른다. */
392
+ onSelect?: (id: string) => void;
393
+ onOpenChange?: (open: boolean) => void;
394
+ /** 여는 단추에 붙일 클래스. */
395
+ triggerClassName?: string;
396
+ };
397
+
398
+ /** 더보기 메뉴 — 표 행 동작·카드 우상단처럼 「이 대상에 대한 동작 묶음」을 접는다. */
399
+ export function ContextMenu({
400
+ items,
401
+ label,
402
+ triggerLabel,
403
+ icon = 'more',
404
+ size = 'small',
405
+ align = 'right',
406
+ onSelect,
407
+ onOpenChange,
408
+ triggerClassName,
409
+ className,
410
+ ref,
411
+ ...rest
412
+ }: ContextMenuProps) {
413
+ const ctl = useMenuController();
414
+ const triggerRef = useRef<HTMLButtonElement>(null);
415
+ const baseId = useId();
416
+ const menuId = `${baseId}-menu`;
417
+ const triggerId = `${baseId}-trigger`;
418
+
419
+ const onKeyDownMenu = useMenuKeyDown({ items: ctl.items, close: ctl.close }, items);
420
+ useOpenChange(ctl.open, onOpenChange);
421
+
422
+ const toggle = () => {
423
+ // 마우스로 눌렀을 때 Safari 는 단추에 초점을 주지 않는다 — 돌려줄 자리를 직접 지정한다.
424
+ if (ctl.open) ctl.close(true);
425
+ else ctl.openMenu('first', triggerRef.current);
426
+ };
427
+
428
+ const onTriggerKeyDown = (e: ReactKeyboardEvent<HTMLButtonElement>) => {
429
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
430
+ e.preventDefault();
431
+ // APG: ↓ 로 열면 첫 항목, ↑ 로 열면 마지막 항목에 초점.
432
+ ctl.openMenu(e.key === 'ArrowDown' ? 'first' : 'last', triggerRef.current);
433
+ };
434
+
435
+ const triggerProps = {
436
+ id: triggerId,
437
+ ref: triggerRef,
438
+ size,
439
+ variant: 'text' as const,
440
+ // drop-btn 은 킷 CSS 에 없는 손잡이 클래스지만 샘플에 있으므로 남긴다.
441
+ className: cx('drop-btn', triggerClassName),
442
+ 'aria-haspopup': 'true' as const,
443
+ 'aria-expanded': ctl.open,
444
+ 'aria-controls': menuId,
445
+ onClick: toggle,
446
+ onKeyDown: onTriggerKeyDown,
447
+ };
448
+
449
+ return (
450
+ <div
451
+ {...rest}
452
+ ref={mergeRef(ctl.wrapRef, ref)}
453
+ className={cx('krds-drop-wrap', alignClass(align), className)}
454
+ // 초점이 여는 단추에 있을 때의 Esc. 목록 안에서는 <ul> 이 먼저 받아 여기까지 오지 않는다.
455
+ onKeyDown={(e) => {
456
+ if (e.key === 'Escape' && ctl.open) {
457
+ e.stopPropagation();
458
+ ctl.close(true);
459
+ }
460
+ }}
461
+ >
462
+ {triggerLabel === undefined ? (
463
+ <Button iconOnly icon={icon} label={label} {...triggerProps} />
464
+ ) : (
465
+ <Button icon="toggle" {...triggerProps}>
466
+ {triggerLabel}
467
+ </Button>
468
+ )}
469
+
470
+ {/*
471
+ 킷은 `.drop-menu{display:none}` 만 두고 여는 클래스를 주지 않는다 — 인라인으로 연다.
472
+ 접힌 동안 display:none 이라 스크린리더도 읽지 않는다(별도 hidden 처리가 필요 없다).
473
+ */}
474
+ <div className="drop-menu" style={ctl.open ? { display: 'block' } : undefined}>
475
+ <div className="drop-in">
476
+ <MenuList
477
+ id={menuId}
478
+ items={items}
479
+ labelledBy={triggerId}
480
+ menuRef={ctl.menuRef}
481
+ onKeyDown={onKeyDownMenu}
482
+ onActivate={(item) => {
483
+ ctl.close(true);
484
+ item.onSelect?.(item.id);
485
+ onSelect?.(item.id);
486
+ }}
487
+ />
488
+ </div>
489
+ </div>
490
+ </div>
491
+ );
492
+ }
493
+
494
+ /* ────────────────────────────────────────────────────────────────────────
495
+ * 6. 우클릭 영역 — ContextMenuArea
496
+ * ──────────────────────────────────────────────────────────────────────── */
497
+
498
+ export type ContextMenuAreaProps = Omit<ComponentPropsWithRef<'div'>, 'onSelect'> & {
499
+ items: ReadonlyArray<ContextMenuItem>;
500
+ /** 메뉴 이름. 여는 단추가 없으니 여기서 준다 — 「목록 항목 동작」처럼. */
501
+ label: string;
502
+ onSelect?: (id: string) => void;
503
+ onOpenChange?: (open: boolean) => void;
504
+ /** 우클릭을 가로채지 않는다(브라우저 기본 메뉴가 뜬다). */
505
+ disabled?: boolean;
506
+ };
507
+
508
+ /**
509
+ * 우클릭 메뉴 — 영역 안에서 오른쪽 단추(또는 Shift+F10 · ContextMenu 키)로 연다.
510
+ *
511
+ * **브라우저 기본 메뉴를 가린다.** 「새 탭에서 열기」를 빼앗으므로 꼭 필요한 곳에만 쓰고,
512
+ * 같은 동작을 여는 눈에 보이는 단추(`ContextMenu`)를 함께 둔다.
513
+ *
514
+ * @example
515
+ * <ContextMenuArea label="목록 항목 동작" items={rowActions}>
516
+ * <ul>…</ul>
517
+ * </ContextMenuArea>
518
+ */
519
+ export function ContextMenuArea({
520
+ items,
521
+ label,
522
+ onSelect,
523
+ onOpenChange,
524
+ disabled,
525
+ className,
526
+ children,
527
+ ref,
528
+ ...rest
529
+ }: ContextMenuAreaProps) {
530
+ const ctl = useMenuController();
531
+ const menuId = useId();
532
+ /** 감싸개 왼쪽 위를 원점으로 한 앵커 좌표(px). */
533
+ const [pos, setPos] = useState({ x: 0, y: 0 });
534
+ const [side, setSide] = useState<'left' | 'right'>('left');
535
+ const anchorRef = useRef<HTMLDivElement>(null);
536
+
537
+ const onKeyDownMenu = useMenuKeyDown({ items: ctl.items, close: ctl.close }, items);
538
+ useOpenChange(ctl.open, onOpenChange);
539
+
540
+ // 화면 아래로 넘치면 그만큼 끌어올린다. 상태가 아니라 앵커의 style.top 을 직접 쓴다 —
541
+ // 재고 나서 상태를 바꾸면 한 프레임이 잘못된 자리에 그려진다. 위로 뒤집지는 않는다(꼭지가 거꾸로 선다).
542
+ useEffect(() => {
543
+ if (!ctl.open) return;
544
+ const anchor = anchorRef.current;
545
+ const box = anchor?.firstElementChild?.getBoundingClientRect();
546
+ if (!anchor || !box) return;
547
+ const over = box.bottom - (window.innerHeight - VIEWPORT_GUTTER_PX);
548
+ if (over > 0) anchor.style.top = `${pos.y - over}px`;
549
+ }, [ctl.open, pos.x, pos.y]);
550
+
551
+ /** 감싸개 기준 좌표로 옮겨 담고 연다. */
552
+ const openAt = (clientX: number, clientY: number) => {
553
+ const rect = ctl.wrapRef.current?.getBoundingClientRect();
554
+ if (!rect) return;
555
+ setPos({ x: clientX - rect.left, y: clientY - rect.top });
556
+ // 오른쪽 절반에서 열면 메뉴를 왼쪽으로 편다.
557
+ setSide(clientX > window.innerWidth / 2 ? 'right' : 'left');
558
+ ctl.openMenu('first');
559
+ };
560
+
561
+ const onContextMenu = (e: ReactMouseEvent<HTMLDivElement>) => {
562
+ if (disabled) return;
563
+ e.preventDefault();
564
+ openAt(e.clientX, e.clientY);
565
+ };
566
+
567
+ const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
568
+ // 메뉴가 열려 있으면 목록 쪽 처리에 맡긴다.
569
+ if (ctl.open) {
570
+ if (e.key === 'Escape') {
571
+ e.stopPropagation();
572
+ ctl.close(true);
573
+ }
574
+ return;
575
+ }
576
+ if (disabled) return;
577
+ // 키보드로 상황 메뉴를 여는 표준 두 가지.
578
+ if (e.key !== 'ContextMenu' && !(e.key === 'F10' && e.shiftKey)) return;
579
+ e.preventDefault();
580
+ // 초점이 있는 요소의 왼쪽 아래에 띄운다 — 커서가 없으니 그 자리가 기준이다.
581
+ const box = (e.target as HTMLElement).getBoundingClientRect?.();
582
+ if (box) openAt(box.left, box.bottom);
583
+ };
584
+
585
+ return (
586
+ <div
587
+ {...rest}
588
+ ref={mergeRef(ctl.wrapRef, ref)}
589
+ // 킷 감싸개는 inline-flex 다 — 영역을 감싸는 쓰임이라 block 으로 되돌린다.
590
+ className={cx('krds-drop-wrap block', ctl.open && `drop-${side}`, className)}
591
+ onContextMenu={onContextMenu}
592
+ onKeyDown={onKeyDown}
593
+ >
594
+ {children}
595
+
596
+ {ctl.open && (
597
+ // 0 크기 앵커. 메뉴의 기준점일 뿐이라 화면에도 스크린리더에도 잡히지 않는다.
598
+ <div
599
+ ref={anchorRef}
600
+ className="absolute"
601
+ style={{ left: pos.x, top: pos.y, width: 0, height: 0 }}
602
+ >
603
+ <div className="drop-menu" style={{ display: 'block' }}>
604
+ <div className="drop-in">
605
+ <MenuList
606
+ id={menuId}
607
+ items={items}
608
+ label={label}
609
+ menuRef={ctl.menuRef}
610
+ onKeyDown={onKeyDownMenu}
611
+ onActivate={(item) => {
612
+ ctl.close(true);
613
+ item.onSelect?.(item.id);
614
+ onSelect?.(item.id);
615
+ }}
616
+ />
617
+ </div>
618
+ </div>
619
+ </div>
620
+ )}
621
+ </div>
622
+ );
623
+ }