@rovula/ui 0.1.7 → 0.1.9

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 (196) hide show
  1. package/dist/cjs/bundle.css +273 -126
  2. package/dist/cjs/bundle.js +1545 -1545
  3. package/dist/cjs/bundle.js.map +1 -1
  4. package/dist/cjs/types/components/AlertDialog/AlertDialog.stories.d.ts +3 -0
  5. package/dist/cjs/types/components/Dialog/Dialog.d.ts +7 -1
  6. package/dist/cjs/types/components/Dialog/Dialog.stories.d.ts +3 -0
  7. package/dist/cjs/types/components/Dropdown/Dropdown.d.ts +2 -0
  8. package/dist/cjs/types/components/Dropdown/Dropdown.stories.d.ts +2 -0
  9. package/dist/cjs/types/components/Form/Field.d.ts +26 -0
  10. package/dist/cjs/types/components/Form/FieldMessage.d.ts +7 -0
  11. package/dist/cjs/types/components/Form/Form.d.ts +49 -11
  12. package/dist/cjs/types/components/Form/Form.stories.d.ts +23 -0
  13. package/dist/cjs/types/components/Form/ValidationHintList.d.ts +20 -0
  14. package/dist/cjs/types/components/Form/ValidationHintList.stories.d.ts +9 -0
  15. package/dist/cjs/types/components/Form/index.d.ts +10 -0
  16. package/dist/cjs/types/components/Form/useOptionBridge.d.ts +17 -0
  17. package/dist/cjs/types/components/OtpInput/OtpInput.d.ts +17 -0
  18. package/dist/cjs/types/components/OtpInput/OtpInput.stories.d.ts +15 -0
  19. package/dist/cjs/types/components/OtpInput/OtpInputGroup.d.ts +25 -0
  20. package/dist/cjs/types/components/OtpInput/index.d.ts +5 -0
  21. package/dist/cjs/types/components/TextInput/TextInput.styles.d.ts +3 -0
  22. package/dist/cjs/types/index.d.ts +5 -0
  23. package/dist/cjs/types/theme/ThemeColorCoverageRuntime.stories.d.ts +10 -0
  24. package/dist/cjs/types/utils/colors.d.ts +84 -0
  25. package/dist/components/ActionButton/ActionButton.stories.js +2 -2
  26. package/dist/components/ActionButton/ActionButton.styles.js +1 -1
  27. package/dist/components/AlertDialog/AlertDialog.js +6 -6
  28. package/dist/components/AlertDialog/AlertDialog.stories.js +3 -0
  29. package/dist/components/Avatar/Avatar.stories.js +1 -1
  30. package/dist/components/Avatar/Avatar.styles.js +1 -1
  31. package/dist/components/Avatar/AvatarBase.js +1 -1
  32. package/dist/components/Avatar/AvatarGroup.stories.js +1 -1
  33. package/dist/components/Button/Buttons.stories.js +2 -2
  34. package/dist/components/Calendar/Calendar.js +1 -1
  35. package/dist/components/Checkbox/Checkbox.js +1 -1
  36. package/dist/components/Checkbox/Checkbox.stories.js +17 -7
  37. package/dist/components/Collapsible/Collapsible.styles.js +1 -1
  38. package/dist/components/DataTable/DataTable.js +2 -2
  39. package/dist/components/Dialog/Dialog.js +12 -7
  40. package/dist/components/Dialog/Dialog.stories.js +90 -2
  41. package/dist/components/Dropdown/Dropdown.js +2 -2
  42. package/dist/components/DropdownMenu/DropdownMenu.js +3 -3
  43. package/dist/components/FocusedScrollView/FocusedScrollView.stories.js +6 -6
  44. package/dist/components/Form/Field.js +60 -0
  45. package/dist/components/Form/FieldMessage.js +24 -0
  46. package/dist/components/Form/Form.js +73 -41
  47. package/dist/components/Form/Form.stories.js +221 -0
  48. package/dist/components/Form/ValidationHintList.js +30 -0
  49. package/dist/components/Form/ValidationHintList.stories.js +50 -0
  50. package/dist/components/Form/index.js +5 -0
  51. package/dist/components/Form/useOptionBridge.js +27 -0
  52. package/dist/components/InputFilter/InputFilter.js +5 -4
  53. package/dist/components/InputFilter/InputFilter.stories.js +1 -1
  54. package/dist/components/InputFilter/InputFilter.styles.js +14 -1
  55. package/dist/components/Label/Label.styles.js +1 -1
  56. package/dist/components/Menu/Menu.js +2 -2
  57. package/dist/components/NumberInput/NumberInput.stories.js +1 -1
  58. package/dist/components/OtpInput/OtpInput.js +118 -0
  59. package/dist/components/OtpInput/OtpInput.stories.js +60 -0
  60. package/dist/components/OtpInput/OtpInputGroup.js +23 -0
  61. package/dist/components/OtpInput/index.js +3 -0
  62. package/dist/components/PasswordInput/PasswordInput.stories.js +1 -1
  63. package/dist/components/Popover/Popover.js +1 -1
  64. package/dist/components/RadioGroup/RadioGroup.js +1 -1
  65. package/dist/components/RadioGroup/RadioGroup.stories.js +2 -2
  66. package/dist/components/Search/Search.js +13 -1
  67. package/dist/components/Search/Search.stories.js +1 -1
  68. package/dist/components/Slider/Slider.js +1 -1
  69. package/dist/components/Slider/Slider.stories.js +5 -5
  70. package/dist/components/Switch/Switch.stories.js +2 -2
  71. package/dist/components/Table/Table.js +5 -5
  72. package/dist/components/Tabs/Tabs.js +12 -9
  73. package/dist/components/Tabs/Tabs.stories.js +1 -1
  74. package/dist/components/Text/Text.js +1 -1
  75. package/dist/components/Text/Text.stories.js +1 -1
  76. package/dist/components/TextArea/TextArea.stories.js +1 -1
  77. package/dist/components/TextArea/TextArea.styles.js +3 -3
  78. package/dist/components/TextInput/TextInput.js +3 -2
  79. package/dist/components/TextInput/TextInput.stories.js +3 -3
  80. package/dist/components/TextInput/TextInput.styles.js +41 -19
  81. package/dist/components/Toast/Toast.js +4 -2
  82. package/dist/components/Toast/Toast.stories.js +1 -1
  83. package/dist/components/Toast/Toast.styles.js +4 -4
  84. package/dist/components/Toast/Toaster.js +2 -2
  85. package/dist/components/Tree/Tree.stories.js +1 -1
  86. package/dist/components/Tree/TreeItem.js +1 -1
  87. package/dist/esm/bundle.css +273 -126
  88. package/dist/esm/bundle.js +1545 -1545
  89. package/dist/esm/bundle.js.map +1 -1
  90. package/dist/esm/types/components/AlertDialog/AlertDialog.stories.d.ts +3 -0
  91. package/dist/esm/types/components/Dialog/Dialog.d.ts +7 -1
  92. package/dist/esm/types/components/Dialog/Dialog.stories.d.ts +3 -0
  93. package/dist/esm/types/components/Dropdown/Dropdown.d.ts +2 -0
  94. package/dist/esm/types/components/Dropdown/Dropdown.stories.d.ts +2 -0
  95. package/dist/esm/types/components/Form/Field.d.ts +26 -0
  96. package/dist/esm/types/components/Form/FieldMessage.d.ts +7 -0
  97. package/dist/esm/types/components/Form/Form.d.ts +49 -11
  98. package/dist/esm/types/components/Form/Form.stories.d.ts +23 -0
  99. package/dist/esm/types/components/Form/ValidationHintList.d.ts +20 -0
  100. package/dist/esm/types/components/Form/ValidationHintList.stories.d.ts +9 -0
  101. package/dist/esm/types/components/Form/index.d.ts +10 -0
  102. package/dist/esm/types/components/Form/useOptionBridge.d.ts +17 -0
  103. package/dist/esm/types/components/OtpInput/OtpInput.d.ts +17 -0
  104. package/dist/esm/types/components/OtpInput/OtpInput.stories.d.ts +15 -0
  105. package/dist/esm/types/components/OtpInput/OtpInputGroup.d.ts +25 -0
  106. package/dist/esm/types/components/OtpInput/index.d.ts +5 -0
  107. package/dist/esm/types/components/TextInput/TextInput.styles.d.ts +3 -0
  108. package/dist/esm/types/index.d.ts +5 -0
  109. package/dist/esm/types/theme/ThemeColorCoverageRuntime.stories.d.ts +10 -0
  110. package/dist/esm/types/utils/colors.d.ts +84 -0
  111. package/dist/index.d.ts +248 -2
  112. package/dist/index.js +3 -0
  113. package/dist/src/theme/global.css +340 -151
  114. package/dist/theme/ThemeColorCoverageRuntime.stories.js +91 -0
  115. package/dist/utils/colors.js +92 -0
  116. package/package.json +4 -2
  117. package/src/components/ActionButton/ActionButton.stories.tsx +6 -6
  118. package/src/components/ActionButton/ActionButton.styles.ts +1 -1
  119. package/src/components/AlertDialog/AlertDialog.stories.tsx +22 -0
  120. package/src/components/AlertDialog/AlertDialog.tsx +6 -6
  121. package/src/components/Avatar/Avatar.stories.tsx +1 -1
  122. package/src/components/Avatar/Avatar.styles.ts +1 -1
  123. package/src/components/Avatar/AvatarBase.tsx +1 -1
  124. package/src/components/Avatar/AvatarGroup.stories.tsx +1 -1
  125. package/src/components/Button/Buttons.stories.tsx +10 -10
  126. package/src/components/Calendar/Calendar.tsx +3 -3
  127. package/src/components/Checkbox/Checkbox.stories.tsx +35 -12
  128. package/src/components/Checkbox/Checkbox.tsx +7 -5
  129. package/src/components/Collapsible/Collapsible.styles.ts +1 -1
  130. package/src/components/DataTable/DataTable.tsx +2 -2
  131. package/src/components/Dialog/Dialog.stories.tsx +173 -0
  132. package/src/components/Dialog/Dialog.tsx +32 -15
  133. package/src/components/Dropdown/Dropdown.styles.ts +1 -1
  134. package/src/components/Dropdown/Dropdown.tsx +16 -14
  135. package/src/components/DropdownMenu/DropdownMenu.tsx +3 -3
  136. package/src/components/FocusedScrollView/FocusedScrollView.stories.tsx +10 -10
  137. package/src/components/Form/Field.tsx +160 -0
  138. package/src/components/Form/FieldMessage.tsx +38 -0
  139. package/src/components/Form/Form.docs.mdx +67 -0
  140. package/src/components/Form/Form.stories.tsx +490 -0
  141. package/src/components/Form/Form.tsx +185 -87
  142. package/src/components/Form/README.md +284 -0
  143. package/src/components/Form/ValidationHintList.stories.tsx +118 -0
  144. package/src/components/Form/ValidationHintList.tsx +95 -0
  145. package/src/components/Form/index.ts +28 -0
  146. package/src/components/Form/useOptionBridge.ts +55 -0
  147. package/src/components/InputFilter/InputFilter.stories.tsx +1 -1
  148. package/src/components/InputFilter/InputFilter.styles.ts +14 -1
  149. package/src/components/InputFilter/InputFilter.tsx +33 -28
  150. package/src/components/Label/Label.styles.ts +2 -2
  151. package/src/components/Label/Label.tsx +1 -1
  152. package/src/components/Menu/Menu.tsx +12 -12
  153. package/src/components/NumberInput/NumberInput.stories.tsx +1 -1
  154. package/src/components/OtpInput/OtpInput.stories.tsx +168 -0
  155. package/src/components/OtpInput/OtpInput.tsx +223 -0
  156. package/src/components/OtpInput/OtpInputGroup.tsx +74 -0
  157. package/src/components/OtpInput/index.ts +5 -0
  158. package/src/components/PasswordInput/PasswordInput.stories.tsx +1 -1
  159. package/src/components/Popover/Popover.tsx +1 -1
  160. package/src/components/RadioGroup/RadioGroup.stories.tsx +4 -4
  161. package/src/components/RadioGroup/RadioGroup.tsx +2 -1
  162. package/src/components/Search/Search.stories.tsx +1 -1
  163. package/src/components/Search/Search.tsx +6 -2
  164. package/src/components/Slider/Slider.stories.tsx +7 -7
  165. package/src/components/Slider/Slider.tsx +1 -1
  166. package/src/components/Switch/Switch.stories.tsx +4 -4
  167. package/src/components/Table/Table.tsx +5 -5
  168. package/src/components/Tabs/Tabs.stories.tsx +1 -1
  169. package/src/components/Tabs/Tabs.tsx +29 -18
  170. package/src/components/Text/Text.stories.tsx +1 -1
  171. package/src/components/Text/Text.tsx +1 -1
  172. package/src/components/TextArea/TextArea.stories.tsx +1 -1
  173. package/src/components/TextArea/TextArea.styles.ts +3 -3
  174. package/src/components/TextInput/TextInput.stories.tsx +7 -7
  175. package/src/components/TextInput/TextInput.styles.ts +42 -19
  176. package/src/components/TextInput/TextInput.tsx +3 -1
  177. package/src/components/Toast/Toast.stories.tsx +1 -1
  178. package/src/components/Toast/Toast.styles.tsx +7 -7
  179. package/src/components/Toast/Toast.tsx +5 -4
  180. package/src/components/Toast/Toaster.tsx +17 -20
  181. package/src/components/Tree/Tree.stories.tsx +1 -1
  182. package/src/components/Tree/TreeItem.tsx +1 -1
  183. package/src/index.ts +5 -0
  184. package/src/theme/ThemeColorCoverageRuntime.stories.tsx +236 -0
  185. package/src/theme/direct-token-migration-plan.md +121 -0
  186. package/src/theme/figma-mcp-check-report.md +225 -0
  187. package/src/theme/figma-mcp-component-checklist.json +1250 -0
  188. package/src/theme/presets/colors.js +155 -44
  189. package/src/theme/themes/xspector/components/loading.css +2 -2
  190. package/src/theme/tokens/color.css +3 -3
  191. package/src/theme/tokens/components/action-button.css +1 -1
  192. package/src/theme/tokens/components/dropdown-menu.css +3 -3
  193. package/src/theme/tokens/components/loading.css +2 -2
  194. package/src/theme/tokens/components/switch.css +1 -1
  195. package/src/theme/utils.js +164 -25
  196. package/src/utils/colors.ts +92 -0
