@zeno-lib/forms 0.0.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 +47 -0
- package/src/addons/index.ts +2 -0
- package/src/addons/validation-spinner.tsx +25 -0
- package/src/fields/checkbox-field.tsx +80 -0
- package/src/fields/combobox-field.tsx +189 -0
- package/src/fields/date-picker-field.tsx +141 -0
- package/src/fields/email-field.tsx +21 -0
- package/src/fields/index.ts +16 -0
- package/src/fields/input-field.tsx +95 -0
- package/src/fields/number-field.tsx +33 -0
- package/src/fields/otp-field.tsx +111 -0
- package/src/fields/password-field.tsx +20 -0
- package/src/fields/radio-group-field.tsx +105 -0
- package/src/fields/reset-button.tsx +29 -0
- package/src/fields/select-field.tsx +113 -0
- package/src/fields/slider-field.tsx +103 -0
- package/src/fields/submit-button.tsx +31 -0
- package/src/fields/switch-field.tsx +80 -0
- package/src/fields/textarea-field.tsx +91 -0
- package/src/form.tsx +105 -0
- package/src/lib/apply-validation-error.test.ts +97 -0
- package/src/lib/apply-validation-error.ts +31 -0
- package/src/lib/aria.ts +7 -0
- package/src/lib/contexts.ts +9 -0
- package/src/lib/required-indicator.tsx +22 -0
- package/src/lib/schema-defaults.test.ts +195 -0
- package/src/lib/schema-defaults.ts +127 -0
- package/src/lib/schema-required.test.ts +132 -0
- package/src/lib/schema-required.ts +64 -0
- package/src/lib/use-is-invalid.ts +109 -0
- package/src/lib/use-unsaved-changes-warning.ts +57 -0
- package/src/lib/validation-error.test.ts +41 -0
- package/src/lib/validation-error.ts +30 -0
- package/src/lib/validation-logic.test.ts +261 -0
- package/src/lib/validation-logic.ts +192 -0
- package/src/lib/validation-modes.test-d.ts +9 -0
- package/src/lib/validation-modes.test.ts +72 -0
- package/src/lib/validation-modes.ts +73 -0
- package/src/tanstack.ts +2 -0
- package/src/use-app-fields.test-d.ts +112 -0
- package/src/use-app-fields.test.tsx +163 -0
- package/src/use-app-fields.tsx +272 -0
- package/src/use-form.cascade.test.tsx +83 -0
- package/src/use-form.test-d.ts +153 -0
- package/src/use-form.test.tsx +348 -0
- package/src/use-form.tsx +450 -0
- package/src/use-form.unsaved-warning.test.tsx +192 -0
- package/src/use-form.validation-modes.test.tsx +149 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Field,
|
|
5
|
+
FieldDescription,
|
|
6
|
+
FieldError,
|
|
7
|
+
FieldLabel,
|
|
8
|
+
} from "@zeno-lib/ui/field"
|
|
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"
|
|
15
|
+
import {
|
|
16
|
+
useHideFieldErrors,
|
|
17
|
+
useIsFieldRequired,
|
|
18
|
+
useIsInvalid,
|
|
19
|
+
} from "../lib/use-is-invalid"
|
|
20
|
+
|
|
21
|
+
// Flat surface — `OTPInput`'s prop type is a discriminated union over
|
|
22
|
+
// `render`/`children`, which doesn't compose cleanly with `Omit`. Listing the
|
|
23
|
+
// knobs we forward keeps the field's typing flat and predictable.
|
|
24
|
+
type OtpFieldProps = {
|
|
25
|
+
description?: ReactNode
|
|
26
|
+
label?: ReactNode
|
|
27
|
+
/** Number of digits/characters in the code. Defaults to 6. */
|
|
28
|
+
maxLength?: number
|
|
29
|
+
/** Regex applied to each keystroke (e.g. `^[0-9]+$` for digits-only). */
|
|
30
|
+
pattern?: string
|
|
31
|
+
autoFocus?: boolean
|
|
32
|
+
disabled?: boolean
|
|
33
|
+
className?: string
|
|
34
|
+
containerClassName?: string
|
|
35
|
+
/**
|
|
36
|
+
* Custom slot rendering. Overrides the default single-group layout when
|
|
37
|
+
* you need a separator between segments.
|
|
38
|
+
*/
|
|
39
|
+
children?: ReactNode
|
|
40
|
+
/** Force the required `*` indicator on or off. Defaults to schema-derived. */
|
|
41
|
+
required?: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function OtpField({
|
|
45
|
+
autoFocus,
|
|
46
|
+
children,
|
|
47
|
+
className,
|
|
48
|
+
containerClassName,
|
|
49
|
+
description,
|
|
50
|
+
disabled,
|
|
51
|
+
label,
|
|
52
|
+
maxLength = 6,
|
|
53
|
+
pattern,
|
|
54
|
+
required,
|
|
55
|
+
}: OtpFieldProps) {
|
|
56
|
+
const field = useFieldContext<string>()
|
|
57
|
+
const errorId = `${field.name}-error`
|
|
58
|
+
const descriptionId = `${field.name}-description`
|
|
59
|
+
const isInvalid = useIsInvalid(field)
|
|
60
|
+
const hideErrors = useHideFieldErrors(field)
|
|
61
|
+
const showError = isInvalid && !hideErrors
|
|
62
|
+
const schemaRequired = useIsFieldRequired(field)
|
|
63
|
+
const isRequired = required ?? schemaRequired
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Field data-invalid={isInvalid}>
|
|
67
|
+
{label && (
|
|
68
|
+
<FieldLabel htmlFor={field.name}>
|
|
69
|
+
{label}
|
|
70
|
+
{isRequired && <RequiredIndicator />}
|
|
71
|
+
</FieldLabel>
|
|
72
|
+
)}
|
|
73
|
+
<InputOTP
|
|
74
|
+
aria-describedby={describedBy(
|
|
75
|
+
[description, descriptionId],
|
|
76
|
+
[showError, errorId]
|
|
77
|
+
)}
|
|
78
|
+
aria-invalid={isInvalid || undefined}
|
|
79
|
+
autoFocus={autoFocus}
|
|
80
|
+
className={className}
|
|
81
|
+
containerClassName={containerClassName}
|
|
82
|
+
disabled={disabled}
|
|
83
|
+
id={field.name}
|
|
84
|
+
maxLength={maxLength}
|
|
85
|
+
name={field.name}
|
|
86
|
+
onBlur={field.handleBlur}
|
|
87
|
+
onChange={(value) => field.handleChange(value)}
|
|
88
|
+
pattern={pattern}
|
|
89
|
+
value={field.state.value ?? ""}
|
|
90
|
+
>
|
|
91
|
+
{children ?? (
|
|
92
|
+
<InputOTPGroup>
|
|
93
|
+
{Array.from({ length: maxLength }, (_, index) => (
|
|
94
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: OTP slots are a fixed-length ordered list — index *is* the stable identity.
|
|
95
|
+
<InputOTPSlot index={index} key={index} />
|
|
96
|
+
))}
|
|
97
|
+
</InputOTPGroup>
|
|
98
|
+
)}
|
|
99
|
+
</InputOTP>
|
|
100
|
+
{description && (
|
|
101
|
+
<FieldDescription id={descriptionId}>{description}</FieldDescription>
|
|
102
|
+
)}
|
|
103
|
+
{showError && (
|
|
104
|
+
<FieldError errors={field.state.meta.errors} id={errorId} />
|
|
105
|
+
)}
|
|
106
|
+
</Field>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type { OtpFieldProps }
|
|
111
|
+
export { OtpField }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { InputField, type InputFieldProps } from "./input-field"
|
|
2
|
+
|
|
3
|
+
type PasswordFieldProps = Omit<InputFieldProps, "type">
|
|
4
|
+
|
|
5
|
+
function PasswordField({
|
|
6
|
+
autoComplete = "current-password",
|
|
7
|
+
label = "Password",
|
|
8
|
+
...props
|
|
9
|
+
}: PasswordFieldProps) {
|
|
10
|
+
return (
|
|
11
|
+
<InputField
|
|
12
|
+
autoComplete={autoComplete}
|
|
13
|
+
label={label}
|
|
14
|
+
type="password"
|
|
15
|
+
{...props}
|
|
16
|
+
/>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { PasswordField }
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Field,
|
|
5
|
+
FieldDescription,
|
|
6
|
+
FieldError,
|
|
7
|
+
FieldLabel,
|
|
8
|
+
} from "@zeno-lib/ui/field"
|
|
9
|
+
import { cn } from "@zeno-lib/ui/lib/utils"
|
|
10
|
+
import { RadioGroup, RadioGroupItem } from "@zeno-lib/ui/radio-group"
|
|
11
|
+
import { type ComponentProps, type ReactNode, useId } from "react"
|
|
12
|
+
|
|
13
|
+
import { describedBy } from "../lib/aria"
|
|
14
|
+
import { useFieldContext } from "../lib/contexts"
|
|
15
|
+
import { RequiredIndicator } from "../lib/required-indicator"
|
|
16
|
+
import {
|
|
17
|
+
useHideFieldErrors,
|
|
18
|
+
useIsFieldRequired,
|
|
19
|
+
useIsInvalid,
|
|
20
|
+
} from "../lib/use-is-invalid"
|
|
21
|
+
|
|
22
|
+
type RadioGroupFieldProps = Omit<
|
|
23
|
+
ComponentProps<typeof RadioGroup>,
|
|
24
|
+
"name" | "onValueChange" | "value"
|
|
25
|
+
> & {
|
|
26
|
+
description?: ReactNode
|
|
27
|
+
label?: ReactNode
|
|
28
|
+
/** Force the required `*` indicator on or off. Defaults to schema-derived. */
|
|
29
|
+
required?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function RadioGroupField({
|
|
33
|
+
children,
|
|
34
|
+
description,
|
|
35
|
+
label,
|
|
36
|
+
required,
|
|
37
|
+
...props
|
|
38
|
+
}: RadioGroupFieldProps) {
|
|
39
|
+
const field = useFieldContext<string>()
|
|
40
|
+
const errorId = `${field.name}-error`
|
|
41
|
+
const descriptionId = `${field.name}-description`
|
|
42
|
+
const isInvalid = useIsInvalid(field)
|
|
43
|
+
const hideErrors = useHideFieldErrors(field)
|
|
44
|
+
const showError = isInvalid && !hideErrors
|
|
45
|
+
const schemaRequired = useIsFieldRequired(field)
|
|
46
|
+
const isRequired = required ?? schemaRequired
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Field data-invalid={isInvalid}>
|
|
50
|
+
{label && (
|
|
51
|
+
<FieldLabel>
|
|
52
|
+
{label}
|
|
53
|
+
{isRequired && <RequiredIndicator />}
|
|
54
|
+
</FieldLabel>
|
|
55
|
+
)}
|
|
56
|
+
<RadioGroup
|
|
57
|
+
aria-describedby={describedBy(
|
|
58
|
+
[description, descriptionId],
|
|
59
|
+
[showError, errorId]
|
|
60
|
+
)}
|
|
61
|
+
aria-invalid={isInvalid || undefined}
|
|
62
|
+
name={field.name}
|
|
63
|
+
onValueChange={(value) => field.handleChange(String(value))}
|
|
64
|
+
value={field.state.value ?? ""}
|
|
65
|
+
{...props}
|
|
66
|
+
>
|
|
67
|
+
{children}
|
|
68
|
+
</RadioGroup>
|
|
69
|
+
{description && (
|
|
70
|
+
<FieldDescription id={descriptionId}>{description}</FieldDescription>
|
|
71
|
+
)}
|
|
72
|
+
{showError && (
|
|
73
|
+
<FieldError errors={field.state.meta.errors} id={errorId} />
|
|
74
|
+
)}
|
|
75
|
+
</Field>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type RadioGroupFieldItemProps = Omit<
|
|
80
|
+
ComponentProps<typeof RadioGroupItem>,
|
|
81
|
+
"id"
|
|
82
|
+
> & {
|
|
83
|
+
children?: ReactNode
|
|
84
|
+
labelClassName?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function RadioGroupFieldItem({
|
|
88
|
+
children,
|
|
89
|
+
className,
|
|
90
|
+
labelClassName,
|
|
91
|
+
...props
|
|
92
|
+
}: RadioGroupFieldItemProps) {
|
|
93
|
+
const id = useId()
|
|
94
|
+
return (
|
|
95
|
+
<label
|
|
96
|
+
className={cn("flex items-center gap-2 text-sm", labelClassName)}
|
|
97
|
+
htmlFor={id}
|
|
98
|
+
>
|
|
99
|
+
<RadioGroupItem className={className} id={id} {...props} />
|
|
100
|
+
{children}
|
|
101
|
+
</label>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export { RadioGroupField, RadioGroupFieldItem }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Button } from "@zeno-lib/ui/button"
|
|
4
|
+
import type { ComponentProps, ReactNode } from "react"
|
|
5
|
+
|
|
6
|
+
import { useFormContext } from "../lib/contexts"
|
|
7
|
+
|
|
8
|
+
type ResetButtonProps = Omit<ComponentProps<typeof Button>, "onClick" | "type">
|
|
9
|
+
|
|
10
|
+
function ResetButton({
|
|
11
|
+
children,
|
|
12
|
+
variant = "outline",
|
|
13
|
+
...props
|
|
14
|
+
}: ResetButtonProps) {
|
|
15
|
+
const form = useFormContext()
|
|
16
|
+
return (
|
|
17
|
+
<Button
|
|
18
|
+
onClick={() => form.reset()}
|
|
19
|
+
type="button"
|
|
20
|
+
variant={variant}
|
|
21
|
+
{...props}
|
|
22
|
+
>
|
|
23
|
+
{(children ?? "Reset") as ReactNode}
|
|
24
|
+
</Button>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type { ResetButtonProps }
|
|
29
|
+
export { ResetButton }
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Field,
|
|
5
|
+
FieldDescription,
|
|
6
|
+
FieldError,
|
|
7
|
+
FieldLabel,
|
|
8
|
+
} from "@zeno-lib/ui/field"
|
|
9
|
+
import {
|
|
10
|
+
Select,
|
|
11
|
+
SelectContent,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
} from "@zeno-lib/ui/select"
|
|
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"
|
|
20
|
+
import { RequiredIndicator } from "../lib/required-indicator"
|
|
21
|
+
import {
|
|
22
|
+
useHideFieldErrors,
|
|
23
|
+
useIsFieldRequired,
|
|
24
|
+
useIsInvalid,
|
|
25
|
+
} from "../lib/use-is-invalid"
|
|
26
|
+
|
|
27
|
+
type SelectFieldProps = Omit<
|
|
28
|
+
ComponentProps<typeof Select>,
|
|
29
|
+
"name" | "onValueChange" | "value" | "items"
|
|
30
|
+
> & {
|
|
31
|
+
children: ReactNode
|
|
32
|
+
description?: ReactNode
|
|
33
|
+
label?: ReactNode
|
|
34
|
+
placeholder?: string
|
|
35
|
+
/** Force the required `*` indicator on or off. Defaults to schema-derived. */
|
|
36
|
+
required?: boolean
|
|
37
|
+
triggerClassName?: string
|
|
38
|
+
triggerSize?: ComponentProps<typeof SelectTrigger>["size"]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function SelectField({
|
|
42
|
+
children,
|
|
43
|
+
description,
|
|
44
|
+
label,
|
|
45
|
+
placeholder,
|
|
46
|
+
required,
|
|
47
|
+
triggerClassName,
|
|
48
|
+
triggerSize,
|
|
49
|
+
...props
|
|
50
|
+
}: SelectFieldProps) {
|
|
51
|
+
const field = useFieldContext()
|
|
52
|
+
const errorId = `${field.name}-error`
|
|
53
|
+
const descriptionId = `${field.name}-description`
|
|
54
|
+
const isInvalid = useIsInvalid(field)
|
|
55
|
+
const hideErrors = useHideFieldErrors(field)
|
|
56
|
+
const showError = isInvalid && !hideErrors
|
|
57
|
+
const schemaRequired = useIsFieldRequired(field)
|
|
58
|
+
const isRequired = required ?? schemaRequired
|
|
59
|
+
|
|
60
|
+
const items = useMemo(() => {
|
|
61
|
+
const map: Record<string, ReactNode> = {}
|
|
62
|
+
Children.forEach(children, (child) => {
|
|
63
|
+
if (isValidElement(child)) {
|
|
64
|
+
const p = child.props as { value?: unknown; children?: ReactNode }
|
|
65
|
+
if (p.value !== undefined) {
|
|
66
|
+
map[String(p.value)] = p.children
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
return map
|
|
71
|
+
}, [children])
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Field data-invalid={isInvalid}>
|
|
75
|
+
{label && (
|
|
76
|
+
<FieldLabel htmlFor={field.name}>
|
|
77
|
+
{label}
|
|
78
|
+
{isRequired && <RequiredIndicator />}
|
|
79
|
+
</FieldLabel>
|
|
80
|
+
)}
|
|
81
|
+
<Select
|
|
82
|
+
items={items}
|
|
83
|
+
name={field.name}
|
|
84
|
+
onValueChange={(value) => field.handleChange(value)}
|
|
85
|
+
value={field.state.value ?? null}
|
|
86
|
+
{...props}
|
|
87
|
+
>
|
|
88
|
+
<SelectTrigger
|
|
89
|
+
aria-describedby={describedBy(
|
|
90
|
+
[description, descriptionId],
|
|
91
|
+
[showError, errorId]
|
|
92
|
+
)}
|
|
93
|
+
aria-invalid={isInvalid || undefined}
|
|
94
|
+
className={triggerClassName}
|
|
95
|
+
id={field.name}
|
|
96
|
+
onBlur={field.handleBlur}
|
|
97
|
+
size={triggerSize}
|
|
98
|
+
>
|
|
99
|
+
<SelectValue placeholder={placeholder} />
|
|
100
|
+
</SelectTrigger>
|
|
101
|
+
<SelectContent>{children}</SelectContent>
|
|
102
|
+
</Select>
|
|
103
|
+
{description && (
|
|
104
|
+
<FieldDescription id={descriptionId}>{description}</FieldDescription>
|
|
105
|
+
)}
|
|
106
|
+
{showError && (
|
|
107
|
+
<FieldError errors={field.state.meta.errors} id={errorId} />
|
|
108
|
+
)}
|
|
109
|
+
</Field>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export { SelectField }
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Field,
|
|
5
|
+
FieldContent,
|
|
6
|
+
FieldDescription,
|
|
7
|
+
FieldError,
|
|
8
|
+
FieldLabel,
|
|
9
|
+
} from "@zeno-lib/ui/field"
|
|
10
|
+
import { Slider } from "@zeno-lib/ui/slider"
|
|
11
|
+
import type { ComponentProps, ReactNode } from "react"
|
|
12
|
+
|
|
13
|
+
import { describedBy } from "../lib/aria"
|
|
14
|
+
import { useFieldContext } from "../lib/contexts"
|
|
15
|
+
import { RequiredIndicator } from "../lib/required-indicator"
|
|
16
|
+
import {
|
|
17
|
+
useHideFieldErrors,
|
|
18
|
+
useIsFieldRequired,
|
|
19
|
+
useIsInvalid,
|
|
20
|
+
} from "../lib/use-is-invalid"
|
|
21
|
+
|
|
22
|
+
type SliderValue = number | number[]
|
|
23
|
+
|
|
24
|
+
type SliderFieldProps = Omit<
|
|
25
|
+
ComponentProps<typeof Slider>,
|
|
26
|
+
"name" | "onBlur" | "onValueChange" | "value"
|
|
27
|
+
> & {
|
|
28
|
+
description?: ReactNode
|
|
29
|
+
label?: ReactNode
|
|
30
|
+
/**
|
|
31
|
+
* Render a live numeric readout next to the label. Accepts the current
|
|
32
|
+
* value(s) so callers can format units (e.g. `${v}%`, `$${a}-$${b}`).
|
|
33
|
+
*/
|
|
34
|
+
formatValue?: (value: SliderValue) => ReactNode
|
|
35
|
+
/** Force the required `*` indicator on or off. Defaults to schema-derived. */
|
|
36
|
+
required?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function SliderField({
|
|
40
|
+
description,
|
|
41
|
+
formatValue,
|
|
42
|
+
label,
|
|
43
|
+
required,
|
|
44
|
+
...props
|
|
45
|
+
}: SliderFieldProps) {
|
|
46
|
+
const field = useFieldContext<SliderValue>()
|
|
47
|
+
const errorId = `${field.name}-error`
|
|
48
|
+
const descriptionId = `${field.name}-description`
|
|
49
|
+
const isInvalid = useIsInvalid(field)
|
|
50
|
+
const hideErrors = useHideFieldErrors(field)
|
|
51
|
+
const showError = isInvalid && !hideErrors
|
|
52
|
+
const schemaRequired = useIsFieldRequired(field)
|
|
53
|
+
const isRequired = required ?? schemaRequired
|
|
54
|
+
|
|
55
|
+
const value = field.state.value
|
|
56
|
+
const readout = formatValue && value !== undefined ? formatValue(value) : null
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Field data-invalid={isInvalid}>
|
|
60
|
+
{(label || readout) && (
|
|
61
|
+
<FieldContent className="flex-row items-center justify-between">
|
|
62
|
+
{label && (
|
|
63
|
+
<FieldLabel htmlFor={field.name}>
|
|
64
|
+
{label}
|
|
65
|
+
{isRequired && <RequiredIndicator />}
|
|
66
|
+
</FieldLabel>
|
|
67
|
+
)}
|
|
68
|
+
{readout && (
|
|
69
|
+
<span className="text-muted-foreground text-sm tabular-nums">
|
|
70
|
+
{readout}
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
</FieldContent>
|
|
74
|
+
)}
|
|
75
|
+
<Slider
|
|
76
|
+
aria-describedby={describedBy(
|
|
77
|
+
[description, descriptionId],
|
|
78
|
+
[showError, errorId]
|
|
79
|
+
)}
|
|
80
|
+
aria-invalid={isInvalid || undefined}
|
|
81
|
+
id={field.name}
|
|
82
|
+
name={field.name}
|
|
83
|
+
onBlur={field.handleBlur}
|
|
84
|
+
onValueChange={(next) => {
|
|
85
|
+
const value: SliderValue =
|
|
86
|
+
typeof next === "number" ? next : Array.from(next)
|
|
87
|
+
field.handleChange(value)
|
|
88
|
+
}}
|
|
89
|
+
value={value}
|
|
90
|
+
{...props}
|
|
91
|
+
/>
|
|
92
|
+
{description && (
|
|
93
|
+
<FieldDescription id={descriptionId}>{description}</FieldDescription>
|
|
94
|
+
)}
|
|
95
|
+
{showError && (
|
|
96
|
+
<FieldError errors={field.state.meta.errors} id={errorId} />
|
|
97
|
+
)}
|
|
98
|
+
</Field>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type { SliderFieldProps }
|
|
103
|
+
export { SliderField }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Button } from "@zeno-lib/ui/button"
|
|
4
|
+
import { Spinner } from "@zeno-lib/ui/spinner"
|
|
5
|
+
import type { ComponentProps } from "react"
|
|
6
|
+
|
|
7
|
+
import { useFormContext } from "../lib/contexts"
|
|
8
|
+
|
|
9
|
+
type SubmitButtonProps = Omit<ComponentProps<typeof Button>, "type">
|
|
10
|
+
|
|
11
|
+
function SubmitButton({ children, disabled, ...props }: SubmitButtonProps) {
|
|
12
|
+
const form = useFormContext()
|
|
13
|
+
return (
|
|
14
|
+
<form.Subscribe
|
|
15
|
+
selector={(state) => [state.canSubmit, state.isSubmitting] as const}
|
|
16
|
+
>
|
|
17
|
+
{([canSubmit, isSubmitting]) => (
|
|
18
|
+
<Button
|
|
19
|
+
disabled={disabled || !canSubmit || isSubmitting}
|
|
20
|
+
type="submit"
|
|
21
|
+
{...props}
|
|
22
|
+
>
|
|
23
|
+
{isSubmitting && <Spinner />}
|
|
24
|
+
{children}
|
|
25
|
+
</Button>
|
|
26
|
+
)}
|
|
27
|
+
</form.Subscribe>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { SubmitButton }
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Field,
|
|
5
|
+
FieldContent,
|
|
6
|
+
FieldDescription,
|
|
7
|
+
FieldError,
|
|
8
|
+
FieldLabel,
|
|
9
|
+
} from "@zeno-lib/ui/field"
|
|
10
|
+
import { Switch } from "@zeno-lib/ui/switch"
|
|
11
|
+
import type { ComponentProps, ReactNode } from "react"
|
|
12
|
+
|
|
13
|
+
import { describedBy } from "../lib/aria"
|
|
14
|
+
import { useFieldContext } from "../lib/contexts"
|
|
15
|
+
import { RequiredIndicator } from "../lib/required-indicator"
|
|
16
|
+
import {
|
|
17
|
+
useHideFieldErrors,
|
|
18
|
+
useIsFieldRequired,
|
|
19
|
+
useIsInvalid,
|
|
20
|
+
} from "../lib/use-is-invalid"
|
|
21
|
+
|
|
22
|
+
type SwitchFieldProps = Omit<
|
|
23
|
+
ComponentProps<typeof Switch>,
|
|
24
|
+
"checked" | "id" | "name" | "onBlur" | "onCheckedChange"
|
|
25
|
+
> & {
|
|
26
|
+
description?: ReactNode
|
|
27
|
+
label?: ReactNode
|
|
28
|
+
/** Force the required `*` indicator on or off. Defaults to schema-derived. */
|
|
29
|
+
required?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function SwitchField({
|
|
33
|
+
description,
|
|
34
|
+
label,
|
|
35
|
+
required,
|
|
36
|
+
...props
|
|
37
|
+
}: SwitchFieldProps) {
|
|
38
|
+
const field = useFieldContext<boolean>()
|
|
39
|
+
const errorId = `${field.name}-error`
|
|
40
|
+
const descriptionId = `${field.name}-description`
|
|
41
|
+
const isInvalid = useIsInvalid(field)
|
|
42
|
+
const hideErrors = useHideFieldErrors(field)
|
|
43
|
+
const showError = isInvalid && !hideErrors
|
|
44
|
+
const schemaRequired = useIsFieldRequired(field)
|
|
45
|
+
const isRequired = required ?? schemaRequired
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Field data-invalid={isInvalid} orientation="horizontal">
|
|
49
|
+
<FieldContent>
|
|
50
|
+
{label && (
|
|
51
|
+
<FieldLabel htmlFor={field.name}>
|
|
52
|
+
{label}
|
|
53
|
+
{isRequired && <RequiredIndicator />}
|
|
54
|
+
</FieldLabel>
|
|
55
|
+
)}
|
|
56
|
+
{description && (
|
|
57
|
+
<FieldDescription id={descriptionId}>{description}</FieldDescription>
|
|
58
|
+
)}
|
|
59
|
+
{showError && (
|
|
60
|
+
<FieldError errors={field.state.meta.errors} id={errorId} />
|
|
61
|
+
)}
|
|
62
|
+
</FieldContent>
|
|
63
|
+
<Switch
|
|
64
|
+
aria-describedby={describedBy(
|
|
65
|
+
[description, descriptionId],
|
|
66
|
+
[showError, errorId]
|
|
67
|
+
)}
|
|
68
|
+
aria-invalid={isInvalid || undefined}
|
|
69
|
+
checked={field.state.value ?? false}
|
|
70
|
+
id={field.name}
|
|
71
|
+
name={field.name}
|
|
72
|
+
onBlur={field.handleBlur}
|
|
73
|
+
onCheckedChange={(value) => field.handleChange(value)}
|
|
74
|
+
{...props}
|
|
75
|
+
/>
|
|
76
|
+
</Field>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export { SwitchField }
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Field,
|
|
5
|
+
FieldDescription,
|
|
6
|
+
FieldError,
|
|
7
|
+
FieldLabel,
|
|
8
|
+
} from "@zeno-lib/ui/field"
|
|
9
|
+
import { InputGroup, InputGroupTextarea } from "@zeno-lib/ui/input-group"
|
|
10
|
+
import { Textarea } from "@zeno-lib/ui/textarea"
|
|
11
|
+
import { Children, type ComponentProps, type ReactNode } from "react"
|
|
12
|
+
|
|
13
|
+
import { describedBy } from "../lib/aria"
|
|
14
|
+
import { useFieldContext } from "../lib/contexts"
|
|
15
|
+
import { RequiredIndicator } from "../lib/required-indicator"
|
|
16
|
+
import {
|
|
17
|
+
useHideFieldErrors,
|
|
18
|
+
useIsFieldRequired,
|
|
19
|
+
useIsInvalid,
|
|
20
|
+
} from "../lib/use-is-invalid"
|
|
21
|
+
|
|
22
|
+
type TextAreaFieldProps = Omit<
|
|
23
|
+
ComponentProps<typeof Textarea>,
|
|
24
|
+
"children" | "id" | "name" | "onBlur" | "onChange" | "value"
|
|
25
|
+
> & {
|
|
26
|
+
children?: ReactNode
|
|
27
|
+
description?: ReactNode
|
|
28
|
+
label?: ReactNode
|
|
29
|
+
/** Force the required `*` indicator on or off. Defaults to schema-derived. */
|
|
30
|
+
required?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function TextAreaField({
|
|
34
|
+
children,
|
|
35
|
+
description,
|
|
36
|
+
label,
|
|
37
|
+
required,
|
|
38
|
+
...props
|
|
39
|
+
}: TextAreaFieldProps) {
|
|
40
|
+
const field = useFieldContext<string>()
|
|
41
|
+
const errorId = `${field.name}-error`
|
|
42
|
+
const descriptionId = `${field.name}-description`
|
|
43
|
+
const isInvalid = useIsInvalid(field)
|
|
44
|
+
const hideErrors = useHideFieldErrors(field)
|
|
45
|
+
const showError = isInvalid && !hideErrors
|
|
46
|
+
const hasAddons = Children.count(children) > 0
|
|
47
|
+
const schemaRequired = useIsFieldRequired(field)
|
|
48
|
+
const isRequired = required ?? schemaRequired
|
|
49
|
+
|
|
50
|
+
const textareaProps = {
|
|
51
|
+
"aria-describedby": describedBy(
|
|
52
|
+
[description, descriptionId],
|
|
53
|
+
[showError, errorId]
|
|
54
|
+
),
|
|
55
|
+
"aria-invalid": isInvalid || undefined,
|
|
56
|
+
id: field.name,
|
|
57
|
+
name: field.name,
|
|
58
|
+
onBlur: field.handleBlur,
|
|
59
|
+
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) =>
|
|
60
|
+
field.handleChange(event.target.value),
|
|
61
|
+
value: field.state.value ?? "",
|
|
62
|
+
...props,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Field data-invalid={isInvalid}>
|
|
67
|
+
{label && (
|
|
68
|
+
<FieldLabel htmlFor={field.name}>
|
|
69
|
+
{label}
|
|
70
|
+
{isRequired && <RequiredIndicator />}
|
|
71
|
+
</FieldLabel>
|
|
72
|
+
)}
|
|
73
|
+
{hasAddons ? (
|
|
74
|
+
<InputGroup>
|
|
75
|
+
<InputGroupTextarea {...textareaProps} />
|
|
76
|
+
{children}
|
|
77
|
+
</InputGroup>
|
|
78
|
+
) : (
|
|
79
|
+
<Textarea {...textareaProps} />
|
|
80
|
+
)}
|
|
81
|
+
{description && (
|
|
82
|
+
<FieldDescription id={descriptionId}>{description}</FieldDescription>
|
|
83
|
+
)}
|
|
84
|
+
{showError && (
|
|
85
|
+
<FieldError errors={field.state.meta.errors} id={errorId} />
|
|
86
|
+
)}
|
|
87
|
+
</Field>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { TextAreaField }
|