@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.
Files changed (84) hide show
  1. package/dist/create-zeno-form.d.mts +1 -2
  2. package/dist/create-zeno-form.mjs +1 -2
  3. package/dist/form-element.d.mts +0 -2
  4. package/dist/form-element.mjs +1 -2
  5. package/dist/lib/action-result.d.mts +0 -2
  6. package/dist/lib/action-result.mjs +1 -2
  7. package/dist/lib/apply-validation-error.d.mts +0 -2
  8. package/dist/lib/apply-validation-error.mjs +1 -2
  9. package/dist/lib/aria.d.mts +1 -3
  10. package/dist/lib/aria.mjs +1 -2
  11. package/dist/lib/contexts.d.mts +0 -2
  12. package/dist/lib/contexts.mjs +1 -2
  13. package/dist/lib/date-value.d.mts +0 -2
  14. package/dist/lib/date-value.mjs +2 -3
  15. package/dist/lib/default-props.d.mts +1 -3
  16. package/dist/lib/default-props.mjs +1 -2
  17. package/dist/lib/empty-value.d.mts +9 -3
  18. package/dist/lib/empty-value.mjs +15 -5
  19. package/dist/lib/field-hints.d.mts +16 -0
  20. package/dist/lib/field-hints.mjs +23 -0
  21. package/dist/lib/focus-first-invalid.d.mts +0 -2
  22. package/dist/lib/focus-first-invalid.mjs +1 -2
  23. package/dist/lib/form-disabled.d.mts +0 -2
  24. package/dist/lib/form-disabled.mjs +1 -2
  25. package/dist/lib/formatted-number.d.mts +0 -2
  26. package/dist/lib/formatted-number.mjs +10 -11
  27. package/dist/lib/item-options.d.mts +0 -2
  28. package/dist/lib/item-options.mjs +1 -2
  29. package/dist/lib/schema-defaults.d.mts +6 -3
  30. package/dist/lib/schema-defaults.mjs +37 -5
  31. package/dist/lib/schema-required.d.mts +0 -2
  32. package/dist/lib/schema-required.mjs +3 -4
  33. package/dist/lib/submit-action.d.mts +0 -2
  34. package/dist/lib/submit-action.mjs +1 -2
  35. package/dist/lib/submit-error.d.mts +0 -2
  36. package/dist/lib/submit-error.mjs +1 -2
  37. package/dist/lib/use-field-control.d.mts +0 -2
  38. package/dist/lib/use-field-control.mjs +1 -2
  39. package/dist/lib/use-form-dialog.d.mts +0 -2
  40. package/dist/lib/use-form-dialog.mjs +2 -3
  41. package/dist/lib/use-formatted-number.d.mts +0 -2
  42. package/dist/lib/use-formatted-number.mjs +1 -2
  43. package/dist/lib/use-is-invalid.d.mts +0 -2
  44. package/dist/lib/use-is-invalid.mjs +1 -2
  45. package/dist/lib/use-rebased-default-values.d.mts +0 -2
  46. package/dist/lib/use-rebased-default-values.mjs +1 -2
  47. package/dist/lib/use-unsaved-changes-warning.d.mts +0 -2
  48. package/dist/lib/use-unsaved-changes-warning.mjs +1 -2
  49. package/dist/lib/validation-error.d.mts +0 -2
  50. package/dist/lib/validation-error.mjs +1 -2
  51. package/dist/lib/validation-logic.d.mts +0 -2
  52. package/dist/lib/validation-logic.mjs +1 -2
  53. package/dist/lib/validation-modes.d.mts +0 -2
  54. package/dist/lib/validation-modes.mjs +1 -2
  55. package/package.json +8 -8
  56. package/src/composition.test.tsx +3 -3
  57. package/src/control-field.test.tsx +1 -1
  58. package/src/date-picker-field.test.tsx +3 -3
  59. package/src/field-hints.test.tsx +118 -0
  60. package/src/fields/input-field.tsx +6 -4
  61. package/src/fields/percentage-field.tsx +16 -8
  62. package/src/fields/textarea-field.tsx +6 -4
  63. package/src/fields/year-field.tsx +8 -3
  64. package/src/form-dialog.test.tsx +3 -3
  65. package/src/form-error.test.tsx +1 -1
  66. package/src/formatted-number-fields.test.tsx +3 -3
  67. package/src/lib/date-value.ts +2 -1
  68. package/src/lib/empty-value.ts +26 -3
  69. package/src/lib/field-hints.ts +64 -0
  70. package/src/lib/formatted-number.ts +9 -9
  71. package/src/lib/schema-defaults.test.ts +6 -3
  72. package/src/lib/schema-defaults.ts +60 -3
  73. package/src/lib/schema-required.ts +2 -2
  74. package/src/lib/use-form-dialog.ts +1 -1
  75. package/src/multi-value-fields.test.tsx +2 -2
  76. package/src/nullable-text.test.tsx +80 -0
  77. package/src/submit-action.test.tsx +4 -4
  78. package/src/use-app-fields.test.tsx +10 -10
  79. package/src/use-form.cascade.test.tsx +2 -2
  80. package/src/use-form.reset-defaults.test.tsx +2 -2
  81. package/src/use-form.test.tsx +3 -3
  82. package/src/use-form.unsaved-warning.test.tsx +1 -1
  83. package/src/use-form.validation-modes.test.tsx +2 -2
  84. 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
