sh-ui-cli 0.52.1 → 0.52.3

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 (88) hide show
  1. package/data/changelog/versions.json +27 -0
  2. package/data/registry/react/components/_smoke/vanilla-extract.test.ts +33 -0
  3. package/data/registry/react/components/input/styles.css.ts +6 -6
  4. package/data/registry/react/registry.json +35 -852
  5. package/package.json +1 -1
  6. package/src/api.d.ts +3 -4
  7. package/src/constants.js +9 -5
  8. package/src/mcp.mjs +0 -1
  9. package/data/registry/react/components/accordion/index.vanilla-extract.tsx +0 -97
  10. package/data/registry/react/components/accordion/styles.css.ts +0 -131
  11. package/data/registry/react/components/avatar/index.vanilla-extract.tsx +0 -73
  12. package/data/registry/react/components/avatar/styles.css.ts +0 -68
  13. package/data/registry/react/components/badge/index.vanilla-extract.tsx +0 -40
  14. package/data/registry/react/components/badge/styles.css.ts +0 -71
  15. package/data/registry/react/components/breadcrumb/index.vanilla-extract.tsx +0 -152
  16. package/data/registry/react/components/breadcrumb/styles.css.ts +0 -95
  17. package/data/registry/react/components/calendar/index.vanilla-extract.tsx +0 -806
  18. package/data/registry/react/components/calendar/styles.css.ts +0 -250
  19. package/data/registry/react/components/carousel/index.vanilla-extract.tsx +0 -430
  20. package/data/registry/react/components/carousel/styles.css.ts +0 -169
  21. package/data/registry/react/components/checkbox/index.vanilla-extract.tsx +0 -96
  22. package/data/registry/react/components/checkbox/styles.css.ts +0 -74
  23. package/data/registry/react/components/code-editor/index.vanilla-extract.tsx +0 -230
  24. package/data/registry/react/components/code-editor/styles.css.ts +0 -97
  25. package/data/registry/react/components/code-panel/index.vanilla-extract.tsx +0 -191
  26. package/data/registry/react/components/code-panel/styles.css.ts +0 -151
  27. package/data/registry/react/components/color-picker/index.vanilla-extract.tsx +0 -467
  28. package/data/registry/react/components/color-picker/styles.css.ts +0 -169
  29. package/data/registry/react/components/combobox/index.vanilla-extract.tsx +0 -165
  30. package/data/registry/react/components/combobox/styles.css.ts +0 -174
  31. package/data/registry/react/components/context-menu/index.vanilla-extract.tsx +0 -251
  32. package/data/registry/react/components/context-menu/styles.css.ts +0 -167
  33. package/data/registry/react/components/date-picker/index.vanilla-extract.tsx +0 -520
  34. package/data/registry/react/components/date-picker/styles.css.ts +0 -111
  35. package/data/registry/react/components/dialog/index.vanilla-extract.tsx +0 -95
  36. package/data/registry/react/components/dialog/styles.css.ts +0 -140
  37. package/data/registry/react/components/dropdown-menu/index.vanilla-extract.tsx +0 -255
  38. package/data/registry/react/components/dropdown-menu/styles.css.ts +0 -175
  39. package/data/registry/react/components/file-upload/index.vanilla-extract.tsx +0 -487
  40. package/data/registry/react/components/file-upload/styles.css.ts +0 -193
  41. package/data/registry/react/components/form/index.vanilla-extract.tsx +0 -61
  42. package/data/registry/react/components/form/styles.css.ts +0 -56
  43. package/data/registry/react/components/header/index.vanilla-extract.tsx +0 -805
  44. package/data/registry/react/components/header/styles.css.ts +0 -413
  45. package/data/registry/react/components/label/index.vanilla-extract.tsx +0 -52
  46. package/data/registry/react/components/label/styles.css.ts +0 -141
  47. package/data/registry/react/components/markdown-editor/index.vanilla-extract.tsx +0 -119
  48. package/data/registry/react/components/markdown-editor/styles.css.ts +0 -231
  49. package/data/registry/react/components/menubar/index.vanilla-extract.tsx +0 -32
  50. package/data/registry/react/components/menubar/styles.css.ts +0 -53
  51. package/data/registry/react/components/numeric-input/index.vanilla-extract.tsx +0 -148
  52. package/data/registry/react/components/numeric-input/styles.css.ts +0 -65
  53. package/data/registry/react/components/page-toc/index.vanilla-extract.tsx +0 -174
  54. package/data/registry/react/components/page-toc/styles.css.ts +0 -97
  55. package/data/registry/react/components/pagination/index.vanilla-extract.tsx +0 -269
  56. package/data/registry/react/components/pagination/styles.css.ts +0 -113
  57. package/data/registry/react/components/popover/index.vanilla-extract.tsx +0 -113
  58. package/data/registry/react/components/popover/styles.css.ts +0 -78
  59. package/data/registry/react/components/progress/index.vanilla-extract.tsx +0 -54
  60. package/data/registry/react/components/progress/styles.css.ts +0 -53
  61. package/data/registry/react/components/radio/index.vanilla-extract.tsx +0 -65
  62. package/data/registry/react/components/radio/styles.css.ts +0 -79
  63. package/data/registry/react/components/rich-text-editor/index.vanilla-extract.tsx +0 -348
  64. package/data/registry/react/components/rich-text-editor/styles.css.ts +0 -243
  65. package/data/registry/react/components/select/index.vanilla-extract.tsx +0 -234
  66. package/data/registry/react/components/select/styles.css.ts +0 -225
  67. package/data/registry/react/components/separator/index.vanilla-extract.tsx +0 -46
  68. package/data/registry/react/components/separator/styles.css.ts +0 -24
  69. package/data/registry/react/components/sidebar/index.vanilla-extract.tsx +0 -1067
  70. package/data/registry/react/components/sidebar/styles.css.ts +0 -578
  71. package/data/registry/react/components/skeleton/index.vanilla-extract.tsx +0 -22
  72. package/data/registry/react/components/skeleton/styles.css.ts +0 -30
  73. package/data/registry/react/components/slider/index.vanilla-extract.tsx +0 -298
  74. package/data/registry/react/components/slider/styles.css.ts +0 -75
  75. package/data/registry/react/components/spinner/index.vanilla-extract.tsx +0 -38
  76. package/data/registry/react/components/spinner/styles.css.ts +0 -60
  77. package/data/registry/react/components/switch/index.vanilla-extract.tsx +0 -39
  78. package/data/registry/react/components/switch/styles.css.ts +0 -87
  79. package/data/registry/react/components/tabs/index.vanilla-extract.tsx +0 -91
  80. package/data/registry/react/components/tabs/styles.css.ts +0 -145
  81. package/data/registry/react/components/textarea/index.vanilla-extract.tsx +0 -23
  82. package/data/registry/react/components/textarea/styles.css.ts +0 -55
  83. package/data/registry/react/components/toast/index.vanilla-extract.tsx +0 -258
  84. package/data/registry/react/components/toast/styles.css.ts +0 -307
  85. package/data/registry/react/components/toggle/index.vanilla-extract.tsx +0 -131
  86. package/data/registry/react/components/toggle/styles.css.ts +0 -109
  87. package/data/registry/react/components/tooltip/index.vanilla-extract.tsx +0 -83
  88. package/data/registry/react/components/tooltip/styles.css.ts +0 -59
