@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,276 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ forwardRef,
6
+ isValidElement,
7
+ cloneElement,
8
+ useContext,
9
+ useId,
10
+ Children,
11
+ type ReactElement,
12
+ } from "react";
13
+
14
+ import { cn } from "@/lib/utils";
15
+ import { Label } from "./label";
16
+
17
+ // Per docs/emara-ui-phase-2-components.md §7.
18
+ // FieldGroup is a pure-composition primitive — no Radix, no behavior of its
19
+ // own. It centralizes a11y wiring (label↔control, aria-describedby for help,
20
+ // aria-errormessage for error) so callers don't have to repeat boilerplate.
21
+
22
+ // ----------------------------------------------------------------------------
23
+ // Context — propagates id/required/optional/invalid/disabled to sub-parts.
24
+ // ----------------------------------------------------------------------------
25
+
26
+ interface FieldGroupContextValue {
27
+ id: string;
28
+ helpId: string | undefined;
29
+ errorId: string | undefined;
30
+ required: boolean;
31
+ optional: boolean;
32
+ invalid: boolean;
33
+ disabled: boolean;
34
+ orientation: "vertical" | "horizontal";
35
+ /** Override for the horizontal-layout label column width (CSS length).
36
+ * Undefined → use the `--field-label-min-w` token default. */
37
+ labelWidth: string | undefined;
38
+ }
39
+
40
+ const FieldGroupContext = createContext<FieldGroupContextValue | null>(null);
41
+
42
+ function useFieldGroup(): FieldGroupContextValue {
43
+ const ctx = useContext(FieldGroupContext);
44
+ if (!ctx) {
45
+ throw new Error("FieldGroup sub-component must be rendered inside a <FieldGroup>.");
46
+ }
47
+ return ctx;
48
+ }
49
+
50
+ // ----------------------------------------------------------------------------
51
+ // Root
52
+ // ----------------------------------------------------------------------------
53
+
54
+ interface FieldGroupProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "id"> {
55
+ id?: string;
56
+ required?: boolean;
57
+ optional?: boolean;
58
+ invalid?: boolean;
59
+ disabled?: boolean;
60
+ orientation?: "vertical" | "horizontal";
61
+ /** When `orientation="horizontal"`, override the label column width.
62
+ * Accepts any CSS length (e.g. `"10rem"`, `"180px"`, `"min(20%, 12rem)"`).
63
+ * Default: `--field-label-min-w` token (7rem). */
64
+ labelWidth?: string;
65
+ }
66
+
67
+ const FieldGroupRoot = forwardRef<HTMLDivElement, FieldGroupProps>(function FieldGroup(
68
+ {
69
+ className,
70
+ id,
71
+ required = false,
72
+ optional = false,
73
+ invalid = false,
74
+ disabled = false,
75
+ orientation = "vertical",
76
+ labelWidth,
77
+ children,
78
+ ...props
79
+ },
80
+ ref,
81
+ ) {
82
+ if (required && optional) {
83
+ throw new Error("FieldGroup: `required` and `optional` are mutually exclusive.");
84
+ }
85
+
86
+ const reactId = useId();
87
+ const fieldId = id ?? reactId;
88
+ const helpId = `${fieldId}-help`;
89
+ const errorId = `${fieldId}-error`;
90
+
91
+ // First pass: detect whether HelpText/ErrorText are actually rendered. This
92
+ // is what lets us decide whether to wire `aria-describedby`/`aria-errormessage`.
93
+ let hasHelp = false;
94
+ let hasError = false;
95
+ Children.forEach(children, (child) => {
96
+ if (!isValidElement(child)) return;
97
+ if (child.type === FieldHelpText) hasHelp = true;
98
+ if (child.type === FieldErrorText && invalid) hasError = true;
99
+ });
100
+
101
+ const value: FieldGroupContextValue = {
102
+ id: fieldId,
103
+ required,
104
+ optional,
105
+ invalid,
106
+ disabled,
107
+ orientation,
108
+ helpId: hasHelp ? helpId : undefined,
109
+ errorId: hasError ? errorId : undefined,
110
+ labelWidth,
111
+ };
112
+
113
+ return (
114
+ <FieldGroupContext.Provider value={value}>
115
+ <div
116
+ ref={ref}
117
+ data-disabled={disabled || undefined}
118
+ className={cn(
119
+ "group/field",
120
+ orientation === "vertical" ? "space-y-1.5" : "flex items-start gap-4",
121
+ className,
122
+ )}
123
+ {...props}
124
+ >
125
+ {children}
126
+ </div>
127
+ </FieldGroupContext.Provider>
128
+ );
129
+ });
130
+ FieldGroupRoot.displayName = "FieldGroup";
131
+
132
+ // ----------------------------------------------------------------------------
133
+ // FieldGroup.Label — wraps the Emara Label, forwards required/optional from the
134
+ // parent FieldGroup so the consumer doesn't have to set them twice.
135
+ // ----------------------------------------------------------------------------
136
+
137
+ const FieldLabel = forwardRef<
138
+ HTMLLabelElement,
139
+ Omit<React.ComponentProps<typeof Label>, "htmlFor" | "required" | "optional">
140
+ >(function FieldGroupLabel({ className, style, ...props }, ref) {
141
+ const ctx = useFieldGroup();
142
+ const horizontal = ctx.orientation === "horizontal";
143
+ // When a labelWidth override is set, drive min-width via inline style so it
144
+ // wins over the token default. Otherwise the `min-w-field-label` class
145
+ // applies the token.
146
+ const horizontalStyle =
147
+ horizontal && ctx.labelWidth !== undefined ? { minWidth: ctx.labelWidth } : undefined;
148
+ return (
149
+ <Label
150
+ ref={ref}
151
+ htmlFor={ctx.id}
152
+ required={ctx.required}
153
+ optional={ctx.optional}
154
+ style={horizontalStyle ? { ...horizontalStyle, ...style } : style}
155
+ className={cn(
156
+ horizontal && "pt-1.5",
157
+ horizontal && ctx.labelWidth === undefined && "min-w-field-label",
158
+ className,
159
+ )}
160
+ {...props}
161
+ />
162
+ );
163
+ });
164
+ FieldLabel.displayName = "FieldGroup.Label";
165
+
166
+ // ----------------------------------------------------------------------------
167
+ // FieldGroup.Control — clones the single child and injects `id`,
168
+ // `aria-invalid`, `aria-describedby`, `aria-errormessage`, and `disabled`.
169
+ // ----------------------------------------------------------------------------
170
+
171
+ type ControlInjectedProps = {
172
+ id?: string | undefined;
173
+ "aria-invalid"?: boolean | "true" | "false" | undefined;
174
+ "aria-describedby"?: string | undefined;
175
+ "aria-errormessage"?: string | undefined;
176
+ disabled?: boolean | undefined;
177
+ required?: boolean | undefined;
178
+ invalid?: boolean | undefined;
179
+ };
180
+
181
+ interface FieldControlProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
182
+ children: ReactElement;
183
+ }
184
+
185
+ const FieldControl = forwardRef<HTMLDivElement, FieldControlProps>(function FieldGroupControl(
186
+ { className, children, ...props },
187
+ ref,
188
+ ) {
189
+ const ctx = useFieldGroup();
190
+
191
+ const describedBy = [ctx.helpId, ctx.errorId].filter(Boolean).join(" ") || undefined;
192
+
193
+ const childProps = (children.props ?? {}) as ControlInjectedProps;
194
+ const injected: ControlInjectedProps = {
195
+ id: childProps.id ?? ctx.id,
196
+ "aria-invalid": ctx.invalid || undefined,
197
+ "aria-describedby":
198
+ [childProps["aria-describedby"], describedBy].filter(Boolean).join(" ").trim() || undefined,
199
+ "aria-errormessage": ctx.errorId,
200
+ disabled: childProps.disabled ?? ctx.disabled,
201
+ };
202
+ // Some Emara controls have their own `invalid` / `required` props that drive
203
+ // styling; pass them through so the FieldGroup is the source of truth.
204
+ if (ctx.invalid && childProps.invalid === undefined) injected.invalid = true;
205
+ if (ctx.required && childProps.required === undefined) injected.required = true;
206
+
207
+ const cloned = cloneElement(children, injected);
208
+
209
+ return (
210
+ <div ref={ref} className={cn("flex-1", className)} {...props}>
211
+ {cloned}
212
+ </div>
213
+ );
214
+ });
215
+ FieldControl.displayName = "FieldGroup.Control";
216
+
217
+ // ----------------------------------------------------------------------------
218
+ // FieldGroup.HelpText
219
+ // ----------------------------------------------------------------------------
220
+
221
+ const FieldHelpText = forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
222
+ function FieldGroupHelpText({ className, ...props }, ref) {
223
+ const ctx = useFieldGroup();
224
+ return (
225
+ <p
226
+ ref={ref}
227
+ id={ctx.helpId}
228
+ className={cn("text-muted-foreground text-xs", className)}
229
+ {...props}
230
+ />
231
+ );
232
+ },
233
+ );
234
+ FieldHelpText.displayName = "FieldGroup.HelpText";
235
+
236
+ // ----------------------------------------------------------------------------
237
+ // FieldGroup.ErrorText — renders only when the FieldGroup is invalid.
238
+ // ----------------------------------------------------------------------------
239
+
240
+ const FieldErrorText = forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
241
+ function FieldGroupErrorText({ className, ...props }, ref) {
242
+ const ctx = useFieldGroup();
243
+ if (!ctx.invalid) return null;
244
+ return (
245
+ <p
246
+ ref={ref}
247
+ id={ctx.errorId}
248
+ role="alert"
249
+ className={cn("text-destructive text-xs", className)}
250
+ {...props}
251
+ />
252
+ );
253
+ },
254
+ );
255
+ FieldErrorText.displayName = "FieldGroup.ErrorText";
256
+
257
+ // ----------------------------------------------------------------------------
258
+ // Compose the dotted-namespace API while keeping each part exportable on its
259
+ // own (useful for tests and re-exports).
260
+ // ----------------------------------------------------------------------------
261
+
262
+ type FieldGroupNamespace = typeof FieldGroupRoot & {
263
+ Label: typeof FieldLabel;
264
+ Control: typeof FieldControl;
265
+ HelpText: typeof FieldHelpText;
266
+ ErrorText: typeof FieldErrorText;
267
+ };
268
+
269
+ const FieldGroup = FieldGroupRoot as FieldGroupNamespace;
270
+ FieldGroup.Label = FieldLabel;
271
+ FieldGroup.Control = FieldControl;
272
+ FieldGroup.HelpText = FieldHelpText;
273
+ FieldGroup.ErrorText = FieldErrorText;
274
+
275
+ export { FieldGroup, FieldLabel, FieldControl, FieldHelpText, FieldErrorText };
276
+ export type { FieldGroupProps, FieldControlProps };
@@ -0,0 +1,219 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { useForm } from "react-hook-form";
4
+
5
+ import { Button } from "./button";
6
+ import { FieldGroup } from "./field-group";
7
+ import { Form, FormError, FormField } from "./form";
8
+ import { Input } from "./input";
9
+ import { Textarea } from "./textarea";
10
+ import { TooltipProvider } from "./tooltip";
11
+
12
+ const meta: Meta<typeof Form> = {
13
+ title: "Forms/Form",
14
+ component: Form,
15
+ parameters: { layout: "centered" },
16
+ decorators: [
17
+ (Story) => (
18
+ <TooltipProvider delayDuration={200}>
19
+ <div className="w-96">
20
+ <Story />
21
+ </div>
22
+ </TooltipProvider>
23
+ ),
24
+ ],
25
+ };
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof Form>;
29
+
30
+ // ----------------------------------------------------------------------------
31
+ // Plain mode — no react-hook-form, FormField owns its own state.
32
+ // ----------------------------------------------------------------------------
33
+
34
+ export const PlainMode: Story = {
35
+ render: () => {
36
+ const Wrapper = () => {
37
+ const [submitted, setSubmitted] = useState<Record<string, unknown> | null>(null);
38
+ return (
39
+ <Form
40
+ className="space-y-4"
41
+ onSubmit={(values) => {
42
+ setSubmitted(values);
43
+ }}
44
+ >
45
+ <FormField
46
+ name="name"
47
+ defaultValue=""
48
+ render={({ field, fieldState }) => (
49
+ <FieldGroup required invalid={fieldState.invalid}>
50
+ <FieldGroup.Label>Full name</FieldGroup.Label>
51
+ <FieldGroup.Control>
52
+ <Input placeholder="Alice Bouchaib" {...field.inputProps} />
53
+ </FieldGroup.Control>
54
+ <FieldGroup.ErrorText>{fieldState.error?.message}</FieldGroup.ErrorText>
55
+ </FieldGroup>
56
+ )}
57
+ />
58
+ <FormField
59
+ name="email"
60
+ defaultValue=""
61
+ validate={(v) =>
62
+ typeof v === "string" && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v)
63
+ ? undefined
64
+ : "Enter a valid email."
65
+ }
66
+ render={({ field, fieldState }) => (
67
+ <FieldGroup required invalid={fieldState.invalid}>
68
+ <FieldGroup.Label>Email</FieldGroup.Label>
69
+ <FieldGroup.Control>
70
+ <Input type="email" placeholder="alice@example.com" {...field.inputProps} />
71
+ </FieldGroup.Control>
72
+ <FieldGroup.HelpText>We&apos;ll never share this.</FieldGroup.HelpText>
73
+ <FieldGroup.ErrorText>{fieldState.error?.message}</FieldGroup.ErrorText>
74
+ </FieldGroup>
75
+ )}
76
+ />
77
+ <FormField
78
+ name="bio"
79
+ defaultValue=""
80
+ render={({ field }) => (
81
+ <FieldGroup optional>
82
+ <FieldGroup.Label>Bio</FieldGroup.Label>
83
+ <FieldGroup.Control>
84
+ <Textarea placeholder="Tell us a bit about yourself…" {...field.inputProps} />
85
+ </FieldGroup.Control>
86
+ </FieldGroup>
87
+ )}
88
+ />
89
+ <Button type="submit">Save</Button>
90
+ {submitted ? (
91
+ <pre className="border-border bg-muted rounded-md border p-3 text-xs">
92
+ {JSON.stringify(submitted, null, 2)}
93
+ </pre>
94
+ ) : null}
95
+ </Form>
96
+ );
97
+ };
98
+ return <Wrapper />;
99
+ },
100
+ };
101
+
102
+ // ----------------------------------------------------------------------------
103
+ // RHF mode — consumer provides `useForm()` result.
104
+ // ----------------------------------------------------------------------------
105
+
106
+ type RhfValues = { name: string; email: string };
107
+
108
+ export const RhfMode: Story = {
109
+ render: () => {
110
+ const Wrapper = () => {
111
+ const form = useForm<RhfValues>({
112
+ defaultValues: { name: "", email: "" },
113
+ mode: "onBlur",
114
+ });
115
+ const [submitted, setSubmitted] = useState<RhfValues | null>(null);
116
+
117
+ return (
118
+ <Form
119
+ mode="rhf"
120
+ form={form}
121
+ className="space-y-4"
122
+ onSubmit={(values) => {
123
+ setSubmitted(values);
124
+ }}
125
+ >
126
+ <FormField
127
+ name="name"
128
+ rules={{ required: "Name is required" }}
129
+ render={({ field, fieldState }) => (
130
+ <FieldGroup required invalid={fieldState.invalid}>
131
+ <FieldGroup.Label>Full name</FieldGroup.Label>
132
+ <FieldGroup.Control>
133
+ <Input placeholder="Alice Bouchaib" {...field.inputProps} />
134
+ </FieldGroup.Control>
135
+ <FieldGroup.ErrorText>{fieldState.error?.message}</FieldGroup.ErrorText>
136
+ </FieldGroup>
137
+ )}
138
+ />
139
+ <FormField
140
+ name="email"
141
+ rules={{
142
+ required: "Email is required",
143
+ pattern: { value: /^[^@\s]+@[^@\s]+\.[^@\s]+$/, message: "Invalid email" },
144
+ }}
145
+ render={({ field, fieldState }) => (
146
+ <FieldGroup required invalid={fieldState.invalid}>
147
+ <FieldGroup.Label>Email</FieldGroup.Label>
148
+ <FieldGroup.Control>
149
+ <Input type="email" placeholder="alice@example.com" {...field.inputProps} />
150
+ </FieldGroup.Control>
151
+ <FieldGroup.ErrorText>{fieldState.error?.message}</FieldGroup.ErrorText>
152
+ </FieldGroup>
153
+ )}
154
+ />
155
+ <Button type="submit">Save</Button>
156
+ {submitted ? (
157
+ <pre className="border-border bg-muted rounded-md border p-3 text-xs">
158
+ {JSON.stringify(submitted, null, 2)}
159
+ </pre>
160
+ ) : null}
161
+ </Form>
162
+ );
163
+ };
164
+ return <Wrapper />;
165
+ },
166
+ };
167
+
168
+ export const Loading: Story = {
169
+ render: () => (
170
+ <Form className="space-y-4" loading onSubmit={() => {}}>
171
+ <FormField
172
+ name="name"
173
+ defaultValue=""
174
+ render={({ field }) => (
175
+ <FieldGroup>
176
+ <FieldGroup.Label>Name</FieldGroup.Label>
177
+ <FieldGroup.Control>
178
+ <Input placeholder="Submitting…" {...field.inputProps} />
179
+ </FieldGroup.Control>
180
+ </FieldGroup>
181
+ )}
182
+ />
183
+ <Button type="submit">Save</Button>
184
+ </Form>
185
+ ),
186
+ };
187
+
188
+ export const TopLevelError: Story = {
189
+ render: () => (
190
+ <Form className="space-y-4" onSubmit={() => {}}>
191
+ <FormError>Sign-in failed. Check your password and try again.</FormError>
192
+ <FormField
193
+ name="email"
194
+ defaultValue=""
195
+ render={({ field }) => (
196
+ <FieldGroup>
197
+ <FieldGroup.Label>Email</FieldGroup.Label>
198
+ <FieldGroup.Control>
199
+ <Input {...field.inputProps} />
200
+ </FieldGroup.Control>
201
+ </FieldGroup>
202
+ )}
203
+ />
204
+ <FormField
205
+ name="password"
206
+ defaultValue=""
207
+ render={({ field }) => (
208
+ <FieldGroup>
209
+ <FieldGroup.Label>Password</FieldGroup.Label>
210
+ <FieldGroup.Control>
211
+ <Input type="password" {...field.inputProps} />
212
+ </FieldGroup.Control>
213
+ </FieldGroup>
214
+ )}
215
+ />
216
+ <Button type="submit">Sign in</Button>
217
+ </Form>
218
+ ),
219
+ };