@fogpipe/forma-react 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -26
- package/dist/FormRenderer-D_ZVK44t.d.ts +558 -0
- package/dist/chunk-5K4QITFH.js +1276 -0
- package/dist/chunk-5K4QITFH.js.map +1 -0
- package/dist/defaults/index.d.ts +56 -0
- package/dist/defaults/index.js +895 -0
- package/dist/defaults/index.js.map +1 -0
- package/dist/defaults/styles/forma-defaults.css +696 -0
- package/dist/index.d.ts +13 -549
- package/dist/index.js +34 -1273
- package/dist/index.js.map +1 -1
- package/package.json +17 -3
- package/src/FieldRenderer.tsx +12 -4
- package/src/FormRenderer.tsx +26 -9
- package/src/__tests__/FieldRenderer.test.tsx +5 -1
- package/src/__tests__/FormRenderer.test.tsx +146 -0
- package/src/__tests__/canProceed.test.ts +243 -0
- package/src/__tests__/defaults/components.test.tsx +818 -0
- package/src/__tests__/defaults/integration.test.tsx +494 -0
- package/src/__tests__/defaults/layout.test.tsx +298 -0
- package/src/__tests__/events.test.ts +15 -5
- package/src/__tests__/useForma.test.ts +108 -5
- package/src/defaults/DefaultFormRenderer.tsx +43 -0
- package/src/defaults/componentMap.ts +45 -0
- package/src/defaults/components/ArrayField.tsx +183 -0
- package/src/defaults/components/BooleanInput.tsx +32 -0
- package/src/defaults/components/ComputedDisplay.tsx +26 -0
- package/src/defaults/components/DateInput.tsx +59 -0
- package/src/defaults/components/DisplayField.tsx +15 -0
- package/src/defaults/components/FallbackField.tsx +35 -0
- package/src/defaults/components/MatrixField.tsx +98 -0
- package/src/defaults/components/MultiSelectInput.tsx +51 -0
- package/src/defaults/components/NumberInput.tsx +73 -0
- package/src/defaults/components/ObjectField.tsx +22 -0
- package/src/defaults/components/SelectInput.tsx +44 -0
- package/src/defaults/components/TextInput.tsx +48 -0
- package/src/defaults/components/TextareaInput.tsx +46 -0
- package/src/defaults/index.ts +33 -0
- package/src/defaults/layout/FieldWrapper.tsx +83 -0
- package/src/defaults/layout/FormLayout.tsx +34 -0
- package/src/defaults/layout/PageWrapper.tsx +18 -0
- package/src/defaults/layout/WizardLayout.tsx +130 -0
- package/src/defaults/styles/forma-defaults.css +696 -0
- package/src/events.ts +4 -1
- package/src/types.ts +16 -4
- package/src/useForma.ts +48 -34
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { TextComponentProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
export function TextareaInput({ field }: TextComponentProps) {
|
|
5
|
+
const hasErrors = field.visibleErrors.length > 0;
|
|
6
|
+
const describedBy = [
|
|
7
|
+
field.description ? `${field.name}-description` : null,
|
|
8
|
+
hasErrors ? `${field.name}-errors` : null,
|
|
9
|
+
]
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.join(" ");
|
|
12
|
+
|
|
13
|
+
const textarea = (
|
|
14
|
+
<textarea
|
|
15
|
+
id={field.name}
|
|
16
|
+
name={field.name}
|
|
17
|
+
className="forma-textarea"
|
|
18
|
+
value={field.value}
|
|
19
|
+
onChange={(e) => field.onChange(e.target.value)}
|
|
20
|
+
onBlur={field.onBlur}
|
|
21
|
+
disabled={field.disabled}
|
|
22
|
+
readOnly={field.readonly}
|
|
23
|
+
placeholder={field.placeholder}
|
|
24
|
+
rows={3}
|
|
25
|
+
aria-invalid={hasErrors || undefined}
|
|
26
|
+
aria-required={field.required || undefined}
|
|
27
|
+
aria-describedby={describedBy || undefined}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
if (field.prefix || field.suffix) {
|
|
32
|
+
return (
|
|
33
|
+
<div className="forma-input-adorner">
|
|
34
|
+
{field.prefix && (
|
|
35
|
+
<span className="forma-input-adorner__prefix">{field.prefix}</span>
|
|
36
|
+
)}
|
|
37
|
+
{textarea}
|
|
38
|
+
{field.suffix && (
|
|
39
|
+
<span className="forma-input-adorner__suffix">{field.suffix}</span>
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return textarea;
|
|
46
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Convenience wrapper
|
|
2
|
+
export { DefaultFormRenderer } from "./DefaultFormRenderer.js";
|
|
3
|
+
export type { DefaultFormRendererProps } from "./DefaultFormRenderer.js";
|
|
4
|
+
|
|
5
|
+
// Component map + layout aliases
|
|
6
|
+
export {
|
|
7
|
+
defaultComponentMap,
|
|
8
|
+
defaultFieldWrapper,
|
|
9
|
+
defaultLayout,
|
|
10
|
+
defaultWizardLayout,
|
|
11
|
+
defaultPageWrapper,
|
|
12
|
+
} from "./componentMap.js";
|
|
13
|
+
|
|
14
|
+
// Individual field components (for override/cherry-pick)
|
|
15
|
+
export { TextInput } from "./components/TextInput.js";
|
|
16
|
+
export { TextareaInput } from "./components/TextareaInput.js";
|
|
17
|
+
export { NumberInput, IntegerInput } from "./components/NumberInput.js";
|
|
18
|
+
export { BooleanInput } from "./components/BooleanInput.js";
|
|
19
|
+
export { DateInput, DateTimeInput } from "./components/DateInput.js";
|
|
20
|
+
export { SelectInput } from "./components/SelectInput.js";
|
|
21
|
+
export { MultiSelectInput } from "./components/MultiSelectInput.js";
|
|
22
|
+
export { ArrayField } from "./components/ArrayField.js";
|
|
23
|
+
export { ObjectField } from "./components/ObjectField.js";
|
|
24
|
+
export { ComputedDisplay } from "./components/ComputedDisplay.js";
|
|
25
|
+
export { DisplayField } from "./components/DisplayField.js";
|
|
26
|
+
export { MatrixField } from "./components/MatrixField.js";
|
|
27
|
+
export { FallbackField } from "./components/FallbackField.js";
|
|
28
|
+
|
|
29
|
+
// Layout components
|
|
30
|
+
export { FieldWrapper } from "./layout/FieldWrapper.js";
|
|
31
|
+
export { FormLayout } from "./layout/FormLayout.js";
|
|
32
|
+
export { WizardLayout } from "./layout/WizardLayout.js";
|
|
33
|
+
export { PageWrapper } from "./layout/PageWrapper.js";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { FieldWrapperProps } from "../../types.js";
|
|
3
|
+
import { useFormaContext } from "../../context.js";
|
|
4
|
+
|
|
5
|
+
export function FieldWrapper({
|
|
6
|
+
fieldPath,
|
|
7
|
+
field,
|
|
8
|
+
children,
|
|
9
|
+
errors,
|
|
10
|
+
touched,
|
|
11
|
+
showRequiredIndicator,
|
|
12
|
+
visible,
|
|
13
|
+
}: FieldWrapperProps) {
|
|
14
|
+
const { isSubmitted } = useFormaContext();
|
|
15
|
+
|
|
16
|
+
if (!visible) return null;
|
|
17
|
+
|
|
18
|
+
const shouldShowMessages = touched || isSubmitted;
|
|
19
|
+
const visibleErrors = shouldShowMessages
|
|
20
|
+
? errors.filter((e) => e.severity === "error")
|
|
21
|
+
: [];
|
|
22
|
+
const visibleWarnings = shouldShowMessages
|
|
23
|
+
? errors.filter((e) => e.severity === "warning")
|
|
24
|
+
: [];
|
|
25
|
+
const hasErrors = visibleErrors.length > 0;
|
|
26
|
+
const hasWarnings = visibleWarnings.length > 0;
|
|
27
|
+
|
|
28
|
+
const classNames = [
|
|
29
|
+
"forma-field",
|
|
30
|
+
hasErrors && "forma-field--error",
|
|
31
|
+
!hasErrors && hasWarnings && "forma-field--warning",
|
|
32
|
+
showRequiredIndicator && "forma-field--required",
|
|
33
|
+
]
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.join(" ");
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className={classNames} data-field-path={fieldPath}>
|
|
39
|
+
{field.label && field.type !== "boolean" && (
|
|
40
|
+
<label htmlFor={fieldPath} className="forma-label">
|
|
41
|
+
{field.label}
|
|
42
|
+
{showRequiredIndicator && (
|
|
43
|
+
<span className="forma-label__required" aria-hidden="true">
|
|
44
|
+
{" "}
|
|
45
|
+
*
|
|
46
|
+
</span>
|
|
47
|
+
)}
|
|
48
|
+
</label>
|
|
49
|
+
)}
|
|
50
|
+
{field.description && (
|
|
51
|
+
<div
|
|
52
|
+
id={`${fieldPath}-description`}
|
|
53
|
+
className="forma-field__description"
|
|
54
|
+
>
|
|
55
|
+
{field.description}
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
{children}
|
|
59
|
+
{hasErrors && (
|
|
60
|
+
<div
|
|
61
|
+
id={`${fieldPath}-errors`}
|
|
62
|
+
className="forma-field__errors"
|
|
63
|
+
role="alert"
|
|
64
|
+
>
|
|
65
|
+
{visibleErrors.map((error, i) => (
|
|
66
|
+
<span key={i} className="forma-field__error">
|
|
67
|
+
{error.message}
|
|
68
|
+
</span>
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
{hasWarnings && (
|
|
73
|
+
<div className="forma-field__warnings">
|
|
74
|
+
{visibleWarnings.map((warning, i) => (
|
|
75
|
+
<span key={i} className="forma-field__warning">
|
|
76
|
+
{warning.message}
|
|
77
|
+
</span>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { LayoutProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
export function FormLayout({
|
|
5
|
+
children,
|
|
6
|
+
onSubmit,
|
|
7
|
+
isSubmitting,
|
|
8
|
+
}: LayoutProps) {
|
|
9
|
+
return (
|
|
10
|
+
<form
|
|
11
|
+
className="forma-form"
|
|
12
|
+
onSubmit={(e) => {
|
|
13
|
+
e.preventDefault();
|
|
14
|
+
onSubmit();
|
|
15
|
+
}}
|
|
16
|
+
noValidate
|
|
17
|
+
>
|
|
18
|
+
{children}
|
|
19
|
+
<div className="forma-form__actions">
|
|
20
|
+
{/* Submit is not disabled when invalid — intentional. Disabling prevents
|
|
21
|
+
users from discovering which fields need attention. Instead, clicking
|
|
22
|
+
submit triggers validation and displays errors via FieldWrapper. */}
|
|
23
|
+
<button
|
|
24
|
+
type="submit"
|
|
25
|
+
className={`forma-button forma-button--primary forma-submit${isSubmitting ? " forma-submit--loading" : ""}`}
|
|
26
|
+
disabled={isSubmitting}
|
|
27
|
+
aria-busy={isSubmitting || undefined}
|
|
28
|
+
>
|
|
29
|
+
{isSubmitting ? "Submitting..." : "Submit"}
|
|
30
|
+
</button>
|
|
31
|
+
</div>
|
|
32
|
+
</form>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { PageWrapperProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
export function PageWrapper({
|
|
5
|
+
title,
|
|
6
|
+
description,
|
|
7
|
+
children,
|
|
8
|
+
}: PageWrapperProps) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="forma-page">
|
|
11
|
+
{title && <h2 className="forma-page__title">{title}</h2>}
|
|
12
|
+
{description && (
|
|
13
|
+
<p className="forma-page__description">{description}</p>
|
|
14
|
+
)}
|
|
15
|
+
{children}
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import React, { useCallback } from "react";
|
|
2
|
+
import type { LayoutProps } from "../../types.js";
|
|
3
|
+
import { useFormaContext } from "../../context.js";
|
|
4
|
+
|
|
5
|
+
export function WizardLayout({
|
|
6
|
+
children,
|
|
7
|
+
onSubmit,
|
|
8
|
+
isSubmitting,
|
|
9
|
+
}: LayoutProps) {
|
|
10
|
+
const { wizard } = useFormaContext();
|
|
11
|
+
|
|
12
|
+
const handleNext = useCallback(() => {
|
|
13
|
+
if (!wizard) return;
|
|
14
|
+
wizard.touchCurrentPageFields();
|
|
15
|
+
if (wizard.validateCurrentPage()) {
|
|
16
|
+
wizard.nextPage();
|
|
17
|
+
}
|
|
18
|
+
}, [wizard]);
|
|
19
|
+
|
|
20
|
+
const handleSubmit = useCallback(
|
|
21
|
+
(e: React.FormEvent<HTMLFormElement>) => {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
|
|
24
|
+
if (!wizard) {
|
|
25
|
+
onSubmit();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const nativeEvent = e.nativeEvent as SubmitEvent;
|
|
30
|
+
const submitter = nativeEvent.submitter as HTMLButtonElement | null;
|
|
31
|
+
|
|
32
|
+
if (wizard.isLastPage && submitter?.dataset.action === "submit") {
|
|
33
|
+
onSubmit();
|
|
34
|
+
} else if (!wizard.isLastPage) {
|
|
35
|
+
handleNext();
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
[wizard, onSubmit, handleNext],
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Fallback to simple form layout if no wizard
|
|
42
|
+
if (!wizard) {
|
|
43
|
+
return (
|
|
44
|
+
<form
|
|
45
|
+
className="forma-form"
|
|
46
|
+
onSubmit={handleSubmit}
|
|
47
|
+
noValidate
|
|
48
|
+
>
|
|
49
|
+
{children}
|
|
50
|
+
<div className="forma-form__actions">
|
|
51
|
+
<button
|
|
52
|
+
type="submit"
|
|
53
|
+
className="forma-button forma-button--primary forma-submit"
|
|
54
|
+
disabled={isSubmitting}
|
|
55
|
+
aria-busy={isSubmitting || undefined}
|
|
56
|
+
>
|
|
57
|
+
{isSubmitting ? "Submitting..." : "Submit"}
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
</form>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<form className="forma-wizard" onSubmit={handleSubmit} noValidate>
|
|
66
|
+
{/* Step indicator */}
|
|
67
|
+
<div className="forma-wizard__steps" role="navigation" aria-label="Form progress">
|
|
68
|
+
{wizard.pages.map((page, index) => {
|
|
69
|
+
const isCompleted = index < wizard.currentPageIndex;
|
|
70
|
+
const isCurrent = index === wizard.currentPageIndex;
|
|
71
|
+
const stepClass = [
|
|
72
|
+
"forma-step",
|
|
73
|
+
isCompleted && "forma-step--completed",
|
|
74
|
+
isCurrent && "forma-step--current",
|
|
75
|
+
!isCompleted && !isCurrent && "forma-step--upcoming",
|
|
76
|
+
]
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.join(" ");
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div key={page.id} className={stepClass} aria-current={isCurrent ? "step" : undefined}>
|
|
82
|
+
<span className="forma-step__indicator">
|
|
83
|
+
{isCompleted ? "\u2713" : index + 1}
|
|
84
|
+
</span>
|
|
85
|
+
<span className="forma-step__label">{page.title}</span>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
})}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{/* Page content */}
|
|
92
|
+
{children}
|
|
93
|
+
|
|
94
|
+
{/* Navigation */}
|
|
95
|
+
<div className="forma-wizard__nav">
|
|
96
|
+
{wizard.hasPreviousPage ? (
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
className="forma-button forma-button--secondary"
|
|
100
|
+
onClick={() => wizard.previousPage()}
|
|
101
|
+
>
|
|
102
|
+
Previous
|
|
103
|
+
</button>
|
|
104
|
+
) : (
|
|
105
|
+
<div />
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{wizard.isLastPage ? (
|
|
109
|
+
<button
|
|
110
|
+
type="submit"
|
|
111
|
+
data-action="submit"
|
|
112
|
+
className={`forma-button forma-button--primary forma-submit${isSubmitting ? " forma-submit--loading" : ""}`}
|
|
113
|
+
disabled={isSubmitting}
|
|
114
|
+
aria-busy={isSubmitting || undefined}
|
|
115
|
+
>
|
|
116
|
+
{isSubmitting ? "Submitting..." : "Submit"}
|
|
117
|
+
</button>
|
|
118
|
+
) : (
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
className="forma-button forma-button--primary"
|
|
122
|
+
onClick={handleNext}
|
|
123
|
+
>
|
|
124
|
+
Next
|
|
125
|
+
</button>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
</form>
|
|
129
|
+
);
|
|
130
|
+
}
|