@zeno-lib/forms 0.9.1 → 0.10.1
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 +1 -2
- package/dist/create-zeno-form.mjs +1 -2
- package/dist/form-element.d.mts +0 -2
- package/dist/form-element.mjs +1 -2
- package/dist/lib/action-result.d.mts +0 -2
- package/dist/lib/action-result.mjs +1 -2
- package/dist/lib/apply-validation-error.d.mts +0 -2
- package/dist/lib/apply-validation-error.mjs +1 -2
- package/dist/lib/aria.d.mts +1 -3
- package/dist/lib/aria.mjs +1 -2
- package/dist/lib/contexts.d.mts +0 -2
- package/dist/lib/contexts.mjs +1 -2
- package/dist/lib/date-value.d.mts +0 -2
- package/dist/lib/date-value.mjs +2 -3
- package/dist/lib/default-props.d.mts +1 -3
- package/dist/lib/default-props.mjs +1 -2
- package/dist/lib/empty-value.d.mts +9 -3
- package/dist/lib/empty-value.mjs +15 -5
- package/dist/lib/field-hints.d.mts +16 -0
- package/dist/lib/field-hints.mjs +23 -0
- package/dist/lib/focus-first-invalid.d.mts +0 -2
- package/dist/lib/focus-first-invalid.mjs +1 -2
- package/dist/lib/form-disabled.d.mts +0 -2
- package/dist/lib/form-disabled.mjs +1 -2
- package/dist/lib/formatted-number.d.mts +0 -2
- package/dist/lib/formatted-number.mjs +10 -11
- package/dist/lib/item-options.d.mts +0 -2
- package/dist/lib/item-options.mjs +1 -2
- package/dist/lib/schema-defaults.d.mts +6 -3
- package/dist/lib/schema-defaults.mjs +37 -5
- package/dist/lib/schema-required.d.mts +0 -2
- package/dist/lib/schema-required.mjs +3 -4
- package/dist/lib/submit-action.d.mts +0 -2
- package/dist/lib/submit-action.mjs +1 -2
- package/dist/lib/submit-error.d.mts +0 -2
- package/dist/lib/submit-error.mjs +1 -2
- package/dist/lib/use-field-control.d.mts +0 -2
- package/dist/lib/use-field-control.mjs +1 -2
- package/dist/lib/use-form-dialog.d.mts +0 -2
- package/dist/lib/use-form-dialog.mjs +2 -3
- package/dist/lib/use-formatted-number.d.mts +0 -2
- package/dist/lib/use-formatted-number.mjs +1 -2
- package/dist/lib/use-is-invalid.d.mts +0 -2
- package/dist/lib/use-is-invalid.mjs +1 -2
- package/dist/lib/use-rebased-default-values.d.mts +0 -2
- package/dist/lib/use-rebased-default-values.mjs +1 -2
- package/dist/lib/use-unsaved-changes-warning.d.mts +0 -2
- package/dist/lib/use-unsaved-changes-warning.mjs +1 -2
- package/dist/lib/validation-error.d.mts +0 -2
- package/dist/lib/validation-error.mjs +1 -2
- package/dist/lib/validation-logic.d.mts +0 -2
- package/dist/lib/validation-logic.mjs +1 -2
- package/dist/lib/validation-modes.d.mts +0 -2
- package/dist/lib/validation-modes.mjs +1 -2
- package/package.json +8 -8
- package/src/composition.test.tsx +3 -3
- package/src/control-field.test.tsx +1 -1
- package/src/date-picker-field.test.tsx +3 -3
- package/src/field-hints.test.tsx +118 -0
- package/src/fields/input-field.tsx +6 -4
- package/src/fields/percentage-field.tsx +16 -8
- package/src/fields/textarea-field.tsx +6 -4
- package/src/fields/year-field.tsx +8 -3
- package/src/form-dialog.test.tsx +3 -3
- package/src/form-error.test.tsx +1 -1
- package/src/formatted-number-fields.test.tsx +3 -3
- package/src/lib/date-value.ts +2 -1
- package/src/lib/empty-value.ts +26 -3
- package/src/lib/field-hints.ts +64 -0
- package/src/lib/formatted-number.ts +9 -9
- package/src/lib/schema-defaults.test.ts +6 -3
- package/src/lib/schema-defaults.ts +60 -3
- package/src/lib/schema-required.ts +2 -2
- package/src/lib/use-form-dialog.ts +1 -1
- package/src/multi-value-fields.test.tsx +2 -2
- package/src/nullable-text.test.tsx +80 -0
- package/src/submit-action.test.tsx +4 -4
- package/src/use-app-fields.test.tsx +10 -10
- package/src/use-form.cascade.test.tsx +2 -2
- package/src/use-form.reset-defaults.test.tsx +2 -2
- package/src/use-form.test.tsx +3 -3
- package/src/use-form.unsaved-warning.test.tsx +1 -1
- package/src/use-form.validation-modes.test.tsx +2 -2
- package/src/with-form.test.tsx +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useCallback, useRef, useState } from "react";
|
|
3
|
-
|
|
3
|
+
// src/lib/use-form-dialog.ts
|
|
4
4
|
/**
|
|
5
5
|
* Open/close state for a dialog-hosted form, one "session" per `open()`.
|
|
6
6
|
*
|
|
@@ -91,7 +91,7 @@ function useLeaveGuard({ hasUnsavedChanges, onSave }) {
|
|
|
91
91
|
}
|
|
92
92
|
const FOCUSABLE = "input:not([type=\"hidden\"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex=\"-1\"])";
|
|
93
93
|
function attributeSelector(attribute, value) {
|
|
94
|
-
return `[${attribute}="${value.replace(/["\\]/
|
|
94
|
+
return `[${attribute}="${value.replace(/["\\]/gu, "\\$&")}"]`;
|
|
95
95
|
}
|
|
96
96
|
/**
|
|
97
97
|
* Find the element to focus for field `name` inside `root`: the control with
|
|
@@ -108,5 +108,4 @@ function findFieldElement(root, name) {
|
|
|
108
108
|
const labelFor = fieldRoot.querySelector("label[for]")?.getAttribute("for");
|
|
109
109
|
return (labelFor ? fieldRoot.querySelector(attributeSelector("id", labelFor)) : null) ?? fieldRoot.querySelector(FOCUSABLE);
|
|
110
110
|
}
|
|
111
|
-
//#endregion
|
|
112
111
|
export { findFieldElement, useFormDialog, useLeaveGuard };
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { ChangeEvent, ClipboardEvent } from "react";
|
|
2
|
-
//#region src/lib/use-formatted-number.d.ts
|
|
3
2
|
type UseFormattedNumberOptions = {
|
|
4
3
|
/** The stored value. `null`/`undefined` renders an empty input. */
|
|
5
4
|
value: number | null | undefined;
|
|
@@ -57,5 +56,4 @@ type FormattedNumberInputProps = {
|
|
|
57
56
|
* can use it.
|
|
58
57
|
*/
|
|
59
58
|
declare function useFormattedNumber({ value, onValueChange, onBlur, locale, maximumFractionDigits, padFraction, useGrouping, allowNegative, maxIntegerDigits, min, max, toDisplay, fromDisplay }: UseFormattedNumberOptions): FormattedNumberInputProps;
|
|
60
|
-
//#endregion
|
|
61
59
|
export { type FormattedNumberInputProps, type UseFormattedNumberOptions, useFormattedNumber };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { formatNumber, mapCaret, parseLocaleNumber, parseTypedNumber, roundTo } from "./formatted-number.mjs";
|
|
3
3
|
import { useLayoutEffect, useReducer, useRef, useState } from "react";
|
|
4
|
-
|
|
4
|
+
// src/lib/use-formatted-number.ts
|
|
5
5
|
const identity = (value) => value;
|
|
6
6
|
function clamp(value, min, max) {
|
|
7
7
|
let next = value;
|
|
@@ -100,5 +100,4 @@ function useFormattedNumber({ value, onValueChange, onBlur, locale, maximumFract
|
|
|
100
100
|
value: text
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
|
-
//#endregion
|
|
104
103
|
export { useFormattedNumber };
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { AnyFieldApi } from "@tanstack/react-form";
|
|
2
|
-
//#region src/lib/use-is-invalid.d.ts
|
|
3
2
|
declare function useIsInvalid(field: AnyFieldApi): boolean;
|
|
4
3
|
declare function useHideFieldErrors(field: AnyFieldApi): boolean;
|
|
5
4
|
declare function useIsFieldRequired(field: AnyFieldApi): boolean;
|
|
6
5
|
declare function useIsFieldRequiredBySchema(field: AnyFieldApi): boolean;
|
|
7
|
-
//#endregion
|
|
8
6
|
export { useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { getFormHideFieldErrors, getFormValidationMode, isFieldRequired, isFieldRequiredBySchema } from "./validation-modes.mjs";
|
|
3
3
|
import { useSelector } from "@tanstack/react-form";
|
|
4
|
-
|
|
4
|
+
// src/lib/use-is-invalid.ts
|
|
5
5
|
function hasFieldLevelError(errorMap, errorSourceMap, cause) {
|
|
6
6
|
if (!(errorMap && errorSourceMap)) return false;
|
|
7
7
|
return errorSourceMap[cause] === "field" && errorMap[cause] !== void 0;
|
|
@@ -30,5 +30,4 @@ function useIsFieldRequired(field) {
|
|
|
30
30
|
function useIsFieldRequiredBySchema(field) {
|
|
31
31
|
return isFieldRequiredBySchema(field.form, field.name);
|
|
32
32
|
}
|
|
33
|
-
//#endregion
|
|
34
33
|
export { useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid };
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { AnyFormApi } from "@tanstack/react-form";
|
|
2
|
-
//#region src/lib/use-rebased-default-values.d.ts
|
|
3
2
|
declare function useRebasedDefaultValues<T>(callerDefaults: T): {
|
|
4
3
|
defaultValues: T;
|
|
5
4
|
onMount: (props: {
|
|
6
5
|
formApi: AnyFormApi;
|
|
7
6
|
}) => void;
|
|
8
7
|
};
|
|
9
|
-
//#endregion
|
|
10
8
|
export { useRebasedDefaultValues };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { evaluate } from "@tanstack/react-form";
|
|
3
3
|
import { useCallback, useLayoutEffect, useRef } from "react";
|
|
4
|
-
|
|
4
|
+
// src/lib/use-rebased-default-values.ts
|
|
5
5
|
function useRebasedDefaultValues(callerDefaults) {
|
|
6
6
|
const liveForm = useRef(null);
|
|
7
7
|
const lastCallerDefaults = useRef(callerDefaults);
|
|
@@ -18,5 +18,4 @@ function useRebasedDefaultValues(callerDefaults) {
|
|
|
18
18
|
onMount
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
-
//#endregion
|
|
22
21
|
export { useRebasedDefaultValues };
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { AnyFormApi } from "@tanstack/react-form";
|
|
2
|
-
//#region src/lib/use-unsaved-changes-warning.d.ts
|
|
3
2
|
type UnsavedChangesMode = "if-changed" | "if-touched";
|
|
4
3
|
declare function useUnsavedChangesWarning(form: AnyFormApi, enabled: boolean | UnsavedChangesMode): void;
|
|
5
|
-
//#endregion
|
|
6
4
|
export { type UnsavedChangesMode, useUnsavedChangesWarning };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useSelector } from "@tanstack/react-form";
|
|
3
3
|
import { useEffect } from "react";
|
|
4
|
-
|
|
4
|
+
// src/lib/use-unsaved-changes-warning.ts
|
|
5
5
|
function useUnsavedChangesWarning(form, enabled) {
|
|
6
6
|
let mode;
|
|
7
7
|
if (enabled === false) mode = null;
|
|
@@ -20,5 +20,4 @@ function useUnsavedChangesWarning(form, enabled) {
|
|
|
20
20
|
return () => window.removeEventListener("beforeunload", handler);
|
|
21
21
|
}, [active]);
|
|
22
22
|
}
|
|
23
|
-
//#endregion
|
|
24
23
|
export { useUnsavedChangesWarning };
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
//#region src/lib/validation-error.d.ts
|
|
2
1
|
type FieldMessage = string | readonly string[];
|
|
3
2
|
declare class ValidationError extends Error {
|
|
4
3
|
readonly fields: Readonly<Record<string, FieldMessage>>;
|
|
@@ -8,5 +7,4 @@ declare class ValidationError extends Error {
|
|
|
8
7
|
message?: string;
|
|
9
8
|
});
|
|
10
9
|
}
|
|
11
|
-
//#endregion
|
|
12
10
|
export { type FieldMessage, ValidationError };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// src/lib/validation-error.ts
|
|
2
2
|
var ValidationError = class extends Error {
|
|
3
3
|
constructor(fields, options = {}) {
|
|
4
4
|
super(options.message ?? "Validation failed");
|
|
@@ -7,5 +7,4 @@ var ValidationError = class extends Error {
|
|
|
7
7
|
this.formError = options.formError;
|
|
8
8
|
}
|
|
9
9
|
};
|
|
10
|
-
//#endregion
|
|
11
10
|
export { ValidationError };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
|
|
2
|
+
// src/lib/validation-logic.ts
|
|
3
3
|
function pickLiveValidator(validators, isAsync) {
|
|
4
4
|
const fn = isAsync ? validators?.onChangeAsync ?? validators?.onBlurAsync : validators?.onChange ?? validators?.onBlur;
|
|
5
5
|
return fn ? {
|
|
@@ -110,5 +110,4 @@ function blurThenChangeLogicImpl(props) {
|
|
|
110
110
|
});
|
|
111
111
|
}
|
|
112
112
|
const blurThenChangeLogic = blurThenChangeLogicImpl;
|
|
113
|
-
//#endregion
|
|
114
113
|
export { blurThenChangeLogic };
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { StandardSchemaLike } from "./schema-required.mjs";
|
|
2
2
|
import { AnyFormApi } from "@tanstack/react-form";
|
|
3
|
-
//#region src/lib/validation-modes.d.ts
|
|
4
3
|
type ValidationMode = "change" | "blur" | "submit" | "blur-then-change";
|
|
5
4
|
declare const DEFAULT_VALIDATION_MODE: ValidationMode;
|
|
6
5
|
type ZenoFormState = {
|
|
@@ -16,5 +15,4 @@ declare function getFormHideFieldErrors(form: AnyFormApi): boolean;
|
|
|
16
15
|
declare function getFormSchema(form: AnyFormApi): StandardSchemaLike | undefined;
|
|
17
16
|
declare function isFieldRequiredBySchema(form: AnyFormApi, name: string): boolean;
|
|
18
17
|
declare function isFieldRequired(form: AnyFormApi, name: string): boolean;
|
|
19
|
-
//#endregion
|
|
20
18
|
export { DEFAULT_VALIDATION_MODE, type ValidationMode, getFormHideFieldErrors, getFormSchema, getFormValidationMode, isFieldRequired, isFieldRequiredBySchema, setFormZenoState };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { toRequiredPathKey } from "./schema-required.mjs";
|
|
3
|
-
|
|
3
|
+
// src/lib/validation-modes.ts
|
|
4
4
|
const DEFAULT_VALIDATION_MODE = "blur-then-change";
|
|
5
5
|
const DEFAULT_FORM_STATE = {
|
|
6
6
|
hideFieldErrors: false,
|
|
@@ -39,5 +39,4 @@ function isFieldRequiredBySchema(form, name) {
|
|
|
39
39
|
function isFieldRequired(form, name) {
|
|
40
40
|
return getFormZenoState(form).requiredIndicator && isFieldRequiredBySchema(form, name);
|
|
41
41
|
}
|
|
42
|
-
//#endregion
|
|
43
42
|
export { DEFAULT_VALIDATION_MODE, getFormHideFieldErrors, getFormSchema, getFormValidationMode, isFieldRequired, isFieldRequiredBySchema, setFormZenoState };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeno-lib/forms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"url": "https://github.com/zeno-lib/zeno.git",
|
|
6
6
|
"directory": "packages/forms"
|
|
@@ -33,18 +33,18 @@
|
|
|
33
33
|
"@tanstack/react-form": "1.33.5"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@types/node": "26.6.
|
|
36
|
+
"@types/node": "26.6.5",
|
|
37
37
|
"@types/react": "19.3.0",
|
|
38
38
|
"@types/react-dom": "19.3.0",
|
|
39
|
-
"@zeno-lib/test": "^0.1.
|
|
39
|
+
"@zeno-lib/test": "^0.1.2",
|
|
40
40
|
"@zeno-lib/typescript": "^1.2.0",
|
|
41
41
|
"@zeno-lib/ui": "^0.0.1",
|
|
42
|
-
"lucide-react": "^1.
|
|
43
|
-
"tsdown": "^0.23.
|
|
42
|
+
"lucide-react": "^1.62.0",
|
|
43
|
+
"tsdown": "^0.23.1",
|
|
44
44
|
"typescript": "7.0.2",
|
|
45
|
-
"vite": "8.3.
|
|
46
|
-
"vitest": "5.0.
|
|
47
|
-
"zod": "^4.6.
|
|
45
|
+
"vite": "8.3.5",
|
|
46
|
+
"vitest": "5.0.5",
|
|
47
|
+
"zod": "^4.6.7"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"@tanstack/react-devtools": ">=0.10",
|
package/src/composition.test.tsx
CHANGED
|
@@ -11,9 +11,9 @@ import {
|
|
|
11
11
|
withForm,
|
|
12
12
|
} from "./create-form"
|
|
13
13
|
|
|
14
|
-
const STREET_LABEL = /Street/
|
|
15
|
-
const CITY_LABEL = /City/
|
|
16
|
-
const BILLING_STREET_LABEL = /Billing street/
|
|
14
|
+
const STREET_LABEL = /Street/u
|
|
15
|
+
const CITY_LABEL = /City/u
|
|
16
|
+
const BILLING_STREET_LABEL = /Billing street/u
|
|
17
17
|
|
|
18
18
|
afterEach(() => {
|
|
19
19
|
cleanup()
|
|
@@ -9,9 +9,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
|
|
|
9
9
|
import { Form, FormProvider, useForm } from "./create-form"
|
|
10
10
|
import type { DateValueType } from "./lib/date-value"
|
|
11
11
|
|
|
12
|
-
const DAY_LABEL = /Day/
|
|
13
|
-
const OCTOBER_9_SELECTED = /October 9th, 2026, selected/
|
|
14
|
-
const OCTOBER_10 = /October 10th, 2026/
|
|
12
|
+
const DAY_LABEL = /Day/u
|
|
13
|
+
const OCTOBER_9_SELECTED = /October 9th, 2026, selected/u
|
|
14
|
+
const OCTOBER_10 = /October 10th, 2026/u
|
|
15
15
|
|
|
16
16
|
afterEach(() => {
|
|
17
17
|
cleanup()
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
|
|
2
|
+
import userEvent from "@zeno-lib/test/user-event"
|
|
3
|
+
import { afterEach, expect, test, vi } from "vitest"
|
|
4
|
+
import { z } from "zod"
|
|
5
|
+
import { Form, FormProvider, useForm } from "./create-form"
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup)
|
|
8
|
+
|
|
9
|
+
type Hint = { field: string; scale?: "fraction" | "percent" }
|
|
10
|
+
|
|
11
|
+
// Where `@zeno-lib/schema` registers a column's hint.
|
|
12
|
+
const HINTS_KEY = Symbol.for("@zeno-lib/field-hints")
|
|
13
|
+
const holder = globalThis as {
|
|
14
|
+
[HINTS_KEY]?: z.core.$ZodRegistry<Hint>
|
|
15
|
+
}
|
|
16
|
+
const hints = holder[HINTS_KEY] ?? z.registry<Hint>()
|
|
17
|
+
holder[HINTS_KEY] = hints
|
|
18
|
+
|
|
19
|
+
const hinted = <T extends z.ZodType>(column: T, hint: Hint) => {
|
|
20
|
+
hints.add(column, hint)
|
|
21
|
+
return column
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// What a `percentage()` or `year()` column of `@zeno-lib/db/schema` hands a
|
|
25
|
+
// form: the rule, its hint, and the wrappers of a nullable column.
|
|
26
|
+
const schema = z.object({
|
|
27
|
+
built: hinted(z.int().min(1900).max(2100), { field: "year" })
|
|
28
|
+
.nullable()
|
|
29
|
+
.optional(),
|
|
30
|
+
occupancy: hinted(z.number().min(0).max(1), {
|
|
31
|
+
field: "percentage",
|
|
32
|
+
scale: "fraction",
|
|
33
|
+
})
|
|
34
|
+
.nullable()
|
|
35
|
+
.optional()
|
|
36
|
+
// Metadata of the form's own, around the column's schema.
|
|
37
|
+
.describe("Share of the floor area let"),
|
|
38
|
+
rate: hinted(z.number().min(-1).max(1), {
|
|
39
|
+
field: "percentage",
|
|
40
|
+
scale: "fraction",
|
|
41
|
+
})
|
|
42
|
+
// A caller's refinement narrows the column's bounds.
|
|
43
|
+
.max(0.5)
|
|
44
|
+
.nullable()
|
|
45
|
+
.optional(),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const captured: { values?: z.input<typeof schema> } = {}
|
|
49
|
+
|
|
50
|
+
function Harness({ occupancyMax }: { occupancyMax?: number }) {
|
|
51
|
+
const form = useForm({
|
|
52
|
+
defaultValues: { occupancy: 0.125 },
|
|
53
|
+
onSubmit: vi.fn(),
|
|
54
|
+
schema,
|
|
55
|
+
})
|
|
56
|
+
const { PercentageField, Subscribe, YearField } = form
|
|
57
|
+
return (
|
|
58
|
+
<FormProvider form={form}>
|
|
59
|
+
<Form>
|
|
60
|
+
<PercentageField
|
|
61
|
+
label="Occupancy"
|
|
62
|
+
locale="en-US"
|
|
63
|
+
max={occupancyMax}
|
|
64
|
+
name="occupancy"
|
|
65
|
+
/>
|
|
66
|
+
<PercentageField label="Rate" locale="en-US" name="rate" />
|
|
67
|
+
<YearField label="Built" name="built" />
|
|
68
|
+
<Subscribe selector={(state) => state.values}>
|
|
69
|
+
{(values) => {
|
|
70
|
+
captured.values = values
|
|
71
|
+
return null
|
|
72
|
+
}}
|
|
73
|
+
</Subscribe>
|
|
74
|
+
</Form>
|
|
75
|
+
</FormProvider>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test("a percentage field takes its scale and bounds from the column's hint", async () => {
|
|
80
|
+
const user = userEvent.setup()
|
|
81
|
+
render(<Harness />)
|
|
82
|
+
const occupancy = screen.getByLabelText("Occupancy") as HTMLInputElement
|
|
83
|
+
expect(occupancy.value).toBe("12.5")
|
|
84
|
+
|
|
85
|
+
await user.clear(occupancy)
|
|
86
|
+
await user.type(occupancy, "150")
|
|
87
|
+
await user.tab()
|
|
88
|
+
expect(captured.values?.occupancy).toBe(1)
|
|
89
|
+
|
|
90
|
+
// A bound below 0 lets the field take a negative rate, and the field
|
|
91
|
+
// clamps to the bound the schema narrowed.
|
|
92
|
+
const rate = screen.getByLabelText("Rate")
|
|
93
|
+
await user.type(rate, "-25")
|
|
94
|
+
await user.tab()
|
|
95
|
+
expect(captured.values?.rate).toBe(-0.25)
|
|
96
|
+
await user.clear(rate)
|
|
97
|
+
await user.type(rate, "90")
|
|
98
|
+
await user.tab()
|
|
99
|
+
expect(captured.values?.rate).toBe(0.5)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test("a year field clamps to the column's hinted bounds", async () => {
|
|
103
|
+
const user = userEvent.setup()
|
|
104
|
+
render(<Harness />)
|
|
105
|
+
await user.type(screen.getByLabelText("Built"), "1850")
|
|
106
|
+
await user.tab()
|
|
107
|
+
expect(captured.values?.built).toBe(1900)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test("a prop wins over the hint", async () => {
|
|
111
|
+
const user = userEvent.setup()
|
|
112
|
+
render(<Harness occupancyMax={0.5} />)
|
|
113
|
+
const occupancy = screen.getByLabelText("Occupancy")
|
|
114
|
+
await user.clear(occupancy)
|
|
115
|
+
await user.type(occupancy, "90")
|
|
116
|
+
await user.tab()
|
|
117
|
+
expect(captured.values?.occupancy).toBe(0.5)
|
|
118
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
4
|
+
import { getNoTextValue } from "@zeno-lib/forms/lib/empty-value"
|
|
4
5
|
import { useFieldControl } from "@zeno-lib/forms/lib/use-field-control"
|
|
5
6
|
import { Children, type ComponentProps, type ReactNode } from "react"
|
|
6
7
|
import { Input } from "@/components/ui/input"
|
|
@@ -29,10 +30,10 @@ function InputField({
|
|
|
29
30
|
required,
|
|
30
31
|
...props
|
|
31
32
|
}: InputFieldProps) {
|
|
32
|
-
const field = useFieldContext<string>()
|
|
33
|
+
const field = useFieldContext<string | null>()
|
|
33
34
|
// The label names the input through `htmlFor`, so the input needs no
|
|
34
35
|
// `aria-labelledby` and the hook doesn't get `label`.
|
|
35
|
-
const { controlProps, ...shell } = useFieldControl<string>({
|
|
36
|
+
const { controlProps, ...shell } = useFieldControl<string | null>({
|
|
36
37
|
description,
|
|
37
38
|
disabled,
|
|
38
39
|
required,
|
|
@@ -40,11 +41,12 @@ function InputField({
|
|
|
40
41
|
const hasAddons = Children.count(children) > 0
|
|
41
42
|
|
|
42
43
|
// A text input keeps the `""` the user typed, where `controlProps.onChange`
|
|
43
|
-
// would store the field's empty value
|
|
44
|
+
// would store the field's empty value, unless the schema takes `null`: then
|
|
45
|
+
// no text is `null`, as the field starts, so it saves as none.
|
|
44
46
|
const inputProps = {
|
|
45
47
|
...controlProps,
|
|
46
48
|
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
|
47
|
-
field.handleChange(event.target.value),
|
|
49
|
+
field.handleChange(event.target.value || getNoTextValue(field)),
|
|
48
50
|
value: field.state.value ?? "",
|
|
49
51
|
...props,
|
|
50
52
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
4
4
|
import { getEmptyValue } from "@zeno-lib/forms/lib/empty-value"
|
|
5
|
+
import { getFieldHints } from "@zeno-lib/forms/lib/field-hints"
|
|
5
6
|
import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
|
|
6
7
|
import { InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
|
|
7
8
|
import { InputField, type InputFieldProps } from "./input-field"
|
|
@@ -14,13 +15,16 @@ type PercentageFieldProps = Omit<
|
|
|
14
15
|
* How the form value relates to what the user types:
|
|
15
16
|
* - `"percent"` (default): the value is the number shown, `12.5` ↔ `12.5 %`.
|
|
16
17
|
* - `"fraction"`: the value is a ratio, `0.125` ↔ `12.5 %`.
|
|
18
|
+
*
|
|
19
|
+
* A `percentage()` column of `@zeno-lib/db/schema` sets it, as it sets
|
|
20
|
+
* `min` and `max`.
|
|
17
21
|
*/
|
|
18
22
|
scale?: "fraction" | "percent"
|
|
19
23
|
/** BCP 47 locale driving the decimal separator. Defaults to the runtime locale. */
|
|
20
24
|
locale?: string
|
|
21
25
|
/** Digits allowed after the decimal separator (of the shown percentage). Defaults to `2`. */
|
|
22
26
|
fractionDigits?: number
|
|
23
|
-
/** Accept negative percentages. Defaults to `
|
|
27
|
+
/** Accept negative percentages. Defaults to whether `min` is below 0. */
|
|
24
28
|
allowNegative?: boolean
|
|
25
29
|
/** Clamp on blur, in the form value's scale. */
|
|
26
30
|
min?: number
|
|
@@ -33,23 +37,27 @@ const fromPercent = (value: number) => value / 100
|
|
|
33
37
|
|
|
34
38
|
/**
|
|
35
39
|
* Percentage input with a `%` add-on. The form value is `number | null`, on
|
|
36
|
-
* the `scale` you pick
|
|
40
|
+
* the `scale` you pick, or the one the schema's `percentage()` column hints
|
|
41
|
+
* (`0–100` by default, `0–1` with `scale="fraction"`).
|
|
37
42
|
*/
|
|
38
43
|
function PercentageField({
|
|
39
|
-
allowNegative
|
|
44
|
+
allowNegative,
|
|
40
45
|
children,
|
|
41
46
|
fractionDigits = 2,
|
|
42
47
|
locale,
|
|
43
|
-
max,
|
|
44
|
-
min,
|
|
45
|
-
scale
|
|
48
|
+
max: maxProp,
|
|
49
|
+
min: minProp,
|
|
50
|
+
scale: scaleProp,
|
|
46
51
|
...props
|
|
47
52
|
}: PercentageFieldProps) {
|
|
48
53
|
const field = useFieldContext<number | null | undefined>()
|
|
49
|
-
const
|
|
54
|
+
const hints = getFieldHints(field, "percentage")
|
|
55
|
+
const max = maxProp ?? hints.max
|
|
56
|
+
const min = minProp ?? hints.min
|
|
57
|
+
const isFraction = (scaleProp ?? hints.scale ?? "percent") === "fraction"
|
|
50
58
|
|
|
51
59
|
const inputProps = useFormattedNumber({
|
|
52
|
-
allowNegative,
|
|
60
|
+
allowNegative: allowNegative ?? (min !== undefined && min < 0),
|
|
53
61
|
locale,
|
|
54
62
|
max,
|
|
55
63
|
maximumFractionDigits: fractionDigits,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
4
|
+
import { getNoTextValue } from "@zeno-lib/forms/lib/empty-value"
|
|
4
5
|
import { useFieldControl } from "@zeno-lib/forms/lib/use-field-control"
|
|
5
6
|
import { Children, type ComponentProps, type ReactNode } from "react"
|
|
6
7
|
import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group"
|
|
@@ -29,10 +30,10 @@ function TextAreaField({
|
|
|
29
30
|
required,
|
|
30
31
|
...props
|
|
31
32
|
}: TextAreaFieldProps) {
|
|
32
|
-
const field = useFieldContext<string>()
|
|
33
|
+
const field = useFieldContext<string | null>()
|
|
33
34
|
// The label names the textarea through `htmlFor`, so the textarea needs no
|
|
34
35
|
// `aria-labelledby` and the hook doesn't get `label`.
|
|
35
|
-
const { controlProps, ...shell } = useFieldControl<string>({
|
|
36
|
+
const { controlProps, ...shell } = useFieldControl<string | null>({
|
|
36
37
|
description,
|
|
37
38
|
disabled,
|
|
38
39
|
required,
|
|
@@ -40,11 +41,12 @@ function TextAreaField({
|
|
|
40
41
|
const hasAddons = Children.count(children) > 0
|
|
41
42
|
|
|
42
43
|
// A textarea keeps the `""` the user typed, where `controlProps.onChange`
|
|
43
|
-
// would store the field's empty value
|
|
44
|
+
// would store the field's empty value, unless the schema takes `null`: then
|
|
45
|
+
// no text is `null`, as the field starts, so it saves as none.
|
|
44
46
|
const textareaProps = {
|
|
45
47
|
...controlProps,
|
|
46
48
|
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) =>
|
|
47
|
-
field.handleChange(event.target.value),
|
|
49
|
+
field.handleChange(event.target.value || getNoTextValue(field)),
|
|
48
50
|
value: field.state.value ?? "",
|
|
49
51
|
...props,
|
|
50
52
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
4
4
|
import { getEmptyValue } from "@zeno-lib/forms/lib/empty-value"
|
|
5
|
+
import { getFieldHints } from "@zeno-lib/forms/lib/field-hints"
|
|
5
6
|
import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
|
|
6
7
|
import { InputField, type InputFieldProps } from "./input-field"
|
|
7
8
|
|
|
@@ -25,13 +26,17 @@ type YearFieldProps = Omit<
|
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Four-digit year input. The form value is an integer year or `null`. No
|
|
28
|
-
* thousands separator, digits only, clamped to `min`/`max
|
|
29
|
+
* thousands separator, digits only, clamped on blur to `min`/`max`, or to the
|
|
30
|
+
* bounds the schema's `year()` column hints.
|
|
29
31
|
*/
|
|
30
|
-
function YearField({ max, min, ...props }: YearFieldProps) {
|
|
32
|
+
function YearField({ max: maxProp, min: minProp, ...props }: YearFieldProps) {
|
|
31
33
|
const field = useFieldContext<number | null | undefined>()
|
|
34
|
+
const hints = getFieldHints(field, "year")
|
|
35
|
+
const max = maxProp ?? hints.max
|
|
36
|
+
const min = minProp ?? hints.min
|
|
32
37
|
|
|
33
38
|
const inputProps = useFormattedNumber({
|
|
34
|
-
allowNegative:
|
|
39
|
+
allowNegative: min !== undefined && min < 0,
|
|
35
40
|
max,
|
|
36
41
|
maxIntegerDigits: 4,
|
|
37
42
|
maximumFractionDigits: 0,
|
package/src/form-dialog.test.tsx
CHANGED
|
@@ -20,9 +20,9 @@ import type { ActionResult } from "./lib/action-result"
|
|
|
20
20
|
import { submitAction } from "./lib/submit-action"
|
|
21
21
|
import { type FormDialogController, useFormDialog } from "./lib/use-form-dialog"
|
|
22
22
|
|
|
23
|
-
const NAME = /Name/
|
|
24
|
-
const EMAIL = /Email/
|
|
25
|
-
const INNER_NAME = /Inner name/
|
|
23
|
+
const NAME = /Name/u
|
|
24
|
+
const EMAIL = /Email/u
|
|
25
|
+
const INNER_NAME = /Inner name/u
|
|
26
26
|
|
|
27
27
|
const animatable = HTMLElement.prototype as {
|
|
28
28
|
getAnimations?: () => unknown[]
|
package/src/form-error.test.tsx
CHANGED
|
@@ -11,9 +11,9 @@ import { z } from "zod"
|
|
|
11
11
|
import { Form, FormProvider, useForm } from "./create-form"
|
|
12
12
|
import { getNumberSeparators } from "./lib/formatted-number"
|
|
13
13
|
|
|
14
|
-
const AMOUNT = /Amount/
|
|
15
|
-
const RATE = /Rate/
|
|
16
|
-
const YEAR = /Year/
|
|
14
|
+
const AMOUNT = /Amount/u
|
|
15
|
+
const RATE = /Rate/u
|
|
16
|
+
const YEAR = /Year/u
|
|
17
17
|
const CH = getNumberSeparators("de-CH").group
|
|
18
18
|
|
|
19
19
|
afterEach(() => {
|
package/src/lib/date-value.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
type DateValueType = "date" | "iso-date" | "iso-datetime"
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// biome-ignore lint/style/useNamedCaptureGroup: named groups need ES2018, past the ES6 target
|
|
13
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/u
|
|
13
14
|
|
|
14
15
|
function pad(value: number, length: number): string {
|
|
15
16
|
return String(value).padStart(length, "0")
|
package/src/lib/empty-value.ts
CHANGED
|
@@ -12,8 +12,8 @@ import { getFormSchema } from "./validation-modes"
|
|
|
12
12
|
// accepts none of them is required. A cleared field holds its empty value,
|
|
13
13
|
// and so does an untouched one whose schema rejects `""` or `[]`.
|
|
14
14
|
|
|
15
|
-
const NAME_SEGMENT = /[^.[\]]+/
|
|
16
|
-
const INDEX = /^\d+$/
|
|
15
|
+
const NAME_SEGMENT = /[^.[\]]+/gu
|
|
16
|
+
const INDEX = /^\d+$/u
|
|
17
17
|
|
|
18
18
|
// `members[2].name` (or `members.2.name`) → `["members", 2, "name"]`.
|
|
19
19
|
function toPathKeys(name: string): PropertyKey[] {
|
|
@@ -171,4 +171,27 @@ function getEmptyValue<T>(field: AnyFieldApi, fallback: T): T {
|
|
|
171
171
|
) as T
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
|
|
174
|
+
/**
|
|
175
|
+
* What a text control stores once its text is cleared: `null` where the field
|
|
176
|
+
* starts at `null` (a nullable string), so it saves as none, and `""`
|
|
177
|
+
* otherwise. Read from the schema's shape rather than by validating the form,
|
|
178
|
+
* so the other fields' values never change it.
|
|
179
|
+
*/
|
|
180
|
+
function getNoTextValue(field: AnyFieldApi): string | null {
|
|
181
|
+
const schema = getFormSchema(field.form)
|
|
182
|
+
const start = schema
|
|
183
|
+
? extractZodDefaultAt(
|
|
184
|
+
schema as Parameters<typeof extractZodDefaultAt>[0],
|
|
185
|
+
toPathKeys(field.name)
|
|
186
|
+
)
|
|
187
|
+
: { ok: false as const }
|
|
188
|
+
return start.ok && start.value === null ? null : ""
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export {
|
|
192
|
+
emptyValueFor,
|
|
193
|
+
getEmptyValue,
|
|
194
|
+
getNoTextValue,
|
|
195
|
+
toPathKeys,
|
|
196
|
+
withEmptyStartValues,
|
|
197
|
+
}
|