sh-ui-cli 0.15.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 +9 -2
- 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,146 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render } from "@testing-library/react";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Form } from "./form";
|
|
5
|
+
import { useFormContext, FormContext } from "./context";
|
|
6
|
+
|
|
7
|
+
function Probe({ onReady }: { onReady: (store: any) => void }) {
|
|
8
|
+
const store = useFormContext();
|
|
9
|
+
React.useEffect(() => {
|
|
10
|
+
onReady(store);
|
|
11
|
+
}, []);
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("Form root", () => {
|
|
16
|
+
it("renders a <form> element", () => {
|
|
17
|
+
const { container } = render(
|
|
18
|
+
<Form>
|
|
19
|
+
<button type="submit">go</button>
|
|
20
|
+
</Form>
|
|
21
|
+
);
|
|
22
|
+
expect(container.querySelector("form")).toBeTruthy();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("provides FormStore via context", () => {
|
|
26
|
+
const onReady = vi.fn();
|
|
27
|
+
render(
|
|
28
|
+
<Form>
|
|
29
|
+
<Probe onReady={onReady} />
|
|
30
|
+
</Form>
|
|
31
|
+
);
|
|
32
|
+
expect(onReady).toHaveBeenCalled();
|
|
33
|
+
expect(typeof onReady.mock.calls[0][0].subscribe).toBe("function");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("accepts external form prop", async () => {
|
|
37
|
+
const { useShUiForm } = await import("./use-sh-ui-form");
|
|
38
|
+
let captured: any = null;
|
|
39
|
+
const probe = (s: any) => {
|
|
40
|
+
captured = s;
|
|
41
|
+
};
|
|
42
|
+
function Wrapper() {
|
|
43
|
+
const store = useShUiForm({ defaultValues: { a: 1 } });
|
|
44
|
+
return (
|
|
45
|
+
<Form form={store}>
|
|
46
|
+
<Probe onReady={probe} />
|
|
47
|
+
</Form>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
render(<Wrapper />);
|
|
51
|
+
expect(captured).not.toBeNull();
|
|
52
|
+
expect(captured.getFieldState("a").value).toBe(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("throws in dev mode when nested", () => {
|
|
56
|
+
const originalError = console.error;
|
|
57
|
+
console.error = vi.fn();
|
|
58
|
+
expect(() => {
|
|
59
|
+
render(
|
|
60
|
+
<Form>
|
|
61
|
+
<Form>
|
|
62
|
+
<div />
|
|
63
|
+
</Form>
|
|
64
|
+
</Form>
|
|
65
|
+
);
|
|
66
|
+
}).toThrow(/cannot be nested/i);
|
|
67
|
+
console.error = originalError;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("focuses first errored field after submit", async () => {
|
|
71
|
+
const { screen } = await import("@testing-library/react");
|
|
72
|
+
const userEvent = (await import("@testing-library/user-event")).default;
|
|
73
|
+
const { Field } = await import("./field");
|
|
74
|
+
const { FormControl } = await import("./field");
|
|
75
|
+
|
|
76
|
+
const user = userEvent.setup();
|
|
77
|
+
render(
|
|
78
|
+
<Form>
|
|
79
|
+
<Field name="a" validate={(v) => (v ? undefined : "req")}>
|
|
80
|
+
<FormControl>
|
|
81
|
+
<input data-testid="a" />
|
|
82
|
+
</FormControl>
|
|
83
|
+
</Field>
|
|
84
|
+
<Field name="b" validate={(v) => (v ? undefined : "req")}>
|
|
85
|
+
<FormControl>
|
|
86
|
+
<input data-testid="b" />
|
|
87
|
+
</FormControl>
|
|
88
|
+
</Field>
|
|
89
|
+
<button type="submit">go</button>
|
|
90
|
+
</Form>
|
|
91
|
+
);
|
|
92
|
+
await user.click(screen.getByText("go"));
|
|
93
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
94
|
+
expect(document.activeElement).toBe(screen.getByTestId("a"));
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("Form-level states", () => {
|
|
99
|
+
it("sets aria-busy while submitting", async () => {
|
|
100
|
+
const { screen } = await import("@testing-library/react");
|
|
101
|
+
const userEvent = (await import("@testing-library/user-event")).default;
|
|
102
|
+
const { Field } = await import("./field");
|
|
103
|
+
const { FormControl } = await import("./field");
|
|
104
|
+
|
|
105
|
+
const user = userEvent.setup();
|
|
106
|
+
let resolveSubmit: () => void;
|
|
107
|
+
const blocker = new Promise<void>((r) => (resolveSubmit = r));
|
|
108
|
+
|
|
109
|
+
render(
|
|
110
|
+
<Form
|
|
111
|
+
onSubmit={async () => {
|
|
112
|
+
await blocker;
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<Field name="x" validate={() => undefined}>
|
|
116
|
+
<FormControl>
|
|
117
|
+
<input />
|
|
118
|
+
</FormControl>
|
|
119
|
+
</Field>
|
|
120
|
+
<button type="submit">go</button>
|
|
121
|
+
</Form>
|
|
122
|
+
);
|
|
123
|
+
const form = document.querySelector("form")!;
|
|
124
|
+
await user.click(screen.getByText("go"));
|
|
125
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
126
|
+
expect(form.getAttribute("aria-busy")).toBe("true");
|
|
127
|
+
resolveSubmit!();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("Form disabled disables all controls", async () => {
|
|
131
|
+
const { screen } = await import("@testing-library/react");
|
|
132
|
+
const { Field } = await import("./field");
|
|
133
|
+
const { FormControl } = await import("./field");
|
|
134
|
+
|
|
135
|
+
render(
|
|
136
|
+
<Form disabled>
|
|
137
|
+
<Field name="x">
|
|
138
|
+
<FormControl>
|
|
139
|
+
<input data-testid="i" />
|
|
140
|
+
</FormControl>
|
|
141
|
+
</Field>
|
|
142
|
+
</Form>
|
|
143
|
+
);
|
|
144
|
+
expect((screen.getByTestId("i") as HTMLInputElement).disabled).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { createFormStore, type CreateFormStoreOptions } from "./store";
|
|
5
|
+
import {
|
|
6
|
+
FormContext,
|
|
7
|
+
SectionContext,
|
|
8
|
+
DisabledContext,
|
|
9
|
+
useFormState,
|
|
10
|
+
} from "./context";
|
|
11
|
+
import type { FormStore, StandardSchemaV1 } from "./types";
|
|
12
|
+
import { scopedPath } from "./utils";
|
|
13
|
+
import { focusFirstError } from "./focus-first-error";
|
|
14
|
+
|
|
15
|
+
export interface FormProps<T = unknown>
|
|
16
|
+
extends Omit<React.FormHTMLAttributes<HTMLFormElement>, "onSubmit" | "onInvalid"> {
|
|
17
|
+
form?: FormStore<T>;
|
|
18
|
+
defaultValues?: CreateFormStoreOptions<T>["defaultValues"];
|
|
19
|
+
schema?: CreateFormStoreOptions<T>["schema"];
|
|
20
|
+
validateOn?: CreateFormStoreOptions<T>["validateOn"];
|
|
21
|
+
onSubmit?: CreateFormStoreOptions<T>["onSubmit"];
|
|
22
|
+
onInvalid?: CreateFormStoreOptions<T>["onInvalid"];
|
|
23
|
+
scrollToFirstError?: boolean;
|
|
24
|
+
focusFirstError?: boolean;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function FormInner<T>({
|
|
29
|
+
form: externalForm,
|
|
30
|
+
defaultValues,
|
|
31
|
+
schema,
|
|
32
|
+
validateOn,
|
|
33
|
+
onSubmit,
|
|
34
|
+
onInvalid,
|
|
35
|
+
scrollToFirstError,
|
|
36
|
+
focusFirstError: focusFirstErrorProp,
|
|
37
|
+
disabled,
|
|
38
|
+
children,
|
|
39
|
+
...rest
|
|
40
|
+
}: FormProps<T>) {
|
|
41
|
+
const parent = React.useContext(FormContext);
|
|
42
|
+
if (process.env.NODE_ENV !== "production" && parent) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
"<Form> cannot be nested. For reusable field groups, use <Form.Section>-based components without a Form root."
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const internalStoreRef = React.useRef<FormStore<T> | null>(null);
|
|
49
|
+
if (!externalForm && !internalStoreRef.current) {
|
|
50
|
+
internalStoreRef.current = createFormStore<T>({
|
|
51
|
+
defaultValues,
|
|
52
|
+
schema,
|
|
53
|
+
validateOn,
|
|
54
|
+
onSubmit,
|
|
55
|
+
onInvalid,
|
|
56
|
+
scrollToFirstError,
|
|
57
|
+
focusFirstError: focusFirstErrorProp,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const store = (externalForm ?? internalStoreRef.current) as FormStore<T>;
|
|
61
|
+
|
|
62
|
+
const formElRef = React.useRef<HTMLFormElement | null>(null);
|
|
63
|
+
|
|
64
|
+
const handleSubmit: React.FormEventHandler<HTMLFormElement> = (e) => {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
void (async () => {
|
|
67
|
+
await store.submit();
|
|
68
|
+
const s = store.getState();
|
|
69
|
+
const hasErrors = Object.values(s.errors).some(Boolean);
|
|
70
|
+
if (
|
|
71
|
+
hasErrors &&
|
|
72
|
+
store._config.focusFirstError &&
|
|
73
|
+
formElRef.current
|
|
74
|
+
) {
|
|
75
|
+
focusFirstError(
|
|
76
|
+
store as unknown as FormStore<unknown>,
|
|
77
|
+
formElRef.current,
|
|
78
|
+
store._config.scrollToFirstError
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
})();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<FormContext.Provider value={store as unknown as FormStore<unknown>}>
|
|
86
|
+
<DisabledContext.Provider value={disabled ?? false}>
|
|
87
|
+
<FormElement
|
|
88
|
+
formElRef={formElRef}
|
|
89
|
+
onSubmit={handleSubmit}
|
|
90
|
+
noValidate
|
|
91
|
+
{...rest}
|
|
92
|
+
>
|
|
93
|
+
{children}
|
|
94
|
+
</FormElement>
|
|
95
|
+
</DisabledContext.Provider>
|
|
96
|
+
</FormContext.Provider>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function FormElement({
|
|
101
|
+
formElRef,
|
|
102
|
+
children,
|
|
103
|
+
onSubmit,
|
|
104
|
+
className,
|
|
105
|
+
...rest
|
|
106
|
+
}: {
|
|
107
|
+
formElRef: React.RefObject<HTMLFormElement | null>;
|
|
108
|
+
children?: React.ReactNode;
|
|
109
|
+
onSubmit: React.FormEventHandler<HTMLFormElement>;
|
|
110
|
+
} & Omit<React.FormHTMLAttributes<HTMLFormElement>, "onSubmit">) {
|
|
111
|
+
const state = useFormState();
|
|
112
|
+
return (
|
|
113
|
+
<form
|
|
114
|
+
ref={formElRef}
|
|
115
|
+
aria-busy={state.submitting || undefined}
|
|
116
|
+
onSubmit={onSubmit}
|
|
117
|
+
className={`sh-ui-form${className ? ` ${className}` : ""}`}
|
|
118
|
+
{...rest}
|
|
119
|
+
>
|
|
120
|
+
{children}
|
|
121
|
+
</form>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface FormSectionProps extends React.HTMLAttributes<HTMLElement> {
|
|
126
|
+
name?: string;
|
|
127
|
+
schema?: StandardSchemaV1;
|
|
128
|
+
as?: "div" | "fieldset";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function Section({
|
|
132
|
+
name,
|
|
133
|
+
schema,
|
|
134
|
+
as = "div",
|
|
135
|
+
className,
|
|
136
|
+
children,
|
|
137
|
+
...rest
|
|
138
|
+
}: FormSectionProps) {
|
|
139
|
+
const parent = React.useContext(SectionContext);
|
|
140
|
+
const store = React.useContext(FormContext);
|
|
141
|
+
const path = scopedPath(parent.path, name);
|
|
142
|
+
|
|
143
|
+
React.useEffect(() => {
|
|
144
|
+
if (!schema || !store || !path) return;
|
|
145
|
+
return store.registerSectionSchema(path, schema);
|
|
146
|
+
}, [schema, store, path]);
|
|
147
|
+
|
|
148
|
+
const Tag = as as any;
|
|
149
|
+
const role = as === "div" ? "group" : undefined;
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<SectionContext.Provider value={{ path }}>
|
|
153
|
+
<Tag
|
|
154
|
+
role={role}
|
|
155
|
+
className={`sh-ui-form-section${className ? ` ${className}` : ""}`}
|
|
156
|
+
{...rest}
|
|
157
|
+
>
|
|
158
|
+
{children}
|
|
159
|
+
</Tag>
|
|
160
|
+
</SectionContext.Provider>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function SectionTitle({
|
|
165
|
+
children,
|
|
166
|
+
...rest
|
|
167
|
+
}: React.HTMLAttributes<HTMLElement>) {
|
|
168
|
+
return <legend {...rest}>{children}</legend>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
type FormType = typeof FormInner & {
|
|
172
|
+
Section: typeof Section;
|
|
173
|
+
SectionTitle: typeof SectionTitle;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export const Form = FormInner as unknown as FormType;
|
|
177
|
+
Form.Section = Section;
|
|
178
|
+
Form.SectionTitle = SectionTitle;
|
|
179
|
+
|
|
180
|
+
export { Section, SectionTitle };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import "./styles.css";
|
|
4
|
+
import { Form as FormRoot, Section, SectionTitle } from "./form";
|
|
5
|
+
import {
|
|
6
|
+
Field,
|
|
7
|
+
FormLabel,
|
|
8
|
+
FormDescription,
|
|
9
|
+
FormError,
|
|
10
|
+
FormControl,
|
|
11
|
+
} from "./field";
|
|
12
|
+
import { Steps, Step } from "./steps";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* sh-ui Form의 compound 진입점. `Form.Field`, `Form.Label`, `Form.Description`,
|
|
16
|
+
* `Form.Error`, `Form.Control`, `Form.Section`, `Form.SectionTitle`, `Form.Steps`, `Form.Step` 으로
|
|
17
|
+
* 구조를 조립한다. 검증은 Standard Schema(yup/zod 등) 또는 inline 함수로 부착한다.
|
|
18
|
+
*/
|
|
19
|
+
type FormType = typeof FormRoot & {
|
|
20
|
+
Section: typeof Section;
|
|
21
|
+
SectionTitle: typeof SectionTitle;
|
|
22
|
+
Field: typeof Field;
|
|
23
|
+
Label: typeof FormLabel;
|
|
24
|
+
Description: typeof FormDescription;
|
|
25
|
+
Error: typeof FormError;
|
|
26
|
+
Control: typeof FormControl;
|
|
27
|
+
Steps: typeof Steps;
|
|
28
|
+
Step: typeof Step;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const Form = FormRoot as FormType;
|
|
32
|
+
Form.Section = Section;
|
|
33
|
+
Form.SectionTitle = SectionTitle;
|
|
34
|
+
Form.Field = Field;
|
|
35
|
+
Form.Label = FormLabel;
|
|
36
|
+
Form.Description = FormDescription;
|
|
37
|
+
Form.Error = FormError;
|
|
38
|
+
Form.Control = FormControl;
|
|
39
|
+
Form.Steps = Steps;
|
|
40
|
+
Form.Step = Step;
|
|
41
|
+
|
|
42
|
+
export { Form };
|
|
43
|
+
export { useShUiForm } from "./use-sh-ui-form";
|
|
44
|
+
export {
|
|
45
|
+
useFormContext,
|
|
46
|
+
useFormField,
|
|
47
|
+
useFormSection,
|
|
48
|
+
useFormState,
|
|
49
|
+
} from "./context";
|
|
50
|
+
export { useFormSteps } from "./steps";
|
|
51
|
+
export { createFormStore } from "./store";
|
|
52
|
+
export type {
|
|
53
|
+
FormStore,
|
|
54
|
+
FormStoreState,
|
|
55
|
+
FieldState,
|
|
56
|
+
FieldError,
|
|
57
|
+
FieldConfig,
|
|
58
|
+
FieldValidate,
|
|
59
|
+
ValidateOn,
|
|
60
|
+
StandardSchemaV1,
|
|
61
|
+
} from "./types";
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { Form } from "./form";
|
|
6
|
+
import { Field } from "./field";
|
|
7
|
+
import { FormControl } from "./field";
|
|
8
|
+
import { Steps, Step, useFormSteps } from "./steps";
|
|
9
|
+
|
|
10
|
+
function Nav() {
|
|
11
|
+
const { next, prev, activeStepId, isLastStep } = useFormSteps();
|
|
12
|
+
return (
|
|
13
|
+
<>
|
|
14
|
+
<span data-testid="active">{activeStepId}</span>
|
|
15
|
+
<button onClick={prev} type="button">prev</button>
|
|
16
|
+
<button onClick={() => void next()} type="button">
|
|
17
|
+
{isLastStep ? "done" : "next"}
|
|
18
|
+
</button>
|
|
19
|
+
</>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("Form.Steps", () => {
|
|
24
|
+
it("renders only active step", () => {
|
|
25
|
+
render(
|
|
26
|
+
<Form>
|
|
27
|
+
<Steps defaultStep="a">
|
|
28
|
+
<Step id="a"><div data-testid="a">A</div></Step>
|
|
29
|
+
<Step id="b"><div data-testid="b">B</div></Step>
|
|
30
|
+
</Steps>
|
|
31
|
+
</Form>
|
|
32
|
+
);
|
|
33
|
+
expect(screen.queryByTestId("a")).toBeInTheDocument();
|
|
34
|
+
expect(screen.queryByTestId("b")).not.toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("next() moves to next step when current is valid", async () => {
|
|
38
|
+
const user = userEvent.setup();
|
|
39
|
+
render(
|
|
40
|
+
<Form>
|
|
41
|
+
<Steps defaultStep="a">
|
|
42
|
+
<Step id="a"><div data-testid="a">A</div></Step>
|
|
43
|
+
<Step id="b"><div data-testid="b">B</div></Step>
|
|
44
|
+
</Steps>
|
|
45
|
+
<Nav />
|
|
46
|
+
</Form>
|
|
47
|
+
);
|
|
48
|
+
await user.click(screen.getByText("next"));
|
|
49
|
+
// next() is fired-and-forgotten from onClick (void next()) so await the async chain
|
|
50
|
+
await waitFor(() =>
|
|
51
|
+
expect(screen.getByTestId("active").textContent).toBe("b")
|
|
52
|
+
);
|
|
53
|
+
expect(screen.queryByTestId("b")).toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("next() blocks when current step has invalid fields", async () => {
|
|
57
|
+
const user = userEvent.setup();
|
|
58
|
+
render(
|
|
59
|
+
<Form>
|
|
60
|
+
<Steps defaultStep="a">
|
|
61
|
+
<Step id="a">
|
|
62
|
+
<Field name="email" validate={(v) => (v ? undefined : "required")}>
|
|
63
|
+
<FormControl><input data-testid="i" /></FormControl>
|
|
64
|
+
</Field>
|
|
65
|
+
</Step>
|
|
66
|
+
<Step id="b"><div data-testid="b">B</div></Step>
|
|
67
|
+
</Steps>
|
|
68
|
+
<Nav />
|
|
69
|
+
</Form>
|
|
70
|
+
);
|
|
71
|
+
await user.click(screen.getByText("next"));
|
|
72
|
+
// Give next()'s async chain a chance to settle, then assert it stayed on "a"
|
|
73
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
74
|
+
expect(screen.getByTestId("active").textContent).toBe("a");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("value persistence across step navigation", () => {
|
|
79
|
+
it("keeps value when navigating away and back", async () => {
|
|
80
|
+
// Store keeps field values even when Step unmounts — values live in FormStore, not DOM
|
|
81
|
+
const user = userEvent.setup();
|
|
82
|
+
render(
|
|
83
|
+
<Form defaultValues={{ email: "" }}>
|
|
84
|
+
<Steps defaultStep="a">
|
|
85
|
+
<Step id="a">
|
|
86
|
+
<Field name="email">
|
|
87
|
+
<FormControl><input data-testid="i" /></FormControl>
|
|
88
|
+
</Field>
|
|
89
|
+
</Step>
|
|
90
|
+
<Step id="b"><div>B</div></Step>
|
|
91
|
+
</Steps>
|
|
92
|
+
<Nav />
|
|
93
|
+
</Form>
|
|
94
|
+
);
|
|
95
|
+
const input = screen.getByTestId("i") as HTMLInputElement;
|
|
96
|
+
await user.type(input, "a@b.com");
|
|
97
|
+
await user.click(screen.getByText("next"));
|
|
98
|
+
// next() fires async — wait for step b to mount
|
|
99
|
+
await waitFor(() =>
|
|
100
|
+
expect(screen.getByTestId("active").textContent).toBe("b")
|
|
101
|
+
);
|
|
102
|
+
await user.click(screen.getByText("prev"));
|
|
103
|
+
await waitFor(() => expect(screen.queryByTestId("i")).toBeInTheDocument());
|
|
104
|
+
expect((screen.getByTestId("i") as HTMLInputElement).value).toBe("a@b.com");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { FormContext, StepContext, useFormState } from "./context";
|
|
5
|
+
|
|
6
|
+
export interface StepsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
7
|
+
defaultStep?: string;
|
|
8
|
+
activeStep?: string;
|
|
9
|
+
onStepChange?: (id: string) => void;
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ─── Internal store backdoor types ────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
interface StepsNav {
|
|
16
|
+
activeStepId: string | null;
|
|
17
|
+
order: string[];
|
|
18
|
+
setActive: (id: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type StoreWithSteps = {
|
|
22
|
+
__stepsNav?: StepsNav;
|
|
23
|
+
__stepConfig?: Record<string, { skipValidationOnNext?: boolean }>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ─── StepsContext — only used within the Steps subtree ────────────────────────
|
|
27
|
+
|
|
28
|
+
interface StepsContextValue {
|
|
29
|
+
activeStepId: string | null;
|
|
30
|
+
setActive: (id: string) => void;
|
|
31
|
+
order: string[];
|
|
32
|
+
registerStepId: (id: string) => () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const StepsContext = React.createContext<StepsContextValue | null>(null);
|
|
36
|
+
|
|
37
|
+
// ─── Steps ────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export function Steps({
|
|
40
|
+
defaultStep,
|
|
41
|
+
activeStep,
|
|
42
|
+
onStepChange,
|
|
43
|
+
children,
|
|
44
|
+
...rest
|
|
45
|
+
}: StepsProps) {
|
|
46
|
+
const store = React.useContext(FormContext);
|
|
47
|
+
if (!store) throw new Error("<Form.Steps> must be inside <Form>");
|
|
48
|
+
|
|
49
|
+
const [internal, setInternal] = React.useState<string | null>(
|
|
50
|
+
defaultStep ?? null
|
|
51
|
+
);
|
|
52
|
+
const [order, setOrder] = React.useState<string[]>([]);
|
|
53
|
+
|
|
54
|
+
const active = activeStep ?? internal;
|
|
55
|
+
|
|
56
|
+
const setActive = React.useCallback(
|
|
57
|
+
(id: string) => {
|
|
58
|
+
if (activeStep === undefined) setInternal(id);
|
|
59
|
+
onStepChange?.(id);
|
|
60
|
+
},
|
|
61
|
+
[activeStep, onStepChange]
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const registerStepId = React.useCallback((id: string) => {
|
|
65
|
+
setOrder((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
|
66
|
+
return () => {
|
|
67
|
+
setOrder((prev) => prev.filter((x) => x !== id));
|
|
68
|
+
};
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
// Activate first step when order becomes available and no step is active
|
|
72
|
+
React.useEffect(() => {
|
|
73
|
+
if (!active && order.length > 0) setInternal(order[0]);
|
|
74
|
+
}, [active, order]);
|
|
75
|
+
|
|
76
|
+
// Sync active step to form store
|
|
77
|
+
React.useEffect(() => {
|
|
78
|
+
store.setActiveStep(active);
|
|
79
|
+
}, [store, active]);
|
|
80
|
+
|
|
81
|
+
// Publish nav API onto store backdoor so useFormSteps can access it
|
|
82
|
+
// from anywhere in the Form tree (not just inside Steps subtree)
|
|
83
|
+
React.useEffect(() => {
|
|
84
|
+
(store as unknown as StoreWithSteps).__stepsNav = {
|
|
85
|
+
activeStepId: active,
|
|
86
|
+
order,
|
|
87
|
+
setActive,
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const ctxValue = React.useMemo(
|
|
92
|
+
() => ({ activeStepId: active, setActive, order, registerStepId }),
|
|
93
|
+
[active, setActive, order, registerStepId]
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<StepsContext.Provider value={ctxValue}>
|
|
98
|
+
<div {...rest}>{children}</div>
|
|
99
|
+
</StepsContext.Provider>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Step ─────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
export interface StepProps {
|
|
106
|
+
id: string;
|
|
107
|
+
skipValidationOnNext?: boolean;
|
|
108
|
+
children?: React.ReactNode;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function Step({ id, skipValidationOnNext, children }: StepProps) {
|
|
112
|
+
const ctx = React.useContext(StepsContext);
|
|
113
|
+
const store = React.useContext(FormContext);
|
|
114
|
+
if (!ctx || !store) throw new Error("<Form.Step> must be inside <Form.Steps>");
|
|
115
|
+
|
|
116
|
+
const { registerStepId } = ctx;
|
|
117
|
+
React.useEffect(() => registerStepId(id), [registerStepId, id]);
|
|
118
|
+
|
|
119
|
+
React.useEffect(() => {
|
|
120
|
+
const s = store as unknown as StoreWithSteps;
|
|
121
|
+
const current = s.__stepConfig ?? {};
|
|
122
|
+
s.__stepConfig = { ...current, [id]: { skipValidationOnNext } };
|
|
123
|
+
}, [store, id, skipValidationOnNext]);
|
|
124
|
+
|
|
125
|
+
if (ctx.activeStepId !== id) return null;
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<StepContext.Provider value={{ id }}>{children}</StepContext.Provider>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── useFormSteps ─────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export function useFormSteps() {
|
|
135
|
+
const store = React.useContext(FormContext);
|
|
136
|
+
if (!store) throw new Error("useFormSteps must be used inside <Form>");
|
|
137
|
+
|
|
138
|
+
// Subscribe to store so we re-render on step changes
|
|
139
|
+
useFormState();
|
|
140
|
+
|
|
141
|
+
// Read nav from backdoor — set synchronously by Steps on every render
|
|
142
|
+
const nav = (store as unknown as StoreWithSteps).__stepsNav;
|
|
143
|
+
|
|
144
|
+
const activeStepId = nav?.activeStepId ?? store.getState().activeStepId;
|
|
145
|
+
const order = nav?.order ?? [];
|
|
146
|
+
const setActive = nav?.setActive ?? (() => {});
|
|
147
|
+
|
|
148
|
+
const idx = activeStepId ? order.indexOf(activeStepId) : -1;
|
|
149
|
+
const isLastStep = order.length > 0 ? idx === order.length - 1 : false;
|
|
150
|
+
const isFirstStep = idx === 0;
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
activeStepId,
|
|
154
|
+
isLastStep,
|
|
155
|
+
isFirstStep,
|
|
156
|
+
next: async () => {
|
|
157
|
+
// Read nav fresh at call time so stale closures don't bite us
|
|
158
|
+
const currentNav = (store as unknown as StoreWithSteps).__stepsNav;
|
|
159
|
+
const currentActiveStepId = currentNav?.activeStepId ?? store.getState().activeStepId;
|
|
160
|
+
if (!currentActiveStepId) return false;
|
|
161
|
+
const stepConfig = (store as unknown as StoreWithSteps).__stepConfig;
|
|
162
|
+
const cfg = stepConfig?.[currentActiveStepId];
|
|
163
|
+
const ok = cfg?.skipValidationOnNext
|
|
164
|
+
? true
|
|
165
|
+
: await store.validateStep(currentActiveStepId);
|
|
166
|
+
if (!ok) return false;
|
|
167
|
+
// Re-read nav after async operation in case it changed
|
|
168
|
+
const latestNav = (store as unknown as StoreWithSteps).__stepsNav;
|
|
169
|
+
const latestOrder = latestNav?.order ?? [];
|
|
170
|
+
const latestSetActive = latestNav?.setActive ?? (() => {});
|
|
171
|
+
const latestIdx = latestOrder.indexOf(currentActiveStepId);
|
|
172
|
+
const latestIsLast = latestIdx === latestOrder.length - 1;
|
|
173
|
+
if (latestIsLast) {
|
|
174
|
+
await store.submit();
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
latestSetActive(latestOrder[latestIdx + 1]);
|
|
178
|
+
return true;
|
|
179
|
+
},
|
|
180
|
+
prev: () => {
|
|
181
|
+
if (isFirstStep) return;
|
|
182
|
+
setActive(order[idx - 1]);
|
|
183
|
+
},
|
|
184
|
+
goTo: (id: string) => {
|
|
185
|
+
if (order.includes(id)) setActive(id);
|
|
186
|
+
},
|
|
187
|
+
isStepValid: (id: string) => {
|
|
188
|
+
const storeState = store.getState();
|
|
189
|
+
const fields = storeState.fieldsByStep.get(id) ?? new Set();
|
|
190
|
+
return !Array.from(fields).some((f) => storeState.errors[f]);
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|