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.
- package/data/changelog/versions.json +24 -0
- package/data/registry/react/components/accordion/index.tailwind.tsx +88 -0
- package/data/registry/react/components/avatar/index.tailwind.tsx +74 -0
- package/data/registry/react/components/badge/index.tailwind.tsx +47 -0
- package/data/registry/react/components/breadcrumb/index.tailwind.tsx +138 -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/checkbox/index.tailwind.tsx +72 -0
- package/data/registry/react/components/code-editor/index.tailwind.tsx +168 -0
- package/data/registry/react/components/code-panel/index.tailwind.tsx +107 -0
- package/data/registry/react/components/color-picker/index.tailwind.tsx +309 -0
- package/data/registry/react/components/combobox/index.tailwind.tsx +160 -0
- package/data/registry/react/components/context-menu/index.tailwind.tsx +170 -0
- package/data/registry/react/components/date-picker/index.tailwind.tsx +294 -0
- package/data/registry/react/components/dialog/index.tailwind.tsx +96 -0
- package/data/registry/react/components/dropdown-menu/index.tailwind.tsx +205 -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/label/index.tailwind.tsx +78 -0
- package/data/registry/react/components/markdown-editor/index.tailwind.tsx +118 -0
- package/data/registry/react/components/menubar/index.tailwind.tsx +32 -0
- package/data/registry/react/components/numeric-input/index.tailwind.tsx +113 -0
- package/data/registry/react/components/page-toc/index.tailwind.tsx +149 -0
- package/data/registry/react/components/pagination/index.tailwind.tsx +148 -0
- package/data/registry/react/components/popover/index.tailwind.tsx +77 -0
- package/data/registry/react/components/progress/index.tailwind.tsx +60 -0
- package/data/registry/react/components/radio/index.tailwind.tsx +54 -0
- package/data/registry/react/components/rich-text-editor/index.tailwind.tsx +211 -0
- package/data/registry/react/components/select/index.tailwind.tsx +199 -0
- package/data/registry/react/components/separator/index.tailwind.tsx +42 -0
- package/data/registry/react/components/sidebar/index.tailwind.tsx +635 -0
- package/data/registry/react/components/skeleton/index.tailwind.tsx +39 -0
- package/data/registry/react/components/slider/index.tailwind.tsx +255 -0
- package/data/registry/react/components/spinner/index.tailwind.tsx +63 -0
- package/data/registry/react/components/switch/index.tailwind.tsx +62 -0
- package/data/registry/react/components/tabs/index.tailwind.tsx +113 -0
- package/data/registry/react/components/textarea/index.tailwind.tsx +21 -0
- package/data/registry/react/components/toast/index.tailwind.tsx +215 -0
- package/data/registry/react/components/toggle/index.tailwind.tsx +111 -0
- package/data/registry/react/components/tooltip/index.tailwind.tsx +55 -0
- package/data/registry/react/registry.json +696 -98
- package/package.json +1 -1
- package/src/mcp.mjs +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { FormContext, FieldContext, SectionContext, StepContext, DisabledContext, useFormField } from "./context";
|
|
5
|
+
import type { FieldValidate, ValidateOn } from "./types";
|
|
6
|
+
import { scopedPath } from "./utils";
|
|
7
|
+
|
|
8
|
+
const fieldClass = "flex flex-col gap-[var(--space-1,0.25rem)] data-[disabled]:opacity-60 data-[disabled]:pointer-events-none";
|
|
9
|
+
const errorClass = "text-[var(--color-danger,#dc2626)] text-[length:var(--text-sm,0.875rem)] m-0 animate-[sh-ui-form-error-in_150ms_var(--easing-out,cubic-bezier(0.16,1,0.3,1))] motion-reduce:[animation-duration:0.01ms]";
|
|
10
|
+
|
|
11
|
+
export interface FieldProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
|
|
12
|
+
name: string;
|
|
13
|
+
validate?: FieldValidate;
|
|
14
|
+
validateOn?: ValidateOn;
|
|
15
|
+
required?: boolean;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
readOnly?: boolean;
|
|
18
|
+
children?: React.ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Field({ name, validate, validateOn, required, disabled, readOnly, className, children, ...rest }: FieldProps) {
|
|
22
|
+
const store = React.useContext(FormContext);
|
|
23
|
+
if (!store) throw new Error("<Form.Field> must be inside <Form>");
|
|
24
|
+
|
|
25
|
+
const section = React.useContext(SectionContext);
|
|
26
|
+
const step = React.useContext(StepContext);
|
|
27
|
+
const formDisabled = React.useContext(DisabledContext);
|
|
28
|
+
const path = scopedPath(section.path, name);
|
|
29
|
+
|
|
30
|
+
const id = React.useId();
|
|
31
|
+
const descId = `${id}-desc`;
|
|
32
|
+
const errorId = `${id}-error`;
|
|
33
|
+
|
|
34
|
+
React.useEffect(() => {
|
|
35
|
+
return store.registerField(path, {
|
|
36
|
+
validate, validateOn, stepId: step.id ?? undefined,
|
|
37
|
+
sectionPath: section.path || undefined, required,
|
|
38
|
+
});
|
|
39
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
40
|
+
}, [store, path]);
|
|
41
|
+
|
|
42
|
+
const effectiveDisabled = disabled || formDisabled;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<FieldContext.Provider value={{ path, id, descId, errorId, disabled: effectiveDisabled, readOnly, required }}>
|
|
46
|
+
<div
|
|
47
|
+
className={`${fieldClass}${className ? ` ${className}` : ""}`}
|
|
48
|
+
data-disabled={effectiveDisabled || undefined}
|
|
49
|
+
data-readonly={readOnly || undefined}
|
|
50
|
+
{...rest}
|
|
51
|
+
>
|
|
52
|
+
{children}
|
|
53
|
+
</div>
|
|
54
|
+
</FieldContext.Provider>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const FormLabel = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
|
|
59
|
+
({ className, ...props }, ref) => {
|
|
60
|
+
const ctx = React.useContext(FieldContext);
|
|
61
|
+
if (!ctx) throw new Error("<Form.Label> must be inside <Form.Field>");
|
|
62
|
+
return <label ref={ref} htmlFor={ctx.id} className={className} {...props} />;
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
FormLabel.displayName = "Form.Label";
|
|
66
|
+
|
|
67
|
+
export const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
|
68
|
+
({ className, ...props }, ref) => {
|
|
69
|
+
const ctx = React.useContext(FieldContext);
|
|
70
|
+
if (!ctx) throw new Error("<Form.Description> must be inside <Form.Field>");
|
|
71
|
+
return <p ref={ref} id={ctx.descId} className={className} {...props} />;
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
FormDescription.displayName = "Form.Description";
|
|
75
|
+
|
|
76
|
+
export interface FormErrorProps extends Omit<React.HTMLAttributes<HTMLParagraphElement>, "children"> {
|
|
77
|
+
children?: React.ReactNode | ((err: { message: string; type?: string }) => React.ReactNode);
|
|
78
|
+
matches?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function FormError({ children, matches, className, ...rest }: FormErrorProps) {
|
|
82
|
+
const ctx = React.useContext(FieldContext);
|
|
83
|
+
if (!ctx) throw new Error("<Form.Error> must be inside <Form.Field>");
|
|
84
|
+
const field = useFormField(ctx.path);
|
|
85
|
+
|
|
86
|
+
const err = field.error;
|
|
87
|
+
if (!err) return null;
|
|
88
|
+
if (matches && err.type !== matches) return null;
|
|
89
|
+
|
|
90
|
+
const content = typeof children === "function"
|
|
91
|
+
? (children as (e: { message: string; type?: string }) => React.ReactNode)(err)
|
|
92
|
+
: children ?? err.message;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<p
|
|
96
|
+
id={ctx.errorId}
|
|
97
|
+
className={`${errorClass}${className ? ` ${className}` : ""}`}
|
|
98
|
+
role="alert"
|
|
99
|
+
aria-live="polite"
|
|
100
|
+
{...rest}
|
|
101
|
+
>
|
|
102
|
+
{content}
|
|
103
|
+
</p>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ControlProps {
|
|
108
|
+
id: string; name: string;
|
|
109
|
+
value?: unknown; checked?: boolean;
|
|
110
|
+
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
|
|
111
|
+
onBlur: () => void;
|
|
112
|
+
"aria-invalid"?: true;
|
|
113
|
+
"aria-describedby"?: string;
|
|
114
|
+
"aria-required"?: true;
|
|
115
|
+
disabled?: boolean; readOnly?: boolean; required?: boolean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface FormControlProps {
|
|
119
|
+
children?: React.ReactElement;
|
|
120
|
+
valueAs?: "value" | "checked";
|
|
121
|
+
render?: (ctrl: ControlProps) => React.ReactElement;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function FormControl({ children, valueAs = "value", render }: FormControlProps) {
|
|
125
|
+
const ctx = React.useContext(FieldContext);
|
|
126
|
+
if (!ctx) throw new Error("<Form.Control> must be inside <Form.Field>");
|
|
127
|
+
const store = React.useContext(FormContext)!;
|
|
128
|
+
const field = useFormField(ctx.path);
|
|
129
|
+
|
|
130
|
+
const describedBy = [ctx.descId, field.hasError ? ctx.errorId : null].filter(Boolean).join(" ") || undefined;
|
|
131
|
+
|
|
132
|
+
const ctrl: ControlProps = {
|
|
133
|
+
id: ctx.id, name: ctx.path,
|
|
134
|
+
onChange: (e) => {
|
|
135
|
+
const target = e.target as HTMLInputElement;
|
|
136
|
+
const next = valueAs === "checked" ? target.checked : target.value;
|
|
137
|
+
store.setFieldValue(ctx.path, next);
|
|
138
|
+
if (store.getState().revalidateOnChange.has(ctx.path)) void store.validateField(ctx.path);
|
|
139
|
+
},
|
|
140
|
+
onBlur: () => {
|
|
141
|
+
store.setFieldTouched(ctx.path, true);
|
|
142
|
+
void store.validateField(ctx.path);
|
|
143
|
+
},
|
|
144
|
+
"aria-describedby": describedBy,
|
|
145
|
+
disabled: ctx.disabled, readOnly: ctx.readOnly, required: ctx.required,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (field.hasError) ctrl["aria-invalid"] = true;
|
|
149
|
+
if (ctx.required) ctrl["aria-required"] = true;
|
|
150
|
+
|
|
151
|
+
if (valueAs === "checked") ctrl.checked = Boolean(field.value);
|
|
152
|
+
else ctrl.value = field.value ?? "";
|
|
153
|
+
|
|
154
|
+
if (render) return render(ctrl);
|
|
155
|
+
if (!children) return null;
|
|
156
|
+
const child = React.Children.only(children);
|
|
157
|
+
return React.cloneElement(child, ctrl as unknown as Record<string, unknown>);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (typeof document !== "undefined" && !document.querySelector("style[data-sh-ui-form]")) {
|
|
161
|
+
const style = document.createElement("style");
|
|
162
|
+
style.setAttribute("data-sh-ui-form", "");
|
|
163
|
+
style.textContent = `@keyframes sh-ui-form-error-in { from { opacity: 0; transform: translateY(-4px) } to { opacity: 1; transform: translateY(0) } }`;
|
|
164
|
+
document.head.appendChild(style);
|
|
165
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { createFormStore, type CreateFormStoreOptions } from "./store";
|
|
5
|
+
import { FormContext, SectionContext, DisabledContext, useFormState } from "./context";
|
|
6
|
+
import type { FormStore, StandardSchemaV1 } from "./types";
|
|
7
|
+
import { scopedPath } from "./utils";
|
|
8
|
+
import { focusFirstError } from "./focus-first-error";
|
|
9
|
+
|
|
10
|
+
export interface FormProps<T = unknown>
|
|
11
|
+
extends Omit<React.FormHTMLAttributes<HTMLFormElement>, "onSubmit" | "onInvalid"> {
|
|
12
|
+
form?: FormStore<T>;
|
|
13
|
+
defaultValues?: CreateFormStoreOptions<T>["defaultValues"];
|
|
14
|
+
schema?: CreateFormStoreOptions<T>["schema"];
|
|
15
|
+
validateOn?: CreateFormStoreOptions<T>["validateOn"];
|
|
16
|
+
onSubmit?: CreateFormStoreOptions<T>["onSubmit"];
|
|
17
|
+
onInvalid?: CreateFormStoreOptions<T>["onInvalid"];
|
|
18
|
+
scrollToFirstError?: boolean;
|
|
19
|
+
focusFirstError?: boolean;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const formClass = "flex flex-col gap-[var(--space-4,1rem)]";
|
|
24
|
+
|
|
25
|
+
function FormInner<T>({
|
|
26
|
+
form: externalForm, defaultValues, schema, validateOn, onSubmit, onInvalid,
|
|
27
|
+
scrollToFirstError, focusFirstError: focusFirstErrorProp, disabled, children, ...rest
|
|
28
|
+
}: FormProps<T>) {
|
|
29
|
+
const parent = React.useContext(FormContext);
|
|
30
|
+
if (process.env.NODE_ENV !== "production" && parent) {
|
|
31
|
+
throw new Error("<Form> cannot be nested. For reusable field groups, use <Form.Section>-based components without a Form root.");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const internalStoreRef = React.useRef<FormStore<T> | null>(null);
|
|
35
|
+
if (!externalForm && !internalStoreRef.current) {
|
|
36
|
+
internalStoreRef.current = createFormStore<T>({
|
|
37
|
+
defaultValues, schema, validateOn, onSubmit, onInvalid,
|
|
38
|
+
scrollToFirstError, focusFirstError: focusFirstErrorProp,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const store = (externalForm ?? internalStoreRef.current) as FormStore<T>;
|
|
42
|
+
const formElRef = React.useRef<HTMLFormElement | null>(null);
|
|
43
|
+
|
|
44
|
+
const handleSubmit: React.FormEventHandler<HTMLFormElement> = (e) => {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
void (async () => {
|
|
47
|
+
await store.submit();
|
|
48
|
+
const s = store.getState();
|
|
49
|
+
const hasErrors = Object.values(s.errors).some(Boolean);
|
|
50
|
+
if (hasErrors && store._config.focusFirstError && formElRef.current) {
|
|
51
|
+
focusFirstError(store as unknown as FormStore<unknown>, formElRef.current, store._config.scrollToFirstError);
|
|
52
|
+
}
|
|
53
|
+
})();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<FormContext.Provider value={store as unknown as FormStore<unknown>}>
|
|
58
|
+
<DisabledContext.Provider value={disabled ?? false}>
|
|
59
|
+
<FormElement formElRef={formElRef} onSubmit={handleSubmit} noValidate {...rest}>
|
|
60
|
+
{children}
|
|
61
|
+
</FormElement>
|
|
62
|
+
</DisabledContext.Provider>
|
|
63
|
+
</FormContext.Provider>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function FormElement({ formElRef, children, onSubmit, className, ...rest }: {
|
|
68
|
+
formElRef: React.RefObject<HTMLFormElement | null>;
|
|
69
|
+
children?: React.ReactNode;
|
|
70
|
+
onSubmit: React.FormEventHandler<HTMLFormElement>;
|
|
71
|
+
} & Omit<React.FormHTMLAttributes<HTMLFormElement>, "onSubmit">) {
|
|
72
|
+
const state = useFormState();
|
|
73
|
+
return (
|
|
74
|
+
<form
|
|
75
|
+
ref={formElRef}
|
|
76
|
+
aria-busy={state.submitting || undefined}
|
|
77
|
+
onSubmit={onSubmit}
|
|
78
|
+
className={`${formClass}${className ? ` ${className}` : ""}`}
|
|
79
|
+
{...rest}
|
|
80
|
+
>
|
|
81
|
+
{children}
|
|
82
|
+
</form>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface FormSectionProps extends React.HTMLAttributes<HTMLElement> {
|
|
87
|
+
name?: string;
|
|
88
|
+
schema?: StandardSchemaV1;
|
|
89
|
+
as?: "div" | "fieldset";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const sectionClass = "flex flex-col gap-[var(--space-4,1rem)]";
|
|
93
|
+
|
|
94
|
+
function Section({ name, schema, as = "div", className, children, ...rest }: FormSectionProps) {
|
|
95
|
+
const parent = React.useContext(SectionContext);
|
|
96
|
+
const store = React.useContext(FormContext);
|
|
97
|
+
const path = scopedPath(parent.path, name);
|
|
98
|
+
|
|
99
|
+
React.useEffect(() => {
|
|
100
|
+
if (!schema || !store || !path) return;
|
|
101
|
+
return store.registerSectionSchema(path, schema);
|
|
102
|
+
}, [schema, store, path]);
|
|
103
|
+
|
|
104
|
+
const Tag = as as any;
|
|
105
|
+
const role = as === "div" ? "group" : undefined;
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<SectionContext.Provider value={{ path }}>
|
|
109
|
+
<Tag role={role} className={`${sectionClass}${className ? ` ${className}` : ""}`} {...rest}>
|
|
110
|
+
{children}
|
|
111
|
+
</Tag>
|
|
112
|
+
</SectionContext.Provider>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function SectionTitle({ children, ...rest }: React.HTMLAttributes<HTMLElement>) {
|
|
117
|
+
return <legend {...rest}>{children}</legend>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type FormType = typeof FormInner & {
|
|
121
|
+
Section: typeof Section;
|
|
122
|
+
SectionTitle: typeof SectionTitle;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const Form = FormInner as unknown as FormType;
|
|
126
|
+
Form.Section = Section;
|
|
127
|
+
Form.SectionTitle = SectionTitle;
|
|
128
|
+
|
|
129
|
+
export { Section, SectionTitle };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Form as FormRoot, Section, SectionTitle } from "./form";
|
|
4
|
+
import { Field, FormLabel, FormDescription, FormError, FormControl } from "./field";
|
|
5
|
+
import { Steps, Step } from "./steps";
|
|
6
|
+
|
|
7
|
+
type FormType = typeof FormRoot & {
|
|
8
|
+
Section: typeof Section;
|
|
9
|
+
SectionTitle: typeof SectionTitle;
|
|
10
|
+
Field: typeof Field;
|
|
11
|
+
Label: typeof FormLabel;
|
|
12
|
+
Description: typeof FormDescription;
|
|
13
|
+
Error: typeof FormError;
|
|
14
|
+
Control: typeof FormControl;
|
|
15
|
+
Steps: typeof Steps;
|
|
16
|
+
Step: typeof Step;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const Form = FormRoot as FormType;
|
|
20
|
+
Form.Section = Section;
|
|
21
|
+
Form.SectionTitle = SectionTitle;
|
|
22
|
+
Form.Field = Field;
|
|
23
|
+
Form.Label = FormLabel;
|
|
24
|
+
Form.Description = FormDescription;
|
|
25
|
+
Form.Error = FormError;
|
|
26
|
+
Form.Control = FormControl;
|
|
27
|
+
Form.Steps = Steps;
|
|
28
|
+
Form.Step = Step;
|
|
29
|
+
|
|
30
|
+
export { Form };
|
|
31
|
+
export { useShUiForm } from "./use-sh-ui-form";
|
|
32
|
+
export {
|
|
33
|
+
useFormContext,
|
|
34
|
+
useFormField,
|
|
35
|
+
useFormSection,
|
|
36
|
+
useFormState,
|
|
37
|
+
} from "./context";
|
|
38
|
+
export { useFormSteps } from "./steps";
|
|
39
|
+
export { createFormStore } from "./store";
|
|
40
|
+
export type {
|
|
41
|
+
FormStore,
|
|
42
|
+
FormStoreState,
|
|
43
|
+
FieldState,
|
|
44
|
+
FieldError,
|
|
45
|
+
FieldConfig,
|
|
46
|
+
FieldValidate,
|
|
47
|
+
ValidateOn,
|
|
48
|
+
StandardSchemaV1,
|
|
49
|
+
} from "./types";
|