@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.
Files changed (34) hide show
  1. package/dist/cjs/bundle.js +1545 -1545
  2. package/dist/cjs/bundle.js.map +1 -1
  3. package/dist/cjs/types/components/Form/Form.d.ts +1 -1
  4. package/dist/cjs/types/index.d.ts +2 -0
  5. package/dist/cjs/types/patterns/confirm-dialog/ConfirmDialog.d.ts +25 -0
  6. package/dist/cjs/types/patterns/confirm-dialog/ConfirmDialog.stories.d.ts +54 -0
  7. package/dist/cjs/types/patterns/form-dialog/FormDialog.d.ts +40 -0
  8. package/dist/cjs/types/patterns/form-dialog/FormDialog.stories.d.ts +63 -0
  9. package/dist/components/AlertDialog/AlertDialog.js +1 -1
  10. package/dist/components/Dialog/Dialog.js +1 -1
  11. package/dist/components/Form/Form.js +15 -4
  12. package/dist/esm/bundle.js +1545 -1545
  13. package/dist/esm/bundle.js.map +1 -1
  14. package/dist/esm/types/components/Form/Form.d.ts +1 -1
  15. package/dist/esm/types/index.d.ts +2 -0
  16. package/dist/esm/types/patterns/confirm-dialog/ConfirmDialog.d.ts +25 -0
  17. package/dist/esm/types/patterns/confirm-dialog/ConfirmDialog.stories.d.ts +54 -0
  18. package/dist/esm/types/patterns/form-dialog/FormDialog.d.ts +40 -0
  19. package/dist/esm/types/patterns/form-dialog/FormDialog.stories.d.ts +63 -0
  20. package/dist/index.d.ts +67 -2
  21. package/dist/index.js +3 -0
  22. package/dist/patterns/confirm-dialog/ConfirmDialog.js +45 -0
  23. package/dist/patterns/confirm-dialog/ConfirmDialog.stories.js +103 -0
  24. package/dist/patterns/form-dialog/FormDialog.js +10 -0
  25. package/dist/patterns/form-dialog/FormDialog.stories.js +223 -0
  26. package/package.json +1 -1
  27. package/src/components/AlertDialog/AlertDialog.tsx +11 -13
  28. package/src/components/Dialog/Dialog.tsx +3 -9
  29. package/src/components/Form/Form.tsx +19 -4
  30. package/src/index.ts +4 -0
  31. package/src/patterns/confirm-dialog/ConfirmDialog.stories.tsx +193 -0
  32. package/src/patterns/confirm-dialog/ConfirmDialog.tsx +164 -0
  33. package/src/patterns/form-dialog/FormDialog.stories.tsx +437 -0
  34. 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 &ldquo;{typeToConfirm}&rdquo; 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
+ };