sh-ui-cli 0.43.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/data/changelog/versions.json +24 -0
  2. package/data/registry/react/components/accordion/index.tailwind.tsx +88 -0
  3. package/data/registry/react/components/avatar/index.tailwind.tsx +74 -0
  4. package/data/registry/react/components/badge/index.tailwind.tsx +47 -0
  5. package/data/registry/react/components/breadcrumb/index.tailwind.tsx +138 -0
  6. package/data/registry/react/components/calendar/index.tailwind.tsx +498 -0
  7. package/data/registry/react/components/carousel/index.tailwind.tsx +309 -0
  8. package/data/registry/react/components/checkbox/index.tailwind.tsx +72 -0
  9. package/data/registry/react/components/code-editor/index.tailwind.tsx +168 -0
  10. package/data/registry/react/components/code-panel/index.tailwind.tsx +107 -0
  11. package/data/registry/react/components/color-picker/index.tailwind.tsx +309 -0
  12. package/data/registry/react/components/combobox/index.tailwind.tsx +160 -0
  13. package/data/registry/react/components/context-menu/index.tailwind.tsx +170 -0
  14. package/data/registry/react/components/date-picker/index.tailwind.tsx +294 -0
  15. package/data/registry/react/components/dialog/index.tailwind.tsx +96 -0
  16. package/data/registry/react/components/dropdown-menu/index.tailwind.tsx +205 -0
  17. package/data/registry/react/components/file-upload/index.tailwind.tsx +290 -0
  18. package/data/registry/react/components/form/field.tailwind.tsx +165 -0
  19. package/data/registry/react/components/form/form.tailwind.tsx +129 -0
  20. package/data/registry/react/components/form/index.tailwind.tsx +49 -0
  21. package/data/registry/react/components/header/index.tailwind.tsx +550 -0
  22. package/data/registry/react/components/label/index.tailwind.tsx +78 -0
  23. package/data/registry/react/components/markdown-editor/index.tailwind.tsx +118 -0
  24. package/data/registry/react/components/menubar/index.tailwind.tsx +32 -0
  25. package/data/registry/react/components/numeric-input/index.tailwind.tsx +113 -0
  26. package/data/registry/react/components/page-toc/index.tailwind.tsx +149 -0
  27. package/data/registry/react/components/pagination/index.tailwind.tsx +148 -0
  28. package/data/registry/react/components/popover/index.tailwind.tsx +77 -0
  29. package/data/registry/react/components/progress/index.tailwind.tsx +60 -0
  30. package/data/registry/react/components/radio/index.tailwind.tsx +54 -0
  31. package/data/registry/react/components/rich-text-editor/index.tailwind.tsx +211 -0
  32. package/data/registry/react/components/select/index.tailwind.tsx +199 -0
  33. package/data/registry/react/components/separator/index.tailwind.tsx +42 -0
  34. package/data/registry/react/components/sidebar/index.tailwind.tsx +635 -0
  35. package/data/registry/react/components/skeleton/index.tailwind.tsx +39 -0
  36. package/data/registry/react/components/slider/index.tailwind.tsx +255 -0
  37. package/data/registry/react/components/spinner/index.tailwind.tsx +63 -0
  38. package/data/registry/react/components/switch/index.tailwind.tsx +62 -0
  39. package/data/registry/react/components/tabs/index.tailwind.tsx +113 -0
  40. package/data/registry/react/components/textarea/index.tailwind.tsx +21 -0
  41. package/data/registry/react/components/toast/index.tailwind.tsx +215 -0
  42. package/data/registry/react/components/toggle/index.tailwind.tsx +111 -0
  43. package/data/registry/react/components/tooltip/index.tailwind.tsx +55 -0
  44. package/data/registry/react/registry.json +696 -98
  45. package/package.json +1 -1
  46. package/src/mcp.mjs +1 -1
