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.
@@ -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,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
+ }