@fogpipe/forma-react 0.17.1 → 0.19.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.
Files changed (40) hide show
  1. package/README.md +82 -0
  2. package/dist/FormRenderer-B7qwG4to.d.ts +566 -0
  3. package/dist/chunk-CFX3T5WK.js +1298 -0
  4. package/dist/chunk-CFX3T5WK.js.map +1 -0
  5. package/dist/defaults/index.d.ts +56 -0
  6. package/dist/defaults/index.js +899 -0
  7. package/dist/defaults/index.js.map +1 -0
  8. package/dist/defaults/styles/forma-defaults.css +696 -0
  9. package/dist/index.d.ts +7 -559
  10. package/dist/index.js +53 -1293
  11. package/dist/index.js.map +1 -1
  12. package/package.json +17 -3
  13. package/src/FieldRenderer.tsx +33 -1
  14. package/src/FormRenderer.tsx +35 -1
  15. package/src/__tests__/defaults/components.test.tsx +1074 -0
  16. package/src/__tests__/defaults/integration.test.tsx +626 -0
  17. package/src/__tests__/defaults/layout.test.tsx +298 -0
  18. package/src/__tests__/test-utils.tsx +4 -2
  19. package/src/defaults/DefaultFormRenderer.tsx +43 -0
  20. package/src/defaults/componentMap.ts +45 -0
  21. package/src/defaults/components/ArrayField.tsx +183 -0
  22. package/src/defaults/components/BooleanInput.tsx +32 -0
  23. package/src/defaults/components/ComputedDisplay.tsx +27 -0
  24. package/src/defaults/components/DateInput.tsx +59 -0
  25. package/src/defaults/components/DisplayField.tsx +22 -0
  26. package/src/defaults/components/FallbackField.tsx +35 -0
  27. package/src/defaults/components/MatrixField.tsx +98 -0
  28. package/src/defaults/components/MultiSelectInput.tsx +51 -0
  29. package/src/defaults/components/NumberInput.tsx +73 -0
  30. package/src/defaults/components/ObjectField.tsx +22 -0
  31. package/src/defaults/components/SelectInput.tsx +44 -0
  32. package/src/defaults/components/TextInput.tsx +48 -0
  33. package/src/defaults/components/TextareaInput.tsx +46 -0
  34. package/src/defaults/index.ts +33 -0
  35. package/src/defaults/layout/FieldWrapper.tsx +83 -0
  36. package/src/defaults/layout/FormLayout.tsx +34 -0
  37. package/src/defaults/layout/PageWrapper.tsx +18 -0
  38. package/src/defaults/layout/WizardLayout.tsx +130 -0
  39. package/src/defaults/styles/forma-defaults.css +696 -0
  40. package/src/types.ts +7 -0
