@mohasinac/appkit 4.21.0 → 4.22.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/dist/client.d.ts +5 -0
- package/dist/client.js +3 -0
- package/dist/features/account/components/AddressForm.js +45 -29
- package/dist/features/admin/components/AdminBundleEditorView.js +3 -0
- package/dist/features/auctions/components/PlaceBidFormClient.js +3 -1
- package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -1
- package/dist/features/layout/BottomActions.js +18 -1
- package/dist/features/layout/BottomActionsContext.d.ts +19 -0
- package/dist/features/layout/BottomActionsContext.js +37 -0
- package/dist/features/layout/hooks/useBottomActions.d.ts +10 -0
- package/dist/features/layout/hooks/useBottomActions.js +48 -11
- package/dist/features/layout/hooks/useFormBottomActions.d.ts +57 -0
- package/dist/features/layout/hooks/useFormBottomActions.js +89 -0
- package/dist/features/layout/index.d.ts +2 -0
- package/dist/features/layout/index.js +1 -0
- package/dist/features/products/components/MakeOfferButton.js +5 -3
- package/dist/features/shell/QuickFormDrawer.js +15 -1
- package/dist/features/shell/SectionForm.d.ts +16 -2
- package/dist/features/shell/SectionForm.js +28 -2
- package/dist/features/shipments/components/AdminShipmentEditorView.js +3 -1
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +36 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/ui/components/Form.d.ts +22 -1
- package/dist/ui/components/Form.js +30 -2
- package/dist/ui/components/Modal.js +4 -3
- package/dist/ui/components/SideDrawer.js +5 -4
- package/dist/ui/components/overlay-context.d.ts +21 -0
- package/dist/ui/components/overlay-context.js +25 -0
- package/dist/ui/forms/FormErrorList.d.ts +29 -0
- package/dist/ui/forms/FormErrorList.js +36 -0
- package/dist/ui/forms/FormErrorSummary.d.ts +8 -0
- package/dist/ui/forms/FormErrorSummary.js +15 -8
- package/dist/ui/forms/FormShell.d.ts +25 -1
- package/dist/ui/forms/FormShell.js +39 -4
- package/dist/ui/forms/index.d.ts +2 -0
- package/dist/ui/forms/index.js +1 -0
- package/package.json +1 -1
|
@@ -25,6 +25,15 @@ export function QuickFormDrawer({ isOpen, onClose, title, fields, defaultValues,
|
|
|
25
25
|
const drawerRef = useRef(null);
|
|
26
26
|
const [values, setValues] = useState(() => initValues(fields, defaultValues));
|
|
27
27
|
const [errors, setErrors] = useState({});
|
|
28
|
+
/*
|
|
29
|
+
* The drawer's `errors` state is already submit-gated (only `validate()`
|
|
30
|
+
* writes it), but the summary below reads `liveErrors`, which is a pure
|
|
31
|
+
* function of the current values and therefore non-empty from first paint.
|
|
32
|
+
* Without this flag the drawer opens listing every field it is about to
|
|
33
|
+
* ask for. Reset on close, so reopening starts clean.
|
|
34
|
+
*/
|
|
35
|
+
const [submitAttemptCount, setSubmitAttemptCount] = useState(0);
|
|
36
|
+
const submitAttempted = submitAttemptCount > 0;
|
|
28
37
|
const [submitting, setSubmitting] = useState(false);
|
|
29
38
|
// Re-initialise when defaultValues change (e.g. edit mode)
|
|
30
39
|
useEffect(() => {
|
|
@@ -101,6 +110,7 @@ export function QuickFormDrawer({ isOpen, onClose, title, fields, defaultValues,
|
|
|
101
110
|
})();
|
|
102
111
|
const handleSubmit = useCallback(async (e) => {
|
|
103
112
|
e?.preventDefault();
|
|
113
|
+
setSubmitAttemptCount((n) => n + 1);
|
|
104
114
|
if (!validate())
|
|
105
115
|
return;
|
|
106
116
|
setSubmitting(true);
|
|
@@ -118,6 +128,7 @@ export function QuickFormDrawer({ isOpen, onClose, title, fields, defaultValues,
|
|
|
118
128
|
const handleClose = useCallback(() => {
|
|
119
129
|
setValues(initValues(fields, defaultValues));
|
|
120
130
|
setErrors({});
|
|
131
|
+
setSubmitAttemptCount(0);
|
|
121
132
|
onClose();
|
|
122
133
|
}, [fields, defaultValues, onClose]);
|
|
123
134
|
// Keyboard: Esc → close; Tab → trap focus
|
|
@@ -175,7 +186,10 @@ export function QuickFormDrawer({ isOpen, onClose, title, fields, defaultValues,
|
|
|
175
186
|
isDirty: false,
|
|
176
187
|
isSubmitting: submitting,
|
|
177
188
|
stepErrorCounts: [],
|
|
178
|
-
|
|
189
|
+
submitAttempted,
|
|
190
|
+
submitAttemptCount,
|
|
191
|
+
markSubmitAttempted: () => setSubmitAttemptCount((n) => n + 1),
|
|
192
|
+
}), [liveErrors, submitting, submitAttempted, submitAttemptCount]);
|
|
179
193
|
if (!isOpen)
|
|
180
194
|
return null;
|
|
181
195
|
const busy = submitting || isLoading;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import type { JsonValue } from "@mohasinac/appkit/client";
|
|
3
3
|
import type { ZodTypeAny } from "zod";
|
|
4
|
-
import type
|
|
4
|
+
import { type FormShellStep } from "../../ui/forms/FormShell";
|
|
5
5
|
/**
|
|
6
6
|
* A collapsible form segment. Replaces `StepDef` — the differences are the
|
|
7
7
|
* point, not incidental:
|
|
@@ -64,6 +64,20 @@ export interface SectionFormProps<T extends object = Record<string, JsonValue>>
|
|
|
64
64
|
expandMode?: "single" | "multi";
|
|
65
65
|
/** Suppress the built-in submit row (a FormShell footer owns it instead). */
|
|
66
66
|
hideActions?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Secondary action for the pinned mobile bar. Omit on a settings page with
|
|
69
|
+
* nowhere to go back to — the bar then shows Save alone.
|
|
70
|
+
*/
|
|
71
|
+
onCancel?: () => void;
|
|
72
|
+
cancelLabel?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Publish Save/Cancel and the error sheet into the mobile bottom-chrome
|
|
75
|
+
* tier. On by default: a SectionForm is a page-level form, which is exactly
|
|
76
|
+
* the case the pinned bar exists for. The hook suppresses itself inside a
|
|
77
|
+
* Modal or SideDrawer regardless, so this is only for the rarer case of a
|
|
78
|
+
* page that wants to own the bar itself.
|
|
79
|
+
*/
|
|
80
|
+
bottomBar?: boolean;
|
|
67
81
|
/**
|
|
68
82
|
* Fires whenever live validation recomputes. SectionForm deliberately does
|
|
69
83
|
* NOT mount its own `FormShellContext.Provider` — the owning view keeps the
|
|
@@ -95,5 +109,5 @@ export interface UseSectionFormNavResult<T extends object> {
|
|
|
95
109
|
export declare function useSectionFormNav<T extends object = Record<string, JsonValue>>(sections: SectionDef<T>[], values?: T): UseSectionFormNavResult<T>;
|
|
96
110
|
/** DOM id for a section panel — kept in one place so nav and render agree. */
|
|
97
111
|
export declare function sectionAnchorId(id: string): string;
|
|
98
|
-
export declare function SectionForm<T extends object = Record<string, JsonValue>>({ sections, values, onChange, onSubmit, submitLabel, isLoading, schema, openIds: controlledOpenIds, onOpenChange, expandMode, hideActions, onValidationChange, }: SectionFormProps<T>): import("react").JSX.Element;
|
|
112
|
+
export declare function SectionForm<T extends object = Record<string, JsonValue>>({ sections, values, onChange, onSubmit, submitLabel, isLoading, schema, openIds: controlledOpenIds, onOpenChange, expandMode, hideActions, onValidationChange, onCancel, cancelLabel, bottomBar, }: SectionFormProps<T>): import("react").JSX.Element;
|
|
99
113
|
export default SectionForm;
|
|
@@ -5,6 +5,8 @@ import { Button } from "../../ui/components/Button";
|
|
|
5
5
|
import { CollapsibleSection } from "../../ui/components/CollapsibleSection";
|
|
6
6
|
import { Badge } from "../../ui/components/Badge";
|
|
7
7
|
import { Row, Span, Stack } from "../../ui";
|
|
8
|
+
import { FormShellContext } from "../../ui/forms/FormShell";
|
|
9
|
+
import { useFormBottomActions } from "../layout/hooks/useFormBottomActions";
|
|
8
10
|
import { FormSchemaContext } from "./FormShell";
|
|
9
11
|
/** Sections in render order: required first, original order preserved within each group. */
|
|
10
12
|
export function orderSections(sections) {
|
|
@@ -53,8 +55,15 @@ export function useSectionFormNav(sections, values) {
|
|
|
53
55
|
export function sectionAnchorId(id) {
|
|
54
56
|
return `section-${id}`;
|
|
55
57
|
}
|
|
56
|
-
export function SectionForm({ sections, values, onChange, onSubmit, submitLabel = "Save", isLoading = false, schema, openIds: controlledOpenIds, onOpenChange, expandMode = "multi", hideActions = false, onValidationChange, }) {
|
|
58
|
+
export function SectionForm({ sections, values, onChange, onSubmit, submitLabel = "Save", isLoading = false, schema, openIds: controlledOpenIds, onOpenChange, expandMode = "multi", hideActions = false, onValidationChange, onCancel, cancelLabel, bottomBar = true, }) {
|
|
57
59
|
const inheritedSchema = useContext(FormSchemaContext);
|
|
60
|
+
/*
|
|
61
|
+
* SectionForm deliberately does not OWN a FormShellContext (the owning view
|
|
62
|
+
* keeps the provider — see the prop docs above), but it does own the submit
|
|
63
|
+
* button, so it is the one place that knows an attempt happened. Null when
|
|
64
|
+
* a caller renders it outside a provider, which is legitimate.
|
|
65
|
+
*/
|
|
66
|
+
const shellCtx = useContext(FormShellContext);
|
|
58
67
|
const [fieldErrors, setFieldErrors] = useState({});
|
|
59
68
|
const [uncontrolledOpenIds, setUncontrolledOpenIds] = useState(() => orderSections(sections).filter((s) => s.required).map((s) => s.id));
|
|
60
69
|
const ordered = useMemo(() => orderSections(sections.filter((s) => (s.when ? s.when(values) : true))), [sections, values]);
|
|
@@ -100,6 +109,23 @@ export function SectionForm({ sections, values, onChange, onSubmit, submitLabel
|
|
|
100
109
|
onChange(partial);
|
|
101
110
|
runValidation({ ...values, ...partial });
|
|
102
111
|
}, [values, onChange, runValidation]);
|
|
112
|
+
/*
|
|
113
|
+
* The one submit path, shared by the inline button below and the pinned
|
|
114
|
+
* mobile bar — so an attempt is recorded and the error sheet revealed
|
|
115
|
+
* identically whichever the user presses.
|
|
116
|
+
*/
|
|
117
|
+
const handleSubmit = useCallback(() => {
|
|
118
|
+
shellCtx?.markSubmitAttempted();
|
|
119
|
+
void onSubmit();
|
|
120
|
+
}, [shellCtx, onSubmit]);
|
|
121
|
+
useFormBottomActions({
|
|
122
|
+
onSubmit: handleSubmit,
|
|
123
|
+
onCancel,
|
|
124
|
+
submitLabel,
|
|
125
|
+
cancelLabel,
|
|
126
|
+
isLoading,
|
|
127
|
+
enabled: bottomBar && !hideActions,
|
|
128
|
+
});
|
|
103
129
|
const toggle = useCallback((id) => {
|
|
104
130
|
const isOpen = openIds.includes(id);
|
|
105
131
|
if (expandMode === "single") {
|
|
@@ -123,6 +149,6 @@ export function SectionForm({ sections, values, onChange, onSubmit, submitLabel
|
|
|
123
149
|
toggle(section.id); }, keepMounted: section.keepMounted, renderHeaderExtra: errorCount > 0
|
|
124
150
|
? () => (_jsxs(Badge, { variant: "danger", children: [errorCount, " ", errorCount === 1 ? "issue" : "issues"] }))
|
|
125
151
|
: undefined, children: section.render({ values, onChange: handleFieldChange, errors: fieldErrors }) }, section.id));
|
|
126
|
-
}), !hideActions && (_jsx(Row, { justify: "end", paddingY: "y-sm", children: _jsx(Button, { variant: "primary", type: "submit", onClick:
|
|
152
|
+
}), !hideActions && (_jsx(Row, { justify: "end", paddingY: "y-sm", children: _jsx(Button, { variant: "primary", type: "submit", onClick: handleSubmit, isLoading: isLoading, disabled: isLoading, children: submitLabel }) }))] }));
|
|
127
153
|
}
|
|
128
154
|
export default SectionForm;
|
|
@@ -77,7 +77,9 @@ export function AdminShipmentEditorView({ shipmentId, onSaved }) {
|
|
|
77
77
|
onSaved?.(saved.id);
|
|
78
78
|
},
|
|
79
79
|
});
|
|
80
|
-
return (_jsx(Form, { schema: createShipmentSchema, spacing: "md", children: ({ setFieldError }) => (_jsxs(_Fragment, { children: [_jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(FieldInput, { name: "shipmentNumber", label: "Shipment Number", required: true, value: shipmentNumber, onChange: setShipmentNumber, placeholder: "SH-2026-0001" }), _jsx(FieldInput, { name: "supplierName", label: "Supplier Name", required: true, value: supplierName, onChange: setSupplierName }), _jsx(FieldInput, { name: "originCountry", label: "Origin Country", value: originCountry, onChange: setOriginCountry }), _jsx(FieldSelect, { name: "status", label: "Status", required: true, value: status, onChange: setStatus, options: STATUS_OPTIONS }), _jsx(FieldInput, { name: "trackingNumber", label: "Tracking Number", value: trackingNumber, onChange: setTrackingNumber }), _jsx(FieldInput, { name: "carrier", label: "Carrier", value: carrier, onChange: setCarrier }), _jsx(FieldInput, { name: "etaDate", label: "ETA Date", type: "date", value: etaDate, onChange: setEtaDate }), _jsx(FieldInput, { name: "laborHoursSpent", label: "Labor Hours Spent", type: "number", min: "0", value: laborHoursSpent, onChange: setLaborHoursSpent }), _jsx(FieldInput, { name: "customsTotal", label: "Customs Total (\u20B9)", type: "number", min: "0", value: customsTotalRupees, onChange: setCustomsTotalRupees, hint: "Split across lots by declared value" }), _jsx(FieldInput, { name: "shippingTotal", label: "Shipping Total (\u20B9)", type: "number", min: "0", value: shippingTotalRupees, onChange: setShippingTotalRupees, hint: "Split across lots by weight" })] }), _jsx(FieldTextarea, { name: "notes", label: "Notes", value: notes, onChange: setNotes, rows: 3 }), _jsx(FormErrorSummary, {}), _jsx(Button, { type: "button", isLoading: saveMutation.isPending, onClick: () => {
|
|
80
|
+
return (_jsx(Form, { schema: createShipmentSchema, spacing: "md", children: ({ setFieldError, markSubmitAttempted }) => (_jsxs(_Fragment, { children: [_jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(FieldInput, { name: "shipmentNumber", label: "Shipment Number", required: true, value: shipmentNumber, onChange: setShipmentNumber, placeholder: "SH-2026-0001" }), _jsx(FieldInput, { name: "supplierName", label: "Supplier Name", required: true, value: supplierName, onChange: setSupplierName }), _jsx(FieldInput, { name: "originCountry", label: "Origin Country", value: originCountry, onChange: setOriginCountry }), _jsx(FieldSelect, { name: "status", label: "Status", required: true, value: status, onChange: setStatus, options: STATUS_OPTIONS }), _jsx(FieldInput, { name: "trackingNumber", label: "Tracking Number", value: trackingNumber, onChange: setTrackingNumber }), _jsx(FieldInput, { name: "carrier", label: "Carrier", value: carrier, onChange: setCarrier }), _jsx(FieldInput, { name: "etaDate", label: "ETA Date", type: "date", value: etaDate, onChange: setEtaDate }), _jsx(FieldInput, { name: "laborHoursSpent", label: "Labor Hours Spent", type: "number", min: "0", value: laborHoursSpent, onChange: setLaborHoursSpent }), _jsx(FieldInput, { name: "customsTotal", label: "Customs Total (\u20B9)", type: "number", min: "0", value: customsTotalRupees, onChange: setCustomsTotalRupees, hint: "Split across lots by declared value" }), _jsx(FieldInput, { name: "shippingTotal", label: "Shipping Total (\u20B9)", type: "number", min: "0", value: shippingTotalRupees, onChange: setShippingTotalRupees, hint: "Split across lots by weight" })] }), _jsx(FieldTextarea, { name: "notes", label: "Notes", value: notes, onChange: setNotes, rows: 3 }), _jsx(FormErrorSummary, {}), _jsx(Button, { type: "button", isLoading: saveMutation.isPending, onClick: () => {
|
|
81
|
+
// type="button" — no native submit, so unhide the summary here.
|
|
82
|
+
markSubmitAttempted();
|
|
81
83
|
const parsed = createShipmentSchema.safeParse({
|
|
82
84
|
shipmentNumber,
|
|
83
85
|
supplierName,
|
|
@@ -1156,6 +1156,42 @@ const rawTesterChecklistItems = [
|
|
|
1156
1156
|
description: "The fix had to cover the update path as well as create — a create-only fix would mean the listing saved correctly and then lost its per-type block the first time anyone edited anything else (Root Cause #39).",
|
|
1157
1157
|
href: "/store/classified",
|
|
1158
1158
|
},
|
|
1159
|
+
{
|
|
1160
|
+
key: "form-errors-wait-for-first-submit",
|
|
1161
|
+
label: "Opening the address form shows NO errors until you press Save",
|
|
1162
|
+
description: "Live bug fixed 2026-08-26: every schema-driven form greeted the user with a list of its own requirements as errors before they had typed anything. Two causes compounded — <FormErrorSummary> was deliberately un-gated, and ~20 views run validate(draft) in a mount effect, so an untouched empty draft failed every .min(1) on first paint. Open /user/addresses/new: the summary block must be absent. Press Save with the form empty: NOW it lists the missing fields.",
|
|
1163
|
+
href: "/user/addresses/new",
|
|
1164
|
+
},
|
|
1165
|
+
{
|
|
1166
|
+
key: "form-errors-stay-live-after-first-submit",
|
|
1167
|
+
label: "After a failed Save, the error list SHRINKS as you fill fields in",
|
|
1168
|
+
description: "The fix gates the DISPLAY, not the computation — the live validation underneath is what makes the list useful. Press Save on an empty address form, then fill Full name. The summary must drop that entry immediately, without a second Save. If the list is frozen, the on-mount validate() was removed instead of gated, which is the wrong fix.",
|
|
1169
|
+
href: "/user/addresses/new",
|
|
1170
|
+
},
|
|
1171
|
+
{
|
|
1172
|
+
key: "form-mobile-bar-pinned",
|
|
1173
|
+
label: "On a phone, Save and Cancel are pinned above the tab bar on the address form",
|
|
1174
|
+
description: "Added 2026-08-26 (W21). Narrow the window below 1024px, or use a real phone. Save/Cancel should sit in the same fixed bar the cart uses for 'Proceed to checkout' — above the bottom tab bar, not scrolled off the end of a long form. On desktop the bar is hidden and the inline Save/Cancel row at the bottom of the form is the control.",
|
|
1175
|
+
href: "/user/addresses/new",
|
|
1176
|
+
},
|
|
1177
|
+
{
|
|
1178
|
+
key: "form-mobile-error-sheet",
|
|
1179
|
+
label: "On a phone, a failed Save opens a 'Fix N issues' sheet you can tap through",
|
|
1180
|
+
description: "Below 1024px, press Save on an empty address form. A 'Fix 6 issues' row must appear above the buttons with the list already open. Collapse it, press Save again — it must RE-open (the count is a counter, not a boolean, precisely so a second attempt is a new event). Tapping an entry on a sectioned form jumps to that field.",
|
|
1181
|
+
href: "/user/addresses/new",
|
|
1182
|
+
},
|
|
1183
|
+
{
|
|
1184
|
+
key: "form-bar-restores-listing-bulk-bar",
|
|
1185
|
+
label: "Closing a form drawer over a listing brings the listing's bulk bar back",
|
|
1186
|
+
description: "Regression test for the singleton clobber: there is ONE bottom bar per route and DataListingView claims it on ~70 admin screens. Select a few rows on an admin listing so the bulk bar appears, open a row's editor drawer, then close it — the bulk bar must still be there with the selection intact. Before the claim stack landed, the drawer overwrote it and blanked it on close.",
|
|
1187
|
+
href: "/admin/products",
|
|
1188
|
+
},
|
|
1189
|
+
{
|
|
1190
|
+
key: "form-bar-absent-inside-a-modal",
|
|
1191
|
+
label: "A form inside a modal or drawer does NOT get a second bar at the bottom of the screen",
|
|
1192
|
+
description: "An overlay owns its own footer, and a viewport-fixed bar would render behind the backdrop, below the dialog it belongs to. Open any drawer-based editor on a phone-width window and confirm the only Save/Cancel controls are the ones inside the drawer.",
|
|
1193
|
+
href: "/admin/coupons",
|
|
1194
|
+
},
|
|
1159
1195
|
{
|
|
1160
1196
|
key: "address-routes-normalised",
|
|
1161
1197
|
label: "Adding and editing an address works, and the OLD urls still resolve",
|
package/dist/index.d.ts
CHANGED
|
@@ -2120,6 +2120,9 @@ export { SidebarLayout } from "./features/layout/index";
|
|
|
2120
2120
|
export { TitleBar } from "./features/layout/index";
|
|
2121
2121
|
export { TitleBarLayout } from "./features/layout/index";
|
|
2122
2122
|
export { useBottomActions } from "./features/layout/index";
|
|
2123
|
+
export { useFormBottomActions } from "./features/layout/index";
|
|
2124
|
+
export { FormErrorList } from "./ui/forms";
|
|
2125
|
+
export { OverlayContext, useIsInsideOverlay } from "./ui/components/overlay-context";
|
|
2123
2126
|
export { useBottomActionsContext } from "./features/layout/index";
|
|
2124
2127
|
export { useDashboardNav } from "./features/layout/index";
|
|
2125
2128
|
export type { AppLayoutShellProps } from "./features/layout/index";
|
package/dist/index.js
CHANGED
|
@@ -3934,6 +3934,10 @@ export { TitleBarLayout } from "./features/layout/index";
|
|
|
3934
3934
|
// [CLIENT-SSR]-Runs in both SSR and browser â€" React component or hook that does not depend on browser-only APIs.
|
|
3935
3935
|
// useBottomActions - React hook for use bottom actions.
|
|
3936
3936
|
export { useBottomActions } from "./features/layout/index";
|
|
3937
|
+
// useFormBottomActions - pins a form's Save/Cancel row + error sheet to the mobile bottom-chrome tier.
|
|
3938
|
+
export { useFormBottomActions } from "./features/layout/index";
|
|
3939
|
+
export { FormErrorList } from "./ui/forms";
|
|
3940
|
+
export { OverlayContext, useIsInsideOverlay } from "./ui/components/overlay-context";
|
|
3937
3941
|
// [CLIENT-SSR]-Runs in both SSR and browser â€" React component or hook that does not depend on browser-only APIs.
|
|
3938
3942
|
// useBottomActionsContext - React hook for use bottom actions context.
|
|
3939
3943
|
export { useBottomActionsContext } from "./features/layout/index";
|
|
@@ -28,13 +28,34 @@ export interface FormProps extends Omit<React.FormHTMLAttributes<HTMLFormElement
|
|
|
28
28
|
* provider would shadow the parent's for everything rendered as `children`.
|
|
29
29
|
*/
|
|
30
30
|
shellCtx?: FormShellContextValue;
|
|
31
|
+
/**
|
|
32
|
+
* Publish Save/Cancel and the mobile error sheet into the bottom-chrome
|
|
33
|
+
* tier — the same bar the cart uses.
|
|
34
|
+
*
|
|
35
|
+
* Opt-IN, unlike `<SectionForm>` where it is on by default. `<Form>` is the
|
|
36
|
+
* generic wrapper and is used inside `<Modal>` and `<SideDrawer>` footers,
|
|
37
|
+
* inside listing toolbars, and several times over on one page
|
|
38
|
+
* (`AdminSiteSettingsView` mounts twenty). Publishing route-level chrome
|
|
39
|
+
* from all of those by default would be wrong far more often than right.
|
|
40
|
+
* The hook additionally suppresses itself inside an overlay, so passing
|
|
41
|
+
* this in a modal is a no-op rather than a bug.
|
|
42
|
+
*/
|
|
43
|
+
bottomBar?: FormBottomBarConfig;
|
|
31
44
|
/** Cross-axis alignment of flex/grid children. Replaces raw `items-*` className. */
|
|
32
45
|
align?: "start" | "center" | "end" | "stretch";
|
|
33
46
|
/** Gap between children — use instead of raw `gap-*` className. */
|
|
34
47
|
gap?: GapToken;
|
|
35
48
|
}
|
|
36
49
|
export type GapToken = "none" | "xs" | "sm" | "md" | "lg" | "xl";
|
|
37
|
-
export
|
|
50
|
+
export interface FormBottomBarConfig {
|
|
51
|
+
onSubmit: () => void | Promise<void>;
|
|
52
|
+
onCancel?: () => void;
|
|
53
|
+
submitLabel?: string;
|
|
54
|
+
cancelLabel?: string;
|
|
55
|
+
isLoading?: boolean;
|
|
56
|
+
disabled?: boolean;
|
|
57
|
+
}
|
|
58
|
+
export declare function Form({ children, spacing, schema, shellCtx: shellCtxProp, bottomBar, align, gap, surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow, className, ...props }: FormProps): React.JSX.Element;
|
|
38
59
|
export interface FormGroupProps {
|
|
39
60
|
children: React.ReactNode;
|
|
40
61
|
columns?: 1 | 2 | 3 | 4;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import React from "react";
|
|
3
4
|
import { Row } from "./Layout";
|
|
4
5
|
import { buildSurfaceClasses } from "./surface-tokens";
|
|
5
6
|
import { FormShellContext, useFormShellState, } from "../forms/FormShell";
|
|
7
|
+
import { useFormBottomActions } from "../../features/layout/hooks/useFormBottomActions";
|
|
6
8
|
const FORM_SPACING_MAP = {
|
|
7
9
|
none: "",
|
|
8
10
|
xs: "space-y-1",
|
|
@@ -43,9 +45,35 @@ const FORM_ALIGN_MAP = {
|
|
|
43
45
|
end: "items-end",
|
|
44
46
|
stretch: "items-stretch",
|
|
45
47
|
};
|
|
46
|
-
export function Form({ children, spacing, schema, shellCtx: shellCtxProp, align, gap, surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow, className = "", ...props }) {
|
|
48
|
+
export function Form({ children, spacing, schema, shellCtx: shellCtxProp, bottomBar, align, gap, surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow, className = "", ...props }) {
|
|
47
49
|
const helpers = useFormShellState(schema);
|
|
48
50
|
const effectiveCtx = shellCtxProp ?? helpers.shellCtx;
|
|
51
|
+
/*
|
|
52
|
+
* Marking the attempt on the NATIVE submit event covers both shapes this
|
|
53
|
+
* component is used in: the caller that calls `validate()` (which marks
|
|
54
|
+
* anyway) and the caller that hand-rolls its checks in the submit button's
|
|
55
|
+
* `onClick`. React fires `onClick` before `onSubmit`, so by the time the
|
|
56
|
+
* re-render happens the errors are already in the context.
|
|
57
|
+
*
|
|
58
|
+
* `effectiveCtx` may come from a parent's `useFormShellState`, so mark on
|
|
59
|
+
* whichever context is actually mounted — not on `helpers`, which is a
|
|
60
|
+
* second, ignored state instance in that case.
|
|
61
|
+
*/
|
|
62
|
+
const { onSubmit: onSubmitProp } = props;
|
|
63
|
+
const handleSubmit = React.useCallback((e) => {
|
|
64
|
+
effectiveCtx.markSubmitAttempted();
|
|
65
|
+
onSubmitProp?.(e);
|
|
66
|
+
}, [effectiveCtx, onSubmitProp]);
|
|
67
|
+
useFormBottomActions({
|
|
68
|
+
onSubmit: bottomBar?.onSubmit ?? (() => { }),
|
|
69
|
+
onCancel: bottomBar?.onCancel,
|
|
70
|
+
submitLabel: bottomBar?.submitLabel,
|
|
71
|
+
cancelLabel: bottomBar?.cancelLabel,
|
|
72
|
+
isLoading: bottomBar?.isLoading,
|
|
73
|
+
disabled: bottomBar?.disabled,
|
|
74
|
+
enabled: bottomBar != null,
|
|
75
|
+
ctx: effectiveCtx,
|
|
76
|
+
});
|
|
49
77
|
const content = typeof children === "function"
|
|
50
78
|
? children({ ...helpers, shellCtx: effectiveCtx })
|
|
51
79
|
: children;
|
|
@@ -56,7 +84,7 @@ export function Form({ children, spacing, schema, shellCtx: shellCtxProp, align,
|
|
|
56
84
|
gap ? GAP_MAP[gap] : "",
|
|
57
85
|
buildSurfaceClasses({ surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow }),
|
|
58
86
|
className,
|
|
59
|
-
].filter(Boolean).join(" "), ...props, children: content }) }));
|
|
87
|
+
].filter(Boolean).join(" "), ...props, onSubmit: handleSubmit, children: content }) }));
|
|
60
88
|
}
|
|
61
89
|
export function FormGroup({ children, columns = 1, gap = "md", className = "", }) {
|
|
62
90
|
const gridClasses = {
|
|
@@ -7,6 +7,7 @@ import { Heading } from "./Typography";
|
|
|
7
7
|
import { Button } from "./Button";
|
|
8
8
|
import { SPRING_GENTLE } from "../../tokens/motion";
|
|
9
9
|
import { useHandMode } from "../../_internal/client/hand-mode";
|
|
10
|
+
import { OverlayContext } from "./overlay-context";
|
|
10
11
|
const SIZE_CLASSES = {
|
|
11
12
|
sm: "appkit-modal__panel--sm",
|
|
12
13
|
md: "appkit-modal__panel--md",
|
|
@@ -69,9 +70,9 @@ export function Modal({ isOpen, open, onClose, title, children, size = "md", sho
|
|
|
69
70
|
if (typeof document === "undefined") {
|
|
70
71
|
return null;
|
|
71
72
|
}
|
|
72
|
-
return createPortal(_jsx(AnimatePresence, { children: visible && (_jsxs(motion.div, { className: "appkit-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": title ? titleId : undefined, onKeyDown: handleKeyDown, initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0.15 }, "data-section": "modal-div-561", children: [_jsx("div", { className: "appkit-modal__backdrop", "aria-hidden": "true", onClick: onClose }), _jsxs(motion.div, { ref: panelRef, tabIndex: -1, className: ["appkit-modal__panel", SIZE_CLASSES[size], className]
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
return createPortal(_jsx(OverlayContext.Provider, { value: true, children: _jsx(AnimatePresence, { children: visible && (_jsxs(motion.div, { className: "appkit-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": title ? titleId : undefined, onKeyDown: handleKeyDown, initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0.15 }, "data-section": "modal-div-561", children: [_jsx("div", { className: "appkit-modal__backdrop", "aria-hidden": "true", onClick: onClose }), _jsxs(motion.div, { ref: panelRef, tabIndex: -1, className: ["appkit-modal__panel", SIZE_CLASSES[size], className]
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
.join(" "), initial: reduced ? false : { opacity: 0, scale: 0.95, y: 8 }, animate: { opacity: 1, scale: 1, y: 0 }, exit: { opacity: 0, scale: 0.95, y: 8 }, transition: SPRING_GENTLE, "data-section": "modal-div-562", children: [(title || showCloseButton) && (_jsxs("div", { className: hand === "left" ? "appkit-modal__header flex-row-reverse" : "appkit-modal__header", "data-section": "modal-div-563", children: [title ? (_jsx(Heading, { level: 2, id: titleId, className: "appkit-modal__title", children: title })) : (_jsx("span", {})), showCloseButton ? (_jsx(Button, { variant: "ghost", size: "sm", type: "button", onClick: onClose, className: "appkit-modal__close", "aria-label": "Close", children: _jsx("span", { className: "appkit-modal__close-icon", "aria-hidden": "true", children: "\u00D7" }) })) : null] })), _jsx("div", { className: "appkit-modal__body", "data-section": "modal-div-564", children: children }), actions ? _jsx(ModalFooter, { children: actions }) : null] })] })) }) }), document.body);
|
|
75
76
|
}
|
|
76
77
|
export function ModalFooter({ children, className = "", }) {
|
|
77
78
|
return (_jsx("div", { className: [
|
|
@@ -10,6 +10,7 @@ import { useSwipe } from "../../react";
|
|
|
10
10
|
import { Text } from "./Typography";
|
|
11
11
|
import { SPRING_SNAPPY } from "../../tokens/motion";
|
|
12
12
|
import { useHandMode } from "../../_internal/client/hand-mode";
|
|
13
|
+
import { OverlayContext } from "./overlay-context";
|
|
13
14
|
const UI_SIDE_DRAWER = {
|
|
14
15
|
backdrop: "appkit-side-drawer__backdrop",
|
|
15
16
|
root: "appkit-side-drawer",
|
|
@@ -152,8 +153,8 @@ export function SideDrawer({ isOpen, onClose, title, children, footer, mode = "v
|
|
|
152
153
|
const reduced = useReducedMotion();
|
|
153
154
|
const positionClass = resolvedSide === "left" ? UI_SIDE_DRAWER.left : UI_SIDE_DRAWER.right;
|
|
154
155
|
const slideX = resolvedSide === "left" ? "-100%" : "100%";
|
|
155
|
-
return (_jsx(AnimatePresence, { children: isOpen && (_jsxs(_Fragment, { children: [_jsx(motion.div, { className: UI_SIDE_DRAWER.backdrop, onClick: attemptClose, "aria-hidden": "true", initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0.2 } }), _jsxs(motion.div, { ref: drawerRef, className: [UI_SIDE_DRAWER.root, positionClass].join(" "), role: "dialog", "aria-modal": "true", "aria-labelledby": "drawer-title", initial: reduced ? false : { x: slideX }, animate: { x: 0 }, exit: { x: slideX }, transition: SPRING_SNAPPY, "data-section": "sidedrawer-div-598", children: [_jsxs(Row, { justify: "between", gap: "none", className: [
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
return (_jsx(OverlayContext.Provider, { value: true, children: _jsx(AnimatePresence, { children: isOpen && (_jsxs(_Fragment, { children: [_jsx(motion.div, { className: UI_SIDE_DRAWER.backdrop, onClick: attemptClose, "aria-hidden": "true", initial: reduced ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0.2 } }), _jsxs(motion.div, { ref: drawerRef, className: [UI_SIDE_DRAWER.root, positionClass].join(" "), role: "dialog", "aria-modal": "true", "aria-labelledby": "drawer-title", initial: reduced ? false : { x: slideX }, animate: { x: 0 }, exit: { x: slideX }, transition: SPRING_SNAPPY, "data-section": "sidedrawer-div-598", children: [_jsxs(Row, { justify: "between", gap: "none", className: [
|
|
157
|
+
UI_SIDE_DRAWER.header,
|
|
158
|
+
UI_SIDE_DRAWER.headerMode[mode],
|
|
159
|
+
].join(" "), children: [_jsxs(Row, { gap: "3", className: "min-w-0", reverse: hand === "left", children: [_jsx(Button, { variant: "ghost", onClick: attemptClose, className: UI_SIDE_DRAWER.closeBtn, "aria-label": tActions("close"), children: _jsx("svg", { className: UI_SIDE_DRAWER.closeIcon, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) }) }), _jsx(Heading, { level: 3, id: "drawer-title", className: "truncate", children: title })] }), mode === "delete" && (_jsx(Span, { className: UI_SIDE_DRAWER.deleteBadge, children: tActions("delete") }))] }), _jsx("div", { className: UI_SIDE_DRAWER.content, "data-section": "sidedrawer-div-599", children: children }), footer && _jsx("div", { className: UI_SIDE_DRAWER.footer, "data-section": "sidedrawer-div-600", children: footer })] }), showUnsavedWarning && (_jsxs(_Fragment, { children: [_jsx("div", { className: UI_SIDE_DRAWER.warnBackdrop, onClick: cancelClose }), _jsxs("div", { className: UI_SIDE_DRAWER.warnDialog, "data-section": "sidedrawer-div-601", children: [_jsxs("div", { className: UI_SIDE_DRAWER.warnHeader, "data-section": "sidedrawer-div-602", children: [_jsx("div", { className: UI_SIDE_DRAWER.warnIcon, "data-section": "sidedrawer-div-603", children: _jsx("svg", { fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" }) }) }), _jsxs("div", { "data-section": "sidedrawer-div-604", children: [_jsx(Heading, { level: 4, className: "mb-1", children: tConfirm("unsavedChangesTitle") }), _jsx(Text, { className: UI_SIDE_DRAWER.warnText, children: tConfirm("unsavedChangesDescription") })] })] }), _jsxs("div", { className: UI_SIDE_DRAWER.warnActions, "data-section": "sidedrawer-div-605", children: [_jsx(Button, { variant: "outline", onClick: cancelClose, size: "sm", children: tActions("keepEditing") }), _jsx(Button, { variant: "danger", onClick: confirmClose, size: "sm", children: tActions("discardChanges") })] })] })] }))] })) }) }));
|
|
159
160
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Am I rendering inside a Modal or a SideDrawer?"
|
|
3
|
+
*
|
|
4
|
+
* WHY: route-level chrome must not attach itself to content that is floating
|
|
5
|
+
* above the route. The concrete case is `useFormBottomActions` — a form on a
|
|
6
|
+
* page publishes its Save/Cancel row and its error sheet into the fixed
|
|
7
|
+
* `BottomChrome` tier, which is correct there and wrong inside an overlay:
|
|
8
|
+
* the overlay already owns a footer, and a bar pinned to the viewport bottom
|
|
9
|
+
* would sit *behind* the backdrop, below the very dialog it belongs to.
|
|
10
|
+
*
|
|
11
|
+
* Detection has to come from the overlay itself. There is no way to infer it
|
|
12
|
+
* from the DOM at render time (both portal elsewhere), and asking every form
|
|
13
|
+
* to declare where it is being rendered is the kind of convention that is one
|
|
14
|
+
* forgetful call site away from a bar appearing under a backdrop.
|
|
15
|
+
*
|
|
16
|
+
* Mounted by `Modal` and `SideDrawer`. Default `false`, so a component outside
|
|
17
|
+
* any overlay — the overwhelmingly common case — needs no provider at all.
|
|
18
|
+
*/
|
|
19
|
+
export declare const OverlayContext: import("react").Context<boolean>;
|
|
20
|
+
/** True when the calling component is rendered inside a Modal or SideDrawer. */
|
|
21
|
+
export declare function useIsInsideOverlay(): boolean;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* "Am I rendering inside a Modal or a SideDrawer?"
|
|
5
|
+
*
|
|
6
|
+
* WHY: route-level chrome must not attach itself to content that is floating
|
|
7
|
+
* above the route. The concrete case is `useFormBottomActions` — a form on a
|
|
8
|
+
* page publishes its Save/Cancel row and its error sheet into the fixed
|
|
9
|
+
* `BottomChrome` tier, which is correct there and wrong inside an overlay:
|
|
10
|
+
* the overlay already owns a footer, and a bar pinned to the viewport bottom
|
|
11
|
+
* would sit *behind* the backdrop, below the very dialog it belongs to.
|
|
12
|
+
*
|
|
13
|
+
* Detection has to come from the overlay itself. There is no way to infer it
|
|
14
|
+
* from the DOM at render time (both portal elsewhere), and asking every form
|
|
15
|
+
* to declare where it is being rendered is the kind of convention that is one
|
|
16
|
+
* forgetful call site away from a bar appearing under a backdrop.
|
|
17
|
+
*
|
|
18
|
+
* Mounted by `Modal` and `SideDrawer`. Default `false`, so a component outside
|
|
19
|
+
* any overlay — the overwhelmingly common case — needs no provider at all.
|
|
20
|
+
*/
|
|
21
|
+
export const OverlayContext = createContext(false);
|
|
22
|
+
/** True when the calling component is rendered inside a Modal or SideDrawer. */
|
|
23
|
+
export function useIsInsideOverlay() {
|
|
24
|
+
return useContext(OverlayContext);
|
|
25
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type FormShellContextValue } from "./FormShell";
|
|
2
|
+
export interface FormErrorListProps {
|
|
3
|
+
/**
|
|
4
|
+
* Read errors from this context value instead of the ambient one. The
|
|
5
|
+
* mobile bottom sheet needs it: `useFormBottomActions` builds the panel as
|
|
6
|
+
* a detached ReactNode that is rendered by `BottomActions`, high up in the
|
|
7
|
+
* layout tree and therefore OUTSIDE the form's own provider.
|
|
8
|
+
*/
|
|
9
|
+
ctx?: FormShellContextValue;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The `<ul>` of current validation errors, with a jump link per entry when
|
|
14
|
+
* the form declares sections.
|
|
15
|
+
*
|
|
16
|
+
* WHY it is its own file: two surfaces render this exact list — the inline
|
|
17
|
+
* `<FormErrorSummary>` beside the submit button on desktop, and the mobile
|
|
18
|
+
* "Fix N issues" sheet in the bottom-chrome tier. Writing it twice would mean
|
|
19
|
+
* a jump-link fix, an empty-state fix or a wording fix landing in one and not
|
|
20
|
+
* the other, which is the bug-fix multiplier the Duplication Framework calls
|
|
21
|
+
* out. There are exactly two consumers and they are the same list, so this is
|
|
22
|
+
* a consolidation, not speculative abstraction.
|
|
23
|
+
*
|
|
24
|
+
* Renders nothing when there are no errors. It does NOT gate on
|
|
25
|
+
* `submitAttempted` — that is the *summary's* decision, not the list's, and
|
|
26
|
+
* the sheet needs to keep rendering while open.
|
|
27
|
+
*/
|
|
28
|
+
export declare function FormErrorList({ ctx: ctxProp, className }: FormErrorListProps): import("react").JSX.Element | null;
|
|
29
|
+
export default FormErrorList;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useContext } from "react";
|
|
4
|
+
import { FormShellContext } from "./FormShell";
|
|
5
|
+
import { Span } from "../components/Typography";
|
|
6
|
+
/**
|
|
7
|
+
* The `<ul>` of current validation errors, with a jump link per entry when
|
|
8
|
+
* the form declares sections.
|
|
9
|
+
*
|
|
10
|
+
* WHY it is its own file: two surfaces render this exact list — the inline
|
|
11
|
+
* `<FormErrorSummary>` beside the submit button on desktop, and the mobile
|
|
12
|
+
* "Fix N issues" sheet in the bottom-chrome tier. Writing it twice would mean
|
|
13
|
+
* a jump-link fix, an empty-state fix or a wording fix landing in one and not
|
|
14
|
+
* the other, which is the bug-fix multiplier the Duplication Framework calls
|
|
15
|
+
* out. There are exactly two consumers and they are the same list, so this is
|
|
16
|
+
* a consolidation, not speculative abstraction.
|
|
17
|
+
*
|
|
18
|
+
* Renders nothing when there are no errors. It does NOT gate on
|
|
19
|
+
* `submitAttempted` — that is the *summary's* decision, not the list's, and
|
|
20
|
+
* the sheet needs to keep rendering while open.
|
|
21
|
+
*/
|
|
22
|
+
export function FormErrorList({ ctx: ctxProp, className }) {
|
|
23
|
+
const ambient = useContext(FormShellContext);
|
|
24
|
+
const ctx = ctxProp ?? ambient;
|
|
25
|
+
if (!ctx)
|
|
26
|
+
return null;
|
|
27
|
+
const entries = Object.entries(ctx.errors);
|
|
28
|
+
if (entries.length === 0)
|
|
29
|
+
return null;
|
|
30
|
+
return (_jsx("ul", { className: ["appkit-formshell__error-summary__list", className].filter(Boolean).join(" "), children: entries.map(([key, message]) => {
|
|
31
|
+
const stepIndex = ctx.fieldToStepIndex?.[key];
|
|
32
|
+
const stepLabel = stepIndex != null ? ctx.steps[stepIndex]?.label : undefined;
|
|
33
|
+
return (_jsx("li", { children: stepLabel ? (_jsxs("button", { type: "button", className: "appkit-formshell__error-summary__step-link", onClick: () => ctx.goToStep(stepIndex), children: [_jsxs(Span, { weight: "semibold", children: [stepLabel, ":"] }), " ", message] })) : (_jsx(Span, { children: message })) }, key));
|
|
34
|
+
}) }));
|
|
35
|
+
}
|
|
36
|
+
export default FormErrorList;
|
|
@@ -13,6 +13,14 @@ export interface FormErrorSummaryProps {
|
|
|
13
13
|
* error immediately as fields change — not gated by `touched`, unlike the
|
|
14
14
|
* inline per-field errors.
|
|
15
15
|
*
|
|
16
|
+
* 🛑 It IS gated on `ctx.submitAttempted`, and must stay that way. The
|
|
17
|
+
* summary speaks about fields the user has not reached yet, so before a
|
|
18
|
+
* submit it reads as a list of accusations rather than of mistakes — and
|
|
19
|
+
* ~20 views run `validate(draft)` in a mount effect, so an untouched empty
|
|
20
|
+
* draft fails every `.min(1)` on first paint. Gate the DISPLAY only: the
|
|
21
|
+
* live validation underneath is what keeps this list shrinking as the user
|
|
22
|
+
* fixes things after a failed submit, which is the whole point of it.
|
|
23
|
+
*
|
|
16
24
|
* For multi-step forms whose step engine populates
|
|
17
25
|
* `FormShellContextValue.fieldToStepIndex`, each entry names its owning step
|
|
18
26
|
* and is clickable to jump there. Falls back to a flat list when no step
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useContext } from "react";
|
|
4
4
|
import { FormShellContext } from "./FormShell";
|
|
5
|
-
import {
|
|
5
|
+
import { FormErrorList } from "./FormErrorList";
|
|
6
6
|
/**
|
|
7
7
|
* Shared, live validation-error summary — supplements (does not replace) the
|
|
8
8
|
* per-field inline errors `FieldInput`/`FieldSelect`/`FormField` already
|
|
@@ -11,6 +11,14 @@ import { Span } from "../components/Typography";
|
|
|
11
11
|
* error immediately as fields change — not gated by `touched`, unlike the
|
|
12
12
|
* inline per-field errors.
|
|
13
13
|
*
|
|
14
|
+
* 🛑 It IS gated on `ctx.submitAttempted`, and must stay that way. The
|
|
15
|
+
* summary speaks about fields the user has not reached yet, so before a
|
|
16
|
+
* submit it reads as a list of accusations rather than of mistakes — and
|
|
17
|
+
* ~20 views run `validate(draft)` in a mount effect, so an untouched empty
|
|
18
|
+
* draft fails every `.min(1)` on first paint. Gate the DISPLAY only: the
|
|
19
|
+
* live validation underneath is what keeps this list shrinking as the user
|
|
20
|
+
* fixes things after a failed submit, which is the whole point of it.
|
|
21
|
+
*
|
|
14
22
|
* For multi-step forms whose step engine populates
|
|
15
23
|
* `FormShellContextValue.fieldToStepIndex`, each entry names its owning step
|
|
16
24
|
* and is clickable to jump there. Falls back to a flat list when no step
|
|
@@ -23,15 +31,14 @@ export function FormErrorSummary({ hideWhenEmpty = true, className, title = "Ple
|
|
|
23
31
|
const ctx = useContext(FormShellContext);
|
|
24
32
|
if (!ctx)
|
|
25
33
|
return null;
|
|
34
|
+
// Nothing to say until they've tried. See the header.
|
|
35
|
+
if (!ctx.submitAttempted)
|
|
36
|
+
return null;
|
|
26
37
|
const entries = Object.entries(ctx.errors);
|
|
38
|
+
// `hideWhenEmpty` is the only reason an empty list would render at all —
|
|
39
|
+
// as an empty titled block confirming there is nothing to fix.
|
|
27
40
|
if (entries.length === 0 && hideWhenEmpty)
|
|
28
41
|
return null;
|
|
29
|
-
|
|
30
|
-
return null;
|
|
31
|
-
return (_jsxs("div", { role: "alert", "aria-live": "polite", className: ["appkit-formshell__error-summary", className].filter(Boolean).join(" "), children: [_jsx("div", { className: "appkit-formshell__error-summary__title", children: title }), _jsx("ul", { className: "appkit-formshell__error-summary__list", children: entries.map(([key, message]) => {
|
|
32
|
-
const stepIndex = ctx.fieldToStepIndex?.[key];
|
|
33
|
-
const stepLabel = stepIndex != null ? ctx.steps[stepIndex]?.label : undefined;
|
|
34
|
-
return (_jsx("li", { children: stepLabel ? (_jsxs("button", { type: "button", className: "appkit-formshell__error-summary__step-link", onClick: () => ctx.goToStep(stepIndex), children: [_jsxs(Span, { weight: "semibold", children: [stepLabel, ":"] }), " ", message] })) : (_jsx(Span, { children: message })) }, key));
|
|
35
|
-
}) })] }));
|
|
42
|
+
return (_jsxs("div", { role: "alert", "aria-live": "polite", className: ["appkit-formshell__error-summary", className].filter(Boolean).join(" "), children: [_jsx("div", { className: "appkit-formshell__error-summary__title", children: title }), _jsx(FormErrorList, {})] }));
|
|
36
43
|
}
|
|
37
44
|
export default FormErrorSummary;
|
|
@@ -56,6 +56,19 @@ export interface FormShellContextValue {
|
|
|
56
56
|
* treats undefined as "no step context" and renders a flat list.
|
|
57
57
|
*/
|
|
58
58
|
fieldToStepIndex?: Record<string, number>;
|
|
59
|
+
submitAttempted: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* How many times. `submitAttempted` is `submitAttemptCount > 0` — DERIVED
|
|
62
|
+
* at the one place each producer builds this object, never stored twice.
|
|
63
|
+
*
|
|
64
|
+
* The count exists because a boolean cannot express "this is a NEW
|
|
65
|
+
* attempt". The mobile error sheet force-opens on a bumped counter, so
|
|
66
|
+
* pressing Save a second time re-opens a sheet the user had collapsed,
|
|
67
|
+
* while an ordinary re-render does not.
|
|
68
|
+
*/
|
|
69
|
+
submitAttemptCount: number;
|
|
70
|
+
/** Call from a submit handler, before validating. */
|
|
71
|
+
markSubmitAttempted: () => void;
|
|
59
72
|
}
|
|
60
73
|
export declare const FormShellContext: React.Context<FormShellContextValue | null>;
|
|
61
74
|
export declare function useFormShell(): FormShellContextValue;
|
|
@@ -70,6 +83,16 @@ export interface UseFormShellStateResult {
|
|
|
70
83
|
* parsed value on success or null on failure.
|
|
71
84
|
*/
|
|
72
85
|
validate: <T = unknown>(values: unknown) => T | null;
|
|
86
|
+
/** True once the user has actually tried to submit. Gates <FormErrorSummary>. */
|
|
87
|
+
submitAttempted: boolean;
|
|
88
|
+
/** See FormShellContextValue.submitAttemptCount. */
|
|
89
|
+
submitAttemptCount: number;
|
|
90
|
+
/**
|
|
91
|
+
* Record a submit attempt. `<Form>` / `<SectionForm>` / `<QuickFormDrawer>`
|
|
92
|
+
* call this for you; call it directly only when your submit control is a
|
|
93
|
+
* `type="button"` with an `onClick`, so no native submit event fires.
|
|
94
|
+
*/
|
|
95
|
+
markSubmitAttempted: () => void;
|
|
73
96
|
}
|
|
74
97
|
/**
|
|
75
98
|
* `applyZodIssues` — pipe every issue from a Zod safeParse failure into the
|
|
@@ -135,5 +158,6 @@ export interface FormShellProviderProps {
|
|
|
135
158
|
* used to be hardcoded `[]`. Supply alongside `fieldToStepIndex`.
|
|
136
159
|
*/
|
|
137
160
|
sections?: FormShellStep[];
|
|
161
|
+
submitAttempted?: boolean;
|
|
138
162
|
}
|
|
139
|
-
export declare function FormShellProvider({ children, onSaveDraft, isDirty, autoSaveDelayMs, values, schema, fieldToStepIndex, onGoToStep, sections, }: FormShellProviderProps): React.JSX.Element;
|
|
163
|
+
export declare function FormShellProvider({ children, onSaveDraft, isDirty, autoSaveDelayMs, values, schema, fieldToStepIndex, onGoToStep, sections, submitAttempted: submitAttemptedProp, }: FormShellProviderProps): React.JSX.Element;
|