@rovula/ui 0.1.13 → 0.1.15

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 (41) hide show
  1. package/dist/cjs/bundle.css +7 -36
  2. package/dist/cjs/bundle.js +2 -2
  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.stories.d.ts +4 -1
  6. package/dist/cjs/types/components/Form/Form.d.ts +1 -1
  7. package/dist/cjs/types/patterns/confirm-dialog/ConfirmDialog.d.ts +24 -0
  8. package/dist/cjs/types/patterns/confirm-dialog/ConfirmDialog.stories.d.ts +53 -0
  9. package/dist/cjs/types/patterns/form-dialog/FormDialog.d.ts +39 -0
  10. package/dist/cjs/types/patterns/form-dialog/FormDialog.stories.d.ts +62 -0
  11. package/dist/components/AlertDialog/AlertDialog.js +5 -5
  12. package/dist/components/AlertDialog/AlertDialog.stories.js +22 -0
  13. package/dist/components/Dialog/Dialog.js +6 -6
  14. package/dist/components/Dialog/Dialog.stories.js +6 -34
  15. package/dist/components/Form/Form.js +15 -4
  16. package/dist/esm/bundle.css +7 -36
  17. package/dist/esm/bundle.js +1 -1
  18. package/dist/esm/bundle.js.map +1 -1
  19. package/dist/esm/types/components/AlertDialog/AlertDialog.stories.d.ts +3 -0
  20. package/dist/esm/types/components/Dialog/Dialog.stories.d.ts +4 -1
  21. package/dist/esm/types/components/Form/Form.d.ts +1 -1
  22. package/dist/esm/types/patterns/confirm-dialog/ConfirmDialog.d.ts +24 -0
  23. package/dist/esm/types/patterns/confirm-dialog/ConfirmDialog.stories.d.ts +53 -0
  24. package/dist/esm/types/patterns/form-dialog/FormDialog.d.ts +39 -0
  25. package/dist/esm/types/patterns/form-dialog/FormDialog.stories.d.ts +62 -0
  26. package/dist/index.d.ts +1 -1
  27. package/dist/patterns/confirm-dialog/ConfirmDialog.js +44 -0
  28. package/dist/patterns/confirm-dialog/ConfirmDialog.stories.js +103 -0
  29. package/dist/patterns/form-dialog/FormDialog.js +10 -0
  30. package/dist/patterns/form-dialog/FormDialog.stories.js +223 -0
  31. package/dist/src/theme/global.css +9 -40
  32. package/package.json +1 -1
  33. package/src/components/AlertDialog/AlertDialog.stories.tsx +69 -2
  34. package/src/components/AlertDialog/AlertDialog.tsx +13 -15
  35. package/src/components/Dialog/Dialog.stories.tsx +62 -99
  36. package/src/components/Dialog/Dialog.tsx +6 -12
  37. package/src/components/Form/Form.tsx +19 -4
  38. package/src/patterns/confirm-dialog/ConfirmDialog.stories.tsx +193 -0
  39. package/src/patterns/confirm-dialog/ConfirmDialog.tsx +153 -0
  40. package/src/patterns/form-dialog/FormDialog.stories.tsx +437 -0
  41. package/src/patterns/form-dialog/FormDialog.tsx +137 -0
@@ -3874,10 +3874,6 @@ input[type=number] {
3874
3874
  margin-top: 1rem;
3875
3875
  }
3876
3876
 
3877
- .mt-8 {
3878
- margin-top: 2rem;
3879
- }
3880
-
3881
3877
  .mt-\[6px\] {
3882
3878
  margin-top: 6px;
3883
3879
  }
@@ -3910,10 +3906,6 @@ input[type=number] {
3910
3906
  display: grid;
3911
3907
  }
3912
3908
 
3913
- .contents {
3914
- display: contents;
3915
- }
3916
-
3917
3909
  .hidden {
3918
3910
  display: none;
3919
3911
  }
