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,206 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { createFormStore } from "./store";
|
|
3
|
+
|
|
4
|
+
describe("createFormStore — basic", () => {
|
|
5
|
+
it("starts with empty state + defaults merged", () => {
|
|
6
|
+
const store = createFormStore({ defaultValues: { a: 1, b: { c: 2 } } });
|
|
7
|
+
expect(store.getState().values).toEqual({ "a": 1, "b.c": 2 });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("setFieldValue updates flat values and notifies", () => {
|
|
11
|
+
const store = createFormStore();
|
|
12
|
+
const listener = vi.fn();
|
|
13
|
+
store.subscribe(listener);
|
|
14
|
+
store.setFieldValue("name", "sh");
|
|
15
|
+
expect(store.getState().values["name"]).toBe("sh");
|
|
16
|
+
expect(listener).toHaveBeenCalled();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("getFieldState returns value + touched + error snapshot", () => {
|
|
20
|
+
const store = createFormStore({ defaultValues: { name: "a" } });
|
|
21
|
+
const s = store.getFieldState("name");
|
|
22
|
+
expect(s.value).toBe("a");
|
|
23
|
+
expect(s.touched).toBe(false);
|
|
24
|
+
expect(s.hasError).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("getValues returns nested", () => {
|
|
28
|
+
const store = createFormStore({ defaultValues: { profile: { name: "a" } } });
|
|
29
|
+
expect(store.getValues()).toEqual({ profile: { name: "a" } });
|
|
30
|
+
expect(store.getValues("profile")).toEqual({ name: "a" });
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("registerField + validateField", () => {
|
|
35
|
+
it("registers validator and unregisters on dispose", () => {
|
|
36
|
+
const store = createFormStore();
|
|
37
|
+
const unregister = store.registerField("email", { validate: (v) => (v ? undefined : "required") });
|
|
38
|
+
expect(store.getState().fieldValidators.has("email")).toBe(true);
|
|
39
|
+
unregister();
|
|
40
|
+
expect(store.getState().fieldValidators.has("email")).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("validateField sets error on fail", async () => {
|
|
44
|
+
const store = createFormStore();
|
|
45
|
+
store.registerField("email", { validate: (v) => (v ? undefined : "required") });
|
|
46
|
+
store.setFieldValue("email", "");
|
|
47
|
+
const ok = await store.validateField("email");
|
|
48
|
+
expect(ok).toBe(false);
|
|
49
|
+
expect(store.getState().errors["email"]?.message).toBe("required");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("validateField clears error on pass", async () => {
|
|
53
|
+
const store = createFormStore();
|
|
54
|
+
store.registerField("email", { validate: (v) => (v ? undefined : "required") });
|
|
55
|
+
store.setFieldValue("email", "");
|
|
56
|
+
await store.validateField("email");
|
|
57
|
+
store.setFieldValue("email", "a@b.com");
|
|
58
|
+
const ok = await store.validateField("email");
|
|
59
|
+
expect(ok).toBe(true);
|
|
60
|
+
expect(store.getState().errors["email"]).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("steps", () => {
|
|
65
|
+
it("registerStep tracks step id, activates on setActiveStep", () => {
|
|
66
|
+
const store = createFormStore();
|
|
67
|
+
store.registerStep("a");
|
|
68
|
+
store.registerStep("b");
|
|
69
|
+
store.setActiveStep("a");
|
|
70
|
+
expect(store.getState().activeStepId).toBe("a");
|
|
71
|
+
store.setActiveStep("b");
|
|
72
|
+
expect(store.getState().activeStepId).toBe("b");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("validateStep validates only fields registered with stepId", async () => {
|
|
76
|
+
const store = createFormStore();
|
|
77
|
+
store.registerField("email", {
|
|
78
|
+
stepId: "account",
|
|
79
|
+
validate: (v) => (v ? undefined : "required"),
|
|
80
|
+
});
|
|
81
|
+
store.registerField("name", {
|
|
82
|
+
stepId: "profile",
|
|
83
|
+
validate: (v) => (v ? undefined : "required"),
|
|
84
|
+
});
|
|
85
|
+
const ok = await store.validateStep("account");
|
|
86
|
+
expect(ok).toBe(false);
|
|
87
|
+
expect(store.getState().errors["email"]).toBeDefined();
|
|
88
|
+
expect(store.getState().errors["name"]).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("section schema & priority", () => {
|
|
93
|
+
const stdSchema = (path: string[], msg: string) => ({
|
|
94
|
+
"~standard": {
|
|
95
|
+
version: 1 as const,
|
|
96
|
+
vendor: "test",
|
|
97
|
+
validate: (v: any) => ({ issues: [{ path, message: msg }] }),
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("section schema produces error for fields under its path", async () => {
|
|
102
|
+
const store = createFormStore();
|
|
103
|
+
store.registerField("profile.name", { sectionPath: "profile" });
|
|
104
|
+
store.registerSectionSchema("profile", stdSchema(["name"], "section says bad"));
|
|
105
|
+
await store.validateField("profile.name");
|
|
106
|
+
expect(store.getState().errors["profile.name"]?.message).toBe("section says bad");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("root schema validates at root, only relevant when no earlier failure", async () => {
|
|
110
|
+
const store = createFormStore({ schema: stdSchema(["email"], "root says bad") });
|
|
111
|
+
store.registerField("email", {});
|
|
112
|
+
await store.validateField("email");
|
|
113
|
+
expect(store.getState().errors["email"]?.message).toBe("root says bad");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("section schema overrides root schema for same path (no merge)", async () => {
|
|
117
|
+
const store = createFormStore({ schema: stdSchema(["profile", "name"], "root") });
|
|
118
|
+
store.registerField("profile.name", { sectionPath: "profile" });
|
|
119
|
+
store.registerSectionSchema("profile", stdSchema(["name"], "section"));
|
|
120
|
+
await store.validateField("profile.name");
|
|
121
|
+
expect(store.getState().errors["profile.name"]?.message).toBe("section");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("field validate runs before schema; schema skipped if field validate fails", async () => {
|
|
125
|
+
const store = createFormStore({ schema: stdSchema(["email"], "schema says bad") });
|
|
126
|
+
store.registerField("email", { validate: (v) => (v ? undefined : "field says required") });
|
|
127
|
+
await store.validateField("email");
|
|
128
|
+
expect(store.getState().errors["email"]?.message).toBe("field says required");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("submit / reset / setError", () => {
|
|
133
|
+
it("submit calls onSubmit with nested values when valid", async () => {
|
|
134
|
+
const onSubmit = vi.fn();
|
|
135
|
+
const store = createFormStore<{ email: string }>({
|
|
136
|
+
defaultValues: { email: "a@b.com" },
|
|
137
|
+
onSubmit,
|
|
138
|
+
});
|
|
139
|
+
store.registerField("email", { validate: (v) => (v ? undefined : "required") });
|
|
140
|
+
await store.submit();
|
|
141
|
+
expect(onSubmit).toHaveBeenCalledWith({ email: "a@b.com" }, expect.any(Object));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("submit blocks onSubmit when validation fails, calls onInvalid", async () => {
|
|
145
|
+
const onSubmit = vi.fn();
|
|
146
|
+
const onInvalid = vi.fn();
|
|
147
|
+
const store = createFormStore({ onSubmit, onInvalid });
|
|
148
|
+
store.registerField("email", { validate: (v) => (v ? undefined : "required") });
|
|
149
|
+
store.setFieldValue("email", "");
|
|
150
|
+
await store.submit();
|
|
151
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
152
|
+
expect(onInvalid).toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("setError injects server error, next value change clears it", () => {
|
|
156
|
+
const store = createFormStore();
|
|
157
|
+
store.registerField("email", {});
|
|
158
|
+
store.setError("email", "taken");
|
|
159
|
+
expect(store.getFieldState("email").error?.message).toBe("taken");
|
|
160
|
+
store.setFieldValue("email", "new@x.com");
|
|
161
|
+
expect(store.getFieldState("email").error).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("reset clears values, errors, touched, submitCount", () => {
|
|
165
|
+
const store = createFormStore({ defaultValues: { name: "a" } });
|
|
166
|
+
store.setFieldValue("name", "b");
|
|
167
|
+
store.setError("name", "err");
|
|
168
|
+
store.reset();
|
|
169
|
+
expect(store.getFieldState("name").value).toBe("a");
|
|
170
|
+
expect(store.getFieldState("name").error).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("async validate", () => {
|
|
175
|
+
it("sets isValidating during async validate", async () => {
|
|
176
|
+
const store = createFormStore();
|
|
177
|
+
store.registerField("email", {
|
|
178
|
+
validate: async (v) => {
|
|
179
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
180
|
+
return v ? undefined : "bad";
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
store.setFieldValue("email", "");
|
|
184
|
+
const p = store.validateField("email");
|
|
185
|
+
expect(store.getState().validatingFields.has("email")).toBe(true);
|
|
186
|
+
await p;
|
|
187
|
+
expect(store.getState().validatingFields.has("email")).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("stale resolution is discarded if value changed", async () => {
|
|
191
|
+
const store = createFormStore();
|
|
192
|
+
let resolve1: (v: string | undefined) => void;
|
|
193
|
+
store.registerField("email", {
|
|
194
|
+
validate: (v) =>
|
|
195
|
+
v === "slow"
|
|
196
|
+
? new Promise<string | undefined>((r) => (resolve1 = r))
|
|
197
|
+
: undefined,
|
|
198
|
+
});
|
|
199
|
+
store.setFieldValue("email", "slow");
|
|
200
|
+
const p1 = store.validateField("email");
|
|
201
|
+
store.setFieldValue("email", "ok");
|
|
202
|
+
resolve1!("slow says bad");
|
|
203
|
+
await p1;
|
|
204
|
+
expect(store.getState().errors["email"]).toBeUndefined();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FormStore,
|
|
3
|
+
FormStoreState,
|
|
4
|
+
FormConfig,
|
|
5
|
+
FieldState,
|
|
6
|
+
FieldError,
|
|
7
|
+
FieldConfig,
|
|
8
|
+
StandardSchemaV1,
|
|
9
|
+
} from "./types";
|
|
10
|
+
import { flatten, unflatten, getByPath } from "./utils";
|
|
11
|
+
import { runFieldValidate, runSchema } from "./validation";
|
|
12
|
+
|
|
13
|
+
export interface CreateFormStoreOptions<T = unknown> {
|
|
14
|
+
defaultValues?: Partial<T>;
|
|
15
|
+
schema?: StandardSchemaV1<T>;
|
|
16
|
+
validateOn?: "submit" | "blur" | "change";
|
|
17
|
+
onSubmit?: FormConfig<T>["onSubmit"];
|
|
18
|
+
onInvalid?: FormConfig<T>["onInvalid"];
|
|
19
|
+
scrollToFirstError?: boolean;
|
|
20
|
+
focusFirstError?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createFormStore<T = unknown>(
|
|
24
|
+
options: CreateFormStoreOptions<T> = {}
|
|
25
|
+
): FormStore<T> {
|
|
26
|
+
const config: FormConfig<T> = {
|
|
27
|
+
schema: options.schema,
|
|
28
|
+
validateOn: options.validateOn ?? "blur",
|
|
29
|
+
onSubmit: options.onSubmit,
|
|
30
|
+
onInvalid: options.onInvalid,
|
|
31
|
+
scrollToFirstError: options.scrollToFirstError ?? true,
|
|
32
|
+
focusFirstError: options.focusFirstError ?? true,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const initialValues = options.defaultValues
|
|
36
|
+
? flatten(options.defaultValues as Record<string, unknown>)
|
|
37
|
+
: {};
|
|
38
|
+
|
|
39
|
+
let state: FormStoreState = {
|
|
40
|
+
values: { ...initialValues },
|
|
41
|
+
errors: {},
|
|
42
|
+
touched: {},
|
|
43
|
+
submitting: false,
|
|
44
|
+
submitCount: 0,
|
|
45
|
+
activeStepId: null,
|
|
46
|
+
fieldsByStep: new Map(),
|
|
47
|
+
fieldsBySection: new Map(),
|
|
48
|
+
fieldValidators: new Map(),
|
|
49
|
+
sectionSchemas: new Map(),
|
|
50
|
+
validatingFields: new Set(),
|
|
51
|
+
revalidateOnChange: new Set(),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const listeners = new Set<() => void>();
|
|
55
|
+
const notify = () => listeners.forEach((l) => l());
|
|
56
|
+
|
|
57
|
+
const store: FormStore<T> = {
|
|
58
|
+
subscribe(listener) {
|
|
59
|
+
listeners.add(listener);
|
|
60
|
+
return () => {
|
|
61
|
+
listeners.delete(listener);
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
getState: () => state,
|
|
66
|
+
|
|
67
|
+
getFieldState(path): FieldState {
|
|
68
|
+
const err = state.errors[path];
|
|
69
|
+
return {
|
|
70
|
+
value: state.values[path],
|
|
71
|
+
error: err,
|
|
72
|
+
errors: err ? [err] : [],
|
|
73
|
+
touched: !!state.touched[path],
|
|
74
|
+
isValidating: state.validatingFields.has(path),
|
|
75
|
+
hasError: !!err,
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
setFieldValue(path, value) {
|
|
80
|
+
const nextErrors = { ...state.errors };
|
|
81
|
+
delete nextErrors[path];
|
|
82
|
+
state = {
|
|
83
|
+
...state,
|
|
84
|
+
values: { ...state.values, [path]: value },
|
|
85
|
+
errors: nextErrors,
|
|
86
|
+
};
|
|
87
|
+
notify();
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
setFieldTouched(path, touched) {
|
|
91
|
+
if (state.touched[path] === touched) return;
|
|
92
|
+
state = { ...state, touched: { ...state.touched, [path]: touched } };
|
|
93
|
+
notify();
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
registerField(path, cfg) {
|
|
97
|
+
state.fieldValidators.set(path, cfg);
|
|
98
|
+
if (cfg.stepId) {
|
|
99
|
+
const set = state.fieldsByStep.get(cfg.stepId) ?? new Set<string>();
|
|
100
|
+
set.add(path);
|
|
101
|
+
state.fieldsByStep.set(cfg.stepId, set);
|
|
102
|
+
}
|
|
103
|
+
if (cfg.sectionPath) {
|
|
104
|
+
const set = state.fieldsBySection.get(cfg.sectionPath) ?? new Set<string>();
|
|
105
|
+
set.add(path);
|
|
106
|
+
state.fieldsBySection.set(cfg.sectionPath, set);
|
|
107
|
+
}
|
|
108
|
+
notify();
|
|
109
|
+
return () => {
|
|
110
|
+
state.fieldValidators.delete(path);
|
|
111
|
+
if (cfg.stepId) state.fieldsByStep.get(cfg.stepId)?.delete(path);
|
|
112
|
+
if (cfg.sectionPath) state.fieldsBySection.get(cfg.sectionPath)?.delete(path);
|
|
113
|
+
notify();
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
registerStep(stepId) {
|
|
118
|
+
if (!state.fieldsByStep.has(stepId)) {
|
|
119
|
+
state.fieldsByStep.set(stepId, new Set());
|
|
120
|
+
notify();
|
|
121
|
+
}
|
|
122
|
+
return () => {};
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
setActiveStep(stepId) {
|
|
126
|
+
if (state.activeStepId === stepId) return;
|
|
127
|
+
state = { ...state, activeStepId: stepId };
|
|
128
|
+
notify();
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
registerSectionSchema(sectionPath, schema) {
|
|
132
|
+
state.sectionSchemas.set(sectionPath, schema);
|
|
133
|
+
notify();
|
|
134
|
+
return () => {
|
|
135
|
+
state.sectionSchemas.delete(sectionPath);
|
|
136
|
+
notify();
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
async validateField(path) {
|
|
141
|
+
const cfg = state.fieldValidators.get(path);
|
|
142
|
+
const valueAtStart = state.values[path];
|
|
143
|
+
const values = store.getValues() as Record<string, unknown>;
|
|
144
|
+
|
|
145
|
+
// Mark as validating
|
|
146
|
+
state = {
|
|
147
|
+
...state,
|
|
148
|
+
validatingFields: new Set(state.validatingFields).add(path),
|
|
149
|
+
};
|
|
150
|
+
notify();
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
// 1. field validate (highest priority)
|
|
154
|
+
const fieldErr = await runFieldValidate(cfg?.validate, valueAtStart, values);
|
|
155
|
+
// Stale check — if value changed while awaiting, bail
|
|
156
|
+
if (state.values[path] !== valueAtStart) return !state.errors[path];
|
|
157
|
+
if (fieldErr) {
|
|
158
|
+
state = {
|
|
159
|
+
...state,
|
|
160
|
+
errors: { ...state.errors, [path]: fieldErr },
|
|
161
|
+
revalidateOnChange: new Set(state.revalidateOnChange).add(path),
|
|
162
|
+
};
|
|
163
|
+
notify();
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 2. section schema — path starts with sectionPath prefix
|
|
168
|
+
for (const [sectionPath, schema] of state.sectionSchemas) {
|
|
169
|
+
if (path === sectionPath || path.startsWith(sectionPath + ".")) {
|
|
170
|
+
const scopedValues = (getByPath(values, sectionPath) ?? {}) as Record<string, unknown>;
|
|
171
|
+
const errors = await runSchema(schema, scopedValues);
|
|
172
|
+
// Stale check
|
|
173
|
+
if (state.values[path] !== valueAtStart) return !state.errors[path];
|
|
174
|
+
const relativePath = path.slice(sectionPath.length + 1);
|
|
175
|
+
const err = errors[relativePath];
|
|
176
|
+
const nextErrors = { ...state.errors };
|
|
177
|
+
const nextRevalidate = new Set(state.revalidateOnChange);
|
|
178
|
+
if (err) {
|
|
179
|
+
nextErrors[path] = err;
|
|
180
|
+
nextRevalidate.add(path);
|
|
181
|
+
} else {
|
|
182
|
+
delete nextErrors[path];
|
|
183
|
+
nextRevalidate.delete(path);
|
|
184
|
+
}
|
|
185
|
+
state = { ...state, errors: nextErrors, revalidateOnChange: nextRevalidate };
|
|
186
|
+
notify();
|
|
187
|
+
return !err;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 3. root schema (lowest priority)
|
|
192
|
+
if (store._config.schema) {
|
|
193
|
+
const errors = await runSchema(store._config.schema, values);
|
|
194
|
+
// Stale check
|
|
195
|
+
if (state.values[path] !== valueAtStart) return !state.errors[path];
|
|
196
|
+
const err = errors[path];
|
|
197
|
+
const nextErrors = { ...state.errors };
|
|
198
|
+
const nextRevalidate = new Set(state.revalidateOnChange);
|
|
199
|
+
if (err) {
|
|
200
|
+
nextErrors[path] = err;
|
|
201
|
+
nextRevalidate.add(path);
|
|
202
|
+
} else {
|
|
203
|
+
delete nextErrors[path];
|
|
204
|
+
nextRevalidate.delete(path);
|
|
205
|
+
}
|
|
206
|
+
state = { ...state, errors: nextErrors, revalidateOnChange: nextRevalidate };
|
|
207
|
+
notify();
|
|
208
|
+
return !err;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 4. No validation — clear any existing error
|
|
212
|
+
const nextErrors = { ...state.errors };
|
|
213
|
+
delete nextErrors[path];
|
|
214
|
+
const nextRevalidate = new Set(state.revalidateOnChange);
|
|
215
|
+
nextRevalidate.delete(path);
|
|
216
|
+
state = { ...state, errors: nextErrors, revalidateOnChange: nextRevalidate };
|
|
217
|
+
notify();
|
|
218
|
+
return true;
|
|
219
|
+
} finally {
|
|
220
|
+
const nextSet = new Set(state.validatingFields);
|
|
221
|
+
nextSet.delete(path);
|
|
222
|
+
state = { ...state, validatingFields: nextSet };
|
|
223
|
+
notify();
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
async validateStep(stepId) {
|
|
228
|
+
const fields = Array.from(state.fieldsByStep.get(stepId) ?? []);
|
|
229
|
+
if (fields.length === 0) return true;
|
|
230
|
+
const results = await Promise.all(fields.map((f) => store.validateField(f)));
|
|
231
|
+
if (results.some((r) => !r)) {
|
|
232
|
+
const nextTouched = { ...state.touched };
|
|
233
|
+
for (const f of fields) nextTouched[f] = true;
|
|
234
|
+
state = { ...state, touched: nextTouched };
|
|
235
|
+
notify();
|
|
236
|
+
}
|
|
237
|
+
return results.every(Boolean);
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
async validateAll() {
|
|
241
|
+
const paths = Array.from(state.fieldValidators.keys());
|
|
242
|
+
const results = await Promise.all(paths.map((p) => store.validateField(p)));
|
|
243
|
+
if (store._config.schema) {
|
|
244
|
+
const values = store.getValues() as Record<string, unknown>;
|
|
245
|
+
const errors = await runSchema(store._config.schema, values);
|
|
246
|
+
const nextErrors = { ...state.errors };
|
|
247
|
+
for (const [path, err] of Object.entries(errors)) {
|
|
248
|
+
if (!nextErrors[path]) nextErrors[path] = err;
|
|
249
|
+
}
|
|
250
|
+
state = { ...state, errors: nextErrors };
|
|
251
|
+
notify();
|
|
252
|
+
}
|
|
253
|
+
return results.every(Boolean) && Object.values(state.errors).every((e) => !e);
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
getValues<S = T>(scope?: string): S {
|
|
257
|
+
const nested = unflatten(state.values);
|
|
258
|
+
return (scope ? getByPath(nested, scope) : nested) as S;
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
async submit() {
|
|
262
|
+
state = { ...state, submitting: true, submitCount: state.submitCount + 1 };
|
|
263
|
+
notify();
|
|
264
|
+
|
|
265
|
+
// Touch all registered fields
|
|
266
|
+
const allFields = Array.from(state.fieldValidators.keys());
|
|
267
|
+
const nextTouched = { ...state.touched };
|
|
268
|
+
for (const f of allFields) nextTouched[f] = true;
|
|
269
|
+
state = { ...state, touched: nextTouched };
|
|
270
|
+
|
|
271
|
+
const valid = await store.validateAll();
|
|
272
|
+
if (!valid) {
|
|
273
|
+
state = { ...state, submitting: false };
|
|
274
|
+
notify();
|
|
275
|
+
store._config.onInvalid?.(state.errors as Record<string, FieldError>);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const values = store.getValues();
|
|
280
|
+
try {
|
|
281
|
+
await store._config.onSubmit?.(values as T, {
|
|
282
|
+
reset: (d) => store.reset(d),
|
|
283
|
+
setError: (p, m) => store.setError(p, m),
|
|
284
|
+
});
|
|
285
|
+
} finally {
|
|
286
|
+
state = { ...state, submitting: false };
|
|
287
|
+
notify();
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
reset(defaults) {
|
|
292
|
+
const src = defaults ?? options.defaultValues;
|
|
293
|
+
const flat = src ? flatten(src as Record<string, unknown>) : {};
|
|
294
|
+
state = {
|
|
295
|
+
...state,
|
|
296
|
+
values: flat,
|
|
297
|
+
errors: {},
|
|
298
|
+
touched: {},
|
|
299
|
+
submitCount: 0,
|
|
300
|
+
submitting: false,
|
|
301
|
+
revalidateOnChange: new Set(),
|
|
302
|
+
};
|
|
303
|
+
notify();
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
setError(path, message) {
|
|
307
|
+
state = {
|
|
308
|
+
...state,
|
|
309
|
+
errors: { ...state.errors, [path]: { message, source: "validate" } },
|
|
310
|
+
};
|
|
311
|
+
notify();
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
_config: config,
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
return store;
|
|
318
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
.sh-ui-form {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: var(--space-4, 1rem);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.sh-ui-form-section {
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-direction: column;
|
|
10
|
+
gap: var(--space-4, 1rem);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.sh-ui-form-field {
|
|
14
|
+
display: flex;
|
|
15
|
+
flex-direction: column;
|
|
16
|
+
gap: var(--space-1, 0.25rem);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.sh-ui-form-field[data-disabled] {
|
|
20
|
+
opacity: 0.6;
|
|
21
|
+
pointer-events: none;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.sh-ui-form-error {
|
|
25
|
+
color: var(--color-danger, #dc2626);
|
|
26
|
+
font-size: var(--text-sm, 0.875rem);
|
|
27
|
+
margin: 0;
|
|
28
|
+
animation: sh-ui-form-error-in 150ms
|
|
29
|
+
var(--easing-out, cubic-bezier(0.16, 1, 0.3, 1));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@keyframes sh-ui-form-error-in {
|
|
33
|
+
from {
|
|
34
|
+
opacity: 0;
|
|
35
|
+
transform: translateY(-4px);
|
|
36
|
+
}
|
|
37
|
+
to {
|
|
38
|
+
opacity: 1;
|
|
39
|
+
transform: translateY(0);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@media (prefers-reduced-motion: reduce) {
|
|
44
|
+
.sh-ui-form-error {
|
|
45
|
+
animation-duration: 0.01ms;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Standard Schema v1 최소 타입 — https://standardschema.dev 스펙과 동일
|
|
2
|
+
export interface StandardSchemaPathSegment {
|
|
3
|
+
readonly key: PropertyKey;
|
|
4
|
+
}
|
|
5
|
+
export type StandardSchemaPath = ReadonlyArray<PropertyKey | StandardSchemaPathSegment>;
|
|
6
|
+
export interface StandardSchemaIssue {
|
|
7
|
+
readonly message: string;
|
|
8
|
+
readonly path?: StandardSchemaPath;
|
|
9
|
+
}
|
|
10
|
+
export interface StandardSchemaV1<TInput = unknown, TOutput = TInput> {
|
|
11
|
+
readonly "~standard": {
|
|
12
|
+
readonly version: 1;
|
|
13
|
+
readonly vendor: string;
|
|
14
|
+
readonly validate: (
|
|
15
|
+
value: unknown
|
|
16
|
+
) =>
|
|
17
|
+
| { value: TOutput }
|
|
18
|
+
| { issues: ReadonlyArray<StandardSchemaIssue> }
|
|
19
|
+
| Promise<{ value: TOutput } | { issues: ReadonlyArray<StandardSchemaIssue> }>;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ErrorSource = "html5" | "validate" | "schema";
|
|
24
|
+
|
|
25
|
+
export interface FieldError {
|
|
26
|
+
message: string;
|
|
27
|
+
type?: string;
|
|
28
|
+
source: ErrorSource;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FieldState {
|
|
32
|
+
value: unknown;
|
|
33
|
+
error: FieldError | undefined;
|
|
34
|
+
errors: FieldError[];
|
|
35
|
+
touched: boolean;
|
|
36
|
+
isValidating: boolean;
|
|
37
|
+
hasError: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ValidateOn = "submit" | "blur" | "change";
|
|
41
|
+
|
|
42
|
+
export type FieldValidate =
|
|
43
|
+
| ((value: unknown, values: unknown) => string | undefined | Promise<string | undefined>)
|
|
44
|
+
| {
|
|
45
|
+
fn: (value: unknown, values: unknown) => string | undefined | Promise<string | undefined>;
|
|
46
|
+
debounce?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export interface FieldConfig {
|
|
50
|
+
validate?: FieldValidate;
|
|
51
|
+
validateOn?: ValidateOn;
|
|
52
|
+
stepId?: string;
|
|
53
|
+
sectionPath?: string;
|
|
54
|
+
required?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SubmitHelpers<T> {
|
|
58
|
+
reset: (defaults?: Partial<T>) => void;
|
|
59
|
+
setError: (path: string, message: string) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface FormStoreState {
|
|
63
|
+
values: Record<string, unknown>;
|
|
64
|
+
errors: Record<string, FieldError | undefined>;
|
|
65
|
+
touched: Record<string, boolean>;
|
|
66
|
+
submitting: boolean;
|
|
67
|
+
submitCount: number;
|
|
68
|
+
activeStepId: string | null;
|
|
69
|
+
fieldsByStep: Map<string, Set<string>>;
|
|
70
|
+
fieldsBySection: Map<string, Set<string>>;
|
|
71
|
+
fieldValidators: Map<string, FieldConfig>;
|
|
72
|
+
sectionSchemas: Map<string, StandardSchemaV1>;
|
|
73
|
+
validatingFields: Set<string>;
|
|
74
|
+
revalidateOnChange: Set<string>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface FormConfig<T> {
|
|
78
|
+
schema?: StandardSchemaV1<T>;
|
|
79
|
+
validateOn: ValidateOn;
|
|
80
|
+
onSubmit?: (values: T, helpers: SubmitHelpers<T>) => void | Promise<void>;
|
|
81
|
+
onInvalid?: (errors: Record<string, FieldError>) => void;
|
|
82
|
+
scrollToFirstError: boolean;
|
|
83
|
+
focusFirstError: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface FormStore<T = unknown> {
|
|
87
|
+
subscribe(listener: () => void): () => void;
|
|
88
|
+
getState(): FormStoreState;
|
|
89
|
+
getFieldState(path: string): FieldState;
|
|
90
|
+
setFieldValue(path: string, value: unknown): void;
|
|
91
|
+
setFieldTouched(path: string, touched: boolean): void;
|
|
92
|
+
registerField(path: string, config: FieldConfig): () => void;
|
|
93
|
+
registerStep(stepId: string, onActivate?: (active: boolean) => void): () => void;
|
|
94
|
+
setActiveStep(stepId: string | null): void;
|
|
95
|
+
registerSectionSchema(sectionPath: string, schema: StandardSchemaV1): () => void;
|
|
96
|
+
validateField(path: string): Promise<boolean>;
|
|
97
|
+
validateStep(stepId: string): Promise<boolean>;
|
|
98
|
+
validateAll(): Promise<boolean>;
|
|
99
|
+
getValues<S = T>(scope?: string): S;
|
|
100
|
+
submit(): Promise<void>;
|
|
101
|
+
reset(defaults?: Partial<T>): void;
|
|
102
|
+
setError(path: string, message: string): void;
|
|
103
|
+
_config: FormConfig<T>;
|
|
104
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { createFormStore, type CreateFormStoreOptions } from "./store";
|
|
5
|
+
import type { FormStore } from "./types";
|
|
6
|
+
|
|
7
|
+
export function useShUiForm<T = unknown>(
|
|
8
|
+
options?: CreateFormStoreOptions<T>
|
|
9
|
+
): FormStore<T> {
|
|
10
|
+
const storeRef = React.useRef<FormStore<T> | null>(null);
|
|
11
|
+
if (!storeRef.current) {
|
|
12
|
+
storeRef.current = createFormStore<T>(options);
|
|
13
|
+
}
|
|
14
|
+
return storeRef.current;
|
|
15
|
+
}
|