sh-ui-cli 0.45.3 → 0.47.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 (93) hide show
  1. package/data/changelog/versions.json +26 -0
  2. package/data/registry/react/components/accordion/index.module.tsx +97 -0
  3. package/data/registry/react/components/accordion/styles.module.css +111 -0
  4. package/data/registry/react/components/avatar/index.module.tsx +73 -0
  5. package/data/registry/react/components/avatar/styles.module.css +36 -0
  6. package/data/registry/react/components/badge/index.module.tsx +40 -0
  7. package/data/registry/react/components/badge/styles.module.css +57 -0
  8. package/data/registry/react/components/breadcrumb/index.module.tsx +152 -0
  9. package/data/registry/react/components/breadcrumb/styles.module.css +82 -0
  10. package/data/registry/react/components/button/index.module.tsx +45 -0
  11. package/data/registry/react/components/button/styles.module.css +92 -0
  12. package/data/registry/react/components/calendar/index.module.tsx +806 -0
  13. package/data/registry/react/components/calendar/styles.module.css +213 -0
  14. package/data/registry/react/components/card/index.module.tsx +63 -0
  15. package/data/registry/react/components/card/styles.module.css +73 -0
  16. package/data/registry/react/components/carousel/index.module.tsx +430 -0
  17. package/data/registry/react/components/carousel/styles.module.css +155 -0
  18. package/data/registry/react/components/checkbox/index.module.tsx +96 -0
  19. package/data/registry/react/components/checkbox/styles.module.css +75 -0
  20. package/data/registry/react/components/code-editor/index.module.tsx +230 -0
  21. package/data/registry/react/components/code-editor/styles.module.css +76 -0
  22. package/data/registry/react/components/code-panel/index.module.tsx +191 -0
  23. package/data/registry/react/components/code-panel/styles.module.css +124 -0
  24. package/data/registry/react/components/color-picker/index.module.tsx +467 -0
  25. package/data/registry/react/components/color-picker/styles.module.css +166 -0
  26. package/data/registry/react/components/combobox/index.module.tsx +165 -0
  27. package/data/registry/react/components/combobox/styles.module.css +151 -0
  28. package/data/registry/react/components/context-menu/index.module.tsx +251 -0
  29. package/data/registry/react/components/context-menu/styles.module.css +140 -0
  30. package/data/registry/react/components/date-picker/index.module.tsx +520 -0
  31. package/data/registry/react/components/date-picker/styles.module.css +103 -0
  32. package/data/registry/react/components/dialog/index.module.tsx +95 -0
  33. package/data/registry/react/components/dialog/styles.module.css +127 -0
  34. package/data/registry/react/components/dropdown-menu/index.module.tsx +255 -0
  35. package/data/registry/react/components/dropdown-menu/styles.module.css +150 -0
  36. package/data/registry/react/components/file-upload/index.module.tsx +487 -0
  37. package/data/registry/react/components/file-upload/styles.module.css +170 -0
  38. package/data/registry/react/components/form/index.module.tsx +61 -0
  39. package/data/registry/react/components/form/styles.module.css +47 -0
  40. package/data/registry/react/components/header/index.module.tsx +805 -0
  41. package/data/registry/react/components/header/styles.module.css +350 -0
  42. package/data/registry/react/components/input/index.module.tsx +486 -0
  43. package/data/registry/react/components/input/styles.module.css +200 -0
  44. package/data/registry/react/components/label/index.module.tsx +52 -0
  45. package/data/registry/react/components/label/styles.module.css +90 -0
  46. package/data/registry/react/components/markdown-editor/index.module.tsx +119 -0
  47. package/data/registry/react/components/markdown-editor/styles.module.css +160 -0
  48. package/data/registry/react/components/menubar/index.module.tsx +32 -0
  49. package/data/registry/react/components/menubar/styles.module.css +45 -0
  50. package/data/registry/react/components/numeric-input/index.module.tsx +148 -0
  51. package/data/registry/react/components/numeric-input/styles.module.css +56 -0
  52. package/data/registry/react/components/page-toc/index.module.tsx +174 -0
  53. package/data/registry/react/components/page-toc/styles.module.css +82 -0
  54. package/data/registry/react/components/pagination/index.module.tsx +269 -0
  55. package/data/registry/react/components/pagination/styles.module.css +105 -0
  56. package/data/registry/react/components/popover/index.module.tsx +113 -0
  57. package/data/registry/react/components/popover/styles.module.css +65 -0
  58. package/data/registry/react/components/progress/index.module.tsx +54 -0
  59. package/data/registry/react/components/progress/styles.module.css +41 -0
  60. package/data/registry/react/components/radio/index.module.tsx +65 -0
  61. package/data/registry/react/components/radio/styles.module.css +80 -0
  62. package/data/registry/react/components/rich-text-editor/index.module.tsx +348 -0
  63. package/data/registry/react/components/rich-text-editor/styles.module.css +196 -0
  64. package/data/registry/react/components/select/index.module.tsx +234 -0
  65. package/data/registry/react/components/select/styles.module.css +193 -0
  66. package/data/registry/react/components/separator/index.module.tsx +46 -0
  67. package/data/registry/react/components/separator/styles.module.css +15 -0
  68. package/data/registry/react/components/sidebar/index.module.tsx +1067 -0
  69. package/data/registry/react/components/sidebar/styles.module.css +502 -0
  70. package/data/registry/react/components/skeleton/index.module.tsx +22 -0
  71. package/data/registry/react/components/skeleton/styles.module.css +24 -0
  72. package/data/registry/react/components/slider/index.module.tsx +298 -0
  73. package/data/registry/react/components/slider/styles.module.css +64 -0
  74. package/data/registry/react/components/spinner/index.module.tsx +38 -0
  75. package/data/registry/react/components/spinner/styles.module.css +37 -0
  76. package/data/registry/react/components/switch/index.module.tsx +39 -0
  77. package/data/registry/react/components/switch/styles.module.css +83 -0
  78. package/data/registry/react/components/tabs/index.module.tsx +91 -0
  79. package/data/registry/react/components/tabs/styles.module.css +148 -0
  80. package/data/registry/react/components/textarea/index.module.tsx +23 -0
  81. package/data/registry/react/components/textarea/styles.module.css +54 -0
  82. package/data/registry/react/components/toast/index.module.tsx +258 -0
  83. package/data/registry/react/components/toast/styles.module.css +290 -0
  84. package/data/registry/react/components/toggle/index.module.tsx +131 -0
  85. package/data/registry/react/components/toggle/styles.module.css +85 -0
  86. package/data/registry/react/components/tooltip/index.module.tsx +83 -0
  87. package/data/registry/react/components/tooltip/styles.module.css +44 -0
  88. package/data/registry/react/registry.json +604 -1
  89. package/data/tokens/build.mjs +4 -0
  90. package/package.json +1 -1
  91. package/src/add.mjs +12 -12
  92. package/src/api.d.ts +4 -3
  93. package/src/constants.js +4 -3
