@zeno-lib/forms 0.6.0 → 0.7.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 (59) hide show
  1. package/dist/create-zeno-form.d.mts +19 -4
  2. package/dist/create-zeno-form.mjs +29 -6
  3. package/dist/form-element.d.mts +16 -3
  4. package/dist/form-element.mjs +38 -18
  5. package/dist/index.d.mts +2 -1
  6. package/dist/index.mjs +2 -1
  7. package/dist/lib/date-value.d.mts +24 -0
  8. package/dist/lib/date-value.mjs +37 -0
  9. package/dist/lib/default-props.d.mts +7 -0
  10. package/dist/lib/default-props.mjs +13 -0
  11. package/dist/lib/empty-value.mjs +25 -1
  12. package/dist/lib/form-disabled.d.mts +28 -0
  13. package/dist/lib/form-disabled.mjs +54 -0
  14. package/dist/lib/item-options.d.mts +45 -0
  15. package/dist/lib/item-options.mjs +70 -0
  16. package/dist/lib/schema-required.mjs +17 -0
  17. package/dist/lib/use-field-control.d.mts +7 -0
  18. package/dist/lib/use-field-control.mjs +4 -1
  19. package/package.json +1 -1
  20. package/src/create-form.tsx +34 -29
  21. package/src/create-zeno-form.tsx +124 -51
  22. package/src/date-picker-field.test.tsx +108 -0
  23. package/src/default-props.test-d.ts +66 -0
  24. package/src/default-props.test.tsx +165 -0
  25. package/src/fields/checkbox-field.tsx +5 -0
  26. package/src/fields/checkbox-group-field.tsx +6 -2
  27. package/src/fields/combobox-field.tsx +107 -40
  28. package/src/fields/date-picker-field.tsx +34 -11
  29. package/src/fields/field-shell.tsx +8 -1
  30. package/src/fields/input-field.tsx +2 -0
  31. package/src/fields/multi-select-field.tsx +100 -42
  32. package/src/fields/otp-field.tsx +8 -2
  33. package/src/fields/radio-group-field.tsx +9 -1
  34. package/src/fields/reset-button.tsx +4 -0
  35. package/src/fields/select-field.tsx +9 -1
  36. package/src/fields/slider-field.tsx +9 -1
  37. package/src/fields/submit-button.tsx +3 -1
  38. package/src/fields/switch-field.tsx +5 -0
  39. package/src/fields/textarea-field.tsx +2 -0
  40. package/src/form-dialog.test.tsx +255 -4
  41. package/src/form-dialog.tsx +187 -51
  42. package/src/form-disabled.test.tsx +329 -0
  43. package/src/form-element.tsx +64 -29
  44. package/src/grouped-options.test-d.ts +59 -0
  45. package/src/grouped-options.test.tsx +251 -0
  46. package/src/index.ts +1 -0
  47. package/src/lib/date-value.test.ts +14 -0
  48. package/src/lib/date-value.ts +70 -0
  49. package/src/lib/default-props.ts +19 -0
  50. package/src/lib/empty-value.test.ts +19 -0
  51. package/src/lib/empty-value.ts +45 -1
  52. package/src/lib/form-disabled.ts +95 -0
  53. package/src/lib/item-options.test.ts +11 -0
  54. package/src/lib/item-options.ts +165 -0
  55. package/src/lib/schema-required.test.ts +9 -0
  56. package/src/lib/schema-required.ts +33 -0
  57. package/src/lib/use-field-control.ts +11 -1
  58. package/src/with-form.test-d.ts +76 -0
  59. package/src/with-form.test.tsx +60 -0
