@emara/ui 1.1.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 (218) hide show
  1. package/components/ui/.gitkeep +0 -0
  2. package/components/ui/accordion.stories.tsx +231 -0
  3. package/components/ui/accordion.tsx +250 -0
  4. package/components/ui/app-shell.stories.tsx +270 -0
  5. package/components/ui/app-shell.tsx +491 -0
  6. package/components/ui/avatar.stories.tsx +174 -0
  7. package/components/ui/avatar.tsx +257 -0
  8. package/components/ui/badge.stories.tsx +127 -0
  9. package/components/ui/badge.tsx +146 -0
  10. package/components/ui/breadcrumb.stories.tsx +92 -0
  11. package/components/ui/breadcrumb.tsx +302 -0
  12. package/components/ui/button.stories.tsx +186 -0
  13. package/components/ui/button.tsx +128 -0
  14. package/components/ui/card.stories.tsx +279 -0
  15. package/components/ui/card.tsx +250 -0
  16. package/components/ui/checkbox.stories.tsx +93 -0
  17. package/components/ui/checkbox.tsx +131 -0
  18. package/components/ui/combobox.stories.tsx +489 -0
  19. package/components/ui/combobox.tsx +874 -0
  20. package/components/ui/context-menu.stories.tsx +202 -0
  21. package/components/ui/context-menu.tsx +309 -0
  22. package/components/ui/data-table.stories.tsx +227 -0
  23. package/components/ui/data-table.tsx +539 -0
  24. package/components/ui/date-picker.stories.tsx +225 -0
  25. package/components/ui/date-picker.tsx +597 -0
  26. package/components/ui/dialog.stories.tsx +193 -0
  27. package/components/ui/dialog.tsx +262 -0
  28. package/components/ui/divider.stories.tsx +84 -0
  29. package/components/ui/divider.tsx +135 -0
  30. package/components/ui/drawer.stories.tsx +218 -0
  31. package/components/ui/drawer.tsx +329 -0
  32. package/components/ui/dropdown-menu.stories.tsx +270 -0
  33. package/components/ui/dropdown-menu.tsx +353 -0
  34. package/components/ui/empty-state.stories.tsx +121 -0
  35. package/components/ui/empty-state.tsx +289 -0
  36. package/components/ui/field-group.stories.tsx +201 -0
  37. package/components/ui/field-group.tsx +276 -0
  38. package/components/ui/form.stories.tsx +219 -0
  39. package/components/ui/form.tsx +542 -0
  40. package/components/ui/input.stories.tsx +154 -0
  41. package/components/ui/input.tsx +208 -0
  42. package/components/ui/label.stories.tsx +84 -0
  43. package/components/ui/label.tsx +98 -0
  44. package/components/ui/page-header.stories.tsx +136 -0
  45. package/components/ui/page-header.tsx +315 -0
  46. package/components/ui/pagination.stories.tsx +136 -0
  47. package/components/ui/pagination.tsx +427 -0
  48. package/components/ui/popover.stories.tsx +212 -0
  49. package/components/ui/popover.tsx +167 -0
  50. package/components/ui/radio-group.stories.tsx +96 -0
  51. package/components/ui/radio-group.tsx +250 -0
  52. package/components/ui/select.stories.tsx +203 -0
  53. package/components/ui/select.tsx +318 -0
  54. package/components/ui/sidebar.stories.tsx +186 -0
  55. package/components/ui/sidebar.tsx +623 -0
  56. package/components/ui/skeleton.stories.tsx +131 -0
  57. package/components/ui/skeleton.tsx +311 -0
  58. package/components/ui/switch.stories.tsx +74 -0
  59. package/components/ui/switch.tsx +186 -0
  60. package/components/ui/table.stories.tsx +107 -0
  61. package/components/ui/table.tsx +285 -0
  62. package/components/ui/tabs.stories.tsx +222 -0
  63. package/components/ui/tabs.tsx +287 -0
  64. package/components/ui/textarea.stories.tsx +96 -0
  65. package/components/ui/textarea.tsx +182 -0
  66. package/components/ui/toast.stories.tsx +169 -0
  67. package/components/ui/toast.tsx +250 -0
  68. package/components/ui/tooltip.stories.tsx +146 -0
  69. package/components/ui/tooltip.tsx +156 -0
  70. package/components/ui/top-bar.stories.tsx +182 -0
  71. package/components/ui/top-bar.tsx +155 -0
  72. package/dist/components/ui/accordion.d.ts +45 -0
  73. package/dist/components/ui/accordion.d.ts.map +1 -0
  74. package/dist/components/ui/accordion.js +99 -0
  75. package/dist/components/ui/accordion.js.map +1 -0
  76. package/dist/components/ui/app-shell.d.ts +70 -0
  77. package/dist/components/ui/app-shell.d.ts.map +1 -0
  78. package/dist/components/ui/app-shell.js +199 -0
  79. package/dist/components/ui/app-shell.js.map +1 -0
  80. package/dist/components/ui/avatar.d.ts +41 -0
  81. package/dist/components/ui/avatar.d.ts.map +1 -0
  82. package/dist/components/ui/avatar.js +104 -0
  83. package/dist/components/ui/avatar.js.map +1 -0
  84. package/dist/components/ui/badge.d.ts +27 -0
  85. package/dist/components/ui/badge.d.ts.map +1 -0
  86. package/dist/components/ui/badge.js +65 -0
  87. package/dist/components/ui/badge.js.map +1 -0
  88. package/dist/components/ui/breadcrumb.d.ts +35 -0
  89. package/dist/components/ui/breadcrumb.d.ts.map +1 -0
  90. package/dist/components/ui/breadcrumb.js +88 -0
  91. package/dist/components/ui/breadcrumb.js.map +1 -0
  92. package/dist/components/ui/button.d.ts +26 -0
  93. package/dist/components/ui/button.d.ts.map +1 -0
  94. package/dist/components/ui/button.js +73 -0
  95. package/dist/components/ui/button.js.map +1 -0
  96. package/dist/components/ui/card.d.ts +52 -0
  97. package/dist/components/ui/card.d.ts.map +1 -0
  98. package/dist/components/ui/card.js +96 -0
  99. package/dist/components/ui/card.js.map +1 -0
  100. package/dist/components/ui/checkbox.d.ts +18 -0
  101. package/dist/components/ui/checkbox.d.ts.map +1 -0
  102. package/dist/components/ui/checkbox.js +59 -0
  103. package/dist/components/ui/checkbox.js.map +1 -0
  104. package/dist/components/ui/combobox.d.ts +194 -0
  105. package/dist/components/ui/combobox.d.ts.map +1 -0
  106. package/dist/components/ui/combobox.js +361 -0
  107. package/dist/components/ui/combobox.js.map +1 -0
  108. package/dist/components/ui/context-menu.d.ts +46 -0
  109. package/dist/components/ui/context-menu.d.ts.map +1 -0
  110. package/dist/components/ui/context-menu.js +95 -0
  111. package/dist/components/ui/context-menu.js.map +1 -0
  112. package/dist/components/ui/data-table.d.ts +53 -0
  113. package/dist/components/ui/data-table.d.ts.map +1 -0
  114. package/dist/components/ui/data-table.js +163 -0
  115. package/dist/components/ui/data-table.js.map +1 -0
  116. package/dist/components/ui/date-picker.d.ts +103 -0
  117. package/dist/components/ui/date-picker.d.ts.map +1 -0
  118. package/dist/components/ui/date-picker.js +306 -0
  119. package/dist/components/ui/date-picker.js.map +1 -0
  120. package/dist/components/ui/dialog.d.ts +40 -0
  121. package/dist/components/ui/dialog.d.ts.map +1 -0
  122. package/dist/components/ui/dialog.js +110 -0
  123. package/dist/components/ui/dialog.js.map +1 -0
  124. package/dist/components/ui/divider.d.ts +30 -0
  125. package/dist/components/ui/divider.d.ts.map +1 -0
  126. package/dist/components/ui/divider.js +62 -0
  127. package/dist/components/ui/divider.js.map +1 -0
  128. package/dist/components/ui/drawer.d.ts +56 -0
  129. package/dist/components/ui/drawer.d.ts.map +1 -0
  130. package/dist/components/ui/drawer.js +147 -0
  131. package/dist/components/ui/drawer.js.map +1 -0
  132. package/dist/components/ui/dropdown-menu.d.ts +63 -0
  133. package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
  134. package/dist/components/ui/dropdown-menu.js +116 -0
  135. package/dist/components/ui/dropdown-menu.js.map +1 -0
  136. package/dist/components/ui/empty-state.d.ts +43 -0
  137. package/dist/components/ui/empty-state.d.ts.map +1 -0
  138. package/dist/components/ui/empty-state.js +128 -0
  139. package/dist/components/ui/empty-state.js.map +1 -0
  140. package/dist/components/ui/field-group.d.ts +38 -0
  141. package/dist/components/ui/field-group.d.ts.map +1 -0
  142. package/dist/components/ui/field-group.js +107 -0
  143. package/dist/components/ui/field-group.js.map +1 -0
  144. package/dist/components/ui/form.d.ts +67 -0
  145. package/dist/components/ui/form.d.ts.map +1 -0
  146. package/dist/components/ui/form.js +286 -0
  147. package/dist/components/ui/form.js.map +1 -0
  148. package/dist/components/ui/input.d.ts +36 -0
  149. package/dist/components/ui/input.d.ts.map +1 -0
  150. package/dist/components/ui/input.js +99 -0
  151. package/dist/components/ui/input.js.map +1 -0
  152. package/dist/components/ui/label.d.ts +37 -0
  153. package/dist/components/ui/label.d.ts.map +1 -0
  154. package/dist/components/ui/label.js +34 -0
  155. package/dist/components/ui/label.js.map +1 -0
  156. package/dist/components/ui/page-header.d.ts +65 -0
  157. package/dist/components/ui/page-header.d.ts.map +1 -0
  158. package/dist/components/ui/page-header.js +140 -0
  159. package/dist/components/ui/page-header.js.map +1 -0
  160. package/dist/components/ui/pagination.d.ts +67 -0
  161. package/dist/components/ui/pagination.d.ts.map +1 -0
  162. package/dist/components/ui/pagination.js +109 -0
  163. package/dist/components/ui/pagination.js.map +1 -0
  164. package/dist/components/ui/popover.d.ts +28 -0
  165. package/dist/components/ui/popover.d.ts.map +1 -0
  166. package/dist/components/ui/popover.js +85 -0
  167. package/dist/components/ui/popover.js.map +1 -0
  168. package/dist/components/ui/radio-group.d.ts +35 -0
  169. package/dist/components/ui/radio-group.d.ts.map +1 -0
  170. package/dist/components/ui/radio-group.js +103 -0
  171. package/dist/components/ui/radio-group.js.map +1 -0
  172. package/dist/components/ui/select.d.ts +42 -0
  173. package/dist/components/ui/select.d.ts.map +1 -0
  174. package/dist/components/ui/select.js +86 -0
  175. package/dist/components/ui/select.js.map +1 -0
  176. package/dist/components/ui/sidebar.d.ts +59 -0
  177. package/dist/components/ui/sidebar.d.ts.map +1 -0
  178. package/dist/components/ui/sidebar.js +189 -0
  179. package/dist/components/ui/sidebar.js.map +1 -0
  180. package/dist/components/ui/skeleton.d.ts +77 -0
  181. package/dist/components/ui/skeleton.d.ts.map +1 -0
  182. package/dist/components/ui/skeleton.js +115 -0
  183. package/dist/components/ui/skeleton.js.map +1 -0
  184. package/dist/components/ui/switch.d.ts +26 -0
  185. package/dist/components/ui/switch.d.ts.map +1 -0
  186. package/dist/components/ui/switch.js +84 -0
  187. package/dist/components/ui/switch.js.map +1 -0
  188. package/dist/components/ui/table.d.ts +52 -0
  189. package/dist/components/ui/table.d.ts.map +1 -0
  190. package/dist/components/ui/table.js +109 -0
  191. package/dist/components/ui/table.js.map +1 -0
  192. package/dist/components/ui/tabs.d.ts +42 -0
  193. package/dist/components/ui/tabs.d.ts.map +1 -0
  194. package/dist/components/ui/tabs.js +163 -0
  195. package/dist/components/ui/tabs.js.map +1 -0
  196. package/dist/components/ui/textarea.d.ts +26 -0
  197. package/dist/components/ui/textarea.d.ts.map +1 -0
  198. package/dist/components/ui/textarea.js +96 -0
  199. package/dist/components/ui/textarea.js.map +1 -0
  200. package/dist/components/ui/toast.d.ts +77 -0
  201. package/dist/components/ui/toast.d.ts.map +1 -0
  202. package/dist/components/ui/toast.js +141 -0
  203. package/dist/components/ui/toast.js.map +1 -0
  204. package/dist/components/ui/tooltip.d.ts +31 -0
  205. package/dist/components/ui/tooltip.d.ts.map +1 -0
  206. package/dist/components/ui/tooltip.js +71 -0
  207. package/dist/components/ui/tooltip.js.map +1 -0
  208. package/dist/components/ui/top-bar.d.ts +30 -0
  209. package/dist/components/ui/top-bar.d.ts.map +1 -0
  210. package/dist/components/ui/top-bar.js +64 -0
  211. package/dist/components/ui/top-bar.js.map +1 -0
  212. package/dist/lib/utils.d.ts +3 -0
  213. package/dist/lib/utils.d.ts.map +1 -0
  214. package/dist/lib/utils.js +6 -0
  215. package/dist/lib/utils.js.map +1 -0
  216. package/lib/utils.ts +6 -0
  217. package/package.json +112 -0
  218. package/styles/globals.css +685 -0
