sh-ui-cli 0.43.0 → 0.45.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 (46) hide show
  1. package/data/changelog/versions.json +24 -0
  2. package/data/registry/react/components/accordion/index.tailwind.tsx +88 -0
  3. package/data/registry/react/components/avatar/index.tailwind.tsx +74 -0
  4. package/data/registry/react/components/badge/index.tailwind.tsx +47 -0
  5. package/data/registry/react/components/breadcrumb/index.tailwind.tsx +138 -0
  6. package/data/registry/react/components/calendar/index.tailwind.tsx +498 -0
  7. package/data/registry/react/components/carousel/index.tailwind.tsx +309 -0
  8. package/data/registry/react/components/checkbox/index.tailwind.tsx +72 -0
  9. package/data/registry/react/components/code-editor/index.tailwind.tsx +168 -0
  10. package/data/registry/react/components/code-panel/index.tailwind.tsx +107 -0
  11. package/data/registry/react/components/color-picker/index.tailwind.tsx +309 -0
  12. package/data/registry/react/components/combobox/index.tailwind.tsx +160 -0
  13. package/data/registry/react/components/context-menu/index.tailwind.tsx +170 -0
  14. package/data/registry/react/components/date-picker/index.tailwind.tsx +294 -0
  15. package/data/registry/react/components/dialog/index.tailwind.tsx +96 -0
  16. package/data/registry/react/components/dropdown-menu/index.tailwind.tsx +205 -0
  17. package/data/registry/react/components/file-upload/index.tailwind.tsx +290 -0
  18. package/data/registry/react/components/form/field.tailwind.tsx +165 -0
  19. package/data/registry/react/components/form/form.tailwind.tsx +129 -0
  20. package/data/registry/react/components/form/index.tailwind.tsx +49 -0
  21. package/data/registry/react/components/header/index.tailwind.tsx +550 -0
  22. package/data/registry/react/components/label/index.tailwind.tsx +78 -0
  23. package/data/registry/react/components/markdown-editor/index.tailwind.tsx +118 -0
  24. package/data/registry/react/components/menubar/index.tailwind.tsx +32 -0
  25. package/data/registry/react/components/numeric-input/index.tailwind.tsx +113 -0
  26. package/data/registry/react/components/page-toc/index.tailwind.tsx +149 -0
  27. package/data/registry/react/components/pagination/index.tailwind.tsx +148 -0
  28. package/data/registry/react/components/popover/index.tailwind.tsx +77 -0
  29. package/data/registry/react/components/progress/index.tailwind.tsx +60 -0
  30. package/data/registry/react/components/radio/index.tailwind.tsx +54 -0
  31. package/data/registry/react/components/rich-text-editor/index.tailwind.tsx +211 -0
  32. package/data/registry/react/components/select/index.tailwind.tsx +199 -0
  33. package/data/registry/react/components/separator/index.tailwind.tsx +42 -0
  34. package/data/registry/react/components/sidebar/index.tailwind.tsx +635 -0
  35. package/data/registry/react/components/skeleton/index.tailwind.tsx +39 -0
  36. package/data/registry/react/components/slider/index.tailwind.tsx +255 -0
  37. package/data/registry/react/components/spinner/index.tailwind.tsx +63 -0
  38. package/data/registry/react/components/switch/index.tailwind.tsx +62 -0
  39. package/data/registry/react/components/tabs/index.tailwind.tsx +113 -0
  40. package/data/registry/react/components/textarea/index.tailwind.tsx +21 -0
  41. package/data/registry/react/components/toast/index.tailwind.tsx +215 -0
  42. package/data/registry/react/components/toggle/index.tailwind.tsx +111 -0
  43. package/data/registry/react/components/tooltip/index.tailwind.tsx +55 -0
  44. package/data/registry/react/registry.json +696 -98
  45. package/package.json +1 -1
  46. package/src/mcp.mjs +1 -1
