@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.
- package/components/ui/.gitkeep +0 -0
- package/components/ui/accordion.stories.tsx +231 -0
- package/components/ui/accordion.tsx +250 -0
- package/components/ui/app-shell.stories.tsx +270 -0
- package/components/ui/app-shell.tsx +491 -0
- package/components/ui/avatar.stories.tsx +174 -0
- package/components/ui/avatar.tsx +257 -0
- package/components/ui/badge.stories.tsx +127 -0
- package/components/ui/badge.tsx +146 -0
- package/components/ui/breadcrumb.stories.tsx +92 -0
- package/components/ui/breadcrumb.tsx +302 -0
- package/components/ui/button.stories.tsx +186 -0
- package/components/ui/button.tsx +128 -0
- package/components/ui/card.stories.tsx +279 -0
- package/components/ui/card.tsx +250 -0
- package/components/ui/checkbox.stories.tsx +93 -0
- package/components/ui/checkbox.tsx +131 -0
- package/components/ui/combobox.stories.tsx +489 -0
- package/components/ui/combobox.tsx +874 -0
- package/components/ui/context-menu.stories.tsx +202 -0
- package/components/ui/context-menu.tsx +309 -0
- package/components/ui/data-table.stories.tsx +227 -0
- package/components/ui/data-table.tsx +539 -0
- package/components/ui/date-picker.stories.tsx +225 -0
- package/components/ui/date-picker.tsx +597 -0
- package/components/ui/dialog.stories.tsx +193 -0
- package/components/ui/dialog.tsx +262 -0
- package/components/ui/divider.stories.tsx +84 -0
- package/components/ui/divider.tsx +135 -0
- package/components/ui/drawer.stories.tsx +218 -0
- package/components/ui/drawer.tsx +329 -0
- package/components/ui/dropdown-menu.stories.tsx +270 -0
- package/components/ui/dropdown-menu.tsx +353 -0
- package/components/ui/empty-state.stories.tsx +121 -0
- package/components/ui/empty-state.tsx +289 -0
- package/components/ui/field-group.stories.tsx +201 -0
- package/components/ui/field-group.tsx +276 -0
- package/components/ui/form.stories.tsx +219 -0
- package/components/ui/form.tsx +542 -0
- package/components/ui/input.stories.tsx +154 -0
- package/components/ui/input.tsx +208 -0
- package/components/ui/label.stories.tsx +84 -0
- package/components/ui/label.tsx +98 -0
- package/components/ui/page-header.stories.tsx +136 -0
- package/components/ui/page-header.tsx +315 -0
- package/components/ui/pagination.stories.tsx +136 -0
- package/components/ui/pagination.tsx +427 -0
- package/components/ui/popover.stories.tsx +212 -0
- package/components/ui/popover.tsx +167 -0
- package/components/ui/radio-group.stories.tsx +96 -0
- package/components/ui/radio-group.tsx +250 -0
- package/components/ui/select.stories.tsx +203 -0
- package/components/ui/select.tsx +318 -0
- package/components/ui/sidebar.stories.tsx +186 -0
- package/components/ui/sidebar.tsx +623 -0
- package/components/ui/skeleton.stories.tsx +131 -0
- package/components/ui/skeleton.tsx +311 -0
- package/components/ui/switch.stories.tsx +74 -0
- package/components/ui/switch.tsx +186 -0
- package/components/ui/table.stories.tsx +107 -0
- package/components/ui/table.tsx +285 -0
- package/components/ui/tabs.stories.tsx +222 -0
- package/components/ui/tabs.tsx +287 -0
- package/components/ui/textarea.stories.tsx +96 -0
- package/components/ui/textarea.tsx +182 -0
- package/components/ui/toast.stories.tsx +169 -0
- package/components/ui/toast.tsx +250 -0
- package/components/ui/tooltip.stories.tsx +146 -0
- package/components/ui/tooltip.tsx +156 -0
- package/components/ui/top-bar.stories.tsx +182 -0
- package/components/ui/top-bar.tsx +155 -0
- package/dist/components/ui/accordion.d.ts +45 -0
- package/dist/components/ui/accordion.d.ts.map +1 -0
- package/dist/components/ui/accordion.js +99 -0
- package/dist/components/ui/accordion.js.map +1 -0
- package/dist/components/ui/app-shell.d.ts +70 -0
- package/dist/components/ui/app-shell.d.ts.map +1 -0
- package/dist/components/ui/app-shell.js +199 -0
- package/dist/components/ui/app-shell.js.map +1 -0
- package/dist/components/ui/avatar.d.ts +41 -0
- package/dist/components/ui/avatar.d.ts.map +1 -0
- package/dist/components/ui/avatar.js +104 -0
- package/dist/components/ui/avatar.js.map +1 -0
- package/dist/components/ui/badge.d.ts +27 -0
- package/dist/components/ui/badge.d.ts.map +1 -0
- package/dist/components/ui/badge.js +65 -0
- package/dist/components/ui/badge.js.map +1 -0
- package/dist/components/ui/breadcrumb.d.ts +35 -0
- package/dist/components/ui/breadcrumb.d.ts.map +1 -0
- package/dist/components/ui/breadcrumb.js +88 -0
- package/dist/components/ui/breadcrumb.js.map +1 -0
- package/dist/components/ui/button.d.ts +26 -0
- package/dist/components/ui/button.d.ts.map +1 -0
- package/dist/components/ui/button.js +73 -0
- package/dist/components/ui/button.js.map +1 -0
- package/dist/components/ui/card.d.ts +52 -0
- package/dist/components/ui/card.d.ts.map +1 -0
- package/dist/components/ui/card.js +96 -0
- package/dist/components/ui/card.js.map +1 -0
- package/dist/components/ui/checkbox.d.ts +18 -0
- package/dist/components/ui/checkbox.d.ts.map +1 -0
- package/dist/components/ui/checkbox.js +59 -0
- package/dist/components/ui/checkbox.js.map +1 -0
- package/dist/components/ui/combobox.d.ts +194 -0
- package/dist/components/ui/combobox.d.ts.map +1 -0
- package/dist/components/ui/combobox.js +361 -0
- package/dist/components/ui/combobox.js.map +1 -0
- package/dist/components/ui/context-menu.d.ts +46 -0
- package/dist/components/ui/context-menu.d.ts.map +1 -0
- package/dist/components/ui/context-menu.js +95 -0
- package/dist/components/ui/context-menu.js.map +1 -0
- package/dist/components/ui/data-table.d.ts +53 -0
- package/dist/components/ui/data-table.d.ts.map +1 -0
- package/dist/components/ui/data-table.js +163 -0
- package/dist/components/ui/data-table.js.map +1 -0
- package/dist/components/ui/date-picker.d.ts +103 -0
- package/dist/components/ui/date-picker.d.ts.map +1 -0
- package/dist/components/ui/date-picker.js +306 -0
- package/dist/components/ui/date-picker.js.map +1 -0
- package/dist/components/ui/dialog.d.ts +40 -0
- package/dist/components/ui/dialog.d.ts.map +1 -0
- package/dist/components/ui/dialog.js +110 -0
- package/dist/components/ui/dialog.js.map +1 -0
- package/dist/components/ui/divider.d.ts +30 -0
- package/dist/components/ui/divider.d.ts.map +1 -0
- package/dist/components/ui/divider.js +62 -0
- package/dist/components/ui/divider.js.map +1 -0
- package/dist/components/ui/drawer.d.ts +56 -0
- package/dist/components/ui/drawer.d.ts.map +1 -0
- package/dist/components/ui/drawer.js +147 -0
- package/dist/components/ui/drawer.js.map +1 -0
- package/dist/components/ui/dropdown-menu.d.ts +63 -0
- package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
- package/dist/components/ui/dropdown-menu.js +116 -0
- package/dist/components/ui/dropdown-menu.js.map +1 -0
- package/dist/components/ui/empty-state.d.ts +43 -0
- package/dist/components/ui/empty-state.d.ts.map +1 -0
- package/dist/components/ui/empty-state.js +128 -0
- package/dist/components/ui/empty-state.js.map +1 -0
- package/dist/components/ui/field-group.d.ts +38 -0
- package/dist/components/ui/field-group.d.ts.map +1 -0
- package/dist/components/ui/field-group.js +107 -0
- package/dist/components/ui/field-group.js.map +1 -0
- package/dist/components/ui/form.d.ts +67 -0
- package/dist/components/ui/form.d.ts.map +1 -0
- package/dist/components/ui/form.js +286 -0
- package/dist/components/ui/form.js.map +1 -0
- package/dist/components/ui/input.d.ts +36 -0
- package/dist/components/ui/input.d.ts.map +1 -0
- package/dist/components/ui/input.js +99 -0
- package/dist/components/ui/input.js.map +1 -0
- package/dist/components/ui/label.d.ts +37 -0
- package/dist/components/ui/label.d.ts.map +1 -0
- package/dist/components/ui/label.js +34 -0
- package/dist/components/ui/label.js.map +1 -0
- package/dist/components/ui/page-header.d.ts +65 -0
- package/dist/components/ui/page-header.d.ts.map +1 -0
- package/dist/components/ui/page-header.js +140 -0
- package/dist/components/ui/page-header.js.map +1 -0
- package/dist/components/ui/pagination.d.ts +67 -0
- package/dist/components/ui/pagination.d.ts.map +1 -0
- package/dist/components/ui/pagination.js +109 -0
- package/dist/components/ui/pagination.js.map +1 -0
- package/dist/components/ui/popover.d.ts +28 -0
- package/dist/components/ui/popover.d.ts.map +1 -0
- package/dist/components/ui/popover.js +85 -0
- package/dist/components/ui/popover.js.map +1 -0
- package/dist/components/ui/radio-group.d.ts +35 -0
- package/dist/components/ui/radio-group.d.ts.map +1 -0
- package/dist/components/ui/radio-group.js +103 -0
- package/dist/components/ui/radio-group.js.map +1 -0
- package/dist/components/ui/select.d.ts +42 -0
- package/dist/components/ui/select.d.ts.map +1 -0
- package/dist/components/ui/select.js +86 -0
- package/dist/components/ui/select.js.map +1 -0
- package/dist/components/ui/sidebar.d.ts +59 -0
- package/dist/components/ui/sidebar.d.ts.map +1 -0
- package/dist/components/ui/sidebar.js +189 -0
- package/dist/components/ui/sidebar.js.map +1 -0
- package/dist/components/ui/skeleton.d.ts +77 -0
- package/dist/components/ui/skeleton.d.ts.map +1 -0
- package/dist/components/ui/skeleton.js +115 -0
- package/dist/components/ui/skeleton.js.map +1 -0
- package/dist/components/ui/switch.d.ts +26 -0
- package/dist/components/ui/switch.d.ts.map +1 -0
- package/dist/components/ui/switch.js +84 -0
- package/dist/components/ui/switch.js.map +1 -0
- package/dist/components/ui/table.d.ts +52 -0
- package/dist/components/ui/table.d.ts.map +1 -0
- package/dist/components/ui/table.js +109 -0
- package/dist/components/ui/table.js.map +1 -0
- package/dist/components/ui/tabs.d.ts +42 -0
- package/dist/components/ui/tabs.d.ts.map +1 -0
- package/dist/components/ui/tabs.js +163 -0
- package/dist/components/ui/tabs.js.map +1 -0
- package/dist/components/ui/textarea.d.ts +26 -0
- package/dist/components/ui/textarea.d.ts.map +1 -0
- package/dist/components/ui/textarea.js +96 -0
- package/dist/components/ui/textarea.js.map +1 -0
- package/dist/components/ui/toast.d.ts +77 -0
- package/dist/components/ui/toast.d.ts.map +1 -0
- package/dist/components/ui/toast.js +141 -0
- package/dist/components/ui/toast.js.map +1 -0
- package/dist/components/ui/tooltip.d.ts +31 -0
- package/dist/components/ui/tooltip.d.ts.map +1 -0
- package/dist/components/ui/tooltip.js +71 -0
- package/dist/components/ui/tooltip.js.map +1 -0
- package/dist/components/ui/top-bar.d.ts +30 -0
- package/dist/components/ui/top-bar.d.ts.map +1 -0
- package/dist/components/ui/top-bar.js +64 -0
- package/dist/components/ui/top-bar.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/lib/utils.ts +6 -0
- package/package.json +112 -0
- 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'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
|
+
};
|