@zeno-lib/forms 0.4.0 → 0.5.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.
@@ -5,6 +5,7 @@ import { deepMergeDefaults, extractZodDefaults } from "./lib/schema-defaults.mjs
5
5
  import { getRequiredPaths } from "./lib/schema-required.mjs";
6
6
  import { DEFAULT_VALIDATION_MODE, setFormZenoState } from "./lib/validation-modes.mjs";
7
7
  import { withEmptyStartValues } from "./lib/empty-value.mjs";
8
+ import { setSubmitError } from "./lib/submit-error.mjs";
8
9
  import { useRebasedDefaultValues } from "./lib/use-rebased-default-values.mjs";
9
10
  import { useUnsavedChangesWarning } from "./lib/use-unsaved-changes-warning.mjs";
10
11
  import { ValidationError } from "./lib/validation-error.mjs";
@@ -77,6 +78,7 @@ function createZenoForm(config) {
77
78
  resolvedValidationLogic = mode === "blur-then-change" ? blurThenChangeLogic : void 0;
78
79
  }
79
80
  const wrappedOnSubmit = userOnSubmit ? (async (props) => {
81
+ const { submissionAttempts } = props.formApi.state;
80
82
  try {
81
83
  await userOnSubmit(props);
82
84
  } catch (error) {
@@ -84,6 +86,8 @@ function createZenoForm(config) {
84
86
  applyValidationError(props.formApi, error);
85
87
  return;
86
88
  }
89
+ const { state } = props.formApi;
90
+ if (state.values === props.value && state.submissionAttempts === submissionAttempts) setSubmitError(props.formApi, error);
87
91
  throw error;
88
92
  }
89
93
  }) : void 0;
@@ -1,5 +1,7 @@
1
1
  "use client";
2
2
  import { FormProvider as FormProvider$1, useFormContext } from "./lib/contexts.mjs";
3
+ import { reportSubmitError } from "./lib/submit-error.mjs";
4
+ import { focusFirstInvalid, restoreFocus } from "./lib/focus-first-invalid.mjs";
3
5
  import { jsx } from "react/jsx-runtime";
4
6
  //#region src/form-element.tsx
