@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,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
|
+
};
|