@@ -0,0 +1,309 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ function cx(...args: (string | undefined | false | null)[]) {
6
+ return args.filter(Boolean).join(" ");
7
+ }
8
+
9
+ type Orientation = "horizontal" | "vertical";
10
+
11
+ interface CarouselContextValue {
12
+ orientation: Orientation; loop: boolean;
13
+ index: number; count: number;
14
+ goTo: (i: number) => void; goPrev: () => void; goNext: () => void;
15
+ registerItem: () => () => void;
16
+ setContentEl: (el: HTMLDivElement | null) => void;
17
+ contentRef: React.RefObject<HTMLDivElement | null>;
18
+ }
19
+
20
+ const CarouselContext = React.createContext<CarouselContextValue | null>(null);
21
+
22
+ function useCarousel() {
23
+ const ctx = React.useContext(CarouselContext);
24
+ if (!ctx) throw new Error("Carousel parts must be used within <Carousel>");
25
+ return ctx;
26
+ }
27
+
28
+ export function useCarouselState() {
29
+ const { orientation, loop, index, count, goTo, goPrev, goNext } = useCarousel();
30
+ return { orientation, loop, index, count, goTo, goPrev, goNext };
31
+ }
32
+
33
+ export interface CarouselProps extends React.HTMLAttributes<HTMLDivElement> {
34
+ orientation?: Orientation;
35
+ loop?: boolean;
36
+ autoPlay?: boolean | number;
37
+ defaultIndex?: number;
38
+ index?: number;
39
+ onIndexChange?: (index: number) => void;
40
+ }
41
+
42
+ export const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
43
+ ({ className, orientation = "horizontal", loop = false, autoPlay, defaultIndex = 0, index: controlledIndex, onIndexChange, children, onKeyDown: userOnKeyDown, ...props }, ref) => {
44
+ const isControlled = controlledIndex !== undefined;
45
+ const [uncontrolled, setUncontrolled] = React.useState(defaultIndex);
46
+ const index = isControlled ? controlledIndex : uncontrolled;
47
+ const [count, setCount] = React.useState(0);
48
+ const contentRef = React.useRef<HTMLDivElement | null>(null);
49
+ const scrollTargetRef = React.useRef<number | null>(null);
50
+
51
+ const setContentEl = React.useCallback((el: HTMLDivElement | null) => { contentRef.current = el; }, []);
52
+ const registerItem = React.useCallback(() => {
53
+ setCount((c) => c + 1);
54
+ return () => setCount((c) => Math.max(0, c - 1));
55
+ }, []);
56
+
57
+ const setIndex = React.useCallback((next: number) => {
58
+ if (!isControlled) setUncontrolled(next);
59
+ onIndexChange?.(next);
60
+ }, [isControlled, onIndexChange]);
61
+
62
+ const clamp = React.useCallback((i: number) => {
63
+ if (count <= 0) return 0;
64
+ if (loop) return ((i % count) + count) % count;
65
+ return Math.max(0, Math.min(count - 1, i));
66
+ }, [count, loop]);
67
+
68
+ const goTo = React.useCallback((i: number) => setIndex(clamp(i)), [clamp, setIndex]);
69
+ const goPrev = React.useCallback(() => setIndex(clamp(index - 1)), [clamp, index, setIndex]);
70
+ const goNext = React.useCallback(() => setIndex(clamp(index + 1)), [clamp, index, setIndex]);
71
+
72
+ React.useEffect(() => {
73
+ const el = contentRef.current;
74
+ if (!el) return;
75
+ const child = el.children[index] as HTMLElement | undefined;
76
+ if (!child) return;
77
+ scrollTargetRef.current = index;
78
+ if (orientation === "horizontal") el.scrollTo({ left: child.offsetLeft, behavior: "smooth" });
79
+ else el.scrollTo({ top: child.offsetTop, behavior: "smooth" });
80
+ }, [index, orientation, count]);
81
+
82
+ React.useEffect(() => {
83
+ const el = contentRef.current;
84
+ if (!el) return;
85
+ let raf = 0;
86
+ const onScroll = () => {
87
+ cancelAnimationFrame(raf);
88
+ raf = requestAnimationFrame(() => {
89
+ const children = Array.from(el.children) as HTMLElement[];
90
+ if (children.length === 0) return;
91
+ const axis = orientation === "horizontal" ? "offsetLeft" : "offsetTop";
92
+ const scroll = orientation === "horizontal" ? el.scrollLeft : el.scrollTop;
93
+ let nearest = 0; let minDelta = Infinity;
94
+ children.forEach((c, i) => {
95
+ const d = Math.abs(c[axis] - scroll);
96
+ if (d < minDelta) { minDelta = d; nearest = i; }
97
+ });
98
+ if (nearest !== index) {
99
+ if (scrollTargetRef.current !== null && nearest !== scrollTargetRef.current) return;
100
+ scrollTargetRef.current = null;
101
+ setIndex(nearest);
102
+ } else scrollTargetRef.current = null;
103
+ });
104
+ };
105
+ el.addEventListener("scroll", onScroll, { passive: true });
106
+ return () => { cancelAnimationFrame(raf); el.removeEventListener("scroll", onScroll); };
107
+ }, [orientation, index, setIndex]);
108
+
109
+ React.useEffect(() => {
110
+ if (!autoPlay || count <= 1) return;
111
+ const delay = autoPlay === true ? 4000 : autoPlay;
112
+ const el = contentRef.current;
113
+ let paused = false;
114
+ const pause = () => (paused = true);
115
+ const resume = () => (paused = false);
116
+ el?.addEventListener("mouseenter", pause);
117
+ el?.addEventListener("mouseleave", resume);
118
+ el?.addEventListener("focusin", pause);
119
+ el?.addEventListener("focusout", resume);
120
+ const id = window.setInterval(() => { if (paused) return; setIndex(clamp(index + 1)); }, delay);
121
+ return () => {
122
+ window.clearInterval(id);
123
+ el?.removeEventListener("mouseenter", pause);
124
+ el?.removeEventListener("mouseleave", resume);
125
+ el?.removeEventListener("focusin", pause);
126
+ el?.removeEventListener("focusout", resume);
127
+ };
128
+ }, [autoPlay, count, index, clamp, setIndex]);
129
+
130
+ const onKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
131
+ userOnKeyDown?.(event);
132
+ if (event.defaultPrevented) return;
133
+ const prevKey = orientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
134
+ const nextKey = orientation === "horizontal" ? "ArrowRight" : "ArrowDown";
135
+ if (event.key === prevKey) { event.preventDefault(); goPrev(); }
136
+ else if (event.key === nextKey) { event.preventDefault(); goNext(); }
137
+ }, [orientation, goPrev, goNext, userOnKeyDown]);
138
+
139
+ const value = React.useMemo<CarouselContextValue>(() => ({
140
+ orientation, loop, index, count, goTo, goPrev, goNext, registerItem, setContentEl, contentRef,
141
+ }), [orientation, loop, index, count, goTo, goPrev, goNext, registerItem, setContentEl]);
142
+
143
+ return (
144
+ <CarouselContext.Provider value={value}>
145
+ <div
146
+ ref={ref}
147
+ className={cx("relative w-full", className)}
148
+ data-orientation={orientation}
149
+ role="region"
150
+ aria-roledescription="carousel"
151
+ onKeyDown={onKeyDown}
152
+ {...props}
153
+ >
154
+ {children}
155
+ </div>
156
+ </CarouselContext.Provider>
157
+ );
158
+ },
159
+ );
160
+ Carousel.displayName = "Carousel";
161
+
162
+ export const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
163
+ ({ className, ...props }, ref) => {
164
+ const { orientation, setContentEl } = useCarousel();
165
+ const mergedRef = React.useCallback((el: HTMLDivElement | null) => {
166
+ setContentEl(el);
167
+ if (typeof ref === "function") ref(el);
168
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = el;
169
+ }, [ref, setContentEl]);
170
+
171
+ return (
172
+ <div
173
+ ref={mergedRef}
174
+ className={cx(
175
+ "flex gap-[var(--space-4)] overflow-x-auto overflow-y-hidden snap-x snap-mandatory scroll-smooth [scrollbar-width:none] [-ms-overflow-style:none] [-webkit-overflow-scrolling:touch] overscroll-x-contain [&::-webkit-scrollbar]:hidden motion-reduce:scroll-auto",
176
+ orientation === "vertical" && "flex-col overflow-x-hidden overflow-y-auto snap-y snap-mandatory h-80",
177
+ className,
178
+ )}
179
+ data-orientation={orientation}
180
+ {...props}
181
+ />
182
+ );
183
+ },
184
+ );
185
+ CarouselContent.displayName = "CarouselContent";
186
+
187
+ export const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
188
+ ({ className, ...props }, ref) => {
189
+ const { orientation, registerItem } = useCarousel();
190
+ React.useEffect(() => registerItem(), [registerItem]);
191
+ return (
192
+ <div
193
+ ref={ref}
194
+ role="group"
195
+ aria-roledescription="slide"
196
+ className={cx(
197
+ "flex-[0_0_100%] min-w-0 snap-start snap-always",
198
+ orientation === "vertical" && "basis-auto",
199
+ className,
200
+ )}
201
+ data-orientation={orientation}
202
+ {...props}
203
+ />
204
+ );
205
+ },
206
+ );
207
+ CarouselItem.displayName = "CarouselItem";
208
+
209
+ const navClasses =
210
+ "absolute top-1/2 w-8 h-8 inline-flex items-center justify-center bg-background text-foreground border border-border rounded-full cursor-pointer -translate-y-1/2 z-[1] transition-[opacity,background-color] duration-[var(--duration-fast)] hover:not-disabled:bg-background-muted focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 disabled:opacity-40 disabled:cursor-not-allowed motion-reduce:transition-none data-[orientation=vertical]:left-1/2 data-[orientation=vertical]:-translate-x-1/2 data-[orientation=vertical]:[top:auto]";
211
+
212
+ export const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
213
+ ({ className, onClick, disabled, children, ...props }, ref) => {
214
+ const { goPrev, orientation, index, count, loop } = useCarousel();
215
+ const atStart = !loop && index <= 0;
216
+ return (
217
+ <button
218
+ ref={ref}
219
+ type="button"
220
+ aria-label="이전"
221
+ className={cx(
222
+ navClasses,
223
+ orientation === "horizontal" ? "-left-4" : "-top-4 left-1/2",
224
+ className,
225
+ )}
226
+ data-orientation={orientation}
227
+ disabled={disabled || atStart || count === 0}
228
+ onClick={(e) => { onClick?.(e); if (!e.defaultPrevented) goPrev(); }}
229
+ {...props}
230
+ >
231
+ {children ?? (
232
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
233
+ <path d={orientation === "horizontal" ? "M10 4l-4 4 4 4" : "M4 10l4-4 4 4"} stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
234
+ </svg>
235
+ )}
236
+ </button>
237
+ );
238
+ },
239
+ );
240
+ CarouselPrevious.displayName = "CarouselPrevious";
241
+
242
+ export const CarouselNext = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
243
+ ({ className, onClick, disabled, children, ...props }, ref) => {
244
+ const { goNext, orientation, index, count, loop } = useCarousel();
245
+ const atEnd = !loop && index >= count - 1;
246
+ return (
247
+ <button
248
+ ref={ref}
249
+ type="button"
250
+ aria-label="다음"
251
+ className={cx(
252
+ navClasses,
253
+ orientation === "horizontal" ? "-right-4" : "-bottom-4 left-1/2 [top:auto]",
254
+ className,
255
+ )}
256
+ data-orientation={orientation}
257
+ disabled={disabled || atEnd || count === 0}
258
+ onClick={(e) => { onClick?.(e); if (!e.defaultPrevented) goNext(); }}
259
+ {...props}
260
+ >
261
+ {children ?? (
262
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
263
+ <path d={orientation === "horizontal" ? "M6 4l4 4-4 4" : "M4 6l4 4 4-4"} stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
264
+ </svg>
265
+ )}
266
+ </button>
267
+ );
268
+ },
269
+ );
270
+ CarouselNext.displayName = "CarouselNext";
271
+
272
+ export interface CarouselIndicatorsProps extends React.HTMLAttributes<HTMLDivElement> {
273
+ labelFor?: (index: number) => string;
274
+ }
275
+
276
+ export const CarouselIndicators = React.forwardRef<HTMLDivElement, CarouselIndicatorsProps>(
277
+ ({ className, labelFor, ...props }, ref) => {
278
+ const { count, index, goTo, orientation } = useCarousel();
279
+ if (count <= 0) return null;
280
+ return (
281
+ <div
282
+ ref={ref}
283
+ role="tablist"
284
+ aria-label="슬라이드 선택"
285
+ className={cx(
286
+ "flex justify-center items-center gap-[var(--space-2)] mt-[var(--space-3)]",
287
+ orientation === "vertical" && "absolute top-1/2 right-2 mt-0 flex-col -translate-y-1/2",
288
+ className,
289
+ )}
290
+ data-orientation={orientation}
291
+ {...props}
292
+ >
293
+ {Array.from({ length: count }).map((_, i) => (
294
+ <button
295
+ key={i}
296
+ type="button"
297
+ role="tab"
298
+ aria-selected={i === index}
299
+ aria-label={labelFor ? labelFor(i) : `${i + 1}번 슬라이드`}
300
+ className="w-2 h-2 p-0 bg-border border-none rounded-full cursor-pointer transition-[background-color,transform] duration-[var(--duration-fast)] hover:bg-border-strong data-[active]:bg-foreground data-[active]:scale-[1.2] focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 motion-reduce:transition-none"
301
+ data-active={i === index || undefined}
302
+ onClick={() => goTo(i)}
303
+ />
304
+ ))}
305
+ </div>
306
+ );
307
+ },
308
+ );
309
+ CarouselIndicators.displayName = "CarouselIndicators";
@@ -0,0 +1,72 @@
1
+ import * as React from "react";
2
+ import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox";
3
+ import { CheckboxGroup as BaseCheckboxGroup } from "@base-ui/react/checkbox-group";
4
+
5
+ function cx(...args: (string | undefined | false)[]) {
6
+ return args.filter(Boolean).join(" ");
7
+ }
8
+
9
+ export type CheckboxProps = Omit<
10
+ React.ComponentPropsWithoutRef<typeof BaseCheckbox.Root>,
11
+ "className"
12
+ > & {
13
+ className?: string;
14
+ };
15
+
16
+ export const Checkbox = React.forwardRef<HTMLElement, CheckboxProps>(
17
+ ({ className, ...props }, ref) => (
18
+ <BaseCheckbox.Root
19
+ ref={ref}
20
+ className={cx(
21
+ "inline-flex items-center justify-center w-[1.125rem] h-[1.125rem] border border-border-strong rounded-[calc(var(--radius)-2px)] bg-background text-primary-foreground cursor-pointer shrink-0 transition-[background-color,border-color] duration-[var(--duration-fast)] hover:not-data-[disabled]:border-foreground focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 data-[checked]:bg-primary data-[checked]:border-primary data-[indeterminate]:bg-primary data-[indeterminate]:border-primary data-[disabled]:opacity-[var(--opacity-disabled)] data-[disabled]:cursor-not-allowed motion-reduce:transition-none [@media(hover:none)_and_(pointer:coarse)]:w-5 [@media(hover:none)_and_(pointer:coarse)]:h-5",
22
+ className,
23
+ )}
24
+ {...props}
25
+ >
26
+ <BaseCheckbox.Indicator className="inline-flex items-center justify-center">
27
+ {props.indeterminate ? <MinusIcon /> : <CheckIcon />}
28
+ </BaseCheckbox.Indicator>
29
+ </BaseCheckbox.Root>
30
+ ),
31
+ );
32
+ Checkbox.displayName = "Checkbox";
33
+
34
+ export type CheckboxGroupProps = Omit<
35
+ React.ComponentPropsWithoutRef<typeof BaseCheckboxGroup>,
36
+ "className"
37
+ > & {
38
+ className?: string;
39
+ orientation?: "horizontal" | "vertical";
40
+ };
41
+
42
+ export const CheckboxGroup = React.forwardRef<HTMLDivElement, CheckboxGroupProps>(
43
+ ({ className, orientation = "vertical", ...props }, ref) => (
44
+ <BaseCheckboxGroup
45
+ ref={ref}
46
+ className={cx(
47
+ "flex gap-2.5",
48
+ orientation === "vertical" ? "flex-col" : "flex-row flex-wrap",
49
+ className,
50
+ )}
51
+ data-orientation={orientation}
52
+ {...props}
53
+ />
54
+ ),
55
+ );
56
+ CheckboxGroup.displayName = "CheckboxGroup";
57
+
58
+ function CheckIcon() {
59
+ return (
60
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" aria-hidden>
61
+ <path d="M3.5 8.5l3 3 6-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
62
+ </svg>
63
+ );
64
+ }
65
+
66
+ function MinusIcon() {
67
+ return (
68
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" aria-hidden>
69
+ <path d="M4 8h8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
70
+ </svg>
71
+ );
72
+ }
@@ -0,0 +1,168 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef } from "react";
4
+ import { Compartment, EditorState, type Extension } from "@codemirror/state";
5
+ import { EditorView, placeholder as placeholderExt } from "@codemirror/view";
6
+ import { basicSetup } from "codemirror";
7
+ import { javascript } from "@codemirror/lang-javascript";
8
+ import { json } from "@codemirror/lang-json";
9
+ import { css as cssLang } from "@codemirror/lang-css";
10
+ import { html } from "@codemirror/lang-html";
11
+ import { markdown } from "@codemirror/lang-markdown";
12
+
13
+ export type CodeEditorLanguage =
14
+ | "text" | "javascript" | "typescript" | "jsx" | "tsx"
15
+ | "json" | "css" | "html" | "markdown";
16
+
17
+ export interface CodeEditorProps {
18
+ value?: string;
19
+ defaultValue?: string;
20
+ onChange?: (value: string) => void;
21
+ language?: CodeEditorLanguage;
22
+ placeholder?: string;
23
+ readOnly?: boolean;
24
+ showLineNumbers?: boolean;
25
+ minHeight?: string;
26
+ maxHeight?: string;
27
+ className?: string;
28
+ id?: string;
29
+ "aria-label"?: string;
30
+ "aria-labelledby"?: string;
31
+ }
32
+
33
+ function cx(...args: (string | undefined | false | null)[]) {
34
+ return args.filter(Boolean).join(" ");
35
+ }
36
+
37
+ function languageExtension(language: CodeEditorLanguage): Extension {
38
+ switch (language) {
39
+ case "javascript": return javascript();
40
+ case "typescript": return javascript({ typescript: true });
41
+ case "jsx": return javascript({ jsx: true });
42
+ case "tsx": return javascript({ jsx: true, typescript: true });
43
+ case "json": return json();
44
+ case "css": return cssLang();
45
+ case "html": return html();
46
+ case "markdown": return markdown();
47
+ case "text":
48
+ default: return [];
49
+ }
50
+ }
51
+
52
+ /**
53
+ * CodeMirror 6 기반 인라인 코드 에디터 (Tailwind 변종).
54
+ * CodeMirror 자체의 .cm-* descendant 클래스는 Tailwind utility 로 표현 어려워
55
+ * <style> 태그로 한 번 inject — 컴포넌트의 outer wrapper 만 Tailwind utility.
56
+ */
57
+ export function CodeEditor({
58
+ value: valueProp, defaultValue, onChange, language = "text", placeholder, readOnly = false,
59
+ showLineNumbers = true, minHeight, maxHeight, className, id,
60
+ "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy,
61
+ }: CodeEditorProps) {
62
+ const isControlled = valueProp !== undefined;
63
+ const hostRef = useRef<HTMLDivElement>(null);
64
+ const viewRef = useRef<EditorView | null>(null);
65
+ const onChangeRef = useRef(onChange);
66
+ onChangeRef.current = onChange;
67
+ const initialDocRef = useRef(valueProp ?? defaultValue ?? "");
68
+
69
+ const compartments = useMemo(() => ({
70
+ language: new Compartment(),
71
+ readOnly: new Compartment(),
72
+ lineNumbers: new Compartment(),
73
+ placeholder: new Compartment(),
74
+ }), []);
75
+
76
+ useEffect(() => {
77
+ if (!hostRef.current) return;
78
+ const extensions: Extension[] = [
79
+ basicSetup,
80
+ EditorView.lineWrapping,
81
+ EditorView.updateListener.of((update) => {
82
+ if (update.docChanged) onChangeRef.current?.(update.state.doc.toString());
83
+ }),
84
+ compartments.language.of(languageExtension(language)),
85
+ compartments.readOnly.of(EditorState.readOnly.of(readOnly)),
86
+ compartments.lineNumbers.of(showLineNumbers ? [] : EditorView.theme({ ".cm-gutters": { display: "none" } })),
87
+ compartments.placeholder.of(placeholder ? placeholderExt(placeholder) : []),
88
+ ];
89
+ const view = new EditorView({
90
+ state: EditorState.create({ doc: initialDocRef.current, extensions }),
91
+ parent: hostRef.current,
92
+ });
93
+ viewRef.current = view;
94
+ return () => { view.destroy(); viewRef.current = null; };
95
+ // eslint-disable-next-line react-hooks/exhaustive-deps
96
+ }, []);
97
+
98
+ useEffect(() => {
99
+ if (!isControlled) return;
100
+ const view = viewRef.current;
101
+ if (!view) return;
102
+ const current = view.state.doc.toString();
103
+ if (current === valueProp) return;
104
+ view.dispatch({ changes: { from: 0, to: current.length, insert: valueProp ?? "" } });
105
+ }, [isControlled, valueProp]);
106
+
107
+ useEffect(() => {
108
+ viewRef.current?.dispatch({ effects: compartments.language.reconfigure(languageExtension(language)) });
109
+ }, [language, compartments.language]);
110
+
111
+ useEffect(() => {
112
+ viewRef.current?.dispatch({ effects: compartments.readOnly.reconfigure(EditorState.readOnly.of(readOnly)) });
113
+ }, [readOnly, compartments.readOnly]);
114
+
115
+ useEffect(() => {
116
+ viewRef.current?.dispatch({
117
+ effects: compartments.lineNumbers.reconfigure(showLineNumbers ? [] : EditorView.theme({ ".cm-gutters": { display: "none" } })),
118
+ });
119
+ }, [showLineNumbers, compartments.lineNumbers]);
120
+
121
+ useEffect(() => {
122
+ viewRef.current?.dispatch({
123
+ effects: compartments.placeholder.reconfigure(placeholder ? placeholderExt(placeholder) : []),
124
+ });
125
+ }, [placeholder, compartments.placeholder]);
126
+
127
+ useEffect(() => {
128
+ const view = viewRef.current;
129
+ if (!view) return;
130
+ const node = view.contentDOM;
131
+ if (id) node.id = id;
132
+ if (ariaLabel) node.setAttribute("aria-label", ariaLabel); else node.removeAttribute("aria-label");
133
+ if (ariaLabelledBy) node.setAttribute("aria-labelledby", ariaLabelledBy); else node.removeAttribute("aria-labelledby");
134
+ }, [id, ariaLabel, ariaLabelledBy]);
135
+
136
+ return (
137
+ <div
138
+ ref={hostRef}
139
+ className={cx(
140
+ "sh-ui-code-editor relative border border-border rounded-[var(--radius)] bg-background text-[0.8125rem] leading-relaxed overflow-hidden transition-[border-color] duration-[var(--duration-fast)] focus-within:border-foreground focus-within:outline-[length:var(--border-width-strong)] focus-within:outline-foreground focus-within:outline-offset-2 data-[readonly]:bg-background-subtle motion-reduce:transition-none",
141
+ className,
142
+ )}
143
+ data-readonly={readOnly || undefined}
144
+ style={{ "--sh-ui-code-editor-min-height": minHeight, "--sh-ui-code-editor-max-height": maxHeight } as React.CSSProperties}
145
+ />
146
+ );
147
+ }
148
+
149
+ if (typeof document !== "undefined" && !document.querySelector("style[data-sh-ui-code-editor]")) {
150
+ const style = document.createElement("style");
151
+ style.setAttribute("data-sh-ui-code-editor", "");
152
+ style.textContent = `
153
+ .sh-ui-code-editor .cm-editor { background: transparent; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
154
+ .sh-ui-code-editor .cm-editor.cm-focused { outline: none; }
155
+ .sh-ui-code-editor .cm-scroller { font-family: inherit; min-height: var(--sh-ui-code-editor-min-height, 7.5rem); max-height: var(--sh-ui-code-editor-max-height, 25rem); }
156
+ .sh-ui-code-editor .cm-content { caret-color: var(--foreground); color: var(--foreground); padding: var(--space-3) 0; }
157
+ .sh-ui-code-editor .cm-line { padding: 0 var(--space-3); }
158
+ .sh-ui-code-editor .cm-gutters { background: var(--background-subtle); color: var(--foreground-muted); border-right: 1px solid var(--border); }
159
+ .sh-ui-code-editor .cm-activeLineGutter, .sh-ui-code-editor .cm-activeLine { background: var(--background-muted); }
160
+ .sh-ui-code-editor .cm-cursor, .sh-ui-code-editor .cm-dropCursor { border-left-color: var(--foreground); }
161
+ .sh-ui-code-editor .cm-selectionBackground, .sh-ui-code-editor .cm-editor .cm-selectionBackground, .sh-ui-code-editor .cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .sh-ui-code-editor ::selection { background: var(--background-muted) !important; }
162
+ .sh-ui-code-editor .cm-placeholder { color: var(--foreground-muted); }
163
+ .sh-ui-code-editor .cm-tooltip { background: var(--background); border: 1px solid var(--border); color: var(--foreground); border-radius: calc(var(--radius) - 2px); }
164
+ .sh-ui-code-editor .cm-tooltip-autocomplete > ul > li[aria-selected] { background: var(--background-muted); color: var(--foreground); }
165
+ .sh-ui-code-editor .cm-matchingBracket, .sh-ui-code-editor .cm-nonmatchingBracket { background: var(--background-muted); color: var(--foreground); }
166
+ `;
167
+ document.head.appendChild(style);
168
+ }
@@ -0,0 +1,107 @@
1
+ import { codeToHtml } from "shiki";
2
+ import { CodePanelCopyButton } from "./copy";
3
+
4
+ function cx(...args: (string | undefined | false | null)[]) {
5
+ return args.filter(Boolean).join(" ");
6
+ }
7
+
8
+ export interface CodePanelProps
9
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
10
+ code?: string;
11
+ language?: string;
12
+ filename?: string;
13
+ showLineNumbers?: boolean;
14
+ hideCopy?: boolean;
15
+ children?: React.ReactNode;
16
+ }
17
+
18
+ const rootClasses =
19
+ "group relative border border-border rounded-[var(--radius)] bg-background-subtle overflow-hidden text-[0.8125rem] leading-relaxed my-[var(--space-4)] max-sm:text-[length:var(--text-xs)]";
20
+
21
+ export async function CodePanel({
22
+ code, language = "text", filename, showLineNumbers = true, hideCopy, className, children, ...rest
23
+ }: CodePanelProps) {
24
+ const classes = cx(rootClasses, className);
25
+
26
+ if (children !== undefined) {
27
+ return <div className={classes} {...rest}>{children}</div>;
28
+ }
29
+ if (code === undefined) throw new Error("CodePanel: `code` prop 또는 children 중 하나가 필요합니다.");
30
+
31
+ const trimmed = code.replace(/\n$/, "");
32
+
33
+ return (
34
+ <div className={classes} {...rest}>
35
+ {filename ? (
36
+ <CodePanelHeader>
37
+ <CodePanelFilename>{filename}</CodePanelFilename>
38
+ {!hideCopy && <CodePanelCopy code={trimmed} />}
39
+ </CodePanelHeader>
40
+ ) : (
41
+ !hideCopy && (
42
+ <div className="absolute top-[var(--space-2)] right-[var(--space-2)] z-[1] opacity-0 transition-opacity duration-[var(--duration-fast)] group-hover:opacity-100 group-focus-within:opacity-100">
43
+ <CodePanelCopy code={trimmed} />
44
+ </div>
45
+ )
46
+ )}
47
+ <CodePanelBody code={trimmed} language={language} showLineNumbers={showLineNumbers} />
48
+ </div>
49
+ );
50
+ }
51
+
52
+ export function CodePanelHeader({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
53
+ return (
54
+ <div
55
+ className={cx(
56
+ "flex items-center justify-between gap-[var(--space-2)] py-[var(--space-2)] pl-[var(--space-4)] pr-[var(--space-3)] border-b border-border bg-background-muted text-[length:var(--text-xs)] text-foreground-muted",
57
+ className,
58
+ )}
59
+ {...props}
60
+ >
61
+ {children}
62
+ </div>
63
+ );
64
+ }
65
+
66
+ export function CodePanelFilename({ className, children, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
67
+ return (
68
+ <span className={cx("font-mono text-foreground", className)} {...props}>{children}</span>
69
+ );
70
+ }
71
+
72
+ export interface CodePanelCopyProps { code: string; }
73
+ export function CodePanelCopy({ code }: CodePanelCopyProps) {
74
+ return <CodePanelCopyButton code={code} />;
75
+ }
76
+
77
+ export interface CodePanelBodyProps
78
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "dangerouslySetInnerHTML"> {
79
+ code: string;
80
+ language?: string;
81
+ showLineNumbers?: boolean;
82
+ }
83
+
84
+ export async function CodePanelBody({
85
+ code, language = "text", showLineNumbers = true, className, ...rest
86
+ }: CodePanelBodyProps) {
87
+ const trimmed = code.replace(/\n$/, "");
88
+ const html = await codeToHtml(trimmed, {
89
+ lang: language,
90
+ themes: { light: "github-light", dark: "github-dark" },
91
+ defaultColor: false,
92
+ });
93
+
94
+ return (
95
+ <div
96
+ className={cx(
97
+ "overflow-x-auto [&_pre]:m-0 [&_pre]:py-[var(--space-3)] [&_pre]:px-[var(--space-4)] [&_pre]:!bg-transparent [&_pre]:font-mono [&_pre]:text-[length:inherit] [&_pre]:leading-[inherit] [&_pre]:border-none [&_pre]:rounded-none [&_code]:bg-transparent [&_code]:p-0 [&_code]:text-[length:inherit] [&_code]:block [&_.shiki]:!text-[var(--shiki-light)] [&_.shiki_span]:!text-[var(--shiki-light)] [&_.shiki]:!bg-transparent [&_.shiki_span]:!bg-transparent [.dark_&_.shiki]:!text-[var(--shiki-dark)] [.dark_&_.shiki_span]:!text-[var(--shiki-dark)] data-[line-numbers]:[&_pre_code]:[counter-reset:step] data-[line-numbers]:[&_pre_code_.line]:before:[content:counter(step)] data-[line-numbers]:[&_pre_code_.line]:before:[counter-increment:step] data-[line-numbers]:[&_pre_code_.line]:before:inline-block data-[line-numbers]:[&_pre_code_.line]:before:w-7 data-[line-numbers]:[&_pre_code_.line]:before:mr-[var(--space-4)] data-[line-numbers]:[&_pre_code_.line]:before:text-right data-[line-numbers]:[&_pre_code_.line]:before:text-foreground-muted data-[line-numbers]:[&_pre_code_.line]:before:opacity-70 data-[line-numbers]:[&_pre_code_.line]:before:select-none",
98
+ className,
99
+ )}
100
+ data-line-numbers={showLineNumbers || undefined}
101
+ dangerouslySetInnerHTML={{ __html: html }}
102
+ {...rest}
103
+ />
104
+ );
105
+ }
106
+
107
+ export { CodePanelCopyButton };