sh-ui-cli 0.24.0 → 0.31.1

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