@@ -4041,6 +4033,10 @@ input[type=number] {
4041
4033
  height: 15rem;
4042
4034
  }
4043
4035
 
4036
+ .h-\[200px\] {
4037
+ height: 200px;
4038
+ }
4039
+
4044
4040
  .h-\[200vh\] {
4045
4041
  height: 200vh;
4046
4042
  }
@@ -4134,10 +4130,6 @@ input[type=number] {
4134
4130
  min-height: 18px;
4135
4131
  }
4136
4132
 
4137
- .min-h-\[686px\] {
4138
- min-height: 686px;
4139
- }
4140
-
4141
4133
  .min-h-screen {
4142
4134
  min-height: 100vh;
4143
4135
  }
@@ -4198,10 +4190,6 @@ input[type=number] {
4198
4190
  width: 140px;
4199
4191
  }
4200
4192
 
4201
- .w-\[180px\] {
4202
- width: 180px;
4203
- }
4204
-
4205
4193
  .w-\[200px\] {
4206
4194
  width: 200px;
4207
4195
  }
@@ -6148,6 +6136,11 @@ input[type=number] {
6148
6136
  background-color: color-mix(in srgb, var(--transparent-primary-8) calc(100% * var(--tw-bg-opacity, 1)), transparent);
6149
6137
  }
6150
6138
 
