@zeno-lib/forms 0.4.0 → 0.6.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/create-zeno-form.d.mts +8 -2
- package/dist/create-zeno-form.mjs +4 -0
- package/dist/form-element.mjs +7 -5
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/lib/focus-first-invalid.d.mts +23 -0
- package/dist/lib/focus-first-invalid.mjs +37 -0
- package/dist/lib/submit-action.d.mts +8 -2
- package/dist/lib/submit-action.mjs +4 -2
- package/dist/lib/submit-error.d.mts +36 -0
- package/dist/lib/submit-error.mjs +66 -0
- package/dist/lib/use-field-control.d.mts +76 -0
- package/dist/lib/use-field-control.mjs +67 -0
- package/dist/lib/use-form-dialog.d.mts +15 -1
- package/dist/lib/use-form-dialog.mjs +20 -2
- package/package.json +1 -1
- package/src/control-field.test.tsx +338 -0
- package/src/create-form.tsx +5 -0
- package/src/create-zeno-form.tsx +32 -1
- package/src/fields/control-field.tsx +57 -0
- package/src/fields/field-shell.tsx +73 -0
- package/src/fields/form-error.tsx +61 -0
- package/src/fields/index.ts +3 -0
- package/src/fields/input-field.tsx +14 -48
- package/src/fields/textarea-field.tsx +14 -48
- package/src/form-dialog.test.tsx +493 -2
- package/src/form-dialog.tsx +217 -33
- package/src/form-element.tsx +13 -11
- package/src/form-error.test.tsx +255 -0
- package/src/index.ts +6 -0
- package/src/lib/focus-first-invalid.test.ts +22 -0
- package/src/lib/focus-first-invalid.ts +52 -0
- package/src/lib/submit-action.ts +18 -4
- package/src/lib/submit-error.ts +133 -0
- package/src/lib/use-field-control.ts +181 -0
- package/src/lib/use-form-dialog.ts +49 -1
- package/src/submit-action.test.tsx +103 -0
- package/src/use-app-fields.test-d.ts +51 -1
- package/src/use-form.focus-invalid.test.tsx +34 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { FieldControlProps } from "./lib/use-field-control.mjs";
|
|
1
2
|
import { ValidationMode } from "./lib/validation-modes.mjs";
|
|
2
|
-
import { DeepKeys, FormAsyncValidateOrFn, FormOptions, FormValidateOrFn } from "@tanstack/react-form";
|
|
3
|
+
import { DeepKeys, DeepValue, FormAsyncValidateOrFn, FormOptions, FormValidateOrFn } from "@tanstack/react-form";
|
|
3
4
|
import { ComponentProps, ReactNode } from "react";
|
|
4
5
|
//#region src/create-zeno-form.d.ts
|
|
5
6
|
type StandardSchema<T> = {
|
|
@@ -84,7 +85,12 @@ type WithValidators = {
|
|
|
84
85
|
listeners?: AnyFieldListeners;
|
|
85
86
|
};
|
|
86
87
|
type AnyComponent = React.ComponentType<any>;
|
|
87
|
-
type
|
|
88
|
+
type WithControlValue<P, V> = P extends {
|
|
89
|
+
render: (control: infer C) => ReactNode;
|
|
90
|
+
} ? [C] extends [FieldControlProps<any>] ? Omit<P, "render"> & {
|
|
91
|
+
render: (control: FieldControlProps<V>) => ReactNode;
|
|
92
|
+
} : P : P;
|
|
93
|
+
type FieldWrappers<FC extends Record<string, AnyComponent>, T> = { [K in keyof FC]: <N extends DeepKeys<T>>(props: WithControlValue<ComponentProps<FC[K]>, DeepValue<T, N>> & {
|
|
88
94
|
name: N;
|
|
89
95
|
} & WithValidators) => ReactNode; };
|
|
90
96
|
type CreateZenoFormConfig<FC extends Record<string, AnyComponent>, FMC extends Record<string, AnyComponent>> = {
|
|
@@ -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;
|
package/dist/form-element.mjs
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
package/dist/index.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FieldControl, FieldControlProps, UseFieldControlOptions, useFieldControl } from "./lib/use-field-control.mjs";
|
|
1
2
|
import { ValidationMode } from "./lib/validation-modes.mjs";
|
|
2
3
|
import { createZenoForm } from "./create-zeno-form.mjs";
|
|
3
4
|
import { Form, FormProvider } from "./form-element.mjs";
|
|
@@ -9,4 +10,4 @@ import { SubmitActionOptions, SubmitActionSchema, applyActionError, submitAction
|
|
|
9
10
|
import { useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid } from "./lib/use-is-invalid.mjs";
|
|
10
11
|
import { blurThenChangeLogic } from "./lib/validation-logic.mjs";
|
|
11
12
|
import { useSelector } from "@tanstack/react-form";
|
|
12
|
-
export { type ActionError, type ActionIssue, type ActionResult, type FieldMessage, Form, FormProvider, type SubmitActionOptions, type SubmitActionSchema, ValidationError, type ValidationMode, applyActionError, applyValidationError, blurThenChangeLogic, createZenoForm, submitAction, toActionError, toFieldName, useFieldContext, useFormContext, useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid, useSelector };
|
|
13
|
+
export { type ActionError, type ActionIssue, type ActionResult, type FieldControl, type FieldControlProps, type FieldMessage, Form, FormProvider, type SubmitActionOptions, type SubmitActionSchema, type UseFieldControlOptions, ValidationError, type ValidationMode, applyActionError, applyValidationError, blurThenChangeLogic, createZenoForm, submitAction, toActionError, toFieldName, useFieldContext, useFieldControl, useFormContext, useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid, useSelector };
|
package/dist/index.mjs
CHANGED
|
@@ -7,5 +7,6 @@ import { createZenoForm } from "./create-zeno-form.mjs";
|
|
|
7
7
|
import { Form, FormProvider } from "./form-element.mjs";
|
|
8
8
|
import { applyActionError, submitAction } from "./lib/submit-action.mjs";
|
|
9
9
|
import { useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid } from "./lib/use-is-invalid.mjs";
|
|
10
|
+
import { useFieldControl } from "./lib/use-field-control.mjs";
|
|
10
11
|
import { useSelector } from "@tanstack/react-form";
|
|
11
|
-
export { Form, FormProvider, ValidationError, applyActionError, applyValidationError, blurThenChangeLogic, createZenoForm, submitAction, toActionError, toFieldName, useFieldContext, useFormContext, useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid, useSelector };
|
|
12
|
+
export { Form, FormProvider, ValidationError, applyActionError, applyValidationError, blurThenChangeLogic, createZenoForm, submitAction, toActionError, toFieldName, useFieldContext, useFieldControl, useFormContext, useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid, useSelector };
|
|
@@ -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 };
|
|
@@ -40,7 +40,9 @@ type SubmitActionSchemaOptions<TValues, TOutput, TData> = SubmitActionOptions<TV
|
|
|
40
40
|
/**
|
|
41
41
|
* Parsed on the client before the call: `onSubmit` receives the form's
|
|
42
42
|
* input values, and the action gets the schema's output. A failure is
|
|
43
|
-
* applied to the form and the action is never called.
|
|
43
|
+
* applied to the form and the action is never called. Replaces the form's
|
|
44
|
+
* own `schema` for this call; pass one only when the action takes a
|
|
45
|
+
* different schema than the form.
|
|
44
46
|
*/
|
|
45
47
|
schema: SubmitActionSchema<TOutput>;
|
|
46
48
|
};
|
|
@@ -49,9 +51,13 @@ type SubmitActionSchemaOptions<TValues, TOutput, TData> = SubmitActionOptions<TV
|
|
|
49
51
|
* form. Pass `onSubmit`'s own argument straight through:
|
|
50
52
|
*
|
|
51
53
|
* ```ts
|
|
52
|
-
* onSubmit: (submit) => submitAction(submit, saveProfile, { reset: true
|
|
54
|
+
* onSubmit: (submit) => submitAction(submit, saveProfile, { reset: true })
|
|
53
55
|
* ```
|
|
54
56
|
*
|
|
57
|
+
* The values are parsed with `options.schema`, else with the form's own
|
|
58
|
+
* `useForm({ schema })`, and the action gets the schema's output. A form with
|
|
59
|
+
* no schema (`useForm` without one, `useAppForm`) sends its values as they are.
|
|
60
|
+
*
|
|
55
61
|
* On `{ ok: false }` the field errors land on their fields (array paths like
|
|
56
62
|
* `owners[0].percentage` included) and clear as each field is edited. On
|
|
57
63
|
* `{ ok: true }` the form is optionally reset. Either way the result is
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { applyValidationError } from "./apply-validation-error.mjs";
|
|
2
2
|
import { toActionError } from "./action-result.mjs";
|
|
3
|
+
import { getFormSchema } from "./validation-modes.mjs";
|
|
3
4
|
import { ValidationError } from "./validation-error.mjs";
|
|
4
5
|
//#region src/lib/submit-action.ts
|
|
5
6
|
function applyActionError(formApi, error) {
|
|
@@ -15,9 +16,10 @@ function applyActionError(formApi, error) {
|
|
|
15
16
|
}
|
|
16
17
|
async function submitAction(props, action, options = {}) {
|
|
17
18
|
const { formApi, value } = props;
|
|
19
|
+
const schema = options.schema ?? getFormSchema(formApi);
|
|
18
20
|
let input = value;
|
|
19
|
-
if (
|
|
20
|
-
const parsed = await
|
|
21
|
+
if (schema) {
|
|
22
|
+
const parsed = await schema["~standard"].validate(value);
|
|
21
23
|
if (parsed.issues) {
|
|
22
24
|
const error = toActionError(parsed.issues);
|
|
23
25
|
applyActionError(formApi, error);
|
|
@@ -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 };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { AnyFieldMeta } from "@tanstack/react-form";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
//#region src/lib/use-field-control.d.ts
|
|
4
|
+
type UseFieldControlOptions = {
|
|
5
|
+
/**
|
|
6
|
+
* The description the field renders, if any. The control is described by
|
|
7
|
+
* it, and by the error while one shows.
|
|
8
|
+
*/
|
|
9
|
+
description?: ReactNode;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* The label the field renders, if any. The control gets `aria-labelledby`,
|
|
13
|
+
* which names a control a `<label htmlFor>` can't, such as a
|
|
14
|
+
* `role="radiogroup"`.
|
|
15
|
+
*/
|
|
16
|
+
label?: ReactNode;
|
|
17
|
+
/**
|
|
18
|
+
* Mark the field required, or not, over the schema. Drives the `*` and
|
|
19
|
+
* `aria-required`.
|
|
20
|
+
*/
|
|
21
|
+
required?: boolean;
|
|
22
|
+
};
|
|
23
|
+
/** The props that bind a control to its field. Spread them on the control. */
|
|
24
|
+
type FieldControlProps<TValue = unknown> = {
|
|
25
|
+
"aria-describedby": string | undefined;
|
|
26
|
+
"aria-invalid": true | undefined;
|
|
27
|
+
"aria-labelledby": string | undefined;
|
|
28
|
+
"aria-required": true | undefined;
|
|
29
|
+
disabled: boolean | undefined;
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
/** Marks the field touched. */
|
|
33
|
+
onBlur: () => void;
|
|
34
|
+
/**
|
|
35
|
+
* Stores `value`, not a change event: a control that reports one maps it
|
|
36
|
+
* (`onChange(event.target.value)`). An empty value (`null`, `undefined`,
|
|
37
|
+
* `""`, `NaN` or `[]`) stores the field's empty value instead, which its
|
|
38
|
+
* schema picks. Stable across renders.
|
|
39
|
+
*/
|
|
40
|
+
onChange: (value: TValue) => void;
|
|
41
|
+
/**
|
|
42
|
+
* The value as stored, the field's empty value (`undefined` or `null`)
|
|
43
|
+
* included. A control that needs a defined value maps it
|
|
44
|
+
* (`value={value ?? ""}`), or it turns uncontrolled once the field empties.
|
|
45
|
+
*/
|
|
46
|
+
value: TValue;
|
|
47
|
+
};
|
|
48
|
+
type FieldControl<TValue = unknown> = {
|
|
49
|
+
controlProps: FieldControlProps<TValue>;
|
|
50
|
+
descriptionId: string;
|
|
51
|
+
errorId: string;
|
|
52
|
+
/**
|
|
53
|
+
* The errors to render: none while the field's errors are hidden, by the
|
|
54
|
+
* form's validation mode or `hideFieldErrors`.
|
|
55
|
+
*/
|
|
56
|
+
errors: AnyFieldMeta["errors"];
|
|
57
|
+
/** The control's id, for the label's `htmlFor`. */
|
|
58
|
+
id: string;
|
|
59
|
+
isInvalid: boolean;
|
|
60
|
+
/** The field requires a value, by the schema or `required`. */
|
|
61
|
+
isRequired: boolean;
|
|
62
|
+
/** The label's id, when `label` is passed. */
|
|
63
|
+
labelId: string | undefined;
|
|
64
|
+
name: string;
|
|
65
|
+
/** Show the `*`: as `isRequired`, but off under `requiredIndicator: false`. */
|
|
66
|
+
showRequiredIndicator: boolean;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Everything a control needs to act as a field of the form, inside a field
|
|
70
|
+
* component the form renders (`form.AppField`, or one registered with
|
|
71
|
+
* `createZenoForm`): the props to spread on the control, and the ids and
|
|
72
|
+
* state its label, description and error need.
|
|
73
|
+
*/
|
|
74
|
+
declare function useFieldControl<TValue = unknown>({ description, disabled, label, required }?: UseFieldControlOptions): FieldControl<TValue>;
|
|
75
|
+
//#endregion
|
|
76
|
+
export { type FieldControl, type FieldControlProps, type UseFieldControlOptions, useFieldControl };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useFieldContext } from "./contexts.mjs";
|
|
3
|
+
import { getFormSchema } from "./validation-modes.mjs";
|
|
4
|
+
import { emptyValueFor } from "./empty-value.mjs";
|
|
5
|
+
import { describedBy } from "./aria.mjs";
|
|
6
|
+
import { useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid } from "./use-is-invalid.mjs";
|
|
7
|
+
import { useCallback, useId } from "react";
|
|
8
|
+
//#region src/lib/use-field-control.ts
|
|
9
|
+
function isEmpty(value) {
|
|
10
|
+
return value === null || value === void 0 || value === "" || Number.isNaN(value) || Array.isArray(value) && value.length === 0;
|
|
11
|
+
}
|
|
12
|
+
function isSyntheticEvent(value) {
|
|
13
|
+
return typeof value === "object" && value !== null && "nativeEvent" in value && "target" in value;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Everything a control needs to act as a field of the form, inside a field
|
|
17
|
+
* component the form renders (`form.AppField`, or one registered with
|
|
18
|
+
* `createZenoForm`): the props to spread on the control, and the ids and
|
|
19
|
+
* state its label, description and error need.
|
|
20
|
+
*/
|
|
21
|
+
function useFieldControl({ description, disabled, label, required } = {}) {
|
|
22
|
+
const field = useFieldContext();
|
|
23
|
+
const id = useId();
|
|
24
|
+
const labelId = label ? `${id}-label` : void 0;
|
|
25
|
+
const descriptionId = `${id}-description`;
|
|
26
|
+
const errorId = `${id}-error`;
|
|
27
|
+
const isInvalid = useIsInvalid(field);
|
|
28
|
+
const hideErrors = useHideFieldErrors(field);
|
|
29
|
+
const showError = isInvalid && !hideErrors;
|
|
30
|
+
const schemaRequired = useIsFieldRequired(field);
|
|
31
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field);
|
|
32
|
+
const isRequired = required ?? schemaRequiresValue;
|
|
33
|
+
const { form, handleBlur, handleChange, name } = field;
|
|
34
|
+
const onChange = useCallback((value) => {
|
|
35
|
+
if (isSyntheticEvent(value)) throw new TypeError(`Field "${name}" got a change event, not a value. Map it in the control: onChange(event.target.value).`);
|
|
36
|
+
handleChange(isEmpty(value) ? emptyValueFor(getFormSchema(form), form.state.values, name, value) : value);
|
|
37
|
+
}, [
|
|
38
|
+
form,
|
|
39
|
+
handleChange,
|
|
40
|
+
name
|
|
41
|
+
]);
|
|
42
|
+
return {
|
|
43
|
+
controlProps: {
|
|
44
|
+
"aria-describedby": describedBy([description, descriptionId], [showError, errorId]),
|
|
45
|
+
"aria-invalid": isInvalid || void 0,
|
|
46
|
+
"aria-labelledby": labelId,
|
|
47
|
+
"aria-required": isRequired || void 0,
|
|
48
|
+
disabled,
|
|
49
|
+
id,
|
|
50
|
+
name,
|
|
51
|
+
onBlur: handleBlur,
|
|
52
|
+
onChange,
|
|
53
|
+
value: field.state.value
|
|
54
|
+
},
|
|
55
|
+
descriptionId,
|
|
56
|
+
errorId,
|
|
57
|
+
errors: showError ? field.state.meta.errors : [],
|
|
58
|
+
id,
|
|
59
|
+
isInvalid,
|
|
60
|
+
isRequired,
|
|
61
|
+
labelId,
|
|
62
|
+
name,
|
|
63
|
+
showRequiredIndicator: required ?? schemaRequired
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
export { useFieldControl };
|
|
@@ -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
|
-
|
|
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\"])";
|