sh-ui-cli 0.46.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/changelog/versions.json +13 -0
- package/data/registry/react/components/accordion/index.module.tsx +97 -0
- package/data/registry/react/components/accordion/styles.module.css +111 -0
- package/data/registry/react/components/avatar/index.module.tsx +73 -0
- package/data/registry/react/components/avatar/styles.module.css +36 -0
- package/data/registry/react/components/badge/index.module.tsx +40 -0
- package/data/registry/react/components/badge/styles.module.css +57 -0
- package/data/registry/react/components/breadcrumb/index.module.tsx +152 -0
- package/data/registry/react/components/breadcrumb/styles.module.css +82 -0
- package/data/registry/react/components/calendar/index.module.tsx +806 -0
- package/data/registry/react/components/calendar/styles.module.css +213 -0
- package/data/registry/react/components/carousel/index.module.tsx +430 -0
- package/data/registry/react/components/carousel/styles.module.css +155 -0
- package/data/registry/react/components/checkbox/index.module.tsx +96 -0
- package/data/registry/react/components/checkbox/styles.module.css +75 -0
- package/data/registry/react/components/code-editor/index.module.tsx +230 -0
- package/data/registry/react/components/code-editor/styles.module.css +76 -0
- package/data/registry/react/components/code-panel/index.module.tsx +191 -0
- package/data/registry/react/components/code-panel/styles.module.css +124 -0
- package/data/registry/react/components/color-picker/index.module.tsx +467 -0
- package/data/registry/react/components/color-picker/styles.module.css +166 -0
- package/data/registry/react/components/combobox/index.module.tsx +165 -0
- package/data/registry/react/components/combobox/styles.module.css +151 -0
- package/data/registry/react/components/context-menu/index.module.tsx +251 -0
- package/data/registry/react/components/context-menu/styles.module.css +140 -0
- package/data/registry/react/components/date-picker/index.module.tsx +520 -0
- package/data/registry/react/components/date-picker/styles.module.css +103 -0
- package/data/registry/react/components/dialog/index.module.tsx +95 -0
- package/data/registry/react/components/dialog/styles.module.css +127 -0
- package/data/registry/react/components/dropdown-menu/index.module.tsx +255 -0
- package/data/registry/react/components/dropdown-menu/styles.module.css +150 -0
- package/data/registry/react/components/file-upload/index.module.tsx +487 -0
- package/data/registry/react/components/file-upload/styles.module.css +170 -0
- package/data/registry/react/components/form/index.module.tsx +61 -0
- package/data/registry/react/components/form/styles.module.css +47 -0
- package/data/registry/react/components/header/index.module.tsx +805 -0
- package/data/registry/react/components/header/styles.module.css +350 -0
- package/data/registry/react/components/label/index.module.tsx +52 -0
- package/data/registry/react/components/label/styles.module.css +90 -0
- package/data/registry/react/components/markdown-editor/index.module.tsx +119 -0
- package/data/registry/react/components/markdown-editor/styles.module.css +160 -0
- package/data/registry/react/components/menubar/index.module.tsx +32 -0
- package/data/registry/react/components/menubar/styles.module.css +45 -0
- package/data/registry/react/components/numeric-input/index.module.tsx +148 -0
- package/data/registry/react/components/numeric-input/styles.module.css +56 -0
- package/data/registry/react/components/page-toc/index.module.tsx +174 -0
- package/data/registry/react/components/page-toc/styles.module.css +82 -0
- package/data/registry/react/components/pagination/index.module.tsx +269 -0
- package/data/registry/react/components/pagination/styles.module.css +105 -0
- package/data/registry/react/components/popover/index.module.tsx +113 -0
- package/data/registry/react/components/popover/styles.module.css +65 -0
- package/data/registry/react/components/progress/index.module.tsx +54 -0
- package/data/registry/react/components/progress/styles.module.css +41 -0
- package/data/registry/react/components/radio/index.module.tsx +65 -0
- package/data/registry/react/components/radio/styles.module.css +80 -0
- package/data/registry/react/components/rich-text-editor/index.module.tsx +348 -0
- package/data/registry/react/components/rich-text-editor/styles.module.css +196 -0
- package/data/registry/react/components/select/index.module.tsx +234 -0
- package/data/registry/react/components/select/styles.module.css +193 -0
- package/data/registry/react/components/separator/index.module.tsx +46 -0
- package/data/registry/react/components/separator/styles.module.css +15 -0
- package/data/registry/react/components/sidebar/index.module.tsx +1067 -0
- package/data/registry/react/components/sidebar/styles.module.css +502 -0
- package/data/registry/react/components/skeleton/index.module.tsx +22 -0
- package/data/registry/react/components/skeleton/styles.module.css +24 -0
- package/data/registry/react/components/slider/index.module.tsx +298 -0
- package/data/registry/react/components/slider/styles.module.css +64 -0
- package/data/registry/react/components/spinner/index.module.tsx +38 -0
- package/data/registry/react/components/spinner/styles.module.css +37 -0
- package/data/registry/react/components/switch/index.module.tsx +39 -0
- package/data/registry/react/components/switch/styles.module.css +83 -0
- package/data/registry/react/components/tabs/index.module.tsx +91 -0
- package/data/registry/react/components/tabs/styles.module.css +148 -0
- package/data/registry/react/components/textarea/index.module.tsx +23 -0
- package/data/registry/react/components/textarea/styles.module.css +54 -0
- package/data/registry/react/components/toast/index.module.tsx +258 -0
- package/data/registry/react/components/toast/styles.module.css +290 -0
- package/data/registry/react/components/toggle/index.module.tsx +131 -0
- package/data/registry/react/components/toggle/styles.module.css +85 -0
- package/data/registry/react/components/tooltip/index.module.tsx +83 -0
- package/data/registry/react/components/tooltip/styles.module.css +44 -0
- package/data/registry/react/registry.json +560 -0
- package/package.json +1 -1
- package/src/api.d.ts +4 -3
- package/src/constants.js +4 -3
|
@@ -0,0 +1,1067 @@
|
|
|
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 styles from "./styles.module.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(styles["sidebar-wrapper"], 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(styles.sidebar, styles["sidebar--static"], 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(styles.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={styles.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={styles.sidebar__backdrop} onClick={close} aria-hidden />
|
|
352
|
+
)}
|
|
353
|
+
<aside
|
|
354
|
+
ref={asideRef}
|
|
355
|
+
className={cn(styles.sidebar, styles["sidebar--mobile"], 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(styles.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(styles.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={styles["sidebar__panel-close"]}
|
|
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(styles["sidebar__panel-header"], 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(styles["sidebar__panel-content"], 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(styles["sidebar-inset"], 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(styles.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(styles.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(styles.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(styles.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(styles.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(styles["sidebar__group-label"], className)}
|
|
542
|
+
{...props}
|
|
543
|
+
/>
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** 그룹 내부의 항목 컨테이너. */
|
|
548
|
+
export function SidebarGroupContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
549
|
+
return (
|
|
550
|
+
<div
|
|
551
|
+
className={cn(styles["sidebar__group-content"], 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(styles.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(styles["sidebar__menu-item"], 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(styles["sidebar__menu-button"],
|
|
640
|
+
styles[`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(styles["sidebar__menu-sub"], 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(styles["sidebar__menu-sub-item"], 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(styles["sidebar__menu-sub-button"],
|
|
718
|
+
styles[`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(styles["sidebar__menu-button"],
|
|
850
|
+
styles[`sidebar__menu-button--${size}`],
|
|
851
|
+
styles["sidebar__collapsible-trigger"],
|
|
852
|
+
className);
|
|
853
|
+
|
|
854
|
+
const isOpen = flyoutMode ? flyoutOpen : open;
|
|
855
|
+
|
|
856
|
+
const content = (
|
|
857
|
+
<>
|
|
858
|
+
{children}
|
|
859
|
+
<ChevronRightIcon className={styles.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={styles["sidebar__collapsible-flyout"]}
|
|
914
|
+
>
|
|
915
|
+
{children}
|
|
916
|
+
</PopoverContent>
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
return (
|
|
921
|
+
<div className={styles["sidebar__collapsible-content"]} 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
|
+
}
|