@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.
Files changed (38) hide show
  1. package/dist/client.d.ts +5 -0
  2. package/dist/client.js +3 -0
  3. package/dist/features/account/components/AddressForm.js +45 -29
  4. package/dist/features/admin/components/AdminBundleEditorView.js +3 -0
  5. package/dist/features/auctions/components/PlaceBidFormClient.js +3 -1
  6. package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -1
  7. package/dist/features/layout/BottomActions.js +18 -1
  8. package/dist/features/layout/BottomActionsContext.d.ts +19 -0
  9. package/dist/features/layout/BottomActionsContext.js +37 -0
  10. package/dist/features/layout/hooks/useBottomActions.d.ts +10 -0
  11. package/dist/features/layout/hooks/useBottomActions.js +48 -11
  12. package/dist/features/layout/hooks/useFormBottomActions.d.ts +57 -0
  13. package/dist/features/layout/hooks/useFormBottomActions.js +89 -0
  14. package/dist/features/layout/index.d.ts +2 -0
  15. package/dist/features/layout/index.js +1 -0
  16. package/dist/features/products/components/MakeOfferButton.js +5 -3
  17. package/dist/features/shell/QuickFormDrawer.js +15 -1
  18. package/dist/features/shell/SectionForm.d.ts +16 -2
  19. package/dist/features/shell/SectionForm.js +28 -2
  20. package/dist/features/shipments/components/AdminShipmentEditorView.js +3 -1
  21. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +36 -0
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.js +4 -0
  24. package/dist/ui/components/Form.d.ts +22 -1
  25. package/dist/ui/components/Form.js +30 -2
  26. package/dist/ui/components/Modal.js +4 -3
  27. package/dist/ui/components/SideDrawer.js +5 -4
  28. package/dist/ui/components/overlay-context.d.ts +21 -0
  29. package/dist/ui/components/overlay-context.js +25 -0
  30. package/dist/ui/forms/FormErrorList.d.ts +29 -0
  31. package/dist/ui/forms/FormErrorList.js +36 -0
  32. package/dist/ui/forms/FormErrorSummary.d.ts +8 -0
  33. package/dist/ui/forms/FormErrorSummary.js +15 -8
  34. package/dist/ui/forms/FormShell.d.ts +25 -1
  35. package/dist/ui/forms/FormShell.js +39 -4
  36. package/dist/ui/forms/index.d.ts +2 -0
  37. package/dist/ui/forms/index.js +1 -0
  38. 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
- }), [liveErrors, submitting]);
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 { FormShellStep } from "../../ui/forms/FormShell";
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: () => void onSubmit(), isLoading: isLoading, disabled: isLoading, children: submitLabel }) }))] }));
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 declare function Form({ children, spacing, schema, shellCtx: shellCtxProp, align, gap, surface, padding, paddingX, paddingY, rounded, roundedTop, roundedBottom, border, shadow, overflow, className, ...props }: FormProps): React.JSX.Element;
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
- .filter(Boolean)
74
- .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);
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
- UI_SIDE_DRAWER.header,
157
- UI_SIDE_DRAWER.headerMode[mode],
158
- ].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") })] })] })] }))] })) }));
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 { Span } from "../components/Typography";
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
- if (entries.length === 0)
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;