sh-ui-cli 0.44.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.
- package/data/changelog/versions.json +12 -0
- package/data/registry/react/components/calendar/index.tailwind.tsx +498 -0
- package/data/registry/react/components/carousel/index.tailwind.tsx +309 -0
- package/data/registry/react/components/code-editor/index.tailwind.tsx +168 -0
- package/data/registry/react/components/color-picker/index.tailwind.tsx +309 -0
- package/data/registry/react/components/file-upload/index.tailwind.tsx +290 -0
- package/data/registry/react/components/form/field.tailwind.tsx +165 -0
- package/data/registry/react/components/form/form.tailwind.tsx +129 -0
- package/data/registry/react/components/form/index.tailwind.tsx +49 -0
- package/data/registry/react/components/header/index.tailwind.tsx +550 -0
- package/data/registry/react/components/markdown-editor/index.tailwind.tsx +118 -0
- package/data/registry/react/components/rich-text-editor/index.tailwind.tsx +211 -0
- package/data/registry/react/components/sidebar/index.tailwind.tsx +635 -0
- package/data/registry/react/components/toast/index.tailwind.tsx +215 -0
- package/data/registry/react/registry.json +187 -24
- package/package.json +1 -1
- package/src/mcp.mjs +1 -1
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { ChevronRightIcon, PanelLeftIcon } from "lucide-react";
|
|
5
|
+
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
|
|
6
|
+
|
|
7
|
+
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
|
8
|
+
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
|
9
|
+
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
|
10
|
+
const MOBILE_BREAKPOINT = 768;
|
|
11
|
+
|
|
12
|
+
const FOCUSABLE_SELECTOR =
|
|
13
|
+
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
14
|
+
|
|
15
|
+
function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>, active: boolean, onClose: () => void) {
|
|
16
|
+
React.useEffect(() => {
|
|
17
|
+
if (!active) return;
|
|
18
|
+
const container = containerRef.current;
|
|
19
|
+
if (!container) return;
|
|
20
|
+
const previouslyFocused = (document.activeElement as HTMLElement) ?? null;
|
|
21
|
+
const focusables = () => Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
|
22
|
+
const first = focusables()[0];
|
|
23
|
+
if (first) first.focus();
|
|
24
|
+
else { container.setAttribute("tabindex", "-1"); container.focus(); }
|
|
25
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
26
|
+
if (e.key === "Escape") { e.preventDefault(); onClose(); return; }
|
|
27
|
+
if (e.key !== "Tab") return;
|
|
28
|
+
const items = focusables();
|
|
29
|
+
if (items.length === 0) { e.preventDefault(); return; }
|
|
30
|
+
const firstEl = items[0]; const lastEl = items[items.length - 1];
|
|
31
|
+
if (e.shiftKey && document.activeElement === firstEl) { e.preventDefault(); lastEl.focus(); }
|
|
32
|
+
else if (!e.shiftKey && document.activeElement === lastEl) { e.preventDefault(); firstEl.focus(); }
|
|
33
|
+
};
|
|
34
|
+
container.addEventListener("keydown", onKeyDown);
|
|
35
|
+
return () => {
|
|
36
|
+
container.removeEventListener("keydown", onKeyDown);
|
|
37
|
+
if (previouslyFocused && typeof previouslyFocused.focus === "function") previouslyFocused.focus();
|
|
38
|
+
};
|
|
39
|
+
}, [active, containerRef, onClose]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function useIsMobile() {
|
|
43
|
+
const [isMobile, setIsMobile] = React.useState(false);
|
|
44
|
+
React.useEffect(() => {
|
|
45
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
46
|
+
const onChange = () => setIsMobile(mql.matches);
|
|
47
|
+
onChange();
|
|
48
|
+
mql.addEventListener("change", onChange);
|
|
49
|
+
return () => mql.removeEventListener("change", onChange);
|
|
50
|
+
}, []);
|
|
51
|
+
return isMobile;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type SidebarContextValue = {
|
|
55
|
+
state: "expanded" | "collapsed";
|
|
56
|
+
open: boolean; setOpen: (open: boolean) => void;
|
|
57
|
+
openMobile: boolean; setOpenMobile: (open: boolean) => void;
|
|
58
|
+
isMobile: boolean; toggleSidebar: () => void;
|
|
59
|
+
activePanel: string | null;
|
|
60
|
+
setActivePanel: (id: string | null) => void;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const SidebarContext = React.createContext<SidebarContextValue | null>(null);
|
|
64
|
+
export function useSidebar() {
|
|
65
|
+
const ctx = React.useContext(SidebarContext);
|
|
66
|
+
if (!ctx) throw new Error("useSidebar must be used within a SidebarProvider.");
|
|
67
|
+
return ctx;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SidebarProviderProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
71
|
+
defaultOpen?: boolean; open?: boolean;
|
|
72
|
+
onOpenChange?: (open: boolean) => void;
|
|
73
|
+
embedded?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function SidebarProvider({
|
|
77
|
+
defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, embedded, ...props
|
|
78
|
+
}: SidebarProviderProps) {
|
|
79
|
+
const isMobile = useIsMobile();
|
|
80
|
+
const [openMobile, setOpenMobile] = React.useState(false);
|
|
81
|
+
const [_open, _setOpen] = React.useState(defaultOpen);
|
|
82
|
+
const open = openProp ?? _open;
|
|
83
|
+
const setOpen = React.useCallback((value: boolean | ((prev: boolean) => boolean)) => {
|
|
84
|
+
const next = typeof value === "function" ? value(open) : value;
|
|
85
|
+
if (setOpenProp) setOpenProp(next); else _setOpen(next);
|
|
86
|
+
if (typeof document !== "undefined") {
|
|
87
|
+
document.cookie = `${SIDEBAR_COOKIE_NAME}=${next}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
|
88
|
+
}
|
|
89
|
+
}, [open, setOpenProp]);
|
|
90
|
+
|
|
91
|
+
const toggleSidebar = React.useCallback(() => {
|
|
92
|
+
if (isMobile) setOpenMobile((v) => !v);
|
|
93
|
+
else setOpen((v) => !v);
|
|
94
|
+
}, [isMobile, setOpen]);
|
|
95
|
+
|
|
96
|
+
const [activePanel, _setActivePanel] = React.useState<string | null>(null);
|
|
97
|
+
const setActivePanel = React.useCallback((id: string | null) => {
|
|
98
|
+
_setActivePanel((prev) => (prev === id ? null : id));
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
React.useEffect(() => {
|
|
102
|
+
const handler = (e: KeyboardEvent) => {
|
|
103
|
+
if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
|
|
104
|
+
e.preventDefault(); toggleSidebar();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
window.addEventListener("keydown", handler);
|
|
108
|
+
return () => window.removeEventListener("keydown", handler);
|
|
109
|
+
}, [toggleSidebar]);
|
|
110
|
+
|
|
111
|
+
const state: "expanded" | "collapsed" = open ? "expanded" : "collapsed";
|
|
112
|
+
const value = React.useMemo<SidebarContextValue>(() => ({
|
|
113
|
+
state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, activePanel, setActivePanel,
|
|
114
|
+
}), [state, open, setOpen, isMobile, openMobile, toggleSidebar, activePanel, setActivePanel]);
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<SidebarContext.Provider value={value}>
|
|
118
|
+
<div
|
|
119
|
+
className={[
|
|
120
|
+
"[--sidebar-width:16rem] [--sidebar-width-icon:3rem] [--sidebar-width-mobile:18rem]",
|
|
121
|
+
"[--sidebar-bg:var(--background-subtle)] [--sidebar-fg:var(--foreground)] [--sidebar-border:var(--border)]",
|
|
122
|
+
"[--sidebar-accent:var(--background-muted)] [--sidebar-accent-fg:var(--foreground)]",
|
|
123
|
+
"flex w-full",
|
|
124
|
+
embedded ? "min-h-0 h-full" : "min-h-[100svh]",
|
|
125
|
+
className,
|
|
126
|
+
].filter(Boolean).join(" ")}
|
|
127
|
+
style={style}
|
|
128
|
+
data-embedded={embedded || undefined}
|
|
129
|
+
data-panel-open={activePanel ? "true" : undefined}
|
|
130
|
+
{...props}
|
|
131
|
+
>
|
|
132
|
+
{children}
|
|
133
|
+
</div>
|
|
134
|
+
</SidebarContext.Provider>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type SidebarRenderCtx = {
|
|
139
|
+
collapsible: "offcanvas" | "icon" | "none";
|
|
140
|
+
variant: "sidebar" | "floating" | "inset";
|
|
141
|
+
side: "left" | "right";
|
|
142
|
+
};
|
|
143
|
+
const SidebarRenderContext = React.createContext<SidebarRenderCtx>({ collapsible: "offcanvas", variant: "sidebar", side: "left" });
|
|
144
|
+
export const useSidebarRender = () => React.useContext(SidebarRenderContext);
|
|
145
|
+
|
|
146
|
+
export interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
147
|
+
side?: "left" | "right";
|
|
148
|
+
variant?: "sidebar" | "floating" | "inset";
|
|
149
|
+
collapsible?: "offcanvas" | "icon" | "none";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const sidebarRoot =
|
|
153
|
+
"flex flex-col w-[var(--sidebar-width)] shrink-0 bg-[var(--sidebar-bg)] text-[var(--sidebar-fg)] border-r border-[var(--sidebar-border)] transition-[width] duration-[var(--duration-slow)] relative z-[5] data-[side=right]:border-r-0 data-[side=right]:border-l data-[side=right]:order-1 data-[state=collapsed][data-collapsible=offcanvas]:w-0 data-[state=collapsed][data-collapsible=offcanvas]:border-r-0 data-[state=collapsed][data-collapsible=offcanvas]:border-l-0 data-[state=collapsed][data-collapsible=offcanvas]:overflow-hidden data-[state=collapsed][data-collapsible=icon]:w-[var(--sidebar-width-icon)] data-[variant=floating]:border-none data-[variant=floating]:p-[var(--space-2)] data-[variant=floating]:bg-transparent data-[variant=inset]:bg-transparent data-[variant=inset]:border-none motion-reduce:transition-none";
|
|
154
|
+
|
|
155
|
+
export function Sidebar({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }: SidebarProps) {
|
|
156
|
+
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
|
157
|
+
const renderCtx = React.useMemo(() => ({ collapsible, variant, side }), [collapsible, variant, side]);
|
|
158
|
+
const wrap = (node: React.ReactNode) => <SidebarRenderContext.Provider value={renderCtx}>{node}</SidebarRenderContext.Provider>;
|
|
159
|
+
|
|
160
|
+
if (collapsible === "none") {
|
|
161
|
+
return wrap(
|
|
162
|
+
<aside
|
|
163
|
+
className={[sidebarRoot, "h-[100svh] sticky top-0", className].filter(Boolean).join(" ")}
|
|
164
|
+
data-side={side}
|
|
165
|
+
data-variant={variant}
|
|
166
|
+
{...props}
|
|
167
|
+
>
|
|
168
|
+
{children}
|
|
169
|
+
</aside>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if (isMobile) {
|
|
173
|
+
return wrap(<MobileSidebar side={side} className={className} openMobile={openMobile} setOpenMobile={setOpenMobile} {...props}>{children}</MobileSidebar>);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const innerWrap =
|
|
177
|
+
variant === "floating"
|
|
178
|
+
? "flex flex-col h-[calc(100svh-1rem)] sticky top-[var(--space-2)] overflow-hidden border border-[var(--sidebar-border)] rounded-[var(--radius)] bg-[var(--sidebar-bg)]"
|
|
179
|
+
: "flex flex-col h-[100svh] sticky top-0 overflow-hidden";
|
|
180
|
+
|
|
181
|
+
return wrap(
|
|
182
|
+
<aside
|
|
183
|
+
className={[sidebarRoot, className].filter(Boolean).join(" ")}
|
|
184
|
+
data-state={state}
|
|
185
|
+
data-collapsible={state === "collapsed" ? collapsible : ""}
|
|
186
|
+
data-variant={variant}
|
|
187
|
+
data-side={side}
|
|
188
|
+
{...props}
|
|
189
|
+
>
|
|
190
|
+
<div className={innerWrap}>{children}</div>
|
|
191
|
+
</aside>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function MobileSidebar({ side, className, openMobile, setOpenMobile, children, ...props }: {
|
|
196
|
+
side: "left" | "right"; className?: string;
|
|
197
|
+
openMobile: boolean; setOpenMobile: (open: boolean) => void;
|
|
198
|
+
children: React.ReactNode;
|
|
199
|
+
} & React.HTMLAttributes<HTMLElement>) {
|
|
200
|
+
const asideRef = React.useRef<HTMLElement>(null);
|
|
201
|
+
const close = React.useCallback(() => setOpenMobile(false), [setOpenMobile]);
|
|
202
|
+
useFocusTrap(asideRef, openMobile, close);
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<>
|
|
206
|
+
{openMobile && (
|
|
207
|
+
<div className="fixed inset-0 bg-black/25 [backdrop-filter:blur(8px)] z-40" onClick={close} aria-hidden />
|
|
208
|
+
)}
|
|
209
|
+
<aside
|
|
210
|
+
ref={asideRef}
|
|
211
|
+
className={[
|
|
212
|
+
"fixed top-0 bottom-0 w-[var(--sidebar-width-mobile)] z-[var(--z-overlay)] transition-transform duration-[var(--duration-slow)] flex flex-col bg-[var(--sidebar-bg)] text-[var(--sidebar-fg)] motion-reduce:transition-none",
|
|
213
|
+
side === "left"
|
|
214
|
+
? "left-0 border-r border-[var(--sidebar-border)] data-[state=open]:translate-x-0 data-[state=closed]:-translate-x-full"
|
|
215
|
+
: "right-0 border-l border-[var(--sidebar-border)] data-[state=open]:translate-x-0 data-[state=closed]:translate-x-full",
|
|
216
|
+
className,
|
|
217
|
+
].filter(Boolean).join(" ")}
|
|
218
|
+
data-side={side}
|
|
219
|
+
data-state={openMobile ? "open" : "closed"}
|
|
220
|
+
role="dialog"
|
|
221
|
+
aria-modal={openMobile ? "true" : undefined}
|
|
222
|
+
aria-hidden={!openMobile || undefined}
|
|
223
|
+
{...props}
|
|
224
|
+
>
|
|
225
|
+
{children}
|
|
226
|
+
</aside>
|
|
227
|
+
</>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface SidebarTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
|
232
|
+
|
|
233
|
+
export function SidebarTrigger({ className, onClick, ...props }: SidebarTriggerProps) {
|
|
234
|
+
const { toggleSidebar } = useSidebar();
|
|
235
|
+
return (
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
aria-label="Toggle Sidebar"
|
|
239
|
+
className={[
|
|
240
|
+
"inline-flex items-center justify-center w-8 h-8 border border-transparent bg-transparent text-foreground-muted rounded-[calc(var(--radius)-2px)] cursor-pointer transition-[background-color,color,border-color] duration-[var(--duration-fast)] hover:bg-[var(--sidebar-accent)] hover:text-foreground focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 motion-reduce:transition-none",
|
|
241
|
+
className,
|
|
242
|
+
].filter(Boolean).join(" ")}
|
|
243
|
+
onClick={(e) => { onClick?.(e); toggleSidebar(); }}
|
|
244
|
+
{...props}
|
|
245
|
+
>
|
|
246
|
+
<PanelLeftIcon size={16} aria-hidden />
|
|
247
|
+
</button>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface SidebarPanelProps extends React.HTMLAttributes<HTMLDivElement> { id: string; }
|
|
252
|
+
|
|
253
|
+
export function SidebarPanel({ id, className, children, ...props }: SidebarPanelProps) {
|
|
254
|
+
const { activePanel, setActivePanel, isMobile } = useSidebar();
|
|
255
|
+
const open = activePanel === id;
|
|
256
|
+
const ref = React.useRef<HTMLElement>(null);
|
|
257
|
+
const close = React.useCallback(() => setActivePanel(null), [setActivePanel]);
|
|
258
|
+
useFocusTrap(ref, open && isMobile, close);
|
|
259
|
+
|
|
260
|
+
React.useEffect(() => {
|
|
261
|
+
if (!open || isMobile) return;
|
|
262
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
|
|
263
|
+
window.addEventListener("keydown", onKey);
|
|
264
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
265
|
+
}, [open, isMobile, close]);
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<aside
|
|
269
|
+
ref={ref}
|
|
270
|
+
className={[
|
|
271
|
+
"[--sidebar-panel-width:20rem] flex flex-col w-[var(--sidebar-panel-width)] shrink-0 bg-background border-r border-[var(--sidebar-border)] relative z-[4] overflow-hidden animate-[sh-ui-sidebar-panel-in_180ms_ease-out] data-[state=closed]:hidden max-md:fixed max-md:top-0 max-md:bottom-0 max-md:left-0 max-md:w-[min(var(--sidebar-panel-width),90vw)] max-md:z-[var(--z-modal)] max-md:shadow-[0_10px_30px_rgba(0,0,0,0.15)] motion-reduce:animate-none",
|
|
272
|
+
className,
|
|
273
|
+
].filter(Boolean).join(" ")}
|
|
274
|
+
data-state={open ? "open" : "closed"}
|
|
275
|
+
role={isMobile ? "dialog" : undefined}
|
|
276
|
+
aria-modal={open && isMobile ? "true" : undefined}
|
|
277
|
+
hidden={!open}
|
|
278
|
+
{...props}
|
|
279
|
+
>
|
|
280
|
+
{children}
|
|
281
|
+
<button
|
|
282
|
+
type="button"
|
|
283
|
+
aria-label="패널 닫기"
|
|
284
|
+
className="absolute top-[var(--space-2)] right-[var(--space-2)] inline-flex items-center justify-center w-8 h-8 border-0 rounded-[calc(var(--radius)-2px)] bg-transparent text-foreground-muted text-[length:var(--text-lg)] leading-none cursor-pointer transition-[background-color,color] duration-[var(--duration-fast)] hover:bg-[var(--sidebar-accent)] hover:text-foreground motion-reduce:transition-none"
|
|
285
|
+
onClick={close}
|
|
286
|
+
>
|
|
287
|
+
×
|
|
288
|
+
</button>
|
|
289
|
+
</aside>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function SidebarPanelHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
294
|
+
return (
|
|
295
|
+
<div
|
|
296
|
+
className={["flex items-center gap-[var(--space-2)] py-3.5 px-[var(--space-4)] border-b border-[var(--sidebar-border)] font-semibold text-[0.9375rem]", className].filter(Boolean).join(" ")}
|
|
297
|
+
{...props}
|
|
298
|
+
/>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function SidebarPanelContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
303
|
+
return (
|
|
304
|
+
<div
|
|
305
|
+
className={["flex-1 min-h-0 overflow-y-auto py-[var(--space-3)] px-[var(--space-4)] pb-[var(--space-4)]", className].filter(Boolean).join(" ")}
|
|
306
|
+
{...props}
|
|
307
|
+
/>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function SidebarInset({ className, ...props }: React.HTMLAttributes<HTMLElement>) {
|
|
312
|
+
return <main className={["flex-1 min-w-0 bg-background flex flex-col", className].filter(Boolean).join(" ")} {...props} />;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function SidebarHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
316
|
+
return <div className={["flex flex-col gap-[var(--space-2)] p-[var(--space-2)] overflow-hidden", className].filter(Boolean).join(" ")} {...props} />;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function SidebarFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
320
|
+
return <div className={["flex flex-col gap-[var(--space-2)] p-[var(--space-2)] overflow-hidden", className].filter(Boolean).join(" ")} {...props} />;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function SidebarContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
324
|
+
return <div className={["flex flex-col flex-1 min-h-0 overflow-y-auto gap-0", className].filter(Boolean).join(" ")} {...props} />;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function SidebarSeparator({ className, ...props }: React.HTMLAttributes<HTMLHRElement>) {
|
|
328
|
+
return <hr className={["my-[var(--space-1)] mx-[var(--space-2)] border-0 border-t border-[var(--sidebar-border)] w-auto", className].filter(Boolean).join(" ")} {...props} />;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function SidebarGroup({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
332
|
+
return <div className={["flex flex-col p-[var(--space-2)] min-w-0", className].filter(Boolean).join(" ")} {...props} />;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function SidebarGroupLabel({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
336
|
+
return (
|
|
337
|
+
<div
|
|
338
|
+
className={[
|
|
339
|
+
"flex items-center h-8 px-[var(--space-2)] text-[length:var(--text-xs)] font-medium text-foreground-muted rounded-[calc(var(--radius)-2px)] [[data-state=collapsed][data-collapsible=icon]_&]:hidden",
|
|
340
|
+
className,
|
|
341
|
+
].filter(Boolean).join(" ")}
|
|
342
|
+
{...props}
|
|
343
|
+
/>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function SidebarGroupContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
348
|
+
return <div className={["w-full text-[length:var(--text-sm)]", className].filter(Boolean).join(" ")} {...props} />;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function SidebarMenu({ className, ...props }: React.HTMLAttributes<HTMLUListElement>) {
|
|
352
|
+
return <ul className={["list-none m-0 p-0 flex flex-col min-w-0 gap-0", className].filter(Boolean).join(" ")} {...props} />;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function SidebarMenuItem({ className, ...props }: React.HTMLAttributes<HTMLLIElement>) {
|
|
356
|
+
return <li className={["relative m-0", className].filter(Boolean).join(" ")} {...props} />;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export interface SidebarMenuButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
360
|
+
isActive?: boolean;
|
|
361
|
+
size?: "sm" | "md" | "lg";
|
|
362
|
+
asChild?: boolean;
|
|
363
|
+
sectionId?: string;
|
|
364
|
+
panelId?: string;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const menuButtonBase =
|
|
368
|
+
"flex w-full items-center gap-[var(--space-2)] p-[var(--space-2)] text-left text-[length:var(--text-sm)] text-[var(--sidebar-fg)] bg-transparent border-none rounded-[calc(var(--radius)-2px)] cursor-pointer transition-[background-color,color] duration-[var(--duration-fast)] no-underline font-[inherit] leading-snug [&>svg]:w-4 [&>svg]:h-4 [&>svg]:shrink-0 [&>span]:flex-1 [&>span]:min-w-0 [&>span]:overflow-hidden [&>span]:[text-overflow:ellipsis] [&>span]:whitespace-nowrap hover:bg-[var(--sidebar-accent)] hover:text-[var(--sidebar-accent-fg)] focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:[outline-offset:-2px] data-[active]:bg-primary data-[active]:text-primary-foreground data-[active]:font-semibold data-[active]:hover:bg-primary-hover disabled:opacity-[var(--opacity-disabled)] disabled:pointer-events-none aria-disabled:opacity-[var(--opacity-disabled)] aria-disabled:pointer-events-none [[data-state=collapsed][data-collapsible=icon]_&]:justify-center [[data-state=collapsed][data-collapsible=icon]_&]:p-[var(--space-2)] [[data-state=collapsed][data-collapsible=icon]_&>span]:hidden motion-reduce:transition-none";
|
|
369
|
+
|
|
370
|
+
const menuButtonSize = {
|
|
371
|
+
sm: "h-7 py-[var(--space-1)] px-[var(--space-2)] text-[0.8125rem]",
|
|
372
|
+
md: "",
|
|
373
|
+
lg: "p-[var(--space-3)] text-[0.9375rem]",
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
export const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenuButtonProps>(
|
|
377
|
+
function SidebarMenuButton({ className, isActive, size = "md", asChild, sectionId, panelId, onClick, children, ...props }, ref) {
|
|
378
|
+
const tocActive = useTOCActiveId();
|
|
379
|
+
const ctx = React.useContext(SidebarContext);
|
|
380
|
+
const panelActive = panelId != null && ctx?.activePanel === panelId;
|
|
381
|
+
const resolvedIsActive = isActive ?? (panelId != null ? panelActive : undefined) ?? (sectionId != null ? tocActive === sectionId : undefined);
|
|
382
|
+
|
|
383
|
+
const handleClick = React.useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
|
384
|
+
onClick?.(e);
|
|
385
|
+
if (!e.defaultPrevented && panelId != null && ctx) ctx.setActivePanel(panelId);
|
|
386
|
+
}, [onClick, panelId, ctx]);
|
|
387
|
+
|
|
388
|
+
const cls = [menuButtonBase, menuButtonSize[size], className].filter(Boolean).join(" ");
|
|
389
|
+
|
|
390
|
+
if (asChild && React.isValidElement(children)) {
|
|
391
|
+
const child = children as React.ReactElement<Record<string, unknown>>;
|
|
392
|
+
const merged: Record<string, unknown> = {
|
|
393
|
+
...props,
|
|
394
|
+
onClick: handleClick,
|
|
395
|
+
className: [(child.props.className as string) || "", cls].filter(Boolean).join(" "),
|
|
396
|
+
"data-active": resolvedIsActive || undefined,
|
|
397
|
+
};
|
|
398
|
+
return React.cloneElement(child, merged);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return (
|
|
402
|
+
<button ref={ref} type="button" className={cls} data-active={resolvedIsActive || undefined} onClick={handleClick} {...props}>
|
|
403
|
+
{children}
|
|
404
|
+
</button>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
export function SidebarMenuSub({ className, ...props }: React.HTMLAttributes<HTMLUListElement>) {
|
|
410
|
+
return (
|
|
411
|
+
<ul
|
|
412
|
+
className={[
|
|
413
|
+
"list-none mt-0.5 ml-3.5 pt-0.5 pr-0 pb-0.5 pl-2.5 border-l border-[var(--sidebar-border)] flex flex-col gap-0.5 min-w-0 [[data-state=collapsed][data-collapsible=icon]_&]:hidden",
|
|
414
|
+
className,
|
|
415
|
+
].filter(Boolean).join(" ")}
|
|
416
|
+
{...props}
|
|
417
|
+
/>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function SidebarMenuSubItem({ className, ...props }: React.HTMLAttributes<HTMLLIElement>) {
|
|
422
|
+
return <li className={["relative", className].filter(Boolean).join(" ")} {...props} />;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export interface SidebarMenuSubButtonProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
426
|
+
isActive?: boolean;
|
|
427
|
+
size?: "sm" | "md";
|
|
428
|
+
asChild?: boolean;
|
|
429
|
+
sectionId?: string;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const menuSubButtonBase =
|
|
433
|
+
"flex items-center gap-[var(--space-2)] h-7 px-[var(--space-2)] rounded-[calc(var(--radius)-2px)] text-[0.8125rem] text-[var(--sidebar-fg)] no-underline transition-[background-color,color] duration-[var(--duration-fast)] min-w-0 [&>span]:flex-1 [&>span]:min-w-0 [&>span]:overflow-hidden [&>span]:[text-overflow:ellipsis] [&>span]:whitespace-nowrap hover:bg-[var(--sidebar-accent)] hover:text-[var(--sidebar-accent-fg)] data-[active]:bg-primary data-[active]:text-primary-foreground data-[active]:font-semibold data-[active]:hover:bg-primary-hover motion-reduce:transition-none";
|
|
434
|
+
|
|
435
|
+
export const SidebarMenuSubButton = React.forwardRef<HTMLAnchorElement, SidebarMenuSubButtonProps>(
|
|
436
|
+
function SidebarMenuSubButton({ className, isActive, size = "md", asChild, sectionId, children, ...props }, ref) {
|
|
437
|
+
const tocActive = useTOCActiveId();
|
|
438
|
+
const resolvedIsActive = isActive ?? (sectionId != null ? tocActive === sectionId : undefined);
|
|
439
|
+
const cls = [menuSubButtonBase, size === "sm" && "text-[length:var(--text-xs)]", className].filter(Boolean).join(" ");
|
|
440
|
+
|
|
441
|
+
if (asChild && React.isValidElement(children)) {
|
|
442
|
+
const child = children as React.ReactElement<Record<string, unknown>>;
|
|
443
|
+
const merged: Record<string, unknown> = {
|
|
444
|
+
...props,
|
|
445
|
+
className: [(child.props.className as string) || "", cls].filter(Boolean).join(" "),
|
|
446
|
+
"data-active": resolvedIsActive || undefined,
|
|
447
|
+
};
|
|
448
|
+
return React.cloneElement(child, merged);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return <a ref={ref} className={cls} data-active={resolvedIsActive || undefined} {...props}>{children}</a>;
|
|
452
|
+
}
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
type CollapsibleContextValue = {
|
|
456
|
+
open: boolean; toggle: () => void;
|
|
457
|
+
flyoutMode: boolean;
|
|
458
|
+
flyoutOpen: boolean; setFlyoutOpen: (open: boolean) => void;
|
|
459
|
+
};
|
|
460
|
+
const CollapsibleContext = React.createContext<CollapsibleContextValue | null>(null);
|
|
461
|
+
function useCollapsible() {
|
|
462
|
+
const ctx = React.useContext(CollapsibleContext);
|
|
463
|
+
if (!ctx) throw new Error("SidebarCollapsible 하위에서만 사용할 수 있습니다.");
|
|
464
|
+
return ctx;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export interface SidebarCollapsibleProps {
|
|
468
|
+
defaultOpen?: boolean; open?: boolean;
|
|
469
|
+
onOpenChange?: (open: boolean) => void;
|
|
470
|
+
children: React.ReactNode;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function SidebarCollapsible({ defaultOpen = false, open: openProp, onOpenChange, children }: SidebarCollapsibleProps) {
|
|
474
|
+
const [_open, _setOpen] = React.useState(defaultOpen);
|
|
475
|
+
const open = openProp ?? _open;
|
|
476
|
+
const toggle = React.useCallback(() => {
|
|
477
|
+
const next = !open;
|
|
478
|
+
if (onOpenChange) onOpenChange(next); else _setOpen(next);
|
|
479
|
+
}, [open, onOpenChange]);
|
|
480
|
+
|
|
481
|
+
const sidebar = React.useContext(SidebarContext);
|
|
482
|
+
const render = useSidebarRender();
|
|
483
|
+
const flyoutMode = !!sidebar && !sidebar.isMobile && sidebar.state === "collapsed" && render.collapsible === "icon";
|
|
484
|
+
const [flyoutOpen, setFlyoutOpen] = React.useState(false);
|
|
485
|
+
|
|
486
|
+
React.useEffect(() => { if (!flyoutMode) setFlyoutOpen(false); }, [flyoutMode]);
|
|
487
|
+
|
|
488
|
+
const value = React.useMemo(() => ({ open, toggle, flyoutMode, flyoutOpen, setFlyoutOpen }), [open, toggle, flyoutMode, flyoutOpen]);
|
|
489
|
+
|
|
490
|
+
if (flyoutMode) {
|
|
491
|
+
return (
|
|
492
|
+
<CollapsibleContext.Provider value={value}>
|
|
493
|
+
<Popover open={flyoutOpen} onOpenChange={setFlyoutOpen}>{children}</Popover>
|
|
494
|
+
</CollapsibleContext.Provider>
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
return <CollapsibleContext.Provider value={value}>{children}</CollapsibleContext.Provider>;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export interface SidebarCollapsibleTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
501
|
+
size?: "sm" | "md" | "lg";
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function SidebarCollapsibleTrigger({ className, size = "md", children, onClick, ...props }: SidebarCollapsibleTriggerProps) {
|
|
505
|
+
const { open, toggle, flyoutMode, flyoutOpen } = useCollapsible();
|
|
506
|
+
const cls = [menuButtonBase, menuButtonSize[size], className].filter(Boolean).join(" ");
|
|
507
|
+
const isOpen = flyoutMode ? flyoutOpen : open;
|
|
508
|
+
|
|
509
|
+
const content = (
|
|
510
|
+
<>
|
|
511
|
+
{children}
|
|
512
|
+
<ChevronRightIcon className="!w-3.5 !h-3.5 ml-auto shrink-0 transition-transform duration-[150ms] [[data-state=open]_&]:rotate-90 text-foreground-muted [[data-state=collapsed][data-collapsible=icon]_&]:hidden motion-reduce:transition-none" aria-hidden />
|
|
513
|
+
</>
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
if (flyoutMode) {
|
|
517
|
+
return (
|
|
518
|
+
<PopoverTrigger
|
|
519
|
+
openOnHover
|
|
520
|
+
delay={0}
|
|
521
|
+
closeDelay={150}
|
|
522
|
+
render={(triggerProps) => (
|
|
523
|
+
<button {...triggerProps} {...props} type="button" className={cls} data-state={isOpen ? "open" : "closed"}>
|
|
524
|
+
{content}
|
|
525
|
+
</button>
|
|
526
|
+
)}
|
|
527
|
+
/>
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return (
|
|
532
|
+
<button
|
|
533
|
+
type="button"
|
|
534
|
+
className={cls}
|
|
535
|
+
data-state={isOpen ? "open" : "closed"}
|
|
536
|
+
aria-expanded={open}
|
|
537
|
+
onClick={(e) => { onClick?.(e); toggle(); }}
|
|
538
|
+
{...props}
|
|
539
|
+
>
|
|
540
|
+
{content}
|
|
541
|
+
</button>
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export function SidebarCollapsibleContent({ children }: { children: React.ReactNode }) {
|
|
546
|
+
const { open, flyoutMode } = useCollapsible();
|
|
547
|
+
const render = useSidebarRender();
|
|
548
|
+
|
|
549
|
+
if (flyoutMode) {
|
|
550
|
+
return (
|
|
551
|
+
<PopoverContent
|
|
552
|
+
side={render.side === "right" ? "left" : "right"}
|
|
553
|
+
align="start"
|
|
554
|
+
className="[&_ul]:!flex [&_ul]:!flex-col [&_ul]:gap-0.5 [&_ul]:m-0 [&_ul]:p-0 [&_ul]:border-l-0 [&_a]:pl-2.5"
|
|
555
|
+
>
|
|
556
|
+
{children}
|
|
557
|
+
</PopoverContent>
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
return (
|
|
561
|
+
<div className="data-[state=closed]:hidden [[data-state=collapsed][data-collapsible=icon]_&]:hidden" data-state={open ? "open" : "closed"} hidden={!open}>
|
|
562
|
+
{children}
|
|
563
|
+
</div>
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const TOCContext = React.createContext<string | undefined>(undefined);
|
|
568
|
+
function useTOCActiveId(): string | undefined { return React.useContext(TOCContext); }
|
|
569
|
+
|
|
570
|
+
export interface SidebarTOCProps {
|
|
571
|
+
sectionIds: string[];
|
|
572
|
+
rootMargin?: string;
|
|
573
|
+
root?: Element | null;
|
|
574
|
+
defaultActiveId?: string;
|
|
575
|
+
onActiveChange?: (id: string | undefined) => void;
|
|
576
|
+
children: React.ReactNode;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function SidebarTOC({ sectionIds, rootMargin = "-20% 0px -70% 0px", root = null, defaultActiveId, onActiveChange, children }: SidebarTOCProps) {
|
|
580
|
+
const [activeId, setActiveId] = React.useState<string | undefined>(defaultActiveId ?? sectionIds[0]);
|
|
581
|
+
const idsKey = sectionIds.join("|");
|
|
582
|
+
|
|
583
|
+
React.useEffect(() => {
|
|
584
|
+
if (typeof window === "undefined") return;
|
|
585
|
+
if (sectionIds.length === 0) return;
|
|
586
|
+
const visible = new Map<string, IntersectionObserverEntry>();
|
|
587
|
+
const observer = new IntersectionObserver((entries) => {
|
|
588
|
+
for (const entry of entries) {
|
|
589
|
+
const id = (entry.target as HTMLElement).id;
|
|
590
|
+
if (entry.isIntersecting) visible.set(id, entry); else visible.delete(id);
|
|
591
|
+
}
|
|
592
|
+
if (visible.size === 0) return;
|
|
593
|
+
let topId: string | undefined; let topY = Number.POSITIVE_INFINITY;
|
|
594
|
+
visible.forEach((entry, id) => {
|
|
595
|
+
const y = entry.boundingClientRect.top;
|
|
596
|
+
if (y < topY) { topY = y; topId = id; }
|
|
597
|
+
});
|
|
598
|
+
if (topId) setActiveId(topId);
|
|
599
|
+
}, { rootMargin, root, threshold: 0 });
|
|
600
|
+
|
|
601
|
+
const ids = idsKey.split("|").filter(Boolean);
|
|
602
|
+
ids.map((id) => document.getElementById(id)).filter((el): el is HTMLElement => el !== null).forEach((el) => observer.observe(el));
|
|
603
|
+
|
|
604
|
+
const scrollTarget: Element | Window = root ?? (typeof window !== "undefined" ? window : (null as never));
|
|
605
|
+
if (!scrollTarget) return () => observer.disconnect();
|
|
606
|
+
|
|
607
|
+
const handleScroll = () => {
|
|
608
|
+
const lastId = ids[ids.length - 1]; if (!lastId) return;
|
|
609
|
+
const el = root ?? (document.scrollingElement as HTMLElement | null) ?? document.documentElement;
|
|
610
|
+
const scrollTop = "scrollTop" in el ? el.scrollTop : 0;
|
|
611
|
+
const clientHeight = "clientHeight" in el ? el.clientHeight : window.innerHeight;
|
|
612
|
+
const scrollHeight = "scrollHeight" in el ? el.scrollHeight : 0;
|
|
613
|
+
if (scrollTop + clientHeight >= scrollHeight - 2) setActiveId(lastId);
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
scrollTarget.addEventListener("scroll", handleScroll, { passive: true });
|
|
617
|
+
handleScroll();
|
|
618
|
+
|
|
619
|
+
return () => {
|
|
620
|
+
observer.disconnect();
|
|
621
|
+
scrollTarget.removeEventListener("scroll", handleScroll);
|
|
622
|
+
};
|
|
623
|
+
}, [idsKey, rootMargin, root]);
|
|
624
|
+
|
|
625
|
+
React.useEffect(() => { onActiveChange?.(activeId); }, [activeId, onActiveChange]);
|
|
626
|
+
|
|
627
|
+
return <TOCContext.Provider value={activeId}>{children}</TOCContext.Provider>;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (typeof document !== "undefined" && !document.querySelector("style[data-sh-ui-sidebar]")) {
|
|
631
|
+
const style = document.createElement("style");
|
|
632
|
+
style.setAttribute("data-sh-ui-sidebar", "");
|
|
633
|
+
style.textContent = `@keyframes sh-ui-sidebar-panel-in { from { transform: translateX(-8px); opacity: 0 } to { transform: translateX(0); opacity: 1 } }`;
|
|
634
|
+
document.head.appendChild(style);
|
|
635
|
+
}
|