@@ -0,0 +1,805 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { createPortal } from "react-dom";
5
+ import styles from "./styles.module.css";
6
+
7
+
8
+ import { cn } from "@SH_UI_UTILS@";
9
+ const FOCUSABLE_SELECTOR =
10
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
11
+
12
+ /* ───────── useFocusTrap ─────────
13
+ * drawer 가 열려 있을 때만 활성. 첫 tabbable 로 포커스 이동, Tab 순환,
14
+ * ESC 로 닫기, 닫힐 때 이전 포커스 복원.
15
+ */
16
+ function useFocusTrap(
17
+ containerRef: React.RefObject<HTMLElement | null>,
18
+ active: boolean,
19
+ onClose: () => void,
20
+ ) {
21
+ React.useEffect(() => {
22
+ if (!active) return;
23
+ const container = containerRef.current;
24
+ if (!container) return;
25
+
26
+ const previouslyFocused = (document.activeElement as HTMLElement) ?? null;
27
+ const focusables = () =>
28
+ Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
29
+ (el) => el.offsetParent !== null || el === document.activeElement,
30
+ );
31
+
32
+ const first = focusables()[0];
33
+ if (first) first.focus();
34
+ else {
35
+ container.setAttribute("tabindex", "-1");
36
+ container.focus();
37
+ }
38
+
39
+ const onKeyDown = (e: KeyboardEvent) => {
40
+ if (e.key === "Escape") {
41
+ e.preventDefault();
42
+ onClose();
43
+ return;
44
+ }
45
+ if (e.key !== "Tab") return;
46
+ const items = focusables();
47
+ if (items.length === 0) {
48
+ e.preventDefault();
49
+ return;
50
+ }
51
+ const firstEl = items[0];
52
+ const lastEl = items[items.length - 1];
53
+ if (e.shiftKey && document.activeElement === firstEl) {
54
+ e.preventDefault();
55
+ lastEl.focus();
56
+ } else if (!e.shiftKey && document.activeElement === lastEl) {
57
+ e.preventDefault();
58
+ firstEl.focus();
59
+ }
60
+ };
61
+
62
+ container.addEventListener("keydown", onKeyDown);
63
+ return () => {
64
+ container.removeEventListener("keydown", onKeyDown);
65
+ if (previouslyFocused && typeof previouslyFocused.focus === "function") {
66
+ previouslyFocused.focus();
67
+ }
68
+ };
69
+ }, [active, containerRef, onClose]);
70
+ }
71
+
72
+ /* ───────── Context ───────── */
73
+
74
+ type HeaderCtx = {
75
+ open: boolean;
76
+ setOpen: (v: boolean) => void;
77
+ triggerRef: React.RefObject<HTMLButtonElement | null>;
78
+ };
79
+
80
+ const HeaderContext = React.createContext<HeaderCtx | null>(null);
81
+
82
+ function useHeader(): HeaderCtx {
83
+ const ctx = React.useContext(HeaderContext);
84
+ if (!ctx) {
85
+ throw new Error("Header 하위 컴포넌트는 <Header> 안에서만 사용할 수 있습니다.");
86
+ }
87
+ return ctx;
88
+ }
89
+
90
+ /** 자식이 inline nav 안에 있는지 drawer 안에 있는지 알리는 컨텍스트 — HeaderMenu 가 모드 전환에 사용. */
91
+ type NavLocation = "inline" | "drawer";
92
+ const NavLocationContext = React.createContext<NavLocation>("inline");
93
+
94
+ /**
95
+ * HeaderNav 의 `value`(현재 경로 등) 와 매칭 함수를 자식 HeaderItem 에 전파하는 컨텍스트.
96
+ * HeaderItem 의 `active` 가 명시되지 않으면 이 컨텍스트를 통해 자동 계산된다.
97
+ */
98
+ type NavMatch = {
99
+ value: string | undefined;
100
+ match: (itemHref: string, value: string) => boolean;
101
+ /** uncontrolled 모드에서 HeaderItem 클릭 시 자동 active 갱신용. controlled 모드에선 onValueChange 만 호출. */
102
+ setValue: (value: string) => void;
103
+ };
104
+
105
+ /**
106
+ * 기본 매칭 — exact equality 또는 prefix match (`/docs` 항목이 `/docs/intro` 에서도 활성).
107
+ * 단, root(`"/"`/`""`) 는 prefix 가 모든 경로에 매칭돼버리는 걸 막기 위해 exact 일 때만 활성.
108
+ */
109
+ const defaultNavMatch = (itemHref: string, value: string): boolean => {
110
+ if (itemHref === value) return true;
111
+ if (itemHref === "" || itemHref === "/") return false;
112
+ return value.startsWith(itemHref + "/");
113
+ };
114
+
115
+ const NavMatchContext = React.createContext<NavMatch>({
116
+ value: undefined,
117
+ match: defaultNavMatch,
118
+ setValue: () => {},
119
+ });
120
+
121
+ /* ───────── Root ───────── */
122
+
123
+ export interface HeaderProps extends React.HTMLAttributes<HTMLElement> {
124
+ /**
125
+ * 모바일 drawer 초기 상태 (비제어 모드).
126
+ * @default false
127
+ */
128
+ defaultOpen?: boolean;
129
+ /** 모바일 drawer 열림 상태 (제어 모드). 지정 시 내부 state 대신 이 값이 우선. */
130
+ open?: boolean;
131
+ /** drawer 열림 변경 콜백. */
132
+ onOpenChange?: (open: boolean) => void;
133
+ /**
134
+ * 배경 표현 — 기본은 단색. transparent 는 hero 위 등 투명 배경, blur 는 반투명 + backdrop-filter.
135
+ *
136
+ * blur 의 불투명도/반경은 CSS 변수로 instance 별 조정 가능 — 컴포넌트 카피본 수정 없이
137
+ * `style={{ "--sh-ui-header-blur-opacity": "92%", "--sh-ui-header-blur-radius": "20px" }}` 처럼.
138
+ *
139
+ * @default "solid"
140
+ */
141
+ variant?: "solid" | "transparent" | "blur";
142
+ /**
143
+ * 스크롤 다운 시 헤더를 자동으로 숨기고, 위로 스크롤하면 다시 노출.
144
+ * `position: sticky` 와 함께 쓰는 걸 전제로 한다. 가장 가까운 스크롤 가능 조상을
145
+ * 자동 감지해 그 컨테이너의 scroll 이벤트에 반응하며, `prefers-reduced-motion: reduce`
146
+ * 환경에서는 슬라이드 애니메이션이 즉시 toggle 로 대체된다.
147
+ *
148
+ * @default false
149
+ */
150
+ stickyHide?: boolean;
151
+ /**
152
+ * stickyHide 가 활성일 때, 이 픽셀만큼 스크롤 다운한 뒤부터 숨김 동작 시작.
153
+ * @default 80
154
+ */
155
+ stickyHideThreshold?: number;
156
+ }
157
+
158
+ /**
159
+ * 사이트 상단 헤더(`<header>`). 데스크탑에서는 inline nav, 모바일에서는 햄버거 + drawer 로
160
+ * CSS 가 자동 전환된다. drawer 가 열리면 focus trap · ESC 닫기 · 트리거로 포커스 복원이 활성.
161
+ */
162
+ /** 가장 가까운 스크롤 가능 조상을 찾는다. 없으면 window 폴백. */
163
+ function findScrollParent(el: HTMLElement | null): HTMLElement | Window {
164
+ let node = el?.parentElement ?? null;
165
+ while (node) {
166
+ const style = window.getComputedStyle(node);
167
+ const oy = style.overflowY;
168
+ if ((oy === "auto" || oy === "scroll" || oy === "overlay") && node.scrollHeight > node.clientHeight) {
169
+ return node;
170
+ }
171
+ node = node.parentElement;
172
+ }
173
+ return window;
174
+ }
175
+
176
+ function getScrollY(target: HTMLElement | Window): number {
177
+ return target instanceof Window ? target.scrollY : target.scrollTop;
178
+ }
179
+
180
+ export const Header = React.forwardRef<HTMLElement, HeaderProps>(function Header(
181
+ {
182
+ children,
183
+ className,
184
+ defaultOpen = false,
185
+ open: openProp,
186
+ onOpenChange,
187
+ variant = "solid",
188
+ stickyHide = false,
189
+ stickyHideThreshold = 80,
190
+ ...props
191
+ },
192
+ ref,
193
+ ) {
194
+ const isControlled = openProp !== undefined;
195
+ const [internal, setInternal] = React.useState(defaultOpen);
196
+ const open = isControlled ? openProp : internal;
197
+ const triggerRef = React.useRef<HTMLButtonElement | null>(null);
198
+ const headerRef = React.useRef<HTMLElement | null>(null);
199
+
200
+ const setRefs = React.useCallback(
201
+ (node: HTMLElement | null) => {
202
+ headerRef.current = node;
203
+ if (typeof ref === "function") ref(node);
204
+ else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;
205
+ },
206
+ [ref],
207
+ );
208
+
209
+ const setOpen = React.useCallback(
210
+ (v: boolean) => {
211
+ if (!isControlled) setInternal(v);
212
+ onOpenChange?.(v);
213
+ },
214
+ [isControlled, onOpenChange],
215
+ );
216
+
217
+ React.useEffect(() => {
218
+ if (!open) return;
219
+ const prev = document.body.style.overflow;
220
+ document.body.style.overflow = "hidden";
221
+ return () => {
222
+ document.body.style.overflow = prev;
223
+ };
224
+ }, [open]);
225
+
226
+ const [hidden, setHidden] = React.useState(false);
227
+ React.useEffect(() => {
228
+ if (!stickyHide) {
229
+ setHidden(false);
230
+ return;
231
+ }
232
+ const target = findScrollParent(headerRef.current);
233
+ let lastY = getScrollY(target);
234
+ let ticking = false;
235
+ const onScroll = () => {
236
+ if (ticking) return;
237
+ ticking = true;
238
+ requestAnimationFrame(() => {
239
+ const y = getScrollY(target);
240
+ const delta = y - lastY;
241
+ if (y < stickyHideThreshold) {
242
+ setHidden(false);
243
+ } else if (delta > 4) {
244
+ setHidden(true);
245
+ } else if (delta < -4) {
246
+ setHidden(false);
247
+ }
248
+ lastY = y;
249
+ ticking = false;
250
+ });
251
+ };
252
+ target.addEventListener("scroll", onScroll, { passive: true });
253
+ return () => target.removeEventListener("scroll", onScroll);
254
+ }, [stickyHide, stickyHideThreshold]);
255
+
256
+ const ctx = React.useMemo<HeaderCtx>(
257
+ () => ({ open, setOpen, triggerRef }),
258
+ [open, setOpen],
259
+ );
260
+
261
+ return (
262
+ <HeaderContext.Provider value={ctx}>
263
+ <header
264
+ ref={setRefs}
265
+ className={cn(styles.header, styles[`header--${variant}`], className)}
266
+ data-drawer-open={open ? "" : undefined}
267
+ data-sticky-hide={stickyHide ? "" : undefined}
268
+ data-hidden={hidden ? "" : undefined}
269
+ {...props}
270
+ >
271
+ {children}
272
+ </header>
273
+ </HeaderContext.Provider>
274
+ );
275
+ });
276
+
277
+ /* ───────── Brand / Logo / Title ───────── */
278
+
279
+ export const HeaderBrand = React.forwardRef<
280
+ HTMLDivElement,
281
+ React.HTMLAttributes<HTMLDivElement>
282
+ >(function HeaderBrand({ className, ...props }, ref) {
283
+ return <div ref={ref} className={cn(styles.header__brand, className)} {...props} />;
284
+ });
285
+
286
+ export const HeaderLogo = React.forwardRef<
287
+ HTMLSpanElement,
288
+ React.HTMLAttributes<HTMLSpanElement>
289
+ >(function HeaderLogo({ className, ...props }, ref) {
290
+ return <span ref={ref} className={cn(styles.header__logo, className)} {...props} />;
291
+ });
292
+
293
+ export const HeaderTitle = React.forwardRef<
294
+ HTMLSpanElement,
295
+ React.HTMLAttributes<HTMLSpanElement>
296
+ >(function HeaderTitle({ className, ...props }, ref) {
297
+ return <span ref={ref} className={cn(styles.header__title, className)} {...props} />;
298
+ });
299
+
300
+ /* ───────── Trigger ─────────
301
+ * 햄버거 버튼. CSS 미디어 쿼리로 모바일에서만 노출. drawer 토글.
302
+ */
303
+
304
+ export const HeaderTrigger = React.forwardRef<
305
+ HTMLButtonElement,
306
+ React.ButtonHTMLAttributes<HTMLButtonElement>
307
+ >(function HeaderTrigger({ className, onClick, children, ...props }, ref) {
308
+ const { open, setOpen, triggerRef } = useHeader();
309
+
310
+ const setRefs = React.useCallback(
311
+ (node: HTMLButtonElement | null) => {
312
+ triggerRef.current = node;
313
+ if (typeof ref === "function") ref(node);
314
+ else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;
315
+ },
316
+ [ref, triggerRef],
317
+ );
318
+
319
+ return (
320
+ <button
321
+ ref={setRefs}
322
+ type="button"
323
+ className={cn(styles.header__trigger, className)}
324
+ aria-label={open ? "메뉴 닫기" : "메뉴 열기"}
325
+ aria-expanded={open}
326
+ data-open={open ? "" : undefined}
327
+ onClick={(e) => {
328
+ setOpen(!open);
329
+ onClick?.(e);
330
+ }}
331
+ {...props}
332
+ >
333
+ {children ?? (open ? <CloseIcon /> : <MenuIcon />)}
334
+ </button>
335
+ );
336
+ });
337
+
338
+ /* ───────── Nav ─────────
339
+ * 자식을 inline nav 와 drawer 두 곳에 렌더하며 CSS 가 뷰포트에 따라 한쪽만 노출.
340
+ * 각 렌더 위치를 NavLocationContext 로 전파해 HeaderMenu 가 dropdown vs collapsible 모드를 자동 선택.
341
+ *
342
+ * `value` + `match` 로 자식 HeaderItem 의 active 를 일괄 관리할 수 있다 — 항목마다 active 비교를
343
+ * 반복하는 대신 부모가 진실원천 한 군데에서 결정한다 (Tabs/RadioGroup 와 같은 패턴).
344
+ */
345
+
346
+ export interface HeaderNavProps extends React.HTMLAttributes<HTMLElement> {
347
+ /**
348
+ * Controlled 모드 — 현재 활성 경로/키 (예: Next.js usePathname() 결과). 자식 HeaderItem 의
349
+ * `href` 와 비교해 `data-active` 가 자동 부여된다. 자식에 `active` prop 이 명시되면 그게 우선.
350
+ */
351
+ value?: string;
352
+ /**
353
+ * Uncontrolled 모드 초기 값. `value` 미지정 시에만 사용된다. 자식 HeaderItem 클릭마다
354
+ * 내부 상태가 자동 갱신돼 active 가 따라 이동 — Tabs/RadioGroup 와 동일한 패턴.
355
+ */
356
+ defaultValue?: string;
357
+ /**
358
+ * 활성 값 변경 콜백. controlled / uncontrolled 모두에서 클릭 시 호출된다.
359
+ * controlled 면 외부 상태 갱신용, uncontrolled 면 단순 알림용.
360
+ */
361
+ onValueChange?: (value: string) => void;
362
+ /**
363
+ * 매칭 함수 커스터마이즈. 기본은 exact 또는 prefix(`/docs` 가 `/docs/intro` 에서도 활성).
364
+ * root(`/`/`""`) 는 prefix 매칭에서 제외된다 — 모든 경로에 매칭되는 걸 막기 위해.
365
+ */
366
+ match?: (itemHref: string, value: string) => boolean;
367
+ }
368
+
369
+ export const HeaderNav = React.forwardRef<HTMLElement, HeaderNavProps>(
370
+ function HeaderNav(
371
+ { value, defaultValue, onValueChange, match, className, children, ...props },
372
+ ref,
373
+ ) {
374
+ const { open, setOpen } = useHeader();
375
+ const drawerRef = React.useRef<HTMLElement | null>(null);
376
+
377
+ const close = React.useCallback(() => setOpen(false), [setOpen]);
378
+ useFocusTrap(drawerRef, open, close);
379
+
380
+ const isControlled = value !== undefined;
381
+ const [internalValue, setInternalValue] = React.useState(defaultValue);
382
+ const currentValue = isControlled ? value : internalValue;
383
+
384
+ const setValue = React.useCallback(
385
+ (next: string) => {
386
+ if (!isControlled) setInternalValue(next);
387
+ onValueChange?.(next);
388
+ },
389
+ [isControlled, onValueChange],
390
+ );
391
+
392
+ const navMatch = React.useMemo<NavMatch>(
393
+ () => ({ value: currentValue, match: match ?? defaultNavMatch, setValue }),
394
+ [currentValue, match, setValue],
395
+ );
396
+
397
+ return (
398
+ <NavMatchContext.Provider value={navMatch}>
399
+ <NavLocationContext.Provider value="inline">
400
+ <nav ref={ref} className={cn(styles.header__nav, className)} {...props}>
401
+ {children}
402
+ </nav>
403
+ </NavLocationContext.Provider>
404
+
405
+ <div
406
+ className={styles.header__backdrop}
407
+ data-open={open ? "" : undefined}
408
+ onClick={close}
409
+ aria-hidden
410
+ />
411
+ <aside
412
+ ref={drawerRef}
413
+ className={styles.header__drawer}
414
+ data-open={open ? "" : undefined}
415
+ aria-hidden={!open}
416
+ role="dialog"
417
+ aria-modal="true"
418
+ aria-label="메뉴"
419
+ >
420
+ <div className={styles["header__drawer-head"]}>
421
+ <HeaderTrigger />
422
+ </div>
423
+ <NavLocationContext.Provider value="drawer">
424
+ <nav className={styles["header__drawer-nav"]}>{children}</nav>
425
+ </NavLocationContext.Provider>
426
+ </aside>
427
+ </NavMatchContext.Provider>
428
+ );
429
+ },
430
+ );
431
+
432
+ /* ───────── Item ───────── */
433
+
434
+ export const HeaderItem = React.forwardRef<
435
+ HTMLAnchorElement,
436
+ React.AnchorHTMLAttributes<HTMLAnchorElement> & {
437
+ /**
438
+ * 활성 상태. 명시하지 않으면 부모 `HeaderNav` 의 `value` 와 자기 `href` 를 비교해 자동 계산된다.
439
+ * 명시적으로 `active={true}` / `active={false}` 를 주면 자동 계산보다 우선.
440
+ */
441
+ active?: boolean;
442
+ }
443
+ >(function HeaderItem({ className, active, onClick, href, ...props }, ref) {
444
+ const { setOpen } = useHeader();
445
+ const navMatch = React.useContext(NavMatchContext);
446
+
447
+ const computedActive =
448
+ active !== undefined
449
+ ? active
450
+ : navMatch.value !== undefined && href !== undefined
451
+ ? navMatch.match(href, navMatch.value)
452
+ : false;
453
+
454
+ return (
455
+ <a
456
+ ref={ref}
457
+ href={href}
458
+ className={cn(styles.header__item, className)}
459
+ data-active={computedActive ? "" : undefined}
460
+ aria-current={computedActive ? "page" : undefined}
461
+ onClick={(e) => {
462
+ setOpen(false);
463
+ // HeaderNav 의 NavMatch 에 활성 값 전달 — uncontrolled 면 내부 상태 갱신, controlled 면 onValueChange 호출
464
+ if (href !== undefined) navMatch.setValue(href);
465
+ onClick?.(e);
466
+ }}
467
+ {...props}
468
+ />
469
+ );
470
+ });
471
+
472
+ /* ───────── Actions ───────── */
473
+
474
+ export const HeaderActions = React.forwardRef<
475
+ HTMLDivElement,
476
+ React.HTMLAttributes<HTMLDivElement>
477
+ >(function HeaderActions({ className, ...props }, ref) {
478
+ return (
479
+ <div ref={ref} className={cn(styles.header__actions, className)} {...props} />
480
+ );
481
+ });
482
+
483
+ /* ───────── 반응형 가시성 유틸 ─────────
484
+ * HeaderNav 와 달리 자식을 drawer 로 옮기지 않고 단순히 가시성만 토글한다.
485
+ * display: contents 라 부모의 flex/grid 흐름을 그대로 유지 — wrapper 가 레이아웃에 잡히지 않음.
486
+ */
487
+
488
+ /** 데스크탑(≥768px) 에서만 보이는 슬롯. 모바일에서는 자식이 통째로 사라진다 (drawer 로 이동하지 않음). */
489
+ export const HeaderDesktopOnly = React.forwardRef<
490
+ HTMLDivElement,
491
+ React.HTMLAttributes<HTMLDivElement>
492
+ >(function HeaderDesktopOnly({ className, ...props }, ref) {
493
+ return (
494
+ <div ref={ref} className={cn(styles["header__desktop-only"], className)} {...props} />
495
+ );
496
+ });
497
+
498
+ /** 모바일(<768px) 에서만 보이는 슬롯. 데스크탑에서는 자식이 통째로 사라진다. 사용자 정의 drawer 트리거 등에 사용. */
499
+ export const HeaderMobileOnly = React.forwardRef<
500
+ HTMLDivElement,
501
+ React.HTMLAttributes<HTMLDivElement>
502
+ >(function HeaderMobileOnly({ className, ...props }, ref) {
503
+ return (
504
+ <div ref={ref} className={cn(styles["header__mobile-only"], className)} {...props} />
505
+ );
506
+ });
507
+
508
+ /* ───────── NavGroup (drawer 안 섹션 라벨) ─────────
509
+ * inline nav 에서는 자식만 펼쳐 평면으로 렌더(라벨 숨김), drawer 에서는 라벨 + 들여쓴 항목으로 렌더.
510
+ */
511
+
512
+ export interface HeaderNavGroupProps extends React.HTMLAttributes<HTMLDivElement> {
513
+ /** 그룹 섹션 라벨. drawer 모드에서만 보인다. */
514
+ label?: React.ReactNode;
515
+ }
516
+
517
+ /** drawer 안에서 nav 항목을 섹션으로 묶는다. inline 모드에서는 라벨 없이 자식만 펼쳐 렌더. */
518
+ export const HeaderNavGroup = React.forwardRef<HTMLDivElement, HeaderNavGroupProps>(
519
+ function HeaderNavGroup({ className, label, children, ...props }, ref) {
520
+ const location = React.useContext(NavLocationContext);
521
+ if (location === "inline") {
522
+ return (
523
+ <div
524
+ ref={ref}
525
+ className={cn(styles.header__group, styles["header__group--inline"], className)}
526
+ {...props}
527
+ >
528
+ {children}
529
+ </div>
530
+ );
531
+ }
532
+ return (
533
+ <div
534
+ ref={ref}
535
+ className={cn(styles.header__group, styles["header__group--drawer"], className)}
536
+ role="group"
537
+ aria-label={typeof label === "string" ? label : undefined}
538
+ {...props}
539
+ >
540
+ {label != null && (
541
+ <div className={styles["header__group-label"]}>{label}</div>
542
+ )}
543
+ <div className={styles["header__group-items"]}>{children}</div>
544
+ </div>
545
+ );
546
+ },
547
+ );
548
+
549
+ /* ───────── Menu (서브메뉴) ─────────
550
+ * desktop (inline) 에서는 절대 위치 dropdown, drawer 안에서는 collapsible.
551
+ * 동일한 자식 트리를 두 모드 모두 동일하게 렌더.
552
+ */
553
+
554
+ type MenuCtx = {
555
+ open: boolean;
556
+ setOpen: (v: boolean) => void;
557
+ triggerId: string;
558
+ contentId: string;
559
+ location: NavLocation;
560
+ triggerRef: React.RefObject<HTMLButtonElement | null>;
561
+ contentRef: React.RefObject<HTMLDivElement | null>;
562
+ };
563
+ const MenuContext = React.createContext<MenuCtx | null>(null);
564
+
565
+ function useMenu() {
566
+ const ctx = React.useContext(MenuContext);
567
+ if (!ctx) throw new Error("HeaderMenu 하위 컴포넌트는 <HeaderMenu> 안에서만 사용할 수 있습니다.");
568
+ return ctx;
569
+ }
570
+
571
+ /** 드롭다운/콜랩서블 서브메뉴 wrapper. <HeaderMenuTrigger> + <HeaderMenuContent> 와 함께 사용. */
572
+ export function HeaderMenu({
573
+ children,
574
+ className,
575
+ defaultOpen = false,
576
+ }: {
577
+ children: React.ReactNode;
578
+ className?: string;
579
+ /** drawer 모드에서 collapsible 의 초기 펼침 상태. */
580
+ defaultOpen?: boolean;
581
+ }) {
582
+ const location = React.useContext(NavLocationContext);
583
+ const [open, setOpen] = React.useState(location === "drawer" ? defaultOpen : false);
584
+ const containerRef = React.useRef<HTMLDivElement>(null);
585
+ const triggerRef = React.useRef<HTMLButtonElement | null>(null);
586
+ const contentRef = React.useRef<HTMLDivElement | null>(null);
587
+ const triggerId = React.useId();
588
+ const contentId = React.useId();
589
+
590
+ // dropdown 모드에서만 외부 클릭 닫기 + ESC 닫기.
591
+ // portal 로 띄운 content 는 containerRef 의 자식이 아니므로 contentRef 도 별도 검사.
592
+ React.useEffect(() => {
593
+ if (location !== "inline") return;
594
+ if (!open) return;
595
+
596
+ const onPointerDown = (e: PointerEvent) => {
597
+ const target = e.target as Node;
598
+ if (containerRef.current?.contains(target)) return;
599
+ if (contentRef.current?.contains(target)) return;
600
+ setOpen(false);
601
+ };
602
+ const onKey = (e: KeyboardEvent) => {
603
+ if (e.key === "Escape") setOpen(false);
604
+ };
605
+ document.addEventListener("pointerdown", onPointerDown);
606
+ document.addEventListener("keydown", onKey);
607
+ return () => {
608
+ document.removeEventListener("pointerdown", onPointerDown);
609
+ document.removeEventListener("keydown", onKey);
610
+ };
611
+ }, [open, location]);
612
+
613
+ // location 이 inline ↔ drawer 로 바뀔 때 reset
614
+ React.useEffect(() => {
615
+ if (location === "inline") setOpen(false);
616
+ }, [location]);
617
+
618
+ const ctx = React.useMemo<MenuCtx>(
619
+ () => ({ open, setOpen, triggerId, contentId, location, triggerRef, contentRef }),
620
+ [open, triggerId, contentId, location],
621
+ );
622
+
623
+ return (
624
+ <MenuContext.Provider value={ctx}>
625
+ <div
626
+ ref={containerRef}
627
+ className={cn(
628
+ styles.header__menu,
629
+ styles[`header__menu--${location}`],
630
+ open && "is-open",
631
+ className,
632
+ )}
633
+ data-open={open ? "" : undefined}
634
+ >
635
+ {children}
636
+ </div>
637
+ </MenuContext.Provider>
638
+ );
639
+ }
640
+
641
+ /** HeaderMenu 토글 버튼. HeaderItem 과 비슷한 룩, 우측에 chevron. */
642
+ export const HeaderMenuTrigger = React.forwardRef<
643
+ HTMLButtonElement,
644
+ React.ButtonHTMLAttributes<HTMLButtonElement>
645
+ >(function HeaderMenuTrigger({ className, children, onClick, ...props }, ref) {
646
+ const { open, setOpen, triggerId, contentId, triggerRef } = useMenu();
647
+
648
+ const setRefs = React.useCallback(
649
+ (node: HTMLButtonElement | null) => {
650
+ triggerRef.current = node;
651
+ if (typeof ref === "function") ref(node);
652
+ else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;
653
+ },
654
+ [ref, triggerRef],
655
+ );
656
+
657
+ return (
658
+ <button
659
+ ref={setRefs}
660
+ type="button"
661
+ id={triggerId}
662
+ aria-haspopup="menu"
663
+ aria-expanded={open}
664
+ aria-controls={contentId}
665
+ data-open={open ? "" : undefined}
666
+ className={cn(styles["header__menu-trigger"], className)}
667
+ onClick={(e) => {
668
+ setOpen(!open);
669
+ onClick?.(e);
670
+ }}
671
+ {...props}
672
+ >
673
+ <span className={styles["header__menu-trigger-label"]}>{children}</span>
674
+ <ChevronDownIcon />
675
+ </button>
676
+ );
677
+ });
678
+
679
+ /** HeaderMenu 의 펼쳐지는 본문. inline 모드에서는 document.body 로 portal — 부모 overflow 클리핑을 회피한다. */
680
+ export const HeaderMenuContent = React.forwardRef<
681
+ HTMLDivElement,
682
+ React.HTMLAttributes<HTMLDivElement>
683
+ >(function HeaderMenuContent({ className, children, style, ...props }, ref) {
684
+ const { open, contentId, triggerId, location, triggerRef, contentRef } = useMenu();
685
+
686
+ const setRefs = React.useCallback(
687
+ (node: HTMLDivElement | null) => {
688
+ contentRef.current = node;
689
+ if (typeof ref === "function") ref(node);
690
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
691
+ },
692
+ [ref, contentRef],
693
+ );
694
+
695
+ // drawer 모드 — 트리거 바로 아래 inline 으로 펼쳐지는 collapsible.
696
+ if (location === "drawer") {
697
+ return (
698
+ <div
699
+ ref={setRefs}
700
+ id={contentId}
701
+ role="menu"
702
+ aria-labelledby={triggerId}
703
+ data-open={open ? "" : undefined}
704
+ hidden={!open}
705
+ className={cn(styles["header__menu-content"], className)}
706
+ style={style}
707
+ {...props}
708
+ >
709
+ {children}
710
+ </div>
711
+ );
712
+ }
713
+
714
+ // inline 모드 — document.body 로 portal + 트리거 위치 추종
715
+ const [mounted, setMounted] = React.useState(false);
716
+ React.useEffect(() => setMounted(true), []);
717
+
718
+ const [pos, setPos] = React.useState<{ top: number; left: number; minWidth: number }>({
719
+ top: 0,
720
+ left: 0,
721
+ minWidth: 0,
722
+ });
723
+
724
+ React.useLayoutEffect(() => {
725
+ if (!open) return;
726
+ const update = () => {
727
+ const trigger = triggerRef.current;
728
+ if (!trigger) return;
729
+ const rect = trigger.getBoundingClientRect();
730
+ setPos({
731
+ top: rect.bottom + window.scrollY + 4,
732
+ left: rect.left + window.scrollX,
733
+ minWidth: rect.width,
734
+ });
735
+ };
736
+ update();
737
+ // capture: true 로 모든 스크롤 컨테이너 변화를 잡아 재배치
738
+ window.addEventListener("scroll", update, true);
739
+ window.addEventListener("resize", update);
740
+ return () => {
741
+ window.removeEventListener("scroll", update, true);
742
+ window.removeEventListener("resize", update);
743
+ };
744
+ }, [open, triggerRef]);
745
+
746
+ if (!mounted || !open) return null;
747
+
748
+ return createPortal(
749
+ <div
750
+ ref={setRefs}
751
+ id={contentId}
752
+ role="menu"
753
+ aria-labelledby={triggerId}
754
+ data-open=""
755
+ className={cn(styles["header__menu-content"], styles["header__menu-content--portal"], className)}
756
+ style={{
757
+ position: "absolute",
758
+ top: pos.top,
759
+ left: pos.left,
760
+ minWidth: Math.max(pos.minWidth, 192),
761
+ ...style,
762
+ }}
763
+ {...props}
764
+ >
765
+ {children}
766
+ </div>,
767
+ document.body,
768
+ );
769
+ });
770
+
771
+ /* ───────── 기본 아이콘 ───────── */
772
+
773
+ function MenuIcon() {
774
+ return (
775
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
776
+ <path
777
+ d="M3 6h18M3 12h18M3 18h18"
778
+ stroke="currentColor"
779
+ strokeWidth="1.75"
780
+ strokeLinecap="round"
781
+ />
782
+ </svg>
783
+ );
784
+ }
785
+
786
+ function CloseIcon() {
787
+ return (
788
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
789
+ <path
790
+ d="M6 6l12 12M18 6L6 18"
791
+ stroke="currentColor"
792
+ strokeWidth="1.75"
793
+ strokeLinecap="round"
794
+ />
795
+ </svg>
796
+ );
797
+ }
798
+
799
+ function ChevronDownIcon() {
800
+ return (
801
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden className={styles.header__chevron}>
802
+ <path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
803
+ </svg>
804
+ );
805
+ }