@zeno-lib/forms 0.5.0 → 0.7.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/dist/create-zeno-form.d.mts +27 -6
- package/dist/create-zeno-form.mjs +29 -6
- package/dist/form-element.d.mts +16 -3
- package/dist/form-element.mjs +38 -18
- package/dist/index.d.mts +3 -1
- package/dist/index.mjs +3 -1
- package/dist/lib/date-value.d.mts +24 -0
- package/dist/lib/date-value.mjs +37 -0
- package/dist/lib/default-props.d.mts +7 -0
- package/dist/lib/default-props.mjs +13 -0
- package/dist/lib/empty-value.mjs +25 -1
- package/dist/lib/form-disabled.d.mts +28 -0
- package/dist/lib/form-disabled.mjs +54 -0
- package/dist/lib/item-options.d.mts +45 -0
- package/dist/lib/item-options.mjs +70 -0
- package/dist/lib/schema-required.mjs +17 -0
- package/dist/lib/submit-action.d.mts +8 -2
- package/dist/lib/submit-action.mjs +4 -2
- package/dist/lib/use-field-control.d.mts +83 -0
- package/dist/lib/use-field-control.mjs +70 -0
- package/package.json +1 -1
- package/src/control-field.test.tsx +338 -0
- package/src/create-form.tsx +36 -28
- package/src/create-zeno-form.tsx +144 -52
- package/src/date-picker-field.test.tsx +108 -0
- package/src/default-props.test-d.ts +66 -0
- package/src/default-props.test.tsx +165 -0
- package/src/fields/checkbox-field.tsx +5 -0
- package/src/fields/checkbox-group-field.tsx +6 -2
- package/src/fields/combobox-field.tsx +107 -40
- package/src/fields/control-field.tsx +57 -0
- package/src/fields/date-picker-field.tsx +34 -11
- package/src/fields/field-shell.tsx +80 -0
- package/src/fields/index.ts +2 -0
- package/src/fields/input-field.tsx +16 -48
- package/src/fields/multi-select-field.tsx +100 -42
- package/src/fields/otp-field.tsx +8 -2
- package/src/fields/radio-group-field.tsx +9 -1
- package/src/fields/reset-button.tsx +4 -0
- package/src/fields/select-field.tsx +9 -1
- package/src/fields/slider-field.tsx +9 -1
- package/src/fields/submit-button.tsx +3 -1
- package/src/fields/switch-field.tsx +5 -0
- package/src/fields/textarea-field.tsx +16 -48
- package/src/form-dialog.test.tsx +102 -1
- package/src/form-dialog.tsx +143 -42
- package/src/form-disabled.test.tsx +329 -0
- package/src/form-element.tsx +64 -29
- package/src/grouped-options.test-d.ts +59 -0
- package/src/grouped-options.test.tsx +251 -0
- package/src/index.ts +7 -0
- package/src/lib/date-value.test.ts +14 -0
- package/src/lib/date-value.ts +70 -0
- package/src/lib/default-props.ts +19 -0
- package/src/lib/empty-value.test.ts +19 -0
- package/src/lib/empty-value.ts +45 -1
- package/src/lib/form-disabled.ts +95 -0
- package/src/lib/item-options.test.ts +11 -0
- package/src/lib/item-options.ts +165 -0
- package/src/lib/schema-required.test.ts +9 -0
- package/src/lib/schema-required.ts +33 -0
- package/src/lib/submit-action.ts +18 -4
- package/src/lib/use-field-control.ts +191 -0
- package/src/submit-action.test.tsx +103 -0
- package/src/use-app-fields.test-d.ts +51 -1
- package/src/with-form.test-d.ts +76 -0
- package/src/with-form.test.tsx +60 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { describedBy } from "@zeno-lib/forms/lib/aria"
|
|
4
4
|
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
5
|
+
import { useIsFormDisabled } from "@zeno-lib/forms/lib/form-disabled"
|
|
5
6
|
import {
|
|
6
7
|
useHideFieldErrors,
|
|
7
8
|
useIsFieldRequired,
|
|
@@ -34,11 +35,13 @@ type CheckboxFieldProps = Omit<
|
|
|
34
35
|
|
|
35
36
|
function CheckboxField({
|
|
36
37
|
description,
|
|
38
|
+
disabled,
|
|
37
39
|
label,
|
|
38
40
|
required,
|
|
39
41
|
...props
|
|
40
42
|
}: CheckboxFieldProps) {
|
|
41
43
|
const field = useFieldContext<boolean>()
|
|
44
|
+
const isDisabled = useIsFormDisabled(field.form) || Boolean(disabled)
|
|
42
45
|
const id = useId()
|
|
43
46
|
const errorId = `${id}-error`
|
|
44
47
|
const descriptionId = `${id}-description`
|
|
@@ -52,6 +55,7 @@ function CheckboxField({
|
|
|
52
55
|
|
|
53
56
|
return (
|
|
54
57
|
<Field
|
|
58
|
+
data-disabled={isDisabled || undefined}
|
|
55
59
|
data-field={field.name}
|
|
56
60
|
data-invalid={isInvalid}
|
|
57
61
|
orientation="horizontal"
|
|
@@ -64,6 +68,7 @@ function CheckboxField({
|
|
|
64
68
|
aria-invalid={isInvalid || undefined}
|
|
65
69
|
aria-required={requiresValue || undefined}
|
|
66
70
|
checked={field.state.value ?? false}
|
|
71
|
+
disabled={isDisabled}
|
|
67
72
|
id={id}
|
|
68
73
|
name={field.name}
|
|
69
74
|
onBlur={field.handleBlur}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { describedBy } from "@zeno-lib/forms/lib/aria"
|
|
4
4
|
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
5
|
+
import { useIsFormDisabled } from "@zeno-lib/forms/lib/form-disabled"
|
|
5
6
|
import {
|
|
6
7
|
useHideFieldErrors,
|
|
7
8
|
useIsFieldRequired,
|
|
@@ -64,6 +65,7 @@ function CheckboxGroupField<V = string>({
|
|
|
64
65
|
required,
|
|
65
66
|
}: CheckboxGroupFieldProps<V>) {
|
|
66
67
|
const field = useFieldContext<V[] | null | undefined>()
|
|
68
|
+
const isDisabled = useIsFormDisabled(field.form) || Boolean(disabled)
|
|
67
69
|
const id = useId()
|
|
68
70
|
const errorId = `${id}-error`
|
|
69
71
|
const descriptionId = `${id}-description`
|
|
@@ -97,9 +99,10 @@ function CheckboxGroupField<V = string>({
|
|
|
97
99
|
)}
|
|
98
100
|
aria-invalid={isInvalid || undefined}
|
|
99
101
|
className={className}
|
|
102
|
+
data-disabled={isDisabled || undefined}
|
|
100
103
|
data-field={field.name}
|
|
101
104
|
data-invalid={isInvalid}
|
|
102
|
-
disabled={
|
|
105
|
+
disabled={isDisabled}
|
|
103
106
|
>
|
|
104
107
|
{label && (
|
|
105
108
|
<FieldLegend variant="label">
|
|
@@ -120,6 +123,7 @@ function CheckboxGroupField<V = string>({
|
|
|
120
123
|
const optionId = `${id}-${index}`
|
|
121
124
|
return (
|
|
122
125
|
<Field
|
|
126
|
+
data-disabled={isDisabled || option.disabled || undefined}
|
|
123
127
|
data-invalid={isInvalid}
|
|
124
128
|
key={String(option.value)}
|
|
125
129
|
orientation="horizontal"
|
|
@@ -127,7 +131,7 @@ function CheckboxGroupField<V = string>({
|
|
|
127
131
|
<Checkbox
|
|
128
132
|
aria-invalid={isInvalid || undefined}
|
|
129
133
|
checked={checked.includes(option.value)}
|
|
130
|
-
disabled={
|
|
134
|
+
disabled={isDisabled || option.disabled}
|
|
131
135
|
id={optionId}
|
|
132
136
|
name={field.name}
|
|
133
137
|
onBlur={field.handleBlur}
|
|
@@ -3,19 +3,38 @@
|
|
|
3
3
|
import { describedBy } from "@zeno-lib/forms/lib/aria"
|
|
4
4
|
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
5
5
|
import { getEmptyValue } from "@zeno-lib/forms/lib/empty-value"
|
|
6
|
+
import { useIsFormDisabled } from "@zeno-lib/forms/lib/form-disabled"
|
|
7
|
+
import {
|
|
8
|
+
findOption,
|
|
9
|
+
type ItemGroup,
|
|
10
|
+
type ItemObject,
|
|
11
|
+
type ItemOption,
|
|
12
|
+
type ItemValue,
|
|
13
|
+
isItemGroup,
|
|
14
|
+
isItemObject,
|
|
15
|
+
isSameValue,
|
|
16
|
+
itemLabel,
|
|
17
|
+
itemValue,
|
|
18
|
+
toItemList,
|
|
19
|
+
type ValueComparator,
|
|
20
|
+
valueKey,
|
|
21
|
+
} from "@zeno-lib/forms/lib/item-options"
|
|
6
22
|
import {
|
|
7
23
|
useHideFieldErrors,
|
|
8
24
|
useIsFieldRequired,
|
|
9
25
|
useIsFieldRequiredBySchema,
|
|
10
26
|
useIsInvalid,
|
|
11
27
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
12
|
-
import { type ReactNode, useId } from "react"
|
|
28
|
+
import { type ReactNode, useId, useMemo, useState } from "react"
|
|
13
29
|
import {
|
|
14
30
|
Combobox,
|
|
31
|
+
ComboboxCollection,
|
|
15
32
|
ComboboxContent,
|
|
16
33
|
ComboboxEmpty,
|
|
34
|
+
ComboboxGroup,
|
|
17
35
|
ComboboxInput,
|
|
18
36
|
ComboboxItem,
|
|
37
|
+
ComboboxLabel,
|
|
19
38
|
ComboboxList,
|
|
20
39
|
} from "@/components/ui/combobox"
|
|
21
40
|
import {
|
|
@@ -28,13 +47,23 @@ import { InputGroupAddon } from "@/components/ui/input-group"
|
|
|
28
47
|
import { Spinner } from "@/components/ui/spinner"
|
|
29
48
|
import { RequiredIndicator } from "../lib/required-indicator"
|
|
30
49
|
|
|
31
|
-
type ComboboxItemObject<V> =
|
|
50
|
+
type ComboboxItemObject<V> = ItemObject<V>
|
|
51
|
+
|
|
52
|
+
/** Items under a heading. */
|
|
53
|
+
type ComboboxItemGroup<T> = ItemGroup<T>
|
|
54
|
+
|
|
55
|
+
/** An item of `items`, or of one of its groups. */
|
|
56
|
+
type ComboboxOption<T> = ItemOption<T>
|
|
57
|
+
|
|
58
|
+
/** What picking an item stores: `item.value` for an object, else the item. */
|
|
59
|
+
type ComboboxOptionValue<T> = ItemValue<T>
|
|
32
60
|
|
|
33
61
|
type ComboboxFieldProps<T = string> = {
|
|
34
62
|
/**
|
|
35
63
|
* Items shown in the dropdown. Pass plain strings (value === label) or
|
|
36
64
|
* `{ value, label }` objects when the value isn't a string (e.g. numeric
|
|
37
|
-
* database IDs).
|
|
65
|
+
* database IDs, or an object). To show them under headings, pass
|
|
66
|
+
* `{ label, items }` groups instead.
|
|
38
67
|
*/
|
|
39
68
|
items: readonly T[]
|
|
40
69
|
description?: ReactNode
|
|
@@ -43,7 +72,18 @@ type ComboboxFieldProps<T = string> = {
|
|
|
43
72
|
/** Message shown when filtering produces zero matches. */
|
|
44
73
|
emptyMessage?: ReactNode
|
|
45
74
|
/** Override per-row rendering. Default: plain `<ComboboxItem>{label}</ComboboxItem>`. */
|
|
46
|
-
renderItem?: (item: T) => ReactNode
|
|
75
|
+
renderItem?: (item: ComboboxOption<T>) => ReactNode
|
|
76
|
+
/**
|
|
77
|
+
* Whether an item's value is the stored value. Defaults to `Object.is`.
|
|
78
|
+
* Pass it when the values are objects (`{ kind, id }`), which a reload or a
|
|
79
|
+
* refetch rebuilds: `(a, b) => a.kind === b.kind && a.id === b.id`. Never
|
|
80
|
+
* called with an empty value.
|
|
81
|
+
*/
|
|
82
|
+
// biome-ignore lint/style/useConsistentMethodSignatures: a method's parameters are bivariant, so a comparator typed with the value fits `form.<Field>`, whose props see the items as `unknown`.
|
|
83
|
+
isItemEqualToValue?(
|
|
84
|
+
itemValue: ComboboxOptionValue<T>,
|
|
85
|
+
value: ComboboxOptionValue<T>
|
|
86
|
+
): boolean
|
|
47
87
|
/**
|
|
48
88
|
* Show the clear button when a value is selected. Defaults to `true` on an
|
|
49
89
|
* optional field and `false` on a required one (from the schema or
|
|
@@ -68,7 +108,7 @@ type ComboboxFieldProps<T = string> = {
|
|
|
68
108
|
* Replace Base UI's built-in client-side filter. Pass `null` for
|
|
69
109
|
* server-driven search where `items` is already filtered by the consumer.
|
|
70
110
|
*/
|
|
71
|
-
filter?: ((item: T
|
|
111
|
+
filter?: ((item: ComboboxOption<T>, query: string) => boolean) | null
|
|
72
112
|
/**
|
|
73
113
|
* Show a spinner addon inside the input while options are being fetched.
|
|
74
114
|
* Pair with `onInputValueChange` to drive a debounced server query.
|
|
@@ -76,13 +116,24 @@ type ComboboxFieldProps<T = string> = {
|
|
|
76
116
|
loading?: boolean
|
|
77
117
|
}
|
|
78
118
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
119
|
+
// Base UI copies the selected label into the input whenever `value` is a new
|
|
120
|
+
// object, so items rebuilt on every render (mapped from fetched rows) would
|
|
121
|
+
// overwrite what the user types. Keep the item found until it stands for
|
|
122
|
+
// another value or label.
|
|
123
|
+
function useHeldOption<O>(
|
|
124
|
+
match: O | undefined,
|
|
125
|
+
isEqual: ValueComparator | undefined
|
|
126
|
+
): O | undefined {
|
|
127
|
+
const [held, setHeld] = useState(match)
|
|
128
|
+
const keepsHeld =
|
|
129
|
+
held !== undefined &&
|
|
130
|
+
match !== undefined &&
|
|
131
|
+
isSameValue(itemValue(held), itemValue(match), isEqual) &&
|
|
132
|
+
itemLabel(held) === itemLabel(match)
|
|
133
|
+
if (held !== match && !keepsHeld) {
|
|
134
|
+
setHeld(match)
|
|
135
|
+
}
|
|
136
|
+
return keepsHeld ? held : match
|
|
86
137
|
}
|
|
87
138
|
|
|
88
139
|
function ComboboxField<T = string>({
|
|
@@ -92,6 +143,7 @@ function ComboboxField<T = string>({
|
|
|
92
143
|
emptyMessage = "No results.",
|
|
93
144
|
filter,
|
|
94
145
|
inputValue,
|
|
146
|
+
isItemEqualToValue,
|
|
95
147
|
items,
|
|
96
148
|
label,
|
|
97
149
|
loading,
|
|
@@ -102,6 +154,7 @@ function ComboboxField<T = string>({
|
|
|
102
154
|
showClear,
|
|
103
155
|
}: ComboboxFieldProps<T>) {
|
|
104
156
|
const field = useFieldContext()
|
|
157
|
+
const isDisabled = useIsFormDisabled(field.form) || Boolean(disabled)
|
|
105
158
|
const id = useId()
|
|
106
159
|
const errorId = `${id}-error`
|
|
107
160
|
const descriptionId = `${id}-description`
|
|
@@ -114,37 +167,57 @@ function ComboboxField<T = string>({
|
|
|
114
167
|
const requiresValue = required ?? schemaRequiresValue
|
|
115
168
|
const canClear = showClear ?? !requiresValue
|
|
116
169
|
|
|
170
|
+
const list = useMemo(() => toItemList(items), [items])
|
|
171
|
+
const isEqual = isItemEqualToValue as ValueComparator | undefined
|
|
172
|
+
|
|
117
173
|
const fieldValue = field.state.value
|
|
118
174
|
// Base UI shows the value it gets as text, so a stored id whose item isn't
|
|
119
175
|
// in `items` (options still loading, or a search that dropped it) would
|
|
120
176
|
// show raw. Only a plain string or number item is its own label.
|
|
121
|
-
const match =
|
|
122
|
-
|
|
123
|
-
)
|
|
124
|
-
const plainItems = items.length > 0 && !items.some(isItemObject)
|
|
177
|
+
const match = useHeldOption(findOption(list, fieldValue, isEqual), isEqual)
|
|
178
|
+
const plainItems = list.options.length > 0 && !list.options.some(isItemObject)
|
|
125
179
|
const value = (match ??
|
|
126
|
-
(plainItems ? (fieldValue ?? null) : null)) as T | null
|
|
180
|
+
(plainItems ? (fieldValue ?? null) : null)) as ComboboxOption<T> | null
|
|
181
|
+
|
|
182
|
+
const renderOption = (option: ComboboxOption<T>) => {
|
|
183
|
+
if (renderItem) {
|
|
184
|
+
return renderItem(option)
|
|
185
|
+
}
|
|
186
|
+
return (
|
|
187
|
+
<ComboboxItem key={valueKey(itemValue(option))} value={option}>
|
|
188
|
+
{itemLabel(option)}
|
|
189
|
+
</ComboboxItem>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
127
192
|
|
|
128
193
|
return (
|
|
129
|
-
<Field
|
|
194
|
+
<Field
|
|
195
|
+
data-disabled={isDisabled || undefined}
|
|
196
|
+
data-field={field.name}
|
|
197
|
+
data-invalid={isInvalid}
|
|
198
|
+
>
|
|
130
199
|
{label && (
|
|
131
200
|
<FieldLabel htmlFor={id}>
|
|
132
201
|
{label}
|
|
133
202
|
{isRequired && <RequiredIndicator />}
|
|
134
203
|
</FieldLabel>
|
|
135
204
|
)}
|
|
136
|
-
<Combobox
|
|
137
|
-
disabled={
|
|
205
|
+
<Combobox<ComboboxOption<T>>
|
|
206
|
+
disabled={isDisabled}
|
|
138
207
|
filter={filter}
|
|
139
208
|
inputValue={inputValue}
|
|
140
|
-
items
|
|
209
|
+
// The held item may be one `items` no longer has.
|
|
210
|
+
isItemEqualToValue={(item, selected) =>
|
|
211
|
+
isSameValue(itemValue(item), itemValue(selected), isEqual)
|
|
212
|
+
}
|
|
213
|
+
items={list.items}
|
|
141
214
|
onInputValueChange={onInputValueChange}
|
|
142
215
|
onValueChange={(next) => {
|
|
143
216
|
if (next === null) {
|
|
144
217
|
field.handleChange(getEmptyValue(field, undefined))
|
|
145
218
|
return
|
|
146
219
|
}
|
|
147
|
-
field.handleChange(
|
|
220
|
+
field.handleChange(itemValue(next))
|
|
148
221
|
}}
|
|
149
222
|
value={value}
|
|
150
223
|
>
|
|
@@ -156,7 +229,7 @@ function ComboboxField<T = string>({
|
|
|
156
229
|
aria-invalid={isInvalid || undefined}
|
|
157
230
|
aria-required={requiresValue || undefined}
|
|
158
231
|
className={className}
|
|
159
|
-
disabled={
|
|
232
|
+
disabled={isDisabled}
|
|
160
233
|
id={id}
|
|
161
234
|
name={field.name}
|
|
162
235
|
onBlur={field.handleBlur}
|
|
@@ -172,23 +245,17 @@ function ComboboxField<T = string>({
|
|
|
172
245
|
<ComboboxContent>
|
|
173
246
|
<ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
|
|
174
247
|
<ComboboxList>
|
|
175
|
-
{(item: T) =>
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
)
|
|
185
|
-
}
|
|
186
|
-
return (
|
|
187
|
-
<ComboboxItem key={String(item)} value={item}>
|
|
188
|
-
{String(item)}
|
|
189
|
-
</ComboboxItem>
|
|
248
|
+
{(item: T, index: number) =>
|
|
249
|
+
isItemGroup(item) ? (
|
|
250
|
+
// Two groups can share a heading, or have none.
|
|
251
|
+
<ComboboxGroup items={item.items} key={index}>
|
|
252
|
+
{item.label && <ComboboxLabel>{item.label}</ComboboxLabel>}
|
|
253
|
+
<ComboboxCollection>{renderOption}</ComboboxCollection>
|
|
254
|
+
</ComboboxGroup>
|
|
255
|
+
) : (
|
|
256
|
+
renderOption(item as ComboboxOption<T>)
|
|
190
257
|
)
|
|
191
|
-
}
|
|
258
|
+
}
|
|
192
259
|
</ComboboxList>
|
|
193
260
|
</ComboboxContent>
|
|
194
261
|
</Combobox>
|
|
@@ -202,5 +269,5 @@ function ComboboxField<T = string>({
|
|
|
202
269
|
)
|
|
203
270
|
}
|
|
204
271
|
|
|
205
|
-
export type { ComboboxFieldProps, ComboboxItemObject }
|
|
272
|
+
export type { ComboboxFieldProps, ComboboxItemGroup, ComboboxItemObject }
|
|
206
273
|
export { ComboboxField }
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type FieldControlProps,
|
|
5
|
+
useFieldControl,
|
|
6
|
+
} from "@zeno-lib/forms/lib/use-field-control"
|
|
7
|
+
import type { ReactNode } from "react"
|
|
8
|
+
import { FieldShell } from "./field-shell"
|
|
9
|
+
|
|
10
|
+
type ControlFieldProps<TValue = unknown> = {
|
|
11
|
+
description?: ReactNode
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
label?: ReactNode
|
|
14
|
+
/**
|
|
15
|
+
* Renders the control, given the props that bind it to the field: `id`,
|
|
16
|
+
* `name`, `value`, `onChange(value)`, `onBlur`, `disabled` and the
|
|
17
|
+
* `aria-*`. Spread them on a control that takes its value that way, or map
|
|
18
|
+
* them for one that doesn't. Keep `aria-invalid` on the focusable element
|
|
19
|
+
* (or a group root around it): a failed submit focuses the first invalid
|
|
20
|
+
* field by it.
|
|
21
|
+
*/
|
|
22
|
+
render: (control: FieldControlProps<TValue>) => ReactNode
|
|
23
|
+
/**
|
|
24
|
+
* Mark the field required, or not, over the schema. Drives the `*` and
|
|
25
|
+
* `aria-required`.
|
|
26
|
+
*/
|
|
27
|
+
required?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Binds a controlled component the kit has no field for (a phone input, a
|
|
32
|
+
* star rating, a color picker) in the kit's field layout. Clearing it stores
|
|
33
|
+
* the field's empty value, as the kit's fields do.
|
|
34
|
+
*/
|
|
35
|
+
function ControlField<TValue = unknown>({
|
|
36
|
+
description,
|
|
37
|
+
disabled,
|
|
38
|
+
label,
|
|
39
|
+
render,
|
|
40
|
+
required,
|
|
41
|
+
}: ControlFieldProps<TValue>) {
|
|
42
|
+
const { controlProps, ...shell } = useFieldControl<TValue>({
|
|
43
|
+
description,
|
|
44
|
+
disabled,
|
|
45
|
+
label,
|
|
46
|
+
required,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<FieldShell {...shell} description={description} label={label}>
|
|
51
|
+
{render(controlProps)}
|
|
52
|
+
</FieldShell>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type { ControlFieldProps }
|
|
57
|
+
export { ControlField }
|
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { describedBy } from "@zeno-lib/forms/lib/aria"
|
|
4
4
|
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
5
|
+
import {
|
|
6
|
+
type DateValueType,
|
|
7
|
+
parseDateValue,
|
|
8
|
+
toDateValue,
|
|
9
|
+
} from "@zeno-lib/forms/lib/date-value"
|
|
5
10
|
import { getEmptyValue } from "@zeno-lib/forms/lib/empty-value"
|
|
11
|
+
import { useIsFormDisabled } from "@zeno-lib/forms/lib/form-disabled"
|
|
6
12
|
import {
|
|
7
13
|
useHideFieldErrors,
|
|
8
14
|
useIsFieldRequired,
|
|
@@ -47,6 +53,15 @@ type DatePickerFieldProps = {
|
|
|
47
53
|
* the `*`, and picking its selected day again keeps it instead of clearing.
|
|
48
54
|
*/
|
|
49
55
|
required?: boolean
|
|
56
|
+
/**
|
|
57
|
+
* What the form value is. Whatever it is, the field reads a stored `Date`
|
|
58
|
+
* or ISO string.
|
|
59
|
+
* - `"date"` (default): a `Date`, at local midnight of the picked day.
|
|
60
|
+
* - `"iso-date"`: the day as `YYYY-MM-DD`, for a `date` column.
|
|
61
|
+
* - `"iso-datetime"`: the day's local midnight as an ISO 8601 timestamp
|
|
62
|
+
* (`toISOString()`), for a timestamp column.
|
|
63
|
+
*/
|
|
64
|
+
valueType?: DateValueType
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
const FALLBACK_PLACEHOLDER = "Pick a date"
|
|
@@ -68,8 +83,10 @@ function DatePickerField({
|
|
|
68
83
|
placeholder = FALLBACK_PLACEHOLDER,
|
|
69
84
|
required,
|
|
70
85
|
triggerClassName,
|
|
86
|
+
valueType = "date",
|
|
71
87
|
}: DatePickerFieldProps) {
|
|
72
|
-
const field = useFieldContext<Date | undefined>()
|
|
88
|
+
const field = useFieldContext<Date | string | null | undefined>()
|
|
89
|
+
const isDisabled = useIsFormDisabled(field.form) || Boolean(disabled)
|
|
73
90
|
// Opening the popover moves focus into it, so the trigger's blur would mark
|
|
74
91
|
// the field touched, and show a required error, before a day is picked.
|
|
75
92
|
// The field is touched once the popover closes, or focus leaves it closed.
|
|
@@ -85,11 +102,14 @@ function DatePickerField({
|
|
|
85
102
|
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
86
103
|
const requiresValue = required ?? schemaRequiresValue
|
|
87
104
|
|
|
88
|
-
const
|
|
89
|
-
const empty = !value
|
|
105
|
+
const date = parseDateValue(field.state.value)
|
|
90
106
|
|
|
91
107
|
return (
|
|
92
|
-
<Field
|
|
108
|
+
<Field
|
|
109
|
+
data-disabled={isDisabled || undefined}
|
|
110
|
+
data-field={field.name}
|
|
111
|
+
data-invalid={isInvalid}
|
|
112
|
+
>
|
|
93
113
|
{label && (
|
|
94
114
|
<FieldLabel htmlFor={id}>
|
|
95
115
|
{label}
|
|
@@ -115,8 +135,8 @@ function DatePickerField({
|
|
|
115
135
|
"w-full justify-start text-left font-normal data-[empty=true]:text-muted-foreground",
|
|
116
136
|
triggerClassName
|
|
117
137
|
)}
|
|
118
|
-
data-empty={
|
|
119
|
-
disabled={
|
|
138
|
+
data-empty={date ? undefined : true}
|
|
139
|
+
disabled={isDisabled}
|
|
120
140
|
id={id}
|
|
121
141
|
onBlur={() => {
|
|
122
142
|
if (!isOpen.current) {
|
|
@@ -125,16 +145,21 @@ function DatePickerField({
|
|
|
125
145
|
}}
|
|
126
146
|
>
|
|
127
147
|
<CalendarIcon />
|
|
128
|
-
{
|
|
148
|
+
{date ? formatValue(date) : <span>{placeholder}</span>}
|
|
129
149
|
</PopoverTrigger>
|
|
130
150
|
<PopoverContent className="w-auto p-0">
|
|
131
151
|
<Calendar
|
|
152
|
+
defaultMonth={date}
|
|
132
153
|
mode="single"
|
|
133
154
|
onSelect={(next: Date | undefined) =>
|
|
134
|
-
field.handleChange(
|
|
155
|
+
field.handleChange(
|
|
156
|
+
next
|
|
157
|
+
? toDateValue(next, valueType)
|
|
158
|
+
: getEmptyValue(field, undefined)
|
|
159
|
+
)
|
|
135
160
|
}
|
|
136
161
|
required={requiresValue}
|
|
137
|
-
selected={
|
|
162
|
+
selected={date}
|
|
138
163
|
{...calendarProps}
|
|
139
164
|
/>
|
|
140
165
|
</PopoverContent>
|
|
@@ -155,7 +180,6 @@ function CalendarIcon() {
|
|
|
155
180
|
aria-hidden
|
|
156
181
|
className="size-4 shrink-0 opacity-60"
|
|
157
182
|
fill="none"
|
|
158
|
-
role="img"
|
|
159
183
|
stroke="currentColor"
|
|
160
184
|
strokeLinecap="round"
|
|
161
185
|
strokeLinejoin="round"
|
|
@@ -163,7 +187,6 @@ function CalendarIcon() {
|
|
|
163
187
|
viewBox="0 0 24 24"
|
|
164
188
|
xmlns="http://www.w3.org/2000/svg"
|
|
165
189
|
>
|
|
166
|
-
<title>Calendar</title>
|
|
167
190
|
<path d="M8 2v3M16 2v3M3 10h18M5 4h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" />
|
|
168
191
|
</svg>
|
|
169
192
|
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { FieldControl } from "@zeno-lib/forms/lib/use-field-control"
|
|
4
|
+
import type { ReactNode } from "react"
|
|
5
|
+
import {
|
|
6
|
+
Field,
|
|
7
|
+
FieldDescription,
|
|
8
|
+
FieldError,
|
|
9
|
+
FieldLabel,
|
|
10
|
+
} from "@/components/ui/field"
|
|
11
|
+
import { RequiredIndicator } from "../lib/required-indicator"
|
|
12
|
+
|
|
13
|
+
type FieldShellProps = Pick<
|
|
14
|
+
FieldControl,
|
|
15
|
+
| "descriptionId"
|
|
16
|
+
| "errorId"
|
|
17
|
+
| "errors"
|
|
18
|
+
| "id"
|
|
19
|
+
| "isInvalid"
|
|
20
|
+
| "labelId"
|
|
21
|
+
| "name"
|
|
22
|
+
| "showRequiredIndicator"
|
|
23
|
+
> & {
|
|
24
|
+
/** The control, bound with `useFieldControl`'s `controlProps`. */
|
|
25
|
+
children: ReactNode
|
|
26
|
+
description?: ReactNode
|
|
27
|
+
/** Dims the label. `useFieldControl` returns it. */
|
|
28
|
+
isDisabled?: boolean
|
|
29
|
+
label?: ReactNode
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The field layout every kit field shares: the label with its `*`, the
|
|
34
|
+
* control, the description and the error, tied together by the ids
|
|
35
|
+
* `useFieldControl` returns. Spread the rest of its result here:
|
|
36
|
+
*
|
|
37
|
+
* ```tsx
|
|
38
|
+
* const { controlProps, ...shell } = useFieldControl({ description, label })
|
|
39
|
+
* <FieldShell {...shell} description={description} label={label}>
|
|
40
|
+
* <MyControl {...controlProps} />
|
|
41
|
+
* </FieldShell>
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
function FieldShell({
|
|
45
|
+
children,
|
|
46
|
+
description,
|
|
47
|
+
descriptionId,
|
|
48
|
+
errorId,
|
|
49
|
+
errors,
|
|
50
|
+
id,
|
|
51
|
+
isDisabled,
|
|
52
|
+
isInvalid,
|
|
53
|
+
label,
|
|
54
|
+
labelId,
|
|
55
|
+
name,
|
|
56
|
+
showRequiredIndicator,
|
|
57
|
+
}: FieldShellProps) {
|
|
58
|
+
return (
|
|
59
|
+
<Field
|
|
60
|
+
data-disabled={isDisabled || undefined}
|
|
61
|
+
data-field={name}
|
|
62
|
+
data-invalid={isInvalid}
|
|
63
|
+
>
|
|
64
|
+
{label && (
|
|
65
|
+
<FieldLabel htmlFor={id} id={labelId}>
|
|
66
|
+
{label}
|
|
67
|
+
{showRequiredIndicator && <RequiredIndicator />}
|
|
68
|
+
</FieldLabel>
|
|
69
|
+
)}
|
|
70
|
+
{children}
|
|
71
|
+
{description && (
|
|
72
|
+
<FieldDescription id={descriptionId}>{description}</FieldDescription>
|
|
73
|
+
)}
|
|
74
|
+
{errors.length > 0 && <FieldError errors={errors} id={errorId} />}
|
|
75
|
+
</Field>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type { FieldShellProps }
|
|
80
|
+
export { FieldShell }
|
package/src/fields/index.ts
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
export * from "./checkbox-field"
|
|
3
3
|
export * from "./checkbox-group-field"
|
|
4
4
|
export * from "./combobox-field"
|
|
5
|
+
export * from "./control-field"
|
|
5
6
|
export * from "./date-picker-field"
|
|
6
7
|
export * from "./email-field"
|
|
8
|
+
export * from "./field-shell"
|
|
7
9
|
export * from "./form-error"
|
|
8
10
|
export * from "./input-field"
|
|
9
11
|
export * from "./money-field"
|