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,309 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
interface HSV { h: number; s: number; v: number; }
|
|
6
|
+
interface HSVA extends HSV { a: number; }
|
|
7
|
+
|
|
8
|
+
export interface ColorPickerProps
|
|
9
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue" | "children"> {
|
|
10
|
+
value?: string;
|
|
11
|
+
onChange?: (hex: string) => void;
|
|
12
|
+
defaultValue?: string;
|
|
13
|
+
children?: React.ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function clamp(n: number, min: number, max: number) { return Math.min(max, Math.max(min, n)); }
|
|
17
|
+
|
|
18
|
+
function hexToRgb(hex: string): [number, number, number] {
|
|
19
|
+
const m = hex.replace("#", "");
|
|
20
|
+
const full = m.length === 3 ? m.split("").map((c) => c + c).join("") : m;
|
|
21
|
+
const n = parseInt(full, 16);
|
|
22
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
23
|
+
}
|
|
24
|
+
function rgbToHex(r: number, g: number, b: number): string {
|
|
25
|
+
const toHex = (n: number) => clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0");
|
|
26
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
|
27
|
+
}
|
|
28
|
+
function rgbToHsv(r: number, g: number, b: number): HSV {
|
|
29
|
+
const rn = r / 255, gn = g / 255, bn = b / 255;
|
|
30
|
+
const max = Math.max(rn, gn, bn); const min = Math.min(rn, gn, bn);
|
|
31
|
+
const d = max - min; let h = 0;
|
|
32
|
+
if (d !== 0) {
|
|
33
|
+
if (max === rn) h = ((gn - bn) / d) % 6;
|
|
34
|
+
else if (max === gn) h = (bn - rn) / d + 2;
|
|
35
|
+
else h = (rn - gn) / d + 4;
|
|
36
|
+
h *= 60; if (h < 0) h += 360;
|
|
37
|
+
}
|
|
38
|
+
const s = max === 0 ? 0 : d / max; const v = max;
|
|
39
|
+
return { h, s, v };
|
|
40
|
+
}
|
|
41
|
+
function hsvToRgb({ h, s, v }: HSV): [number, number, number] {
|
|
42
|
+
const c = v * s; const hh = h / 60;
|
|
43
|
+
const x = c * (1 - Math.abs((hh % 2) - 1));
|
|
44
|
+
let r = 0, g = 0, b = 0;
|
|
45
|
+
if (hh >= 0 && hh < 1) [r, g, b] = [c, x, 0];
|
|
46
|
+
else if (hh < 2) [r, g, b] = [x, c, 0];
|
|
47
|
+
else if (hh < 3) [r, g, b] = [0, c, x];
|
|
48
|
+
else if (hh < 4) [r, g, b] = [0, x, c];
|
|
49
|
+
else if (hh < 5) [r, g, b] = [x, 0, c];
|
|
50
|
+
else [r, g, b] = [c, 0, x];
|
|
51
|
+
const m = v - c;
|
|
52
|
+
return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
|
|
53
|
+
}
|
|
54
|
+
function hexToHsv(hex: string): HSV { const [r, g, b] = hexToRgb(hex); return rgbToHsv(r, g, b); }
|
|
55
|
+
function hsvToHex(hsv: HSV): string { const [r, g, b] = hsvToRgb(hsv); return rgbToHex(r, g, b); }
|
|
56
|
+
|
|
57
|
+
const HEX_RE = /^#?[0-9a-f]{6}$/i;
|
|
58
|
+
|
|
59
|
+
function useDrag(onMove: (e: PointerEvent, el: HTMLElement) => void) {
|
|
60
|
+
const ref = React.useRef<HTMLDivElement>(null);
|
|
61
|
+
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
62
|
+
const el = ref.current; if (!el) return;
|
|
63
|
+
el.setPointerCapture(e.pointerId);
|
|
64
|
+
onMove(e.nativeEvent, el);
|
|
65
|
+
const onPointerMove = (ev: PointerEvent) => onMove(ev, el);
|
|
66
|
+
const onPointerUp = (ev: PointerEvent) => {
|
|
67
|
+
el.releasePointerCapture(ev.pointerId);
|
|
68
|
+
el.removeEventListener("pointermove", onPointerMove);
|
|
69
|
+
el.removeEventListener("pointerup", onPointerUp);
|
|
70
|
+
};
|
|
71
|
+
el.addEventListener("pointermove", onPointerMove);
|
|
72
|
+
el.addEventListener("pointerup", onPointerUp);
|
|
73
|
+
};
|
|
74
|
+
return { ref, onPointerDown };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ColorPickerContextValue {
|
|
78
|
+
hsva: HSVA; hex: string; pureHueHex: string;
|
|
79
|
+
setHsv: (next: Partial<HSV>) => void;
|
|
80
|
+
setAlpha: (a: number) => void;
|
|
81
|
+
commitHex: (raw: string) => boolean;
|
|
82
|
+
}
|
|
83
|
+
const ColorPickerContext = React.createContext<ColorPickerContextValue | null>(null);
|
|
84
|
+
function useColorPicker() {
|
|
85
|
+
const ctx = React.useContext(ColorPickerContext);
|
|
86
|
+
if (!ctx) throw new Error("ColorPicker 하위 컴포넌트는 <ColorPicker> 내부에서만 사용할 수 있습니다.");
|
|
87
|
+
return ctx;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function ColorPicker({
|
|
91
|
+
value: valueProp, onChange, defaultValue = "#000000", className, children, ...rest
|
|
92
|
+
}: ColorPickerProps) {
|
|
93
|
+
const isControlled = valueProp !== undefined;
|
|
94
|
+
const [internal, setInternal] = React.useState(defaultValue);
|
|
95
|
+
const value = isControlled ? valueProp! : internal;
|
|
96
|
+
const [hsva, setHsva] = React.useState<HSVA>(() => ({ ...hexToHsv(value), a: 1 }));
|
|
97
|
+
|
|
98
|
+
const lastEmittedRef = React.useRef(value);
|
|
99
|
+
React.useEffect(() => {
|
|
100
|
+
if (value === lastEmittedRef.current) return;
|
|
101
|
+
setHsva((prev) => ({ ...hexToHsv(value), a: prev.a }));
|
|
102
|
+
}, [value]);
|
|
103
|
+
|
|
104
|
+
const setHsv = React.useCallback((partial: Partial<HSV>) => {
|
|
105
|
+
const next: HSVA = { ...hsva, ...partial };
|
|
106
|
+
const hex = hsvToHex(next);
|
|
107
|
+
lastEmittedRef.current = hex;
|
|
108
|
+
setHsva(next);
|
|
109
|
+
if (!isControlled) setInternal(hex);
|
|
110
|
+
onChange?.(hex);
|
|
111
|
+
}, [hsva, isControlled, onChange]);
|
|
112
|
+
|
|
113
|
+
const setAlpha = React.useCallback((a: number) => {
|
|
114
|
+
setHsva((prev) => ({ ...prev, a: clamp(a, 0, 1) }));
|
|
115
|
+
}, []);
|
|
116
|
+
|
|
117
|
+
const commitHex = React.useCallback((raw: string) => {
|
|
118
|
+
const v = raw.trim();
|
|
119
|
+
if (!HEX_RE.test(v)) return false;
|
|
120
|
+
const normalized = (v.startsWith("#") ? v : `#${v}`).toUpperCase();
|
|
121
|
+
const nextHsv = hexToHsv(normalized);
|
|
122
|
+
const next: HSVA = { ...nextHsv, a: hsva.a };
|
|
123
|
+
const hex = hsvToHex(next);
|
|
124
|
+
lastEmittedRef.current = hex;
|
|
125
|
+
setHsva(next);
|
|
126
|
+
if (!isControlled) setInternal(hex);
|
|
127
|
+
onChange?.(hex);
|
|
128
|
+
return true;
|
|
129
|
+
}, [hsva.a, isControlled, onChange]);
|
|
130
|
+
|
|
131
|
+
const pureHueHex = React.useMemo(() => hsvToHex({ h: hsva.h, s: 1, v: 1 }), [hsva.h]);
|
|
132
|
+
const ctx = React.useMemo<ColorPickerContextValue>(
|
|
133
|
+
() => ({ hsva, hex: value, pureHueHex, setHsv, setAlpha, commitHex }),
|
|
134
|
+
[hsva, value, pureHueHex, setHsv, setAlpha, commitHex],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<ColorPickerContext.Provider value={ctx}>
|
|
139
|
+
<div className={["flex flex-col gap-2.5 w-full select-none", className].filter(Boolean).join(" ")} {...rest}>
|
|
140
|
+
{children ?? (<><ColorPickerSaturation /><ColorPickerHue /><ColorPickerHex /></>)}
|
|
141
|
+
</div>
|
|
142
|
+
</ColorPickerContext.Provider>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface ColorPickerSaturationProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onPointerDown"> {}
|
|
147
|
+
|
|
148
|
+
export function ColorPickerSaturation({ className, style, ...rest }: ColorPickerSaturationProps) {
|
|
149
|
+
const { hsva, hex, pureHueHex, setHsv } = useColorPicker();
|
|
150
|
+
const drag = useDrag((e, el) => {
|
|
151
|
+
const r = el.getBoundingClientRect();
|
|
152
|
+
const x = clamp((e.clientX - r.left) / r.width, 0, 1);
|
|
153
|
+
const y = clamp((e.clientY - r.top) / r.height, 0, 1);
|
|
154
|
+
setHsv({ s: x, v: 1 - y });
|
|
155
|
+
});
|
|
156
|
+
return (
|
|
157
|
+
<div
|
|
158
|
+
ref={drag.ref}
|
|
159
|
+
onPointerDown={drag.onPointerDown}
|
|
160
|
+
className={["relative w-full aspect-[4/3] rounded-[var(--radius)] cursor-crosshair overflow-hidden touch-none", className].filter(Boolean).join(" ")}
|
|
161
|
+
style={{ background: pureHueHex, ...style }}
|
|
162
|
+
role="slider"
|
|
163
|
+
aria-label="채도/명도"
|
|
164
|
+
aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(hsva.s * 100)}
|
|
165
|
+
{...rest}
|
|
166
|
+
>
|
|
167
|
+
<div className="absolute inset-0 bg-[linear-gradient(to_right,#fff,transparent)]" />
|
|
168
|
+
<div className="absolute inset-0 bg-[linear-gradient(to_top,#000,transparent)]" />
|
|
169
|
+
<div
|
|
170
|
+
className="absolute w-3.5 h-3.5 -ml-[0.4375rem] -mt-[0.4375rem] border-2 border-white rounded-full shadow-[0_0_0_1px_rgba(0,0,0,0.4)] pointer-events-none"
|
|
171
|
+
style={{ left: `${hsva.s * 100}%`, top: `${(1 - hsva.v) * 100}%`, background: hex }}
|
|
172
|
+
/>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface ColorPickerHueProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onPointerDown"> {}
|
|
178
|
+
|
|
179
|
+
export function ColorPickerHue({ className, ...rest }: ColorPickerHueProps) {
|
|
180
|
+
const { hsva, setHsv } = useColorPicker();
|
|
181
|
+
const drag = useDrag((e, el) => {
|
|
182
|
+
const r = el.getBoundingClientRect();
|
|
183
|
+
const x = clamp((e.clientX - r.left) / r.width, 0, 1);
|
|
184
|
+
setHsv({ h: x * 360 });
|
|
185
|
+
});
|
|
186
|
+
return (
|
|
187
|
+
<div
|
|
188
|
+
ref={drag.ref}
|
|
189
|
+
onPointerDown={drag.onPointerDown}
|
|
190
|
+
className={["relative w-full h-3.5 rounded-full cursor-pointer touch-none bg-[linear-gradient(to_right,#f00_0%,#ff0_16.66%,#0f0_33.33%,#0ff_50%,#00f_66.66%,#f0f_83.33%,#f00_100%)]", className].filter(Boolean).join(" ")}
|
|
191
|
+
role="slider"
|
|
192
|
+
aria-label="색조"
|
|
193
|
+
aria-valuemin={0} aria-valuemax={360} aria-valuenow={Math.round(hsva.h)}
|
|
194
|
+
{...rest}
|
|
195
|
+
>
|
|
196
|
+
<div
|
|
197
|
+
className="absolute top-1/2 w-3.5 h-3.5 -ml-[0.4375rem] -translate-y-1/2 bg-white rounded-full shadow-[0_0_0_1px_rgba(0,0,0,0.4)] pointer-events-none"
|
|
198
|
+
style={{ left: `${(hsva.h / 360) * 100}%` }}
|
|
199
|
+
/>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface ColorPickerAlphaProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onPointerDown"> {}
|
|
205
|
+
|
|
206
|
+
export function ColorPickerAlpha({ className, style, ...rest }: ColorPickerAlphaProps) {
|
|
207
|
+
const { hsva, hex, setAlpha } = useColorPicker();
|
|
208
|
+
const drag = useDrag((e, el) => {
|
|
209
|
+
const r = el.getBoundingClientRect();
|
|
210
|
+
const x = clamp((e.clientX - r.left) / r.width, 0, 1);
|
|
211
|
+
setAlpha(x);
|
|
212
|
+
});
|
|
213
|
+
const gradient = `linear-gradient(to right, rgba(0,0,0,0) 0%, ${hex} 100%)`;
|
|
214
|
+
return (
|
|
215
|
+
<div
|
|
216
|
+
ref={drag.ref}
|
|
217
|
+
onPointerDown={drag.onPointerDown}
|
|
218
|
+
className={["relative w-full h-3.5 rounded-full cursor-pointer touch-none overflow-hidden bg-white bg-[length:8px_8px] bg-[position:0_0,0_4px,4px_-4px,-4px_0] [background-image:linear-gradient(45deg,#ccc_25%,transparent_25%),linear-gradient(-45deg,#ccc_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#ccc_75%),linear-gradient(-45deg,transparent_75%,#ccc_75%)]", className].filter(Boolean).join(" ")}
|
|
219
|
+
role="slider"
|
|
220
|
+
aria-label="투명도"
|
|
221
|
+
aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(hsva.a * 100)}
|
|
222
|
+
style={style}
|
|
223
|
+
{...rest}
|
|
224
|
+
>
|
|
225
|
+
<div className="absolute inset-0 rounded-[inherit] pointer-events-none" style={{ backgroundImage: gradient }} />
|
|
226
|
+
<div
|
|
227
|
+
className="absolute top-1/2 w-3.5 h-3.5 -ml-[0.4375rem] -translate-y-1/2 bg-white rounded-full shadow-[0_0_0_1px_rgba(0,0,0,0.4)] pointer-events-none"
|
|
228
|
+
style={{ left: `${hsva.a * 100}%` }}
|
|
229
|
+
/>
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface ColorPickerHexProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
235
|
+
showSwatch?: boolean;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function ColorPickerHex({ className, showSwatch = true, ...rest }: ColorPickerHexProps) {
|
|
239
|
+
const { hex, commitHex } = useColorPicker();
|
|
240
|
+
const [draft, setDraft] = React.useState(hex);
|
|
241
|
+
|
|
242
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
243
|
+
React.useEffect(() => {
|
|
244
|
+
if (document.activeElement !== inputRef.current) setDraft(hex);
|
|
245
|
+
}, [hex]);
|
|
246
|
+
|
|
247
|
+
const onCommit = () => { if (!commitHex(draft)) setDraft(hex); };
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<div
|
|
251
|
+
className={["flex items-center gap-[var(--space-2)]", className].filter(Boolean).join(" ")}
|
|
252
|
+
{...rest}
|
|
253
|
+
>
|
|
254
|
+
{showSwatch && (
|
|
255
|
+
<div
|
|
256
|
+
className="w-7 h-7 rounded-[calc(var(--radius)-2px)] border border-border shrink-0"
|
|
257
|
+
style={{ background: hex }}
|
|
258
|
+
aria-hidden
|
|
259
|
+
/>
|
|
260
|
+
)}
|
|
261
|
+
<input
|
|
262
|
+
ref={inputRef}
|
|
263
|
+
type="text"
|
|
264
|
+
className="flex-1 min-w-0 h-7 px-[var(--space-2)] border border-border rounded-[calc(var(--radius)-2px)] bg-background text-foreground font-mono text-[0.8125rem] uppercase focus:outline-none focus:border-foreground focus:shadow-[0_0_0_1px_var(--foreground)]"
|
|
265
|
+
value={draft}
|
|
266
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
267
|
+
onBlur={onCommit}
|
|
268
|
+
onKeyDown={(e) => {
|
|
269
|
+
if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLInputElement).blur(); }
|
|
270
|
+
}}
|
|
271
|
+
spellCheck={false}
|
|
272
|
+
aria-label="Hex"
|
|
273
|
+
/>
|
|
274
|
+
</div>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface ColorPickerSwatchesProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
279
|
+
colors: string[];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function ColorPickerSwatches({ className, colors, ...rest }: ColorPickerSwatchesProps) {
|
|
283
|
+
const { hex, commitHex } = useColorPicker();
|
|
284
|
+
return (
|
|
285
|
+
<div
|
|
286
|
+
role="group"
|
|
287
|
+
aria-label="미리 준비된 색상"
|
|
288
|
+
className={["flex flex-wrap gap-1.5", className].filter(Boolean).join(" ")}
|
|
289
|
+
{...rest}
|
|
290
|
+
>
|
|
291
|
+
{colors.map((c) => {
|
|
292
|
+
const normalized = c.toUpperCase();
|
|
293
|
+
const selected = normalized === hex.toUpperCase();
|
|
294
|
+
return (
|
|
295
|
+
<button
|
|
296
|
+
key={c}
|
|
297
|
+
type="button"
|
|
298
|
+
className="w-5 h-5 p-0 border border-border rounded-[calc(var(--radius)-4px)] cursor-pointer transition-[transform,box-shadow] duration-[var(--duration-fast)] hover:scale-110 focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 data-[selected]:shadow-[0_0_0_2px_var(--background),0_0_0_3.5px_var(--foreground)]"
|
|
299
|
+
aria-label={c}
|
|
300
|
+
aria-pressed={selected}
|
|
301
|
+
data-selected={selected || undefined}
|
|
302
|
+
style={{ background: c }}
|
|
303
|
+
onClick={() => commitHex(c)}
|
|
304
|
+
/>
|
|
305
|
+
);
|
|
306
|
+
})}
|
|
307
|
+
</div>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
function cx(...args: (string | false | undefined)[]) {
|
|
6
|
+
return args.filter(Boolean).join(" ");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function formatBytes(bytes: number): string {
|
|
10
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
11
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
12
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
13
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FileUploadContextValue {
|
|
17
|
+
files: File[];
|
|
18
|
+
dragging: boolean;
|
|
19
|
+
disabled: boolean;
|
|
20
|
+
multiple: boolean;
|
|
21
|
+
accept?: string;
|
|
22
|
+
id?: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
inputRef: React.RefObject<HTMLInputElement | null>;
|
|
25
|
+
setDragging: (v: boolean) => void;
|
|
26
|
+
addFiles: (incoming: FileList | File[]) => void;
|
|
27
|
+
remove: (idx: number) => void;
|
|
28
|
+
openPicker: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const FileUploadContext = React.createContext<FileUploadContextValue | null>(null);
|
|
32
|
+
|
|
33
|
+
function useFileUpload() {
|
|
34
|
+
const ctx = React.useContext(FileUploadContext);
|
|
35
|
+
if (!ctx) throw new Error("FileUpload 하위 컴포넌트는 <FileUpload> 내부에서만 사용할 수 있습니다.");
|
|
36
|
+
return ctx;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FileUploadProps {
|
|
40
|
+
value?: File[]; defaultValue?: File[];
|
|
41
|
+
onValueChange?: (files: File[]) => void;
|
|
42
|
+
onFiles?: (files: File[]) => void;
|
|
43
|
+
multiple?: boolean; accept?: string;
|
|
44
|
+
maxSize?: number; maxFiles?: number;
|
|
45
|
+
disabled?: boolean;
|
|
46
|
+
onError?: (message: string) => void;
|
|
47
|
+
placeholder?: React.ReactNode; hint?: React.ReactNode;
|
|
48
|
+
showFileList?: boolean;
|
|
49
|
+
className?: string;
|
|
50
|
+
style?: React.CSSProperties;
|
|
51
|
+
id?: string; name?: string;
|
|
52
|
+
children?: React.ReactNode;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const FileUpload = React.forwardRef<HTMLInputElement, FileUploadProps>(
|
|
56
|
+
({ value, defaultValue, onValueChange, onFiles, multiple = false, accept, maxSize, maxFiles, disabled = false, onError, placeholder, hint, showFileList = true, className, style, id, name, children }, ref) => {
|
|
57
|
+
const isControlled = value !== undefined;
|
|
58
|
+
const [internal, setInternal] = React.useState<File[]>(defaultValue ?? []);
|
|
59
|
+
const files = isControlled ? value! : internal;
|
|
60
|
+
|
|
61
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
62
|
+
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
|
|
63
|
+
const [dragging, setDragging] = React.useState(false);
|
|
64
|
+
|
|
65
|
+
const update = React.useCallback((next: File[]) => {
|
|
66
|
+
if (!isControlled) setInternal(next);
|
|
67
|
+
onValueChange?.(next);
|
|
68
|
+
onFiles?.(next);
|
|
69
|
+
}, [isControlled, onValueChange, onFiles]);
|
|
70
|
+
|
|
71
|
+
const addFiles = React.useCallback((incoming: FileList | File[]) => {
|
|
72
|
+
const arr = Array.from(incoming);
|
|
73
|
+
const accepted: File[] = [];
|
|
74
|
+
for (const f of arr) {
|
|
75
|
+
if (maxSize && f.size > maxSize) {
|
|
76
|
+
onError?.(`${f.name}: 최대 ${formatBytes(maxSize)}까지 업로드 가능합니다.`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
accepted.push(f);
|
|
80
|
+
}
|
|
81
|
+
if (accepted.length === 0) return;
|
|
82
|
+
let next = multiple ? [...files, ...accepted] : [accepted[accepted.length - 1]];
|
|
83
|
+
if (maxFiles && next.length > maxFiles) {
|
|
84
|
+
onError?.(`최대 ${maxFiles}개까지 업로드 가능합니다.`);
|
|
85
|
+
next = next.slice(0, maxFiles);
|
|
86
|
+
}
|
|
87
|
+
update(next);
|
|
88
|
+
}, [files, maxSize, maxFiles, multiple, onError, update]);
|
|
89
|
+
|
|
90
|
+
const remove = React.useCallback((idx: number) => update(files.filter((_, i) => i !== idx)), [files, update]);
|
|
91
|
+
const openPicker = React.useCallback(() => {
|
|
92
|
+
if (disabled) return;
|
|
93
|
+
inputRef.current?.click();
|
|
94
|
+
}, [disabled]);
|
|
95
|
+
|
|
96
|
+
const ctx = React.useMemo<FileUploadContextValue>(() => ({
|
|
97
|
+
files, dragging, disabled, multiple, accept, id, name, inputRef,
|
|
98
|
+
setDragging, addFiles, remove, openPicker,
|
|
99
|
+
}), [files, dragging, disabled, multiple, accept, id, name, addFiles, remove, openPicker]);
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<FileUploadContext.Provider value={ctx}>
|
|
103
|
+
<div className={cx("flex flex-col gap-[var(--space-3)]", className)} style={style}>
|
|
104
|
+
<input
|
|
105
|
+
ref={inputRef}
|
|
106
|
+
id={id}
|
|
107
|
+
name={name}
|
|
108
|
+
type="file"
|
|
109
|
+
multiple={multiple}
|
|
110
|
+
accept={accept}
|
|
111
|
+
disabled={disabled}
|
|
112
|
+
className="absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap border-0 [clip:rect(0,0,0,0)]"
|
|
113
|
+
onChange={(e) => {
|
|
114
|
+
if (e.target.files) addFiles(e.target.files);
|
|
115
|
+
e.target.value = "";
|
|
116
|
+
}}
|
|
117
|
+
/>
|
|
118
|
+
{children ?? <DefaultLayout placeholder={placeholder} hint={hint} showFileList={showFileList} />}
|
|
119
|
+
</div>
|
|
120
|
+
</FileUploadContext.Provider>
|
|
121
|
+
);
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
FileUpload.displayName = "FileUpload";
|
|
125
|
+
|
|
126
|
+
function DefaultLayout({ placeholder, hint, showFileList }: { placeholder?: React.ReactNode; hint?: React.ReactNode; showFileList: boolean }) {
|
|
127
|
+
const { files } = useFileUpload();
|
|
128
|
+
return (
|
|
129
|
+
<>
|
|
130
|
+
<FileUploadDropzone>
|
|
131
|
+
<UploadIcon />
|
|
132
|
+
<div className="text-[length:var(--text-sm)] text-foreground [&_strong]:font-semibold">
|
|
133
|
+
{placeholder ?? <><strong>파일을 드래그</strong>하거나 <strong>클릭해서 선택</strong></>}
|
|
134
|
+
</div>
|
|
135
|
+
{hint && <div className="text-[length:var(--text-xs)] text-foreground-muted">{hint}</div>}
|
|
136
|
+
</FileUploadDropzone>
|
|
137
|
+
{showFileList && files.length > 0 && <FileUploadList />}
|
|
138
|
+
</>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface FileUploadDropzoneProps
|
|
143
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onDrop" | "onDragOver" | "onDragLeave"> {
|
|
144
|
+
children?: React.ReactNode;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const FileUploadDropzone = React.forwardRef<HTMLDivElement, FileUploadDropzoneProps>(
|
|
148
|
+
function FileUploadDropzone({ className, children, onClick, ...rest }, ref) {
|
|
149
|
+
const { dragging, disabled, setDragging, addFiles, openPicker } = useFileUpload();
|
|
150
|
+
return (
|
|
151
|
+
<div
|
|
152
|
+
ref={ref}
|
|
153
|
+
role="button"
|
|
154
|
+
tabIndex={disabled ? -1 : 0}
|
|
155
|
+
aria-disabled={disabled || undefined}
|
|
156
|
+
data-dragging={dragging || undefined}
|
|
157
|
+
className={cx(
|
|
158
|
+
"relative flex flex-col items-center justify-center gap-[var(--space-2)] py-[var(--space-8)] px-[var(--space-6)] min-h-40 bg-background-subtle text-foreground-muted border-[1.5px] border-dashed border-border rounded-[var(--radius)] cursor-pointer text-center transition-[border-color,background-color,color] duration-[var(--duration-fast)] hover:border-border-strong hover:text-foreground focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 focus-visible:border-foreground motion-reduce:transition-none",
|
|
159
|
+
dragging && "border-foreground bg-background-muted text-foreground",
|
|
160
|
+
disabled && "opacity-[var(--opacity-disabled)] cursor-not-allowed pointer-events-none",
|
|
161
|
+
className,
|
|
162
|
+
)}
|
|
163
|
+
onClick={(e) => { onClick?.(e); if (!e.defaultPrevented) openPicker(); }}
|
|
164
|
+
onKeyDown={(e) => {
|
|
165
|
+
if (disabled) return;
|
|
166
|
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openPicker(); }
|
|
167
|
+
}}
|
|
168
|
+
onDragOver={(e) => { e.preventDefault(); if (!disabled) setDragging(true); }}
|
|
169
|
+
onDragLeave={() => setDragging(false)}
|
|
170
|
+
onDrop={(e) => {
|
|
171
|
+
e.preventDefault(); setDragging(false);
|
|
172
|
+
if (disabled) return;
|
|
173
|
+
addFiles(e.dataTransfer.files);
|
|
174
|
+
}}
|
|
175
|
+
{...rest}
|
|
176
|
+
>
|
|
177
|
+
{children}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
export interface FileUploadTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
|
184
|
+
|
|
185
|
+
export const FileUploadTrigger = React.forwardRef<HTMLButtonElement, FileUploadTriggerProps>(
|
|
186
|
+
function FileUploadTrigger({ className, onClick, children, type, ...rest }, ref) {
|
|
187
|
+
const { disabled, openPicker } = useFileUpload();
|
|
188
|
+
return (
|
|
189
|
+
<button
|
|
190
|
+
ref={ref}
|
|
191
|
+
type={type ?? "button"}
|
|
192
|
+
disabled={disabled || rest.disabled}
|
|
193
|
+
className={cx(
|
|
194
|
+
"inline-flex items-center justify-center gap-[var(--space-2)] py-[var(--space-2)] px-[var(--space-3)] text-[length:var(--text-sm)] font-medium text-foreground bg-background border border-border rounded-[calc(var(--radius)-2px)] cursor-pointer transition-[background-color,border-color] duration-[var(--duration-fast)] hover:not-disabled:bg-background-muted hover:not-disabled:border-border-strong focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 disabled:opacity-[var(--opacity-disabled)] disabled:cursor-not-allowed",
|
|
195
|
+
className,
|
|
196
|
+
)}
|
|
197
|
+
onClick={(e) => {
|
|
198
|
+
e.stopPropagation();
|
|
199
|
+
onClick?.(e);
|
|
200
|
+
if (!e.defaultPrevented) openPicker();
|
|
201
|
+
}}
|
|
202
|
+
{...rest}
|
|
203
|
+
>
|
|
204
|
+
{children}
|
|
205
|
+
</button>
|
|
206
|
+
);
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
export interface FileUploadListProps extends Omit<React.HTMLAttributes<HTMLUListElement>, "children"> {
|
|
211
|
+
children?: React.ReactNode | ((args: { files: File[]; remove: (idx: number) => void }) => React.ReactNode);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export const FileUploadList = React.forwardRef<HTMLUListElement, FileUploadListProps>(
|
|
215
|
+
function FileUploadList({ className, children, ...rest }, ref) {
|
|
216
|
+
const { files, remove } = useFileUpload();
|
|
217
|
+
if (files.length === 0) return null;
|
|
218
|
+
const content = typeof children === "function"
|
|
219
|
+
? children({ files, remove })
|
|
220
|
+
: (children ?? files.map((f, i) => <FileUploadItem key={`${f.name}-${i}`} file={f} index={i} />));
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<ul ref={ref} className={cx("list-none m-0 p-0 flex flex-col gap-1.5", className)} {...rest}>
|
|
224
|
+
{content}
|
|
225
|
+
</ul>
|
|
226
|
+
);
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
export interface FileUploadItemProps extends Omit<React.LiHTMLAttributes<HTMLLIElement>, "children"> {
|
|
231
|
+
file: File;
|
|
232
|
+
index: number;
|
|
233
|
+
children?: React.ReactNode;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export const FileUploadItem = React.forwardRef<HTMLLIElement, FileUploadItemProps>(
|
|
237
|
+
function FileUploadItem({ file, index, className, children, ...rest }, ref) {
|
|
238
|
+
const { disabled, remove } = useFileUpload();
|
|
239
|
+
return (
|
|
240
|
+
<li
|
|
241
|
+
ref={ref}
|
|
242
|
+
className={cx(
|
|
243
|
+
"flex items-center gap-2.5 py-[var(--space-2)] px-[var(--space-3)] bg-background border border-border rounded-[calc(var(--radius)-2px)] text-[length:var(--text-sm)] text-foreground [&>svg]:text-foreground-muted [&>svg]:shrink-0",
|
|
244
|
+
className,
|
|
245
|
+
)}
|
|
246
|
+
{...rest}
|
|
247
|
+
>
|
|
248
|
+
{children ?? (
|
|
249
|
+
<>
|
|
250
|
+
<FileIcon />
|
|
251
|
+
<span className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap" title={file.name}>{file.name}</span>
|
|
252
|
+
<span className="text-[length:var(--text-xs)] text-foreground-muted shrink-0">{formatBytes(file.size)}</span>
|
|
253
|
+
<button
|
|
254
|
+
type="button"
|
|
255
|
+
className="inline-flex items-center justify-center w-6 h-6 p-0 bg-transparent border-none rounded-[calc(var(--radius)-4px)] text-foreground-muted cursor-pointer transition-[color,background-color] duration-[var(--duration-fast)] shrink-0 hover:not-disabled:text-foreground 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-[var(--opacity-disabled)] disabled:cursor-not-allowed motion-reduce:transition-none"
|
|
256
|
+
onClick={() => remove(index)}
|
|
257
|
+
disabled={disabled}
|
|
258
|
+
aria-label={`${file.name} 제거`}
|
|
259
|
+
>
|
|
260
|
+
<XIcon />
|
|
261
|
+
</button>
|
|
262
|
+
</>
|
|
263
|
+
)}
|
|
264
|
+
</li>
|
|
265
|
+
);
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
function UploadIcon() {
|
|
270
|
+
return (
|
|
271
|
+
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" aria-hidden>
|
|
272
|
+
<path d="M12 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
273
|
+
</svg>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
function FileIcon() {
|
|
277
|
+
return (
|
|
278
|
+
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" aria-hidden>
|
|
279
|
+
<path d="M5 2.5h6l4 4v9a1.5 1.5 0 0 1-1.5 1.5h-8.5A1.5 1.5 0 0 1 3.5 15.5v-11A1.5 1.5 0 0 1 5 3Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
|
280
|
+
<path d="M11 2.5v4h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
|
281
|
+
</svg>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
function XIcon() {
|
|
285
|
+
return (
|
|
286
|
+
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden>
|
|
287
|
+
<path d="M4 4l8 8m0-8l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
288
|
+
</svg>
|
|
289
|
+
);
|
|
290
|
+
}
|