@@ -1,99 +1,197 @@
1
- // src/Form.tsx
2
- import React, { useState } from "react";
1
+ import React, { FormHTMLAttributes, ReactNode } from "react";
2
+ import {
3
+ DefaultValues,
4
+ FieldValues,
5
+ FormProvider,
6
+ Mode,
7
+ Resolver,
8
+ SubmitErrorHandler,
9
+ SubmitHandler,
10
+ useForm,
11
+ UseFormReturn,
12
+ } from "react-hook-form";
13
+ import { yupResolver } from "@hookform/resolvers/yup";
3
14
  import * as yup from "yup";
4
15
 
5
- interface FormValues {
6
- name: string;
7
- email: string;
8
- password: string;
9
- }
10
-
11
- interface FormProps {
12
- onSubmit: (values: FormValues) => void;
13
- className?: string;
14
- }
15
-
16
- const schema = yup.object().shape({
17
- name: yup.string().required("Name is required"),
18
- email: yup.string().email("Invalid email").required("Email is required"),
19
- password: yup
20
- .string()
21
- .min(6, "Password must be at least 6 characters")
22
- .required("Password is required"),
23
- });
24
-
25
- const Form: React.FC<FormProps> = ({ onSubmit, className = "" }) => {
26
- const [values, setValues] = useState<FormValues>({
27
- name: "",
28
- email: "",
29
- password: "",
16
+ type FormChildren<TFieldValues extends FieldValues> =
17
+ | ReactNode
18
+ | ((methods: UseFormReturn<TFieldValues>) => ReactNode);
19
+
20
+ export type FormController<TFieldValues extends FieldValues> = {
21
+ submit: () => Promise<void>;
22
+ getValues: () => TFieldValues;
23
+ setValue: UseFormReturn<TFieldValues>["setValue"];
24
+ trigger: UseFormReturn<TFieldValues>["trigger"];
25
+ reset: UseFormReturn<TFieldValues>["reset"];
26
+ };
27
+
28
+ export type FormProps<TFieldValues extends FieldValues> = Omit<
29
+ FormHTMLAttributes<HTMLFormElement>,
30
+ "children" | "onSubmit"
31
+ > & {
32
+ children: FormChildren<TFieldValues>;
33
+ defaultValues: DefaultValues<TFieldValues>;
34
+ methods?: UseFormReturn<TFieldValues>;
35
+ controllerRef?: React.MutableRefObject<FormController<TFieldValues> | null>;
36
+ onSubmit: SubmitHandler<TFieldValues>;
37
+ onInvalidSubmit?: SubmitErrorHandler<TFieldValues>;
38
+ resolver?: Resolver<TFieldValues>;
39
+ validationSchema?: yup.ObjectSchema<any>;
40
+ mode?: Mode;
41
+ // RHF does not export this type in all versions.
42
+ // Keep compatibility with allowed re-validate modes.
43
+ reValidateMode?: Exclude<Mode, "onTouched" | "all">;
44
+ };
45
+
46
+ export const createYupResolver = <TFieldValues extends FieldValues>(
47
+ schema: yup.ObjectSchema<any>
48
+ ) => yupResolver(schema) as Resolver<TFieldValues>;
49
+
50
+ export type ControlledFormFactoryOptions<TFieldValues extends FieldValues> = {
51
+ methods: UseFormReturn<TFieldValues>;
52
+ defaultValues: DefaultValues<TFieldValues>;
53
+ controllerRef?: React.MutableRefObject<FormController<TFieldValues> | null>;
54
+ };
55
+
56
+ export type UseControlledFormOptions<TFieldValues extends FieldValues> = {
57
+ defaultValues: DefaultValues<TFieldValues>;
58
+ controllerRef?: React.MutableRefObject<FormController<TFieldValues> | null>;
59
+ resolver?: Resolver<TFieldValues>;
60
+ validationSchema?: yup.ObjectSchema<any>;
61
+ mode?: Mode;
62
+ reValidateMode?: Exclude<Mode, "onTouched" | "all">;
63
+ };
64
+
65
+ export const createControlledForm = <TFieldValues extends FieldValues>({
66
+ methods,
67
+ defaultValues,
68
+ controllerRef,
69
+ }: ControlledFormFactoryOptions<TFieldValues>) => {
70
+ const FormRoot = React.forwardRef<
71
+ FormController<TFieldValues>,
72
+ Omit<FormProps<TFieldValues>, "methods" | "defaultValues" | "controllerRef">
73
+ >(({ children, ...props }, ref) => (
74
+ <Form<TFieldValues>
75
+ {...props}
76
+ ref={ref}
77
+ methods={methods}
78
+ defaultValues={defaultValues}
79
+ controllerRef={controllerRef}
80
+ >
81
+ {children}
82
+ </Form>
83
+ ));
84
+ FormRoot.displayName = "ControlledFormRoot";
85
+
86
+ return {
87
+ methods,
88
+ FormRoot,
89
+ };
90
+ };
91
+
92
+ export const useControlledForm = <TFieldValues extends FieldValues>({
93
+ defaultValues,
94
+ controllerRef,
95
+ resolver,
96
+ validationSchema,
97
+ mode = "onSubmit",
98
+ reValidateMode = "onChange",
99
+ }: UseControlledFormOptions<TFieldValues>) => {
100
+ const resolvedResolver =
101
+ resolver ??
102
+ (validationSchema
103
+ ? createYupResolver<TFieldValues>(validationSchema)
104
+ : undefined);
105
+
106
+ const methods = useForm<TFieldValues>({
107
+ defaultValues,
108
+ resolver: resolvedResolver,
109
+ mode,
110
+ reValidateMode,
111
+ });
112
+
113
+ return createControlledForm<TFieldValues>({
114
+ methods,
115
+ defaultValues,
116
+ controllerRef,
117
+ });
118
+ };
119
+
120
+ const FormInner = <TFieldValues extends FieldValues>(
121
+ {
122
+ children,
123
+ defaultValues,
124
+ methods: externalMethods,
125
+ controllerRef,
126
+ onSubmit,
127
+ onInvalidSubmit,
128
+ resolver,
129
+ validationSchema,
130
+ mode = "onSubmit",
131
+ reValidateMode = "onChange",
132
+ noValidate = true,
133
+ ...formProps
134
+ }: FormProps<TFieldValues>,
135
+ ref: React.ForwardedRef<FormController<TFieldValues>>
136
+ ) => {
137
+ const resolvedResolver =
138
+ resolver ??
139
+ (validationSchema
140
+ ? createYupResolver<TFieldValues>(validationSchema)
141
+ : undefined);
142
+
143
+ const internalMethods = useForm<TFieldValues>({
144
+ defaultValues,
145
+ resolver: resolvedResolver,
146
+ mode,
147
+ reValidateMode,
30
148
  });
31
- const [errors, setErrors] = useState<{ [key in keyof FormValues]?: string }>(
32
- {}
149
+ const methods = externalMethods ?? internalMethods;
150
+
151
+ const controller = React.useMemo<FormController<TFieldValues>>(
152
+ () => ({
153
+ submit: async () => {
154
+ await methods.handleSubmit(onSubmit, onInvalidSubmit)();
155
+ },
156
+ getValues: () => methods.getValues(),
157
+ setValue: methods.setValue,
158
+ trigger: methods.trigger,
159
+ reset: methods.reset,
160
+ }),
161
+ [methods, onSubmit, onInvalidSubmit]
33
162
  );
34
163
 
35
- const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
36
- const { name, value } = e.target;
37
- setValues({ ...values, [name]: value });
38
- };
164
+ React.useImperativeHandle(ref, () => controller, [controller]);
39
165
 
40
- const handleSubmit = async (e: React.FormEvent) => {
41
- e.preventDefault();
42
- try {
43
- await schema.validate(values, { abortEarly: false });
44
- setErrors({});
45
- onSubmit(values);
46
- } catch (err) {
47
- if (err instanceof yup.ValidationError) {
48
- const validationErrors: { [key in keyof FormValues]?: string } = {};
49
- err.inner.forEach((error) => {
50
- if (error.path) {
51
- validationErrors[error.path as keyof FormValues] = error.message;
52
- }
53
- });
54
- setErrors(validationErrors);
55
- }
56
- }
57
- };
166
+ React.useEffect(() => {
167
+ if (!controllerRef) return;
168
+
169
+ controllerRef.current = controller;
170
+
171
+ return () => {
172
+ controllerRef.current = null;
173
+ };
174
+ }, [controllerRef, controller]);
58
175
 
59
176
  return (
60
- <form className={`form ${className}`} onSubmit={handleSubmit}>
61
- <div className="form-group">
62
- <label htmlFor="name">Name</label>
63
- <input
64
- id="name"
65
- name="name"
66
- type="text"
67
- value={values.name}
68
- onChange={handleChange}
69
- />
70
- {errors.name && <span className="error">{errors.name}</span>}
71
- </div>
72
- <div className="form-group">
73
- <label htmlFor="email">Email</label>
74
- <input
75
- id="email"
76
- name="email"
77
- type="email"
78
- value={values.email}
79
- onChange={handleChange}
80
- />
81
- {errors.email && <span className="error">{errors.email}</span>}
82
- </div>
83
- <div className="form-group">
84
- <label htmlFor="password">Password</label>
85
- <input
86
- id="password"
87
- name="password"
88
- type="password"
89
- value={values.password}
90
- onChange={handleChange}
91
- />
92
- {errors.password && <span className="error">{errors.password}</span>}
93
- </div>
94
- <button type="submit">Submit</button>
95
- </form>
177
+ <FormProvider {...methods}>
178
+ <form
179
+ {...formProps}
180
+ noValidate={noValidate}
181
+ onSubmit={methods.handleSubmit(onSubmit, onInvalidSubmit)}
182
+ >
183
+ {typeof children === "function" ? children(methods) : children}
184
+ </form>
185
+ </FormProvider>
96
186
  );
97
187
  };
98
188
 
189
+ type FormComponent = <TFieldValues extends FieldValues>(
190
+ props: FormProps<TFieldValues> & {
191
+ ref?: React.Ref<FormController<TFieldValues>>;
192
+ }
193
+ ) => React.ReactElement;
194
+
195
+ export const Form = React.forwardRef(FormInner) as FormComponent;
196
+
99
197
  export default Form;
@@ -0,0 +1,284 @@
1
+ # Form Component Guide
2
+
3
+ This guide explains how to use the `Form` system in this UI package:
4
+
5
+ - `Form` (react-hook-form wrapper)
6
+ - `Field` (adapter for UI components)
7
+ - `FieldMessage` (error message renderer)
8
+ - `ValidationHintList` (rule checklist UI)
9
+ - `useControlledForm` / `createControlledForm` (control form from parent layer)
10
+ - `useOptionBridge` (map `id <-> option` for async dropdown use cases)
11
+
12
+ ---
13
+
14
+ ## Quick Start
15
+
16
+ ```tsx
17
+ import * as yup from "yup";
18
+ import { Form, Field } from "@/components/Form";
19
+ import TextInput from "@/components/TextInput/TextInput";
20
+ import Button from "@/components/Button/Button";
21
+
22
+ type LoginFormValues = {
23
+ email: string;
24
+ password: string;
25
+ };
26
+
27
+ const schema = yup.object({
28
+ email: yup.string().email("Invalid email").required("Email is required"),
29
+ password: yup.string().min(6, "At least 6 characters").required("Password is required"),
30
+ });
31
+
32
+ <Form<LoginFormValues>
33
+ defaultValues={{ email: "", password: "" }}
34
+ validationSchema={schema}
35
+ onSubmit={(values) => {
36
+ console.log(values);
37
+ }}
38
+ >
39
+ <Field<LoginFormValues, "email">
40
+ name="email"
41
+ component={TextInput}
42
+ componentProps={{ label: "Email", required: true }}
43
+ />
44
+ <Field<LoginFormValues, "password">
45
+ name="password"
46
+ component={TextInput}
47
+ componentProps={{ label: "Password", type: "password", required: true }}
48
+ />
49
+ <Button type="submit">Sign in</Button>
50
+ </Form>;
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Core Concepts
56
+
57
+ ### 1) `defaultValues` is the source of truth
58
+
59
+ Set initial values at `Form.defaultValues` whenever possible.
60
+
61
+ ```tsx
62
+ <Form defaultValues={{ role: "dev" }} ... />
63
+ ```
64
+
65
+ You can also use `Field.defaultValue` for specific cases, but the primary pattern is form-level defaults.
66
+
67
+ ### 2) `Field` injects value/change/blur/ref
68
+
69
+ `Field` internally uses `useController` and injects controlled props into your component.
70
+
71
+ By default:
72
+
73
+ - value prop name: `value`
74
+ - change prop name: `onChange`
75
+ - blur prop name: `onBlur`
76
+ - ref prop name: `ref`
77
+
78
+ If your component uses different prop names, map them with `valuePropName`, `changePropName`, etc.
79
+
80
+ ### 3) Validation modes are RHF modes
81
+
82
+ `Form.mode` and `Form.reValidateMode` pass through to react-hook-form.
83
+
84
+ Examples:
85
+
86
+ - `mode="onSubmit"` (default)
87
+ - `mode="onChange"`
88
+ - `mode="onTouched"`
89
+
90
+ Important: `onTouched` requires your input path to call `onBlur` correctly.
91
+
92
+ ---
93
+
94
+ ## `Field` Props You Will Use Most
95
+
96
+ ### `component` and `componentProps`
97
+
98
+ Use `component` for the render target, and `componentProps` for static/custom props.
99
+
100
+ ```tsx
101
+ <Field
102
+ name="email"
103
+ component={TextInput}
104
+ componentProps={{ label: "Email", helperText: "Use your work email" }}
105
+ />
106
+ ```
107
+
108
+ ### Mapping non-standard component APIs
109
+
110
+ If a component does not use `value` + `onChange`, map them:
111
+
112
+ ```tsx
113
+ <Field
114
+ name="role"
115
+ component={Dropdown}
116
+ valuePropName="value"
117
+ changePropName="onSelect"
118
+ />
119
+ ```
120
+
121
+ ### Transform value in/out (`formatValue` and `parseValue`)
122
+
123
+ - `formatValue`: form state -> component value
124
+ - `parseValue`: component payload -> form state
125
+
126
+ ```tsx
127
+ <Field
128
+ name="roleId"
129
+ component={Dropdown}
130
+ valuePropName="value"
131
+ changePropName="onSelect"
132
+ formatValue={(roleId) => options.find((item) => item.value === roleId)}
133
+ parseValue={(option) => (option as Options | undefined)?.value ?? ""}
134
+ />
135
+ ```
136
+
137
+ ### Optional passthrough toggles
138
+
139
+ Disable specific injected props when a component should not receive them:
140
+
141
+ - `blurPropName={false}`
142
+ - `refPropName={false}`
143
+ - `errorMessagePropName={false}`
144
+ - `invalidPropName={false}`
145
+
146
+ ---
147
+
148
+ ## Showing Error Messages
149
+
150
+ ### A) Through component props (default behavior)
151
+
152
+ `Field` sends:
153
+
154
+ - `errorMessage` (from RHF field error)
155
+ - `error` (boolean invalid state)
156
+
157
+ You can rename these with:
158
+
159
+ - `errorMessagePropName`
160
+ - `invalidPropName`
161
+
162
+ ### B) Via `FieldMessage`
163
+
164
+ Use when your input component does not render its own message:
165
+
166
+ ```tsx
167
+ <Field name="email" component={TextInput} />
168
+ <FieldMessage name="email" />
169
+ ```
170
+
171
+ ---
172
+
173
+ ## Async Dropdown Pattern (`id` in form, option object in UI)
174
+
175
+ Most projects store only primitive IDs in form state (`string`/`number`) and map to option objects for UI rendering.
176
+
177
+ Use `useOptionBridge` to avoid repeated mapping code.
178
+
179
+ ```tsx
180
+ import { useOptionBridge } from "@/components/Form";
181
+
182
+ const { toOption, toValue } = useOptionBridge({
183
+ options, // [{ value: "dev", label: "Developer" }]
184
+ loading,
185
+ });
186
+
187
+ <Field<FormValues, "roleId", DropdownProps>
188
+ name="roleId"
189
+ component={Dropdown}
190
+ componentProps={{ options, disabled: loading }}
191
+ valuePropName="value"
192
+ changePropName="onSelect"
193
+ formatValue={(id) => toOption(id)}
194
+ parseValue={(option) => toValue(option as Option | undefined) ?? ""}
195
+ />;
196
+ ```
197
+
198
+ If options can be delayed, use `toOptionWithFallback` and provide `buildFallbackOption` when needed.
199
+
200
+ ---
201
+
202
+ ## Controlled Form From Parent Layer
203
+
204
+ Use `useControlledForm` when a parent (outside form JSX) needs to:
205
+
206
+ - submit
207
+ - read values
208
+ - set values
209
+ - trigger validation
210
+ - reset
211
+
212
+ ```tsx
213
+ const { methods, FormRoot } = useControlledForm<FormValues>({
214
+ defaultValues,
215
+ validationSchema: schema,
216
+ mode: "onChange",
217
+ });
218
+
219
+ <Button type="button" onClick={() => methods.handleSubmit(onSubmit)()}>
220
+ Submit outside form
221
+ </Button>
222
+
223
+ <FormRoot onSubmit={onSubmit}>
224
+ <Field name="title" component={TextInput} />
225
+ </FormRoot>;
226
+ ```
227
+
228
+ You can also use `controllerRef` / forwarded ref with `FormController`.
229
+
230
+ ### Ref API (`FormController`)
231
+
232
+ When using `ref` or `controllerRef`, available methods are:
233
+
234
+ - `submit()`
235
+ - `getValues()`
236
+ - `setValue()`
237
+ - `trigger()`
238
+ - `reset()`
239
+
240
+ ```tsx
241
+ const formRef = React.useRef<FormController<FormValues> | null>(null);
242
+
243
+ await formRef.current?.submit();
244
+ formRef.current?.setValue("title", "New title", { shouldValidate: true });
245
+ const values = formRef.current?.getValues();
246
+ ```
247
+
248
+ ---
249
+
250
+ ## Best Practices
251
+
252
+ - Keep form state as primitives (`id`, `code`) unless you truly need full objects.
253
+ - Prefer `Form.defaultValues` over per-field defaults.
254
+ - Keep input components controlled and ensure they call `onChange` and `onBlur`.
255
+ - Use `formatValue`/`parseValue` only when API shape differs.
256
+ - For `mode="onTouched"`, make sure focus leaving the field triggers `onBlur` once.
257
+ - Avoid inline anonymous components in `Field.component` for stable rendering.
258
+
259
+ ---
260
+
261
+ ## Common Pitfalls
262
+
263
+ - **Validation not running on touched**: custom input does not call `onBlur`.
264
+ - **Dropdown not showing selected label**: options not loaded yet, or `formatValue` missing.
265
+ - **Submitted payload too heavy**: storing full option objects instead of IDs.
266
+ - **State mismatch**: setting local component default separately from RHF default values.
267
+
268
+ ---
269
+
270
+ ## Export Surface
271
+
272
+ From `@/components/Form`:
273
+
274
+ - `Form`
275
+ - `Field`
276
+ - `FieldMessage`
277
+ - `ValidationHintList`
278
+ - `useControlledForm`
279
+ - `createControlledForm`
280
+ - `createYupResolver`
281
+ - `useOptionBridge`
282
+
283
+ Related types are exported alongside these utilities.
284
+
@@ -0,0 +1,118 @@
1
+ import React, { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import PasswordInput from "@/components/PasswordInput";
4
+ import { ValidationHintList, ValidationHintRule } from "./ValidationHintList";
5
+
6
+ type PasswordValues = {
7
+ password: string;
8
+ confirmPassword: string;
9
+ };
10
+
11
+ const rules: ValidationHintRule<PasswordValues>[] = [
12
+ {
13
+ id: "min-length",
14
+ label: "Minimum 12 characters",
15
+ validate: (values) => values.password.length >= 12,
16
+ when: (values) => Boolean(values.password),
17
+ },
18
+ {
19
+ id: "upper-lower",
20
+ label: "At least one upper and lower case",
21
+ validate: (values) =>
22
+ /[a-z]/.test(values.password) && /[A-Z]/.test(values.password),
23
+ when: (values) => Boolean(values.password),
24
+ },
25
+ {
26
+ id: "match",
27
+ label: "Password and confirmation are matching",
28
+ validate: (values) =>
29
+ Boolean(values.password) && values.password === values.confirmPassword,
30
+ when: (values) =>
31
+ Boolean(values.password) && Boolean(values.confirmPassword),
32
+ },
33
+ ];
34
+
35
+ const meta: Meta = {
36
+ title: "Components/Form/ValidationHintList",
37
+ tags: ["autodocs"],
38
+ parameters: {
39
+ layout: "centered",
40
+ },
41
+ };
42
+
43
+ export default meta;
44
+ type Story = StoryObj;
45
+
46
+ export const ThreeState = {
47
+ render: () => {
48
+ const [values, setValues] = useState<PasswordValues>({
49
+ password: "",
50
+ confirmPassword: "",
51
+ });
52
+
53
+ return (
54
+ <div className="w-[420px] max-w-full rounded-md bg-bg-bg2 p-4">
55
+ <div className="flex flex-col gap-3">
56
+ <PasswordInput
57
+ label="Password"
58
+ value={values.password}
59
+ onChange={(event) =>
60
+ setValues((prev) => ({ ...prev, password: event.target.value }))
61
+ }
62
+ />
63
+ <PasswordInput
64
+ label="Confirm password"
65
+ value={values.confirmPassword}
66
+ onChange={(event) =>
67
+ setValues((prev) => ({
68
+ ...prev,
69
+ confirmPassword: event.target.value,
70
+ }))
71
+ }
72
+ />
73
+ </div>
74
+
75
+ <ValidationHintList values={values} rules={rules} />
76
+ </div>
77
+ );
78
+ },
79
+ } satisfies Story;
80
+
81
+ export const TwoState = {
82
+ render: () => {
83
+ const [values, setValues] = useState<PasswordValues>({
84
+ password: "",
85
+ confirmPassword: "",
86
+ });
87
+
88
+ return (
89
+ <div className="w-[420px] max-w-full rounded-md bg-bg-bg2 p-4">
90
+ <div className="flex flex-col gap-3">
91
+ <PasswordInput
92
+ label="Password"
93
+ value={values.password}
94
+ onChange={(event) =>
95
+ setValues((prev) => ({ ...prev, password: event.target.value }))
96
+ }
97
+ />
98
+ <PasswordInput
99
+ label="Confirm password"
100
+ value={values.confirmPassword}
101
+ onChange={(event) =>
102
+ setValues((prev) => ({
103
+ ...prev,
104
+ confirmPassword: event.target.value,
105
+ }))
106
+ }
107
+ />
108
+ </div>
109
+
110
+ <ValidationHintList
111
+ values={values}
112
+ rules={rules}
113
+ mode={["pending", "valid"]}
114
+ />
115
+ </div>
116
+ );
117
+ },
118
+ } satisfies Story;