@zeno-lib/forms 0.2.1 → 0.3.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 +2 -3
- package/dist/form-element.d.mts +2 -2
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/lib/aria.d.mts +2 -3
- package/dist/lib/contexts.d.mts +2 -2
- package/dist/lib/formatted-number.mjs +5 -5
- package/dist/lib/schema-defaults.mjs +1 -1
- package/dist/lib/schema-required.mjs +34 -13
- package/dist/lib/use-form-dialog.d.mts +3 -2
- package/dist/lib/use-form-dialog.mjs +7 -3
- package/package.json +26 -23
- package/src/fields/checkbox-field.tsx +6 -5
- package/src/fields/checkbox-group-field.tsx +8 -7
- package/src/fields/combobox-field.tsx +8 -7
- package/src/fields/date-picker-field.tsx +7 -6
- package/src/fields/input-field.tsx +6 -5
- package/src/fields/multi-select-field.tsx +6 -5
- package/src/fields/number-field.tsx +1 -1
- package/src/fields/otp-field.tsx +6 -5
- package/src/fields/radio-group-field.tsx +3 -2
- package/src/fields/select-field.tsx +6 -5
- package/src/fields/slider-field.tsx +13 -10
- package/src/fields/switch-field.tsx +6 -5
- package/src/fields/textarea-field.tsx +6 -5
- package/src/form-dialog.tsx +2 -2
- package/src/index.ts +1 -0
- package/src/lib/formatted-number.ts +5 -5
- package/src/lib/schema-defaults.ts +1 -1
- package/src/lib/schema-required.test.ts +22 -2
- package/src/lib/schema-required.ts +63 -19
- package/src/lib/use-form-dialog.ts +11 -3
- package/src/lib/validation-logic.test.ts +0 -1
- package/src/slider-field.test.tsx +30 -0
- package/src/use-app-fields.test.tsx +29 -2
|
@@ -99,7 +99,7 @@ type CreateZenoFormConfig<FC extends Record<string, AnyComponent>, FMC extends R
|
|
|
99
99
|
* the `createFormHook` primitives plus the schema-aware `useForm` and the typed
|
|
100
100
|
* `useAppFields` wrappers (prop types are inferred from the injected components).
|
|
101
101
|
*/
|
|
102
|
-
declare function createZenoForm<FC extends Record<string, AnyComponent>, FMC extends Record<string, AnyComponent>>(config: CreateZenoFormConfig<FC, FMC>): {
|
|
102
|
+
export declare function createZenoForm<FC extends Record<string, AnyComponent>, FMC extends Record<string, AnyComponent>>(config: CreateZenoFormConfig<FC, FMC>): {
|
|
103
103
|
useAppFields: <TForm extends AnyAppForm>(form: TForm) => FieldWrappers<FC, FormDataOf<TForm>>;
|
|
104
104
|
useAppForm: <TFormData, TOnMount extends undefined | FormValidateOrFn<TFormData>, TOnChange extends undefined | FormValidateOrFn<TFormData>, TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnBlur extends undefined | FormValidateOrFn<TFormData>, TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnSubmit extends undefined | FormValidateOrFn<TFormData>, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnDynamic extends undefined | FormValidateOrFn<TFormData>, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>, TSubmitMeta>(props: FormOptions<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta>) => import("@tanstack/react-form").AppFieldExtendedReactFormApi<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta, FC, FMC>;
|
|
105
105
|
useForm: <TFormData, TOnMount extends undefined | FormValidateOrFn<TFormData> = undefined, TOnChange extends undefined | FormValidateOrFn<TFormData> = undefined, TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData> = undefined, TOnBlur extends undefined | FormValidateOrFn<TFormData> = undefined, TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData> = undefined, TOnSubmit extends undefined | FormValidateOrFn<TFormData> = undefined, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData> = undefined, TOnDynamic extends undefined | FormValidateOrFn<TFormData> = undefined, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData> = undefined, TOnServer extends undefined | FormAsyncValidateOrFn<TFormData> = undefined, TSubmitMeta = never>(options: UseFormOptions<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta>) => import("@tanstack/react-form").FormApi<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta> & import("@tanstack/react-form").ReactFormApi<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta> & NoInfer<FMC> & {
|
|
@@ -114,5 +114,4 @@ declare function createZenoForm<FC extends Record<string, AnyComponent>, FMC ext
|
|
|
114
114
|
form: import("@tanstack/react-form").AppFieldExtendedReactFormApi<[unknown] extends [TFormData] ? any : TFormData, [FormValidateOrFn<TFormData> | undefined] extends [TOnMount] ? [TOnMount] extends [TOnMount & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnMount : TOnMount, [FormValidateOrFn<TFormData> | undefined] extends [TOnChange] ? [TOnChange] extends [TOnChange & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnChange : TOnChange, [FormValidateOrFn<TFormData> | undefined] extends [TOnChangeAsync] ? [TOnChangeAsync] extends [TOnChangeAsync & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnChangeAsync : TOnChangeAsync, [FormValidateOrFn<TFormData> | undefined] extends [TOnBlur] ? [TOnBlur] extends [TOnBlur & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnBlur : TOnBlur, [FormValidateOrFn<TFormData> | undefined] extends [TOnBlurAsync] ? [TOnBlurAsync] extends [TOnBlurAsync & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnBlurAsync : TOnBlurAsync, [FormValidateOrFn<TFormData> | undefined] extends [TOnSubmit] ? [TOnSubmit] extends [TOnSubmit & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnSubmit : TOnSubmit, [FormValidateOrFn<TFormData> | undefined] extends [TOnSubmitAsync] ? [TOnSubmitAsync] extends [TOnSubmitAsync & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnSubmitAsync : TOnSubmitAsync, [FormValidateOrFn<TFormData> | undefined] extends [TOnDynamic] ? [TOnDynamic] extends [TOnDynamic & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnDynamic : TOnDynamic, [FormValidateOrFn<TFormData> | undefined] extends [TOnDynamicAsync] ? [TOnDynamicAsync] extends [TOnDynamicAsync & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnDynamicAsync : TOnDynamicAsync, [FormValidateOrFn<TFormData> | undefined] extends [TOnServer] ? [TOnServer] extends [TOnServer & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnServer : TOnServer, [unknown] extends [TSubmitMeta] ? any : TSubmitMeta, [unknown] extends [FC] ? any : FC, [unknown] extends [FMC] ? any : FMC>;
|
|
115
115
|
}>>;
|
|
116
116
|
};
|
|
117
|
-
//#endregion
|
|
118
|
-
export { createZenoForm };
|
|
117
|
+
//#endregion
|
package/dist/form-element.d.mts
CHANGED
|
@@ -6,8 +6,8 @@ type FormProviderProps = {
|
|
|
6
6
|
handleSubmit: () => unknown;
|
|
7
7
|
};
|
|
8
8
|
};
|
|
9
|
-
declare function FormProvider({ children, form }: FormProviderProps): import("react
|
|
9
|
+
declare function FormProvider({ children, form }: FormProviderProps): import("react").JSX.Element;
|
|
10
10
|
type FormProps = Omit<ComponentProps<"form">, "onSubmit">;
|
|
11
|
-
declare function Form({ children, className, ...props }: FormProps): import("react
|
|
11
|
+
declare function Form({ children, className, ...props }: FormProps): import("react").JSX.Element;
|
|
12
12
|
//#endregion
|
|
13
13
|
export { Form, FormProvider };
|
package/dist/index.d.mts
CHANGED
|
@@ -8,4 +8,5 @@ import { useFieldContext, useFormContext } from "./lib/contexts.mjs";
|
|
|
8
8
|
import { SubmitActionOptions, SubmitActionSchema, applyActionError, submitAction } from "./lib/submit-action.mjs";
|
|
9
9
|
import { useHideFieldErrors, useIsFieldRequired, useIsInvalid } from "./lib/use-is-invalid.mjs";
|
|
10
10
|
import { blurThenChangeLogic } from "./lib/validation-logic.mjs";
|
|
11
|
-
|
|
11
|
+
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, useIsInvalid, useSelector };
|
package/dist/index.mjs
CHANGED
|
@@ -7,4 +7,5 @@ import { Form, FormProvider } from "./form-element.mjs";
|
|
|
7
7
|
import { toActionError, toFieldName } from "./lib/action-result.mjs";
|
|
8
8
|
import { applyActionError, submitAction } from "./lib/submit-action.mjs";
|
|
9
9
|
import { useHideFieldErrors, useIsFieldRequired, useIsInvalid } from "./lib/use-is-invalid.mjs";
|
|
10
|
-
|
|
10
|
+
import { useSelector } from "@tanstack/react-form";
|
|
11
|
+
export { Form, FormProvider, ValidationError, applyActionError, applyValidationError, blurThenChangeLogic, createZenoForm, submitAction, toActionError, toFieldName, useFieldContext, useFormContext, useHideFieldErrors, useIsFieldRequired, useIsInvalid, useSelector };
|
package/dist/lib/aria.d.mts
CHANGED
package/dist/lib/contexts.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//#region src/lib/contexts.d.ts
|
|
2
2
|
declare const fieldContext: import("react").Context<import("@tanstack/react-form").AnyFieldApi>, formContext: import("react").Context<import("@tanstack/react-form").AnyFormApi>, useFieldContext: <TData>() => import("@tanstack/react-form").FieldApi<any, string, TData, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any>, useFormContext: () => import("@tanstack/react-form").ReactFormExtendedApi<Record<string, never>, any, any, any, any, any, any, any, any, any, any, any>;
|
|
3
|
-
declare const FormProvider: import("react").Provider<import("@tanstack/react-form").AnyFormApi>;
|
|
3
|
+
export declare const FormProvider: import("react").Provider<import("@tanstack/react-form").AnyFormApi>;
|
|
4
4
|
//#endregion
|
|
5
|
-
export {
|
|
5
|
+
export { fieldContext, formContext, useFieldContext, useFormContext };
|
|
@@ -26,7 +26,7 @@ function normalizeMinus(text) {
|
|
|
26
26
|
}
|
|
27
27
|
/** Format a number for display (on blur, on reset, on first render). */
|
|
28
28
|
function formatNumber(value, { locale, maximumFractionDigits, minimumFractionDigits = 0, useGrouping = true }) {
|
|
29
|
-
if (value
|
|
29
|
+
if (value === null || value === void 0 || !Number.isFinite(value)) return "";
|
|
30
30
|
return normalizeMinus(new Intl.NumberFormat(locale, {
|
|
31
31
|
maximumFractionDigits,
|
|
32
32
|
minimumFractionDigits: Math.min(minimumFractionDigits, maximumFractionDigits),
|
|
@@ -102,19 +102,19 @@ function parseLocaleNumber(text, locale) {
|
|
|
102
102
|
}
|
|
103
103
|
function countSignificant(text, end, decimal) {
|
|
104
104
|
let count = 0;
|
|
105
|
-
for (let i = 0; i < end && i < text.length; i
|
|
105
|
+
for (let i = 0; i < end && i < text.length; i += 1) {
|
|
106
106
|
const char = text[i];
|
|
107
|
-
if (DIGIT.test(char) || char === decimal || MINUS_SIGNS.test(char)) count
|
|
107
|
+
if (DIGIT.test(char) || char === decimal || MINUS_SIGNS.test(char)) count += 1;
|
|
108
108
|
}
|
|
109
109
|
return count;
|
|
110
110
|
}
|
|
111
111
|
function caretForSignificant(text, significant, decimal) {
|
|
112
112
|
if (significant <= 0) return 0;
|
|
113
113
|
let count = 0;
|
|
114
|
-
for (let i = 0; i < text.length; i
|
|
114
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
115
115
|
const char = text[i];
|
|
116
116
|
if (DIGIT.test(char) || char === decimal || char === "-") {
|
|
117
|
-
count
|
|
117
|
+
count += 1;
|
|
118
118
|
if (count === significant) return i + 1;
|
|
119
119
|
}
|
|
120
120
|
}
|
|
@@ -5,7 +5,7 @@ function getDef(node) {
|
|
|
5
5
|
}
|
|
6
6
|
function extractDefaultForField(node) {
|
|
7
7
|
let current = node;
|
|
8
|
-
for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth
|
|
8
|
+
for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) {
|
|
9
9
|
const def = getDef(current);
|
|
10
10
|
if (!def?.type) return { ok: false };
|
|
11
11
|
switch (def.type) {
|
|
@@ -22,11 +22,14 @@ function joinPath(keys) {
|
|
|
22
22
|
function toRequiredPathKey(name) {
|
|
23
23
|
return name.replace(INDEX_IN_NAME, "[0]");
|
|
24
24
|
}
|
|
25
|
-
function
|
|
25
|
+
function probeValue(expected) {
|
|
26
26
|
if (typeof expected !== "string") return;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
switch (expected.toLowerCase()) {
|
|
28
|
+
case "object": return {};
|
|
29
|
+
case "array": return [{}];
|
|
30
|
+
case "string": return "";
|
|
31
|
+
default: return;
|
|
32
|
+
}
|
|
30
33
|
}
|
|
31
34
|
function fillAt(root, keys, value) {
|
|
32
35
|
let node = root;
|
|
@@ -50,20 +53,38 @@ function validateSync(schema, value) {
|
|
|
50
53
|
if (result instanceof Promise) return;
|
|
51
54
|
return "issues" in result && result.issues ? result.issues : [];
|
|
52
55
|
}
|
|
56
|
+
function recordPass(issues, probe, required) {
|
|
57
|
+
const failing = /* @__PURE__ */ new Set();
|
|
58
|
+
const filledStrings = [];
|
|
59
|
+
let descended = false;
|
|
60
|
+
for (const issue of issues) {
|
|
61
|
+
const keys = issue.path?.map(pathKey) ?? [];
|
|
62
|
+
if (keys.length === 0) continue;
|
|
63
|
+
const path = joinPath(keys);
|
|
64
|
+
failing.add(path);
|
|
65
|
+
required.add(path);
|
|
66
|
+
const value = probeValue(issue.expected);
|
|
67
|
+
if (value !== void 0 && fillAt(probe, keys, value)) {
|
|
68
|
+
descended = true;
|
|
69
|
+
if (value === "") filledStrings.push(path);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
descended,
|
|
74
|
+
failing,
|
|
75
|
+
filledStrings
|
|
76
|
+
};
|
|
77
|
+
}
|
|
53
78
|
function getRequiredPaths(schema) {
|
|
54
79
|
const required = /* @__PURE__ */ new Set();
|
|
55
80
|
const probe = {};
|
|
56
|
-
|
|
81
|
+
let filledStrings = [];
|
|
82
|
+
for (let depth = 0; depth < MAX_PROBE_DEPTH; depth += 1) {
|
|
57
83
|
const issues = validateSync(schema, probe);
|
|
58
84
|
if (!issues) return required;
|
|
59
|
-
|
|
60
|
-
for (const
|
|
61
|
-
|
|
62
|
-
if (keys.length === 0) continue;
|
|
63
|
-
required.add(joinPath(keys));
|
|
64
|
-
const container = probeContainer(issue.expected);
|
|
65
|
-
if (container !== void 0 && fillAt(probe, keys, container)) descended = true;
|
|
66
|
-
}
|
|
85
|
+
const { descended, failing, filledStrings: filled } = recordPass(issues, probe, required);
|
|
86
|
+
for (const path of filledStrings) if (!failing.has(path)) required.delete(path);
|
|
87
|
+
filledStrings = filled;
|
|
67
88
|
if (!descended) break;
|
|
68
89
|
}
|
|
69
90
|
return required;
|
|
@@ -65,8 +65,9 @@ type LeaveGuard = {
|
|
|
65
65
|
declare function useLeaveGuard({ hasUnsavedChanges }: UseLeaveGuardOptions): LeaveGuard;
|
|
66
66
|
/**
|
|
67
67
|
* Find the element to focus for field `name` inside `root`: the control with
|
|
68
|
-
* `id={name}` (
|
|
69
|
-
*
|
|
68
|
+
* `id={name}` (fields that use the name as their id), else, inside the field
|
|
69
|
+
* root marked `data-field={name}`, the control its label points at (the shipped
|
|
70
|
+
* fields' ids come from `useId`), else that root's first focusable element.
|
|
70
71
|
*/
|
|
71
72
|
declare function findFieldElement(root: ParentNode | null | undefined, name: string): HTMLElement | null;
|
|
72
73
|
//#endregion
|
|
@@ -77,14 +77,18 @@ function attributeSelector(attribute, value) {
|
|
|
77
77
|
}
|
|
78
78
|
/**
|
|
79
79
|
* Find the element to focus for field `name` inside `root`: the control with
|
|
80
|
-
* `id={name}` (
|
|
81
|
-
*
|
|
80
|
+
* `id={name}` (fields that use the name as their id), else, inside the field
|
|
81
|
+
* root marked `data-field={name}`, the control its label points at (the shipped
|
|
82
|
+
* fields' ids come from `useId`), else that root's first focusable element.
|
|
82
83
|
*/
|
|
83
84
|
function findFieldElement(root, name) {
|
|
84
85
|
if (!root) return null;
|
|
85
86
|
const byId = root.querySelector(attributeSelector("id", name));
|
|
86
87
|
if (byId) return byId;
|
|
87
|
-
|
|
88
|
+
const fieldRoot = root.querySelector(attributeSelector("data-field", name));
|
|
89
|
+
if (!fieldRoot) return null;
|
|
90
|
+
const labelFor = fieldRoot.querySelector("label[for]")?.getAttribute("for");
|
|
91
|
+
return (labelFor ? fieldRoot.querySelector(attributeSelector("id", labelFor)) : null) ?? fieldRoot.querySelector(FOCUSABLE);
|
|
88
92
|
}
|
|
89
93
|
//#endregion
|
|
90
94
|
export { findFieldElement, useFormDialog, useLeaveGuard };
|
package/package.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeno-lib/forms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"url": "https://github.com/zeno-lib/zeno.git",
|
|
6
|
+
"directory": "packages/forms"
|
|
7
|
+
},
|
|
4
8
|
"type": "module",
|
|
5
9
|
"exports": {
|
|
6
10
|
".": {
|
|
@@ -25,12 +29,22 @@
|
|
|
25
29
|
"dist",
|
|
26
30
|
"src"
|
|
27
31
|
],
|
|
28
|
-
"
|
|
29
|
-
"
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@tanstack/react-form": "1.33.5"
|
|
30
34
|
},
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "26.6.4",
|
|
37
|
+
"@types/react": "19.3.0",
|
|
38
|
+
"@types/react-dom": "19.3.0",
|
|
39
|
+
"@zeno-lib/test": "^0.1.1",
|
|
40
|
+
"@zeno-lib/typescript": "^1.1.0",
|
|
41
|
+
"@zeno-lib/ui": "^0.0.1",
|
|
42
|
+
"lucide-react": "^1.52.0",
|
|
43
|
+
"tsdown": "^0.23.0",
|
|
44
|
+
"typescript": "7.0.2",
|
|
45
|
+
"vite": "8.3.3",
|
|
46
|
+
"vitest": "5.0.3",
|
|
47
|
+
"zod": "^4.6.5"
|
|
34
48
|
},
|
|
35
49
|
"peerDependencies": {
|
|
36
50
|
"@tanstack/react-devtools": ">=0.10",
|
|
@@ -46,28 +60,17 @@
|
|
|
46
60
|
},
|
|
47
61
|
"@tanstack/react-form-devtools": {
|
|
48
62
|
"optional": true
|
|
63
|
+
},
|
|
64
|
+
"zod": {
|
|
65
|
+
"optional": true
|
|
49
66
|
}
|
|
50
67
|
},
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"@tanstack/react-form-nextjs": "1.33.5"
|
|
54
|
-
},
|
|
55
|
-
"devDependencies": {
|
|
56
|
-
"@types/node": "25.6.0",
|
|
57
|
-
"@types/react": "19.2.14",
|
|
58
|
-
"@types/react-dom": "19.2.3",
|
|
59
|
-
"lucide-react": "^1.23.0",
|
|
60
|
-
"tsdown": "^0.22.14",
|
|
61
|
-
"typescript": "7.0.2",
|
|
62
|
-
"vite": "8.0.16",
|
|
63
|
-
"vitest": "5.0.2",
|
|
64
|
-
"zod": "^4.3.6",
|
|
65
|
-
"@zeno-lib/test": "^0.1.0",
|
|
66
|
-
"@zeno-lib/typescript": "^1.1.0",
|
|
67
|
-
"@zeno-lib/ui": "^0.0.1"
|
|
68
|
+
"publishConfig": {
|
|
69
|
+
"access": "public"
|
|
68
70
|
},
|
|
69
71
|
"scripts": {
|
|
70
72
|
"build": "tsdown",
|
|
73
|
+
"dev": "tsdown --watch --no-clean",
|
|
71
74
|
"test": "vitest --run",
|
|
72
75
|
"test:watch": "vitest watch",
|
|
73
76
|
"types:check": "tsc --noEmit"
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
useIsFieldRequired,
|
|
8
8
|
useIsInvalid,
|
|
9
9
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import type
|
|
10
|
+
import { type ComponentProps, type ReactNode, useId } from "react"
|
|
11
11
|
import { Checkbox } from "@/components/ui/checkbox"
|
|
12
12
|
import {
|
|
13
13
|
Field,
|
|
@@ -35,8 +35,9 @@ function CheckboxField({
|
|
|
35
35
|
...props
|
|
36
36
|
}: CheckboxFieldProps) {
|
|
37
37
|
const field = useFieldContext<boolean>()
|
|
38
|
-
const
|
|
39
|
-
const
|
|
38
|
+
const id = useId()
|
|
39
|
+
const errorId = `${id}-error`
|
|
40
|
+
const descriptionId = `${id}-description`
|
|
40
41
|
const isInvalid = useIsInvalid(field)
|
|
41
42
|
const hideErrors = useHideFieldErrors(field)
|
|
42
43
|
const showError = isInvalid && !hideErrors
|
|
@@ -56,7 +57,7 @@ function CheckboxField({
|
|
|
56
57
|
)}
|
|
57
58
|
aria-invalid={isInvalid || undefined}
|
|
58
59
|
checked={field.state.value ?? false}
|
|
59
|
-
id={
|
|
60
|
+
id={id}
|
|
60
61
|
name={field.name}
|
|
61
62
|
onBlur={field.handleBlur}
|
|
62
63
|
onCheckedChange={(value) => field.handleChange(value === true)}
|
|
@@ -64,7 +65,7 @@ function CheckboxField({
|
|
|
64
65
|
/>
|
|
65
66
|
<FieldContent>
|
|
66
67
|
{label && (
|
|
67
|
-
<FieldLabel className="font-normal" htmlFor={
|
|
68
|
+
<FieldLabel className="font-normal" htmlFor={id}>
|
|
68
69
|
{label}
|
|
69
70
|
{isRequired && <RequiredIndicator />}
|
|
70
71
|
</FieldLabel>
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
useIsFieldRequired,
|
|
8
8
|
useIsInvalid,
|
|
9
9
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import type
|
|
10
|
+
import { type ReactNode, useId } from "react"
|
|
11
11
|
import { Checkbox } from "@/components/ui/checkbox"
|
|
12
12
|
import {
|
|
13
13
|
Field,
|
|
@@ -64,8 +64,9 @@ function CheckboxGroupField<V = string>({
|
|
|
64
64
|
required,
|
|
65
65
|
}: CheckboxGroupFieldProps<V>) {
|
|
66
66
|
const field = useFieldContext<V[] | null | undefined>()
|
|
67
|
-
const
|
|
68
|
-
const
|
|
67
|
+
const id = useId()
|
|
68
|
+
const errorId = `${id}-error`
|
|
69
|
+
const descriptionId = `${id}-description`
|
|
69
70
|
const isInvalid = useIsInvalid(field)
|
|
70
71
|
const hideErrors = useHideFieldErrors(field)
|
|
71
72
|
const showError = isInvalid && !hideErrors
|
|
@@ -116,7 +117,7 @@ function CheckboxGroupField<V = string>({
|
|
|
116
117
|
data-slot="checkbox-group"
|
|
117
118
|
>
|
|
118
119
|
{options.map((option, index) => {
|
|
119
|
-
const
|
|
120
|
+
const optionId = `${id}-${index}`
|
|
120
121
|
return (
|
|
121
122
|
<Field
|
|
122
123
|
data-invalid={isInvalid}
|
|
@@ -127,7 +128,7 @@ function CheckboxGroupField<V = string>({
|
|
|
127
128
|
aria-invalid={isInvalid || undefined}
|
|
128
129
|
checked={checked.includes(option.value)}
|
|
129
130
|
disabled={disabled || option.disabled}
|
|
130
|
-
id={
|
|
131
|
+
id={optionId}
|
|
131
132
|
name={field.name}
|
|
132
133
|
onBlur={field.handleBlur}
|
|
133
134
|
onCheckedChange={(on) => toggle(option.value, on === true)}
|
|
@@ -135,13 +136,13 @@ function CheckboxGroupField<V = string>({
|
|
|
135
136
|
/>
|
|
136
137
|
{option.description ? (
|
|
137
138
|
<FieldContent>
|
|
138
|
-
<FieldLabel className="font-normal" htmlFor={
|
|
139
|
+
<FieldLabel className="font-normal" htmlFor={optionId}>
|
|
139
140
|
{option.label}
|
|
140
141
|
</FieldLabel>
|
|
141
142
|
<FieldDescription>{option.description}</FieldDescription>
|
|
142
143
|
</FieldContent>
|
|
143
144
|
) : (
|
|
144
|
-
<FieldLabel className="w-auto font-normal" htmlFor={
|
|
145
|
+
<FieldLabel className="w-auto font-normal" htmlFor={optionId}>
|
|
145
146
|
{option.label}
|
|
146
147
|
</FieldLabel>
|
|
147
148
|
)}
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
useIsFieldRequired,
|
|
8
8
|
useIsInvalid,
|
|
9
9
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import type
|
|
10
|
+
import { type ReactNode, useId } from "react"
|
|
11
11
|
import {
|
|
12
12
|
Combobox,
|
|
13
13
|
ComboboxContent,
|
|
@@ -95,8 +95,9 @@ function ComboboxField<T = string>({
|
|
|
95
95
|
showClear = true,
|
|
96
96
|
}: ComboboxFieldProps<T>) {
|
|
97
97
|
const field = useFieldContext()
|
|
98
|
-
const
|
|
99
|
-
const
|
|
98
|
+
const id = useId()
|
|
99
|
+
const errorId = `${id}-error`
|
|
100
|
+
const descriptionId = `${id}-description`
|
|
100
101
|
const isInvalid = useIsInvalid(field)
|
|
101
102
|
const hideErrors = useHideFieldErrors(field)
|
|
102
103
|
const showError = isInvalid && !hideErrors
|
|
@@ -113,7 +114,7 @@ function ComboboxField<T = string>({
|
|
|
113
114
|
return (
|
|
114
115
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
115
116
|
{label && (
|
|
116
|
-
<FieldLabel htmlFor={
|
|
117
|
+
<FieldLabel htmlFor={id}>
|
|
117
118
|
{label}
|
|
118
119
|
{isRequired && <RequiredIndicator />}
|
|
119
120
|
</FieldLabel>
|
|
@@ -124,7 +125,7 @@ function ComboboxField<T = string>({
|
|
|
124
125
|
items={items}
|
|
125
126
|
onInputValueChange={onInputValueChange}
|
|
126
127
|
onValueChange={(next) => {
|
|
127
|
-
if (next
|
|
128
|
+
if (next === null) {
|
|
128
129
|
field.handleChange(undefined)
|
|
129
130
|
return
|
|
130
131
|
}
|
|
@@ -139,11 +140,11 @@ function ComboboxField<T = string>({
|
|
|
139
140
|
)}
|
|
140
141
|
aria-invalid={isInvalid || undefined}
|
|
141
142
|
className={className}
|
|
142
|
-
id={
|
|
143
|
+
id={id}
|
|
143
144
|
name={field.name}
|
|
144
145
|
onBlur={field.handleBlur}
|
|
145
146
|
placeholder={placeholder}
|
|
146
|
-
showClear={showClear && value
|
|
147
|
+
showClear={showClear && value !== null && value !== "" && !loading}
|
|
147
148
|
>
|
|
148
149
|
{loading && (
|
|
149
150
|
<InputGroupAddon align="inline-end">
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
useIsFieldRequired,
|
|
8
8
|
useIsInvalid,
|
|
9
9
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import type
|
|
10
|
+
import { type ComponentProps, type ReactNode, useId } from "react"
|
|
11
11
|
import { buttonVariants } from "@/components/ui/button"
|
|
12
12
|
import { Calendar } from "@/components/ui/calendar"
|
|
13
13
|
import {
|
|
@@ -60,21 +60,22 @@ function DatePickerField({
|
|
|
60
60
|
triggerClassName,
|
|
61
61
|
}: DatePickerFieldProps) {
|
|
62
62
|
const field = useFieldContext<Date | undefined>()
|
|
63
|
-
const
|
|
64
|
-
const
|
|
63
|
+
const id = useId()
|
|
64
|
+
const errorId = `${id}-error`
|
|
65
|
+
const descriptionId = `${id}-description`
|
|
65
66
|
const isInvalid = useIsInvalid(field)
|
|
66
67
|
const hideErrors = useHideFieldErrors(field)
|
|
67
68
|
const showError = isInvalid && !hideErrors
|
|
68
69
|
const schemaRequired = useIsFieldRequired(field)
|
|
69
70
|
const isRequired = required ?? schemaRequired
|
|
70
71
|
|
|
71
|
-
const value = field.state
|
|
72
|
+
const { value } = field.state
|
|
72
73
|
const empty = !value
|
|
73
74
|
|
|
74
75
|
return (
|
|
75
76
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
76
77
|
{label && (
|
|
77
|
-
<FieldLabel htmlFor={
|
|
78
|
+
<FieldLabel htmlFor={id}>
|
|
78
79
|
{label}
|
|
79
80
|
{isRequired && <RequiredIndicator />}
|
|
80
81
|
</FieldLabel>
|
|
@@ -92,7 +93,7 @@ function DatePickerField({
|
|
|
92
93
|
triggerClassName
|
|
93
94
|
)}
|
|
94
95
|
data-empty={empty || undefined}
|
|
95
|
-
id={
|
|
96
|
+
id={id}
|
|
96
97
|
// Base UI's Popover handles open state — we still need blur on the
|
|
97
98
|
// form's reactive store. Passing onBlur here mirrors how `<select>`
|
|
98
99
|
// commits on close.
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
useIsFieldRequired,
|
|
8
8
|
useIsInvalid,
|
|
9
9
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import { Children, type ComponentProps, type ReactNode } from "react"
|
|
10
|
+
import { Children, type ComponentProps, type ReactNode, useId } from "react"
|
|
11
11
|
import {
|
|
12
12
|
Field,
|
|
13
13
|
FieldDescription,
|
|
@@ -40,8 +40,9 @@ function InputField({
|
|
|
40
40
|
...props
|
|
41
41
|
}: InputFieldProps) {
|
|
42
42
|
const field = useFieldContext<string>()
|
|
43
|
-
const
|
|
44
|
-
const
|
|
43
|
+
const id = useId()
|
|
44
|
+
const errorId = `${id}-error`
|
|
45
|
+
const descriptionId = `${id}-description`
|
|
45
46
|
const isInvalid = useIsInvalid(field)
|
|
46
47
|
const hideErrors = useHideFieldErrors(field)
|
|
47
48
|
const showError = isInvalid && !hideErrors
|
|
@@ -55,7 +56,7 @@ function InputField({
|
|
|
55
56
|
[showError, errorId]
|
|
56
57
|
),
|
|
57
58
|
"aria-invalid": isInvalid || undefined,
|
|
58
|
-
id
|
|
59
|
+
id,
|
|
59
60
|
name: field.name,
|
|
60
61
|
onBlur: field.handleBlur,
|
|
61
62
|
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
|
@@ -67,7 +68,7 @@ function InputField({
|
|
|
67
68
|
return (
|
|
68
69
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
69
70
|
{label && (
|
|
70
|
-
<FieldLabel htmlFor={
|
|
71
|
+
<FieldLabel htmlFor={id}>
|
|
71
72
|
{label}
|
|
72
73
|
{isRequired && <RequiredIndicator />}
|
|
73
74
|
</FieldLabel>
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
useIsInvalid,
|
|
9
9
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
10
|
import { XIcon } from "lucide-react"
|
|
11
|
-
import type
|
|
11
|
+
import { type ReactNode, useId } from "react"
|
|
12
12
|
import { Button } from "@/components/ui/button"
|
|
13
13
|
import {
|
|
14
14
|
Combobox,
|
|
@@ -96,8 +96,9 @@ function MultiSelectField<T = string>({
|
|
|
96
96
|
}: MultiSelectFieldProps<T>) {
|
|
97
97
|
const field = useFieldContext<MultiSelectValue<T>[] | null | undefined>()
|
|
98
98
|
const anchor = useComboboxAnchor()
|
|
99
|
-
const
|
|
100
|
-
const
|
|
99
|
+
const id = useId()
|
|
100
|
+
const errorId = `${id}-error`
|
|
101
|
+
const descriptionId = `${id}-description`
|
|
101
102
|
const isInvalid = useIsInvalid(field)
|
|
102
103
|
const hideErrors = useHideFieldErrors(field)
|
|
103
104
|
const showError = isInvalid && !hideErrors
|
|
@@ -114,7 +115,7 @@ function MultiSelectField<T = string>({
|
|
|
114
115
|
return (
|
|
115
116
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
116
117
|
{label && (
|
|
117
|
-
<FieldLabel htmlFor={
|
|
118
|
+
<FieldLabel htmlFor={id}>
|
|
118
119
|
{label}
|
|
119
120
|
{isRequired && <RequiredIndicator />}
|
|
120
121
|
</FieldLabel>
|
|
@@ -143,7 +144,7 @@ function MultiSelectField<T = string>({
|
|
|
143
144
|
[showError, errorId]
|
|
144
145
|
)}
|
|
145
146
|
aria-invalid={isInvalid || undefined}
|
|
146
|
-
id={
|
|
147
|
+
id={id}
|
|
147
148
|
name={field.name}
|
|
148
149
|
onBlur={field.handleBlur}
|
|
149
150
|
placeholder={chips.length === 0 ? placeholder : undefined}
|
|
@@ -11,7 +11,7 @@ type NumberFieldProps = Omit<
|
|
|
11
11
|
|
|
12
12
|
function NumberField(props: NumberFieldProps) {
|
|
13
13
|
const field = useFieldContext<number | undefined>()
|
|
14
|
-
const value = field.state
|
|
14
|
+
const { value } = field.state
|
|
15
15
|
const inputValue =
|
|
16
16
|
typeof value === "number" && !Number.isNaN(value) ? value : ""
|
|
17
17
|
|
package/src/fields/otp-field.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
useIsFieldRequired,
|
|
8
8
|
useIsInvalid,
|
|
9
9
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import type
|
|
10
|
+
import { type ReactNode, useId } from "react"
|
|
11
11
|
import {
|
|
12
12
|
Field,
|
|
13
13
|
FieldDescription,
|
|
@@ -57,8 +57,9 @@ function OtpField({
|
|
|
57
57
|
required,
|
|
58
58
|
}: OtpFieldProps) {
|
|
59
59
|
const field = useFieldContext<string>()
|
|
60
|
-
const
|
|
61
|
-
const
|
|
60
|
+
const id = useId()
|
|
61
|
+
const errorId = `${id}-error`
|
|
62
|
+
const descriptionId = `${id}-description`
|
|
62
63
|
const isInvalid = useIsInvalid(field)
|
|
63
64
|
const hideErrors = useHideFieldErrors(field)
|
|
64
65
|
const showError = isInvalid && !hideErrors
|
|
@@ -68,7 +69,7 @@ function OtpField({
|
|
|
68
69
|
return (
|
|
69
70
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
70
71
|
{label && (
|
|
71
|
-
<FieldLabel htmlFor={
|
|
72
|
+
<FieldLabel htmlFor={id}>
|
|
72
73
|
{label}
|
|
73
74
|
{isRequired && <RequiredIndicator />}
|
|
74
75
|
</FieldLabel>
|
|
@@ -83,7 +84,7 @@ function OtpField({
|
|
|
83
84
|
className={className}
|
|
84
85
|
containerClassName={containerClassName}
|
|
85
86
|
disabled={disabled}
|
|
86
|
-
id={
|
|
87
|
+
id={id}
|
|
87
88
|
maxLength={maxLength}
|
|
88
89
|
name={field.name}
|
|
89
90
|
onBlur={field.handleBlur}
|
|
@@ -36,8 +36,9 @@ function RadioGroupField({
|
|
|
36
36
|
...props
|
|
37
37
|
}: RadioGroupFieldProps) {
|
|
38
38
|
const field = useFieldContext<string>()
|
|
39
|
-
const
|
|
40
|
-
const
|
|
39
|
+
const id = useId()
|
|
40
|
+
const errorId = `${id}-error`
|
|
41
|
+
const descriptionId = `${id}-description`
|
|
41
42
|
const isInvalid = useIsInvalid(field)
|
|
42
43
|
const hideErrors = useHideFieldErrors(field)
|
|
43
44
|
const showError = isInvalid && !hideErrors
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
useIsInvalid,
|
|
9
9
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
10
|
import type { ComponentProps, ReactNode } from "react"
|
|
11
|
-
import { Children, isValidElement, useMemo } from "react"
|
|
11
|
+
import { Children, isValidElement, useId, useMemo } from "react"
|
|
12
12
|
import {
|
|
13
13
|
Field,
|
|
14
14
|
FieldDescription,
|
|
@@ -48,8 +48,9 @@ function SelectField({
|
|
|
48
48
|
...props
|
|
49
49
|
}: SelectFieldProps) {
|
|
50
50
|
const field = useFieldContext()
|
|
51
|
-
const
|
|
52
|
-
const
|
|
51
|
+
const id = useId()
|
|
52
|
+
const errorId = `${id}-error`
|
|
53
|
+
const descriptionId = `${id}-description`
|
|
53
54
|
const isInvalid = useIsInvalid(field)
|
|
54
55
|
const hideErrors = useHideFieldErrors(field)
|
|
55
56
|
const showError = isInvalid && !hideErrors
|
|
@@ -72,7 +73,7 @@ function SelectField({
|
|
|
72
73
|
return (
|
|
73
74
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
74
75
|
{label && (
|
|
75
|
-
<FieldLabel htmlFor={
|
|
76
|
+
<FieldLabel htmlFor={id}>
|
|
76
77
|
{label}
|
|
77
78
|
{isRequired && <RequiredIndicator />}
|
|
78
79
|
</FieldLabel>
|
|
@@ -91,7 +92,7 @@ function SelectField({
|
|
|
91
92
|
)}
|
|
92
93
|
aria-invalid={isInvalid || undefined}
|
|
93
94
|
className={triggerClassName}
|
|
94
|
-
id={
|
|
95
|
+
id={id}
|
|
95
96
|
onBlur={field.handleBlur}
|
|
96
97
|
size={triggerSize}
|
|
97
98
|
>
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
useIsFieldRequired,
|
|
8
8
|
useIsInvalid,
|
|
9
9
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import type
|
|
10
|
+
import { type ComponentProps, type ReactNode, useId } from "react"
|
|
11
11
|
import {
|
|
12
12
|
Field,
|
|
13
13
|
FieldContent,
|
|
@@ -43,28 +43,31 @@ function SliderField({
|
|
|
43
43
|
...props
|
|
44
44
|
}: SliderFieldProps) {
|
|
45
45
|
const field = useFieldContext<SliderValue>()
|
|
46
|
-
const
|
|
47
|
-
const
|
|
46
|
+
const id = useId()
|
|
47
|
+
const errorId = `${id}-error`
|
|
48
|
+
const descriptionId = `${id}-description`
|
|
48
49
|
const isInvalid = useIsInvalid(field)
|
|
49
50
|
const hideErrors = useHideFieldErrors(field)
|
|
50
51
|
const showError = isInvalid && !hideErrors
|
|
51
52
|
const schemaRequired = useIsFieldRequired(field)
|
|
52
53
|
const isRequired = required ?? schemaRequired
|
|
53
54
|
|
|
54
|
-
const value = field.state
|
|
55
|
+
const { value } = field.state
|
|
55
56
|
const readout = formatValue && value !== undefined ? formatValue(value) : null
|
|
57
|
+
// Not a truthiness check: a formatter may return the number 0, which must still render.
|
|
58
|
+
const hasReadout = readout !== null && readout !== undefined
|
|
56
59
|
|
|
57
60
|
return (
|
|
58
61
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
59
|
-
{(label ||
|
|
62
|
+
{(label || hasReadout) && (
|
|
60
63
|
<FieldContent className="flex-row items-center justify-between">
|
|
61
64
|
{label && (
|
|
62
|
-
<FieldLabel htmlFor={
|
|
65
|
+
<FieldLabel htmlFor={id}>
|
|
63
66
|
{label}
|
|
64
67
|
{isRequired && <RequiredIndicator />}
|
|
65
68
|
</FieldLabel>
|
|
66
69
|
)}
|
|
67
|
-
{
|
|
70
|
+
{hasReadout && (
|
|
68
71
|
<span className="text-muted-foreground text-sm tabular-nums">
|
|
69
72
|
{readout}
|
|
70
73
|
</span>
|
|
@@ -77,13 +80,13 @@ function SliderField({
|
|
|
77
80
|
[showError, errorId]
|
|
78
81
|
)}
|
|
79
82
|
aria-invalid={isInvalid || undefined}
|
|
80
|
-
id={
|
|
83
|
+
id={id}
|
|
81
84
|
name={field.name}
|
|
82
85
|
onBlur={field.handleBlur}
|
|
83
86
|
onValueChange={(next) => {
|
|
84
|
-
const
|
|
87
|
+
const nextValue: SliderValue =
|
|
85
88
|
typeof next === "number" ? next : Array.from(next)
|
|
86
|
-
field.handleChange(
|
|
89
|
+
field.handleChange(nextValue)
|
|
87
90
|
}}
|
|
88
91
|
value={value}
|
|
89
92
|
{...props}
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
useIsFieldRequired,
|
|
8
8
|
useIsInvalid,
|
|
9
9
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import type
|
|
10
|
+
import { type ComponentProps, type ReactNode, useId } from "react"
|
|
11
11
|
import {
|
|
12
12
|
Field,
|
|
13
13
|
FieldContent,
|
|
@@ -35,8 +35,9 @@ function SwitchField({
|
|
|
35
35
|
...props
|
|
36
36
|
}: SwitchFieldProps) {
|
|
37
37
|
const field = useFieldContext<boolean>()
|
|
38
|
-
const
|
|
39
|
-
const
|
|
38
|
+
const id = useId()
|
|
39
|
+
const errorId = `${id}-error`
|
|
40
|
+
const descriptionId = `${id}-description`
|
|
40
41
|
const isInvalid = useIsInvalid(field)
|
|
41
42
|
const hideErrors = useHideFieldErrors(field)
|
|
42
43
|
const showError = isInvalid && !hideErrors
|
|
@@ -51,7 +52,7 @@ function SwitchField({
|
|
|
51
52
|
>
|
|
52
53
|
<FieldContent>
|
|
53
54
|
{label && (
|
|
54
|
-
<FieldLabel htmlFor={
|
|
55
|
+
<FieldLabel htmlFor={id}>
|
|
55
56
|
{label}
|
|
56
57
|
{isRequired && <RequiredIndicator />}
|
|
57
58
|
</FieldLabel>
|
|
@@ -70,7 +71,7 @@ function SwitchField({
|
|
|
70
71
|
)}
|
|
71
72
|
aria-invalid={isInvalid || undefined}
|
|
72
73
|
checked={field.state.value ?? false}
|
|
73
|
-
id={
|
|
74
|
+
id={id}
|
|
74
75
|
name={field.name}
|
|
75
76
|
onBlur={field.handleBlur}
|
|
76
77
|
onCheckedChange={(value) => field.handleChange(value)}
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
useIsFieldRequired,
|
|
8
8
|
useIsInvalid,
|
|
9
9
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import { Children, type ComponentProps, type ReactNode } from "react"
|
|
10
|
+
import { Children, type ComponentProps, type ReactNode, useId } from "react"
|
|
11
11
|
import {
|
|
12
12
|
Field,
|
|
13
13
|
FieldDescription,
|
|
@@ -37,8 +37,9 @@ function TextAreaField({
|
|
|
37
37
|
...props
|
|
38
38
|
}: TextAreaFieldProps) {
|
|
39
39
|
const field = useFieldContext<string>()
|
|
40
|
-
const
|
|
41
|
-
const
|
|
40
|
+
const id = useId()
|
|
41
|
+
const errorId = `${id}-error`
|
|
42
|
+
const descriptionId = `${id}-description`
|
|
42
43
|
const isInvalid = useIsInvalid(field)
|
|
43
44
|
const hideErrors = useHideFieldErrors(field)
|
|
44
45
|
const showError = isInvalid && !hideErrors
|
|
@@ -52,7 +53,7 @@ function TextAreaField({
|
|
|
52
53
|
[showError, errorId]
|
|
53
54
|
),
|
|
54
55
|
"aria-invalid": isInvalid || undefined,
|
|
55
|
-
id
|
|
56
|
+
id,
|
|
56
57
|
name: field.name,
|
|
57
58
|
onBlur: field.handleBlur,
|
|
58
59
|
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) =>
|
|
@@ -64,7 +65,7 @@ function TextAreaField({
|
|
|
64
65
|
return (
|
|
65
66
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
66
67
|
{label && (
|
|
67
|
-
<FieldLabel htmlFor={
|
|
68
|
+
<FieldLabel htmlFor={id}>
|
|
68
69
|
{label}
|
|
69
70
|
{isRequired && <RequiredIndicator />}
|
|
70
71
|
</FieldLabel>
|
package/src/form-dialog.tsx
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* biome-ignore-all lint/performance/noBarrelFile: single npm entry for the headless form API. */
|
|
2
2
|
|
|
3
|
+
export { useSelector } from "@tanstack/react-form"
|
|
3
4
|
export { createZenoForm } from "./create-zeno-form"
|
|
4
5
|
export { Form, FormProvider } from "./form-element"
|
|
5
6
|
export {
|
|
@@ -84,7 +84,7 @@ function formatNumber(
|
|
|
84
84
|
useGrouping = true,
|
|
85
85
|
}: FormatNumberOptions
|
|
86
86
|
): string {
|
|
87
|
-
if (value
|
|
87
|
+
if (value === null || value === undefined || !Number.isFinite(value)) {
|
|
88
88
|
return ""
|
|
89
89
|
}
|
|
90
90
|
return normalizeMinus(
|
|
@@ -236,10 +236,10 @@ function parseLocaleNumber(text: string, locale?: string): number | null {
|
|
|
236
236
|
// the same logical place after separators were inserted or removed.
|
|
237
237
|
function countSignificant(text: string, end: number, decimal: string): number {
|
|
238
238
|
let count = 0
|
|
239
|
-
for (let i = 0; i < end && i < text.length; i
|
|
239
|
+
for (let i = 0; i < end && i < text.length; i += 1) {
|
|
240
240
|
const char = text[i] as string
|
|
241
241
|
if (DIGIT.test(char) || char === decimal || MINUS_SIGNS.test(char)) {
|
|
242
|
-
count
|
|
242
|
+
count += 1
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
245
|
return count
|
|
@@ -254,10 +254,10 @@ function caretForSignificant(
|
|
|
254
254
|
return 0
|
|
255
255
|
}
|
|
256
256
|
let count = 0
|
|
257
|
-
for (let i = 0; i < text.length; i
|
|
257
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
258
258
|
const char = text[i] as string
|
|
259
259
|
if (DIGIT.test(char) || char === decimal || char === "-") {
|
|
260
|
-
count
|
|
260
|
+
count += 1
|
|
261
261
|
if (count === significant) {
|
|
262
262
|
return i + 1
|
|
263
263
|
}
|
|
@@ -37,7 +37,7 @@ function getDef(node: SchemaNode | undefined): SchemaDef | undefined {
|
|
|
37
37
|
|
|
38
38
|
function extractDefaultForField(node: SchemaNode): ExtractResult {
|
|
39
39
|
let current: SchemaNode | undefined = node
|
|
40
|
-
for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth
|
|
40
|
+
for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) {
|
|
41
41
|
const def: SchemaDef | undefined = getDef(current)
|
|
42
42
|
if (!def?.type) {
|
|
43
43
|
return { ok: false }
|
|
@@ -38,6 +38,26 @@ describe("getRequiredPaths", () => {
|
|
|
38
38
|
expect(required.has("role")).toBe(false)
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
+
test("a string that accepts the empty value it starts at is not required", () => {
|
|
42
|
+
const schema = z.object({
|
|
43
|
+
email: z.email(),
|
|
44
|
+
members: z.array(z.object({ name: z.string().min(1), note: z.string() })),
|
|
45
|
+
name: z.string().trim().min(1),
|
|
46
|
+
notes: z.string(),
|
|
47
|
+
profile: z.object({ bio: z.string(), title: z.string().min(1) }),
|
|
48
|
+
subtitle: z.string().nullable(),
|
|
49
|
+
})
|
|
50
|
+
const required = getRequiredPaths(schema)
|
|
51
|
+
expect(required.has("notes")).toBe(false)
|
|
52
|
+
expect(required.has("subtitle")).toBe(false)
|
|
53
|
+
expect(required.has("profile.bio")).toBe(false)
|
|
54
|
+
expect(required.has("members[0].note")).toBe(false)
|
|
55
|
+
expect(required.has("email")).toBe(true)
|
|
56
|
+
expect(required.has("name")).toBe(true)
|
|
57
|
+
expect(required.has("profile.title")).toBe(true)
|
|
58
|
+
expect(required.has("members[0].name")).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
|
|
41
61
|
test("nested z.object reports the parent and its required children", () => {
|
|
42
62
|
const schema = z.object({
|
|
43
63
|
profile: z.object({
|
|
@@ -79,7 +99,7 @@ describe("getRequiredPaths", () => {
|
|
|
79
99
|
test("nested arrays descend through every level", () => {
|
|
80
100
|
const schema = z.object({
|
|
81
101
|
groups: z.array(
|
|
82
|
-
z.object({ members: z.array(z.object({ name: z.string() })) })
|
|
102
|
+
z.object({ members: z.array(z.object({ name: z.string().min(1) })) })
|
|
83
103
|
),
|
|
84
104
|
})
|
|
85
105
|
const required = getRequiredPaths(schema)
|
|
@@ -166,7 +186,7 @@ describe("getRequiredPaths", () => {
|
|
|
166
186
|
const schema: StandardSchemaLike = {
|
|
167
187
|
"~standard": {
|
|
168
188
|
validate: (value) => {
|
|
169
|
-
const profile =
|
|
189
|
+
const { profile } = value as { profile?: unknown }
|
|
170
190
|
return profile === undefined
|
|
171
191
|
? { issues: [{ expected: "Object", path: [{ key: "profile" }] }] }
|
|
172
192
|
: {
|
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
// index `0` and recorded index-agnostically, so `members[0].name` in the set
|
|
14
14
|
// matches every row's `members[3].name` via `toRequiredPathKey`.
|
|
15
15
|
//
|
|
16
|
+
// A missing string is probed again as `""`, the value the form starts it at
|
|
17
|
+
// (`extractZodDefaults`). A field that accepts `""` (a bare `z.string()`)
|
|
18
|
+
// can't fail while untouched, so it leaves the set; one that rejects it
|
|
19
|
+
// (`.min(1)`, `z.email()`) stays.
|
|
20
|
+
//
|
|
16
21
|
// Paths use TanStack Form's field-name syntax: dots for object keys, brackets
|
|
17
22
|
// for array indices (`members[0].name`, not `members.0.name`).
|
|
18
23
|
//
|
|
@@ -50,6 +55,7 @@ const INDEX_SEGMENT = /^\d+$/
|
|
|
50
55
|
const INDEX_IN_NAME = /\[\d+\]|\.(\d+)(?=\.|\[|$)/g
|
|
51
56
|
|
|
52
57
|
function pathKey(part: PathPart): PropertyKey {
|
|
58
|
+
// biome-ignore lint/suspicious/noUnnecessaryConditions: issue paths come from third-party validators at runtime, and a non-conforming `null` segment would make `in` throw.
|
|
53
59
|
if (typeof part === "object" && part !== null && "key" in part) {
|
|
54
60
|
return part.key
|
|
55
61
|
}
|
|
@@ -85,18 +91,20 @@ function toRequiredPathKey(name: string): string {
|
|
|
85
91
|
return name.replace(INDEX_IN_NAME, "[0]")
|
|
86
92
|
}
|
|
87
93
|
|
|
88
|
-
function
|
|
94
|
+
function probeValue(expected: unknown): unknown {
|
|
89
95
|
if (typeof expected !== "string") {
|
|
90
96
|
return
|
|
91
97
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
switch (expected.toLowerCase()) {
|
|
99
|
+
case "object":
|
|
100
|
+
return {}
|
|
101
|
+
case "array":
|
|
102
|
+
return [{}]
|
|
103
|
+
case "string":
|
|
104
|
+
return ""
|
|
105
|
+
default:
|
|
106
|
+
return
|
|
98
107
|
}
|
|
99
|
-
return
|
|
100
108
|
}
|
|
101
109
|
|
|
102
110
|
// Write `value` at `keys` inside `root`, only where nothing is set yet.
|
|
@@ -137,26 +145,62 @@ function validateSync(
|
|
|
137
145
|
return "issues" in result && result.issues ? result.issues : []
|
|
138
146
|
}
|
|
139
147
|
|
|
148
|
+
type ProbePass = {
|
|
149
|
+
readonly descended: boolean
|
|
150
|
+
readonly failing: ReadonlySet<string>
|
|
151
|
+
readonly filledStrings: readonly string[]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Record one pass's failing paths, and fill the probe at each of them for the
|
|
155
|
+
// next pass.
|
|
156
|
+
function recordPass(
|
|
157
|
+
issues: readonly StandardIssue[],
|
|
158
|
+
probe: Record<PropertyKey, unknown>,
|
|
159
|
+
required: Set<string>
|
|
160
|
+
): ProbePass {
|
|
161
|
+
const failing = new Set<string>()
|
|
162
|
+
const filledStrings: string[] = []
|
|
163
|
+
let descended = false
|
|
164
|
+
for (const issue of issues) {
|
|
165
|
+
const keys = issue.path?.map(pathKey) ?? []
|
|
166
|
+
if (keys.length === 0) {
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
const path = joinPath(keys)
|
|
170
|
+
failing.add(path)
|
|
171
|
+
required.add(path)
|
|
172
|
+
const value = probeValue(issue.expected)
|
|
173
|
+
if (value !== undefined && fillAt(probe, keys, value)) {
|
|
174
|
+
descended = true
|
|
175
|
+
if (value === "") {
|
|
176
|
+
filledStrings.push(path)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { descended, failing, filledStrings }
|
|
181
|
+
}
|
|
182
|
+
|
|
140
183
|
function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
|
|
141
184
|
const required = new Set<string>()
|
|
142
185
|
const probe: Record<PropertyKey, unknown> = {}
|
|
143
|
-
|
|
186
|
+
// Paths the previous pass filled with `""`: they stay only if they still fail.
|
|
187
|
+
let filledStrings: readonly string[] = []
|
|
188
|
+
for (let depth = 0; depth < MAX_PROBE_DEPTH; depth += 1) {
|
|
144
189
|
const issues = validateSync(schema, probe)
|
|
145
190
|
if (!issues) {
|
|
146
191
|
return required
|
|
147
192
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (container !== undefined && fillAt(probe, keys, container)) {
|
|
157
|
-
descended = true
|
|
193
|
+
const {
|
|
194
|
+
descended,
|
|
195
|
+
failing,
|
|
196
|
+
filledStrings: filled,
|
|
197
|
+
} = recordPass(issues, probe, required)
|
|
198
|
+
for (const path of filledStrings) {
|
|
199
|
+
if (!failing.has(path)) {
|
|
200
|
+
required.delete(path)
|
|
158
201
|
}
|
|
159
202
|
}
|
|
203
|
+
filledStrings = filled
|
|
160
204
|
if (!descended) {
|
|
161
205
|
break
|
|
162
206
|
}
|
|
@@ -156,8 +156,9 @@ function attributeSelector(attribute: string, value: string): string {
|
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
158
|
* Find the element to focus for field `name` inside `root`: the control with
|
|
159
|
-
* `id={name}` (
|
|
160
|
-
*
|
|
159
|
+
* `id={name}` (fields that use the name as their id), else, inside the field
|
|
160
|
+
* root marked `data-field={name}`, the control its label points at (the shipped
|
|
161
|
+
* fields' ids come from `useId`), else that root's first focusable element.
|
|
161
162
|
*/
|
|
162
163
|
function findFieldElement(
|
|
163
164
|
root: ParentNode | null | undefined,
|
|
@@ -171,7 +172,14 @@ function findFieldElement(
|
|
|
171
172
|
return byId
|
|
172
173
|
}
|
|
173
174
|
const fieldRoot = root.querySelector(attributeSelector("data-field", name))
|
|
174
|
-
|
|
175
|
+
if (!fieldRoot) {
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
const labelFor = fieldRoot.querySelector("label[for]")?.getAttribute("for")
|
|
179
|
+
const labelled = labelFor
|
|
180
|
+
? fieldRoot.querySelector<HTMLElement>(attributeSelector("id", labelFor))
|
|
181
|
+
: null
|
|
182
|
+
return labelled ?? fieldRoot.querySelector<HTMLElement>(FOCUSABLE)
|
|
175
183
|
}
|
|
176
184
|
|
|
177
185
|
export type {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
|
|
2
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
3
|
+
import { Form, FormProvider, useForm } from "./create-form"
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
cleanup()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
describe("SliderField", () => {
|
|
10
|
+
function Harness() {
|
|
11
|
+
const form = useForm({ defaultValues: { volume: 0 }, onSubmit: vi.fn() })
|
|
12
|
+
const { SliderField } = form
|
|
13
|
+
return (
|
|
14
|
+
<FormProvider form={form}>
|
|
15
|
+
<Form>
|
|
16
|
+
<SliderField
|
|
17
|
+
formatValue={(value) => value}
|
|
18
|
+
label="Volume"
|
|
19
|
+
name="volume"
|
|
20
|
+
/>
|
|
21
|
+
</Form>
|
|
22
|
+
</FormProvider>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("renders a numeric readout of 0", () => {
|
|
27
|
+
render(<Harness />)
|
|
28
|
+
expect(screen.getByText("0").tagName).toBe("SPAN")
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -30,7 +30,6 @@ describe("EmailField / PasswordField", () => {
|
|
|
30
30
|
}
|
|
31
31
|
render(<H />)
|
|
32
32
|
const input = screen.getByLabelText(CONTACT_EMAIL_LABEL) as HTMLInputElement
|
|
33
|
-
expect(input.id).toBe("contactEmail")
|
|
34
33
|
expect(input.name).toBe("contactEmail")
|
|
35
34
|
await user.type(input, "u@example.com")
|
|
36
35
|
expect(input.value).toBe("u@example.com")
|
|
@@ -54,7 +53,6 @@ describe("EmailField / PasswordField", () => {
|
|
|
54
53
|
}
|
|
55
54
|
render(<H />)
|
|
56
55
|
const input = screen.getByLabelText(PASSWORD_LABEL) as HTMLInputElement
|
|
57
|
-
expect(input.id).toBe("password")
|
|
58
56
|
expect(input.name).toBe("password")
|
|
59
57
|
expect(input.type).toBe("password")
|
|
60
58
|
})
|
|
@@ -137,3 +135,32 @@ describe("CheckboxField — boolean state wrapper", () => {
|
|
|
137
135
|
expect(checkbox.getAttribute("aria-checked")).toBe("true")
|
|
138
136
|
})
|
|
139
137
|
})
|
|
138
|
+
|
|
139
|
+
describe("field ids", () => {
|
|
140
|
+
test("two forms with the same field name label their own controls", () => {
|
|
141
|
+
const schema = z.object({ description: z.string().min(1) })
|
|
142
|
+
function H({ label }: { label: string }) {
|
|
143
|
+
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
144
|
+
const { InputField } = form
|
|
145
|
+
return (
|
|
146
|
+
<FormProvider form={form}>
|
|
147
|
+
<Form>
|
|
148
|
+
<InputField description="Hint" label={label} name="description" />
|
|
149
|
+
</Form>
|
|
150
|
+
</FormProvider>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
render(
|
|
154
|
+
<>
|
|
155
|
+
<H label="Page description" />
|
|
156
|
+
<H label="Dialog description" />
|
|
157
|
+
</>
|
|
158
|
+
)
|
|
159
|
+
const page = screen.getByRole("textbox", { name: "Page description" })
|
|
160
|
+
const dialog = screen.getByRole("textbox", { name: "Dialog description" })
|
|
161
|
+
expect(dialog).not.toBe(page)
|
|
162
|
+
const hints = screen.getAllByText("Hint")
|
|
163
|
+
expect(page.getAttribute("aria-describedby")).toBe(hints[0]?.id)
|
|
164
|
+
expect(dialog.getAttribute("aria-describedby")).toBe(hints[1]?.id)
|
|
165
|
+
})
|
|
166
|
+
})
|