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,912 @@
1
+ 'use client';
2
+
3
+ import { UiLink } from '../lib/link';
4
+ import {
5
+ useCallback,
6
+ useEffect,
7
+ useId,
8
+ useRef,
9
+ useState,
10
+ type ComponentPropsWithRef,
11
+ type FocusEvent as ReactFocusEvent,
12
+ type HTMLAttributes,
13
+ type KeyboardEvent as ReactKeyboardEvent,
14
+ type MouseEvent as ReactMouseEvent,
15
+ type ReactNode,
16
+ } from 'react';
17
+ import { createPortal } from 'react-dom';
18
+ import { cx } from '../lib/krds';
19
+
20
+ /**
21
+ * 주메뉴(GNB) — KRDS `krds-main-menu`(PC) · `krds-main-menu-mobile`(모바일).
22
+ *
23
+ * 같은 `items` 를 두 부품에 넘긴다. 킷 CSS 가 1023px 이하에서 PC 메뉴를, 1024px 이상에서
24
+ * 모바일 서랍을 감추므로 미디어쿼리를 따로 쓰지 않는다.
25
+ * 계층은 **최대 3수준**이고 타입이 그것을 강제한다 — {@link MainMenuTop} → {@link MainMenuGroup}
26
+ * → {@link MainMenuLink}. `items` 가 없는 단계는 드롭다운 없는 단순 링크가 된다.
27
+ * **마우스오버로 열리지 않는다**(규격 금지) — 클릭·키보드로만 연다.
28
+ * 화면 안쪽의 하위 메뉴는 `ui/side-navigation` 이다.
29
+ *
30
+ * @example
31
+ * const menu: MainMenuTop[] = [
32
+ * { label: '기관소개', href: '/about' },
33
+ * {
34
+ * label: '정보공개',
35
+ * items: [
36
+ * {
37
+ * label: '사전정보공표',
38
+ * shortcut: { href: '/open' },
39
+ * items: [{ label: '공표목록', href: '/open/list', current: true }],
40
+ * },
41
+ * ],
42
+ * },
43
+ * ];
44
+ * <MainMenu items={menu} />
45
+ * <MainMenuMobile items={menu} open={open} onClose={close} header={<AuthLinks />} />
46
+ *
47
+ * 자세히: docs/krds/09-부품-노트.md#주메뉴
48
+ */
49
+
50
+ /** 새 창으로 열리는 링크에 붙일 안내 문구. */
51
+ const NEW_WINDOW_TITLE = '새 창 열림';
52
+
53
+ /** 링크가 이 문서 밖을 가리키는가. `/` 로 시작하지 않으면 `UiLink` 를 쓰지 않고 맨 <a> 로 그린다. */
54
+ function isExternalHref(href: string): boolean {
55
+ return !href.startsWith('/');
56
+ }
57
+
58
+ /** `useId()` 는 특수문자를 담는다 — id·href 조각으로 쓸 수 있게 깎는다. */
59
+ function idBase(raw: string): string {
60
+ return raw.replace(/[^a-zA-Z0-9_-]/g, '') || 'gnb';
61
+ }
62
+
63
+ type MenuActionProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
64
+ /** 없으면 `<button type="button">` 으로 그린다(킷 샘플이 링크와 버튼을 둘 다 허용한다). */
65
+ href?: string;
66
+ newWindow?: boolean;
67
+ 'data-trigger'?: string;
68
+ children: ReactNode;
69
+ };
70
+
71
+ /** 메뉴 한 칸을 링크(`href` 있음)나 버튼으로 그린다. 새 창이면 안내 `title` 을 붙인다. */
72
+ function MenuAction({ href, newWindow, children, ...rest }: MenuActionProps) {
73
+ if (!href) {
74
+ return (
75
+ <button type="button" {...rest}>
76
+ {children}
77
+ </button>
78
+ );
79
+ }
80
+
81
+ if (newWindow || isExternalHref(href)) {
82
+ return (
83
+ <a
84
+ href={href}
85
+ target={newWindow ? '_blank' : undefined}
86
+ rel={newWindow ? 'noopener noreferrer' : undefined}
87
+ title={newWindow ? NEW_WINDOW_TITLE : undefined}
88
+ {...rest}
89
+ >
90
+ {children}
91
+ </a>
92
+ );
93
+ }
94
+
95
+ return (
96
+ <UiLink href={href} {...rest}>
97
+ {children}
98
+ </UiLink>
99
+ );
100
+ }
101
+
102
+ /** 마지막 단(3뎁스) 항목. 여기서 트리가 끝난다 — 규격의 「최대 3수준」을 타입으로 막은 자리다. */
103
+ export type MainMenuLink = {
104
+ /** React key. 없으면 차례를 쓴다. */
105
+ id?: string;
106
+ /** 메뉴 이름. */
107
+ label: ReactNode;
108
+ /** 이동 주소. 없으면 버튼이 된다. */
109
+ href?: string;
110
+ /** 새 창으로 연다. 외부 링크면 화살표 아이콘이 함께 붙는다. */
111
+ newWindow?: boolean;
112
+ /** 지금 보고 있는 화면. `aria-current="page"` 가 붙는다. */
113
+ current?: boolean;
114
+ /** 메뉴 설명. 한 목록에 하나라도 있으면 그 목록 전체가 킷의 설명형으로 바뀐다. */
115
+ description?: string;
116
+ /** 눌렀을 때. */
117
+ onSelect?: () => void;
118
+ };
119
+
120
+ /** 드롭다운 오른쪽(또는 아래)의 신규 서비스 배너 — 킷 `gnb-sub-banner`. */
121
+ export type MainMenuBanner = {
122
+ /** 배지 글자. 배지는 대화형이 아니므로 글자만 넣는다. */
123
+ badge?: ReactNode;
124
+ /** 배너 글자. */
125
+ label: ReactNode;
126
+ /** 이동 주소. */
127
+ href?: string;
128
+ /** 새 창으로 연다. */
129
+ newWindow?: boolean;
130
+ /** 배너를 오른쪽 칸에 세운다. 기본은 목록 아래. */
131
+ side?: boolean;
132
+ /** 눌렀을 때. */
133
+ onSelect?: () => void;
134
+ };
135
+
136
+ /** 2뎁스. `items` 가 없으면 왼쪽 칸에 링크 한 줄로만 놓인다. */
137
+ export type MainMenuGroup = {
138
+ /** React key. 없으면 차례를 쓴다. */
139
+ id?: string;
140
+ /** 메뉴 이름. */
141
+ label: ReactNode;
142
+ /** 이동 주소. `items` 가 없을 때 쓰인다. */
143
+ href?: string;
144
+ /** 새 창으로 연다. */
145
+ newWindow?: boolean;
146
+ /** 지금 보고 있는 화면. */
147
+ current?: boolean;
148
+ /** 드롭다운 제목. 생략하면 `label` 을 쓴다 — 제목 없는 드롭다운은 만들 수 없다. */
149
+ title?: ReactNode;
150
+ /** 제목 옆 「바로가기」 링크. */
151
+ shortcut?: { href: string; label?: ReactNode; newWindow?: boolean };
152
+ /** 신규 서비스 배너. */
153
+ banner?: MainMenuBanner;
154
+ /** 3뎁스 항목. */
155
+ items?: MainMenuLink[];
156
+ /** 눌렀을 때. */
157
+ onSelect?: () => void;
158
+ };
159
+
160
+ /** 1뎁스. `items` 가 없으면 드롭다운 없는 단순 링크다. */
161
+ export type MainMenuTop = {
162
+ /** React key. 없으면 차례를 쓴다. */
163
+ id?: string;
164
+ /** 메뉴 이름. */
165
+ label: ReactNode;
166
+ /** 이동 주소. `items` 가 없을 때 쓰인다. */
167
+ href?: string;
168
+ /** 새 창으로 연다. */
169
+ newWindow?: boolean;
170
+ /** 지금 보고 있는 화면. */
171
+ current?: boolean;
172
+ /** 2뎁스 항목. */
173
+ items?: MainMenuGroup[];
174
+ /** 눌렀을 때. */
175
+ onSelect?: () => void;
176
+ };
177
+
178
+ /** 드롭다운 제목의 헤딩 단계. 화면의 제목 위계에 맞춘다. */
179
+ export type MainMenuTitleLevel = 2 | 3 | 4 | 5 | 6;
180
+
181
+ const key = (item: { id?: string }, index: number) => item.id ?? index;
182
+
183
+ /** 하위가 있는 첫 2뎁스. 없으면 -1(오른쪽 판을 펼치지 않는다). */
184
+ function firstPanelIndex(groups: MainMenuGroup[]): number {
185
+ return groups.findIndex((group) => (group.items?.length ?? 0) > 0);
186
+ }
187
+
188
+ function hasItems(group: MainMenuGroup): boolean {
189
+ return (group.items?.length ?? 0) > 0;
190
+ }
191
+
192
+ /** 드롭다운 한 판의 내용 — 킷 `gnb-sub-content` + `gnb-sub-banner`. 열 배치는 킷이 잡는다. */
193
+ function GroupPanel({
194
+ group,
195
+ titleLevel,
196
+ }: {
197
+ group: MainMenuGroup;
198
+ titleLevel: MainMenuTitleLevel;
199
+ }) {
200
+ const Title = `h${titleLevel}` as 'h2';
201
+ const DescTitle = `h${Math.min(titleLevel + 1, 6)}` as 'h3';
202
+ const items = group.items ?? [];
203
+ const described = items.some((item) => item.description);
204
+ const shortcut = group.shortcut;
205
+
206
+ return (
207
+ <>
208
+ <div className="gnb-sub-content">
209
+ <Title className="sub-title">
210
+ {shortcut ? (
211
+ <>
212
+ {group.title ?? group.label}
213
+ <MenuAction
214
+ href={shortcut.href}
215
+ newWindow={shortcut.newWindow}
216
+ className="krds-btn link basic small"
217
+ >
218
+ <span className="underline">{shortcut.label ?? '바로가기'}</span>
219
+ <i className="svg-icon ico-angle right" aria-hidden="true" />
220
+ </MenuAction>
221
+ </>
222
+ ) : (
223
+ <span>{group.title ?? group.label}</span>
224
+ )}
225
+ </Title>
226
+
227
+ <ul className={cx(described && 'type-description')}>
228
+ {items.map((item, index) => {
229
+ const external = item.newWindow || (item.href ? isExternalHref(item.href) : false);
230
+ if (described) {
231
+ return (
232
+ <li key={key(item, index)}>
233
+ <DescTitle className="tit">
234
+ <MenuAction
235
+ href={item.href}
236
+ newWindow={item.newWindow}
237
+ aria-current={item.current ? 'page' : undefined}
238
+ onClick={item.onSelect}
239
+ >
240
+ {item.label}
241
+ {external && <i className="svg-icon ico-go" aria-hidden="true" />}
242
+ </MenuAction>
243
+ </DescTitle>
244
+ {item.description && <p className="txt">{item.description}</p>}
245
+ </li>
246
+ );
247
+ }
248
+ return (
249
+ <li key={key(item, index)}>
250
+ <MenuAction
251
+ href={item.href}
252
+ newWindow={item.newWindow}
253
+ className={cx(item.current && 'active')}
254
+ aria-current={item.current ? 'page' : undefined}
255
+ onClick={item.onSelect}
256
+ >
257
+ {item.label}
258
+ </MenuAction>
259
+ </li>
260
+ );
261
+ })}
262
+ </ul>
263
+ </div>
264
+
265
+ {group.banner && (
266
+ <div className="gnb-sub-banner">
267
+ {group.banner.badge && (
268
+ <span className="krds-badge bg-secondary">{group.banner.badge}</span>
269
+ )}
270
+ <MenuAction
271
+ href={group.banner.href}
272
+ newWindow={group.banner.newWindow}
273
+ className="krds-btn medium text"
274
+ onClick={group.banner.onSelect}
275
+ >
276
+ {group.banner.label}
277
+ <i className="svg-icon ico-angle right" aria-hidden="true" />
278
+ </MenuAction>
279
+ </div>
280
+ )}
281
+ </>
282
+ );
283
+ }
284
+
285
+ export type MainMenuProps = Omit<ComponentPropsWithRef<'nav'>, 'children'> & {
286
+ /** 메뉴 트리(최대 3수준). */
287
+ items: MainMenuTop[];
288
+ /** `<nav>` 의 이름. 규격 권장 문구가 「메인 메뉴」다. */
289
+ label?: string;
290
+ /** 드롭다운 제목의 헤딩 단계. 기본 2. */
291
+ titleLevel?: MainMenuTitleLevel;
292
+ /** 딤을 깔고 본문 스크롤을 잠근다. 기본 켬 — 헤더 안 좁은 드롭다운으로 쓸 때만 끈다. */
293
+ backdrop?: boolean;
294
+ };
295
+
296
+ /**
297
+ * 주메뉴(PC) — 킷 `krds-main-menu`. **1023px 이하에서는 킷이 이 요소를 숨긴다** —
298
+ * 좁은 화면은 {@link MainMenuMobile} 이 맡는다.
299
+ */
300
+ export function MainMenu({
301
+ items,
302
+ label = '메인 메뉴',
303
+ titleLevel = 2,
304
+ backdrop = true,
305
+ className,
306
+ onKeyDown,
307
+ onBlur,
308
+ ref,
309
+ ...rest
310
+ }: MainMenuProps) {
311
+ const uid = idBase(useId());
312
+ const navRef = useRef<HTMLElement | null>(null);
313
+ const listRefs = useRef<Array<HTMLDivElement | null>>([]);
314
+ const [openIndex, setOpenIndex] = useState<number | null>(null);
315
+ /** 1뎁스 차례 → 펼쳐 둔 2뎁스 차례. 없으면 `firstPanelIndex` 를 쓴다. */
316
+ const [activeGroup, setActiveGroup] = useState<Record<number, number>>({});
317
+ const [mounted, setMounted] = useState(false);
318
+
319
+ useEffect(() => setMounted(true), []);
320
+
321
+ const close = useCallback(() => setOpenIndex(null), []);
322
+
323
+ const triggerAt = useCallback((index: number): HTMLElement | null => {
324
+ const triggers = navRef.current?.querySelectorAll<HTMLElement>('.gnb-main-trigger');
325
+ return triggers?.[index] ?? null;
326
+ }, []);
327
+
328
+ // 딤이 떠 있는 동안 본문 스크롤 잠금. 스크롤바가 사라지며 생기는 폭 차이는 킷이 메운다.
329
+ useEffect(() => {
330
+ if (openIndex === null || !backdrop) return;
331
+ const needsGutter = document.body.scrollHeight > window.innerHeight;
332
+ document.body.classList.add('is-gnb-web');
333
+ if (needsGutter) document.body.classList.add('hasScrollY');
334
+ return () => {
335
+ document.body.classList.remove('is-gnb-web');
336
+ if (needsGutter) document.body.classList.remove('hasScrollY');
337
+ };
338
+ }, [openIndex, backdrop]);
339
+
340
+ // 메뉴 밖을 누르면 닫는다(킷 JS 와 같다).
341
+ useEffect(() => {
342
+ if (openIndex === null) return;
343
+ function handleDown(event: MouseEvent) {
344
+ if (!navRef.current?.contains(event.target as Node)) close();
345
+ }
346
+ document.addEventListener('mousedown', handleDown);
347
+ return () => document.removeEventListener('mousedown', handleDown);
348
+ }, [openIndex, close]);
349
+
350
+ // Esc — 펼쳐진 드롭다운을 전부 접고 **그 1뎁스 메뉴로 초점을 되돌린다**(규격).
351
+ useEffect(() => {
352
+ if (openIndex === null) return;
353
+ function handleKey(event: KeyboardEvent) {
354
+ if (event.key !== 'Escape') return;
355
+ const trigger = triggerAt(openIndex as number);
356
+ close();
357
+ trigger?.focus();
358
+ }
359
+ document.addEventListener('keydown', handleKey);
360
+ return () => document.removeEventListener('keydown', handleKey);
361
+ }, [openIndex, close, triggerAt]);
362
+
363
+ // 오른쪽 판은 절대 배치라 왼쪽 칸의 높이를 밀어내지 못한다 — 킷 JS 처럼 min-height 로 옮긴다.
364
+ // 이 값이 있어야 70% 상한을 넘겼을 때 드롭다운이 스스로 스크롤한다.
365
+ useEffect(() => {
366
+ if (openIndex === null) return;
367
+ const list = listRefs.current[openIndex];
368
+ if (!list) return;
369
+ const panel = list.querySelector<HTMLElement>('.gnb-sub-list.active');
370
+ list.style.minHeight = panel ? `${panel.scrollHeight}px` : '';
371
+ return () => {
372
+ list.style.minHeight = '';
373
+ };
374
+ }, [openIndex, activeGroup, items]);
375
+
376
+ function toggleTop(index: number) {
377
+ setOpenIndex((current) => (current === index ? null : index));
378
+ }
379
+
380
+ /** 키보드 이동 — Home/End 는 1뎁스의 처음·끝으로, ←→↑↓ 는 같은 목록의 형제 트리거로 간다. */
381
+ function handleKeyDown(event: ReactKeyboardEvent<HTMLElement>) {
382
+ onKeyDown?.(event);
383
+ if (event.defaultPrevented) return;
384
+
385
+ const target = event.target as HTMLElement | null;
386
+ if (!target || target.dataset?.trigger !== 'gnb') return;
387
+
388
+ const move = (direction: 'next' | 'prev') => {
389
+ const sibling =
390
+ direction === 'next'
391
+ ? target.closest('li')?.nextElementSibling
392
+ : target.closest('li')?.previousElementSibling;
393
+ sibling?.querySelector<HTMLElement>('[data-trigger]')?.focus();
394
+ };
395
+
396
+ switch (event.key) {
397
+ case 'Home':
398
+ event.preventDefault();
399
+ triggerAt(0)?.focus();
400
+ break;
401
+ case 'End':
402
+ event.preventDefault();
403
+ triggerAt(items.length - 1)?.focus();
404
+ break;
405
+ case 'ArrowRight':
406
+ case 'ArrowDown':
407
+ event.preventDefault();
408
+ move('next');
409
+ break;
410
+ case 'ArrowLeft':
411
+ case 'ArrowUp':
412
+ event.preventDefault();
413
+ move('prev');
414
+ break;
415
+ default:
416
+ break;
417
+ }
418
+ }
419
+
420
+ /** Tab 으로 초점이 메뉴 밖으로 나가면 닫는다. 초점이 사라지는 경우(null)는 바깥 클릭이 맡는다. */
421
+ function handleBlur(event: ReactFocusEvent<HTMLElement>) {
422
+ onBlur?.(event);
423
+ const next = event.relatedTarget as Node | null;
424
+ if (next && !event.currentTarget.contains(next)) close();
425
+ }
426
+
427
+ return (
428
+ <>
429
+ <nav
430
+ // 바깥에서 넘긴 ref 도 살린다 — 안쪽 navRef 는 바깥 클릭·초점 판정에 쓴다.
431
+ ref={(node) => {
432
+ navRef.current = node;
433
+ if (typeof ref === 'function') ref(node);
434
+ else if (ref) ref.current = node;
435
+ }}
436
+ className={cx('krds-main-menu', className)}
437
+ aria-label={label}
438
+ onKeyDown={handleKeyDown}
439
+ onBlur={handleBlur}
440
+ {...rest}
441
+ >
442
+ <div className="inner">
443
+ <ul className="gnb-menu">
444
+ {items.map((top, index) => {
445
+ const groups = top.items ?? [];
446
+
447
+ // 하위가 없으면 단순 링크다 — 킷 `gnb-main-trigger is-link`(펼침 화살표가 지워진다).
448
+ if (groups.length === 0) {
449
+ return (
450
+ <li key={key(top, index)}>
451
+ <MenuAction
452
+ href={top.href}
453
+ newWindow={top.newWindow}
454
+ className="gnb-main-trigger is-link"
455
+ data-trigger="gnb"
456
+ aria-current={top.current ? 'page' : undefined}
457
+ onClick={top.onSelect}
458
+ >
459
+ {top.label}
460
+ </MenuAction>
461
+ </li>
462
+ );
463
+ }
464
+
465
+ const open = openIndex === index;
466
+ const panelId = `${uid}-gnb-${index}`;
467
+ const activeIndex = activeGroup[index] ?? firstPanelIndex(groups);
468
+ /** 2뎁스가 하나뿐이면 왼쪽 칸 없이 한 판만 편다 — 킷 `single-list`. */
469
+ const single = groups.length === 1 && hasItems(groups[0]);
470
+
471
+ return (
472
+ <li key={key(top, index)}>
473
+ <button
474
+ type="button"
475
+ className={cx('gnb-main-trigger', open && 'active')}
476
+ data-trigger="gnb"
477
+ aria-expanded={open}
478
+ aria-haspopup
479
+ aria-controls={panelId}
480
+ aria-current={open ? 'true' : top.current ? 'page' : undefined}
481
+ onClick={() => toggleTop(index)}
482
+ >
483
+ {top.label}
484
+ </button>
485
+
486
+ {/* 규격: 드롭다운 최대 높이는 뷰포트의 70%. 킷 CSS 에 없어 유틸리티로 보충한다. */}
487
+ <div
488
+ id={panelId}
489
+ className={cx(
490
+ 'gnb-toggle-wrap',
491
+ open && 'is-open',
492
+ 'max-h-[70vh] overflow-y-auto',
493
+ )}
494
+ >
495
+ <div
496
+ className="gnb-main-list"
497
+ data-has-submenu={single ? undefined : 'true'}
498
+ ref={(node) => {
499
+ listRefs.current[index] = node;
500
+ }}
501
+ >
502
+ {single ? (
503
+ <div
504
+ className={cx(
505
+ 'gnb-sub-list',
506
+ 'single-list',
507
+ groups[0].banner?.side && 'between',
508
+ )}
509
+ >
510
+ <GroupPanel group={groups[0]} titleLevel={titleLevel} />
511
+ </div>
512
+ ) : (
513
+ <ul>
514
+ {groups.map((group, groupIndex) => {
515
+ if (!hasItems(group)) {
516
+ const external =
517
+ group.newWindow ||
518
+ (group.href ? isExternalHref(group.href) : false);
519
+ return (
520
+ <li key={key(group, groupIndex)}>
521
+ <MenuAction
522
+ href={group.href}
523
+ newWindow={group.newWindow}
524
+ className={cx(
525
+ 'gnb-sub-trigger',
526
+ 'is-link',
527
+ external && 'external-link',
528
+ )}
529
+ data-trigger="gnb"
530
+ aria-current={group.current ? 'page' : undefined}
531
+ onClick={group.onSelect}
532
+ >
533
+ {group.label}
534
+ </MenuAction>
535
+ </li>
536
+ );
537
+ }
538
+
539
+ const active = activeIndex === groupIndex;
540
+ const subId = `${panelId}-sub-${groupIndex}`;
541
+
542
+ return (
543
+ <li key={key(group, groupIndex)}>
544
+ <button
545
+ type="button"
546
+ className={cx('gnb-sub-trigger', active && 'active')}
547
+ data-trigger="gnb"
548
+ aria-expanded={active}
549
+ aria-haspopup
550
+ aria-controls={subId}
551
+ onClick={() => {
552
+ setActiveGroup((current) => ({
553
+ ...current,
554
+ [index]: groupIndex,
555
+ }));
556
+ group.onSelect?.();
557
+ }}
558
+ >
559
+ {group.label}
560
+ </button>
561
+ <div
562
+ id={subId}
563
+ className={cx(
564
+ 'gnb-sub-list',
565
+ active && 'active',
566
+ group.banner?.side && 'between',
567
+ )}
568
+ >
569
+ <GroupPanel group={group} titleLevel={titleLevel} />
570
+ </div>
571
+ </li>
572
+ );
573
+ })}
574
+ </ul>
575
+ )}
576
+ </div>
577
+ </div>
578
+ </li>
579
+ );
580
+ })}
581
+ </ul>
582
+ </div>
583
+ </nav>
584
+
585
+ {/* 딤. 킷 JS 도 body 끝에 붙인다 — 헤더의 z-index·overflow 에 갇히지 않게 포털로 낸다. */}
586
+ {backdrop && mounted
587
+ ? createPortal(
588
+ <div className={cx('gnb-backdrop', openIndex !== null && 'active')} aria-hidden="true" />,
589
+ document.body,
590
+ )
591
+ : null}
592
+ </>
593
+ );
594
+ }
595
+
596
+ /* ────────────────────────────────────────────────────────────────────────── */
597
+
598
+ /** 킷 JS 의 지연값. CSS transition 과 짝이라 임의로 줄이면 어긋난다. */
599
+ const MOBILE_ENTER_DELAY = 100;
600
+ const MOBILE_LEAVE_DELAY = 400;
601
+
602
+ /** 킷 CSS 가 PC 주메뉴를 되살리는 폭. 이보다 넓어지면 모바일 서랍을 닫는다. */
603
+ const PC_MIN_WIDTH = 1024;
604
+
605
+ /** 초점 가둠 대상. 킷보다 넓게 잡았다. */
606
+ const FOCUSABLE_SELECTOR = [
607
+ 'a[href]',
608
+ 'button:not([disabled])',
609
+ 'input:not([disabled]):not([type="hidden"])',
610
+ 'select:not([disabled])',
611
+ 'textarea:not([disabled])',
612
+ '[tabindex]:not([tabindex="-1"])',
613
+ ].join(',');
614
+
615
+ function focusables(root: HTMLElement): HTMLElement[] {
616
+ return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
617
+ (el) => !el.hasAttribute('inert') && el.getClientRects().length > 0,
618
+ );
619
+ }
620
+
621
+ export type MainMenuMobileProps = Omit<ComponentPropsWithRef<'div'>, 'children' | 'id'> & {
622
+ /** 메뉴 트리. PC 주메뉴와 같은 값을 넘긴다. */
623
+ items: MainMenuTop[];
624
+ /** 열림 여부. 상태는 화면 쪽이 쥔다. */
625
+ open: boolean;
626
+ /** 닫아 달라는 요청 — Esc·닫기 단추·PC 폭으로 넓어질 때 온다. */
627
+ onClose: () => void;
628
+ /** 서랍의 id. 여는 단추의 `aria-controls` 와 짝이다. **한 화면에 하나만 둔다.** */
629
+ id?: string;
630
+ /** 서랍의 이름(`aria-label`). 기본 「전체 메뉴」. */
631
+ label?: string;
632
+ /** 닫기 단추의 이름. */
633
+ closeLabel?: string;
634
+ /** 판 제목의 헤딩 단계. 기본 2. */
635
+ titleLevel?: MainMenuTitleLevel;
636
+ /** 서랍 머리 자리 — 유틸 링크·로그인·검색칸. */
637
+ header?: ReactNode;
638
+ /** 서랍 바닥 자리 — 정책 링크 등. */
639
+ bottom?: ReactNode;
640
+ };
641
+
642
+ /**
643
+ * 전체메뉴(모바일) — 킷 `krds-main-menu-mobile`. 초점을 가두는 대화상자다.
644
+ *
645
+ * 왼쪽 1뎁스는 탭이 아니라 **앵커**다 — 누르면 오른쪽이 그 자리로 스크롤하고 선택 표시가 따라간다.
646
+ * **1024px 이상에서는 킷이 이 요소를 숨긴다** — PC 는 {@link MainMenu} 가 맡는다.
647
+ * 화면이 PC 폭으로 넓어지면 스스로 닫는다(그대로 두면 닫을 길이 사라진다).
648
+ */
649
+ export function MainMenuMobile({
650
+ items,
651
+ open,
652
+ onClose,
653
+ id = 'mobile-nav',
654
+ label = '전체 메뉴',
655
+ closeLabel = '전체메뉴 닫기',
656
+ titleLevel = 2,
657
+ header,
658
+ bottom,
659
+ className,
660
+ style,
661
+ onKeyDown,
662
+ ...rest
663
+ }: MainMenuMobileProps) {
664
+ const uid = idBase(useId());
665
+ const wrapRef = useRef<HTMLDivElement | null>(null);
666
+ const bodyRef = useRef<HTMLDivElement | null>(null);
667
+ /** `display:block` — 닫히는 동안에도 유지한다. */
668
+ const [shown, setShown] = useState(false);
669
+ /** `.is-open` · `.is-backdrop` — transition 을 태우는 클래스. */
670
+ const [entered, setEntered] = useState(false);
671
+ const [activeTab, setActiveTab] = useState(0);
672
+ const [openDepth3, setOpenDepth3] = useState<Record<string, boolean>>({});
673
+
674
+ const tabId = (index: number) => `${uid}-m-tab-${index}`;
675
+ const panelId = (index: number) => `${uid}-m-panel-${index}`;
676
+
677
+ // 열림 — 스크롤 잠금 · 상태 클래스 · 초점 이동. 정리 단계에서 잠금 해제와 초점 복귀.
678
+ useEffect(() => {
679
+ if (!open) return;
680
+ const restore = document.activeElement as HTMLElement | null;
681
+ setShown(true);
682
+ document.body.classList.add('is-gnb-mobile');
683
+
684
+ const enterTimer = window.setTimeout(() => {
685
+ setEntered(true);
686
+ wrapRef.current?.focus();
687
+ }, MOBILE_ENTER_DELAY);
688
+
689
+ return () => {
690
+ window.clearTimeout(enterTimer);
691
+ setEntered(false);
692
+ document.body.classList.remove('is-gnb-mobile');
693
+ restore?.focus?.();
694
+ };
695
+ }, [open]);
696
+
697
+ // 닫힘 — 클래스를 뗀 뒤 transition 만큼 기다렸다가 DOM 에서 감춘다.
698
+ useEffect(() => {
699
+ if (open || !shown) return;
700
+ const timer = window.setTimeout(() => setShown(false), MOBILE_LEAVE_DELAY);
701
+ return () => window.clearTimeout(timer);
702
+ }, [open, shown]);
703
+
704
+ // 화면이 PC 폭으로 넓어지면 닫는다 — 킷 CSS 가 이 서랍을 숨겨 버려 닫을 길이 사라지기 때문이다.
705
+ useEffect(() => {
706
+ if (!open) return;
707
+ const query = window.matchMedia(`(min-width: ${PC_MIN_WIDTH}px)`);
708
+ const handle = () => {
709
+ if (query.matches) onClose();
710
+ };
711
+ handle();
712
+ query.addEventListener('change', handle);
713
+ return () => query.removeEventListener('change', handle);
714
+ }, [open, onClose]);
715
+
716
+ /** 오른쪽이 스크롤하면 왼쪽 선택 표시를 따라 옮긴다 — 킷 `setupAnchorScroll`. */
717
+ useEffect(() => {
718
+ const body = bodyRef.current;
719
+ if (!open || !body) return;
720
+ const handle = () => {
721
+ const bottomReached = body.scrollTop + body.clientHeight >= body.scrollHeight - 1;
722
+ if (bottomReached) {
723
+ setActiveTab(items.length - 1);
724
+ return;
725
+ }
726
+ let next = 0;
727
+ items.forEach((_, index) => {
728
+ const panel = document.getElementById(panelId(index));
729
+ if (panel && body.scrollTop >= panel.offsetTop) next = index;
730
+ });
731
+ setActiveTab(next);
732
+ };
733
+ body.addEventListener('scroll', handle, { passive: true });
734
+ return () => body.removeEventListener('scroll', handle);
735
+ // eslint-disable-next-line react-hooks/exhaustive-deps
736
+ }, [open, items]);
737
+
738
+ function handleTabClick(event: ReactMouseEvent<HTMLAnchorElement>, index: number) {
739
+ event.preventDefault();
740
+ setActiveTab(index);
741
+ const panel = document.getElementById(panelId(index));
742
+ if (panel) bodyRef.current?.scrollTo({ top: panel.offsetTop, left: 0, behavior: 'smooth' });
743
+ }
744
+
745
+ /** Esc 로 닫고, Tab 초점을 서랍 안에 가둔다. */
746
+ function handleKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
747
+ onKeyDown?.(event);
748
+ if (event.defaultPrevented) return;
749
+
750
+ if (event.key === 'Escape') {
751
+ event.stopPropagation();
752
+ onClose();
753
+ return;
754
+ }
755
+ if (event.key !== 'Tab') return;
756
+
757
+ const wrap = wrapRef.current;
758
+ if (!wrap) return;
759
+ const nodes = focusables(wrap);
760
+ if (nodes.length === 0) {
761
+ event.preventDefault();
762
+ wrap.focus();
763
+ return;
764
+ }
765
+ const first = nodes[0];
766
+ const last = nodes[nodes.length - 1];
767
+ if (event.shiftKey && document.activeElement === first) {
768
+ event.preventDefault();
769
+ last.focus();
770
+ } else if (!event.shiftKey && document.activeElement === last) {
771
+ event.preventDefault();
772
+ first.focus();
773
+ }
774
+ }
775
+
776
+ const Title = `h${titleLevel}` as 'h2';
777
+
778
+ return (
779
+ <div
780
+ id={id}
781
+ className={cx('krds-main-menu-mobile', entered && 'is-open', entered && 'is-backdrop', className)}
782
+ style={{ display: shown ? 'block' : 'none', ...style }}
783
+ onKeyDown={handleKeyDown}
784
+ {...rest}
785
+ >
786
+ <div
787
+ ref={wrapRef}
788
+ className="gnb-wrap"
789
+ role="dialog"
790
+ aria-modal="true"
791
+ aria-label={label}
792
+ tabIndex={-1}
793
+ >
794
+ {header && <div className="gnb-header">{header}</div>}
795
+
796
+ <div className="gnb-body" ref={bodyRef}>
797
+ {/* 규격 권장 문구 — 컨테이너에 「메인 메뉴」. 바깥 대화상자 이름과 겹치지 않게 나눈다. */}
798
+ <div className="gnb-menu" role="navigation" aria-label="메인 메뉴">
799
+ <div className="menu-wrap">
800
+ <ul role="tablist">
801
+ {items.map((top, index) => (
802
+ <li key={key(top, index)} role="none">
803
+ <a
804
+ href={`#${panelId(index)}`}
805
+ id={tabId(index)}
806
+ className={cx('gnb-main-trigger', activeTab === index && 'active')}
807
+ role="tab"
808
+ aria-selected={activeTab === index}
809
+ aria-controls={panelId(index)}
810
+ onClick={(event) => handleTabClick(event, index)}
811
+ >
812
+ {top.label}
813
+ </a>
814
+ </li>
815
+ ))}
816
+ </ul>
817
+ </div>
818
+
819
+ <div className="submenu-wrap">
820
+ {items.map((top, index) => (
821
+ <div
822
+ key={key(top, index)}
823
+ id={panelId(index)}
824
+ className="gnb-sub-list"
825
+ role="tabpanel"
826
+ aria-labelledby={tabId(index)}
827
+ >
828
+ <Title className="sub-title">{top.label}</Title>
829
+ <ul>
830
+ {(top.items ?? []).map((group, groupIndex) => {
831
+ const groupKey = `${index}-${groupIndex}`;
832
+ const items3 = group.items ?? [];
833
+
834
+ if (items3.length === 0) {
835
+ return (
836
+ <li key={key(group, groupIndex)}>
837
+ <MenuAction
838
+ href={group.href}
839
+ newWindow={group.newWindow}
840
+ className={cx('gnb-sub-trigger', group.current && 'selected')}
841
+ aria-current={group.current ? 'page' : undefined}
842
+ onClick={group.onSelect}
843
+ >
844
+ {group.label}
845
+ </MenuAction>
846
+ </li>
847
+ );
848
+ }
849
+
850
+ const expanded = openDepth3[groupKey] ?? false;
851
+ const depth3Id = `${panelId(index)}-d3-${groupIndex}`;
852
+
853
+ return (
854
+ <li key={key(group, groupIndex)}>
855
+ <button
856
+ type="button"
857
+ className={cx(
858
+ 'gnb-sub-trigger',
859
+ 'has-depth3',
860
+ expanded && 'active',
861
+ group.current && 'selected',
862
+ )}
863
+ aria-expanded={expanded}
864
+ aria-controls={depth3Id}
865
+ onClick={() => {
866
+ setOpenDepth3((current) => ({
867
+ ...current,
868
+ [groupKey]: !expanded,
869
+ }));
870
+ group.onSelect?.();
871
+ }}
872
+ >
873
+ {group.label}
874
+ </button>
875
+ <div id={depth3Id} className={cx('depth3-wrap', expanded && 'is-open')}>
876
+ <ul>
877
+ {items3.map((item, itemIndex) => (
878
+ <li key={key(item, itemIndex)}>
879
+ <MenuAction
880
+ href={item.href}
881
+ newWindow={item.newWindow}
882
+ className={cx('depth3-trigger', item.current && 'active')}
883
+ aria-current={item.current ? 'page' : undefined}
884
+ onClick={item.onSelect}
885
+ >
886
+ {item.label}
887
+ </MenuAction>
888
+ </li>
889
+ ))}
890
+ </ul>
891
+ </div>
892
+ </li>
893
+ );
894
+ })}
895
+ </ul>
896
+ </div>
897
+ ))}
898
+ </div>
899
+ </div>
900
+
901
+ {bottom && <div className="gnb-bottom">{bottom}</div>}
902
+ </div>
903
+
904
+ {/* 킷 CSS 가 `#close-nav` 아이디로 위치를 잡는다 — 바꾸면 단추가 제자리에 놓이지 않는다. */}
905
+ <button type="button" className="krds-btn medium icon" id="close-nav" onClick={onClose}>
906
+ <span className="sr-only">{closeLabel}</span>
907
+ <i className="svg-icon ico-popup-close" aria-hidden="true" />
908
+ </button>
909
+ </div>
910
+ </div>
911
+ );
912
+ }