5
7
  function FormProvider({ children, form }) {
@@ -17,11 +19,11 @@ function Form({ children, className, ...props }) {
17
19
  event.preventDefault();
18
20
  event.stopPropagation();
19
21
  const node = event.currentTarget;
20
- await Promise.resolve(form.handleSubmit()).catch(() => void 0);
21
- if (!form.state.isValid) {
22
- const invalid = node.querySelector("[aria-invalid=\"true\"]");
23
- (invalid ? [invalid, ...invalid.querySelectorAll("*")] : []).find((el) => el.tabIndex >= 0)?.focus();
24
- }
22
+ const focused = node.ownerDocument.activeElement;
23
+ await Promise.resolve(form.handleSubmit()).catch(reportSubmitError);
24
+ const { isSubmitSuccessful, isValid } = form.state;
25
+ if (!isValid && focusFirstInvalid(node)) return;
26
+ if (!(isValid && isSubmitSuccessful)) restoreFocus(focused);
25
27
  },
26
28
  ...props,
27
29
  children
@@ -0,0 +1,23 @@
1
+ //#region src/lib/focus-first-invalid.d.ts
2
+ /**
3
+ * Move focus to the first invalid control inside `root` (a `<form>`): the
4
+ * first element with `aria-invalid="true"`, or, for a group root that can't
5
+ * take focus itself (radio group, slider), its first tabbable descendant. An
6
+ * invalid control that won't take focus (disabled, hidden) is skipped for the
7
+ * next one. Call it after `await form.handleSubmit()` leaves the form invalid.
8
+ * Searching only `root` keeps focus inside the submitted form, so a dialog's
9
+ * form never sends it to the page behind. Returns the focused element, or
10
+ * `null`.
11
+ */
12
+ declare function focusFirstInvalid(root: ParentNode | null | undefined): HTMLElement | null;
13
+ /**
14
+ * Give focus back to `element`, what had it when the submit started, if the
15
+ * submit dropped it to the body. A submit button that disables itself while
16
+ * submitting does that in browsers, so a failed submit with no invalid field
17
+ * to focus would otherwise leave the user nowhere. It doesn't scroll, so a
18
+ * user who scrolled away during a slow submit stays put. Returns whether it
19
+ * moved focus.
20
+ */
21
+ declare function restoreFocus(element: Element | null): boolean;
22
+ //#endregion
23
+ export { focusFirstInvalid, restoreFocus };
@@ -0,0 +1,37 @@
1
+ //#region src/lib/focus-first-invalid.ts
2
+ /**
3
+ * Move focus to the first invalid control inside `root` (a `<form>`): the
4
+ * first element with `aria-invalid="true"`, or, for a group root that can't
5
+ * take focus itself (radio group, slider), its first tabbable descendant. An
6
+ * invalid control that won't take focus (disabled, hidden) is skipped for the
7
+ * next one. Call it after `await form.handleSubmit()` leaves the form invalid.
8
+ * Searching only `root` keeps focus inside the submitted form, so a dialog's
9
+ * form never sends it to the page behind. Returns the focused element, or
10
+ * `null`.
11
+ */
12
+ function focusFirstInvalid(root) {
13
+ const invalid = root?.querySelectorAll("[aria-invalid=\"true\"]");
14
+ for (const element of invalid ?? []) for (const target of [element, ...element.querySelectorAll("*")]) {
15
+ if (target.tabIndex < 0) continue;
16
+ target.focus();
17
+ if (target.ownerDocument.activeElement === target) return target;
18
+ }
19
+ return null;
20
+ }
21
+ /**
22
+ * Give focus back to `element`, what had it when the submit started, if the
23
+ * submit dropped it to the body. A submit button that disables itself while
24
+ * submitting does that in browsers, so a failed submit with no invalid field
25
+ * to focus would otherwise leave the user nowhere. It doesn't scroll, so a
26
+ * user who scrolled away during a slow submit stays put. Returns whether it
27
+ * moved focus.
28
+ */
29
+ function restoreFocus(element) {
30
+ if (!(element instanceof HTMLElement && element.isConnected)) return false;
31
+ const active = element.ownerDocument.activeElement;
32
+ if (active !== null && active !== element.ownerDocument.body) return false;
33
+ element.focus({ preventScroll: true });
34
+ return element.ownerDocument.activeElement === element;
35
+ }
36
+ //#endregion
37
+ export { focusFirstInvalid, restoreFocus };
@@ -0,0 +1,36 @@
1
+ //#region src/lib/submit-error.d.ts
2
+ type SubmitErrorState = {
3
+ readonly submissionAttempts: number;
4
+ readonly values: unknown;
5
+ };
6
+ type SubmitErrorForm = {
7
+ readonly state: SubmitErrorState;
8
+ readonly store: {
9
+ subscribe: (listener: () => void) => {
10
+ unsubscribe: () => void;
11
+ };
12
+ };
13
+ };
14
+ type SubmitError = {
15
+ /** What the submit handler threw. */
16
+ readonly error: unknown;
17
+ };
18
+ /**
19
+ * Record that `form`'s submit threw `error`. `useForm` calls it for what
20
+ * `onSubmit` throws; call it for a failure your own submit code catches
21
+ * outside `onSubmit`. The error lasts until the values change or the next
22
+ * submit starts (`reset()` does both), so an edit or a retry clears it. A
23
+ * Next.js navigation (`redirect()`, `notFound()`) is ignored.
24
+ */
25
+ declare function setSubmitError(form: SubmitErrorForm, error: unknown): void;
26
+ /**
27
+ * Report a submit rejection that is caught so the user can retry, as
28
+ * `<Form>` and `FormDialog` do: `reportError` logs it and passes it to the
29
+ * page's `error` listeners (where error monitoring hooks in), as if it were
30
+ * uncaught. A Next.js navigation is skipped.
31
+ */
32
+ declare function reportSubmitError(error: unknown): void;
33
+ /** The error `form`'s last submit threw, until it went stale. */
34
+ declare function useSubmitError(form: SubmitErrorForm): SubmitError | undefined;
35
+ //#endregion
36
+ export { type SubmitError, type SubmitErrorForm, reportSubmitError, setSubmitError, useSubmitError };
@@ -0,0 +1,66 @@
1
+ "use client";
2
+ import { useCallback, useSyncExternalStore } from "react";
3
+ //#region src/lib/submit-error.ts
4
+ const entries = /* @__PURE__ */ new WeakMap();
5
+ const listeners = /* @__PURE__ */ new WeakMap();
6
+ function notify(key) {
7
+ for (const listener of listeners.get(key) ?? []) listener();
8
+ }
9
+ function clear(key) {
10
+ const entry = entries.get(key);
11
+ if (entry) {
12
+ entry.unsubscribe();
13
+ entries.delete(key);
14
+ notify(key);
15
+ }
16
+ }
17
+ function isNavigationError(error) {
18
+ if (typeof error !== "object" || error === null || !("digest" in error)) return false;
19
+ return typeof error.digest === "string" && error.digest.startsWith("NEXT_");
20
+ }
21
+ /**
22
+ * Record that `form`'s submit threw `error`. `useForm` calls it for what
23
+ * `onSubmit` throws; call it for a failure your own submit code catches
24
+ * outside `onSubmit`. The error lasts until the values change or the next
25
+ * submit starts (`reset()` does both), so an edit or a retry clears it. A
26
+ * Next.js navigation (`redirect()`, `notFound()`) is ignored.
27
+ */
28
+ function setSubmitError(form, error) {
29
+ if (isNavigationError(error)) return;
30
+ const key = form.store;
31
+ entries.get(key)?.unsubscribe();
32
+ const { submissionAttempts, values } = form.state;
33
+ const subscription = form.store.subscribe(() => {
34
+ const { state } = form;
35
+ if (state.values !== values || state.submissionAttempts !== submissionAttempts) clear(key);
36
+ });
37
+ entries.set(key, {
38
+ submitError: { error },
39
+ unsubscribe: subscription.unsubscribe
40
+ });
41
+ notify(key);
42
+ }
43
+ /**
44
+ * Report a submit rejection that is caught so the user can retry, as
45
+ * `<Form>` and `FormDialog` do: `reportError` logs it and passes it to the
46
+ * page's `error` listeners (where error monitoring hooks in), as if it were
47
+ * uncaught. A Next.js navigation is skipped.
48
+ */
49
+ function reportSubmitError(error) {
50
+ if (!isNavigationError(error)) globalThis.reportError?.(error);
51
+ }
52
+ /** The error `form`'s last submit threw, until it went stale. */
53
+ function useSubmitError(form) {
54
+ const key = form.store;
55
+ const subscribe = useCallback((listener) => {
56
+ const set = listeners.get(key) ?? /* @__PURE__ */ new Set();
57
+ listeners.set(key, set);
58
+ set.add(listener);
59
+ return () => {
60
+ set.delete(listener);
61
+ };
62
+ }, [key]);
63
+ return useSyncExternalStore(subscribe, () => entries.get(key)?.submitError, () => void 0);
64
+ }
65
+ //#endregion
66
+ export { reportSubmitError, setSubmitError, useSubmitError };
@@ -47,6 +47,11 @@ declare function useFormDialog<TValues>(options?: UseFormDialogOptions<TValues>)
47
47
  type UseLeaveGuardOptions = {
48
48
  /** Whether leaving now would lose changes. */
49
49
  hasUnsavedChanges: boolean;
50
+ /**
51
+ * Save the changes and resolve whether that worked. Backs `saveAndLeave`,
52
+ * so the confirm UI can offer Save next to Discard.
53
+ */
54
+ onSave?: () => boolean | Promise<boolean>;
50
55
  };
51
56
  type LeaveGuard = {
52
57
  /**
@@ -60,9 +65,18 @@ type LeaveGuard = {
60
65
  confirmLeave: () => void;
61
66
  /** The user chose to stay: drop the held leave. */
62
67
  cancelLeave: () => void;
68
+ /**
69
+ * The user chose to save: run `onSave`, then the held leave if it saved.
70
+ * Either way the confirm UI closes; after a failed save the user stays.
71
+ * Resolves whether it saved. A rejection from `onSave` keeps the leave held
72
+ * and propagates.
73
+ */
74
+ saveAndLeave: () => Promise<boolean>;
75
+ /** Whether `saveAndLeave` is waiting on `onSave`. */
76
+ isSaving: boolean;
63
77
  };
64
78
  /** Ask before a leave (close, navigate, switch record) drops unsaved changes. */
65
- declare function useLeaveGuard({ hasUnsavedChanges }: UseLeaveGuardOptions): LeaveGuard;
79
+ declare function useLeaveGuard({ hasUnsavedChanges, onSave }: UseLeaveGuardOptions): LeaveGuard;
66
80
  /**
67
81
  * Find the element to focus for field `name` inside `root`: the control with
68
82
  * `id={name}` (fields that use the name as their id), else, inside the field
@@ -44,9 +44,10 @@ function useFormDialog(options = {}) {
44
44
  };
45
45
  }
46
46
  /** Ask before a leave (close, navigate, switch record) drops unsaved changes. */
47
- function useLeaveGuard({ hasUnsavedChanges }) {
47
+ function useLeaveGuard({ hasUnsavedChanges, onSave }) {
48
48
  const pending = useRef(null);
49
49
  const [isConfirming, setIsConfirming] = useState(false);
50
+ const [isSaving, setIsSaving] = useState(false);
50
51
  const requestLeave = useCallback((proceed) => {
51
52
  if (!hasUnsavedChanges) {
52
53
  proceed();
@@ -68,7 +69,24 @@ function useLeaveGuard({ hasUnsavedChanges }) {
68
69
  }, []),
69
70
  confirmLeave,
70
71
  isConfirming,
71
- requestLeave
72
+ isSaving,
73
+ requestLeave,
74
+ saveAndLeave: useCallback(async () => {
75
+ const proceed = pending.current;
76
+ if (!(proceed && onSave)) return false;
77
+ setIsSaving(true);
78
+ let saved = false;
79
+ try {
80
+ saved = await onSave();
81
+ } finally {
82
+ setIsSaving(false);
83
+ }
84
+ if (pending.current !== proceed) return saved;
85
+ pending.current = null;
86
+ setIsConfirming(false);
87
+ if (saved) proceed();
88
+ return saved;
89
+ }, [onSave])
72
90
  };
73
91
  }
74
92
  const FOCUSABLE = "input:not([type=\"hidden\"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex=\"-1\"])";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeno-lib/forms",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "repository": {
5
5
  "url": "https://github.com/zeno-lib/zeno.git",
6
6
  "directory": "packages/forms"
@@ -8,6 +8,7 @@ import { CheckboxGroupField } from "./fields/checkbox-group-field"
8
8
  import { ComboboxField } from "./fields/combobox-field"
9
9
  import { DatePickerField } from "./fields/date-picker-field"
10
10
  import { EmailField } from "./fields/email-field"
11
+ import { FormError } from "./fields/form-error"
11
12
  import { InputField } from "./fields/input-field"
12
13
  import { MoneyField } from "./fields/money-field"
13
14
  import { MultiSelectField } from "./fields/multi-select-field"
@@ -48,6 +49,7 @@ const { useAppForm, useForm, withFieldGroup, withForm } = createZenoForm({
48
49
  YearField,
49
50
  },
50
51
  formComponents: {
52
+ FormError,
51
53
  ResetButton,
52
54
  SubmitButton,
53
55
  },
@@ -18,6 +18,7 @@ import {
18
18
  getRequiredPaths,
19
19
  type StandardSchemaLike,
20
20
  } from "./lib/schema-required"
21
+ import { setSubmitError } from "./lib/submit-error"
21
22
  import { useRebasedDefaultValues } from "./lib/use-rebased-default-values"
22
23
  import { useUnsavedChangesWarning } from "./lib/use-unsaved-changes-warning"
23
24
  import { ValidationError } from "./lib/validation-error"
@@ -439,6 +440,7 @@ export function createZenoForm<
439
440
 
440
441
  const wrappedOnSubmit = userOnSubmit
441
442
  ? ((async (props: { formApi: AnyFormApi; value: TFormData }) => {
443
+ const { submissionAttempts } = props.formApi.state
442
444
  try {
443
445
  await (
444
446
  userOnSubmit as unknown as (
@@ -450,6 +452,16 @@ export function createZenoForm<
450
452
  applyValidationError(props.formApi, error)
451
453
  return
452
454
  }
455
+ // Kept for `FormError`, unless an edit, a reset (a dialog closed
456
+ // meanwhile) or a newer submit came first: then it is about
457
+ // values no longer on screen.
458
+ const { state } = props.formApi
459
+ if (
460
+ state.values === props.value &&
461
+ state.submissionAttempts === submissionAttempts
462
+ ) {
463
+ setSubmitError(props.formApi, error)
464
+ }
453
465
  throw error
454
466
  }
455
467
  }) as typeof userOnSubmit)
@@ -0,0 +1,61 @@
1
+ "use client"
2
+
3
+ import { useFormContext } from "@zeno-lib/forms/lib/contexts"
4
+ import { useSubmitError } from "@zeno-lib/forms/lib/submit-error"
5
+ import { useSelector } from "@zeno-lib/forms/tanstack"
6
+ import type { ComponentProps, ReactNode } from "react"
7
+ import { FieldError } from "@/components/ui/field"
8
+
9
+ type FormErrorProps = Omit<
10
+ ComponentProps<typeof FieldError>,
11
+ "children" | "errors"
12
+ > & {
13
+ /**
14
+ * Shown when the submit handler throws (a network failure, an unexpected
15
+ * server error). Defaults to `"Something went wrong. Try again."`. A
16
+ * function gets what was thrown.
17
+ */
18
+ submitErrorMessage?: ReactNode | ((error: unknown) => ReactNode)
19
+ }
20
+
21
+ /**
22
+ * The form-level error, as a `role="alert"` message: the `formErrors` an
23
+ * action returns through `submitAction`, a `ValidationError`'s `formError`,
24
+ * or `submitErrorMessage` when the submit throws. Renders nothing otherwise.
25
+ * It clears when the user edits the form, and a thrown one also when they
26
+ * submit again.
27
+ */
28
+ function FormError({
29
+ submitErrorMessage = "Something went wrong. Try again.",
30
+ ...props
31
+ }: FormErrorProps) {
32
+ const form = useFormContext()
33
+ const formError = useSelector(form.store, (state) => state.errorMap.onSubmit)
34
+ const submitError = useSubmitError(form)
35
+
36
+ // `submitAction` joins several messages with a line break. A schema's
37
+ // form-level result is an object, not a message, and isn't shown here. It
38
+ // wins over a thrown submit: it says what went wrong.
39
+ if (typeof formError === "string" && formError !== "") {
40
+ return (
41
+ <FieldError
42
+ data-slot="form-error"
43
+ errors={formError.split("\n").map((message) => ({ message }))}
44
+ {...props}
45
+ />
46
+ )
47
+ }
48
+ if (!submitError) {
49
+ return null
50
+ }
51
+ return (
52
+ <FieldError data-slot="form-error" {...props}>
53
+ {typeof submitErrorMessage === "function"
54
+ ? submitErrorMessage(submitError.error)
55
+ : submitErrorMessage}
56
+ </FieldError>
57
+ )
58
+ }
59
+
60
+ export type { FormErrorProps }
61
+ export { FormError }
@@ -4,6 +4,7 @@ export * from "./checkbox-group-field"
4
4
  export * from "./combobox-field"
5
5
  export * from "./date-picker-field"
6
6
  export * from "./email-field"
7
+ export * from "./form-error"
7
8
  export * from "./input-field"
8
9
  export * from "./money-field"
9
10
  export * from "./multi-select-field"