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,92 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { useSyncExternalStore } from "react";
5
+ import type { FormStore, FieldState } from "./types";
6
+
7
+ export const FormContext = React.createContext<FormStore<unknown> | null>(null);
8
+
9
+ export const SectionContext = React.createContext<{ path: string }>({ path: "" });
10
+
11
+ export const StepContext = React.createContext<{ id: string | null }>({ id: null });
12
+
13
+ export const FieldContext = React.createContext<{
14
+ path: string;
15
+ id: string;
16
+ descId: string;
17
+ errorId: string;
18
+ disabled?: boolean;
19
+ readOnly?: boolean;
20
+ required?: boolean;
21
+ } | null>(null);
22
+
23
+ export const DisabledContext = React.createContext<boolean>(false);
24
+
25
+ export function useFormContext<T = unknown>(): FormStore<T> {
26
+ const ctx = React.useContext(FormContext);
27
+ if (!ctx) throw new Error("useFormContext must be used inside <Form>");
28
+ return ctx as FormStore<T>;
29
+ }
30
+
31
+ export function useFormState() {
32
+ const store = useFormContext();
33
+ return useSyncExternalStore(
34
+ store.subscribe,
35
+ () => store.getState(),
36
+ () => store.getState()
37
+ );
38
+ }
39
+
40
+ export function useFormField(name?: string): FieldState & { path: string } {
41
+ const store = useFormContext();
42
+ const fieldCtx = React.useContext(FieldContext);
43
+ const path = name ?? fieldCtx?.path;
44
+ if (!path) throw new Error("useFormField: name is required outside <Form.Field>");
45
+
46
+ useSyncExternalStore(
47
+ store.subscribe,
48
+ () => {
49
+ const s = store.getState();
50
+ return JSON.stringify([
51
+ s.values[path],
52
+ s.errors[path],
53
+ s.touched[path],
54
+ s.validatingFields.has(path),
55
+ ]);
56
+ },
57
+ () => ""
58
+ );
59
+ const snapshot = store.getFieldState(path);
60
+ return { ...snapshot, path };
61
+ }
62
+
63
+ export function useFormSection(name?: string): {
64
+ hasError: boolean;
65
+ errors: Record<string, { message: string }>;
66
+ isValid: boolean;
67
+ isDirty: boolean;
68
+ } {
69
+ const store = useFormContext();
70
+ const sectionCtx = React.useContext(SectionContext);
71
+ const path = name ?? sectionCtx.path;
72
+
73
+ useSyncExternalStore(
74
+ store.subscribe,
75
+ () => JSON.stringify([store.getState().errors, store.getState().touched]),
76
+ () => ""
77
+ );
78
+
79
+ const state = store.getState();
80
+ const matching = Object.entries(state.errors).filter(
81
+ ([p, e]) => e && (p === path || p.startsWith(path + "."))
82
+ ) as Array<[string, { message: string }]>;
83
+ const touchedAny = Object.entries(state.touched).some(
84
+ ([p, t]) => t && (p === path || p.startsWith(path + "."))
85
+ );
86
+ return {
87
+ hasError: matching.length > 0,
88
+ errors: Object.fromEntries(matching),
89
+ isValid: matching.length === 0,
90
+ isDirty: touchedAny,
91
+ };
92
+ }
@@ -0,0 +1,230 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import * as React from "react";
5
+ import { Form } from "./form";
6
+ import {
7
+ Field,
8
+ FormLabel,
9
+ FormDescription,
10
+ FormError,
11
+ FormControl,
12
+ } from "./field";
13
+ import { FieldContext, FormContext } from "./context";
14
+
15
+ // ─────────────────────────────────────────────
16
+ // Form.Field
17
+ // ─────────────────────────────────────────────
18
+ describe("Form.Field", () => {
19
+ it("registers and unregisters field by name", () => {
20
+ let capturedStore: any = null;
21
+ function StoreProbe() {
22
+ const ctx = React.useContext(FormContext);
23
+ React.useEffect(() => {
24
+ capturedStore = ctx;
25
+ }, [ctx]);
26
+ return null;
27
+ }
28
+ const { rerender } = render(
29
+ <Form>
30
+ <StoreProbe />
31
+ <Field name="email" />
32
+ </Form>
33
+ );
34
+ expect(capturedStore.getState().fieldValidators.has("email")).toBe(true);
35
+ rerender(
36
+ <Form>
37
+ <StoreProbe />
38
+ </Form>
39
+ );
40
+ expect(capturedStore.getState().fieldValidators.has("email")).toBe(false);
41
+ });
42
+
43
+ it("applies section namespace to path", () => {
44
+ let pathSeen = "";
45
+ function Probe() {
46
+ const ctx = React.useContext(FieldContext);
47
+ pathSeen = ctx?.path ?? "";
48
+ return null;
49
+ }
50
+ render(
51
+ <Form>
52
+ <Form.Section name="profile">
53
+ <Field name="name">
54
+ <Probe />
55
+ </Field>
56
+ </Form.Section>
57
+ </Form>
58
+ );
59
+ expect(pathSeen).toBe("profile.name");
60
+ });
61
+ });
62
+
63
+ // ─────────────────────────────────────────────
64
+ // Form.Label
65
+ // ─────────────────────────────────────────────
66
+ describe("Form.Label", () => {
67
+ it("wires htmlFor to Field id", () => {
68
+ render(
69
+ <Form>
70
+ <Field name="email">
71
+ <FormLabel>Email</FormLabel>
72
+ <input data-testid="x" />
73
+ </Field>
74
+ </Form>
75
+ );
76
+ const label = screen.getByText("Email") as HTMLLabelElement;
77
+ expect(label.tagName).toBe("LABEL");
78
+ expect(label.htmlFor).toBeTruthy();
79
+ });
80
+ });
81
+
82
+ // ─────────────────────────────────────────────
83
+ // Form.Description
84
+ // ─────────────────────────────────────────────
85
+ describe("Form.Description", () => {
86
+ it("renders with correct descId", () => {
87
+ render(
88
+ <Form>
89
+ <Field name="x">
90
+ <FormDescription>help</FormDescription>
91
+ </Field>
92
+ </Form>
93
+ );
94
+ const desc = screen.getByText("help");
95
+ expect(desc.id).toMatch(/-desc$/);
96
+ });
97
+ });
98
+
99
+ // ─────────────────────────────────────────────
100
+ // Form.Error
101
+ // ─────────────────────────────────────────────
102
+ describe("Form.Error", () => {
103
+ it("does not render when no error", () => {
104
+ render(
105
+ <Form>
106
+ <Field name="x">
107
+ <FormError />
108
+ </Field>
109
+ </Form>
110
+ );
111
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
112
+ });
113
+ });
114
+
115
+ // ─────────────────────────────────────────────
116
+ // Form.Control — value binding
117
+ // ─────────────────────────────────────────────
118
+ describe("Form.Control — value binding", () => {
119
+ it("injects id/value/onChange and updates store on change", async () => {
120
+ const user = userEvent.setup();
121
+ render(
122
+ <Form defaultValues={{ email: "" }}>
123
+ <Field name="email">
124
+ <FormLabel>Email</FormLabel>
125
+ <FormControl>
126
+ <input data-testid="i" />
127
+ </FormControl>
128
+ </Field>
129
+ </Form>
130
+ );
131
+ const input = screen.getByTestId("i") as HTMLInputElement;
132
+ expect(input.id).toBeTruthy();
133
+ expect(screen.getByText("Email")).toHaveAttribute("for", input.id);
134
+ await user.type(input, "abc");
135
+ expect(input.value).toBe("abc");
136
+ });
137
+
138
+ it("includes descId in aria-describedby", () => {
139
+ render(
140
+ <Form>
141
+ <Field name="x">
142
+ <FormDescription>help</FormDescription>
143
+ <FormControl>
144
+ <input data-testid="i" />
145
+ </FormControl>
146
+ </Field>
147
+ </Form>
148
+ );
149
+ const input = screen.getByTestId("i") as HTMLInputElement;
150
+ expect(input.getAttribute("aria-describedby")).toMatch(/-desc/);
151
+ });
152
+ });
153
+
154
+ // ─────────────────────────────────────────────
155
+ // Form.Control — checked binding
156
+ // ─────────────────────────────────────────────
157
+ describe("Form.Control — checked binding", () => {
158
+ it("valueAs=checked with checkbox", async () => {
159
+ const user = userEvent.setup();
160
+ render(
161
+ <Form defaultValues={{ agree: false }}>
162
+ <Field name="agree">
163
+ <FormControl valueAs="checked">
164
+ <input type="checkbox" data-testid="cb" />
165
+ </FormControl>
166
+ </Field>
167
+ </Form>
168
+ );
169
+ const cb = screen.getByTestId("cb") as HTMLInputElement;
170
+ expect(cb.checked).toBe(false);
171
+ await user.click(cb);
172
+ expect(cb.checked).toBe(true);
173
+ });
174
+ });
175
+
176
+ // ─────────────────────────────────────────────
177
+ // Form.Control — render prop
178
+ // ─────────────────────────────────────────────
179
+ describe("Form.Control — render prop", () => {
180
+ it("passes ControlProps to render callback", () => {
181
+ render(
182
+ <Form>
183
+ <Field name="color">
184
+ <FormControl
185
+ render={(ctrl) => (
186
+ <div
187
+ data-testid="wrap"
188
+ data-id={ctrl.id}
189
+ data-name={ctrl.name}
190
+ />
191
+ )}
192
+ />
193
+ </Field>
194
+ </Form>
195
+ );
196
+ const wrap = screen.getByTestId("wrap");
197
+ expect(wrap.getAttribute("data-name")).toBe("color");
198
+ expect(wrap.getAttribute("data-id")).toBeTruthy();
199
+ });
200
+ });
201
+
202
+ // ─────────────────────────────────────────────
203
+ // validateOn blur → change on error
204
+ // ─────────────────────────────────────────────
205
+ describe("validateOn blur → change on error", () => {
206
+ it("once a field errors, subsequent onChange revalidates", async () => {
207
+ const user = userEvent.setup();
208
+ render(
209
+ <Form>
210
+ <Field
211
+ name="email"
212
+ validate={(v) =>
213
+ String(v).includes("@") ? undefined : "bad"
214
+ }
215
+ >
216
+ <FormControl>
217
+ <input data-testid="i" />
218
+ </FormControl>
219
+ <FormError />
220
+ </Field>
221
+ </Form>
222
+ );
223
+ const input = screen.getByTestId("i") as HTMLInputElement;
224
+ await user.type(input, "abc");
225
+ input.blur();
226
+ await screen.findByText("bad");
227
+ await user.type(input, "@x.com");
228
+ expect(screen.queryByText("bad")).not.toBeInTheDocument();
229
+ });
230
+ });
@@ -0,0 +1,236 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ FormContext,
6
+ FieldContext,
7
+ SectionContext,
8
+ StepContext,
9
+ DisabledContext,
10
+ useFormField,
11
+ } from "./context";
12
+ import type { FieldValidate, ValidateOn } from "./types";
13
+ import { scopedPath } from "./utils";
14
+
15
+ // ─────────────────────────────────────────────
16
+ // Field
17
+ // ─────────────────────────────────────────────
18
+
19
+ export interface FieldProps
20
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
21
+ name: string;
22
+ validate?: FieldValidate;
23
+ validateOn?: ValidateOn;
24
+ required?: boolean;
25
+ disabled?: boolean;
26
+ readOnly?: boolean;
27
+ children?: React.ReactNode;
28
+ }
29
+
30
+ export function Field({
31
+ name,
32
+ validate,
33
+ validateOn,
34
+ required,
35
+ disabled,
36
+ readOnly,
37
+ className,
38
+ children,
39
+ ...rest
40
+ }: FieldProps) {
41
+ const store = React.useContext(FormContext);
42
+ if (!store) throw new Error("<Form.Field> must be inside <Form>");
43
+
44
+ const section = React.useContext(SectionContext);
45
+ const step = React.useContext(StepContext);
46
+ const formDisabled = React.useContext(DisabledContext);
47
+ const path = scopedPath(section.path, name);
48
+
49
+ const id = React.useId();
50
+ const descId = `${id}-desc`;
51
+ const errorId = `${id}-error`;
52
+
53
+ React.useEffect(() => {
54
+ return store.registerField(path, {
55
+ validate,
56
+ validateOn,
57
+ stepId: step.id ?? undefined,
58
+ sectionPath: section.path || undefined,
59
+ required,
60
+ });
61
+ // eslint-disable-next-line react-hooks/exhaustive-deps
62
+ }, [store, path]);
63
+
64
+ const effectiveDisabled = disabled || formDisabled;
65
+
66
+ return (
67
+ <FieldContext.Provider
68
+ value={{
69
+ path,
70
+ id,
71
+ descId,
72
+ errorId,
73
+ disabled: effectiveDisabled,
74
+ readOnly,
75
+ required,
76
+ }}
77
+ >
78
+ <div
79
+ className={`sh-ui-form-field${className ? ` ${className}` : ""}`}
80
+ data-disabled={effectiveDisabled || undefined}
81
+ data-readonly={readOnly || undefined}
82
+ {...rest}
83
+ >
84
+ {children}
85
+ </div>
86
+ </FieldContext.Provider>
87
+ );
88
+ }
89
+
90
+ // ─────────────────────────────────────────────
91
+ // Label
92
+ // ─────────────────────────────────────────────
93
+
94
+ export const FormLabel = React.forwardRef<
95
+ HTMLLabelElement,
96
+ React.LabelHTMLAttributes<HTMLLabelElement>
97
+ >(({ className, ...props }, ref) => {
98
+ const ctx = React.useContext(FieldContext);
99
+ if (!ctx) throw new Error("<Form.Label> must be inside <Form.Field>");
100
+ return <label ref={ref} htmlFor={ctx.id} className={className} {...props} />;
101
+ });
102
+ FormLabel.displayName = "Form.Label";
103
+
104
+ // ─────────────────────────────────────────────
105
+ // Description
106
+ // ─────────────────────────────────────────────
107
+
108
+ export const FormDescription = React.forwardRef<
109
+ HTMLParagraphElement,
110
+ React.HTMLAttributes<HTMLParagraphElement>
111
+ >(({ className, ...props }, ref) => {
112
+ const ctx = React.useContext(FieldContext);
113
+ if (!ctx) throw new Error("<Form.Description> must be inside <Form.Field>");
114
+ return <p ref={ref} id={ctx.descId} className={className} {...props} />;
115
+ });
116
+ FormDescription.displayName = "Form.Description";
117
+
118
+ // ─────────────────────────────────────────────
119
+ // Error
120
+ // ─────────────────────────────────────────────
121
+
122
+ export interface FormErrorProps
123
+ extends Omit<React.HTMLAttributes<HTMLParagraphElement>, "children"> {
124
+ children?:
125
+ | React.ReactNode
126
+ | ((err: { message: string; type?: string }) => React.ReactNode);
127
+ matches?: string;
128
+ }
129
+
130
+ export function FormError({
131
+ children,
132
+ matches,
133
+ className,
134
+ ...rest
135
+ }: FormErrorProps) {
136
+ const ctx = React.useContext(FieldContext);
137
+ if (!ctx) throw new Error("<Form.Error> must be inside <Form.Field>");
138
+ const field = useFormField(ctx.path);
139
+
140
+ const err = field.error;
141
+ if (!err) return null;
142
+ if (matches && err.type !== matches) return null;
143
+
144
+ const content =
145
+ typeof children === "function"
146
+ ? (children as (e: { message: string; type?: string }) => React.ReactNode)(err)
147
+ : children ?? err.message;
148
+
149
+ return (
150
+ <p
151
+ id={ctx.errorId}
152
+ className={`sh-ui-form-error${className ? ` ${className}` : ""}`}
153
+ role="alert"
154
+ aria-live="polite"
155
+ {...rest}
156
+ >
157
+ {content}
158
+ </p>
159
+ );
160
+ }
161
+
162
+ // ─────────────────────────────────────────────
163
+ // Control
164
+ // ─────────────────────────────────────────────
165
+
166
+ export interface ControlProps {
167
+ id: string;
168
+ name: string;
169
+ value?: unknown;
170
+ checked?: boolean;
171
+ onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
172
+ onBlur: () => void;
173
+ "aria-invalid"?: true;
174
+ "aria-describedby"?: string;
175
+ "aria-required"?: true;
176
+ disabled?: boolean;
177
+ readOnly?: boolean;
178
+ required?: boolean;
179
+ }
180
+
181
+ export interface FormControlProps {
182
+ children?: React.ReactElement;
183
+ valueAs?: "value" | "checked";
184
+ render?: (ctrl: ControlProps) => React.ReactElement;
185
+ }
186
+
187
+ export function FormControl({
188
+ children,
189
+ valueAs = "value",
190
+ render,
191
+ }: FormControlProps) {
192
+ const ctx = React.useContext(FieldContext);
193
+ if (!ctx) throw new Error("<Form.Control> must be inside <Form.Field>");
194
+ const store = React.useContext(FormContext)!;
195
+ const field = useFormField(ctx.path);
196
+
197
+ const describedBy =
198
+ [ctx.descId, field.hasError ? ctx.errorId : null]
199
+ .filter(Boolean)
200
+ .join(" ") || undefined;
201
+
202
+ const ctrl: ControlProps = {
203
+ id: ctx.id,
204
+ name: ctx.path,
205
+ onChange: (e) => {
206
+ const target = e.target as HTMLInputElement;
207
+ const next = valueAs === "checked" ? target.checked : target.value;
208
+ store.setFieldValue(ctx.path, next);
209
+ if (store.getState().revalidateOnChange.has(ctx.path)) {
210
+ void store.validateField(ctx.path);
211
+ }
212
+ },
213
+ onBlur: () => {
214
+ store.setFieldTouched(ctx.path, true);
215
+ void store.validateField(ctx.path);
216
+ },
217
+ "aria-describedby": describedBy,
218
+ disabled: ctx.disabled,
219
+ readOnly: ctx.readOnly,
220
+ required: ctx.required,
221
+ };
222
+
223
+ if (field.hasError) ctrl["aria-invalid"] = true;
224
+ if (ctx.required) ctrl["aria-required"] = true;
225
+
226
+ if (valueAs === "checked") {
227
+ ctrl.checked = Boolean(field.value);
228
+ } else {
229
+ ctrl.value = field.value ?? "";
230
+ }
231
+
232
+ if (render) return render(ctrl);
233
+ if (!children) return null;
234
+ const child = React.Children.only(children);
235
+ return React.cloneElement(child, ctrl as unknown as Record<string, unknown>);
236
+ }
@@ -0,0 +1,54 @@
1
+ import type { FormStore } from "./types";
2
+
3
+ function getPrefersReducedMotion(): boolean {
4
+ if (typeof window === "undefined") return false;
5
+ try {
6
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
11
+
12
+ export function focusFirstError(
13
+ store: FormStore,
14
+ formEl: HTMLFormElement,
15
+ scroll: boolean
16
+ ) {
17
+ const state = store.getState();
18
+ const errorPaths = Object.keys(state.errors).filter((p) => state.errors[p]);
19
+ if (errorPaths.length === 0) return;
20
+
21
+ const allInputs = formEl.querySelectorAll<HTMLElement>("[name]");
22
+ for (const el of Array.from(allInputs)) {
23
+ const name = el.getAttribute("name");
24
+ if (name && errorPaths.includes(name)) {
25
+ // Check if field is in a step and that step is inactive
26
+ for (const [stepId, fields] of state.fieldsByStep) {
27
+ if (fields.has(name) && stepId !== state.activeStepId) {
28
+ store.setActiveStep(stepId);
29
+ requestAnimationFrame(() => {
30
+ const retry = formEl.querySelector<HTMLElement>(`[name="${name}"]`);
31
+ retry?.focus({ preventScroll: true });
32
+ if (scroll && retry && typeof retry.scrollIntoView === "function") {
33
+ const reduce = getPrefersReducedMotion();
34
+ retry.scrollIntoView({
35
+ block: "center",
36
+ behavior: reduce ? "auto" : "smooth",
37
+ });
38
+ }
39
+ });
40
+ return;
41
+ }
42
+ }
43
+ el.focus({ preventScroll: true });
44
+ if (scroll && typeof el.scrollIntoView === "function") {
45
+ const reduce = getPrefersReducedMotion();
46
+ el.scrollIntoView({
47
+ block: "center",
48
+ behavior: reduce ? "auto" : "smooth",
49
+ });
50
+ }
51
+ return;
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import * as React from "react";
4
+ import { Form } from "./form";
5
+ import { FormContext } from "./context";
6
+
7
+ describe("Form.Section", () => {
8
+ it("renders as role=group by default", () => {
9
+ render(
10
+ <Form>
11
+ <Form.Section name="profile" aria-labelledby="p-title">
12
+ <h2 id="p-title">Profile</h2>
13
+ </Form.Section>
14
+ </Form>
15
+ );
16
+ expect(screen.getByRole("group")).toBeInTheDocument();
17
+ });
18
+
19
+ it("renders as fieldset when as=fieldset", () => {
20
+ const { container } = render(
21
+ <Form>
22
+ <Form.Section name="x" as="fieldset">
23
+ <Form.SectionTitle>Title</Form.SectionTitle>
24
+ </Form.Section>
25
+ </Form>
26
+ );
27
+ expect(container.querySelector("fieldset")).toBeTruthy();
28
+ expect(container.querySelector("legend")?.textContent).toBe("Title");
29
+ });
30
+
31
+ it("registers section schema when provided", async () => {
32
+ const schema = {
33
+ "~standard": {
34
+ version: 1 as const,
35
+ vendor: "test",
36
+ validate: (_v: unknown) => ({ issues: [{ path: ["x"], message: "bad" }] }),
37
+ },
38
+ };
39
+
40
+ let captured: any = null;
41
+ function Probe() {
42
+ const store = React.useContext(FormContext);
43
+ captured = store;
44
+ return null;
45
+ }
46
+
47
+ render(
48
+ <Form>
49
+ <Form.Section name="profile" schema={schema}>
50
+ <Probe />
51
+ </Form.Section>
52
+ </Form>
53
+ );
54
+
55
+ // schema should be registered under "profile"
56
+ expect(captured.getState().sectionSchemas.has("profile")).toBe(true);
57
+ });
58
+ });