@@ -0,0 +1,542 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ forwardRef,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useId,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ type FormHTMLAttributes,
14
+ type ReactNode,
15
+ } from "react";
16
+ import {
17
+ Controller,
18
+ FormProvider,
19
+ useFormContext,
20
+ type Control,
21
+ type FieldValues,
22
+ type Path,
23
+ type RegisterOptions,
24
+ type UseFormReturn,
25
+ } from "react-hook-form";
26
+
27
+ import { cn } from "@/lib/utils";
28
+
29
+ // Per docs/emara-ui-phase-2-components.md §8.
30
+ //
31
+ // Two modes:
32
+ // - "plain" → FormField owns its own state via useState + a small internal
33
+ // store. Zero RHF dependency in the consumer's tree.
34
+ // - "rhf" → FormField wraps RHF's Controller; the consumer passes
35
+ // `form={useForm(...)}`.
36
+ //
37
+ // Components rendered inside (Input, Textarea, Select, etc.) only see the
38
+ // render-prop API and don't know which mode they're in.
39
+
40
+ // ----------------------------------------------------------------------------
41
+ // Public render-prop API — shared by both modes.
42
+ // ----------------------------------------------------------------------------
43
+
44
+ export interface FormRenderField {
45
+ name: string;
46
+ value: unknown;
47
+ onChange: (value: unknown) => void;
48
+ onBlur: () => void;
49
+ /**
50
+ * Convenience prop bundle that can be spread directly onto Emara controls
51
+ * (Input, Textarea, Select, etc.). Maps `onChange` to the native event
52
+ * shape consumed by `<input>` / `<textarea>` / `<select>`.
53
+ */
54
+ inputProps: {
55
+ name: string;
56
+ value: string;
57
+ onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
58
+ onBlur: () => void;
59
+ };
60
+ }
61
+
62
+ export interface FormRenderFieldState {
63
+ invalid: boolean;
64
+ isDirty: boolean;
65
+ isTouched: boolean;
66
+ error: { message?: string } | undefined;
67
+ }
68
+
69
+ type FormFieldRender = (args: {
70
+ field: FormRenderField;
71
+ fieldState: FormRenderFieldState;
72
+ }) => ReactNode;
73
+
74
+ // ----------------------------------------------------------------------------
75
+ // Form context — captures the current mode + a status bag for autoSave.
76
+ // ----------------------------------------------------------------------------
77
+
78
+ type AutoSaveStatus = "idle" | "saving" | "saved" | "error";
79
+
80
+ interface FormContextValue {
81
+ mode: "plain" | "rhf";
82
+ loading: boolean;
83
+ autoSaveStatus: AutoSaveStatus;
84
+ /**
85
+ * Plain-mode value store. Only populated when `mode === "plain"`. Used by
86
+ * FormField to read/write values without a RHF Controller.
87
+ */
88
+ plainStore: {
89
+ getValues: () => Record<string, unknown>;
90
+ getValue: (name: string) => unknown;
91
+ setValue: (name: string, value: unknown) => void;
92
+ registerField: (name: string, defaultValue: unknown) => void;
93
+ subscribe: (listener: () => void) => () => void;
94
+ setError: (name: string, message: string | undefined) => void;
95
+ getError: (name: string) => string | undefined;
96
+ setTouched: (name: string) => void;
97
+ isTouched: (name: string) => boolean;
98
+ isDirty: (name: string) => boolean;
99
+ } | null;
100
+ }
101
+
102
+ const FormCtx = createContext<FormContextValue | null>(null);
103
+
104
+ function useFormCtx(): FormContextValue {
105
+ const ctx = useContext(FormCtx);
106
+ if (!ctx) throw new Error("FormField must be used inside a <Form>.");
107
+ return ctx;
108
+ }
109
+
110
+ // ----------------------------------------------------------------------------
111
+ // Plain-mode store — tiny pub/sub keyed by field name.
112
+ // ----------------------------------------------------------------------------
113
+
114
+ function createPlainStore() {
115
+ const values = new Map<string, unknown>();
116
+ const initials = new Map<string, unknown>();
117
+ const errors = new Map<string, string | undefined>();
118
+ const touched = new Set<string>();
119
+ const listeners = new Set<() => void>();
120
+
121
+ const emit = () => {
122
+ for (const fn of listeners) fn();
123
+ };
124
+
125
+ return {
126
+ getValues: () => Object.fromEntries(values),
127
+ getValue: (name: string) => values.get(name),
128
+ setValue: (name: string, value: unknown) => {
129
+ values.set(name, value);
130
+ emit();
131
+ },
132
+ registerField: (name: string, defaultValue: unknown) => {
133
+ if (!values.has(name)) {
134
+ values.set(name, defaultValue);
135
+ initials.set(name, defaultValue);
136
+ }
137
+ },
138
+ subscribe: (listener: () => void) => {
139
+ listeners.add(listener);
140
+ return () => {
141
+ listeners.delete(listener);
142
+ };
143
+ },
144
+ setError: (name: string, message: string | undefined) => {
145
+ if (message) errors.set(name, message);
146
+ else errors.delete(name);
147
+ emit();
148
+ },
149
+ getError: (name: string) => errors.get(name),
150
+ setTouched: (name: string) => {
151
+ touched.add(name);
152
+ emit();
153
+ },
154
+ isTouched: (name: string) => touched.has(name),
155
+ isDirty: (name: string) => values.get(name) !== initials.get(name),
156
+ };
157
+ }
158
+
159
+ // ----------------------------------------------------------------------------
160
+ // Form root
161
+ // ----------------------------------------------------------------------------
162
+
163
+ type AutoSaveOptions<T extends FieldValues = FieldValues> = {
164
+ delay: number;
165
+ onSave: (values: T) => Promise<void> | void;
166
+ };
167
+
168
+ type FormPlainProps = Omit<FormHTMLAttributes<HTMLFormElement>, "onSubmit"> & {
169
+ mode?: "plain";
170
+ onSubmit?: (values: Record<string, unknown>) => void | Promise<void>;
171
+ loading?: boolean;
172
+ confirmBeforeLeave?: boolean;
173
+ autoSave?: AutoSaveOptions;
174
+ };
175
+
176
+ type FormRhfProps<T extends FieldValues> = Omit<
177
+ FormHTMLAttributes<HTMLFormElement>,
178
+ "onSubmit"
179
+ > & {
180
+ mode: "rhf";
181
+ form: UseFormReturn<T>;
182
+ onSubmit?: (values: T) => void | Promise<void>;
183
+ loading?: boolean;
184
+ confirmBeforeLeave?: boolean;
185
+ autoSave?: AutoSaveOptions<T>;
186
+ };
187
+
188
+ type FormProps<T extends FieldValues = FieldValues> =
189
+ | FormPlainProps
190
+ | FormRhfProps<T>;
191
+
192
+ function Form<T extends FieldValues = FieldValues>(props: FormProps<T>) {
193
+ const { mode = "plain" } = props;
194
+ if (mode === "rhf") {
195
+ return <FormRhf {...(props as FormRhfProps<T>)} />;
196
+ }
197
+ return <FormPlain {...(props as FormPlainProps)} />;
198
+ }
199
+
200
+ // ----- Plain implementation --------------------------------------------------
201
+
202
+ function FormPlain({
203
+ onSubmit,
204
+ loading = false,
205
+ confirmBeforeLeave = false,
206
+ autoSave,
207
+ className,
208
+ children,
209
+ ...formProps
210
+ }: FormPlainProps) {
211
+ const store = useMemo(() => createPlainStore(), []);
212
+ const [autoSaveStatus, setAutoSaveStatus] = useState<AutoSaveStatus>("idle");
213
+
214
+ // confirmBeforeLeave — warn if any field is dirty.
215
+ useEffect(() => {
216
+ if (!confirmBeforeLeave) return;
217
+ const handler = (e: BeforeUnloadEvent) => {
218
+ const anyDirty = Object.keys(store.getValues()).some((n) => store.isDirty(n));
219
+ if (anyDirty) {
220
+ e.preventDefault();
221
+ e.returnValue = "";
222
+ }
223
+ };
224
+ window.addEventListener("beforeunload", handler);
225
+ return () => {
226
+ window.removeEventListener("beforeunload", handler);
227
+ };
228
+ }, [confirmBeforeLeave, store]);
229
+
230
+ // autoSave — debounce changes, call onSave.
231
+ useEffect(() => {
232
+ if (!autoSave) return;
233
+ let timer: ReturnType<typeof setTimeout> | null = null;
234
+ const unsubscribe = store.subscribe(() => {
235
+ if (timer) clearTimeout(timer);
236
+ timer = setTimeout(async () => {
237
+ setAutoSaveStatus("saving");
238
+ try {
239
+ await autoSave.onSave(store.getValues() as FieldValues);
240
+ setAutoSaveStatus("saved");
241
+ } catch {
242
+ setAutoSaveStatus("error");
243
+ }
244
+ }, autoSave.delay);
245
+ });
246
+ return () => {
247
+ if (timer) clearTimeout(timer);
248
+ unsubscribe();
249
+ };
250
+ }, [autoSave, store]);
251
+
252
+ const ctxValue: FormContextValue = useMemo(
253
+ () => ({ mode: "plain", loading, autoSaveStatus, plainStore: store }),
254
+ [loading, autoSaveStatus, store],
255
+ );
256
+
257
+ return (
258
+ <FormCtx.Provider value={ctxValue}>
259
+ <form
260
+ noValidate
261
+ onSubmit={(e) => {
262
+ e.preventDefault();
263
+ onSubmit?.(store.getValues());
264
+ }}
265
+ {...formProps}
266
+ >
267
+ {/* `<fieldset disabled>` cascades the disabled state to all native
268
+ form controls inside. We strip the browser's default chrome and
269
+ carry the consumer's className here so layout utilities like
270
+ `space-y-*` apply to the fields (they would not on the <form>,
271
+ because the fieldset would be the only DOM child). */}
272
+ <fieldset
273
+ disabled={loading}
274
+ className={cn("min-w-0 m-0 p-0 border-0", className)}
275
+ >
276
+ {children}
277
+ </fieldset>
278
+ </form>
279
+ </FormCtx.Provider>
280
+ );
281
+ }
282
+
283
+ // ----- RHF implementation ----------------------------------------------------
284
+
285
+ function FormRhf<T extends FieldValues>({
286
+ form,
287
+ onSubmit,
288
+ loading = false,
289
+ confirmBeforeLeave = false,
290
+ autoSave,
291
+ className,
292
+ children,
293
+ ...formProps
294
+ }: FormRhfProps<T>) {
295
+ const [autoSaveStatus, setAutoSaveStatus] = useState<AutoSaveStatus>("idle");
296
+
297
+ useEffect(() => {
298
+ if (!confirmBeforeLeave) return;
299
+ const handler = (e: BeforeUnloadEvent) => {
300
+ if (form.formState.isDirty) {
301
+ e.preventDefault();
302
+ e.returnValue = "";
303
+ }
304
+ };
305
+ window.addEventListener("beforeunload", handler);
306
+ return () => {
307
+ window.removeEventListener("beforeunload", handler);
308
+ };
309
+ }, [confirmBeforeLeave, form]);
310
+
311
+ useEffect(() => {
312
+ if (!autoSave) return;
313
+ let timer: ReturnType<typeof setTimeout> | null = null;
314
+ const subscription = form.watch((values) => {
315
+ if (timer) clearTimeout(timer);
316
+ timer = setTimeout(async () => {
317
+ setAutoSaveStatus("saving");
318
+ try {
319
+ await autoSave.onSave(values as T);
320
+ setAutoSaveStatus("saved");
321
+ } catch {
322
+ setAutoSaveStatus("error");
323
+ }
324
+ }, autoSave.delay);
325
+ });
326
+ return () => {
327
+ if (timer) clearTimeout(timer);
328
+ subscription.unsubscribe();
329
+ };
330
+ }, [autoSave, form]);
331
+
332
+ const ctxValue: FormContextValue = useMemo(
333
+ () => ({ mode: "rhf", loading, autoSaveStatus, plainStore: null }),
334
+ [loading, autoSaveStatus],
335
+ );
336
+
337
+ return (
338
+ <FormCtx.Provider value={ctxValue}>
339
+ <FormProvider {...(form as unknown as UseFormReturn<FieldValues>)}>
340
+ <form
341
+ noValidate
342
+ onSubmit={form.handleSubmit(async (values) => {
343
+ await onSubmit?.(values);
344
+ })}
345
+ {...formProps}
346
+ >
347
+ <fieldset
348
+ disabled={loading}
349
+ className={cn("min-w-0 m-0 p-0 border-0", className)}
350
+ >
351
+ {children}
352
+ </fieldset>
353
+ </form>
354
+ </FormProvider>
355
+ </FormCtx.Provider>
356
+ );
357
+ }
358
+
359
+ // ----------------------------------------------------------------------------
360
+ // FormField
361
+ // ----------------------------------------------------------------------------
362
+
363
+ interface FormFieldProps {
364
+ name: string;
365
+ defaultValue?: unknown;
366
+ rules?: RegisterOptions;
367
+ /** Plain-mode only. Synchronous validator returning an error message. */
368
+ validate?: (value: unknown) => string | undefined;
369
+ render: FormFieldRender;
370
+ }
371
+
372
+ function FormField({ name, defaultValue, rules, validate, render }: FormFieldProps) {
373
+ const ctx = useFormCtx();
374
+ if (ctx.mode === "rhf") {
375
+ return rules !== undefined ? (
376
+ <FormFieldRhf name={name} defaultValue={defaultValue} rules={rules} render={render} />
377
+ ) : (
378
+ <FormFieldRhf name={name} defaultValue={defaultValue} render={render} />
379
+ );
380
+ }
381
+ return validate !== undefined ? (
382
+ <FormFieldPlain
383
+ name={name}
384
+ defaultValue={defaultValue}
385
+ validate={validate}
386
+ render={render}
387
+ />
388
+ ) : (
389
+ <FormFieldPlain name={name} defaultValue={defaultValue} render={render} />
390
+ );
391
+ }
392
+
393
+ // ----- Plain field -----------------------------------------------------------
394
+
395
+ function FormFieldPlain({
396
+ name,
397
+ defaultValue,
398
+ validate,
399
+ render,
400
+ }: Omit<FormFieldProps, "rules">) {
401
+ const ctx = useFormCtx();
402
+ const store = ctx.plainStore!;
403
+ const [, forceRender] = useState(0);
404
+ const registeredRef = useRef(false);
405
+
406
+ if (!registeredRef.current) {
407
+ store.registerField(name, defaultValue);
408
+ registeredRef.current = true;
409
+ }
410
+
411
+ useEffect(() => {
412
+ return store.subscribe(() => forceRender((n) => n + 1));
413
+ }, [store]);
414
+
415
+ const value = store.getValue(name);
416
+ const errorMsg = store.getError(name);
417
+ const isTouched = store.isTouched(name);
418
+ const isDirty = store.isDirty(name);
419
+
420
+ const field: FormRenderField = {
421
+ name,
422
+ value,
423
+ onChange: (next: unknown) => {
424
+ store.setValue(name, next);
425
+ const err = validate?.(next);
426
+ store.setError(name, err);
427
+ },
428
+ onBlur: () => {
429
+ store.setTouched(name);
430
+ const err = validate?.(store.getValue(name));
431
+ store.setError(name, err);
432
+ },
433
+ inputProps: {
434
+ name,
435
+ value: value == null ? "" : String(value),
436
+ onChange: (e) => {
437
+ const next = e.target.value;
438
+ store.setValue(name, next);
439
+ const err = validate?.(next);
440
+ store.setError(name, err);
441
+ },
442
+ onBlur: () => {
443
+ store.setTouched(name);
444
+ const err = validate?.(store.getValue(name));
445
+ store.setError(name, err);
446
+ },
447
+ },
448
+ };
449
+
450
+ const fieldState: FormRenderFieldState = {
451
+ invalid: Boolean(errorMsg),
452
+ isTouched,
453
+ isDirty,
454
+ error: errorMsg ? { message: errorMsg } : undefined,
455
+ };
456
+
457
+ return <>{render({ field, fieldState })}</>;
458
+ }
459
+
460
+ // ----- RHF field -------------------------------------------------------------
461
+
462
+ function FormFieldRhf({
463
+ name,
464
+ defaultValue,
465
+ rules,
466
+ render,
467
+ }: Omit<FormFieldProps, "validate">) {
468
+ const methods = useFormContext();
469
+ const controllerProps: Parameters<typeof Controller<FieldValues>>[0] = {
470
+ name: name as Path<FieldValues>,
471
+ control: methods.control as unknown as Control<FieldValues>,
472
+ defaultValue: defaultValue as never,
473
+ render: ({ field, fieldState }) => {
474
+ const f: FormRenderField = {
475
+ name: field.name,
476
+ value: field.value,
477
+ onChange: field.onChange,
478
+ onBlur: field.onBlur,
479
+ inputProps: {
480
+ name: field.name,
481
+ value:
482
+ field.value == null
483
+ ? ""
484
+ : typeof field.value === "string"
485
+ ? field.value
486
+ : String(field.value),
487
+ onChange: (e) => field.onChange(e.target.value),
488
+ onBlur: field.onBlur,
489
+ },
490
+ };
491
+ const s: FormRenderFieldState = {
492
+ invalid: fieldState.invalid,
493
+ isDirty: fieldState.isDirty,
494
+ isTouched: fieldState.isTouched,
495
+ error:
496
+ fieldState.error?.message !== undefined
497
+ ? { message: fieldState.error.message }
498
+ : fieldState.error
499
+ ? {}
500
+ : undefined,
501
+ };
502
+ return <>{render({ field: f, fieldState: s })}</>;
503
+ },
504
+ };
505
+ if (rules !== undefined) controllerProps.rules = rules;
506
+ return <Controller {...controllerProps} />;
507
+ }
508
+
509
+ // ----------------------------------------------------------------------------
510
+ // FormError — top-level error slot with role="alert".
511
+ // ----------------------------------------------------------------------------
512
+
513
+ const FormError = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
514
+ function FormError({ className, ...props }, ref) {
515
+ return (
516
+ <div
517
+ ref={ref}
518
+ role="alert"
519
+ className={cn("text-sm text-destructive", className)}
520
+ {...props}
521
+ />
522
+ );
523
+ },
524
+ );
525
+ FormError.displayName = "FormError";
526
+
527
+ // ----------------------------------------------------------------------------
528
+ // useAutoSaveStatus — hook for consumers to react to autoSave state.
529
+ // ----------------------------------------------------------------------------
530
+
531
+ function useAutoSaveStatus(): AutoSaveStatus {
532
+ const ctx = useContext(FormCtx);
533
+ return ctx?.autoSaveStatus ?? "idle";
534
+ }
535
+
536
+ // Unused-import shim to keep useCallback / useId / useState in scope when
537
+ // future passes wire them in. Removed when actually needed.
538
+ void useCallback;
539
+ void useId;
540
+
541
+ export { Form, FormField, FormError, useAutoSaveStatus };
542
+ export type { FormProps, FormFieldProps, AutoSaveStatus };
@@ -0,0 +1,154 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { RiMailLine, RiSearchLine } from "@remixicon/react";
3
+
4
+ import { Input } from "./input";
5
+
6
+ const meta: Meta<typeof Input> = {
7
+ title: "Foundations/Input",
8
+ component: Input,
9
+ parameters: { layout: "centered" },
10
+ argTypes: {
11
+ size: { control: "select", options: ["xs", "sm", "md", "lg", "xl"] },
12
+ type: {
13
+ control: "select",
14
+ options: ["text", "email", "password", "number", "tel", "url", "search"],
15
+ },
16
+ invalid: { control: "boolean" },
17
+ clearable: { control: "boolean" },
18
+ loading: { control: "boolean" },
19
+ disabled: { control: "boolean" },
20
+ readOnly: { control: "boolean" },
21
+ },
22
+ args: { placeholder: "Type something…" },
23
+ };
24
+
25
+ export default meta;
26
+ type Story = StoryObj<typeof Input>;
27
+
28
+ export const Default: Story = {
29
+ render: (args) => (
30
+ <div className="w-72">
31
+ <Input {...args} />
32
+ </div>
33
+ ),
34
+ };
35
+
36
+ /**
37
+ * One row per `type`, each pre-filled so the type-specific browser behavior is
38
+ * visible: password masks, number rejects letters, search shows the native
39
+ * clear-X chrome, etc. Placeholder text is always plain regardless of type.
40
+ */
41
+ export const Types: Story = {
42
+ render: () => (
43
+ <div className="grid w-96 grid-cols-[5rem_1fr] items-center gap-x-4 gap-y-3">
44
+ <span className="text-sm font-medium">text</span>
45
+ <Input type="text" defaultValue="plain text 123" />
46
+
47
+ <span className="text-sm font-medium">email</span>
48
+ <Input type="email" defaultValue="alice@example.com" />
49
+
50
+ <span className="text-sm font-medium">password</span>
51
+ <Input type="password" defaultValue="secret123" />
52
+
53
+ <span className="text-sm font-medium">number</span>
54
+ <Input type="number" defaultValue="42" />
55
+
56
+ <span className="text-sm font-medium">tel</span>
57
+ <Input type="tel" defaultValue="+212 6 12 34 56 78" />
58
+
59
+ <span className="text-sm font-medium">url</span>
60
+ <Input type="url" defaultValue="https://example.com" />
61
+
62
+ <span className="text-sm font-medium">search</span>
63
+ <Input type="search" defaultValue="cats" />
64
+ </div>
65
+ ),
66
+ };
67
+
68
+ export const Sizes: Story = {
69
+ render: (args) => (
70
+ <div className="w-72 space-y-2">
71
+ <Input {...args} size="xs" placeholder="xs" />
72
+ <Input {...args} size="sm" placeholder="sm" />
73
+ <Input {...args} size="md" placeholder="md" />
74
+ <Input {...args} size="lg" placeholder="lg" />
75
+ <Input {...args} size="xl" placeholder="xl" />
76
+ </div>
77
+ ),
78
+ };
79
+
80
+ export const Invalid: Story = {
81
+ args: { invalid: true, defaultValue: "not-an-email" },
82
+ render: (args) => (
83
+ <div className="w-72">
84
+ <Input {...args} />
85
+ </div>
86
+ ),
87
+ };
88
+
89
+ export const Disabled: Story = {
90
+ args: { disabled: true, defaultValue: "Read this" },
91
+ render: (args) => (
92
+ <div className="w-72">
93
+ <Input {...args} />
94
+ </div>
95
+ ),
96
+ };
97
+
98
+ export const ReadOnly: Story = {
99
+ args: { readOnly: true, defaultValue: "Read-only value" },
100
+ render: (args) => (
101
+ <div className="w-72">
102
+ <Input {...args} />
103
+ </div>
104
+ ),
105
+ };
106
+
107
+ export const WithStartAdornment: Story = {
108
+ args: { startAdornment: <RiSearchLine />, placeholder: "Search" },
109
+ render: (args) => (
110
+ <div className="w-72">
111
+ <Input {...args} />
112
+ </div>
113
+ ),
114
+ };
115
+
116
+ export const WithEndAdornment: Story = {
117
+ args: { endAdornment: <span className="text-xs">MAD</span>, placeholder: "0.00", type: "number" },
118
+ render: (args) => (
119
+ <div className="w-72">
120
+ <Input {...args} />
121
+ </div>
122
+ ),
123
+ };
124
+
125
+ export const BothAdornments: Story = {
126
+ args: {
127
+ startAdornment: <RiMailLine />,
128
+ endAdornment: <span className="text-xs">@example.com</span>,
129
+ placeholder: "alice",
130
+ },
131
+ render: (args) => (
132
+ <div className="w-72">
133
+ <Input {...args} />
134
+ </div>
135
+ ),
136
+ };
137
+
138
+ export const Clearable: Story = {
139
+ args: { clearable: true, defaultValue: "Erase me" },
140
+ render: (args) => (
141
+ <div className="w-72">
142
+ <Input {...args} />
143
+ </div>
144
+ ),
145
+ };
146
+
147
+ export const Loading: Story = {
148
+ args: { loading: true, defaultValue: "Loading…" },
149
+ render: (args) => (
150
+ <div className="w-72">
151
+ <Input {...args} />
152
+ </div>
153
+ ),
154
+ };