@zeno-lib/forms 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/create-zeno-form.mjs +9 -3
  2. package/dist/form-element.mjs +7 -5
  3. package/dist/index.d.mts +2 -2
  4. package/dist/index.mjs +3 -3
  5. package/dist/lib/empty-value.d.mts +12 -0
  6. package/dist/lib/empty-value.mjs +68 -0
  7. package/dist/lib/focus-first-invalid.d.mts +23 -0
  8. package/dist/lib/focus-first-invalid.mjs +37 -0
  9. package/dist/lib/schema-defaults.d.mts +9 -1
  10. package/dist/lib/schema-defaults.mjs +33 -1
  11. package/dist/lib/schema-required.d.mts +2 -1
  12. package/dist/lib/schema-required.mjs +49 -21
  13. package/dist/lib/submit-action.mjs +1 -1
  14. package/dist/lib/submit-error.d.mts +36 -0
  15. package/dist/lib/submit-error.mjs +66 -0
  16. package/dist/lib/use-form-dialog.d.mts +15 -1
  17. package/dist/lib/use-form-dialog.mjs +20 -2
  18. package/dist/lib/use-is-invalid.d.mts +2 -1
  19. package/dist/lib/use-is-invalid.mjs +5 -2
  20. package/dist/lib/validation-modes.d.mts +5 -1
  21. package/dist/lib/validation-modes.mjs +9 -4
  22. package/package.json +1 -1
  23. package/src/create-form.tsx +2 -0
  24. package/src/create-zeno-form.tsx +25 -5
  25. package/src/fields/checkbox-field.tsx +8 -1
  26. package/src/fields/combobox-field.tsx +29 -12
  27. package/src/fields/date-picker-field.tsx +37 -9
  28. package/src/fields/email-field.tsx +8 -0
  29. package/src/fields/form-error.tsx +61 -0
  30. package/src/fields/index.ts +1 -0
  31. package/src/fields/input-field.tsx +6 -2
  32. package/src/fields/money-field.tsx +3 -1
  33. package/src/fields/multi-select-field.tsx +8 -1
  34. package/src/fields/number-field.tsx +9 -7
  35. package/src/fields/otp-field.tsx +8 -1
  36. package/src/fields/percentage-field.tsx +3 -1
  37. package/src/fields/radio-group-field.tsx +11 -4
  38. package/src/fields/select-field.tsx +26 -3
  39. package/src/fields/switch-field.tsx +8 -1
  40. package/src/fields/textarea-field.tsx +8 -1
  41. package/src/fields/year-field.tsx +3 -1
  42. package/src/form-dialog.test.tsx +493 -2
  43. package/src/form-dialog.tsx +217 -33
  44. package/src/form-element.tsx +13 -11
  45. package/src/form-error.test.tsx +255 -0
  46. package/src/index.ts +1 -0
  47. package/src/lib/empty-value.test.ts +72 -0
  48. package/src/lib/empty-value.ts +130 -0
  49. package/src/lib/focus-first-invalid.test.ts +22 -0
  50. package/src/lib/focus-first-invalid.ts +52 -0
  51. package/src/lib/schema-defaults.ts +50 -1
  52. package/src/lib/schema-required.test.ts +24 -2
  53. package/src/lib/schema-required.ts +81 -43
  54. package/src/lib/submit-error.ts +133 -0
  55. package/src/lib/use-form-dialog.ts +49 -1
  56. package/src/lib/use-is-invalid.ts +14 -1
  57. package/src/lib/validation-modes.ts +18 -6
  58. package/src/use-app-fields.test.tsx +312 -2
  59. package/src/use-form.focus-invalid.test.tsx +34 -0
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
4
+ import { getEmptyValue } from "@zeno-lib/forms/lib/empty-value"
4
5
  import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
