@zeno-lib/forms 0.0.1 → 0.2.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/package.json +23 -11
- package/src/addons/validation-spinner.tsx +3 -4
- package/src/create-form.tsx +59 -0
- package/src/create-zeno-form.tsx +531 -0
- package/src/fields/checkbox-field.tsx +15 -12
- package/src/fields/checkbox-group-field.tsx +160 -0
- package/src/fields/combobox-field.tsx +13 -14
- package/src/fields/date-picker-field.tsx +18 -15
- package/src/fields/index.ts +5 -0
- package/src/fields/input-field.tsx +12 -13
- package/src/fields/money-field.tsx +123 -0
- package/src/fields/multi-select-field.tsx +193 -0
- package/src/fields/number-field.tsx +1 -2
- package/src/fields/otp-field.tsx +15 -12
- package/src/fields/percentage-field.tsx +73 -0
- package/src/fields/radio-group-field.tsx +12 -13
- package/src/fields/reset-button.tsx +2 -3
- package/src/fields/select-field.tsx +12 -13
- package/src/fields/slider-field.tsx +11 -12
- package/src/fields/submit-button.tsx +3 -4
- package/src/fields/switch-field.tsx +15 -12
- package/src/fields/textarea-field.tsx +12 -13
- package/src/fields/year-field.tsx +56 -0
- package/src/form-dialog.test.tsx +161 -0
- package/src/form-dialog.tsx +253 -0
- package/src/form-element.tsx +51 -0
- package/src/formatted-number-fields.test.tsx +236 -0
- package/src/index.ts +27 -0
- package/src/lib/action-result.ts +61 -0
- package/src/lib/apply-validation-error.test.ts +16 -6
- package/src/lib/apply-validation-error.ts +38 -6
- package/src/lib/formatted-number.test.ts +157 -0
- package/src/lib/formatted-number.ts +307 -0
- package/src/lib/required-indicator.tsx +1 -1
- package/src/lib/schema-defaults.test.ts +1 -2
- package/src/lib/schema-required.test.ts +98 -6
- package/src/lib/schema-required.ts +126 -22
- package/src/lib/submit-action.test-d.ts +83 -0
- package/src/lib/submit-action.ts +178 -0
- package/src/lib/use-form-dialog.ts +184 -0
- package/src/lib/use-formatted-number.ts +235 -0
- package/src/lib/use-is-invalid.ts +4 -4
- package/src/lib/use-rebased-default-values.ts +42 -0
- package/src/lib/use-unsaved-changes-warning.ts +3 -3
- package/src/lib/validation-error.test.ts +1 -1
- package/src/lib/validation-logic.test.ts +1 -1
- package/src/lib/validation-modes.test-d.ts +1 -1
- package/src/lib/validation-modes.test.ts +12 -2
- package/src/lib/validation-modes.ts +3 -1
- package/src/multi-value-fields.test.tsx +116 -0
- package/src/submit-action.test.tsx +421 -0
- package/src/use-app-fields.test-d.ts +8 -25
- package/src/use-app-fields.test.tsx +8 -32
- package/src/use-form.cascade.test.tsx +4 -6
- package/src/use-form.focus-invalid.test.tsx +198 -0
- package/src/use-form.reset-defaults.test.tsx +125 -0
- package/src/use-form.test-d.ts +2 -3
- package/src/use-form.test.tsx +12 -14
- package/src/use-form.unsaved-warning.test.tsx +4 -13
- package/src/use-form.validation-modes.test.tsx +10 -8
- package/src/form.tsx +0 -105
- package/src/use-app-fields.tsx +0 -272
- package/src/use-form.tsx +0 -450
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
+
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
3
4
|
import type { ChangeEvent } from "react"
|
|
4
|
-
|
|
5
|
-
import { useFieldContext } from "../lib/contexts"
|
|
6
5
|
import { InputField, type InputFieldProps } from "./input-field"
|
|
7
6
|
|
|
8
7
|
type NumberFieldProps = Omit<
|
package/src/fields/otp-field.tsx
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
+
import { describedBy } from "@zeno-lib/forms/lib/aria"
|
|
4
|
+
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
5
|
+
import {
|
|
6
|
+
useHideFieldErrors,
|
|
7
|
+
useIsFieldRequired,
|
|
8
|
+
useIsInvalid,
|
|
9
|
+
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
+
import type { ReactNode } from "react"
|
|
3
11
|
import {
|
|
4
12
|
Field,
|
|
5
13
|
FieldDescription,
|
|
6
14
|
FieldError,
|
|
7
15
|
FieldLabel,
|
|
8
|
-
} from "
|
|
9
|
-
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@zeno-lib/ui/input-otp"
|
|
10
|
-
import type { ReactNode } from "react"
|
|
11
|
-
|
|
12
|
-
import { describedBy } from "../lib/aria"
|
|
13
|
-
import { useFieldContext } from "../lib/contexts"
|
|
14
|
-
import { RequiredIndicator } from "../lib/required-indicator"
|
|
16
|
+
} from "@/components/ui/field"
|
|
15
17
|
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
} from "
|
|
18
|
+
InputOTP,
|
|
19
|
+
InputOTPGroup,
|
|
20
|
+
InputOTPSlot,
|
|
21
|
+
} from "@/components/ui/input-otp"
|
|
22
|
+
import { RequiredIndicator } from "../lib/required-indicator"
|
|
20
23
|
|
|
21
24
|
// Flat surface — `OTPInput`'s prop type is a discriminated union over
|
|
22
25
|
// `render`/`children`, which doesn't compose cleanly with `Omit`. Listing the
|
|
@@ -63,7 +66,7 @@ function OtpField({
|
|
|
63
66
|
const isRequired = required ?? schemaRequired
|
|
64
67
|
|
|
65
68
|
return (
|
|
66
|
-
<Field data-invalid={isInvalid}>
|
|
69
|
+
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
67
70
|
{label && (
|
|
68
71
|
<FieldLabel htmlFor={field.name}>
|
|
69
72
|
{label}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
4
|
+
import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
|
|
5
|
+
import { InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
|
|
6
|
+
import { InputField, type InputFieldProps } from "./input-field"
|
|
7
|
+
|
|
8
|
+
type PercentageFieldProps = Omit<
|
|
9
|
+
InputFieldProps,
|
|
10
|
+
"defaultValue" | "inputMode" | "onChange" | "onPaste" | "type" | "value"
|
|
11
|
+
> & {
|
|
12
|
+
/**
|
|
13
|
+
* How the form value relates to what the user types:
|
|
14
|
+
* - `"percent"` (default): the value is the number shown, `12.5` ↔ `12.5 %`.
|
|
15
|
+
* - `"fraction"`: the value is a ratio, `0.125` ↔ `12.5 %`.
|
|
16
|
+
*/
|
|
17
|
+
scale?: "fraction" | "percent"
|
|
18
|
+
/** BCP 47 locale driving the decimal separator. Defaults to the runtime locale. */
|
|
19
|
+
locale?: string
|
|
20
|
+
/** Digits allowed after the decimal separator (of the shown percentage). Defaults to `2`. */
|
|
21
|
+
fractionDigits?: number
|
|
22
|
+
/** Accept negative percentages. Defaults to `false`. */
|
|
23
|
+
allowNegative?: boolean
|
|
24
|
+
/** Clamp on blur, in the form value's scale. */
|
|
25
|
+
min?: number
|
|
26
|
+
/** Clamp on blur, in the form value's scale. */
|
|
27
|
+
max?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const toPercent = (value: number) => value * 100
|
|
31
|
+
const fromPercent = (value: number) => value / 100
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Percentage input with a `%` add-on. The form value is `number | null`, on
|
|
35
|
+
* the `scale` you pick (`0–100` by default, `0–1` with `scale="fraction"`).
|
|
36
|
+
*/
|
|
37
|
+
function PercentageField({
|
|
38
|
+
allowNegative = false,
|
|
39
|
+
children,
|
|
40
|
+
fractionDigits = 2,
|
|
41
|
+
locale,
|
|
42
|
+
max,
|
|
43
|
+
min,
|
|
44
|
+
scale = "percent",
|
|
45
|
+
...props
|
|
46
|
+
}: PercentageFieldProps) {
|
|
47
|
+
const field = useFieldContext<number | null | undefined>()
|
|
48
|
+
const isFraction = scale === "fraction"
|
|
49
|
+
|
|
50
|
+
const inputProps = useFormattedNumber({
|
|
51
|
+
allowNegative,
|
|
52
|
+
locale,
|
|
53
|
+
max,
|
|
54
|
+
maximumFractionDigits: fractionDigits,
|
|
55
|
+
min,
|
|
56
|
+
onBlur: field.handleBlur,
|
|
57
|
+
onValueChange: (next) => field.handleChange(next),
|
|
58
|
+
value: field.state.value,
|
|
59
|
+
...(isFraction ? { fromDisplay: fromPercent, toDisplay: toPercent } : {}),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<InputField {...props} {...inputProps}>
|
|
64
|
+
<InputGroupAddon align="inline-end">
|
|
65
|
+
<InputGroupText>%</InputGroupText>
|
|
66
|
+
</InputGroupAddon>
|
|
67
|
+
{children}
|
|
68
|
+
</InputField>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type { PercentageFieldProps }
|
|
73
|
+
export { PercentageField }
|
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
+
import { describedBy } from "@zeno-lib/forms/lib/aria"
|
|
4
|
+
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
5
|
+
import {
|
|
6
|
+
useHideFieldErrors,
|
|
7
|
+
useIsFieldRequired,
|
|
8
|
+
useIsInvalid,
|
|
9
|
+
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
+
import { type ComponentProps, type ReactNode, useId } from "react"
|
|
3
11
|
import {
|
|
4
12
|
Field,
|
|
5
13
|
FieldDescription,
|
|
6
14
|
FieldError,
|
|
7
15
|
FieldLabel,
|
|
8
|
-
} from "
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { type ComponentProps, type ReactNode, useId } from "react"
|
|
12
|
-
|
|
13
|
-
import { describedBy } from "../lib/aria"
|
|
14
|
-
import { useFieldContext } from "../lib/contexts"
|
|
16
|
+
} from "@/components/ui/field"
|
|
17
|
+
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
|
18
|
+
import { cn } from "@/lib/utils"
|
|
15
19
|
import { RequiredIndicator } from "../lib/required-indicator"
|
|
16
|
-
import {
|
|
17
|
-
useHideFieldErrors,
|
|
18
|
-
useIsFieldRequired,
|
|
19
|
-
useIsInvalid,
|
|
20
|
-
} from "../lib/use-is-invalid"
|
|
21
20
|
|
|
22
21
|
type RadioGroupFieldProps = Omit<
|
|
23
22
|
ComponentProps<typeof RadioGroup>,
|
|
@@ -46,7 +45,7 @@ function RadioGroupField({
|
|
|
46
45
|
const isRequired = required ?? schemaRequired
|
|
47
46
|
|
|
48
47
|
return (
|
|
49
|
-
<Field data-invalid={isInvalid}>
|
|
48
|
+
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
50
49
|
{label && (
|
|
51
50
|
<FieldLabel>
|
|
52
51
|
{label}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useFormContext } from "@zeno-lib/forms/lib/contexts"
|
|
4
4
|
import type { ComponentProps, ReactNode } from "react"
|
|
5
|
-
|
|
6
|
-
import { useFormContext } from "../lib/contexts"
|
|
5
|
+
import { Button } from "@/components/ui/button"
|
|
7
6
|
|
|
8
7
|
type ResetButtonProps = Omit<ComponentProps<typeof Button>, "onClick" | "type">
|
|
9
8
|
|
|
@@ -1,28 +1,27 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
+
import { describedBy } from "@zeno-lib/forms/lib/aria"
|
|
4
|
+
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
5
|
+
import {
|
|
6
|
+
useHideFieldErrors,
|
|
7
|
+
useIsFieldRequired,
|
|
8
|
+
useIsInvalid,
|
|
9
|
+
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
+
import type { ComponentProps, ReactNode } from "react"
|
|
11
|
+
import { Children, isValidElement, useMemo } from "react"
|
|
3
12
|
import {
|
|
4
13
|
Field,
|
|
5
14
|
FieldDescription,
|
|
6
15
|
FieldError,
|
|
7
16
|
FieldLabel,
|
|
8
|
-
} from "
|
|
17
|
+
} from "@/components/ui/field"
|
|
9
18
|
import {
|
|
10
19
|
Select,
|
|
11
20
|
SelectContent,
|
|
12
21
|
SelectTrigger,
|
|
13
22
|
SelectValue,
|
|
14
|
-
} from "
|
|
15
|
-
import type { ComponentProps, ReactNode } from "react"
|
|
16
|
-
import { Children, isValidElement, useMemo } from "react"
|
|
17
|
-
|
|
18
|
-
import { describedBy } from "../lib/aria"
|
|
19
|
-
import { useFieldContext } from "../lib/contexts"
|
|
23
|
+
} from "@/components/ui/select"
|
|
20
24
|
import { RequiredIndicator } from "../lib/required-indicator"
|
|
21
|
-
import {
|
|
22
|
-
useHideFieldErrors,
|
|
23
|
-
useIsFieldRequired,
|
|
24
|
-
useIsInvalid,
|
|
25
|
-
} from "../lib/use-is-invalid"
|
|
26
25
|
|
|
27
26
|
type SelectFieldProps = Omit<
|
|
28
27
|
ComponentProps<typeof Select>,
|
|
@@ -71,7 +70,7 @@ function SelectField({
|
|
|
71
70
|
}, [children])
|
|
72
71
|
|
|
73
72
|
return (
|
|
74
|
-
<Field data-invalid={isInvalid}>
|
|
73
|
+
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
75
74
|
{label && (
|
|
76
75
|
<FieldLabel htmlFor={field.name}>
|
|
77
76
|
{label}
|
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
+
import { describedBy } from "@zeno-lib/forms/lib/aria"
|
|
4
|
+
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
5
|
+
import {
|
|
6
|
+
useHideFieldErrors,
|
|
7
|
+
useIsFieldRequired,
|
|
8
|
+
useIsInvalid,
|
|
9
|
+
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
+
import type { ComponentProps, ReactNode } from "react"
|
|
3
11
|
import {
|
|
4
12
|
Field,
|
|
5
13
|
FieldContent,
|
|
6
14
|
FieldDescription,
|
|
7
15
|
FieldError,
|
|
8
16
|
FieldLabel,
|
|
9
|
-
} from "
|
|
10
|
-
import { Slider } from "
|
|
11
|
-
import type { ComponentProps, ReactNode } from "react"
|
|
12
|
-
|
|
13
|
-
import { describedBy } from "../lib/aria"
|
|
14
|
-
import { useFieldContext } from "../lib/contexts"
|
|
17
|
+
} from "@/components/ui/field"
|
|
18
|
+
import { Slider } from "@/components/ui/slider"
|
|
15
19
|
import { RequiredIndicator } from "../lib/required-indicator"
|
|
16
|
-
import {
|
|
17
|
-
useHideFieldErrors,
|
|
18
|
-
useIsFieldRequired,
|
|
19
|
-
useIsInvalid,
|
|
20
|
-
} from "../lib/use-is-invalid"
|
|
21
20
|
|
|
22
21
|
type SliderValue = number | number[]
|
|
23
22
|
|
|
@@ -56,7 +55,7 @@ function SliderField({
|
|
|
56
55
|
const readout = formatValue && value !== undefined ? formatValue(value) : null
|
|
57
56
|
|
|
58
57
|
return (
|
|
59
|
-
<Field data-invalid={isInvalid}>
|
|
58
|
+
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
60
59
|
{(label || readout) && (
|
|
61
60
|
<FieldContent className="flex-row items-center justify-between">
|
|
62
61
|
{label && (
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { Spinner } from "@zeno-lib/ui/spinner"
|
|
3
|
+
import { useFormContext } from "@zeno-lib/forms/lib/contexts"
|
|
5
4
|
import type { ComponentProps } from "react"
|
|
6
|
-
|
|
7
|
-
import {
|
|
5
|
+
import { Button } from "@/components/ui/button"
|
|
6
|
+
import { Spinner } from "@/components/ui/spinner"
|
|
8
7
|
|
|
9
8
|
type SubmitButtonProps = Omit<ComponentProps<typeof Button>, "type">
|
|
10
9
|
|
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
+
import { describedBy } from "@zeno-lib/forms/lib/aria"
|
|
4
|
+
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
5
|
+
import {
|
|
6
|
+
useHideFieldErrors,
|
|
7
|
+
useIsFieldRequired,
|
|
8
|
+
useIsInvalid,
|
|
9
|
+
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
+
import type { ComponentProps, ReactNode } from "react"
|
|
3
11
|
import {
|
|
4
12
|
Field,
|
|
5
13
|
FieldContent,
|
|
6
14
|
FieldDescription,
|
|
7
15
|
FieldError,
|
|
8
16
|
FieldLabel,
|
|
9
|
-
} from "
|
|
10
|
-
import { Switch } from "
|
|
11
|
-
import type { ComponentProps, ReactNode } from "react"
|
|
12
|
-
|
|
13
|
-
import { describedBy } from "../lib/aria"
|
|
14
|
-
import { useFieldContext } from "../lib/contexts"
|
|
17
|
+
} from "@/components/ui/field"
|
|
18
|
+
import { Switch } from "@/components/ui/switch"
|
|
15
19
|
import { RequiredIndicator } from "../lib/required-indicator"
|
|
16
|
-
import {
|
|
17
|
-
useHideFieldErrors,
|
|
18
|
-
useIsFieldRequired,
|
|
19
|
-
useIsInvalid,
|
|
20
|
-
} from "../lib/use-is-invalid"
|
|
21
20
|
|
|
22
21
|
type SwitchFieldProps = Omit<
|
|
23
22
|
ComponentProps<typeof Switch>,
|
|
@@ -45,7 +44,11 @@ function SwitchField({
|
|
|
45
44
|
const isRequired = required ?? schemaRequired
|
|
46
45
|
|
|
47
46
|
return (
|
|
48
|
-
<Field
|
|
47
|
+
<Field
|
|
48
|
+
data-field={field.name}
|
|
49
|
+
data-invalid={isInvalid}
|
|
50
|
+
orientation="horizontal"
|
|
51
|
+
>
|
|
49
52
|
<FieldContent>
|
|
50
53
|
{label && (
|
|
51
54
|
<FieldLabel htmlFor={field.name}>
|
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
+
import { describedBy } from "@zeno-lib/forms/lib/aria"
|
|
4
|
+
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
5
|
+
import {
|
|
6
|
+
useHideFieldErrors,
|
|
7
|
+
useIsFieldRequired,
|
|
8
|
+
useIsInvalid,
|
|
9
|
+
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
+
import { Children, type ComponentProps, type ReactNode } from "react"
|
|
3
11
|
import {
|
|
4
12
|
Field,
|
|
5
13
|
FieldDescription,
|
|
6
14
|
FieldError,
|
|
7
15
|
FieldLabel,
|
|
8
|
-
} from "
|
|
9
|
-
import { InputGroup, InputGroupTextarea } from "
|
|
10
|
-
import { Textarea } from "
|
|
11
|
-
import { Children, type ComponentProps, type ReactNode } from "react"
|
|
12
|
-
|
|
13
|
-
import { describedBy } from "../lib/aria"
|
|
14
|
-
import { useFieldContext } from "../lib/contexts"
|
|
16
|
+
} from "@/components/ui/field"
|
|
17
|
+
import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group"
|
|
18
|
+
import { Textarea } from "@/components/ui/textarea"
|
|
15
19
|
import { RequiredIndicator } from "../lib/required-indicator"
|
|
16
|
-
import {
|
|
17
|
-
useHideFieldErrors,
|
|
18
|
-
useIsFieldRequired,
|
|
19
|
-
useIsInvalid,
|
|
20
|
-
} from "../lib/use-is-invalid"
|
|
21
20
|
|
|
22
21
|
type TextAreaFieldProps = Omit<
|
|
23
22
|
ComponentProps<typeof Textarea>,
|
|
@@ -63,7 +62,7 @@ function TextAreaField({
|
|
|
63
62
|
}
|
|
64
63
|
|
|
65
64
|
return (
|
|
66
|
-
<Field data-invalid={isInvalid}>
|
|
65
|
+
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
67
66
|
{label && (
|
|
68
67
|
<FieldLabel htmlFor={field.name}>
|
|
69
68
|
{label}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
4
|
+
import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
|
|
5
|
+
import { InputField, type InputFieldProps } from "./input-field"
|
|
6
|
+
|
|
7
|
+
type YearFieldProps = Omit<
|
|
8
|
+
InputFieldProps,
|
|
9
|
+
| "defaultValue"
|
|
10
|
+
| "inputMode"
|
|
11
|
+
| "max"
|
|
12
|
+
| "maxLength"
|
|
13
|
+
| "min"
|
|
14
|
+
| "onChange"
|
|
15
|
+
| "onPaste"
|
|
16
|
+
| "type"
|
|
17
|
+
| "value"
|
|
18
|
+
> & {
|
|
19
|
+
/** Earliest accepted year. The value is clamped to it on blur. */
|
|
20
|
+
min?: number
|
|
21
|
+
/** Latest accepted year. The value is clamped to it on blur. */
|
|
22
|
+
max?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Four-digit year input. The form value is an integer year or `null`. No
|
|
27
|
+
* thousands separator, digits only, clamped to `min`/`max` on blur.
|
|
28
|
+
*/
|
|
29
|
+
function YearField({ max, min, ...props }: YearFieldProps) {
|
|
30
|
+
const field = useFieldContext<number | null | undefined>()
|
|
31
|
+
|
|
32
|
+
const inputProps = useFormattedNumber({
|
|
33
|
+
allowNegative: false,
|
|
34
|
+
max,
|
|
35
|
+
maxIntegerDigits: 4,
|
|
36
|
+
maximumFractionDigits: 0,
|
|
37
|
+
min,
|
|
38
|
+
onBlur: field.handleBlur,
|
|
39
|
+
onValueChange: (next) => field.handleChange(next),
|
|
40
|
+
useGrouping: false,
|
|
41
|
+
value: field.state.value,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<InputField
|
|
46
|
+
autoComplete="off"
|
|
47
|
+
maxLength={4}
|
|
48
|
+
placeholder="YYYY"
|
|
49
|
+
{...props}
|
|
50
|
+
{...inputProps}
|
|
51
|
+
/>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type { YearFieldProps }
|
|
56
|
+
export { YearField }
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cleanup,
|
|
3
|
+
render,
|
|
4
|
+
screen,
|
|
5
|
+
waitFor,
|
|
6
|
+
} from "@zeno-lib/test/testing-library"
|
|
7
|
+
import userEvent from "@zeno-lib/test/user-event"
|
|
8
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
9
|
+
import { z } from "zod"
|
|
10
|
+
import { useForm } from "./create-form"
|
|
11
|
+
import { FormDialog } from "./form-dialog"
|
|
12
|
+
import { useFormDialog } from "./lib/use-form-dialog"
|
|
13
|
+
|
|
14
|
+
const NAME = /Name/
|
|
15
|
+
const EMAIL = /Email/
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
cleanup()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const schema = z.object({
|
|
22
|
+
email: z.string(),
|
|
23
|
+
name: z.string().min(1, "Required"),
|
|
24
|
+
})
|
|
25
|
+
type Values = z.infer<typeof schema>
|
|
26
|
+
const BLANK: Values = { email: "", name: "" }
|
|
27
|
+
const ROW: Values = { email: "ada@example.com", name: "Ada" }
|
|
28
|
+
|
|
29
|
+
function Harness({
|
|
30
|
+
onSubmit = vi.fn(),
|
|
31
|
+
closeOnSubmit,
|
|
32
|
+
}: {
|
|
33
|
+
onSubmit?: (values: Values) => unknown
|
|
34
|
+
closeOnSubmit?: boolean
|
|
35
|
+
}) {
|
|
36
|
+
const dialog = useFormDialog({ defaultValues: BLANK })
|
|
37
|
+
const form = useForm({
|
|
38
|
+
defaultValues: dialog.defaultValues,
|
|
39
|
+
onSubmit: ({ value }) => onSubmit(value),
|
|
40
|
+
schema,
|
|
41
|
+
})
|
|
42
|
+
const { InputField } = form
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<button onClick={() => dialog.open()} type="button">
|
|
46
|
+
New
|
|
47
|
+
</button>
|
|
48
|
+
<button
|
|
49
|
+
onClick={() => dialog.open({ defaultValues: ROW, focus: "email" })}
|
|
50
|
+
type="button"
|
|
51
|
+
>
|
|
52
|
+
Edit
|
|
53
|
+
</button>
|
|
54
|
+
<FormDialog
|
|
55
|
+
closeOnSubmit={closeOnSubmit}
|
|
56
|
+
dialog={dialog}
|
|
57
|
+
form={form}
|
|
58
|
+
title="Person"
|
|
59
|
+
>
|
|
60
|
+
<InputField label="Name" name="name" />
|
|
61
|
+
<InputField label="Email" name="email" />
|
|
62
|
+
</FormDialog>
|
|
63
|
+
</>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Open the "Edit" session and wait for its initial focus (on Email) to land,
|
|
68
|
+
// so later typing isn't raced by the dialog's focus management.
|
|
69
|
+
async function openEdit(user: ReturnType<typeof userEvent.setup>) {
|
|
70
|
+
await user.click(screen.getByRole("button", { name: "Edit" }))
|
|
71
|
+
const email = await screen.findByLabelText(EMAIL)
|
|
72
|
+
await waitFor(() => expect(document.activeElement).toBe(email))
|
|
73
|
+
return screen.getByLabelText(NAME) as HTMLInputElement
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("FormDialog", () => {
|
|
77
|
+
test("opens with per-session defaults and focuses the requested field", async () => {
|
|
78
|
+
const user = userEvent.setup()
|
|
79
|
+
render(<Harness />)
|
|
80
|
+
const name = await openEdit(user)
|
|
81
|
+
expect(name.value).toBe("Ada")
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("closes without asking when nothing changed", async () => {
|
|
85
|
+
const user = userEvent.setup()
|
|
86
|
+
render(<Harness />)
|
|
87
|
+
await openEdit(user)
|
|
88
|
+
await user.click(screen.getByRole("button", { name: "Cancel" }))
|
|
89
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test("asks before discarding, keeps editing on cancel, resets on discard", async () => {
|
|
93
|
+
const user = userEvent.setup()
|
|
94
|
+
render(<Harness />)
|
|
95
|
+
const name = await openEdit(user)
|
|
96
|
+
await user.type(name, "!")
|
|
97
|
+
await user.keyboard("{Escape}")
|
|
98
|
+
|
|
99
|
+
await screen.findByRole("alertdialog")
|
|
100
|
+
await user.click(screen.getByRole("button", { name: "Keep editing" }))
|
|
101
|
+
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
|
|
102
|
+
expect((screen.getByLabelText(NAME) as HTMLInputElement).value).toBe("Ada!")
|
|
103
|
+
|
|
104
|
+
await user.click(screen.getByRole("button", { name: "Cancel" }))
|
|
105
|
+
await user.click(await screen.findByRole("button", { name: "Discard" }))
|
|
106
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
|
|
107
|
+
|
|
108
|
+
// Reopening a blank session shows the blank defaults, not the old edit.
|
|
109
|
+
await user.click(screen.getByRole("button", { name: "New" }))
|
|
110
|
+
expect(
|
|
111
|
+
((await screen.findByLabelText(NAME)) as HTMLInputElement).value
|
|
112
|
+
).toBe("")
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test("reverting an edit counts as unchanged (isDefaultValue, not isDirty)", async () => {
|
|
116
|
+
const user = userEvent.setup()
|
|
117
|
+
render(<Harness />)
|
|
118
|
+
const name = await openEdit(user)
|
|
119
|
+
await user.type(name, "x{Backspace}")
|
|
120
|
+
await user.click(screen.getByRole("button", { name: "Cancel" }))
|
|
121
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
|
|
122
|
+
expect(screen.queryByRole("alertdialog")).toBeNull()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("submits from the footer button and closes on success", async () => {
|
|
126
|
+
const user = userEvent.setup()
|
|
127
|
+
const onSubmit = vi.fn()
|
|
128
|
+
render(<Harness onSubmit={onSubmit} />)
|
|
129
|
+
const name = await openEdit(user)
|
|
130
|
+
await user.type(name, "!")
|
|
131
|
+
const save = screen.getByRole("button", { name: "Save" })
|
|
132
|
+
expect(save.closest("form")).toBeNull()
|
|
133
|
+
await user.click(save)
|
|
134
|
+
await waitFor(() =>
|
|
135
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
136
|
+
email: "ada@example.com",
|
|
137
|
+
name: "Ada!",
|
|
138
|
+
})
|
|
139
|
+
)
|
|
140
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
|
|
141
|
+
expect(screen.queryByRole("alertdialog")).toBeNull()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test("stays open when validation fails or the submit throws", async () => {
|
|
145
|
+
const user = userEvent.setup()
|
|
146
|
+
const onSubmit = vi.fn(() => {
|
|
147
|
+
throw new Error("nope")
|
|
148
|
+
})
|
|
149
|
+
render(<Harness onSubmit={onSubmit} />)
|
|
150
|
+
await user.click(screen.getByRole("button", { name: "New" }))
|
|
151
|
+
await screen.findByLabelText(NAME)
|
|
152
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
153
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
154
|
+
expect(await screen.findByText("Required")).toBeTruthy()
|
|
155
|
+
|
|
156
|
+
await user.type(screen.getByLabelText(NAME), "Bob")
|
|
157
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
158
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
159
|
+
expect(screen.getByRole("dialog")).toBeTruthy()
|
|
160
|
+
})
|
|
161
|
+
})
|