6139
+ .bg-ramps-secondary-150 {
6140
+ --tw-bg-opacity: 1;
6141
+ background-color: color-mix(in srgb, var(--ramps-secondary-150) calc(100% * var(--tw-bg-opacity, 1)), transparent);
6142
+ }
6143
+
6151
6144
  .bg-red-100 {
6152
6145
  --tw-bg-opacity: 1;
6153
6146
  background-color: rgb(254 226 226 / var(--tw-bg-opacity, 1));
@@ -11019,37 +11012,13 @@ input[type=number] {
11019
11012
  }
11020
11013
 
11021
11014
  @media (min-width: 640px) {
11022
- .sm\:mt-0 {
11023
- margin-top: 0px;
11024
- }
11025
-
11026
11015
  .sm\:max-w-\[425px\] {
11027
11016
  max-width: 425px;
11028
11017
  }
11029
11018
 
11030
- .sm\:flex-row {
11031
- flex-direction: row;
11032
- }
11033
-
11034
- .sm\:items-center {
11035
- align-items: center;
11036
- }
11037
-
11038
- .sm\:justify-end {
11039
- justify-content: flex-end;
11040
- }
11041
-
11042
11019
  .sm\:justify-stretch {
11043
11020
  justify-content: stretch;
11044
11021
  }
11045
-
11046
- .sm\:gap-4 {
11047
- gap: 1rem;
11048
- }
11049
-
11050
- .sm\:gap-6 {
11051
- gap: 1.5rem;
11052
- }
11053
11022
  }
11054
11023
 
11055
11024
  @media (min-width: 768px) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rovula/ui",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "main": "dist/cjs/bundle.js",
5
5
  "module": "dist/esm/bundle.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,4 +1,4 @@
1
- import React from "react";
1
+ import React, { useState } from "react";
2
2
  import type { Meta, StoryObj } from "@storybook/react";
3
3
  import {
4
4
  AlertDialog,
@@ -11,6 +11,7 @@ import {
11
11
  AlertDialogTitle,
12
12
  AlertDialogTrigger,
13
13
  } from "./AlertDialog";
14
+ import { TextInput } from "../TextInput/TextInput";
14
15
 
15
16
  // More on how to set up stories at: https://storybook.js.org/docs/7.0/react/writing-stories/introduction
16
17
  const meta = {
@@ -114,7 +115,8 @@ export const FigmaFail = {
114
115
  <AlertDialogHeader>
115
116
  <AlertDialogTitle>Infomation update failed</AlertDialogTitle>
116
117
  <AlertDialogDescription>
117
- Please login again and complete your profile to activate your account.
118
+ Please login again and complete your profile to activate your
119
+ account.
118
120
  </AlertDialogDescription>
119
121
  </AlertDialogHeader>
120
122
  <AlertDialogFooter>
@@ -127,3 +129,68 @@ export const FigmaFail = {
127
129
  </div>
128
130
  ),
129
131
  } satisfies StoryObj;
132
+
133
+ export const FigmaConfirmRequirePassword = {
134
+ render: () => {
135
+ const [value, setValue] = useState("");
136
+ const [submitted, setSubmitted] = useState(false);
137
+ const isRequired = submitted && value === "";
138
+ const isWrongWord = submitted && value !== "" && value !== "confirm";
139
+ const isValid = value === "confirm";
140
+
141
+ return (
142
+ <div className="flex w-full">
143
+ <AlertDialog defaultOpen>
144
+ <AlertDialogContent>
145
+ <AlertDialogHeader>
146
+ <AlertDialogTitle>Title</AlertDialogTitle>
147
+ <AlertDialogDescription>
148
+ Subtitle description
149
+ </AlertDialogDescription>
150
+ </AlertDialogHeader>
151
+ <div className="flex flex-col gap-4 w-full">
152
+ <p className="typography-small1 text-text-contrast-max">
153
+ Type &ldquo;confirm&rdquo; to proceed.
154
+ </p>
155
+ <TextInput
156
+ label="Type to confirm"
157
+ required
158
+ value={value}
159
+ onChange={(e) => {
160
+ setValue(e.target.value);
161
+ setSubmitted(false);
162
+ }}
163
+ errorMessage={
164
+ isRequired
165
+ ? "This field is required."
166
+ : isWrongWord
167
+ ? "Please type 'confirm' to proceed"
168
+ : undefined
169
+ }
170
+ hasClearIcon
171
+ keepFooterSpace
172
+ fullwidth
173
+ />
174
+ </div>
175
+ <AlertDialogFooter>
176
+ <AlertDialogCancel
177
+ onClick={() => {
178
+ setValue("");
179
+ setSubmitted(false);
180
+ }}
181
+ >
182
+ Cancel
183
+ </AlertDialogCancel>
184
+ <AlertDialogAction
185
+ disabled={!isValid}
186
+ onClick={() => setSubmitted(true)}
187
+ >
188
+ Confirm
189
+ </AlertDialogAction>
190
+ </AlertDialogFooter>
191
+ </AlertDialogContent>
192
+ </AlertDialog>
193
+ </div>
194
+ );
195
+ },
196
+ } satisfies StoryObj;
@@ -19,7 +19,7 @@ const AlertDialogOverlay = React.forwardRef<
19
19
  <AlertDialogPrimitive.Overlay
20
20
  className={cn(
21
21
  "fixed inset-0 bg-modal-overlay z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
22
- className
22
+ className,
23
23
  )}
24
24
  {...props}
25
25
  ref={ref}
@@ -37,8 +37,8 @@ const AlertDialogContent = React.forwardRef<
37
37
  <AlertDialogPrimitive.Content
38
38
  ref={ref}
39
39
  className={cn(
40
- "fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-32px)] max-w-[460px] translate-x-[-50%] translate-y-[-50%] gap-8 rounded-md bg-modal-surface px-6 py-8 text-text-contrast-max shadow-[0px_12px_24px_-4px_rgba(0,0,0,0.12)] duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
41
- className
40
+ "fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-32px)] max-w-[460px] translate-x-[-50%] translate-y-[-50%] gap-6 rounded-md bg-modal-surface px-6 py-8 text-text-contrast-max shadow-[0px_12px_24px_-4px_rgba(0,0,0,0.12)] duration-200 focus:outline-none focus-visible:outline-none outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
41
+ className,
42
42
  )}
43
43
  {...props}
44
44
  />
@@ -51,10 +51,7 @@ const AlertDialogHeader = ({
51
51
  ...props
52
52
  }: React.HTMLAttributes<HTMLDivElement>) => (
53
53
  <div
54
- className={cn(
55
- "flex flex-col items-center gap-2 text-center",
56
- className
57
- )}
54
+ className={cn("flex flex-col items-center gap-2 text-center", className)}
58
55
  {...props}
59
56
  />
60
57
  );
@@ -65,10 +62,7 @@ const AlertDialogFooter = ({
65
62
  ...props
66
63
  }: React.HTMLAttributes<HTMLDivElement>) => (
67
64
  <div
68
- className={cn(
69
- "flex flex-col-reverse items-center justify-center gap-3 sm:flex-row sm:gap-4",
70
- className
71
- )}
65
+ className={cn("flex flex-row items-center justify-center gap-4", className)}
72
66
  {...props}
73
67
  />
74
68
  );
@@ -92,7 +86,7 @@ const AlertDialogDescription = React.forwardRef<
92
86
  >(({ className, ...props }, ref) => (
93
87
  <AlertDialogPrimitive.Description
94
88
  ref={ref}
95
- className={cn("typography-small1 text-text-contrast-max", className)}
89
+ className={cn("typography-body3 text-text-contrast-max", className)}
96
90
  {...props}
97
91
  />
98
92
  ));
@@ -105,7 +99,11 @@ const AlertDialogAction = React.forwardRef<
105
99
  >(({ className, ...props }, ref) => (
106
100
  <AlertDialogPrimitive.Action
107
101
  ref={ref}
108
- className={cn(buttonVariants({ fullwidth: false }), className)}
102
+ className={cn(
103
+ buttonVariants({ fullwidth: false }),
104
+ "w-[100px] justify-center",
105
+ className,
106
+ )}
109
107
  {...props}
110
108
  />
111
109
  ));
@@ -119,8 +117,8 @@ const AlertDialogCancel = React.forwardRef<
119
117
  ref={ref}
120
118
  className={cn(
121
119
  buttonVariants({ fullwidth: false, variant: "outline" }),
122
- "mt-2 sm:mt-0",
123
- className
120
+ "w-[100px] justify-center",
121
+ className,
124
122
  )}
125
123
  {...props}
126
124
  />
@@ -188,104 +188,67 @@ const changePasswordSchema = yup.object({
188
188
  .oneOf([yup.ref("newPassword")], "Passwords must match"),
189
189
  });
190
190
 
191
- export const FigmaChangePassword = {
192
- render: () => {
193
- return (
194
- <div className="flex w-full">
195
- <Dialog defaultOpen>
196
- <DialogContent className="min-h-[686px] max-w-[650px]">
197
- <Form<ChangePasswordFormValues>
198
- className="contents"
199
- defaultValues={{
200
- newPassword: "",
201
- confirmPassword: "",
202
- }}
203
- mode="onTouched"
204
- validationSchema={changePasswordSchema}
205
- onSubmit={(values) => {
206
- // eslint-disable-next-line no-console
207
- console.log("Change password submit:", values);
208
- }}
209
- >
210
- {({ formState, watch }) => {
211
- const newPassword = watch("newPassword");
212
- const confirmPassword = watch("confirmPassword");
213
-
214
- return (
215
- <>
216
- <DialogHeader>
217
- <DialogTitle>Welcome!</DialogTitle>
218
- <DialogDescription>
219
- Please create a new password to replace the temporary
220
- password.
221
- </DialogDescription>
222
- </DialogHeader>
223
-
224
- <DialogBody>
225
- <div className="flex flex-col gap-4">
226
- <Field<ChangePasswordFormValues, "newPassword">
227
- name="newPassword"
228
- component={PasswordInput}
229
- componentProps={{
230
- id: "new-password",
231
- label: "New password",
232
- type: "password",
233
- required: true,
234
- fullwidth: true,
235
- size: "lg",
236
- }}
237
- />
238
- <Field<ChangePasswordFormValues, "confirmPassword">
239
- name="confirmPassword"
240
- component={PasswordInput}
241
- componentProps={{
242
- id: "confirm-password",
243
- label: "Confirm password",
244
- type: "password",
245
- required: true,
246
- fullwidth: true,
247
- size: "lg",
248
- }}
249
- />
250
-
251
- <ValidationHintList<ChangePasswordFormValues>
252
- values={{
253
- newPassword: newPassword || "",
254
- confirmPassword: confirmPassword || "",
255
- }}
256
- rules={passwordHints}
257
- />
258
- </div>
259
- </DialogBody>
191
+ export const FigmaFunctionForm = {
192
+ render: () => (
193
+ <div className="flex w-full">
194
+ <Dialog defaultOpen>
195
+ <DialogContent>
196
+ <DialogHeader>
197
+ <DialogTitle>Title</DialogTitle>
198
+ <DialogDescription>Subtitle description</DialogDescription>
199
+ </DialogHeader>
200
+ <DialogBody>
201
+ <div className="flex items-center justify-center bg-ramps-secondary-150 h-[200px] w-full rounded-sm">
202
+ <p className="typography-body3 text-text-contrast-max">
203
+ Content - Form Area
204
+ </p>
205
+ </div>
206
+ </DialogBody>
207
+ <DialogFooter>
208
+ <Button variant="outline" color="primary" fullwidth={false}>
209
+ Cancel
210
+ </Button>
211
+ <Button disabled fullwidth={false}>
212
+ Confirm
213
+ </Button>
214
+ </DialogFooter>
215
+ </DialogContent>
216
+ </Dialog>
217
+ </div>
218
+ ),
219
+ } satisfies StoryObj;
260
220
 
261
- <DialogFooter>
262
- <Button
263
- type="button"
264
- variant="outline"
265
- color="primary"
266
- size="lg"
267
- fullwidth={false}
268
- >
269
- Back
270
- </Button>
271
- <Button
272
- type="submit"
273
- size="lg"
274
- fullwidth={false}
275
- className="w-[180px]"
276
- disabled={!formState.isValid || formState.isSubmitting}
277
- isLoading={formState.isSubmitting}
278
- >
279
- Save changes
280
- </Button>
281
- </DialogFooter>
282
- </>
283
- );
284
- }}
285
- </Form>
286
- </DialogContent>
287
- </Dialog>
288
- </div>
289
- );
290
- },
221
+ export const FigmaFunctionFormWithAction = {
222
+ render: () => (
223
+ <div className="flex w-full">
224
+ <Dialog defaultOpen>
225
+ <DialogContent>
226
+ <DialogHeader>
227
+ <DialogTitle>Title</DialogTitle>
228
+ <DialogDescription>Subtitle description</DialogDescription>
229
+ </DialogHeader>
230
+ <DialogBody>
231
+ <div className="flex items-center justify-center bg-ramps-secondary-150 h-[200px] w-full rounded-sm">
232
+ <p className="typography-body3 text-text-contrast-max">
233
+ Content - Form Area
234
+ </p>
235
+ </div>
236
+ </DialogBody>
237
+ <DialogFooter className="justify-between">
238
+ <Button variant="outline" color="secondary" fullwidth={false}>
239
+ Medium
240
+ </Button>
241
+ <div className="flex items-center gap-4">
242
+ <Button variant="outline" color="primary" fullwidth={false}>
243
+ Cancel
244
+ </Button>
245
+ <Button disabled fullwidth={false}>
246
+ Confirm
247
+ </Button>
248
+ </div>
249
+ </DialogFooter>
250
+ </DialogContent>
251
+ </Dialog>
252
+ </div>
253
+ ),
291
254
  } satisfies StoryObj;
@@ -51,7 +51,7 @@ const DialogContent = React.forwardRef<
51
51
  <DialogPrimitive.Content
52
52
  ref={ref}
53
53
  className={cn(
54
- "fixed left-[50%] top-[50%] z-50 flex w-[calc(100%-32px)] max-w-[650px] translate-x-[-50%] translate-y-[-50%] flex-col rounded-md bg-modal-surface p-8 text-text-g-contrast-medium shadow-[0px_12px_24px_-4px_rgba(0,0,0,0.12)] duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
54
+ "fixed left-[50%] top-[50%] z-50 flex w-[calc(100%-32px)] max-w-[650px] translate-x-[-50%] translate-y-[-50%] flex-col gap-6 rounded-md bg-modal-surface p-8 text-text-g-contrast-medium shadow-[0px_12px_24px_-4px_rgba(0,0,0,0.12)] duration-200 focus:outline-none focus-visible:outline-none outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
55
55
  className,
56
56
  )}
57
57
  {...props}
@@ -78,7 +78,7 @@ const DialogHeader = ({
78
78
  className,
79
79
  ...props
80
80
  }: React.HTMLAttributes<HTMLDivElement>) => (
81
- <div className={cn("flex flex-col gap-2 text-left", className)} {...props} />
81
+ <div className={cn("flex flex-col gap-1 text-left", className)} {...props} />
82
82
  );
83
83
  DialogHeader.displayName = "DialogHeader";
84
84
 
@@ -89,7 +89,7 @@ const DialogBody = ({
89
89
  }: React.HTMLAttributes<HTMLDivElement> & { scrollable?: boolean }) => (
90
90
  <div
91
91
  className={cn(
92
- "flex flex-1 min-h-0 flex-col mt-8",
92
+ "flex flex-1 min-h-0 flex-col",
93
93
  scrollable && "overflow-y-auto",
94
94
  className,
95
95
  )}
@@ -103,10 +103,7 @@ const DialogFooter = ({
103
103
  ...props
104
104
  }: React.HTMLAttributes<HTMLDivElement>) => (
105
105
  <div
106
- className={cn(
107
- "flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-end sm:gap-6",
108
- className,
109
- )}
106
+ className={cn("flex flex-row items-center justify-end gap-4", className)}
110
107
  {...props}
111
108
  />
112
109
  );
@@ -119,7 +116,7 @@ const DialogTitle = React.forwardRef<
119
116
  <DialogPrimitive.Title
120
117
  ref={ref}
121
118
  className={cn(
122
- "typography-h4 tracking-tight text-text-contrast-max",
119
+ "typography-h6 tracking-tight text-text-contrast-max",
123
120
  className,
124
121
  )}
125
122
  {...props}
@@ -133,10 +130,7 @@ const DialogDescription = React.forwardRef<
133
130
  >(({ className, ...props }, ref) => (
134
131
  <DialogPrimitive.Description
135
132
  ref={ref}
136
- className={cn(
137
- "typography-subtitle1 text-text-g-contrast-medium",
138
- className,
139
- )}
133
+ className={cn("typography-body3 text-text-contrast-max", className)}
140
134
  {...props}
141
135
  />
142
136
  ));
@@ -110,11 +110,26 @@ export const useControlledForm = <TFieldValues extends FieldValues>({
110
110
  reValidateMode,
111
111
  });
112
112
 
113
- return createControlledForm<TFieldValues>({
113
+ // Keep FormRoot component reference stable across re-renders.
114
+ // createControlledForm creates a new component type (via forwardRef) on every call —
115
+ // if FormRoot changes reference, React unmounts + remounts the entire form tree,
116
+ // causing inputs to lose focus whenever parent state changes (e.g. isValid).
117
+ const stableRef = React.useRef<ReturnType<
118
+ typeof createControlledForm<TFieldValues>
119
+ > | null>(null);
120
+
121
+ if (!stableRef.current) {
122
+ stableRef.current = createControlledForm<TFieldValues>({
123
+ methods,
124
+ defaultValues,
125
+ controllerRef,
126
+ });
127
+ }
128
+
129
+ return {
114
130
  methods,
115
- defaultValues,
116
- controllerRef,
117
- });
131
+ FormRoot: stableRef.current.FormRoot,
132
+ };
118
133
  };
119
134
 
120
135
  const FormInner = <TFieldValues extends FieldValues>(