@@ -0,0 +1,165 @@
1
+ import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
2
+ import userEvent from "@zeno-lib/test/user-event"
3
+ import { afterEach, describe, expect, test, vi } from "vitest"
4
+ import { createZenoForm } from "./create-zeno-form"
5
+ import { FormError } from "./fields/form-error"
6
+ import { InputField } from "./fields/input-field"
7
+ import { MoneyField } from "./fields/money-field"
8
+ import { SubmitButton } from "./fields/submit-button"
9
+ import { FormDialog } from "./form-dialog"
10
+ import { Form, FormProvider } from "./form-element"
11
+ import { useFormDialog } from "./lib/use-form-dialog"
12
+
13
+ afterEach(() => {
14
+ cleanup()
15
+ vi.unstubAllGlobals()
16
+ })
17
+
18
+ const { useForm } = createZenoForm({
19
+ defaultProps: {
20
+ FormError: { submitErrorMessage: "Not saved. Try again later." },
21
+ MoneyField: { currency: "CHF", currencyDisplay: "code", placeholder: "0" },
22
+ },
23
+ fieldComponents: { InputField, MoneyField },
24
+ formComponents: { FormError, SubmitButton },
25
+ })
26
+
27
+ const VALUES = {
28
+ amount: null as number | null,
29
+ fee: null as number | null,
30
+ name: "",
31
+ total: null as number | null,
32
+ }
33
+
34
+ function Harness({ onSubmit = vi.fn() }: { onSubmit?: () => unknown }) {
35
+ const form = useForm({ defaultValues: VALUES, onSubmit })
36
+ const unset: string | undefined = undefined
37
+ return (
38
+ <FormProvider form={form}>
39
+ <Form>
40
+ <form.MoneyField label="Amount" name="amount" />
41
+ <form.MoneyField currency="EUR" label="Fee" name="fee" />
42
+ <form.MoneyField currency={unset} label="Total" name="total" />
43
+ <form.InputField label="Name" name="name" />
44
+ <form.FormError />
45
+ <form.SubmitButton>Submit</form.SubmitButton>
46
+ </Form>
47
+ </FormProvider>
48
+ )
49
+ }
50
+
51
+ function addonOf(label: string) {
52
+ return screen
53
+ .getByLabelText(label)
54
+ .closest("[data-slot=input-group]")
55
+ ?.querySelector("[data-slot=input-group-addon]")?.textContent
56
+ }
57
+
58
+ describe("createZenoForm({ defaultProps })", () => {
59
+ test("a field renders with its defaults, and a prop passed at the call site wins", () => {
60
+ render(<Harness />)
61
+ expect(addonOf("Amount")).toBe("CHF")
62
+ expect(addonOf("Fee")).toBe("EUR")
63
+ })
64
+
65
+ test("a prop passed as undefined keeps the default", () => {
66
+ render(<Harness />)
67
+ expect(addonOf("Total")).toBe("CHF")
68
+ })
69
+
70
+ test("a component with no defaults renders as before", () => {
71
+ render(<Harness />)
72
+ expect(screen.getByLabelText("Amount").getAttribute("placeholder")).toBe(
73
+ "0"
74
+ )
75
+ // InputField shares MoneyField's props, but not its defaults.
76
+ expect(screen.getByLabelText("Name").hasAttribute("placeholder")).toBe(
77
+ false
78
+ )
79
+ })
80
+
81
+ test("a form component renders with its defaults", async () => {
82
+ vi.stubGlobal("reportError", vi.fn())
83
+ const user = userEvent.setup()
84
+ render(
85
+ <Harness
86
+ onSubmit={() => {
87
+ throw new Error("offline")
88
+ }}
89
+ />
90
+ )
91
+ await user.click(screen.getByRole("button", { name: "Submit" }))
92
+ expect((await screen.findByRole("alert")).textContent).toBe(
93
+ "Not saved. Try again later."
94
+ )
95
+ })
96
+
97
+ test("a FormDialog renders the form's FormError, with its defaults", async () => {
98
+ vi.stubGlobal("reportError", vi.fn())
99
+ const user = userEvent.setup()
100
+ function DialogHarness() {
101
+ const dialog = useFormDialog({ defaultValues: VALUES })
102
+ const form = useForm({
103
+ defaultValues: dialog.defaultValues,
104
+ onSubmit: () => {
105
+ throw new Error("offline")
106
+ },
107
+ })
108
+ return (
109
+ <>
110
+ <button onClick={() => dialog.open()} type="button">
111
+ New
112
+ </button>
113
+ <FormDialog dialog={dialog} form={form} title="Payment">
114
+ <form.InputField label="Name" name="name" />
115
+ </FormDialog>
116
+ </>
117
+ )
118
+ }
119
+ render(<DialogHarness />)
120
+ await user.click(screen.getByRole("button", { name: "New" }))
121
+ await user.click(await screen.findByRole("button", { name: "Save" }))
122
+ expect((await screen.findByRole("alert")).textContent).toBe(
123
+ "Not saved. Try again later."
124
+ )
125
+ })
126
+
127
+ test("a component with defaults keeps its statics", () => {
128
+ function Hint({ text = "None" }: { text?: string }) {
129
+ return <p>{text}</p>
130
+ }
131
+ Hint.Icon = () => <span>i</span>
132
+ const { useForm: useHintForm } = createZenoForm({
133
+ defaultProps: { Hint: { text: "Marked fields are required" } },
134
+ fieldComponents: { InputField },
135
+ formComponents: { Hint },
136
+ })
137
+ function HintHarness() {
138
+ const form = useHintForm({ defaultValues: {}, onSubmit: vi.fn() })
139
+ return (
140
+ <FormProvider form={form}>
141
+ <form.Hint />
142
+ <form.Hint.Icon />
143
+ </FormProvider>
144
+ )
145
+ }
146
+ render(<HintHarness />)
147
+ screen.getByText("Marked fields are required")
148
+ screen.getByText("i")
149
+ })
150
+
151
+ test("a field rendered through AppField gets its defaults too", () => {
152
+ function AppFieldHarness() {
153
+ const form = useForm({ defaultValues: VALUES, onSubmit: vi.fn() })
154
+ return (
155
+ <FormProvider form={form}>
156
+ <form.AppField name="amount">
157
+ {(field) => <field.MoneyField label="Amount" />}
158
+ </form.AppField>
159
+ </FormProvider>
160
+ )
161
+ }
162
+ render(<AppFieldHarness />)
163
+ expect(addonOf("Amount")).toBe("CHF")
164
+ })
165
+ })
@@ -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={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={disabled || option.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> = { value: V; label: string }
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, query: string) => boolean) | null
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
- function isItemObject(item: unknown): item is ComboboxItemObject<unknown> {
80
- return (
81
- typeof item === "object" &&
82
- item !== null &&
83
- "value" in item &&
84
- "label" in item
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 = items.find(
122
- (item) => (isItemObject(item) ? item.value : item) === fieldValue
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 data-field={field.name} data-invalid={isInvalid}>
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={disabled}
205
+ <Combobox<ComboboxOption<T>>
206
+ disabled={isDisabled}
138
207
  filter={filter}
139
208
  inputValue={inputValue}
140
- items={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(isItemObject(next) ? next.value : next)
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={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
- if (renderItem) {
177
- return renderItem(item)
178
- }
179
- if (isItemObject(item)) {
180
- return (
181
- <ComboboxItem key={String(item.value)} value={item}>
182
- {item.label}
183
- </ComboboxItem>
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 }
@@ -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 { value } = field.state
89
- const empty = !value
105
+ const date = parseDateValue(field.state.value)
90
106
 
91
107
  return (
92
- <Field data-field={field.name} data-invalid={isInvalid}>
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={empty || undefined}
119
- disabled={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
- {empty ? <span>{placeholder}</span> : formatValue(value)}
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(next ?? getEmptyValue(field, undefined))
155
+ field.handleChange(
156
+ next
157
+ ? toDateValue(next, valueType)
158
+ : getEmptyValue(field, undefined)
159
+ )
135
160
  }
136
161
  required={requiresValue}
137
- selected={value}
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
  )
@@ -24,6 +24,8 @@ type FieldShellProps = Pick<
24
24
  /** The control, bound with `useFieldControl`'s `controlProps`. */
25
25
  children: ReactNode
26
26
  description?: ReactNode
27
+ /** Dims the label. `useFieldControl` returns it. */
28
+ isDisabled?: boolean
27
29
  label?: ReactNode
28
30
  }
29
31
 
@@ -46,6 +48,7 @@ function FieldShell({
46
48
  errorId,
47
49
  errors,
48
50
  id,
51
+ isDisabled,
49
52
  isInvalid,
50
53
  label,
51
54
  labelId,
@@ -53,7 +56,11 @@ function FieldShell({
53
56
  showRequiredIndicator,
54
57
  }: FieldShellProps) {
55
58
  return (
56
- <Field data-field={name} data-invalid={isInvalid}>
59
+ <Field
60
+ data-disabled={isDisabled || undefined}
61
+ data-field={name}
62
+ data-invalid={isInvalid}
63
+ >
57
64
  {label && (
58
65
  <FieldLabel htmlFor={id} id={labelId}>
59
66
  {label}
@@ -24,6 +24,7 @@ type InputFieldProps = Omit<
24
24
  function InputField({
25
25
  children,
26
26
  description,
27
+ disabled,
27
28
  label,
28
29
  required,
29
30
  ...props
@@ -33,6 +34,7 @@ function InputField({
33
34
  // `aria-labelledby` and the hook doesn't get `label`.
34
35
  const { controlProps, ...shell } = useFieldControl<string>({
35
36
  description,
37
+ disabled,
36
38
  required,
37
39
  })
38
40
  const hasAddons = Children.count(children) > 0