- //#region src/lib/use-form-dialog.ts
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(/["\\]/g, "\\$&")}"]`;
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
- //#region src/lib/use-formatted-number.ts
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
- //#region src/lib/use-is-invalid.ts
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
- //#region src/lib/use-rebased-default-values.ts
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
- //#region src/lib/use-unsaved-changes-warning.ts
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
- //#region src/lib/validation-error.ts
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,3 @@
1
1
  import { ValidationLogicFn } from "@tanstack/react-form";
2
- //#region src/lib/validation-logic.d.ts
3
2
  declare const blurThenChangeLogic: ValidationLogicFn;
4
- //#endregion
5
3
  export { blurThenChangeLogic };
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- //#region src/lib/validation-logic.ts
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
- //#region src/lib/validation-modes.ts
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.9.1",
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.4",
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.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.52.0",
43
- "tsdown": "^0.23.0",
42
+ "lucide-react": "^1.62.0",
43
+ "tsdown": "^0.23.1",
44
44
  "typescript": "7.0.2",
45
- "vite": "8.3.3",
46
- "vitest": "5.0.3",
47
- "zod": "^4.6.5"
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",
@@ -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,7 +9,7 @@ afterEach(() => {
9
9
  cleanup()
10
10
  })
11
11
 
12
- const PHONE = /Phone/
12
+ const PHONE = /Phone/u
13
13
  const SUBMIT = { name: "Submit" }
14
14
 
15
15
  // A control a `<label htmlFor>` can't name: only `aria-labelledby` does.
@@ -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 `false`. */
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 (`0–100` by default, `0–1` with `scale="fraction"`).
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 = false,
44
+ allowNegative,
40
45
  children,
41
46
  fractionDigits = 2,
42
47
  locale,
43
- max,
44
- min,
45
- scale = "percent",
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 isFraction = scale === "fraction"
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` on blur.
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: false,
39
+ allowNegative: min !== undefined && min < 0,
35
40
  max,
36
41
  maxIntegerDigits: 4,
37
42
  maximumFractionDigits: 0,
@@ -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[]
@@ -17,7 +17,7 @@ afterEach(() => {
17
17
  vi.unstubAllGlobals()
18
18
  })
19
19
 
20
- const NAME = /Name/
20
+ const NAME = /Name/u
21
21
  const SUBMIT = { name: "Submit" }
22
22
 
23
23
  const schema = z.object({ name: z.string().min(1, "Required") })
@@ -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(() => {
@@ -9,7 +9,8 @@
9
9
  */
10
10
  type DateValueType = "date" | "iso-date" | "iso-datetime"
11
11
 
12
- const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/
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")
@@ -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 = /[^.[\]]+/g
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
- export { emptyValueFor, getEmptyValue, withEmptyStartValues }
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
+ }