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