@rovula/ui 0.1.14 → 0.1.16
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/dist/cjs/bundle.js +1545 -1545
- package/dist/cjs/bundle.js.map +1 -1
- package/dist/cjs/types/components/Form/Form.d.ts +1 -1
- package/dist/cjs/types/index.d.ts +2 -0
- package/dist/cjs/types/patterns/confirm-dialog/ConfirmDialog.d.ts +25 -0
- package/dist/cjs/types/patterns/confirm-dialog/ConfirmDialog.stories.d.ts +54 -0
- package/dist/cjs/types/patterns/form-dialog/FormDialog.d.ts +40 -0
- package/dist/cjs/types/patterns/form-dialog/FormDialog.stories.d.ts +63 -0
- package/dist/components/AlertDialog/AlertDialog.js +1 -1
- package/dist/components/Dialog/Dialog.js +1 -1
- package/dist/components/Form/Form.js +15 -4
- package/dist/esm/bundle.js +1545 -1545
- package/dist/esm/bundle.js.map +1 -1
- package/dist/esm/types/components/Form/Form.d.ts +1 -1
- package/dist/esm/types/index.d.ts +2 -0
- package/dist/esm/types/patterns/confirm-dialog/ConfirmDialog.d.ts +25 -0
- package/dist/esm/types/patterns/confirm-dialog/ConfirmDialog.stories.d.ts +54 -0
- package/dist/esm/types/patterns/form-dialog/FormDialog.d.ts +40 -0
- package/dist/esm/types/patterns/form-dialog/FormDialog.stories.d.ts +63 -0
- package/dist/index.d.ts +67 -2
- package/dist/index.js +3 -0
- package/dist/patterns/confirm-dialog/ConfirmDialog.js +45 -0
- package/dist/patterns/confirm-dialog/ConfirmDialog.stories.js +103 -0
- package/dist/patterns/form-dialog/FormDialog.js +10 -0
- package/dist/patterns/form-dialog/FormDialog.stories.js +223 -0
- package/package.json +1 -1
- package/src/components/AlertDialog/AlertDialog.tsx +11 -13
- package/src/components/Dialog/Dialog.tsx +3 -9
- package/src/components/Form/Form.tsx +19 -4
- package/src/index.ts +4 -0
- package/src/patterns/confirm-dialog/ConfirmDialog.stories.tsx +193 -0
- package/src/patterns/confirm-dialog/ConfirmDialog.tsx +164 -0
- package/src/patterns/form-dialog/FormDialog.stories.tsx +437 -0
- package/src/patterns/form-dialog/FormDialog.tsx +144 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import * as yup from "yup";
|
|
5
|
+
import {
|
|
6
|
+
AlertDialog,
|
|
7
|
+
AlertDialogContent,
|
|
8
|
+
AlertDialogHeader,
|
|
9
|
+
AlertDialogTitle,
|
|
10
|
+
AlertDialogDescription,
|
|
11
|
+
AlertDialogFooter,
|
|
12
|
+
AlertDialogAction,
|
|
13
|
+
AlertDialogCancel,
|
|
14
|
+
AlertDialogTrigger,
|
|
15
|
+
} from "@/components/AlertDialog/AlertDialog";
|
|
16
|
+
import { useControlledForm, Field } from "@/components/Form";
|
|
17
|
+
import { TextInput } from "@/components/TextInput/TextInput";
|
|
18
|
+
|
|
19
|
+
export type ConfirmDialogProps = {
|
|
20
|
+
open?: boolean;
|
|
21
|
+
onOpenChange?: (open: boolean) => void;
|
|
22
|
+
title: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
confirmLabel?: string;
|
|
25
|
+
cancelLabel?: string;
|
|
26
|
+
onConfirm?: () => void;
|
|
27
|
+
onCancel?: () => void;
|
|
28
|
+
/** Fires whenever the dialog closes, regardless of how (cancel button, overlay, Escape). */
|
|
29
|
+
onClose?: () => void;
|
|
30
|
+
trigger?: React.ReactNode;
|
|
31
|
+
/**
|
|
32
|
+
* When provided, the user must type this exact text before the confirm button is enabled.
|
|
33
|
+
* e.g. typeToConfirm="confirm" or typeToConfirm="delete"
|
|
34
|
+
*/
|
|
35
|
+
typeToConfirm?: string;
|
|
36
|
+
/**
|
|
37
|
+
* When true, hides the cancel button — useful for info/error alerts that only need one action.
|
|
38
|
+
*/
|
|
39
|
+
hideCancelButton?: boolean;
|
|
40
|
+
testId?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type ConfirmFormValues = { confirmInput: string };
|
|
44
|
+
|
|
45
|
+
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|
46
|
+
open,
|
|
47
|
+
onOpenChange,
|
|
48
|
+
title,
|
|
49
|
+
description,
|
|
50
|
+
confirmLabel = "Confirm",
|
|
51
|
+
cancelLabel = "Cancel",
|
|
52
|
+
onConfirm,
|
|
53
|
+
onCancel,
|
|
54
|
+
onClose,
|
|
55
|
+
trigger,
|
|
56
|
+
typeToConfirm,
|
|
57
|
+
hideCancelButton = false,
|
|
58
|
+
testId,
|
|
59
|
+
}) => {
|
|
60
|
+
const formId = React.useId();
|
|
61
|
+
const requiresInput = !!typeToConfirm;
|
|
62
|
+
|
|
63
|
+
const validationSchema = React.useMemo(
|
|
64
|
+
() =>
|
|
65
|
+
yup.object({
|
|
66
|
+
confirmInput: yup
|
|
67
|
+
.string()
|
|
68
|
+
.required("This field is required.")
|
|
69
|
+
.test(
|
|
70
|
+
"type-to-confirm",
|
|
71
|
+
`Please type '${typeToConfirm}' to proceed`,
|
|
72
|
+
(value) => value === typeToConfirm,
|
|
73
|
+
),
|
|
74
|
+
}),
|
|
75
|
+
[typeToConfirm],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const { methods, FormRoot } = useControlledForm<ConfirmFormValues>({
|
|
79
|
+
defaultValues: { confirmInput: "" },
|
|
80
|
+
validationSchema,
|
|
81
|
+
mode: "onTouched",
|
|
82
|
+
reValidateMode: "onChange",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const isFormValid = methods.formState.isValid;
|
|
86
|
+
|
|
87
|
+
const handleOpenChange = (nextOpen: boolean) => {
|
|
88
|
+
if (!nextOpen) {
|
|
89
|
+
methods.reset();
|
|
90
|
+
onClose?.();
|
|
91
|
+
}
|
|
92
|
+
onOpenChange?.(nextOpen);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const handleCancel = () => {
|
|
96
|
+
methods.reset();
|
|
97
|
+
onCancel?.();
|
|
98
|
+
onClose?.();
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
|
103
|
+
{trigger && <AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>}
|
|
104
|
+
<AlertDialogContent data-testid={testId}>
|
|
105
|
+
<AlertDialogHeader>
|
|
106
|
+
<AlertDialogTitle data-testid={testId && `${testId}-title`}>
|
|
107
|
+
{title}
|
|
108
|
+
</AlertDialogTitle>
|
|
109
|
+
{description && (
|
|
110
|
+
<AlertDialogDescription data-testid={testId && `${testId}-description`}>
|
|
111
|
+
{description}
|
|
112
|
+
</AlertDialogDescription>
|
|
113
|
+
)}
|
|
114
|
+
</AlertDialogHeader>
|
|
115
|
+
|
|
116
|
+
{requiresInput && (
|
|
117
|
+
<FormRoot
|
|
118
|
+
id={formId}
|
|
119
|
+
className="flex flex-col gap-4 w-full"
|
|
120
|
+
onSubmit={() => onConfirm?.()}
|
|
121
|
+
>
|
|
122
|
+
<p className="typography-small1 text-text-contrast-max">
|
|
123
|
+
Type “{typeToConfirm}” to proceed.
|
|
124
|
+
</p>
|
|
125
|
+
<Field<ConfirmFormValues, "confirmInput">
|
|
126
|
+
name="confirmInput"
|
|
127
|
+
component={TextInput}
|
|
128
|
+
componentProps={{
|
|
129
|
+
label: "Type to confirm",
|
|
130
|
+
required: true,
|
|
131
|
+
hasClearIcon: true,
|
|
132
|
+
keepFooterSpace: true,
|
|
133
|
+
fullwidth: true,
|
|
134
|
+
testId: testId && `${testId}-type-to-confirm-input`,
|
|
135
|
+
}}
|
|
136
|
+
/>
|
|
137
|
+
</FormRoot>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
<AlertDialogFooter>
|
|
141
|
+
{!hideCancelButton && (
|
|
142
|
+
<AlertDialogCancel
|
|
143
|
+
data-testid={testId && `${testId}-cancel-button`}
|
|
144
|
+
onClick={handleCancel}
|
|
145
|
+
>
|
|
146
|
+
{cancelLabel}
|
|
147
|
+
</AlertDialogCancel>
|
|
148
|
+
)}
|
|
149
|
+
<AlertDialogAction
|
|
150
|
+
type="submit"
|
|
151
|
+
form={requiresInput ? formId : undefined}
|
|
152
|
+
disabled={requiresInput && !isFormValid}
|
|
153
|
+
onClick={requiresInput ? undefined : () => onConfirm?.()}
|
|
154
|
+
data-testid={testId && `${testId}-confirm-button`}
|
|
155
|
+
>
|
|
156
|
+
{confirmLabel}
|
|
157
|
+
</AlertDialogAction>
|
|
158
|
+
</AlertDialogFooter>
|
|
159
|
+
</AlertDialogContent>
|
|
160
|
+
</AlertDialog>
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
ConfirmDialog.displayName = "ConfirmDialog";
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { useArgs } from "@storybook/preview-api";
|
|
4
|
+
import { FormDialog } from "./FormDialog";
|
|
5
|
+
import Button from "@/components/Button/Button";
|
|
6
|
+
import { TextInput } from "@/components/TextInput/TextInput";
|
|
7
|
+
import { useControlledForm, Field } from "@/components/Form";
|
|
8
|
+
import * as yup from "yup";
|
|
9
|
+
|
|
10
|
+
const meta = {
|
|
11
|
+
title: "Patterns/FormDialog",
|
|
12
|
+
component: FormDialog,
|
|
13
|
+
tags: ["autodocs"],
|
|
14
|
+
parameters: {
|
|
15
|
+
layout: "fullscreen",
|
|
16
|
+
},
|
|
17
|
+
decorators: [
|
|
18
|
+
(Story) => (
|
|
19
|
+
<div className="p-5 flex w-full">
|
|
20
|
+
<Story />
|
|
21
|
+
</div>
|
|
22
|
+
),
|
|
23
|
+
],
|
|
24
|
+
argTypes: {
|
|
25
|
+
open: { control: "boolean" },
|
|
26
|
+
title: { control: "text" },
|
|
27
|
+
description: { control: "text" },
|
|
28
|
+
scrollable: { control: "boolean" },
|
|
29
|
+
},
|
|
30
|
+
} satisfies Meta<typeof FormDialog>;
|
|
31
|
+
|
|
32
|
+
export default meta;
|
|
33
|
+
type Story = StoryObj<typeof FormDialog>;
|
|
34
|
+
|
|
35
|
+
const ContentArea = () => (
|
|
36
|
+
<div className="flex items-center justify-center bg-ramps-secondary-150 h-[200px] w-full rounded-sm">
|
|
37
|
+
<p className="typography-body3 text-text-contrast-max">
|
|
38
|
+
Content - Form Area
|
|
39
|
+
</p>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
export const Default: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
open: false,
|
|
46
|
+
title: "Title",
|
|
47
|
+
description: "Subtitle description",
|
|
48
|
+
},
|
|
49
|
+
render: (args) => {
|
|
50
|
+
const [{ open }, updateArgs] = useArgs();
|
|
51
|
+
return (
|
|
52
|
+
<FormDialog
|
|
53
|
+
{...args}
|
|
54
|
+
open={open}
|
|
55
|
+
onOpenChange={(next) => updateArgs({ open: next })}
|
|
56
|
+
cancelAction={{
|
|
57
|
+
label: "Cancel",
|
|
58
|
+
onClick: () => updateArgs({ open: false }),
|
|
59
|
+
}}
|
|
60
|
+
confirmAction={{ label: "Confirm", disabled: true }}
|
|
61
|
+
trigger={
|
|
62
|
+
<Button fullwidth={false} onClick={() => updateArgs({ open: true })}>
|
|
63
|
+
Open
|
|
64
|
+
</Button>
|
|
65
|
+
}
|
|
66
|
+
>
|
|
67
|
+
<ContentArea />
|
|
68
|
+
</FormDialog>
|
|
69
|
+
);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const WithExtraAction: Story = {
|
|
74
|
+
args: {
|
|
75
|
+
open: false,
|
|
76
|
+
title: "Title",
|
|
77
|
+
description: "Subtitle description",
|
|
78
|
+
},
|
|
79
|
+
render: (args) => {
|
|
80
|
+
const [{ open }, updateArgs] = useArgs();
|
|
81
|
+
return (
|
|
82
|
+
<FormDialog
|
|
83
|
+
{...args}
|
|
84
|
+
open={open}
|
|
85
|
+
onOpenChange={(next) => updateArgs({ open: next })}
|
|
86
|
+
extraAction={{
|
|
87
|
+
label: "Medium",
|
|
88
|
+
onClick: () => console.log("extra action"),
|
|
89
|
+
}}
|
|
90
|
+
cancelAction={{
|
|
91
|
+
label: "Cancel",
|
|
92
|
+
onClick: () => updateArgs({ open: false }),
|
|
93
|
+
}}
|
|
94
|
+
confirmAction={{ label: "Confirm", disabled: true }}
|
|
95
|
+
trigger={
|
|
96
|
+
<Button fullwidth={false} onClick={() => updateArgs({ open: true })}>
|
|
97
|
+
Open (with extra action)
|
|
98
|
+
</Button>
|
|
99
|
+
}
|
|
100
|
+
>
|
|
101
|
+
<ContentArea />
|
|
102
|
+
</FormDialog>
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const WithForm: Story = {
|
|
108
|
+
args: {
|
|
109
|
+
open: false,
|
|
110
|
+
title: "Edit profile",
|
|
111
|
+
description: "Make changes to your profile here.",
|
|
112
|
+
},
|
|
113
|
+
render: (args) => {
|
|
114
|
+
const [{ open }, updateArgs] = useArgs();
|
|
115
|
+
const [name, setName] = useState("");
|
|
116
|
+
const isValid = name.trim().length > 0;
|
|
117
|
+
return (
|
|
118
|
+
<FormDialog
|
|
119
|
+
{...args}
|
|
120
|
+
open={open}
|
|
121
|
+
onOpenChange={(next) => {
|
|
122
|
+
if (!next) setName("");
|
|
123
|
+
updateArgs({ open: next });
|
|
124
|
+
}}
|
|
125
|
+
cancelAction={{
|
|
126
|
+
label: "Cancel",
|
|
127
|
+
onClick: () => {
|
|
128
|
+
setName("");
|
|
129
|
+
updateArgs({ open: false });
|
|
130
|
+
},
|
|
131
|
+
}}
|
|
132
|
+
confirmAction={{
|
|
133
|
+
label: "Save changes",
|
|
134
|
+
disabled: !isValid,
|
|
135
|
+
onClick: () => {
|
|
136
|
+
console.log("save:", name);
|
|
137
|
+
updateArgs({ open: false });
|
|
138
|
+
},
|
|
139
|
+
}}
|
|
140
|
+
trigger={
|
|
141
|
+
<Button
|
|
142
|
+
variant="outline"
|
|
143
|
+
fullwidth={false}
|
|
144
|
+
onClick={() => updateArgs({ open: true })}
|
|
145
|
+
>
|
|
146
|
+
Edit profile
|
|
147
|
+
</Button>
|
|
148
|
+
}
|
|
149
|
+
>
|
|
150
|
+
<div className="flex flex-col gap-4">
|
|
151
|
+
<TextInput
|
|
152
|
+
label="Display name"
|
|
153
|
+
required
|
|
154
|
+
value={name}
|
|
155
|
+
onChange={(e) => setName(e.target.value)}
|
|
156
|
+
fullwidth
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
</FormDialog>
|
|
160
|
+
);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export const FigmaFormDefaultOpen: Story = {
|
|
165
|
+
args: {
|
|
166
|
+
open: true,
|
|
167
|
+
title: "Title",
|
|
168
|
+
description: "Subtitle description",
|
|
169
|
+
},
|
|
170
|
+
render: (args) => {
|
|
171
|
+
const [{ open }, updateArgs] = useArgs();
|
|
172
|
+
return (
|
|
173
|
+
<FormDialog
|
|
174
|
+
{...args}
|
|
175
|
+
open={open}
|
|
176
|
+
onOpenChange={(next) => updateArgs({ open: next })}
|
|
177
|
+
cancelAction={{
|
|
178
|
+
label: "Cancel",
|
|
179
|
+
onClick: () => updateArgs({ open: false }),
|
|
180
|
+
}}
|
|
181
|
+
confirmAction={{ label: "Confirm", disabled: true }}
|
|
182
|
+
trigger={
|
|
183
|
+
<Button fullwidth={false} onClick={() => updateArgs({ open: true })}>
|
|
184
|
+
Open
|
|
185
|
+
</Button>
|
|
186
|
+
}
|
|
187
|
+
>
|
|
188
|
+
<ContentArea />
|
|
189
|
+
</FormDialog>
|
|
190
|
+
);
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export const FigmaFormWithActionDefaultOpen: Story = {
|
|
195
|
+
args: {
|
|
196
|
+
open: true,
|
|
197
|
+
title: "Title",
|
|
198
|
+
description: "Subtitle description",
|
|
199
|
+
},
|
|
200
|
+
render: (args) => {
|
|
201
|
+
const [{ open }, updateArgs] = useArgs();
|
|
202
|
+
return (
|
|
203
|
+
<FormDialog
|
|
204
|
+
{...args}
|
|
205
|
+
open={open}
|
|
206
|
+
onOpenChange={(next) => updateArgs({ open: next })}
|
|
207
|
+
extraAction={{ label: "Medium" }}
|
|
208
|
+
cancelAction={{
|
|
209
|
+
label: "Cancel",
|
|
210
|
+
onClick: () => updateArgs({ open: false }),
|
|
211
|
+
}}
|
|
212
|
+
confirmAction={{ label: "Confirm", disabled: true }}
|
|
213
|
+
trigger={
|
|
214
|
+
<Button fullwidth={false} onClick={() => updateArgs({ open: true })}>
|
|
215
|
+
Open
|
|
216
|
+
</Button>
|
|
217
|
+
}
|
|
218
|
+
>
|
|
219
|
+
<ContentArea />
|
|
220
|
+
</FormDialog>
|
|
221
|
+
);
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// ─── Real Form integration ────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
type EditProfileValues = {
|
|
228
|
+
displayName: string;
|
|
229
|
+
email: string;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const editProfileSchema = yup.object({
|
|
233
|
+
displayName: yup.string().required("Display name is required."),
|
|
234
|
+
email: yup
|
|
235
|
+
.string()
|
|
236
|
+
.required("Email is required.")
|
|
237
|
+
.email("Must be a valid email."),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
type EditProfileDialogProps = {
|
|
241
|
+
open: boolean;
|
|
242
|
+
onOpenChange: (open: boolean) => void;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const EditProfileDialog = ({ open, onOpenChange }: EditProfileDialogProps) => {
|
|
246
|
+
const formId = React.useId();
|
|
247
|
+
|
|
248
|
+
const { methods, FormRoot } = useControlledForm<EditProfileValues>({
|
|
249
|
+
defaultValues: { displayName: "", email: "" },
|
|
250
|
+
validationSchema: editProfileSchema,
|
|
251
|
+
mode: "onTouched",
|
|
252
|
+
reValidateMode: "onChange",
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const isValid = methods.formState.isValid;
|
|
256
|
+
|
|
257
|
+
const handleClose = () => {
|
|
258
|
+
methods.reset();
|
|
259
|
+
onOpenChange(false);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const handleSubmit = (values: EditProfileValues) => {
|
|
263
|
+
console.log("submitted:", values);
|
|
264
|
+
handleClose();
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<FormDialog
|
|
269
|
+
open={open}
|
|
270
|
+
onOpenChange={(next) => {
|
|
271
|
+
if (!next) handleClose();
|
|
272
|
+
else onOpenChange(next);
|
|
273
|
+
}}
|
|
274
|
+
title="Edit profile"
|
|
275
|
+
description="Make changes to your profile here."
|
|
276
|
+
formId={formId}
|
|
277
|
+
cancelAction={{ label: "Cancel", onClick: handleClose }}
|
|
278
|
+
confirmAction={{ label: "Save changes", disabled: !isValid }}
|
|
279
|
+
>
|
|
280
|
+
<FormRoot
|
|
281
|
+
id={formId}
|
|
282
|
+
onSubmit={handleSubmit}
|
|
283
|
+
className="flex flex-col gap-4"
|
|
284
|
+
>
|
|
285
|
+
<Field<EditProfileValues, "displayName">
|
|
286
|
+
name="displayName"
|
|
287
|
+
component={TextInput}
|
|
288
|
+
componentProps={{
|
|
289
|
+
label: "Display name",
|
|
290
|
+
required: true,
|
|
291
|
+
fullwidth: true,
|
|
292
|
+
}}
|
|
293
|
+
/>
|
|
294
|
+
<Field<EditProfileValues, "email">
|
|
295
|
+
name="email"
|
|
296
|
+
component={TextInput}
|
|
297
|
+
componentProps={{ label: "Email", required: true, fullwidth: true }}
|
|
298
|
+
/>
|
|
299
|
+
</FormRoot>
|
|
300
|
+
</FormDialog>
|
|
301
|
+
);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const EditProfileDialogWithLoading = ({
|
|
305
|
+
open,
|
|
306
|
+
onOpenChange,
|
|
307
|
+
}: EditProfileDialogProps) => {
|
|
308
|
+
const formId = React.useId();
|
|
309
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
310
|
+
|
|
311
|
+
const { methods, FormRoot } = useControlledForm<EditProfileValues>({
|
|
312
|
+
defaultValues: { displayName: "", email: "" },
|
|
313
|
+
validationSchema: editProfileSchema,
|
|
314
|
+
mode: "onTouched",
|
|
315
|
+
reValidateMode: "onChange",
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const isValid = methods.formState.isValid;
|
|
319
|
+
|
|
320
|
+
const handleClose = () => {
|
|
321
|
+
methods.reset();
|
|
322
|
+
onOpenChange(false);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const handleSubmit = async (values: EditProfileValues) => {
|
|
326
|
+
setIsLoading(true);
|
|
327
|
+
try {
|
|
328
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
329
|
+
console.log("saved:", values);
|
|
330
|
+
handleClose();
|
|
331
|
+
} finally {
|
|
332
|
+
setIsLoading(false);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
<FormDialog
|
|
338
|
+
open={open}
|
|
339
|
+
onOpenChange={(next) => {
|
|
340
|
+
if (!next && !isLoading) handleClose();
|
|
341
|
+
else if (next) onOpenChange(next);
|
|
342
|
+
}}
|
|
343
|
+
title="Edit profile"
|
|
344
|
+
description="Make changes to your profile here."
|
|
345
|
+
formId={formId}
|
|
346
|
+
cancelAction={{
|
|
347
|
+
label: "Cancel",
|
|
348
|
+
onClick: handleClose,
|
|
349
|
+
disabled: isLoading,
|
|
350
|
+
}}
|
|
351
|
+
confirmAction={{
|
|
352
|
+
label: "Save changes",
|
|
353
|
+
disabled: !isValid || isLoading,
|
|
354
|
+
isLoading,
|
|
355
|
+
}}
|
|
356
|
+
>
|
|
357
|
+
<FormRoot
|
|
358
|
+
id={formId}
|
|
359
|
+
onSubmit={handleSubmit}
|
|
360
|
+
className="flex flex-col gap-4"
|
|
361
|
+
>
|
|
362
|
+
<Field<EditProfileValues, "displayName">
|
|
363
|
+
name="displayName"
|
|
364
|
+
component={TextInput}
|
|
365
|
+
componentProps={{
|
|
366
|
+
label: "Display name",
|
|
367
|
+
required: true,
|
|
368
|
+
fullwidth: true,
|
|
369
|
+
}}
|
|
370
|
+
/>
|
|
371
|
+
<Field<EditProfileValues, "email">
|
|
372
|
+
name="email"
|
|
373
|
+
component={TextInput}
|
|
374
|
+
componentProps={{ label: "Email", required: true, fullwidth: true }}
|
|
375
|
+
/>
|
|
376
|
+
</FormRoot>
|
|
377
|
+
</FormDialog>
|
|
378
|
+
);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Pattern A: useControlledForm
|
|
383
|
+
*
|
|
384
|
+
* Use when you need to:
|
|
385
|
+
* - access formState (isValid, isDirty) to control the confirm button
|
|
386
|
+
* - reset the form on close
|
|
387
|
+
* - trigger validation outside the form element
|
|
388
|
+
*/
|
|
389
|
+
export const WithRovulaForm: Story = {
|
|
390
|
+
args: { open: false },
|
|
391
|
+
render: () => {
|
|
392
|
+
const [{ open }, updateArgs] = useArgs();
|
|
393
|
+
return (
|
|
394
|
+
<>
|
|
395
|
+
<Button
|
|
396
|
+
variant="outline"
|
|
397
|
+
fullwidth={false}
|
|
398
|
+
onClick={() => updateArgs({ open: true })}
|
|
399
|
+
>
|
|
400
|
+
Edit profile
|
|
401
|
+
</Button>
|
|
402
|
+
<EditProfileDialog
|
|
403
|
+
open={open}
|
|
404
|
+
onOpenChange={(next) => updateArgs({ open: next })}
|
|
405
|
+
/>
|
|
406
|
+
</>
|
|
407
|
+
);
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Pattern B: useControlledForm + async API call + loading state
|
|
413
|
+
*
|
|
414
|
+
* Use when the confirm action calls an API.
|
|
415
|
+
* isLoading disables + shows spinner on the confirm button while the request is in flight.
|
|
416
|
+
*/
|
|
417
|
+
export const WithRovulaFormAndApiLoading: Story = {
|
|
418
|
+
args: { open: false },
|
|
419
|
+
render: () => {
|
|
420
|
+
const [{ open }, updateArgs] = useArgs();
|
|
421
|
+
return (
|
|
422
|
+
<>
|
|
423
|
+
<Button
|
|
424
|
+
variant="outline"
|
|
425
|
+
fullwidth={false}
|
|
426
|
+
onClick={() => updateArgs({ open: true })}
|
|
427
|
+
>
|
|
428
|
+
Edit profile (with loading)
|
|
429
|
+
</Button>
|
|
430
|
+
<EditProfileDialogWithLoading
|
|
431
|
+
open={open}
|
|
432
|
+
onOpenChange={(next) => updateArgs({ open: next })}
|
|
433
|
+
/>
|
|
434
|
+
</>
|
|
435
|
+
);
|
|
436
|
+
},
|
|
437
|
+
};
|