@@ -1,1067 +0,0 @@
1
- "use client";
2
-
3
- import * as React from "react";
4
- import { cn } from "@SH_UI_UTILS@";
5
- import { ChevronRightIcon, PanelLeftIcon } from "lucide-react";
6
- import { Popover, PopoverContent, PopoverTrigger } from "../popover";
7
- import { byKey, sidebarWrapper, sidebar, sidebar__inner, sidebarStatic, sidebarMobile, sidebar__backdrop, sidebar__trigger, sidebar__panel, sidebarPanelHeader, sidebarPanelContent, sidebarPanelClose, sidebarInset, sidebar__header, sidebar__footer, sidebar__content, sidebar__separator, sidebar__group, sidebarGroupLabel, sidebarGroupContent, sidebar__menu, sidebarMenuItem, sidebarMenuButton, sidebarMenuButtonSm, sidebarMenuButtonLg, sidebarMenuSub, sidebarMenuSubItem, sidebarMenuSubButton, sidebarMenuSubButtonSm, sidebar__chevron, sidebarCollapsibleTrigger, sidebarCollapsibleContent, sidebarCollapsibleFlyout } from "./styles.css";
8
-
9
- const SIDEBAR_COOKIE_NAME = "sidebar_state";
10
- const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
11
- const SIDEBAR_KEYBOARD_SHORTCUT = "b";
12
- const MOBILE_BREAKPOINT = 768;
13
-
14
- /* ───────────── 포커스 트랩 + Esc 닫기 훅 ─────────────
15
- * `active`가 true일 때:
16
- * - 컨테이너 내부로 초기 포커스 이동 (첫 tabbable 요소)
17
- * - Tab/Shift+Tab 순환을 컨테이너 안에서 가둠
18
- * - Esc 키로 onClose 호출
19
- * - 닫힐 때 열기 전 포커스 요소로 복귀 */
20
- const FOCUSABLE_SELECTOR =
21
- 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
22
-
23
- function useFocusTrap(
24
- containerRef: React.RefObject<HTMLElement | null>,
25
- active: boolean,
26
- onClose: () => void,
27
- ) {
28
- React.useEffect(() => {
29
- if (!active) return;
30
- const container = containerRef.current;
31
- if (!container) return;
32
-
33
- const previouslyFocused = (document.activeElement as HTMLElement) ?? null;
34
- const focusables = () =>
35
- Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
36
- .filter((el) => el.offsetParent !== null || el === document.activeElement);
37
-
38
- // 초기 포커스 — 첫 tabbable 또는 컨테이너 자체
39
- const first = focusables()[0];
40
- if (first) first.focus();
41
- else {
42
- container.setAttribute("tabindex", "-1");
43
- container.focus();
44
- }
45
-
46
- const onKeyDown = (e: KeyboardEvent) => {
47
- if (e.key === "Escape") {
48
- e.preventDefault();
49
- onClose();
50
- return;
51
- }
52
- if (e.key !== "Tab") return;
53
- const items = focusables();
54
- if (items.length === 0) {
55
- e.preventDefault();
56
- return;
57
- }
58
- const firstEl = items[0];
59
- const lastEl = items[items.length - 1];
60
- if (e.shiftKey && document.activeElement === firstEl) {
61
- e.preventDefault();
62
- lastEl.focus();
63
- } else if (!e.shiftKey && document.activeElement === lastEl) {
64
- e.preventDefault();
65
- firstEl.focus();
66
- }
67
- };
68
-
69
- container.addEventListener("keydown", onKeyDown);
70
- return () => {
71
- container.removeEventListener("keydown", onKeyDown);
72
- // 이전에 포커스 되어 있던 요소로 복귀
73
- if (previouslyFocused && typeof previouslyFocused.focus === "function") {
74
- previouslyFocused.focus();
75
- }
76
- };
77
- }, [active, containerRef, onClose]);
78
- }
79
-
80
- /* ───────────── useIsMobile ───────────── */
81
-
82
- function useIsMobile() {
83
- const [isMobile, setIsMobile] = React.useState(false);
84
-
85
- React.useEffect(() => {
86
- const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
87
- const onChange = () => setIsMobile(mql.matches);
88
- onChange();
89
- mql.addEventListener("change", onChange);
90
- return () => mql.removeEventListener("change", onChange);
91
- }, []);
92
-
93
- return isMobile;
94
- }
95
-
96
- /* ───────────── Context ───────────── */
97
-
98
- type SidebarContextValue = {
99
- state: "expanded" | "collapsed";
100
- open: boolean;
101
- setOpen: (open: boolean) => void;
102
- openMobile: boolean;
103
- setOpenMobile: (open: boolean) => void;
104
- isMobile: boolean;
105
- toggleSidebar: () => void;
106
- /** 현재 열린 보조 패널 id. 없으면 null. */
107
- activePanel: string | null;
108
- /** 보조 패널 전환. 같은 id를 다시 주면 닫힌다. */
109
- setActivePanel: (id: string | null) => void;
110
- };
111
-
112
- const SidebarContext = React.createContext<SidebarContextValue | null>(null);
113
-
114
- /** Sidebar의 open/state·toggle·activePanel 등을 읽고 쓰기 위한 훅. SidebarProvider 내부에서만 호출 가능. */
115
- export function useSidebar() {
116
- const ctx = React.useContext(SidebarContext);
117
- if (!ctx) throw new Error("useSidebar must be used within a SidebarProvider.");
118
- return ctx;
119
- }
120
-
121
- /* ───────────── Provider ───────────── */
122
-
123
- export interface SidebarProviderProps extends React.HTMLAttributes<HTMLDivElement> {
124
- /**
125
- * 초기 열림 상태 (비제어 모드). 쿠키 기반 영속화와 함께 쓰려면 서버 컴포넌트에서
126
- * `cookies().get("sidebar_state")`를 읽어 주입해야 hydration 레이아웃 시프트가 없다.
127
- *
128
- * @default true
129
- * @example
130
- * // Next.js App Router
131
- * const s = (await cookies()).get("sidebar_state")?.value;
132
- * <SidebarProvider defaultOpen={s !== "false"}>...</SidebarProvider>
133
- */
134
- defaultOpen?: boolean;
135
- /** 열림 상태 (제어 모드). 지정 시 내부 state 대신 이 값이 우선. */
136
- open?: boolean;
137
- /** 열림 변경 콜백. 제어 모드에서는 이 안에서 외부 상태를 업데이트해야 한다. */
138
- onOpenChange?: (open: boolean) => void;
139
- /**
140
- * 부모 컨테이너 안에 임베드. `100svh` 대신 부모 크기를 따른다. 문서 데모·iframe 등에 사용.
141
- *
142
- * @default false
143
- */
144
- embedded?: boolean;
145
- }
146
-
147
- /**
148
- * Sidebar 영역 전체를 감싸는 Provider. open/closed 상태 관리, 모바일 감지, ⌘/Ctrl+B 단축키,
149
- * 쿠키 영속화, 보조 패널 상태를 담당한다. 반드시 Sidebar 사용 영역 바깥에 한 번 두어야 한다.
150
- */
151
- export function SidebarProvider({
152
- defaultOpen = true,
153
- open: openProp,
154
- onOpenChange: setOpenProp,
155
- className,
156
- style,
157
- children,
158
- embedded,
159
- ...props
160
- }: SidebarProviderProps) {
161
- const isMobile = useIsMobile();
162
- const [openMobile, setOpenMobile] = React.useState(false);
163
-
164
- const [_open, _setOpen] = React.useState(defaultOpen);
165
- const open = openProp ?? _open;
166
- const setOpen = React.useCallback(
167
- (value: boolean | ((prev: boolean) => boolean)) => {
168
- const next = typeof value === "function" ? value(open) : value;
169
- if (setOpenProp) setOpenProp(next);
170
- else _setOpen(next);
171
- if (typeof document !== "undefined") {
172
- document.cookie = `${SIDEBAR_COOKIE_NAME}=${next}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
173
- }
174
- },
175
- [open, setOpenProp]
176
- );
177
-
178
- const toggleSidebar = React.useCallback(() => {
179
- if (isMobile) setOpenMobile((v) => !v);
180
- else setOpen((v) => !v);
181
- }, [isMobile, setOpen]);
182
-
183
- const [activePanel, _setActivePanel] = React.useState<string | null>(null);
184
- const setActivePanel = React.useCallback((id: string | null) => {
185
- _setActivePanel((prev) => (prev === id ? null : id));
186
- }, []);
187
-
188
- React.useEffect(() => {
189
- const handler = (e: KeyboardEvent) => {
190
- if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
191
- e.preventDefault();
192
- toggleSidebar();
193
- }
194
- };
195
- window.addEventListener("keydown", handler);
196
- return () => window.removeEventListener("keydown", handler);
197
- }, [toggleSidebar]);
198
-
199
- const state: "expanded" | "collapsed" = open ? "expanded" : "collapsed";
200
-
201
- const value = React.useMemo<SidebarContextValue>(
202
- () => ({
203
- state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar,
204
- activePanel, setActivePanel,
205
- }),
206
- [state, open, setOpen, isMobile, openMobile, toggleSidebar, activePanel, setActivePanel]
207
- );
208
-
209
- const classes = cn(sidebarWrapper, className);
210
-
211
- return (
212
- <SidebarContext.Provider value={value}>
213
- <div
214
- className={classes}
215
- style={style}
216
- data-embedded={embedded || undefined}
217
- data-panel-open={activePanel ? "true" : undefined}
218
- {...props}
219
- >
220
- {children}
221
- </div>
222
- </SidebarContext.Provider>
223
- );
224
- }
225
-
226
- /* ───────────── Sidebar ─────────────
227
- * Sidebar의 collapsible/variant/side를 자손 컴포넌트(Collapsible 등)에 전파하기 위한 컨텍스트. */
228
-
229
- type SidebarRenderCtx = {
230
- collapsible: "offcanvas" | "icon" | "none";
231
- variant: "sidebar" | "floating" | "inset";
232
- side: "left" | "right";
233
- };
234
- const SidebarRenderContext = React.createContext<SidebarRenderCtx>({
235
- collapsible: "offcanvas",
236
- variant: "sidebar",
237
- side: "left",
238
- });
239
- /** 부모 Sidebar의 collapsible/variant/side를 자식에서 읽는 훅. Collapsible 등 내부에서 사용. */
240
- export const useSidebarRender = () => React.useContext(SidebarRenderContext);
241
-
242
- export interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
243
- /**
244
- * 좌/우 배치.
245
- * @default "left"
246
- */
247
- side?: "left" | "right";
248
- /**
249
- * 외형 변형.
250
- * - `sidebar` — 가장자리에 붙는 기본 사이드바 (기본)
251
- * - `floating` — 카드처럼 띄워 여백·radius 적용
252
- * - `inset` — 사이드바는 가장자리에 붙고 메인 콘텐츠(`SidebarInset`)가 둥근 카드
253
- *
254
- * @default "sidebar"
255
- */
256
- variant?: "sidebar" | "floating" | "inset";
257
- /**
258
- * 접힘(collapsed) 동작.
259
- * - `offcanvas` — 사이드바가 화면 밖으로 슬라이드 아웃 (기본)
260
- * - `icon` — 아이콘만 보이는 좁은 폭으로 축소. hover 시 메뉴 flyout
261
- * - `none` — 접기 비활성. 항상 펼친 상태
262
- *
263
- * @default "offcanvas"
264
- */
265
- collapsible?: "offcanvas" | "icon" | "none";
266
- }
267
-
268
- /**
269
- * 좌/우 사이드바 컨테이너. `collapsible`로 접힘 동작(offcanvas/icon/none),
270
- * `variant`로 외형(sidebar/floating/inset), `side`로 좌우 배치를 결정한다. 모바일에서는
271
- * 자동으로 drawer로 전환되며 포커스 트랩·Esc 닫힘이 활성화된다.
272
- */
273
- export function Sidebar({
274
- side = "left",
275
- variant = "sidebar",
276
- collapsible = "offcanvas",
277
- className,
278
- children,
279
- ...props
280
- }: SidebarProps) {
281
- const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
282
- const renderCtx = React.useMemo(
283
- () => ({ collapsible, variant, side }),
284
- [collapsible, variant, side],
285
- );
286
-
287
- const wrap = (node: React.ReactNode) => (
288
- <SidebarRenderContext.Provider value={renderCtx}>{node}</SidebarRenderContext.Provider>
289
- );
290
-
291
- if (collapsible === "none") {
292
- const classes = cn(sidebar, sidebarStatic, className);
293
- return wrap(
294
- <aside className={classes} data-side={side} data-variant={variant} {...props}>
295
- {children}
296
- </aside>
297
- );
298
- }
299
-
300
- if (isMobile) {
301
- return wrap(
302
- <MobileSidebar
303
- side={side}
304
- className={className}
305
- openMobile={openMobile}
306
- setOpenMobile={setOpenMobile}
307
- {...props}
308
- >
309
- {children}
310
- </MobileSidebar>
311
- );
312
- }
313
-
314
- return wrap(
315
- <aside
316
- className={cn(sidebar, className)}
317
- data-state={state}
318
- data-collapsible={state === "collapsed" ? collapsible : ""}
319
- data-variant={variant}
320
- data-side={side}
321
- {...props}
322
- >
323
- <div className={sidebar__inner}>{children}</div>
324
- </aside>
325
- );
326
- }
327
-
328
- /* ───────────── MobileSidebar (내부 전용) ─────────────
329
- * 모바일 드로어 전용 래퍼. 포커스 트랩 + Esc 닫기 내장. */
330
- function MobileSidebar({
331
- side,
332
- className,
333
- openMobile,
334
- setOpenMobile,
335
- children,
336
- ...props
337
- }: {
338
- side: "left" | "right";
339
- className?: string;
340
- openMobile: boolean;
341
- setOpenMobile: (open: boolean) => void;
342
- children: React.ReactNode;
343
- } & React.HTMLAttributes<HTMLElement>) {
344
- const asideRef = React.useRef<HTMLElement>(null);
345
- const close = React.useCallback(() => setOpenMobile(false), [setOpenMobile]);
346
- useFocusTrap(asideRef, openMobile, close);
347
-
348
- return (
349
- <>
350
- {openMobile && (
351
- <div className={sidebar__backdrop} onClick={close} aria-hidden />
352
- )}
353
- <aside
354
- ref={asideRef}
355
- className={cn(sidebar, sidebarMobile, className)}
356
- data-side={side}
357
- data-state={openMobile ? "open" : "closed"}
358
- role="dialog"
359
- aria-modal={openMobile ? "true" : undefined}
360
- aria-hidden={!openMobile || undefined}
361
- {...props}
362
- >
363
- {children}
364
- </aside>
365
- </>
366
- );
367
- }
368
-
369
- /* ───────────── Trigger ───────────── */
370
-
371
- export interface SidebarTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
372
-
373
- /** Sidebar 토글 버튼. 데스크탑에서는 expand/collapse, 모바일에서는 drawer open/close. */
374
- export function SidebarTrigger({ className, onClick, ...props }: SidebarTriggerProps) {
375
- const { toggleSidebar } = useSidebar();
376
- return (
377
- <button
378
- type="button"
379
- aria-label="Toggle Sidebar"
380
- className={cn(sidebar__trigger, className)}
381
- onClick={(e) => {
382
- onClick?.(e);
383
- toggleSidebar();
384
- }}
385
- {...props}
386
- >
387
- <PanelLeftIcon size={16} aria-hidden />
388
- </button>
389
- );
390
- }
391
-
392
- /* ───────────── Panel (보조 확장 패널) ─────────────
393
- * SidebarMenuButton의 panelId로 열고 닫는 보조 패널.
394
- * 사이드바와 Inset 사이에 위치해 데스크탑에서는 Inset을 밀어내고,
395
- * 모바일에서는 사이드바 드로어 위에 오버레이된다.
396
- */
397
-
398
- export interface SidebarPanelProps extends React.HTMLAttributes<HTMLDivElement> {
399
- /**
400
- * `SidebarMenuButton`의 `panelId`와 매칭되는 식별자.
401
- * 이 id를 가진 버튼이 클릭되면 패널이 열린다.
402
- */
403
- id: string;
404
- }
405
-
406
- /**
407
- * SidebarMenuButton의 `panelId`로 열고 닫는 보조 패널. 데스크탑에서는 인라인 영역,
408
- * 모바일에서는 dialog 오버레이로 전환되며 포커스 트랩과 Esc 닫힘이 자동 적용된다.
409
- */
410
- export function SidebarPanel({ id, className, children, ...props }: SidebarPanelProps) {
411
- const { activePanel, setActivePanel, isMobile } = useSidebar();
412
- const open = activePanel === id;
413
- const ref = React.useRef<HTMLElement>(null);
414
- const close = React.useCallback(() => setActivePanel(null), [setActivePanel]);
415
- // 모바일에선 오버레이 형태로 뜨므로 dialog 취급 (포커스 트랩 + Esc).
416
- // 데스크탑에선 인라인 영역이므로 Esc만 걸고 트랩은 생략.
417
- useFocusTrap(ref, open && isMobile, close);
418
-
419
- React.useEffect(() => {
420
- if (!open || isMobile) return;
421
- const onKey = (e: KeyboardEvent) => {
422
- if (e.key === "Escape") close();
423
- };
424
- window.addEventListener("keydown", onKey);
425
- return () => window.removeEventListener("keydown", onKey);
426
- }, [open, isMobile, close]);
427
-
428
- return (
429
- <aside
430
- ref={ref}
431
- className={cn(sidebar__panel, className)}
432
- data-state={open ? "open" : "closed"}
433
- role={isMobile ? "dialog" : undefined}
434
- aria-modal={open && isMobile ? "true" : undefined}
435
- hidden={!open}
436
- {...props}
437
- >
438
- {children}
439
- <button
440
- type="button"
441
- aria-label="패널 닫기"
442
- className={sidebarPanelClose}
443
- onClick={close}
444
- >
445
- ×
446
- </button>
447
- </aside>
448
- );
449
- }
450
-
451
- /** SidebarPanel 상단 헤더 슬롯. */
452
- export function SidebarPanelHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
453
- return (
454
- <div
455
- className={cn(sidebarPanelHeader, className)}
456
- {...props}
457
- />
458
- );
459
- }
460
-
461
- /** SidebarPanel의 본문 영역. */
462
- export function SidebarPanelContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
463
- return (
464
- <div
465
- className={cn(sidebarPanelContent, className)}
466
- {...props}
467
- />
468
- );
469
- }
470
-
471
- /* ───────────── Inset (main content area, paired with variant=inset) ───────────── */
472
-
473
- /** Sidebar 옆 메인 컨텐츠 영역(`<main>`). variant="inset"과 짝을 이뤄 사용. */
474
- export function SidebarInset({ className, ...props }: React.HTMLAttributes<HTMLElement>) {
475
- return (
476
- <main
477
- className={cn(sidebarInset, className)}
478
- {...props}
479
- />
480
- );
481
- }
482
-
483
- /* ───────────── Header / Footer / Content / Separator ───────────── */
484
-
485
- /** Sidebar 상단 영역. 보통 로고/검색을 둔다. */
486
- export function SidebarHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
487
- return (
488
- <div
489
- className={cn(sidebar__header, className)}
490
- {...props}
491
- />
492
- );
493
- }
494
-
495
- /** Sidebar 하단 영역. 사용자 정보·테마 토글 등을 둔다. */
496
- export function SidebarFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
497
- return (
498
- <div
499
- className={cn(sidebar__footer, className)}
500
- {...props}
501
- />
502
- );
503
- }
504
-
505
- /** Sidebar의 스크롤 영역. 메뉴/그룹 목록을 둔다. */
506
- export function SidebarContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
507
- return (
508
- <div
509
- className={cn(sidebar__content, className)}
510
- {...props}
511
- />
512
- );
513
- }
514
-
515
- /** Sidebar 영역 사이의 시각적 구분선(`<hr>`). */
516
- export function SidebarSeparator({ className, ...props }: React.HTMLAttributes<HTMLHRElement>) {
517
- return (
518
- <hr
519
- className={cn(sidebar__separator, className)}
520
- {...props}
521
- />
522
- );
523
- }
524
-
525
- /* ───────────── Group ───────────── */
526
-
527
- /** 의미적으로 묶이는 메뉴 그룹. SidebarGroupLabel + SidebarGroupContent와 함께 사용. */
528
- export function SidebarGroup({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
529
- return (
530
- <div
531
- className={cn(sidebar__group, className)}
532
- {...props}
533
- />
534
- );
535
- }
536
-
537
- /** 그룹의 카테고리 라벨. */
538
- export function SidebarGroupLabel({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
539
- return (
540
- <div
541
- className={cn(sidebarGroupLabel, className)}
542
- {...props}
543
- />
544
- );
545
- }
546
-
547
- /** 그룹 내부의 항목 컨테이너. */
548
- export function SidebarGroupContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
549
- return (
550
- <div
551
- className={cn(sidebarGroupContent, className)}
552
- {...props}
553
- />
554
- );
555
- }
556
-
557
- /* ───────────── Menu ───────────── */
558
-
559
- /** 메뉴 리스트(`<ul>`). SidebarMenuItem을 자식으로 갖는다. */
560
- export function SidebarMenu({ className, ...props }: React.HTMLAttributes<HTMLUListElement>) {
561
- return (
562
- <ul
563
- className={cn(sidebar__menu, className)}
564
- {...props}
565
- />
566
- );
567
- }
568
-
569
- /** 메뉴 항목(`<li>`). SidebarMenuButton과 (선택) SidebarMenuSub를 자식으로 둔다. */
570
- export function SidebarMenuItem({ className, ...props }: React.HTMLAttributes<HTMLLIElement>) {
571
- return (
572
- <li
573
- className={cn(sidebarMenuItem, className)}
574
- {...props}
575
- />
576
- );
577
- }
578
-
579
- export interface SidebarMenuButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
580
- /**
581
- * 활성 상태(현재 페이지/섹션). 명시 안 해도 `sectionId`/`panelId` 매칭으로 자동 추론된다.
582
- */
583
- isActive?: boolean;
584
- /**
585
- * 크기.
586
- * - `sm` — 컴팩트 메뉴
587
- * - `md` — 일반 (기본)
588
- * - `lg` — 강조 메뉴
589
- *
590
- * @default "md"
591
- */
592
- size?: "sm" | "md" | "lg";
593
- /**
594
- * Radix asChild 패턴. children에 `<a>` 등 다른 요소를 넘겨 button 스타일만 입힐 때 사용.
595
- * Next.js Link와 결합 시 유용 (`<SidebarMenuButton asChild><Link href=...>`).
596
- *
597
- * @default false
598
- */
599
- asChild?: boolean;
600
- /**
601
- * `SidebarTOC` 안에서 활성 섹션 id를 자동 동기화. 이 값과 TOC active id가 일치하면
602
- * `isActive`가 자동으로 `true`가 된다.
603
- */
604
- sectionId?: string;
605
- /**
606
- * 보조 패널 트리거. 지정 시 클릭으로 같은 id의 `SidebarPanel`을 토글하고,
607
- * `activePanel === panelId`일 때 `isActive`가 자동으로 `true`가 된다.
608
- */
609
- panelId?: string;
610
- }
611
-
612
- /**
613
- * 메뉴 한 줄을 누를 수 있는 버튼. `asChild`로 `<a>` 등 다른 요소에 스타일만 입힐 수 있고,
614
- * `sectionId`(SidebarTOC 활성 동기화) / `panelId`(SidebarPanel 토글)를 지정해 활성 상태를 자동 결정한다.
615
- */
616
- export const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenuButtonProps>(
617
- function SidebarMenuButton(
618
- { className, isActive, size = "md", asChild, sectionId, panelId, onClick, children, ...props },
619
- ref
620
- ) {
621
- const tocActive = useTOCActiveId();
622
- const ctx = React.useContext(SidebarContext);
623
- const panelActive = panelId != null && ctx?.activePanel === panelId;
624
- const resolvedIsActive =
625
- isActive ??
626
- (panelId != null ? panelActive : undefined) ??
627
- (sectionId != null ? tocActive === sectionId : undefined);
628
-
629
- const handleClick = React.useCallback(
630
- (e: React.MouseEvent<HTMLButtonElement>) => {
631
- onClick?.(e);
632
- if (!e.defaultPrevented && panelId != null && ctx) {
633
- ctx.setActivePanel(panelId);
634
- }
635
- },
636
- [onClick, panelId, ctx]
637
- );
638
-
639
- const cls = cn(sidebarMenuButton,
640
- byKey[`sidebar__menu-button--${size}`],
641
- className);
642
-
643
- if (asChild && React.isValidElement(children)) {
644
- const child = children as React.ReactElement<Record<string, unknown>>;
645
- const merged: Record<string, unknown> = {
646
- ...props,
647
- onClick: handleClick,
648
- className: cn((child.props.className as string) || "", cls),
649
- "data-active": resolvedIsActive || undefined,
650
- };
651
- return React.cloneElement(child, merged);
652
- }
653
-
654
- return (
655
- <button
656
- ref={ref}
657
- type="button"
658
- className={cls}
659
- data-active={resolvedIsActive || undefined}
660
- onClick={handleClick}
661
- {...props}
662
- >
663
- {children}
664
- </button>
665
- );
666
- }
667
- );
668
-
669
- /* ───────────── Sub menu ───────────── */
670
-
671
- /** 메뉴 항목 내부의 서브 메뉴 리스트. SidebarMenuItem 안에 둔다. */
672
- export function SidebarMenuSub({ className, ...props }: React.HTMLAttributes<HTMLUListElement>) {
673
- return (
674
- <ul
675
- className={cn(sidebarMenuSub, className)}
676
- {...props}
677
- />
678
- );
679
- }
680
-
681
- /** 서브 메뉴 항목(`<li>`). */
682
- export function SidebarMenuSubItem({ className, ...props }: React.HTMLAttributes<HTMLLIElement>) {
683
- return (
684
- <li
685
- className={cn(sidebarMenuSubItem, className)}
686
- {...props}
687
- />
688
- );
689
- }
690
-
691
- export interface SidebarMenuSubButtonProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
692
- /** 활성 상태. 명시 안 해도 `sectionId` 매칭으로 자동 추론된다. */
693
- isActive?: boolean;
694
- /**
695
- * 크기.
696
- * @default "md"
697
- */
698
- size?: "sm" | "md";
699
- /**
700
- * Radix asChild 패턴. children에 다른 anchor 컴포넌트(예: Next.js Link)를 넘길 때 사용.
701
- * @default false
702
- */
703
- asChild?: boolean;
704
- /** `SidebarTOC`의 활성 섹션 id 자동 동기화. 일치하면 `isActive`가 자동으로 `true`. */
705
- sectionId?: string;
706
- }
707
-
708
- /** 서브 메뉴 항목 내부의 링크(`<a>`). `sectionId`로 SidebarTOC 활성 상태와 연동. */
709
- export const SidebarMenuSubButton = React.forwardRef<HTMLAnchorElement, SidebarMenuSubButtonProps>(
710
- function SidebarMenuSubButton(
711
- { className, isActive, size = "md", asChild, sectionId, children, ...props },
712
- ref
713
- ) {
714
- const tocActive = useTOCActiveId();
715
- const resolvedIsActive =
716
- isActive ?? (sectionId != null ? tocActive === sectionId : undefined);
717
- const cls = cn(sidebarMenuSubButton,
718
- byKey[`sidebar__menu-sub-button--${size}`],
719
- className);
720
-
721
- if (asChild && React.isValidElement(children)) {
722
- const child = children as React.ReactElement<Record<string, unknown>>;
723
- const merged: Record<string, unknown> = {
724
- ...props,
725
- className: cn((child.props.className as string) || "", cls),
726
- "data-active": resolvedIsActive || undefined,
727
- };
728
- return React.cloneElement(child, merged);
729
- }
730
-
731
- return (
732
- <a
733
- ref={ref}
734
- className={cls}
735
- data-active={resolvedIsActive || undefined}
736
- {...props}
737
- >
738
- {children}
739
- </a>
740
- );
741
- }
742
- );
743
-
744
- /* ───────────── Collapsible (펼침/접힘 메뉴) ───────────── */
745
-
746
- type CollapsibleContextValue = {
747
- open: boolean;
748
- toggle: () => void;
749
- /** 사이드바가 icon-축소 상태면 자식(Trigger/Content)은 Popover 모드로 전환. */
750
- flyoutMode: boolean;
751
- flyoutOpen: boolean;
752
- setFlyoutOpen: (open: boolean) => void;
753
- };
754
-
755
- const CollapsibleContext = React.createContext<CollapsibleContextValue | null>(null);
756
-
757
- function useCollapsible() {
758
- const ctx = React.useContext(CollapsibleContext);
759
- if (!ctx) throw new Error("SidebarCollapsible 하위에서만 사용할 수 있습니다.");
760
- return ctx;
761
- }
762
-
763
- export interface SidebarCollapsibleProps {
764
- /**
765
- * 초기 펼침 상태 (비제어 모드).
766
- * @default false
767
- */
768
- defaultOpen?: boolean;
769
- /** 펼침 상태 (제어 모드). 지정 시 내부 state 대신 이 값이 우선. */
770
- open?: boolean;
771
- /** 펼침 변경 콜백. */
772
- onOpenChange?: (open: boolean) => void;
773
- children: React.ReactNode;
774
- }
775
-
776
- /**
777
- * 메뉴 안에서 펼침/접힘 상태를 가진 그룹. Sidebar가 icon-축소 상태이면 자동으로 flyout(Popover) 모드로
778
- * 전환되어 hover/focus 시 우측에 메뉴를 띄운다.
779
- */
780
- export function SidebarCollapsible({
781
- defaultOpen = false,
782
- open: openProp,
783
- onOpenChange,
784
- children,
785
- }: SidebarCollapsibleProps) {
786
- const [_open, _setOpen] = React.useState(defaultOpen);
787
- const open = openProp ?? _open;
788
- const toggle = React.useCallback(() => {
789
- const next = !open;
790
- if (onOpenChange) onOpenChange(next);
791
- else _setOpen(next);
792
- }, [open, onOpenChange]);
793
-
794
- // 부모 Sidebar가 collapsed + icon 모드일 때 flyout(팝오버)로 동작.
795
- const sidebar = React.useContext(SidebarContext);
796
- const render = useSidebarRender();
797
- const flyoutMode =
798
- !!sidebar &&
799
- !sidebar.isMobile &&
800
- sidebar.state === "collapsed" &&
801
- render.collapsible === "icon";
802
-
803
- const [flyoutOpen, setFlyoutOpen] = React.useState(false);
804
- React.useEffect(() => {
805
- if (!flyoutMode) setFlyoutOpen(false);
806
- }, [flyoutMode]);
807
-
808
- const value = React.useMemo(
809
- () => ({ open, toggle, flyoutMode, flyoutOpen, setFlyoutOpen }),
810
- [open, toggle, flyoutMode, flyoutOpen],
811
- );
812
-
813
- // flyout 모드면 Popover로 감싸 Trigger/Content가 자연스럽게 anchor된다.
814
- if (flyoutMode) {
815
- return (
816
- <CollapsibleContext.Provider value={value}>
817
- <Popover open={flyoutOpen} onOpenChange={setFlyoutOpen}>
818
- {children}
819
- </Popover>
820
- </CollapsibleContext.Provider>
821
- );
822
- }
823
-
824
- return (
825
- <CollapsibleContext.Provider value={value}>
826
- {children}
827
- </CollapsibleContext.Provider>
828
- );
829
- }
830
-
831
- export interface SidebarCollapsibleTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
832
- /**
833
- * 크기. 부모 메뉴와 시각 위계를 맞춘다.
834
- * @default "md"
835
- */
836
- size?: "sm" | "md" | "lg";
837
- }
838
-
839
- /** Collapsible을 토글하는 메뉴 버튼. flyout 모드면 Popover Trigger로 자동 위임된다. */
840
- export function SidebarCollapsibleTrigger({
841
- className,
842
- size = "md",
843
- children,
844
- onClick,
845
- ...props
846
- }: SidebarCollapsibleTriggerProps) {
847
- const { open, toggle, flyoutMode, flyoutOpen } = useCollapsible();
848
-
849
- const cls = cn(sidebarMenuButton,
850
- byKey[`sidebar__menu-button--${size}`],
851
- sidebarCollapsibleTrigger,
852
- className);
853
-
854
- const isOpen = flyoutMode ? flyoutOpen : open;
855
-
856
- const content = (
857
- <>
858
- {children}
859
- <ChevronRightIcon className={sidebar__chevron} aria-hidden />
860
- </>
861
- );
862
-
863
- // flyout 모드면 Popover가 트리거 anchor/hover/focus 처리 전체를 담당.
864
- if (flyoutMode) {
865
- return (
866
- <PopoverTrigger
867
- openOnHover
868
- delay={0}
869
- closeDelay={150}
870
- render={(triggerProps) => (
871
- <button
872
- {...triggerProps}
873
- {...props}
874
- type="button"
875
- className={cls}
876
- data-state={isOpen ? "open" : "closed"}
877
- >
878
- {content}
879
- </button>
880
- )}
881
- />
882
- );
883
- }
884
-
885
- return (
886
- <button
887
- type="button"
888
- className={cls}
889
- data-state={isOpen ? "open" : "closed"}
890
- aria-expanded={open}
891
- onClick={(e) => {
892
- onClick?.(e);
893
- toggle();
894
- }}
895
- {...props}
896
- >
897
- {content}
898
- </button>
899
- );
900
- }
901
-
902
- /** Collapsible의 펼쳐지는 본문. flyout 모드면 PopoverContent로 자동 래핑된다. */
903
- export function SidebarCollapsibleContent({ children }: { children: React.ReactNode }) {
904
- const { open, flyoutMode } = useCollapsible();
905
- const render = useSidebarRender();
906
-
907
- // flyout 모드: Popover의 Content로 래핑. 위치/포커스/바깥 클릭은 Popover가 처리.
908
- if (flyoutMode) {
909
- return (
910
- <PopoverContent
911
- side={render.side === "right" ? "left" : "right"}
912
- align="start"
913
- className={sidebarCollapsibleFlyout}
914
- >
915
- {children}
916
- </PopoverContent>
917
- );
918
- }
919
-
920
- return (
921
- <div className={sidebarCollapsibleContent} data-state={open ? "open" : "closed"} hidden={!open}>
922
- {children}
923
- </div>
924
- );
925
- }
926
-
927
- /* ───────────── TOC (Table of Contents — 페이지 내 섹션 스크롤 활성화) ─────────────
928
- *
929
- * 사용 예:
930
- * <SidebarTOC sectionIds={["intro", "install", "usage"]}>
931
- * <SidebarMenu>
932
- * <SidebarMenuItem>
933
- * <SidebarMenuButton sectionId="intro" asChild>
934
- * <a href="#intro">Intro</a>
935
- * </SidebarMenuButton>
936
- * </SidebarMenuItem>
937
- * ...
938
- * </SidebarMenu>
939
- * </SidebarTOC>
940
- */
941
-
942
- const TOCContext = React.createContext<string | undefined>(undefined);
943
-
944
- function useTOCActiveId(): string | undefined {
945
- return React.useContext(TOCContext);
946
- }
947
-
948
- export interface SidebarTOCProps {
949
- /** 감시할 섹션의 DOM `id` 목록. 문서 등장 순서대로 나열할 것. */
950
- sectionIds: string[];
951
- /**
952
- * `IntersectionObserver` rootMargin. 어느 지점에서 섹션이 "활성"으로 전환되는지 결정.
953
- * 기본값은 뷰포트 상단 20% / 하단 70% 지점.
954
- *
955
- * @default "-20% 0px -70% 0px"
956
- */
957
- rootMargin?: string;
958
- /**
959
- * 관측 대상 스크롤 컨테이너.
960
- * @default null (뷰포트)
961
- */
962
- root?: Element | null;
963
- /**
964
- * 초기 활성 섹션 id.
965
- * @default sectionIds[0]
966
- */
967
- defaultActiveId?: string;
968
- /** 활성 섹션 변경 콜백. URL 해시 동기화 등 외부 연동 용도. */
969
- onActiveChange?: (id: string | undefined) => void;
970
- children: React.ReactNode;
971
- }
972
-
973
- /**
974
- * 페이지 내 섹션 스크롤 위치를 IntersectionObserver로 추적해 활성 섹션 id를 자식에게 전달한다.
975
- * 자식 SidebarMenuButton/SidebarMenuSubButton에 `sectionId`만 지정하면 활성 강조가 자동 동기화된다.
976
- */
977
- export function SidebarTOC({
978
- sectionIds,
979
- rootMargin = "-20% 0px -70% 0px",
980
- root = null,
981
- defaultActiveId,
982
- onActiveChange,
983
- children,
984
- }: SidebarTOCProps) {
985
- const [activeId, setActiveId] = React.useState<string | undefined>(
986
- defaultActiveId ?? sectionIds[0]
987
- );
988
-
989
- const idsKey = sectionIds.join("|");
990
-
991
- React.useEffect(() => {
992
- if (typeof window === "undefined") return;
993
- if (sectionIds.length === 0) return;
994
-
995
- const visible = new Map<string, IntersectionObserverEntry>();
996
-
997
- const observer = new IntersectionObserver(
998
- (entries) => {
999
- for (const entry of entries) {
1000
- const id = (entry.target as HTMLElement).id;
1001
- if (entry.isIntersecting) visible.set(id, entry);
1002
- else visible.delete(id);
1003
- }
1004
-
1005
- if (visible.size === 0) return;
1006
-
1007
- let topId: string | undefined;
1008
- let topY = Number.POSITIVE_INFINITY;
1009
- visible.forEach((entry, id) => {
1010
- const y = entry.boundingClientRect.top;
1011
- if (y < topY) {
1012
- topY = y;
1013
- topId = id;
1014
- }
1015
- });
1016
- if (topId) setActiveId(topId);
1017
- },
1018
- { rootMargin, root, threshold: 0 }
1019
- );
1020
-
1021
- const ids = idsKey.split("|").filter(Boolean);
1022
- ids
1023
- .map((id) => document.getElementById(id))
1024
- .filter((el): el is HTMLElement => el !== null)
1025
- .forEach((el) => observer.observe(el));
1026
-
1027
- // 스크롤 컨테이너가 끝에 도달하면 마지막 섹션을 강제 활성화.
1028
- // (마지막 섹션이 컨테이너보다 작아서 트리거 라인까지 올라오지 못하는 케이스 보정)
1029
- const scrollTarget: Element | Window =
1030
- root ?? (typeof window !== "undefined" ? window : (null as never));
1031
- if (!scrollTarget) return () => observer.disconnect();
1032
-
1033
- const handleScroll = () => {
1034
- const lastId = ids[ids.length - 1];
1035
- if (!lastId) return;
1036
- const el =
1037
- root ??
1038
- (document.scrollingElement as HTMLElement | null) ??
1039
- document.documentElement;
1040
- const scrollTop = "scrollTop" in el ? el.scrollTop : 0;
1041
- const clientHeight =
1042
- "clientHeight" in el ? el.clientHeight : window.innerHeight;
1043
- const scrollHeight = "scrollHeight" in el ? el.scrollHeight : 0;
1044
- if (scrollTop + clientHeight >= scrollHeight - 2) {
1045
- setActiveId(lastId);
1046
- }
1047
- };
1048
-
1049
- scrollTarget.addEventListener("scroll", handleScroll, { passive: true });
1050
- handleScroll();
1051
-
1052
- return () => {
1053
- observer.disconnect();
1054
- scrollTarget.removeEventListener("scroll", handleScroll);
1055
- };
1056
- }, [idsKey, rootMargin, root]);
1057
-
1058
- React.useEffect(() => {
1059
- onActiveChange?.(activeId);
1060
- }, [activeId, onActiveChange]);
1061
-
1062
- return (
1063
- <TOCContext.Provider value={activeId}>
1064
- {children}
1065
- </TOCContext.Provider>
1066
- );
1067
- }