5
6
  import { InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
6
7
  import { InputField, type InputFieldProps } from "./input-field"
@@ -54,7 +55,8 @@ function PercentageField({
54
55
  maximumFractionDigits: fractionDigits,
55
56
  min,
56
57
  onBlur: field.handleBlur,
57
- onValueChange: (next) => field.handleChange(next),
58
+ onValueChange: (next) =>
59
+ field.handleChange(next ?? getEmptyValue(field, null)),
58
60
  value: field.state.value,
59
61
  ...(isFraction ? { fromDisplay: fromPercent, toDisplay: toPercent } : {}),
60
62
  })
@@ -5,6 +5,7 @@ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
5
  import {
6
6
  useHideFieldErrors,
7
7
  useIsFieldRequired,
8
+ useIsFieldRequiredBySchema,
8
9
  useIsInvalid,
9
10
  } from "@zeno-lib/forms/lib/use-is-invalid"
10
11
  import { type ComponentProps, type ReactNode, useId } from "react"
@@ -24,7 +25,10 @@ type RadioGroupFieldProps = Omit<
24
25
  > & {
25
26
  description?: ReactNode
26
27
  label?: ReactNode
27
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
28
+ /**
29
+ * Mark the field required, or not, over the schema. Drives the `*` and
30
+ * `aria-required`.
31
+ */
28
32
  required?: boolean
29
33
  }
30
34
 
@@ -35,7 +39,7 @@ function RadioGroupField({
35
39
  required,
36
40
  ...props
37
41
  }: RadioGroupFieldProps) {
38
- const field = useFieldContext<string>()
42
+ const field = useFieldContext()
39
43
  const id = useId()
40
44
  const errorId = `${id}-error`
41
45
  const descriptionId = `${id}-description`
@@ -44,6 +48,8 @@ function RadioGroupField({
44
48
  const showError = isInvalid && !hideErrors
45
49
  const schemaRequired = useIsFieldRequired(field)
46
50
  const isRequired = required ?? schemaRequired
51
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
52
+ const requiresValue = required ?? schemaRequiresValue
47
53
 
48
54
  return (
49
55
  <Field data-field={field.name} data-invalid={isInvalid}>
@@ -59,9 +65,10 @@ function RadioGroupField({
59
65
  [showError, errorId]
60
66
  )}
61
67
  aria-invalid={isInvalid || undefined}
68
+ aria-required={requiresValue || undefined}
62
69
  name={field.name}
63
- onValueChange={(value) => field.handleChange(String(value))}
64
- value={field.state.value ?? ""}
70
+ onValueChange={(value) => field.handleChange(value)}
71
+ value={field.state.value ?? null}
65
72
  {...props}
66
73
  >
67
74
  {children}
@@ -5,10 +5,11 @@ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
5
  import {
6
6
  useHideFieldErrors,
7
7
  useIsFieldRequired,
8
+ useIsFieldRequiredBySchema,
8
9
  useIsInvalid,
9
10
  } from "@zeno-lib/forms/lib/use-is-invalid"
10
11
  import type { ComponentProps, ReactNode } from "react"
11
- import { Children, isValidElement, useId, useMemo } from "react"
12
+ import { Children, isValidElement, useId, useMemo, useRef } from "react"
12
13
  import {
13
14
  Field,
14
15
  FieldDescription,
@@ -31,7 +32,10 @@ type SelectFieldProps = Omit<
31
32
  description?: ReactNode
32
33
  label?: ReactNode
33
34
  placeholder?: string
34
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
35
+ /**
36
+ * Mark the field required, or not, over the schema. Drives the `*` and
37
+ * `aria-required`.
38
+ */
35
39
  required?: boolean
36
40
  triggerClassName?: string
37
41
  triggerSize?: ComponentProps<typeof SelectTrigger>["size"]
@@ -41,6 +45,7 @@ function SelectField({
41
45
  children,
42
46
  description,
43
47
  label,
48
+ onOpenChange,
44
49
  placeholder,
45
50
  required,
46
51
  triggerClassName,
@@ -48,6 +53,10 @@ function SelectField({
48
53
  ...props
49
54
  }: SelectFieldProps) {
50
55
  const field = useFieldContext()
56
+ // Opening the popup moves focus into it, so the trigger's blur would mark
57
+ // the field touched, and show a required error, before anything is picked.
58
+ // The field is touched once the popup closes, or focus leaves it closed.
59
+ const isOpen = useRef<boolean>(false)
51
60
  const id = useId()
52
61
  const errorId = `${id}-error`
53
62
  const descriptionId = `${id}-description`
@@ -56,6 +65,8 @@ function SelectField({
56
65
  const showError = isInvalid && !hideErrors
57
66
  const schemaRequired = useIsFieldRequired(field)
58
67
  const isRequired = required ?? schemaRequired
68
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
69
+ const requiresValue = required ?? schemaRequiresValue
59
70
 
60
71
  const items = useMemo(() => {
61
72
  const map: Record<string, ReactNode> = {}
@@ -81,6 +92,13 @@ function SelectField({
81
92
  <Select
82
93
  items={items}
83
94
  name={field.name}
95
+ onOpenChange={(open, eventDetails) => {
96
+ isOpen.current = open
97
+ if (!open) {
98
+ field.handleBlur()
99
+ }
100
+ onOpenChange?.(open, eventDetails)
101
+ }}
84
102
  onValueChange={(value) => field.handleChange(value)}
85
103
  value={field.state.value ?? null}
86
104
  {...props}
@@ -91,9 +109,14 @@ function SelectField({
91
109
  [showError, errorId]
92
110
  )}
93
111
  aria-invalid={isInvalid || undefined}
112
+ aria-required={requiresValue || undefined}
94
113
  className={triggerClassName}
95
114
  id={id}
96
- onBlur={field.handleBlur}
115
+ onBlur={() => {
116
+ if (!isOpen.current) {
117
+ field.handleBlur()
118
+ }
119
+ }}
97
120
  size={triggerSize}
98
121
  >
99
122
  <SelectValue placeholder={placeholder} />
@@ -5,6 +5,7 @@ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
5
  import {
6
6
  useHideFieldErrors,
7
7
  useIsFieldRequired,
8
+ useIsFieldRequiredBySchema,
8
9
  useIsInvalid,
9
10
  } from "@zeno-lib/forms/lib/use-is-invalid"
10
11
  import { type ComponentProps, type ReactNode, useId } from "react"
@@ -24,7 +25,10 @@ type SwitchFieldProps = Omit<
24
25
  > & {
25
26
  description?: ReactNode
26
27
  label?: ReactNode
27
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
28
+ /**
29
+ * Mark the field required, or not, over the schema. Drives the `*` and
30
+ * `aria-required`.
31
+ */
28
32
  required?: boolean
29
33
  }
30
34
 
@@ -43,6 +47,8 @@ function SwitchField({
43
47
  const showError = isInvalid && !hideErrors
44
48
  const schemaRequired = useIsFieldRequired(field)
45
49
  const isRequired = required ?? schemaRequired
50
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
51
+ const requiresValue = required ?? schemaRequiresValue
46
52
 
47
53
  return (
48
54
  <Field
@@ -70,6 +76,7 @@ function SwitchField({
70
76
  [showError, errorId]
71
77
  )}
72
78
  aria-invalid={isInvalid || undefined}
79
+ aria-required={requiresValue || undefined}
73
80
  checked={field.state.value ?? false}
74
81
  id={id}
75
82
  name={field.name}
@@ -5,6 +5,7 @@ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
5
  import {
6
6
  useHideFieldErrors,
7
7
  useIsFieldRequired,
8
+ useIsFieldRequiredBySchema,
8
9
  useIsInvalid,
9
10
  } from "@zeno-lib/forms/lib/use-is-invalid"
10
11
  import { Children, type ComponentProps, type ReactNode, useId } from "react"
@@ -25,7 +26,10 @@ type TextAreaFieldProps = Omit<
25
26
  children?: ReactNode
26
27
  description?: ReactNode
27
28
  label?: ReactNode
28
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
29
+ /**
30
+ * Mark the field required, or not, over the schema. Drives the `*` and
31
+ * `aria-required`.
32
+ */
29
33
  required?: boolean
30
34
  }
31
35
 
@@ -46,6 +50,8 @@ function TextAreaField({
46
50
  const hasAddons = Children.count(children) > 0
47
51
  const schemaRequired = useIsFieldRequired(field)
48
52
  const isRequired = required ?? schemaRequired
53
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
54
+ const requiresValue = required ?? schemaRequiresValue
49
55
 
50
56
  const textareaProps = {
51
57
  "aria-describedby": describedBy(
@@ -53,6 +59,7 @@ function TextAreaField({
53
59
  [showError, errorId]
54
60
  ),
55
61
  "aria-invalid": isInvalid || undefined,
62
+ "aria-required": requiresValue || undefined,
56
63
  id,
57
64
  name: field.name,
58
65
  onBlur: field.handleBlur,
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
4
+ import { getEmptyValue } from "@zeno-lib/forms/lib/empty-value"
4
5
  import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
5
6
  import { InputField, type InputFieldProps } from "./input-field"
6
7
 
@@ -36,7 +37,8 @@ function YearField({ max, min, ...props }: YearFieldProps) {
36
37
  maximumFractionDigits: 0,
37
38
  min,
38
39
  onBlur: field.handleBlur,
39
- onValueChange: (next) => field.handleChange(next),
40
+ onValueChange: (next) =>
41
+ field.handleChange(next ?? getEmptyValue(field, null)),
40
42
  useGrouping: false,
41
43
  value: field.state.value,
42
44
  })