@@ -0,0 +1,550 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { createPortal } from "react-dom";
5
+
6
+ function cx(...args: (string | undefined | false | null)[]) {
7
+ return args.filter(Boolean).join(" ");
8
+ }
9
+
10
+ const FOCUSABLE_SELECTOR =
11
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
12
+
13
+ function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>, active: boolean, onClose: () => void) {
14
+ React.useEffect(() => {
15
+ if (!active) return;
16
+ const container = containerRef.current;
17
+ if (!container) return;
18
+ const previouslyFocused = (document.activeElement as HTMLElement) ?? null;
19
+ const focusables = () => Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter((el) => el.offsetParent !== null || el === document.activeElement);
20
+ const first = focusables()[0];
21
+ if (first) first.focus();
22
+ else { container.setAttribute("tabindex", "-1"); container.focus(); }
23
+ const onKeyDown = (e: KeyboardEvent) => {
24
+ if (e.key === "Escape") { e.preventDefault(); onClose(); return; }
25
+ if (e.key !== "Tab") return;
26
+ const items = focusables();
27
+ if (items.length === 0) { e.preventDefault(); return; }
28
+ const firstEl = items[0];
29
+ const lastEl = items[items.length - 1];
30
+ if (e.shiftKey && document.activeElement === firstEl) { e.preventDefault(); lastEl.focus(); }
31
+ else if (!e.shiftKey && document.activeElement === lastEl) { e.preventDefault(); firstEl.focus(); }
32
+ };
33
+ container.addEventListener("keydown", onKeyDown);
34
+ return () => {
35
+ container.removeEventListener("keydown", onKeyDown);
36
+ if (previouslyFocused && typeof previouslyFocused.focus === "function") previouslyFocused.focus();
37
+ };
38
+ }, [active, containerRef, onClose]);
39
+ }
40
+
41
+ type HeaderCtx = {
42
+ open: boolean;
43
+ setOpen: (v: boolean) => void;
44
+ triggerRef: React.RefObject<HTMLButtonElement | null>;
45
+ };
46
+ const HeaderContext = React.createContext<HeaderCtx | null>(null);
47
+ function useHeader(): HeaderCtx {
48
+ const ctx = React.useContext(HeaderContext);
49
+ if (!ctx) throw new Error("Header 하위 컴포넌트는 <Header> 안에서만 사용할 수 있습니다.");
50
+ return ctx;
51
+ }
52
+
53
+ type NavLocation = "inline" | "drawer";
54
+ const NavLocationContext = React.createContext<NavLocation>("inline");
55
+
56
+ type NavMatch = {
57
+ value: string | undefined;
58
+ match: (itemHref: string, value: string) => boolean;
59
+ setValue: (value: string) => void;
60
+ };
61
+ const defaultNavMatch = (itemHref: string, value: string): boolean => {
62
+ if (itemHref === value) return true;
63
+ if (itemHref === "" || itemHref === "/") return false;
64
+ return value.startsWith(itemHref + "/");
65
+ };
66
+ const NavMatchContext = React.createContext<NavMatch>({ value: undefined, match: defaultNavMatch, setValue: () => {} });
67
+
68
+ export interface HeaderProps extends React.HTMLAttributes<HTMLElement> {
69
+ defaultOpen?: boolean;
70
+ open?: boolean;
71
+ onOpenChange?: (open: boolean) => void;
72
+ variant?: "solid" | "transparent" | "blur";
73
+ stickyHide?: boolean;
74
+ stickyHideThreshold?: number;
75
+ }
76
+
77
+ function findScrollParent(el: HTMLElement | null): HTMLElement | Window {
78
+ let node = el?.parentElement ?? null;
79
+ while (node) {
80
+ const style = window.getComputedStyle(node);
81
+ const oy = style.overflowY;
82
+ if ((oy === "auto" || oy === "scroll" || oy === "overlay") && node.scrollHeight > node.clientHeight) return node;
83
+ node = node.parentElement;
84
+ }
85
+ return window;
86
+ }
87
+ function getScrollY(target: HTMLElement | Window): number {
88
+ return target instanceof Window ? target.scrollY : target.scrollTop;
89
+ }
90
+
91
+ const variantClasses = {
92
+ solid: "bg-background",
93
+ transparent: "bg-transparent border-b-transparent [--sh-ui-header-hover-bg:color-mix(in_srgb,currentColor_14%,transparent)]",
94
+ blur: "bg-[color-mix(in_srgb,var(--background)_var(--sh-ui-header-blur-opacity),transparent)] [backdrop-filter:saturate(180%)_blur(var(--sh-ui-header-blur-radius))] [-webkit-backdrop-filter:saturate(180%)_blur(var(--sh-ui-header-blur-radius))] [--sh-ui-header-hover-bg:color-mix(in_srgb,currentColor_14%,transparent)] supports-[not_(backdrop-filter:blur(1px))]:bg-background",
95
+ };
96
+
97
+ export const Header = React.forwardRef<HTMLElement, HeaderProps>(function Header(
98
+ { children, className, defaultOpen = false, open: openProp, onOpenChange, variant = "solid", stickyHide = false, stickyHideThreshold = 80, ...props },
99
+ ref,
100
+ ) {
101
+ const isControlled = openProp !== undefined;
102
+ const [internal, setInternal] = React.useState(defaultOpen);
103
+ const open = isControlled ? openProp : internal;
104
+ const triggerRef = React.useRef<HTMLButtonElement | null>(null);
105
+ const headerRef = React.useRef<HTMLElement | null>(null);
106
+
107
+ const setRefs = React.useCallback((node: HTMLElement | null) => {
108
+ headerRef.current = node;
109
+ if (typeof ref === "function") ref(node);
110
+ else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;
111
+ }, [ref]);
112
+
113
+ const setOpen = React.useCallback((v: boolean) => {
114
+ if (!isControlled) setInternal(v);
115
+ onOpenChange?.(v);
116
+ }, [isControlled, onOpenChange]);
117
+
118
+ React.useEffect(() => {
119
+ if (!open) return;
120
+ const prev = document.body.style.overflow;
121
+ document.body.style.overflow = "hidden";
122
+ return () => { document.body.style.overflow = prev; };
123
+ }, [open]);
124
+
125
+ const [hidden, setHidden] = React.useState(false);
126
+ React.useEffect(() => {
127
+ if (!stickyHide) { setHidden(false); return; }
128
+ const target = findScrollParent(headerRef.current);
129
+ let lastY = getScrollY(target);
130
+ let ticking = false;
131
+ const onScroll = () => {
132
+ if (ticking) return;
133
+ ticking = true;
134
+ requestAnimationFrame(() => {
135
+ const y = getScrollY(target);
136
+ const delta = y - lastY;
137
+ if (y < stickyHideThreshold) setHidden(false);
138
+ else if (delta > 4) setHidden(true);
139
+ else if (delta < -4) setHidden(false);
140
+ lastY = y;
141
+ ticking = false;
142
+ });
143
+ };
144
+ target.addEventListener("scroll", onScroll, { passive: true });
145
+ return () => target.removeEventListener("scroll", onScroll);
146
+ }, [stickyHide, stickyHideThreshold]);
147
+
148
+ const ctx = React.useMemo<HeaderCtx>(() => ({ open, setOpen, triggerRef }), [open, setOpen]);
149
+
150
+ return (
151
+ <HeaderContext.Provider value={ctx}>
152
+ <header
153
+ ref={setRefs}
154
+ className={cx(
155
+ "relative flex items-center gap-[var(--space-4)] h-[var(--control-md)] px-[var(--space-3)] border-b border-border transition-[transform,background-color] duration-[var(--duration-base)] [--sh-ui-header-hover-bg:var(--background-muted)] [--sh-ui-header-blur-opacity:85%] [--sh-ui-header-blur-radius:16px] motion-reduce:transition-none max-md:gap-[var(--space-2)] data-[sticky-hide][data-hidden]:-translate-y-full",
156
+ variantClasses[variant],
157
+ className,
158
+ )}
159
+ data-drawer-open={open ? "" : undefined}
160
+ data-sticky-hide={stickyHide ? "" : undefined}
161
+ data-hidden={hidden ? "" : undefined}
162
+ {...props}
163
+ >
164
+ {children}
165
+ </header>
166
+ </HeaderContext.Provider>
167
+ );
168
+ });
169
+
170
+ export const HeaderBrand = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
171
+ function HeaderBrand({ className, ...props }, ref) {
172
+ return <div ref={ref} className={cx("inline-flex items-center gap-[var(--space-2)] shrink-0", className)} {...props} />;
173
+ },
174
+ );
175
+
176
+ export const HeaderLogo = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(
177
+ function HeaderLogo({ className, ...props }, ref) {
178
+ return <span ref={ref} className={cx("inline-flex items-center text-foreground", className)} {...props} />;
179
+ },
180
+ );
181
+
182
+ export const HeaderTitle = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(
183
+ function HeaderTitle({ className, ...props }, ref) {
184
+ return <span ref={ref} className={cx("text-[length:var(--text-base)] font-bold text-foreground tracking-[-0.3px]", className)} {...props} />;
185
+ },
186
+ );
187
+
188
+ export const HeaderTrigger = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
189
+ function HeaderTrigger({ className, onClick, children, ...props }, ref) {
190
+ const { open, setOpen, triggerRef } = useHeader();
191
+ const setRefs = React.useCallback((node: HTMLButtonElement | null) => {
192
+ triggerRef.current = node;
193
+ if (typeof ref === "function") ref(node);
194
+ else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;
195
+ }, [ref, triggerRef]);
196
+
197
+ return (
198
+ <button
199
+ ref={setRefs}
200
+ type="button"
201
+ className={cx(
202
+ "hidden items-center justify-center w-9 h-9 p-0 bg-transparent border-0 text-foreground rounded-[calc(var(--radius)-2px)] cursor-pointer transition-[background-color] duration-[var(--duration-fast)] hover:bg-[var(--sh-ui-header-hover-bg)] focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 max-md:inline-flex max-md:order-[-1]",
203
+ className,
204
+ )}
205
+ aria-label={open ? "메뉴 닫기" : "메뉴 열기"}
206
+ aria-expanded={open}
207
+ data-open={open ? "" : undefined}
208
+ onClick={(e) => { setOpen(!open); onClick?.(e); }}
209
+ {...props}
210
+ >
211
+ {children ?? (open ? <CloseIcon /> : <MenuIcon />)}
212
+ </button>
213
+ );
214
+ },
215
+ );
216
+
217
+ export interface HeaderNavProps extends React.HTMLAttributes<HTMLElement> {
218
+ value?: string;
219
+ defaultValue?: string;
220
+ onValueChange?: (value: string) => void;
221
+ match?: (itemHref: string, value: string) => boolean;
222
+ }
223
+
224
+ export const HeaderNav = React.forwardRef<HTMLElement, HeaderNavProps>(
225
+ function HeaderNav({ value, defaultValue, onValueChange, match, className, children, ...props }, ref) {
226
+ const { open, setOpen } = useHeader();
227
+ const drawerRef = React.useRef<HTMLElement | null>(null);
228
+ const close = React.useCallback(() => setOpen(false), [setOpen]);
229
+ useFocusTrap(drawerRef, open, close);
230
+
231
+ const isControlled = value !== undefined;
232
+ const [internalValue, setInternalValue] = React.useState(defaultValue);
233
+ const currentValue = isControlled ? value : internalValue;
234
+
235
+ const setValue = React.useCallback((next: string) => {
236
+ if (!isControlled) setInternalValue(next);
237
+ onValueChange?.(next);
238
+ }, [isControlled, onValueChange]);
239
+
240
+ const navMatch = React.useMemo<NavMatch>(() => ({ value: currentValue, match: match ?? defaultNavMatch, setValue }), [currentValue, match, setValue]);
241
+
242
+ return (
243
+ <NavMatchContext.Provider value={navMatch}>
244
+ <NavLocationContext.Provider value="inline">
245
+ <nav
246
+ ref={ref}
247
+ className={cx(
248
+ "flex items-center gap-[var(--space-1)] flex-1 min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden max-md:hidden",
249
+ className,
250
+ )}
251
+ {...props}
252
+ >
253
+ {children}
254
+ </nav>
255
+ </NavLocationContext.Provider>
256
+
257
+ <div
258
+ className="hidden max-md:block max-md:fixed max-md:inset-0 max-md:bg-black/25 max-md:[backdrop-filter:blur(8px)] max-md:z-[var(--z-overlay)] max-md:opacity-0 max-md:pointer-events-none max-md:transition-opacity max-md:duration-[var(--duration-base)] max-md:data-[open]:opacity-100 max-md:data-[open]:pointer-events-auto motion-reduce:max-md:transition-none"
259
+ data-open={open ? "" : undefined}
260
+ onClick={close}
261
+ aria-hidden
262
+ />
263
+ <aside
264
+ ref={drawerRef}
265
+ className="hidden max-md:flex max-md:fixed max-md:left-0 max-md:top-0 max-md:bottom-0 max-md:w-[min(17.5rem,85vw)] max-md:bg-background-subtle max-md:border-r max-md:border-border max-md:z-[var(--z-modal)] max-md:-translate-x-full max-md:transition-transform max-md:duration-[var(--duration-base)] max-md:flex-col max-md:overflow-y-auto max-md:data-[open]:translate-x-0 motion-reduce:max-md:transition-none"
266
+ data-open={open ? "" : undefined}
267
+ aria-hidden={!open}
268
+ role="dialog"
269
+ aria-modal="true"
270
+ aria-label="메뉴"
271
+ >
272
+ <div className="hidden max-md:flex max-md:items-center max-md:justify-end max-md:p-[var(--space-2)] max-md:border-b max-md:border-border">
273
+ <HeaderTrigger />
274
+ </div>
275
+ <NavLocationContext.Provider value="drawer">
276
+ <nav className="hidden max-md:flex max-md:flex-col max-md:p-[var(--space-2)] max-md:gap-px">{children}</nav>
277
+ </NavLocationContext.Provider>
278
+ </aside>
279
+ </NavMatchContext.Provider>
280
+ );
281
+ },
282
+ );
283
+
284
+ export const HeaderItem = React.forwardRef<
285
+ HTMLAnchorElement,
286
+ React.AnchorHTMLAttributes<HTMLAnchorElement> & { active?: boolean }
287
+ >(function HeaderItem({ className, active, onClick, href, ...props }, ref) {
288
+ const { setOpen } = useHeader();
289
+ const navMatch = React.useContext(NavMatchContext);
290
+ const computedActive = active !== undefined ? active : navMatch.value !== undefined && href !== undefined ? navMatch.match(href, navMatch.value) : false;
291
+
292
+ return (
293
+ <a
294
+ ref={ref}
295
+ href={href}
296
+ className={cx(
297
+ "inline-flex items-center gap-[var(--space-1)] py-[var(--space-2)] px-[var(--space-3)] text-[length:var(--text-sm)] font-medium text-foreground-muted no-underline bg-transparent border-0 rounded-[calc(var(--radius)-2px)] cursor-pointer whitespace-nowrap transition-[color,background-color] duration-[var(--duration-fast)] hover:text-foreground hover:bg-[var(--sh-ui-header-hover-bg)] data-[active]:text-foreground data-[active]:font-semibold focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 motion-reduce:transition-none max-md:py-[var(--space-3)] max-md:px-[var(--space-3)]",
298
+ className,
299
+ )}
300
+ data-active={computedActive ? "" : undefined}
301
+ aria-current={computedActive ? "page" : undefined}
302
+ onClick={(e) => {
303
+ setOpen(false);
304
+ if (href !== undefined) navMatch.setValue(href);
305
+ onClick?.(e);
306
+ }}
307
+ {...props}
308
+ />
309
+ );
310
+ });
311
+
312
+ export const HeaderActions = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
313
+ function HeaderActions({ className, ...props }, ref) {
314
+ return <div ref={ref} className={cx("inline-flex items-center gap-[var(--space-2)] ml-auto shrink-0", className)} {...props} />;
315
+ },
316
+ );
317
+
318
+ export const HeaderDesktopOnly = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
319
+ function HeaderDesktopOnly({ className, ...props }, ref) {
320
+ return <div ref={ref} className={cx("contents max-md:hidden", className)} {...props} />;
321
+ },
322
+ );
323
+
324
+ export const HeaderMobileOnly = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
325
+ function HeaderMobileOnly({ className, ...props }, ref) {
326
+ return <div ref={ref} className={cx("hidden max-md:contents", className)} {...props} />;
327
+ },
328
+ );
329
+
330
+ export interface HeaderNavGroupProps extends React.HTMLAttributes<HTMLDivElement> {
331
+ label?: React.ReactNode;
332
+ }
333
+
334
+ export const HeaderNavGroup = React.forwardRef<HTMLDivElement, HeaderNavGroupProps>(
335
+ function HeaderNavGroup({ className, label, children, ...props }, ref) {
336
+ const location = React.useContext(NavLocationContext);
337
+ if (location === "inline") {
338
+ return <div ref={ref} className={cx("contents", className)} {...props}>{children}</div>;
339
+ }
340
+ return (
341
+ <div
342
+ ref={ref}
343
+ className={cx("flex flex-col mt-[var(--space-3)] first:mt-0", className)}
344
+ role="group"
345
+ aria-label={typeof label === "string" ? label : undefined}
346
+ {...props}
347
+ >
348
+ {label != null && (
349
+ <div className="flex items-center h-8 px-[var(--space-2)] text-[length:var(--text-xs)] font-medium text-foreground-muted">
350
+ {label}
351
+ </div>
352
+ )}
353
+ <div className="flex flex-col gap-px">{children}</div>
354
+ </div>
355
+ );
356
+ },
357
+ );
358
+
359
+ type MenuCtx = {
360
+ open: boolean;
361
+ setOpen: (v: boolean) => void;
362
+ triggerId: string;
363
+ contentId: string;
364
+ location: NavLocation;
365
+ triggerRef: React.RefObject<HTMLButtonElement | null>;
366
+ contentRef: React.RefObject<HTMLDivElement | null>;
367
+ };
368
+ const MenuContext = React.createContext<MenuCtx | null>(null);
369
+ function useMenu() {
370
+ const ctx = React.useContext(MenuContext);
371
+ if (!ctx) throw new Error("HeaderMenu 하위 컴포넌트는 <HeaderMenu> 안에서만 사용할 수 있습니다.");
372
+ return ctx;
373
+ }
374
+
375
+ export function HeaderMenu({ children, className, defaultOpen = false }: { children: React.ReactNode; className?: string; defaultOpen?: boolean; }) {
376
+ const location = React.useContext(NavLocationContext);
377
+ const [open, setOpen] = React.useState(location === "drawer" ? defaultOpen : false);
378
+ const containerRef = React.useRef<HTMLDivElement>(null);
379
+ const triggerRef = React.useRef<HTMLButtonElement | null>(null);
380
+ const contentRef = React.useRef<HTMLDivElement | null>(null);
381
+ const triggerId = React.useId();
382
+ const contentId = React.useId();
383
+
384
+ React.useEffect(() => {
385
+ if (location !== "inline") return;
386
+ if (!open) return;
387
+ const onPointerDown = (e: PointerEvent) => {
388
+ const target = e.target as Node;
389
+ if (containerRef.current?.contains(target)) return;
390
+ if (contentRef.current?.contains(target)) return;
391
+ setOpen(false);
392
+ };
393
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
394
+ document.addEventListener("pointerdown", onPointerDown);
395
+ document.addEventListener("keydown", onKey);
396
+ return () => {
397
+ document.removeEventListener("pointerdown", onPointerDown);
398
+ document.removeEventListener("keydown", onKey);
399
+ };
400
+ }, [open, location]);
401
+
402
+ React.useEffect(() => { if (location === "inline") setOpen(false); }, [location]);
403
+
404
+ const ctx = React.useMemo<MenuCtx>(() => ({ open, setOpen, triggerId, contentId, location, triggerRef, contentRef }), [open, triggerId, contentId, location]);
405
+
406
+ return (
407
+ <MenuContext.Provider value={ctx}>
408
+ <div
409
+ ref={containerRef}
410
+ className={cx(
411
+ "relative",
412
+ location === "inline" ? "inline-block" : "flex flex-col",
413
+ className,
414
+ )}
415
+ data-open={open ? "" : undefined}
416
+ >
417
+ {children}
418
+ </div>
419
+ </MenuContext.Provider>
420
+ );
421
+ }
422
+
423
+ export const HeaderMenuTrigger = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
424
+ function HeaderMenuTrigger({ className, children, onClick, ...props }, ref) {
425
+ const { open, setOpen, triggerId, contentId, triggerRef, location } = useMenu();
426
+ const setRefs = React.useCallback((node: HTMLButtonElement | null) => {
427
+ triggerRef.current = node;
428
+ if (typeof ref === "function") ref(node);
429
+ else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;
430
+ }, [ref, triggerRef]);
431
+
432
+ return (
433
+ <button
434
+ ref={setRefs}
435
+ type="button"
436
+ id={triggerId}
437
+ aria-haspopup="menu"
438
+ aria-expanded={open}
439
+ aria-controls={contentId}
440
+ data-open={open ? "" : undefined}
441
+ className={cx(
442
+ "inline-flex items-center gap-[var(--space-1)] py-[var(--space-2)] px-[var(--space-3)] text-[length:var(--text-sm)] font-medium text-foreground-muted bg-transparent border-0 rounded-[calc(var(--radius)-2px)] cursor-pointer whitespace-nowrap transition-[color,background-color] duration-[var(--duration-fast)] hover:text-foreground hover:bg-[var(--sh-ui-header-hover-bg)] data-[open]:text-foreground data-[open]:bg-[var(--sh-ui-header-hover-bg)] focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 motion-reduce:transition-none",
443
+ location === "drawer" && "max-md:justify-between max-md:w-full max-md:py-[var(--space-3)] max-md:px-[var(--space-3)]",
444
+ className,
445
+ )}
446
+ onClick={(e) => { setOpen(!open); onClick?.(e); }}
447
+ {...props}
448
+ >
449
+ <span>{children}</span>
450
+ <ChevronDownIcon />
451
+ </button>
452
+ );
453
+ },
454
+ );
455
+
456
+ export const HeaderMenuContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
457
+ function HeaderMenuContent({ className, children, style, ...props }, ref) {
458
+ const { open, contentId, triggerId, location, triggerRef, contentRef } = useMenu();
459
+ const setRefs = React.useCallback((node: HTMLDivElement | null) => {
460
+ contentRef.current = node;
461
+ if (typeof ref === "function") ref(node);
462
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
463
+ }, [ref, contentRef]);
464
+
465
+ if (location === "drawer") {
466
+ return (
467
+ <div
468
+ ref={setRefs}
469
+ id={contentId}
470
+ role="menu"
471
+ aria-labelledby={triggerId}
472
+ data-open={open ? "" : undefined}
473
+ hidden={!open}
474
+ className={cx(
475
+ "max-md:flex max-md:flex-col max-md:py-[var(--space-1)] max-md:pl-[var(--space-4)] max-md:gap-px max-md:[&[hidden]]:hidden",
476
+ className,
477
+ )}
478
+ style={style}
479
+ {...props}
480
+ >
481
+ {children}
482
+ </div>
483
+ );
484
+ }
485
+
486
+ const [mounted, setMounted] = React.useState(false);
487
+ React.useEffect(() => setMounted(true), []);
488
+ const [pos, setPos] = React.useState<{ top: number; left: number; minWidth: number }>({ top: 0, left: 0, minWidth: 0 });
489
+
490
+ React.useLayoutEffect(() => {
491
+ if (!open) return;
492
+ const update = () => {
493
+ const trigger = triggerRef.current;
494
+ if (!trigger) return;
495
+ const rect = trigger.getBoundingClientRect();
496
+ setPos({ top: rect.bottom + window.scrollY + 4, left: rect.left + window.scrollX, minWidth: rect.width });
497
+ };
498
+ update();
499
+ window.addEventListener("scroll", update, true);
500
+ window.addEventListener("resize", update);
501
+ return () => {
502
+ window.removeEventListener("scroll", update, true);
503
+ window.removeEventListener("resize", update);
504
+ };
505
+ }, [open, triggerRef]);
506
+
507
+ if (!mounted || !open) return null;
508
+
509
+ return createPortal(
510
+ <div
511
+ ref={setRefs}
512
+ id={contentId}
513
+ role="menu"
514
+ aria-labelledby={triggerId}
515
+ data-open=""
516
+ className={cx(
517
+ "z-[var(--z-dropdown,50)] p-[var(--space-1)] bg-background border border-border rounded-[var(--radius)] shadow-[0_8px_24px_-8px_rgba(0,0,0,0.18)] flex flex-col gap-px text-foreground",
518
+ className,
519
+ )}
520
+ style={{ position: "absolute", top: pos.top, left: pos.left, minWidth: Math.max(pos.minWidth, 192), ...style }}
521
+ {...props}
522
+ >
523
+ {children}
524
+ </div>,
525
+ document.body,
526
+ );
527
+ },
528
+ );
529
+
530
+ function MenuIcon() {
531
+ return (
532
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
533
+ <path d="M3 6h18M3 12h18M3 18h18" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
534
+ </svg>
535
+ );
536
+ }
537
+ function CloseIcon() {
538
+ return (
539
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
540
+ <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
541
+ </svg>
542
+ );
543
+ }
544
+ function ChevronDownIcon() {
545
+ return (
546
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden className="transition-transform duration-[var(--duration-fast)] [[data-open]_&]:rotate-180">
547
+ <path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
548
+ </svg>
549
+ );
550
+ }
@@ -0,0 +1,78 @@
1
+ import * as React from "react";
2
+
3
+ export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
4
+ /**
5
+ * 필수 필드 표시. `true`면 LabelTitle 뒤에 `*` 표시.
6
+ * (Tailwind 변종은 plain 변종의 `:has()` 인접 셀렉터 자동 감지를 지원하지 않음 — 명시적으로 prop 사용.)
7
+ *
8
+ * @default false
9
+ */
10
+ isRequired?: boolean;
11
+ }
12
+
13
+ function cx(...args: (string | undefined | false)[]) {
14
+ return args.filter(Boolean).join(" ");
15
+ }
16
+
17
+ export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
18
+ ({ className, children, isRequired, ...props }, ref) => (
19
+ <label
20
+ ref={ref}
21
+ className={cx(
22
+ "flex flex-col gap-0.5 text-[length:var(--text-sm)] font-medium leading-snug text-foreground cursor-pointer select-none not-has-[[data-sh-ui-label-part]]:block",
23
+ // 필수 표시 — title 이 있으면 title 뒤, 없으면 label 뒤에 * 부착
24
+ isRequired &&
25
+ "has-[[data-sh-ui-label-part='title']]:[&>[data-sh-ui-label-part='title']]:after:content-['_*'] has-[[data-sh-ui-label-part='title']]:[&>[data-sh-ui-label-part='title']]:after:text-danger has-[[data-sh-ui-label-part='title']]:[&>[data-sh-ui-label-part='title']]:after:font-semibold not-has-[[data-sh-ui-label-part='title']]:after:content-['_*'] not-has-[[data-sh-ui-label-part='title']]:after:text-danger not-has-[[data-sh-ui-label-part='title']]:after:font-semibold",
26
+ className,
27
+ )}
28
+ data-required={isRequired || undefined}
29
+ {...props}
30
+ >
31
+ {children}
32
+ </label>
33
+ ),
34
+ );
35
+ Label.displayName = "Label";
36
+
37
+ export function LabelTitle({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
38
+ return (
39
+ <span
40
+ data-sh-ui-label-part="title"
41
+ className={cx("font-semibold text-[length:var(--text-sm)] text-foreground", className)}
42
+ {...props}
43
+ />
44
+ );
45
+ }
46
+
47
+ export function LabelSubtitle({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
48
+ return (
49
+ <span
50
+ data-sh-ui-label-part="subtitle"
51
+ className={cx("font-normal text-[0.8125rem] text-foreground", className)}
52
+ {...props}
53
+ />
54
+ );
55
+ }
56
+
57
+ export function LabelDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
58
+ return (
59
+ <p
60
+ data-sh-ui-label-part="description"
61
+ className={cx("m-0 font-normal text-[0.8125rem] leading-snug text-foreground-muted", className)}
62
+ {...props}
63
+ />
64
+ );
65
+ }
66
+
67
+ export function LabelCaption({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
68
+ return (
69
+ <p
70
+ data-sh-ui-label-part="caption"
71
+ className={cx(
72
+ "m-0 font-normal text-[length:var(--text-xs)] leading-tight text-[var(--foreground-subtle,var(--foreground-muted))] opacity-75",
73
+ className,
74
+ )}
75
+ {...props}
76
+ />
77
+ );
78
+ }