@@ -0,0 +1,59 @@
1
+ import React from "react";
2
+ import type { DateComponentProps, DateTimeComponentProps } from "../../types.js";
3
+
4
+ export function DateInput({ field }: DateComponentProps) {
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
+ return (
14
+ <input
15
+ id={field.name}
16
+ name={field.name}
17
+ type="date"
18
+ className="forma-input forma-input--date"
19
+ value={field.value ?? ""}
20
+ onChange={(e) => field.onChange(e.target.value || null)}
21
+ onBlur={field.onBlur}
22
+ disabled={field.disabled}
23
+ readOnly={field.readonly}
24
+ aria-invalid={hasErrors || undefined}
25
+ aria-required={field.required || undefined}
26
+ aria-describedby={describedBy || undefined}
27
+ />
28
+ );
29
+ }
30
+
31
+ export function DateTimeInput({ field }: DateTimeComponentProps) {
32
+ const hasErrors = field.visibleErrors.length > 0;
33
+ const describedBy = [
34
+ field.description ? `${field.name}-description` : null,
35
+ hasErrors ? `${field.name}-errors` : null,
36
+ ]
37
+ .filter(Boolean)
38
+ .join(" ");
39
+
40
+ // datetime-local expects "YYYY-MM-DDTHH:mm" format
41
+ const inputValue = field.value ?? "";
42
+
43
+ return (
44
+ <input
45
+ id={field.name}
46
+ name={field.name}
47
+ type="datetime-local"
48
+ className="forma-input forma-input--datetime"
49
+ value={inputValue}
50
+ onChange={(e) => field.onChange(e.target.value || null)}
51
+ onBlur={field.onBlur}
52
+ disabled={field.disabled}
53
+ readOnly={field.readonly}
54
+ aria-invalid={hasErrors || undefined}
55
+ aria-required={field.required || undefined}
56
+ aria-describedby={describedBy || undefined}
57
+ />
58
+ );
59
+ }
@@ -0,0 +1,22 @@
1
+ import React from "react";
2
+ import type { FormatOptions } from "@fogpipe/forma-core";
3
+ import { formatValue } from "@fogpipe/forma-core";
4
+ import type { DisplayComponentProps } from "../../types.js";
5
+
6
+ const FORMAT_DEFAULTS: FormatOptions = { nullDisplay: "\u2014" };
7
+
8
+ export function DisplayField({ field }: DisplayComponentProps) {
9
+ const options = field.formatOptions
10
+ ? { ...FORMAT_DEFAULTS, ...field.formatOptions }
11
+ : FORMAT_DEFAULTS;
12
+ const content =
13
+ field.sourceValue !== undefined
14
+ ? formatValue(field.sourceValue, field.format, options)
15
+ : field.content;
16
+
17
+ return (
18
+ <div className="forma-display">
19
+ {content && <p className="forma-display__content">{content}</p>}
20
+ </div>
21
+ );
22
+ }
@@ -0,0 +1,35 @@
1
+ import React from "react";
2
+ import type { FieldComponentProps } from "../../types.js";
3
+
4
+ declare const process: { env: { NODE_ENV?: string } } | undefined;
5
+
6
+ export function FallbackField({ field }: FieldComponentProps) {
7
+ const hasErrors = field.visibleErrors.length > 0;
8
+ const isDev =
9
+ typeof process !== "undefined" && process.env.NODE_ENV !== "production";
10
+
11
+ return (
12
+ <div className="forma-fallback">
13
+ {isDev && (
14
+ <p className="forma-fallback__warning">
15
+ Unknown field type: &quot;{field.field.type}&quot;
16
+ </p>
17
+ )}
18
+ <input
19
+ id={field.name}
20
+ name={field.name}
21
+ type="text"
22
+ className="forma-input"
23
+ value={String(field.value ?? "")}
24
+ onChange={(e) => {
25
+ if ("onChange" in field && typeof field.onChange === "function") {
26
+ (field.onChange as (value: string) => void)(e.target.value);
27
+ }
28
+ }}
29
+ onBlur={field.onBlur}
30
+ disabled={field.disabled}
31
+ aria-invalid={hasErrors || undefined}
32
+ />
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,98 @@
1
+ import React from "react";
2
+ import type { MatrixComponentProps } from "../../types.js";
3
+
4
+ export function MatrixField({ field }: MatrixComponentProps) {
5
+ const hasErrors = field.visibleErrors.length > 0;
6
+ const visibleRows = field.rows.filter((r) => r.visible);
7
+ const currentValue = field.value ?? {};
8
+
9
+ const handleChange = (
10
+ rowId: string,
11
+ colValue: string | number,
12
+ ) => {
13
+ if (field.disabled) return;
14
+
15
+ const next = { ...currentValue };
16
+ if (field.multiSelect) {
17
+ const current = (currentValue[rowId] ?? []) as string[];
18
+ const colStr = String(colValue);
19
+ const exists = current.includes(colStr);
20
+ next[rowId] = exists
21
+ ? current.filter((v) => v !== colStr)
22
+ : [...current, colStr];
23
+ } else {
24
+ next[rowId] = colValue;
25
+ }
26
+ field.onChange(next as Record<string, string | number | string[] | number[]>);
27
+ field.onBlur();
28
+ };
29
+
30
+ const isChecked = (
31
+ rowId: string,
32
+ colValue: string | number,
33
+ ): boolean => {
34
+ const rowValue = currentValue[rowId];
35
+ if (rowValue === undefined || rowValue === null) return false;
36
+ if (Array.isArray(rowValue)) {
37
+ return (rowValue as (string | number)[]).includes(colValue);
38
+ }
39
+ return rowValue === colValue;
40
+ };
41
+
42
+ return (
43
+ <div className="forma-matrix" aria-invalid={hasErrors || undefined}>
44
+ <table className="forma-matrix__table" role="grid">
45
+ <thead>
46
+ <tr>
47
+ <th scope="col" className="forma-matrix__corner" />
48
+ {field.columns.map((col) => (
49
+ <th
50
+ key={String(col.value)}
51
+ scope="col"
52
+ className="forma-matrix__col-header"
53
+ >
54
+ {col.label}
55
+ </th>
56
+ ))}
57
+ </tr>
58
+ </thead>
59
+ <tbody>
60
+ {visibleRows.map((row) => (
61
+ <tr key={row.id} className="forma-matrix__row">
62
+ <th scope="row" className="forma-matrix__row-header">
63
+ {row.label}
64
+ </th>
65
+ {field.columns.map((col) => {
66
+ const cellId = `${field.name}-${row.id}-${col.value}`;
67
+ return (
68
+ <td key={String(col.value)} className="forma-matrix__cell">
69
+ <input
70
+ id={cellId}
71
+ type={field.multiSelect ? "checkbox" : "radio"}
72
+ name={
73
+ field.multiSelect
74
+ ? cellId
75
+ : `${field.name}-${row.id}`
76
+ }
77
+ className={
78
+ field.multiSelect
79
+ ? "forma-checkbox__input"
80
+ : "forma-radio__input"
81
+ }
82
+ checked={isChecked(row.id, col.value)}
83
+ onChange={() => handleChange(row.id, col.value)}
84
+ disabled={field.disabled}
85
+ />
86
+ <label htmlFor={cellId} className="forma-sr-only">
87
+ {row.label}: {col.label}
88
+ </label>
89
+ </td>
90
+ );
91
+ })}
92
+ </tr>
93
+ ))}
94
+ </tbody>
95
+ </table>
96
+ </div>
97
+ );
98
+ }
@@ -0,0 +1,51 @@
1
+ import React from "react";
2
+ import type { MultiSelectComponentProps } from "../../types.js";
3
+
4
+ export function MultiSelectInput({ field }: MultiSelectComponentProps) {
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 selected = field.value ?? [];
14
+
15
+ const handleToggle = (optionValue: string) => {
16
+ if (selected.includes(optionValue)) {
17
+ field.onChange(selected.filter((v) => v !== optionValue));
18
+ } else {
19
+ field.onChange([...selected, optionValue]);
20
+ }
21
+ field.onBlur();
22
+ };
23
+
24
+ return (
25
+ <fieldset
26
+ className="forma-multiselect"
27
+ aria-describedby={describedBy || undefined}
28
+ aria-invalid={hasErrors || undefined}
29
+ >
30
+ <legend className="forma-sr-only">{field.label}</legend>
31
+ {field.options.map((opt) => {
32
+ const optId = `${field.name}-${opt.value}`;
33
+ return (
34
+ <div key={String(opt.value)} className="forma-multiselect__option">
35
+ <input
36
+ id={optId}
37
+ type="checkbox"
38
+ className="forma-checkbox__input"
39
+ checked={selected.includes(String(opt.value))}
40
+ onChange={() => handleToggle(String(opt.value))}
41
+ disabled={field.disabled}
42
+ />
43
+ <label htmlFor={optId} className="forma-checkbox__label">
44
+ {opt.label}
45
+ </label>
46
+ </div>
47
+ );
48
+ })}
49
+ </fieldset>
50
+ );
51
+ }
@@ -0,0 +1,73 @@
1
+ import React from "react";
2
+ import type { NumberComponentProps, IntegerComponentProps } from "../../types.js";
3
+
4
+ function NumberInputBase({
5
+ field,
6
+ parseValue,
7
+ }: {
8
+ field: NumberComponentProps["field"] | IntegerComponentProps["field"];
9
+ parseValue: (val: string) => number;
10
+ }) {
11
+ const hasErrors = field.visibleErrors.length > 0;
12
+ const describedBy = [
13
+ field.description ? `${field.name}-description` : null,
14
+ hasErrors ? `${field.name}-errors` : null,
15
+ ]
16
+ .filter(Boolean)
17
+ .join(" ");
18
+
19
+ const input = (
20
+ <input
21
+ id={field.name}
22
+ name={field.name}
23
+ type="number"
24
+ className={`forma-input forma-input--${field.fieldType}`}
25
+ value={field.value != null ? String(field.value) : ""}
26
+ onChange={(e) => {
27
+ const val = e.target.value;
28
+ if (val === "") {
29
+ field.onChange(null);
30
+ } else {
31
+ const num = parseValue(val);
32
+ field.onChange(isNaN(num) ? null : num);
33
+ }
34
+ }}
35
+ onBlur={field.onBlur}
36
+ disabled={field.disabled}
37
+ readOnly={field.readonly}
38
+ placeholder={field.placeholder}
39
+ min={field.min}
40
+ max={field.max}
41
+ step={field.step ?? (field.fieldType === "integer" ? 1 : "any")}
42
+ aria-invalid={hasErrors || undefined}
43
+ aria-required={field.required || undefined}
44
+ aria-describedby={describedBy || undefined}
45
+ />
46
+ );
47
+
48
+ if (field.prefix || field.suffix) {
49
+ return (
50
+ <div className="forma-input-adorner">
51
+ {field.prefix && (
52
+ <span className="forma-input-adorner__prefix">{field.prefix}</span>
53
+ )}
54
+ {input}
55
+ {field.suffix && (
56
+ <span className="forma-input-adorner__suffix">{field.suffix}</span>
57
+ )}
58
+ </div>
59
+ );
60
+ }
61
+
62
+ return input;
63
+ }
64
+
65
+ export function NumberInput({ field }: NumberComponentProps) {
66
+ return <NumberInputBase field={field} parseValue={parseFloat} />;
67
+ }
68
+
69
+ export function IntegerInput({ field }: IntegerComponentProps) {
70
+ return (
71
+ <NumberInputBase field={field} parseValue={(v) => parseInt(v, 10)} />
72
+ );
73
+ }
@@ -0,0 +1,22 @@
1
+ import React from "react";
2
+ import type { ObjectComponentProps } from "../../types.js";
3
+
4
+ export function ObjectField({ field }: ObjectComponentProps) {
5
+ const hasErrors = field.visibleErrors.length > 0;
6
+
7
+ return (
8
+ <fieldset
9
+ className="forma-object"
10
+ aria-invalid={hasErrors || undefined}
11
+ >
12
+ <legend className="forma-object__legend">{field.label}</legend>
13
+ {field.description && (
14
+ <p className="forma-object__description">{field.description}</p>
15
+ )}
16
+ <div className="forma-object__fields">
17
+ {/* Object child fields are rendered by FormRenderer, not here.
18
+ The object component is a visual container only. */}
19
+ </div>
20
+ </fieldset>
21
+ );
22
+ }
@@ -0,0 +1,44 @@
1
+ import React from "react";
2
+ import type { SelectComponentProps } from "../../types.js";
3
+
4
+ export function SelectInput({ field }: SelectComponentProps) {
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
+ return (
14
+ <select
15
+ id={field.name}
16
+ name={field.name}
17
+ className="forma-select"
18
+ value={field.value !== null ? String(field.value) : ""}
19
+ onChange={(e) => {
20
+ const value = e.target.value;
21
+ if (!value) {
22
+ field.onChange(null);
23
+ } else {
24
+ // Preserve string value
25
+ field.onChange(value);
26
+ }
27
+ }}
28
+ onBlur={field.onBlur}
29
+ disabled={field.disabled}
30
+ aria-invalid={hasErrors || undefined}
31
+ aria-required={field.required || undefined}
32
+ aria-describedby={describedBy || undefined}
33
+ >
34
+ {(!field.required || field.value === null) && (
35
+ <option value="">{field.placeholder ?? "Select..."}</option>
36
+ )}
37
+ {field.options.map((opt) => (
38
+ <option key={String(opt.value)} value={String(opt.value)}>
39
+ {opt.label}
40
+ </option>
41
+ ))}
42
+ </select>
43
+ );
44
+ }
@@ -0,0 +1,48 @@
1
+ import React from "react";
2
+ import type { TextComponentProps } from "../../types.js";
3
+
4
+ export function TextInput({ field }: TextComponentProps) {
5
+ const inputType = field.fieldType === "phone" ? "tel" : field.fieldType;
6
+
7
+ const hasErrors = field.visibleErrors.length > 0;
8
+ const describedBy = [
9
+ field.description ? `${field.name}-description` : null,
10
+ hasErrors ? `${field.name}-errors` : null,
11
+ ]
12
+ .filter(Boolean)
13
+ .join(" ");
14
+
15
+ const input = (
16
+ <input
17
+ id={field.name}
18
+ name={field.name}
19
+ type={inputType}
20
+ className="forma-input"
21
+ value={field.value}
22
+ onChange={(e) => field.onChange(e.target.value)}
23
+ onBlur={field.onBlur}
24
+ disabled={field.disabled}
25
+ readOnly={field.readonly}
26
+ placeholder={field.placeholder}
27
+ aria-invalid={hasErrors || undefined}
28
+ aria-required={field.required || undefined}
29
+ aria-describedby={describedBy || undefined}
30
+ />
31
+ );
32
+
33
+ if (field.prefix || field.suffix) {
34
+ return (
35
+ <div className="forma-input-adorner">
36
+ {field.prefix && (
37
+ <span className="forma-input-adorner__prefix">{field.prefix}</span>
38
+ )}
39
+ {input}
40
+ {field.suffix && (
41
+ <span className="forma-input-adorner__suffix">{field.suffix}</span>
42
+ )}
43
+ </div>
44
+ );
45
+ }
46
+
47
+ return input;
48
+ }
@@ -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
+ }