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.
Files changed (162) hide show
  1. package/bin/sh-ui.mjs +6 -0
  2. package/data/changelog/versions.json +354 -0
  3. package/data/registry/flutter/foundation/sh_ui_tokens.dart +385 -0
  4. package/data/registry/flutter/registry.json +336 -0
  5. package/data/registry/flutter/widgets/sh_ui_accordion.dart +255 -0
  6. package/data/registry/flutter/widgets/sh_ui_app_shell.dart +267 -0
  7. package/data/registry/flutter/widgets/sh_ui_avatar.dart +95 -0
  8. package/data/registry/flutter/widgets/sh_ui_badge.dart +82 -0
  9. package/data/registry/flutter/widgets/sh_ui_breadcrumb.dart +107 -0
  10. package/data/registry/flutter/widgets/sh_ui_button.dart +201 -0
  11. package/data/registry/flutter/widgets/sh_ui_card.dart +159 -0
  12. package/data/registry/flutter/widgets/sh_ui_carousel.dart +204 -0
  13. package/data/registry/flutter/widgets/sh_ui_checkbox.dart +154 -0
  14. package/data/registry/flutter/widgets/sh_ui_color_picker.dart +264 -0
  15. package/data/registry/flutter/widgets/sh_ui_combobox.dart +614 -0
  16. package/data/registry/flutter/widgets/sh_ui_context_menu.dart +71 -0
  17. package/data/registry/flutter/widgets/sh_ui_date_picker.dart +648 -0
  18. package/data/registry/flutter/widgets/sh_ui_dialog.dart +567 -0
  19. package/data/registry/flutter/widgets/sh_ui_dropdown_menu.dart +251 -0
  20. package/data/registry/flutter/widgets/sh_ui_file_upload.dart +200 -0
  21. package/data/registry/flutter/widgets/sh_ui_header.dart +488 -0
  22. package/data/registry/flutter/widgets/sh_ui_input.dart +664 -0
  23. package/data/registry/flutter/widgets/sh_ui_label.dart +145 -0
  24. package/data/registry/flutter/widgets/sh_ui_menubar.dart +98 -0
  25. package/data/registry/flutter/widgets/sh_ui_pagination.dart +276 -0
  26. package/data/registry/flutter/widgets/sh_ui_popover.dart +248 -0
  27. package/data/registry/flutter/widgets/sh_ui_progress.dart +47 -0
  28. package/data/registry/flutter/widgets/sh_ui_radio.dart +108 -0
  29. package/data/registry/flutter/widgets/sh_ui_select.dart +904 -0
  30. package/data/registry/flutter/widgets/sh_ui_separator.dart +42 -0
  31. package/data/registry/flutter/widgets/sh_ui_sidebar.dart +1116 -0
  32. package/data/registry/flutter/widgets/sh_ui_skeleton.dart +129 -0
  33. package/data/registry/flutter/widgets/sh_ui_slider.dart +147 -0
  34. package/data/registry/flutter/widgets/sh_ui_spinner.dart +56 -0
  35. package/data/registry/flutter/widgets/sh_ui_switch.dart +109 -0
  36. package/data/registry/flutter/widgets/sh_ui_tabs.dart +329 -0
  37. package/data/registry/flutter/widgets/sh_ui_textarea.dart +126 -0
  38. package/data/registry/flutter/widgets/sh_ui_toast.dart +362 -0
  39. package/data/registry/flutter/widgets/sh_ui_toggle.dart +229 -0
  40. package/data/registry/flutter/widgets/sh_ui_tooltip.dart +62 -0
  41. package/data/registry/react/components/accordion/index.tsx +85 -0
  42. package/data/registry/react/components/accordion/styles.css +94 -0
  43. package/data/registry/react/components/animations/animations.css +51 -0
  44. package/data/registry/react/components/avatar/index.tsx +75 -0
  45. package/data/registry/react/components/avatar/styles.css +36 -0
  46. package/data/registry/react/components/badge/index.tsx +42 -0
  47. package/data/registry/react/components/badge/styles.css +57 -0
  48. package/data/registry/react/components/base/base.css +102 -0
  49. package/data/registry/react/components/breadcrumb/index.tsx +154 -0
  50. package/data/registry/react/components/breadcrumb/styles.css +82 -0
  51. package/data/registry/react/components/breakpoints/breakpoints.css +17 -0
  52. package/data/registry/react/components/button/index.tsx +47 -0
  53. package/data/registry/react/components/button/styles.css +93 -0
  54. package/data/registry/react/components/card/index.tsx +86 -0
  55. package/data/registry/react/components/card/styles.css +73 -0
  56. package/data/registry/react/components/carousel/index.tsx +432 -0
  57. package/data/registry/react/components/carousel/styles.css +155 -0
  58. package/data/registry/react/components/checkbox/index.tsx +98 -0
  59. package/data/registry/react/components/checkbox/styles.css +75 -0
  60. package/data/registry/react/components/code-panel/copy.tsx +56 -0
  61. package/data/registry/react/components/code-panel/index.tsx +193 -0
  62. package/data/registry/react/components/code-panel/styles.css +124 -0
  63. package/data/registry/react/components/color-picker/index.tsx +466 -0
  64. package/data/registry/react/components/color-picker/styles.css +166 -0
  65. package/data/registry/react/components/combobox/index.tsx +167 -0
  66. package/data/registry/react/components/combobox/styles.css +151 -0
  67. package/data/registry/react/components/context-menu/index.tsx +253 -0
  68. package/data/registry/react/components/context-menu/styles.css +140 -0
  69. package/data/registry/react/components/date-picker/index.tsx +757 -0
  70. package/data/registry/react/components/date-picker/styles.css +279 -0
  71. package/data/registry/react/components/dialog/index.tsx +97 -0
  72. package/data/registry/react/components/dialog/styles.css +127 -0
  73. package/data/registry/react/components/dropdown-menu/index.tsx +257 -0
  74. package/data/registry/react/components/dropdown-menu/styles.css +150 -0
  75. package/data/registry/react/components/file-upload/index.tsx +489 -0
  76. package/data/registry/react/components/file-upload/styles.css +170 -0
  77. package/data/registry/react/components/focus-ring/focus-ring.css +23 -0
  78. package/data/registry/react/components/form/context.ts +92 -0
  79. package/data/registry/react/components/form/field.test.tsx +230 -0
  80. package/data/registry/react/components/form/field.tsx +236 -0
  81. package/data/registry/react/components/form/focus-first-error.ts +54 -0
  82. package/data/registry/react/components/form/form.section.test.tsx +58 -0
  83. package/data/registry/react/components/form/form.test.tsx +146 -0
  84. package/data/registry/react/components/form/form.tsx +180 -0
  85. package/data/registry/react/components/form/index.tsx +61 -0
  86. package/data/registry/react/components/form/steps.test.tsx +106 -0
  87. package/data/registry/react/components/form/steps.tsx +193 -0
  88. package/data/registry/react/components/form/store.test.ts +206 -0
  89. package/data/registry/react/components/form/store.ts +318 -0
  90. package/data/registry/react/components/form/styles.css +47 -0
  91. package/data/registry/react/components/form/types.ts +104 -0
  92. package/data/registry/react/components/form/use-sh-ui-form.ts +15 -0
  93. package/data/registry/react/components/form/utils.test.ts +44 -0
  94. package/data/registry/react/components/form/utils.ts +49 -0
  95. package/data/registry/react/components/form/validation.test.ts +67 -0
  96. package/data/registry/react/components/form/validation.ts +64 -0
  97. package/data/registry/react/components/form-rhf/README.md +27 -0
  98. package/data/registry/react/components/form-rhf/index.tsx +289 -0
  99. package/data/registry/react/components/form-rhf/rhf.test.tsx +42 -0
  100. package/data/registry/react/components/form-tanstack/README.md +27 -0
  101. package/data/registry/react/components/form-tanstack/index.tsx +352 -0
  102. package/data/registry/react/components/form-tanstack/tanstack.test.tsx +45 -0
  103. package/data/registry/react/components/form-yup/README.md +22 -0
  104. package/data/registry/react/components/form-yup/index.tsx +50 -0
  105. package/data/registry/react/components/form-yup/yup.test.ts +27 -0
  106. package/data/registry/react/components/header/index.tsx +257 -0
  107. package/data/registry/react/components/header/styles.css +190 -0
  108. package/data/registry/react/components/input/index.tsx +517 -0
  109. package/data/registry/react/components/input/styles.css +203 -0
  110. package/data/registry/react/components/label/index.tsx +54 -0
  111. package/data/registry/react/components/label/styles.css +90 -0
  112. package/data/registry/react/components/menubar/index.tsx +34 -0
  113. package/data/registry/react/components/menubar/styles.css +45 -0
  114. package/data/registry/react/components/pagination/index.tsx +271 -0
  115. package/data/registry/react/components/pagination/styles.css +105 -0
  116. package/data/registry/react/components/popover/index.tsx +115 -0
  117. package/data/registry/react/components/popover/styles.css +65 -0
  118. package/data/registry/react/components/progress/index.tsx +56 -0
  119. package/data/registry/react/components/progress/styles.css +41 -0
  120. package/data/registry/react/components/radio/index.tsx +67 -0
  121. package/data/registry/react/components/radio/styles.css +80 -0
  122. package/data/registry/react/components/select/index.tsx +236 -0
  123. package/data/registry/react/components/select/styles.css +193 -0
  124. package/data/registry/react/components/separator/index.tsx +48 -0
  125. package/data/registry/react/components/separator/styles.css +15 -0
  126. package/data/registry/react/components/sidebar/index.tsx +1084 -0
  127. package/data/registry/react/components/sidebar/styles.css +502 -0
  128. package/data/registry/react/components/skeleton/index.tsx +24 -0
  129. package/data/registry/react/components/skeleton/styles.css +24 -0
  130. package/data/registry/react/components/slider/index.tsx +300 -0
  131. package/data/registry/react/components/slider/styles.css +64 -0
  132. package/data/registry/react/components/spinner/index.tsx +40 -0
  133. package/data/registry/react/components/spinner/styles.css +37 -0
  134. package/data/registry/react/components/switch/index.tsx +41 -0
  135. package/data/registry/react/components/switch/styles.css +83 -0
  136. package/data/registry/react/components/tabs/index.tsx +93 -0
  137. package/data/registry/react/components/tabs/styles.css +148 -0
  138. package/data/registry/react/components/textarea/index.tsx +25 -0
  139. package/data/registry/react/components/textarea/styles.css +54 -0
  140. package/data/registry/react/components/theme/index.tsx +91 -0
  141. package/data/registry/react/components/toast/index.tsx +257 -0
  142. package/data/registry/react/components/toast/styles.css +290 -0
  143. package/data/registry/react/components/toggle/index.tsx +133 -0
  144. package/data/registry/react/components/toggle/styles.css +85 -0
  145. package/data/registry/react/components/tooltip/index.tsx +85 -0
  146. package/data/registry/react/components/tooltip/styles.css +44 -0
  147. package/data/registry/react/components/z-index/z-index.css +16 -0
  148. package/data/registry/react/hooks/use-active-section.ts +104 -0
  149. package/data/registry/react/hooks/use-media-query.ts +27 -0
  150. package/data/registry/react/lib/cn.ts +39 -0
  151. package/data/registry/react/registry.json +835 -0
  152. package/data/summaries/flutter.json +42 -0
  153. package/data/summaries/react.json +50 -0
  154. package/data/tokens/build.mjs +553 -0
  155. package/data/tokens/src/primitives.json +146 -0
  156. package/data/tokens/src/semantic.json +146 -0
  157. package/package.json +13 -4
  158. package/src/add.mjs +13 -12
  159. package/src/list.mjs +3 -11
  160. package/src/mcp.mjs +308 -0
  161. package/src/paths.mjs +52 -0
  162. 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
+ }