sh-ui-cli 0.14.0 → 0.21.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/bin/sh-ui.mjs +6 -0
- package/data/changelog/versions.json +354 -0
- package/data/registry/flutter/foundation/sh_ui_tokens.dart +385 -0
- package/data/registry/flutter/registry.json +336 -0
- package/data/registry/flutter/widgets/sh_ui_accordion.dart +255 -0
- package/data/registry/flutter/widgets/sh_ui_app_shell.dart +267 -0
- package/data/registry/flutter/widgets/sh_ui_avatar.dart +95 -0
- package/data/registry/flutter/widgets/sh_ui_badge.dart +82 -0
- package/data/registry/flutter/widgets/sh_ui_breadcrumb.dart +107 -0
- package/data/registry/flutter/widgets/sh_ui_button.dart +201 -0
- package/data/registry/flutter/widgets/sh_ui_card.dart +159 -0
- package/data/registry/flutter/widgets/sh_ui_carousel.dart +204 -0
- package/data/registry/flutter/widgets/sh_ui_checkbox.dart +154 -0
- package/data/registry/flutter/widgets/sh_ui_color_picker.dart +264 -0
- package/data/registry/flutter/widgets/sh_ui_combobox.dart +614 -0
- package/data/registry/flutter/widgets/sh_ui_context_menu.dart +71 -0
- package/data/registry/flutter/widgets/sh_ui_date_picker.dart +648 -0
- package/data/registry/flutter/widgets/sh_ui_dialog.dart +567 -0
- package/data/registry/flutter/widgets/sh_ui_dropdown_menu.dart +251 -0
- package/data/registry/flutter/widgets/sh_ui_file_upload.dart +200 -0
- package/data/registry/flutter/widgets/sh_ui_header.dart +488 -0
- package/data/registry/flutter/widgets/sh_ui_input.dart +664 -0
- package/data/registry/flutter/widgets/sh_ui_label.dart +145 -0
- package/data/registry/flutter/widgets/sh_ui_menubar.dart +98 -0
- package/data/registry/flutter/widgets/sh_ui_pagination.dart +276 -0
- package/data/registry/flutter/widgets/sh_ui_popover.dart +248 -0
- package/data/registry/flutter/widgets/sh_ui_progress.dart +47 -0
- package/data/registry/flutter/widgets/sh_ui_radio.dart +108 -0
- package/data/registry/flutter/widgets/sh_ui_select.dart +904 -0
- package/data/registry/flutter/widgets/sh_ui_separator.dart +42 -0
- package/data/registry/flutter/widgets/sh_ui_sidebar.dart +1116 -0
- package/data/registry/flutter/widgets/sh_ui_skeleton.dart +129 -0
- package/data/registry/flutter/widgets/sh_ui_slider.dart +147 -0
- package/data/registry/flutter/widgets/sh_ui_spinner.dart +56 -0
- package/data/registry/flutter/widgets/sh_ui_switch.dart +109 -0
- package/data/registry/flutter/widgets/sh_ui_tabs.dart +329 -0
- package/data/registry/flutter/widgets/sh_ui_textarea.dart +126 -0
- package/data/registry/flutter/widgets/sh_ui_toast.dart +362 -0
- package/data/registry/flutter/widgets/sh_ui_toggle.dart +229 -0
- package/data/registry/flutter/widgets/sh_ui_tooltip.dart +62 -0
- package/data/registry/react/components/accordion/index.tsx +85 -0
- package/data/registry/react/components/accordion/styles.css +94 -0
- package/data/registry/react/components/animations/animations.css +51 -0
- package/data/registry/react/components/avatar/index.tsx +75 -0
- package/data/registry/react/components/avatar/styles.css +36 -0
- package/data/registry/react/components/badge/index.tsx +42 -0
- package/data/registry/react/components/badge/styles.css +57 -0
- package/data/registry/react/components/base/base.css +102 -0
- package/data/registry/react/components/breadcrumb/index.tsx +154 -0
- package/data/registry/react/components/breadcrumb/styles.css +82 -0
- package/data/registry/react/components/breakpoints/breakpoints.css +17 -0
- package/data/registry/react/components/button/index.tsx +47 -0
- package/data/registry/react/components/button/styles.css +93 -0
- package/data/registry/react/components/card/index.tsx +86 -0
- package/data/registry/react/components/card/styles.css +73 -0
- package/data/registry/react/components/carousel/index.tsx +432 -0
- package/data/registry/react/components/carousel/styles.css +155 -0
- package/data/registry/react/components/checkbox/index.tsx +98 -0
- package/data/registry/react/components/checkbox/styles.css +75 -0
- package/data/registry/react/components/code-panel/copy.tsx +56 -0
- package/data/registry/react/components/code-panel/index.tsx +193 -0
- package/data/registry/react/components/code-panel/styles.css +124 -0
- package/data/registry/react/components/color-picker/index.tsx +466 -0
- package/data/registry/react/components/color-picker/styles.css +166 -0
- package/data/registry/react/components/combobox/index.tsx +167 -0
- package/data/registry/react/components/combobox/styles.css +151 -0
- package/data/registry/react/components/context-menu/index.tsx +253 -0
- package/data/registry/react/components/context-menu/styles.css +140 -0
- package/data/registry/react/components/date-picker/index.tsx +757 -0
- package/data/registry/react/components/date-picker/styles.css +279 -0
- package/data/registry/react/components/dialog/index.tsx +97 -0
- package/data/registry/react/components/dialog/styles.css +127 -0
- package/data/registry/react/components/dropdown-menu/index.tsx +257 -0
- package/data/registry/react/components/dropdown-menu/styles.css +150 -0
- package/data/registry/react/components/file-upload/index.tsx +489 -0
- package/data/registry/react/components/file-upload/styles.css +170 -0
- package/data/registry/react/components/focus-ring/focus-ring.css +23 -0
- package/data/registry/react/components/form/context.ts +92 -0
- package/data/registry/react/components/form/field.test.tsx +230 -0
- package/data/registry/react/components/form/field.tsx +236 -0
- package/data/registry/react/components/form/focus-first-error.ts +54 -0
- package/data/registry/react/components/form/form.section.test.tsx +58 -0
- package/data/registry/react/components/form/form.test.tsx +146 -0
- package/data/registry/react/components/form/form.tsx +180 -0
- package/data/registry/react/components/form/index.tsx +61 -0
- package/data/registry/react/components/form/steps.test.tsx +106 -0
- package/data/registry/react/components/form/steps.tsx +193 -0
- package/data/registry/react/components/form/store.test.ts +206 -0
- package/data/registry/react/components/form/store.ts +318 -0
- package/data/registry/react/components/form/styles.css +47 -0
- package/data/registry/react/components/form/types.ts +104 -0
- package/data/registry/react/components/form/use-sh-ui-form.ts +15 -0
- package/data/registry/react/components/form/utils.test.ts +44 -0
- package/data/registry/react/components/form/utils.ts +49 -0
- package/data/registry/react/components/form/validation.test.ts +67 -0
- package/data/registry/react/components/form/validation.ts +64 -0
- package/data/registry/react/components/form-rhf/README.md +27 -0
- package/data/registry/react/components/form-rhf/index.tsx +289 -0
- package/data/registry/react/components/form-rhf/rhf.test.tsx +42 -0
- package/data/registry/react/components/form-tanstack/README.md +27 -0
- package/data/registry/react/components/form-tanstack/index.tsx +352 -0
- package/data/registry/react/components/form-tanstack/tanstack.test.tsx +45 -0
- package/data/registry/react/components/form-yup/README.md +22 -0
- package/data/registry/react/components/form-yup/index.tsx +50 -0
- package/data/registry/react/components/form-yup/yup.test.ts +27 -0
- package/data/registry/react/components/header/index.tsx +257 -0
- package/data/registry/react/components/header/styles.css +190 -0
- package/data/registry/react/components/input/index.tsx +517 -0
- package/data/registry/react/components/input/styles.css +203 -0
- package/data/registry/react/components/label/index.tsx +54 -0
- package/data/registry/react/components/label/styles.css +90 -0
- package/data/registry/react/components/menubar/index.tsx +34 -0
- package/data/registry/react/components/menubar/styles.css +45 -0
- package/data/registry/react/components/pagination/index.tsx +271 -0
- package/data/registry/react/components/pagination/styles.css +105 -0
- package/data/registry/react/components/popover/index.tsx +115 -0
- package/data/registry/react/components/popover/styles.css +65 -0
- package/data/registry/react/components/progress/index.tsx +56 -0
- package/data/registry/react/components/progress/styles.css +41 -0
- package/data/registry/react/components/radio/index.tsx +67 -0
- package/data/registry/react/components/radio/styles.css +80 -0
- package/data/registry/react/components/select/index.tsx +236 -0
- package/data/registry/react/components/select/styles.css +193 -0
- package/data/registry/react/components/separator/index.tsx +48 -0
- package/data/registry/react/components/separator/styles.css +15 -0
- package/data/registry/react/components/sidebar/index.tsx +1084 -0
- package/data/registry/react/components/sidebar/styles.css +502 -0
- package/data/registry/react/components/skeleton/index.tsx +24 -0
- package/data/registry/react/components/skeleton/styles.css +24 -0
- package/data/registry/react/components/slider/index.tsx +300 -0
- package/data/registry/react/components/slider/styles.css +64 -0
- package/data/registry/react/components/spinner/index.tsx +40 -0
- package/data/registry/react/components/spinner/styles.css +37 -0
- package/data/registry/react/components/switch/index.tsx +41 -0
- package/data/registry/react/components/switch/styles.css +83 -0
- package/data/registry/react/components/tabs/index.tsx +93 -0
- package/data/registry/react/components/tabs/styles.css +148 -0
- package/data/registry/react/components/textarea/index.tsx +25 -0
- package/data/registry/react/components/textarea/styles.css +54 -0
- package/data/registry/react/components/theme/index.tsx +91 -0
- package/data/registry/react/components/toast/index.tsx +257 -0
- package/data/registry/react/components/toast/styles.css +290 -0
- package/data/registry/react/components/toggle/index.tsx +133 -0
- package/data/registry/react/components/toggle/styles.css +85 -0
- package/data/registry/react/components/tooltip/index.tsx +85 -0
- package/data/registry/react/components/tooltip/styles.css +44 -0
- package/data/registry/react/components/z-index/z-index.css +16 -0
- package/data/registry/react/hooks/use-active-section.ts +104 -0
- package/data/registry/react/hooks/use-media-query.ts +27 -0
- package/data/registry/react/lib/cn.ts +39 -0
- package/data/registry/react/registry.json +835 -0
- package/data/summaries/flutter.json +42 -0
- package/data/summaries/react.json +50 -0
- package/data/tokens/build.mjs +553 -0
- package/data/tokens/src/primitives.json +146 -0
- package/data/tokens/src/semantic.json +146 -0
- package/package.json +13 -4
- package/src/add.mjs +13 -12
- package/src/list.mjs +3 -11
- package/src/mcp.mjs +308 -0
- package/src/paths.mjs +52 -0
- package/src/remove.mjs +4 -11
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import type { ReactFormExtendedApi } from "@tanstack/react-form";
|
|
2
|
+
import type {
|
|
3
|
+
FormStore,
|
|
4
|
+
FormStoreState,
|
|
5
|
+
FieldError,
|
|
6
|
+
FieldConfig,
|
|
7
|
+
StandardSchemaV1,
|
|
8
|
+
FormConfig,
|
|
9
|
+
} from "../form/types";
|
|
10
|
+
import { flatten, getByPath } from "../form/utils";
|
|
11
|
+
import { runFieldValidate } from "../form/validation";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* TanStack Form 의 `useForm()` 반환 타입은 12 개의 제네릭을 받는다. 우리 어댑터는
|
|
15
|
+
* `TFormData` 만 알면 충분하므로 나머지 11 개는 any 로 두어 사용자 측에서 캐스트
|
|
16
|
+
* 없이 바로 넘길 수 있게 한다. `T` 추론은 유지된다.
|
|
17
|
+
*/
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
|
+
type AnyTanStackForm<T> = ReactFormExtendedApi<T, any, any, any, any, any, any, any, any, any, any, any>;
|
|
20
|
+
|
|
21
|
+
// TanStack Form v1 API (store.subscribe 는 { unsubscribe } 를 반환) — 어댑터가 실제로 사용하는 멤버만 기술
|
|
22
|
+
interface TanStackFieldMeta {
|
|
23
|
+
isTouched?: boolean;
|
|
24
|
+
isDirty?: boolean;
|
|
25
|
+
errors?: string[];
|
|
26
|
+
errorMap?: Record<string, string | undefined>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface TanStackStoreState<T = Record<string, unknown>> {
|
|
30
|
+
values: T;
|
|
31
|
+
fieldMeta: Record<string, TanStackFieldMeta | undefined>;
|
|
32
|
+
isSubmitting?: boolean;
|
|
33
|
+
submissionAttempts?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface TanStackStore<T = Record<string, unknown>> {
|
|
37
|
+
subscribe(listener: () => void): { unsubscribe: () => void };
|
|
38
|
+
state: TanStackStoreState<T>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface TanStackFormApi<T = Record<string, unknown>> {
|
|
42
|
+
store: TanStackStore<T>;
|
|
43
|
+
setFieldValue: (name: string, value: unknown, opts?: { notify?: boolean }) => void;
|
|
44
|
+
setFieldMeta: (name: string, updater: (prev: TanStackFieldMeta) => TanStackFieldMeta) => void;
|
|
45
|
+
validateField: (name: string, cause: string) => Promise<unknown[]>;
|
|
46
|
+
handleSubmit: (opts?: unknown) => Promise<void>;
|
|
47
|
+
reset: (values?: Partial<T>) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface AdapterMeta {
|
|
51
|
+
fieldsByStep: Map<string, Set<string>>;
|
|
52
|
+
fieldsBySection: Map<string, Set<string>>;
|
|
53
|
+
fieldValidators: Map<string, FieldConfig>;
|
|
54
|
+
sectionSchemas: Map<string, StandardSchemaV1>;
|
|
55
|
+
activeStepId: string | null;
|
|
56
|
+
revalidateOnChange: Set<string>;
|
|
57
|
+
validatingFields: Set<string>;
|
|
58
|
+
/** sh-ui 자체 검증 에러 — TanStack fieldMeta 와 별도 관리 */
|
|
59
|
+
localErrors: Map<string, FieldError | undefined>;
|
|
60
|
+
/** sh-ui touched — TanStack fieldMeta.isTouched 와 별도 관리 */
|
|
61
|
+
localTouched: Map<string, boolean>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 마지막으로 계산된 스냅샷 캐시. React useSyncExternalStore 가 Object.is 비교를 사용하므로 동일 상태면 같은 레퍼런스를 반환해야 한다. */
|
|
65
|
+
interface SnapshotCache {
|
|
66
|
+
snapshot: FormStoreState | null;
|
|
67
|
+
version: number;
|
|
68
|
+
snapshotVersion: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface AdapterConfig<T> {
|
|
72
|
+
onSubmit?: (
|
|
73
|
+
values: T,
|
|
74
|
+
helpers: { reset: () => void; setError: (path: string, message: string) => void }
|
|
75
|
+
) => void | Promise<void>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* TanStack Form 인스턴스를 sh-ui FormStore 인터페이스로 감쌉니다.
|
|
80
|
+
*
|
|
81
|
+
* - TanStack Form 이 values 와 submit 상태의 state owner
|
|
82
|
+
* - sh-ui 검증 에러(localErrors)와 메타데이터는 어댑터 내부에서 독립 관리
|
|
83
|
+
* - TanStack fieldMeta 에도 errorMap 으로 동기화 (TanStack 자체 UI 와 호환)
|
|
84
|
+
* - notify() 가 React useSyncExternalStore 리스너를 직접 호출해 re-render 유발
|
|
85
|
+
*/
|
|
86
|
+
export function adaptTanStackForm<T extends Record<string, unknown>>(
|
|
87
|
+
tsForm: AnyTanStackForm<T>,
|
|
88
|
+
config: AdapterConfig<T> = {}
|
|
89
|
+
): FormStore<T> {
|
|
90
|
+
// 내부에선 좁은 interface 로 취급 — 실제 호출하는 멤버만 쓰므로 안전
|
|
91
|
+
const ts = tsForm as unknown as TanStackFormApi<T>;
|
|
92
|
+
const meta: AdapterMeta = {
|
|
93
|
+
fieldsByStep: new Map(),
|
|
94
|
+
fieldsBySection: new Map(),
|
|
95
|
+
fieldValidators: new Map(),
|
|
96
|
+
sectionSchemas: new Map(),
|
|
97
|
+
activeStepId: null,
|
|
98
|
+
revalidateOnChange: new Set(),
|
|
99
|
+
validatingFields: new Set(),
|
|
100
|
+
localErrors: new Map(),
|
|
101
|
+
localTouched: new Map(),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const cache: SnapshotCache = { snapshot: null, version: 0, snapshotVersion: -1 };
|
|
105
|
+
|
|
106
|
+
const listeners = new Set<() => void>();
|
|
107
|
+
const notify = () => {
|
|
108
|
+
cache.version++;
|
|
109
|
+
listeners.forEach((l) => l());
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// TanStack store.subscribe 는 { unsubscribe } 를 반환
|
|
113
|
+
let tsSub: { unsubscribe: () => void } | undefined;
|
|
114
|
+
let tsSubscribed = false;
|
|
115
|
+
|
|
116
|
+
const startTsSubscription = () => {
|
|
117
|
+
tsSub = ts.store.subscribe(() => notify());
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const store: FormStore<T> = {
|
|
121
|
+
subscribe(listener) {
|
|
122
|
+
listeners.add(listener);
|
|
123
|
+
if (!tsSubscribed) {
|
|
124
|
+
tsSubscribed = true;
|
|
125
|
+
startTsSubscription();
|
|
126
|
+
}
|
|
127
|
+
return () => {
|
|
128
|
+
listeners.delete(listener);
|
|
129
|
+
if (listeners.size === 0) {
|
|
130
|
+
tsSubscribed = false;
|
|
131
|
+
try {
|
|
132
|
+
tsSub?.unsubscribe();
|
|
133
|
+
} catch {}
|
|
134
|
+
tsSub = undefined;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
getState(): FormStoreState {
|
|
140
|
+
// 동일 version 이면 캐시 반환 — useSyncExternalStore Object.is 비교 통과
|
|
141
|
+
if (cache.snapshot !== null && cache.snapshotVersion === cache.version) {
|
|
142
|
+
return cache.snapshot;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const s = ts.store.state;
|
|
146
|
+
const rawValues = s.values ?? ({} as T);
|
|
147
|
+
const values = flatten(rawValues as Record<string, unknown>);
|
|
148
|
+
|
|
149
|
+
// 에러와 touched 는 localErrors/localTouched 에서 읽는다
|
|
150
|
+
// (TanStack 자체 fieldMeta 는 참고용이고 sh-ui re-render 트리거가 아님)
|
|
151
|
+
const errors: Record<string, FieldError | undefined> = {};
|
|
152
|
+
const touched: Record<string, boolean> = {};
|
|
153
|
+
|
|
154
|
+
for (const [path, err] of meta.localErrors) {
|
|
155
|
+
if (err) errors[path] = err;
|
|
156
|
+
}
|
|
157
|
+
for (const [path, t] of meta.localTouched) {
|
|
158
|
+
touched[path] = t;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const snapshot: FormStoreState = {
|
|
162
|
+
values,
|
|
163
|
+
errors,
|
|
164
|
+
touched,
|
|
165
|
+
submitting: Boolean(s.isSubmitting),
|
|
166
|
+
submitCount: Number(s.submissionAttempts ?? 0),
|
|
167
|
+
activeStepId: meta.activeStepId,
|
|
168
|
+
fieldsByStep: meta.fieldsByStep,
|
|
169
|
+
fieldsBySection: meta.fieldsBySection,
|
|
170
|
+
fieldValidators: meta.fieldValidators,
|
|
171
|
+
sectionSchemas: meta.sectionSchemas,
|
|
172
|
+
validatingFields: meta.validatingFields,
|
|
173
|
+
revalidateOnChange: meta.revalidateOnChange,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
cache.snapshot = snapshot;
|
|
177
|
+
cache.snapshotVersion = cache.version;
|
|
178
|
+
return snapshot;
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
getFieldState(path) {
|
|
182
|
+
const state = store.getState();
|
|
183
|
+
const err = state.errors[path];
|
|
184
|
+
return {
|
|
185
|
+
value: state.values[path],
|
|
186
|
+
error: err,
|
|
187
|
+
errors: err ? [err] : [],
|
|
188
|
+
touched: !!state.touched[path],
|
|
189
|
+
isValidating: meta.validatingFields.has(path),
|
|
190
|
+
hasError: !!err,
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
setFieldValue(path, value) {
|
|
195
|
+
ts.setFieldValue(path as keyof T extends string ? keyof T : string, value);
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
setFieldTouched(path, isTouched) {
|
|
199
|
+
meta.localTouched.set(path, isTouched);
|
|
200
|
+
// TanStack 에도 동기화
|
|
201
|
+
try {
|
|
202
|
+
ts.setFieldMeta(path, (m) => ({ ...(m ?? {}), isTouched }));
|
|
203
|
+
} catch {}
|
|
204
|
+
notify();
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
registerField(path, cfg) {
|
|
208
|
+
meta.fieldValidators.set(path, cfg);
|
|
209
|
+
if (cfg.stepId) {
|
|
210
|
+
const s = meta.fieldsByStep.get(cfg.stepId) ?? new Set<string>();
|
|
211
|
+
s.add(path);
|
|
212
|
+
meta.fieldsByStep.set(cfg.stepId, s);
|
|
213
|
+
}
|
|
214
|
+
if (cfg.sectionPath) {
|
|
215
|
+
const s = meta.fieldsBySection.get(cfg.sectionPath) ?? new Set<string>();
|
|
216
|
+
s.add(path);
|
|
217
|
+
meta.fieldsBySection.set(cfg.sectionPath, s);
|
|
218
|
+
}
|
|
219
|
+
notify();
|
|
220
|
+
return () => {
|
|
221
|
+
meta.fieldValidators.delete(path);
|
|
222
|
+
meta.localErrors.delete(path);
|
|
223
|
+
meta.localTouched.delete(path);
|
|
224
|
+
if (cfg.stepId) meta.fieldsByStep.get(cfg.stepId)?.delete(path);
|
|
225
|
+
if (cfg.sectionPath) meta.fieldsBySection.get(cfg.sectionPath)?.delete(path);
|
|
226
|
+
notify();
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
registerStep(stepId) {
|
|
231
|
+
if (!meta.fieldsByStep.has(stepId)) {
|
|
232
|
+
meta.fieldsByStep.set(stepId, new Set());
|
|
233
|
+
}
|
|
234
|
+
notify();
|
|
235
|
+
return () => {};
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
setActiveStep(stepId) {
|
|
239
|
+
meta.activeStepId = stepId;
|
|
240
|
+
notify();
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
registerSectionSchema(sectionPath, schema) {
|
|
244
|
+
meta.sectionSchemas.set(sectionPath, schema);
|
|
245
|
+
notify();
|
|
246
|
+
return () => {
|
|
247
|
+
meta.sectionSchemas.delete(sectionPath);
|
|
248
|
+
notify();
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
async validateField(path) {
|
|
253
|
+
const cfg = meta.fieldValidators.get(path);
|
|
254
|
+
const rawValues = ts.store.state.values as Record<string, unknown>;
|
|
255
|
+
const value = getByPath(rawValues, path);
|
|
256
|
+
const err = await runFieldValidate(cfg?.validate, value, rawValues);
|
|
257
|
+
|
|
258
|
+
if (err) {
|
|
259
|
+
meta.localErrors.set(path, err);
|
|
260
|
+
// TanStack 에도 동기화 (errorMap 을 통해야 errors[] 에 반영됨)
|
|
261
|
+
try {
|
|
262
|
+
ts.setFieldMeta(path, (m) => ({
|
|
263
|
+
...(m ?? {}),
|
|
264
|
+
errorMap: { ...(m?.errorMap ?? {}), onBlur: err.message },
|
|
265
|
+
}));
|
|
266
|
+
} catch {}
|
|
267
|
+
meta.revalidateOnChange.add(path);
|
|
268
|
+
notify();
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
meta.localErrors.delete(path);
|
|
273
|
+
try {
|
|
274
|
+
ts.setFieldMeta(path, (m) => ({
|
|
275
|
+
...(m ?? {}),
|
|
276
|
+
errorMap: { ...(m?.errorMap ?? {}), onBlur: undefined },
|
|
277
|
+
}));
|
|
278
|
+
} catch {}
|
|
279
|
+
meta.revalidateOnChange.delete(path);
|
|
280
|
+
notify();
|
|
281
|
+
return true;
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
async validateStep(stepId) {
|
|
285
|
+
const fields = Array.from(meta.fieldsByStep.get(stepId) ?? []);
|
|
286
|
+
if (fields.length === 0) return true;
|
|
287
|
+
const results = await Promise.all(fields.map((f) => store.validateField(f)));
|
|
288
|
+
return results.every(Boolean);
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
async validateAll() {
|
|
292
|
+
const paths = Array.from(meta.fieldValidators.keys());
|
|
293
|
+
const results = await Promise.all(paths.map((p) => store.validateField(p)));
|
|
294
|
+
return results.every(Boolean);
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
getValues<S = T>(scope?: string): S {
|
|
298
|
+
const v = ts.store.state.values as unknown;
|
|
299
|
+
return (scope ? getByPath(v, scope) : v) as S;
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
async submit() {
|
|
303
|
+
await ts.handleSubmit();
|
|
304
|
+
if (config?.onSubmit) {
|
|
305
|
+
await config.onSubmit(ts.store.state.values as T, {
|
|
306
|
+
reset: () => {
|
|
307
|
+
ts.reset();
|
|
308
|
+
meta.localErrors.clear();
|
|
309
|
+
meta.localTouched.clear();
|
|
310
|
+
},
|
|
311
|
+
setError: (p, m) => {
|
|
312
|
+
meta.localErrors.set(p, { message: m, source: "validate" });
|
|
313
|
+
try {
|
|
314
|
+
ts.setFieldMeta(p, (fm) => ({
|
|
315
|
+
...(fm ?? {}),
|
|
316
|
+
errorMap: { ...(fm?.errorMap ?? {}), onSubmit: m },
|
|
317
|
+
}));
|
|
318
|
+
} catch {}
|
|
319
|
+
notify();
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
reset(defaults) {
|
|
326
|
+
ts.reset(defaults);
|
|
327
|
+
meta.localErrors.clear();
|
|
328
|
+
meta.localTouched.clear();
|
|
329
|
+
meta.revalidateOnChange.clear();
|
|
330
|
+
notify();
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
setError(path, message) {
|
|
334
|
+
meta.localErrors.set(path, { message, source: "validate" });
|
|
335
|
+
try {
|
|
336
|
+
ts.setFieldMeta(path, (m) => ({
|
|
337
|
+
...(m ?? {}),
|
|
338
|
+
errorMap: { ...(m?.errorMap ?? {}), onSubmit: message },
|
|
339
|
+
}));
|
|
340
|
+
} catch {}
|
|
341
|
+
notify();
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
_config: {
|
|
345
|
+
validateOn: "blur",
|
|
346
|
+
scrollToFirstError: true,
|
|
347
|
+
focusFirstError: true,
|
|
348
|
+
} as FormConfig<T>,
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
return store;
|
|
352
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { useForm } from "@tanstack/react-form";
|
|
6
|
+
import { Form } from "../form";
|
|
7
|
+
import { Field } from "../form/field";
|
|
8
|
+
import { FormControl, FormError } from "../form/field";
|
|
9
|
+
import { adaptTanStackForm } from "./index";
|
|
10
|
+
|
|
11
|
+
function TestForm() {
|
|
12
|
+
const ts = useForm({
|
|
13
|
+
defaultValues: { email: "" },
|
|
14
|
+
onSubmit: async () => {},
|
|
15
|
+
});
|
|
16
|
+
const form = adaptTanStackForm(ts);
|
|
17
|
+
return (
|
|
18
|
+
<Form form={form}>
|
|
19
|
+
<Field name="email" validate={(v) => (String(v).includes("@") ? undefined : "bad")}>
|
|
20
|
+
<FormControl><input data-testid="i" /></FormControl>
|
|
21
|
+
<FormError />
|
|
22
|
+
</Field>
|
|
23
|
+
<button type="submit">go</button>
|
|
24
|
+
</Form>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("adaptTanStackForm", () => {
|
|
29
|
+
it("value change via Form.Control updates TanStack state", async () => {
|
|
30
|
+
const user = userEvent.setup();
|
|
31
|
+
render(<TestForm />);
|
|
32
|
+
const input = screen.getByTestId("i") as HTMLInputElement;
|
|
33
|
+
await user.type(input, "a@b.com");
|
|
34
|
+
expect(input.value).toBe("a@b.com");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("validation error from sh-ui validate shows under field", async () => {
|
|
38
|
+
const user = userEvent.setup();
|
|
39
|
+
render(<TestForm />);
|
|
40
|
+
const input = screen.getByTestId("i") as HTMLInputElement;
|
|
41
|
+
await user.type(input, "nope");
|
|
42
|
+
await user.tab(); // tab away to trigger blur via userEvent
|
|
43
|
+
await screen.findByText("bad");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# form-yup
|
|
2
|
+
|
|
3
|
+
Yup 스키마를 sh-ui Form 에 붙이기 위한 Standard Schema v1 래퍼.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i yup
|
|
9
|
+
sh-ui add form-yup
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## 사용
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
import * as yup from "yup";
|
|
16
|
+
import { yupSchema } from "@/components/ui/form-yup";
|
|
17
|
+
import { Form } from "@/components/ui/form";
|
|
18
|
+
|
|
19
|
+
const schema = yupSchema(yup.object({ email: yup.string().required() }));
|
|
20
|
+
|
|
21
|
+
<Form schema={schema}>...</Form>
|
|
22
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "../form/types";
|
|
2
|
+
|
|
3
|
+
// yup 의 schema / ValidationError 는 느슨하게 정의 (peerDep — 직접 import 하지 않음)
|
|
4
|
+
interface YupLikeSchema<T = unknown> {
|
|
5
|
+
validate(value: unknown, opts?: { abortEarly?: boolean }): Promise<T>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface YupValidationError {
|
|
9
|
+
inner: Array<{ path?: string; message: string }>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* yup 스키마를 sh-ui Form이 사용하는 Standard Schema(v1)로 감싼다.
|
|
14
|
+
* yup은 peerDependency라 직접 import하지 않으므로, 호출 측에서 만든 스키마를 그대로 넘기면 된다.
|
|
15
|
+
*
|
|
16
|
+
* @param schema - yup 스키마 객체 (`yup.object({...}).required()` 등).
|
|
17
|
+
* @returns Form의 `validation` prop에 그대로 넘길 수 있는 Standard Schema.
|
|
18
|
+
* @example
|
|
19
|
+
* import * as yup from "yup";
|
|
20
|
+
* import { yupSchema } from "@/components/ui/form-yup";
|
|
21
|
+
*
|
|
22
|
+
* const validation = yupSchema(yup.object({
|
|
23
|
+
* email: yup.string().email().required(),
|
|
24
|
+
* }));
|
|
25
|
+
*
|
|
26
|
+
* <Form validation={validation}>...</Form>
|
|
27
|
+
*/
|
|
28
|
+
export function yupSchema<T>(schema: YupLikeSchema<T>): StandardSchemaV1<T> {
|
|
29
|
+
return {
|
|
30
|
+
"~standard": {
|
|
31
|
+
version: 1,
|
|
32
|
+
vendor: "yup",
|
|
33
|
+
validate: async (value: unknown) => {
|
|
34
|
+
try {
|
|
35
|
+
const parsed = await schema.validate(value, { abortEarly: false });
|
|
36
|
+
return { value: parsed };
|
|
37
|
+
} catch (e) {
|
|
38
|
+
const err = e as YupValidationError;
|
|
39
|
+
if (!err.inner) return { issues: [{ message: String(e) }] };
|
|
40
|
+
return {
|
|
41
|
+
issues: err.inner.map((i) => ({
|
|
42
|
+
path: i.path ? i.path.split(".") : undefined,
|
|
43
|
+
message: i.message,
|
|
44
|
+
})),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { yupSchema } from "./index";
|
|
3
|
+
|
|
4
|
+
const mockYupObj = {
|
|
5
|
+
async validate(value: unknown, opts?: { abortEarly?: boolean }) {
|
|
6
|
+
if ((value as any).email) return value;
|
|
7
|
+
const err: any = new Error("validation");
|
|
8
|
+
err.inner = [{ path: "email", message: "required" }];
|
|
9
|
+
throw err;
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe("yupSchema", () => {
|
|
14
|
+
it("wraps yup schema into Standard Schema v1", async () => {
|
|
15
|
+
const schema = yupSchema(mockYupObj);
|
|
16
|
+
const r = await schema["~standard"].validate({ email: "" });
|
|
17
|
+
if ("value" in r) throw new Error("should fail");
|
|
18
|
+
expect(r.issues[0]).toMatchObject({ path: ["email"], message: "required" });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("passes through on success", async () => {
|
|
22
|
+
const schema = yupSchema(mockYupObj);
|
|
23
|
+
const r = await schema["~standard"].validate({ email: "ok" });
|
|
24
|
+
if (!("value" in r)) throw new Error("should pass");
|
|
25
|
+
expect(r.value).toEqual({ email: "ok" });
|
|
26
|
+
});
|
|
27
|
+
});
|