@zeno-lib/forms 0.6.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.
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 +102 -1
  41. package/src/form-dialog.tsx +143 -42
  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
@@ -2,6 +2,21 @@
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"
6
+ import {
7
+ findOption,
8
+ type ItemGroup,
9
+ type ItemObject,
10
+ type ItemOption,
11
+ type ItemValue,
12
+ isItemGroup,
13
+ isSameValue,
14
+ itemLabel,
15
+ itemValue,
16
+ toItemList,
17
+ type ValueComparator,
18
+ valueKey,
19
+ } from "@zeno-lib/forms/lib/item-options"
5
20
  import {
6
21
  useHideFieldErrors,
7
22
  useIsFieldRequired,
@@ -9,16 +24,19 @@ import {
9
24
  useIsInvalid,
10
25
  } from "@zeno-lib/forms/lib/use-is-invalid"
11
26
  import { XIcon } from "lucide-react"
12
- import { type ReactNode, useId } from "react"
27
+ import { type ReactNode, useId, useMemo } from "react"
13
28
  import { Button } from "@/components/ui/button"
14
29
  import {
15
30
  Combobox,
16
31
  ComboboxChip,
17
32
  ComboboxChips,
18
33
  ComboboxChipsInput,
34
+ ComboboxCollection,
19
35
  ComboboxContent,
20
36
  ComboboxEmpty,
37
+ ComboboxGroup,
21
38
  ComboboxItem,
39
+ ComboboxLabel,
22
40
  ComboboxList,
23
41
  ComboboxValue,
24
42
  useComboboxAnchor,
@@ -31,16 +49,23 @@ import {
31
49
  } from "@/components/ui/field"
32
50
  import { RequiredIndicator } from "../lib/required-indicator"
33
51
 
34
- type MultiSelectItemObject<V> = { value: V; label: string }
52
+ type MultiSelectItemObject<V> = ItemObject<V>
53
+
54
+ /** Items under a heading. */
55
+ type MultiSelectItemGroup<T> = ItemGroup<T>
56
+
57
+ /** An item of `items`, or of one of its groups. */
58
+ type MultiSelectOption<T> = ItemOption<T>
35
59
 
36
60
  /** The value stored per selected item: `item.value` for objects, else the item. */
37
- type MultiSelectValue<T> = T extends MultiSelectItemObject<infer V> ? V : T
61
+ type MultiSelectValue<T> = ItemValue<T>
38
62
 
39
63
  type MultiSelectFieldProps<T = string> = {
40
64
  /**
41
65
  * Options shown in the dropdown. Pass plain strings or numbers (value ===
42
66
  * label), or `{ value, label }` objects. The form value is an array of the
43
- * selected values (`item.value` for objects).
67
+ * selected values (`item.value` for objects). To show them under headings,
68
+ * pass `{ label, items }` groups instead.
44
69
  */
45
70
  items: readonly T[]
46
71
  description?: ReactNode
@@ -50,7 +75,18 @@ type MultiSelectFieldProps<T = string> = {
50
75
  /** Message shown when filtering produces zero matches. */
51
76
  emptyMessage?: ReactNode
52
77
  /** Override per-row rendering. Default: the item's label. */
53
- renderItem?: (item: T) => ReactNode
78
+ renderItem?: (item: MultiSelectOption<T>) => ReactNode
79
+ /**
80
+ * Whether an item's value is a stored value. Defaults to `Object.is`. Pass
81
+ * it when the values are objects (`{ kind, id }`), which a reload or a
82
+ * refetch rebuilds: `(a, b) => a.kind === b.kind && a.id === b.id`. Never
83
+ * called with an empty value.
84
+ */
85
+ // 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`.
86
+ isItemEqualToValue?(
87
+ itemValue: MultiSelectValue<T>,
88
+ value: MultiSelectValue<T>
89
+ ): boolean
54
90
  /** Show a button that clears every selection. Defaults to `true`. */
55
91
  showClear?: boolean
56
92
  /** Accessible label of the clear button. Defaults to `"Clear"`. */
@@ -64,23 +100,6 @@ type MultiSelectFieldProps<T = string> = {
64
100
  required?: boolean
65
101
  }
66
102
 
67
- function isItemObject(item: unknown): item is MultiSelectItemObject<unknown> {
68
- return (
69
- typeof item === "object" &&
70
- item !== null &&
71
- "value" in item &&
72
- "label" in item
73
- )
74
- }
75
-
76
- function itemValue(item: unknown): unknown {
77
- return isItemObject(item) ? item.value : item
78
- }
79
-
80
- function itemLabel(item: unknown): string {
81
- return isItemObject(item) ? item.label : String(item)
82
- }
83
-
84
103
  /**
85
104
  * Multi-value combobox: the selection renders as removable chips and the form
86
105
  * value is an array (`[]` when empty).
@@ -91,6 +110,7 @@ function MultiSelectField<T = string>({
91
110
  description,
92
111
  disabled,
93
112
  emptyMessage = "No results.",
113
+ isItemEqualToValue,
94
114
  items,
95
115
  label,
96
116
  placeholder,
@@ -99,6 +119,7 @@ function MultiSelectField<T = string>({
99
119
  showClear = true,
100
120
  }: MultiSelectFieldProps<T>) {
101
121
  const field = useFieldContext<MultiSelectValue<T>[] | null | undefined>()
122
+ const isDisabled = useIsFormDisabled(field.form) || Boolean(disabled)
102
123
  const anchor = useComboboxAnchor()
103
124
  const id = useId()
104
125
  const errorId = `${id}-error`
@@ -111,15 +132,47 @@ function MultiSelectField<T = string>({
111
132
  const schemaRequiresValue = useIsFieldRequiredBySchema(field)
112
133
  const requiresValue = required ?? schemaRequiresValue
113
134
 
135
+ const list = useMemo(() => toItemList(items), [items])
136
+ const isEqual = isItemEqualToValue as ValueComparator | undefined
137
+
114
138
  const values = field.state.value ?? []
115
139
  // Map stored values back to item identities, in selection order, so Base UI
116
140
  // compares by reference and the chips keep the order the user picked.
117
141
  const selected = values
118
- .map((value) => items.find((item) => Object.is(itemValue(item), value)))
119
- .filter((item): item is T => item !== undefined)
142
+ .map((value) => findOption(list, value, isEqual))
143
+ .filter((option): option is MultiSelectOption<T> => option !== undefined)
144
+
145
+ // Base UI only knows the values it has a chip for. Keep the others (their
146
+ // group still loading) where they were, so picking an option doesn't drop
147
+ // them.
148
+ const onValueChange = (next: MultiSelectOption<T>[]) => {
149
+ const picked = next.map(itemValue)
150
+ const kept = values.filter(
151
+ (value) =>
152
+ findOption(list, value, isEqual) === undefined ||
153
+ picked.some((other) => isSameValue(other, value, isEqual))
154
+ )
155
+ const added = picked.filter(
156
+ (other) => !values.some((value) => isSameValue(other, value, isEqual))
157
+ )
158
+ field.handleChange([...kept, ...added] as MultiSelectValue<T>[])
159
+ }
160
+
161
+ const renderOption = (option: MultiSelectOption<T>) =>
162
+ renderItem ? (
163
+ renderItem(option)
164
+ ) : (
165
+ <ComboboxItem key={valueKey(itemValue(option))} value={option}>
166
+ {itemLabel(option)}
167
+ </ComboboxItem>
168
+ )
120
169
 
121
170
  return (
122
- <Field data-field={field.name} data-invalid={isInvalid}>
171
+ <Field
172
+ data-disabled={isDisabled || undefined}
173
+ data-field={field.name}
174
+ data-invalid={isInvalid}
175
+ >
123
176
  {label && (
124
177
  <FieldLabel htmlFor={id}>
125
178
  {label}
@@ -127,21 +180,19 @@ function MultiSelectField<T = string>({
127
180
  </FieldLabel>
128
181
  )}
129
182
  <Combobox
130
- disabled={disabled}
131
- items={items}
183
+ disabled={isDisabled}
184
+ items={list.items}
132
185
  multiple
133
- onValueChange={(next: T[]) =>
134
- field.handleChange(next.map(itemValue) as MultiSelectValue<T>[])
135
- }
186
+ onValueChange={onValueChange}
136
187
  value={selected}
137
188
  >
138
189
  <ComboboxChips className={className} ref={anchor}>
139
190
  <ComboboxValue>
140
- {(chips: T[]) => (
191
+ {(chips: MultiSelectOption<T>[]) => (
141
192
  <>
142
- {chips.map((item) => (
143
- <ComboboxChip key={String(itemValue(item))}>
144
- {itemLabel(item)}
193
+ {chips.map((option) => (
194
+ <ComboboxChip key={valueKey(itemValue(option))}>
195
+ {itemLabel(option)}
145
196
  </ComboboxChip>
146
197
  ))}
147
198
  <ComboboxChipsInput
@@ -159,7 +210,7 @@ function MultiSelectField<T = string>({
159
210
  </>
160
211
  )}
161
212
  </ComboboxValue>
162
- {showClear && selected.length > 0 && !disabled && (
213
+ {showClear && selected.length > 0 && !isDisabled && (
163
214
  <Button
164
215
  aria-label={clearLabel}
165
216
  className="ml-auto"
@@ -175,13 +226,15 @@ function MultiSelectField<T = string>({
175
226
  <ComboboxContent anchor={anchor}>
176
227
  <ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
177
228
  <ComboboxList>
178
- {(item: T) =>
179
- renderItem ? (
180
- renderItem(item)
229
+ {(item: T, index: number) =>
230
+ isItemGroup(item) ? (
231
+ // Two groups can share a heading, or have none.
232
+ <ComboboxGroup items={item.items} key={index}>
233
+ {item.label && <ComboboxLabel>{item.label}</ComboboxLabel>}
234
+ <ComboboxCollection>{renderOption}</ComboboxCollection>
235
+ </ComboboxGroup>
181
236
  ) : (
182
- <ComboboxItem key={String(itemValue(item))} value={item}>
183
- {itemLabel(item)}
184
- </ComboboxItem>
237
+ renderOption(item as MultiSelectOption<T>)
185
238
  )
186
239
  }
187
240
  </ComboboxList>
@@ -197,5 +250,10 @@ function MultiSelectField<T = string>({
197
250
  )
198
251
  }
199
252
 
200
- export type { MultiSelectFieldProps, MultiSelectItemObject, MultiSelectValue }
253
+ export type {
254
+ MultiSelectFieldProps,
255
+ MultiSelectItemGroup,
256
+ MultiSelectItemObject,
257
+ MultiSelectValue,
258
+ }
201
259
  export { MultiSelectField }
@@ -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,
@@ -61,6 +62,7 @@ function OtpField({
61
62
  required,
62
63
  }: OtpFieldProps) {
63
64
  const field = useFieldContext<string>()
65
+ const isDisabled = useIsFormDisabled(field.form) || Boolean(disabled)
64
66
  const id = useId()
65
67
  const errorId = `${id}-error`
66
68
  const descriptionId = `${id}-description`
@@ -73,7 +75,11 @@ function OtpField({
73
75
  const requiresValue = required ?? schemaRequiresValue
74
76
 
75
77
  return (
76
- <Field data-field={field.name} data-invalid={isInvalid}>
78
+ <Field
79
+ data-disabled={isDisabled || undefined}
80
+ data-field={field.name}
81
+ data-invalid={isInvalid}
82
+ >
77
83
  {label && (
78
84
  <FieldLabel htmlFor={id}>
79
85
  {label}
@@ -90,7 +96,7 @@ function OtpField({
90
96
  autoFocus={autoFocus}
91
97
  className={className}
92
98
  containerClassName={containerClassName}
93
- disabled={disabled}
99
+ disabled={isDisabled}
94
100
  id={id}
95
101
  maxLength={maxLength}
96
102
  name={field.name}
@@ -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,
@@ -35,11 +36,13 @@ type RadioGroupFieldProps = Omit<
35
36
  function RadioGroupField({
36
37
  children,
37
38
  description,
39
+ disabled,
38
40
  label,
39
41
  required,
40
42
  ...props
41
43
  }: RadioGroupFieldProps) {
42
44
  const field = useFieldContext()
45
+ const isDisabled = useIsFormDisabled(field.form) || Boolean(disabled)
43
46
  const id = useId()
44
47
  const errorId = `${id}-error`
45
48
  const descriptionId = `${id}-description`
@@ -52,7 +55,11 @@ function RadioGroupField({
52
55
  const requiresValue = required ?? schemaRequiresValue
53
56
 
54
57
  return (
55
- <Field data-field={field.name} data-invalid={isInvalid}>
58
+ <Field
59
+ data-disabled={isDisabled || undefined}
60
+ data-field={field.name}
61
+ data-invalid={isInvalid}
62
+ >
56
63
  {label && (
57
64
  <FieldLabel>
58
65
  {label}
@@ -66,6 +73,7 @@ function RadioGroupField({
66
73
  )}
67
74
  aria-invalid={isInvalid || undefined}
68
75
  aria-required={requiresValue || undefined}
76
+ disabled={isDisabled}
69
77
  name={field.name}
70
78
  onValueChange={(value) => field.handleChange(value)}
71
79
  value={field.state.value ?? null}
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { useFormContext } from "@zeno-lib/forms/lib/contexts"
4
+ import { useIsFormDisabled } from "@zeno-lib/forms/lib/form-disabled"
4
5
  import type { ComponentProps, ReactNode } from "react"
5
6
  import { Button } from "@/components/ui/button"
6
7
 
@@ -8,12 +9,15 @@ type ResetButtonProps = Omit<ComponentProps<typeof Button>, "onClick" | "type">
8
9
 
9
10
  function ResetButton({
10
11
  children,
12
+ disabled,
11
13
  variant = "outline",
12
14
  ...props
13
15
  }: ResetButtonProps) {
14
16
  const form = useFormContext()
17
+ const isDisabled = useIsFormDisabled(form) || Boolean(disabled)
15
18
  return (
16
19
  <Button
20
+ disabled={isDisabled}
17
21
  onClick={() => form.reset()}
18
22
  type="button"
19
23
  variant={variant}
@@ -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,
@@ -44,6 +45,7 @@ type SelectFieldProps = Omit<
44
45
  function SelectField({
45
46
  children,
46
47
  description,
48
+ disabled,
47
49
  label,
48
50
  onOpenChange,
49
51
  placeholder,
@@ -53,6 +55,7 @@ function SelectField({
53
55
  ...props
54
56
  }: SelectFieldProps) {
55
57
  const field = useFieldContext()
58
+ const isDisabled = useIsFormDisabled(field.form) || Boolean(disabled)
56
59
  // Opening the popup moves focus into it, so the trigger's blur would mark
57
60
  // the field touched, and show a required error, before anything is picked.
58
61
  // The field is touched once the popup closes, or focus leaves it closed.
@@ -82,7 +85,11 @@ function SelectField({
82
85
  }, [children])
83
86
 
84
87
  return (
85
- <Field data-field={field.name} data-invalid={isInvalid}>
88
+ <Field
89
+ data-disabled={isDisabled || undefined}
90
+ data-field={field.name}
91
+ data-invalid={isInvalid}
92
+ >
86
93
  {label && (
87
94
  <FieldLabel htmlFor={id}>
88
95
  {label}
@@ -90,6 +97,7 @@ function SelectField({
90
97
  </FieldLabel>
91
98
  )}
92
99
  <Select
100
+ disabled={isDisabled}
93
101
  items={items}
94
102
  name={field.name}
95
103
  onOpenChange={(open, eventDetails) => {
@@ -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,
@@ -37,12 +38,14 @@ type SliderFieldProps = Omit<
37
38
 
38
39
  function SliderField({
39
40
  description,
41
+ disabled,
40
42
  formatValue,
41
43
  label,
42
44
  required,
43
45
  ...props
44
46
  }: SliderFieldProps) {
45
47
  const field = useFieldContext<SliderValue>()
48
+ const isDisabled = useIsFormDisabled(field.form) || Boolean(disabled)
46
49
  const id = useId()
47
50
  const errorId = `${id}-error`
48
51
  const descriptionId = `${id}-description`
@@ -58,7 +61,11 @@ function SliderField({
58
61
  const hasReadout = readout !== null && readout !== undefined
59
62
 
60
63
  return (
61
- <Field data-field={field.name} data-invalid={isInvalid}>
64
+ <Field
65
+ data-disabled={isDisabled || undefined}
66
+ data-field={field.name}
67
+ data-invalid={isInvalid}
68
+ >
62
69
  {(label || hasReadout) && (
63
70
  <FieldContent className="flex-row items-center justify-between">
64
71
  {label && (
@@ -80,6 +87,7 @@ function SliderField({
80
87
  [showError, errorId]
81
88
  )}
82
89
  aria-invalid={isInvalid || undefined}
90
+ disabled={isDisabled}
83
91
  id={id}
84
92
  name={field.name}
85
93
  onBlur={field.handleBlur}
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { useFormContext } from "@zeno-lib/forms/lib/contexts"
4
+ import { useIsFormDisabled } from "@zeno-lib/forms/lib/form-disabled"
4
5
  import type { ComponentProps } from "react"
5
6
  import { Button } from "@/components/ui/button"
6
7
  import { Spinner } from "@/components/ui/spinner"
@@ -9,13 +10,14 @@ type SubmitButtonProps = Omit<ComponentProps<typeof Button>, "type">
9
10
 
10
11
  function SubmitButton({ children, disabled, ...props }: SubmitButtonProps) {
11
12
  const form = useFormContext()
13
+ const isDisabled = useIsFormDisabled(form) || Boolean(disabled)
12
14
  return (
13
15
  <form.Subscribe
14
16
  selector={(state) => [state.canSubmit, state.isSubmitting] as const}
15
17
  >
16
18
  {([canSubmit, isSubmitting]) => (
17
19
  <Button
18
- disabled={disabled || !canSubmit || isSubmitting}
20
+ disabled={isDisabled || !canSubmit || isSubmitting}
19
21
  type="submit"
20
22
  {...props}
21
23
  >
@@ -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 SwitchFieldProps = Omit<
34
35
 
35
36
  function SwitchField({
36
37
  description,
38
+ disabled,
37
39
  label,
38
40
  required,
39
41
  ...props
40
42
  }: SwitchFieldProps) {
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 SwitchField({
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"
@@ -78,6 +82,7 @@ function SwitchField({
78
82
  aria-invalid={isInvalid || undefined}
79
83
  aria-required={requiresValue || undefined}
80
84
  checked={field.state.value ?? false}
85
+ disabled={isDisabled}
81
86
  id={id}
82
87
  name={field.name}
83
88
  onBlur={field.handleBlur}
@@ -24,6 +24,7 @@ type TextAreaFieldProps = Omit<
24
24
  function TextAreaField({
25
25
  children,
26
26
  description,
27
+ disabled,
27
28
  label,
28
29
  required,
29
30
  ...props
@@ -33,6 +34,7 @@ function TextAreaField({
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
@@ -11,7 +11,11 @@ import { type ReactNode, useState } from "react"
11
11
  import { afterEach, describe, expect, onTestFinished, test, vi } from "vitest"
12
12
  import { z } from "zod"
13
13
  import { useForm } from "./create-form"
14
- import { FormDialog } from "./form-dialog"
14
+ import {
15
+ type DiscardPromptText,
16
+ FormDialog,
17
+ FormDialogProvider,
18
+ } from "./form-dialog"
15
19
  import type { ActionResult } from "./lib/action-result"
16
20
  import { submitAction } from "./lib/submit-action"
17
21
  import { useFormDialog } from "./lib/use-form-dialog"
@@ -51,8 +55,10 @@ function Harness({
51
55
  onSubmit = vi.fn(),
52
56
  closeOnSubmit,
53
57
  disabled,
58
+ discardPrompt,
54
59
  saveFromPrompt,
55
60
  submitErrorMessage,
61
+ submitLabel,
56
62
  }: {
57
63
  /** Submit through `submitAction` with this server action instead. */
58
64
  action?: (values: Values) => Promise<ActionResult<unknown>>
@@ -60,8 +66,10 @@ function Harness({
60
66
  onSubmit?: (values: Values) => unknown
61
67
  closeOnSubmit?: boolean
62
68
  disabled?: boolean
69
+ discardPrompt?: DiscardPromptText
63
70
  saveFromPrompt?: boolean
64
71
  submitErrorMessage?: (error: unknown) => ReactNode
72
+ submitLabel?: ReactNode
65
73
  }) {
66
74
  const dialog = useFormDialog({ defaultValues: BLANK })
67
75
  const form = useForm({
@@ -86,9 +94,11 @@ function Harness({
86
94
  closeOnSubmit={closeOnSubmit}
87
95
  dialog={dialog}
88
96
  disabled={disabled}
97
+ discardPrompt={discardPrompt}
89
98
  form={form}
90
99
  saveFromPrompt={saveFromPrompt}
91
100
  submitErrorMessage={submitErrorMessage}
101
+ submitLabel={submitLabel}
92
102
  title="Person"
93
103
  >
94
104
  <InputField label="Name" name="name" />
@@ -650,3 +660,94 @@ describe("FormDialog", () => {
650
660
  expect(outer).not.toHaveBeenCalled()
651
661
  })
652
662
  })
663
+
664
+ describe("FormDialogProvider", () => {
665
+ test("sets the defaults of the dialogs below it; a dialog's own prop wins", async () => {
666
+ const user = userEvent.setup()
667
+ render(
668
+ <FormDialogProvider
669
+ cancelLabel="Abbrechen"
670
+ discardPrompt={{
671
+ confirmLabel: "Verwerfen",
672
+ saveLabel: "Sichern",
673
+ saveTitle: "Speichern?",
674
+ title: "Verwerfen?",
675
+ }}
676
+ >
677
+ <FormDialogProvider saveFromPrompt submitLabel="Speichern">
678
+ <Harness discardPrompt={{ title: "Leave this person?" }} />
679
+ </FormDialogProvider>
680
+ </FormDialogProvider>
681
+ )
682
+ const name = await openEdit(user)
683
+ const dialog = screen.getByRole("dialog")
684
+ within(dialog).getByRole("button", { name: "Speichern" })
685
+ await user.type(name, "!")
686
+ await user.click(within(dialog).getByRole("button", { name: "Abbrechen" }))
687
+
688
+ // The prompt's copy merges key by key, and the nearest level wins: the
689
+ // dialog's title over the outer provider's `saveTitle`, its Discard
690
+ // label, and Save labelled by the inner provider's `submitLabel` over the
691
+ // outer one's `saveLabel`.
692
+ const prompt = await screen.findByRole("alertdialog")
693
+ within(prompt).getByText("Leave this person?")
694
+ within(prompt).getByRole("button", { name: "Verwerfen" })
695
+ within(prompt).getByRole("button", { name: "Speichern" })
696
+ })
697
+
698
+ test.each([
699
+ [true, "Unsaved changes", "Save them before you leave?"],
700
+ [false, "Discard changes?", "They will be lost."],
701
+ ])(
702
+ "a provider words the prompt for each mode (saveFromPrompt: %s)",
703
+ async (saveFromPrompt, title, description) => {
704
+ const user = userEvent.setup()
705
+ render(
706
+ <FormDialogProvider
707
+ discardPrompt={{
708
+ description: "They will be lost.",
709
+ saveDescription: "Save them before you leave?",
710
+ saveTitle: "Unsaved changes",
711
+ title: "Discard changes?",
712
+ }}
713
+ >
714
+ <Harness saveFromPrompt={saveFromPrompt} />
715
+ </FormDialogProvider>
716
+ )
717
+ const name = await openEdit(user)
718
+ await user.type(name, "!")
719
+ await user.keyboard("{Escape}")
720
+ const prompt = await screen.findByRole("alertdialog")
721
+ within(prompt).getByText(title)
722
+ within(prompt).getByText(description)
723
+ }
724
+ )
725
+
726
+ test("a dialog's submitLabel labels the prompt's Save over a provider's saveLabel", async () => {
727
+ const user = userEvent.setup()
728
+ render(
729
+ <FormDialogProvider discardPrompt={{ saveLabel: "Speichern" }}>
730
+ <Harness saveFromPrompt submitLabel="Create invoice" />
731
+ </FormDialogProvider>
732
+ )
733
+ const name = await openEdit(user)
734
+ await user.type(name, "!")
735
+ await user.keyboard("{Escape}")
736
+ const prompt = await screen.findByRole("alertdialog")
737
+ within(prompt).getByRole("button", { name: "Create invoice" })
738
+ })
739
+
740
+ test("a dialog's own false wins over the provider's true", async () => {
741
+ const user = userEvent.setup()
742
+ render(
743
+ <FormDialogProvider saveFromPrompt>
744
+ <Harness saveFromPrompt={false} />
745
+ </FormDialogProvider>
746
+ )
747
+ const name = await openEdit(user)
748
+ await user.type(name, "!")
749
+ await user.keyboard("{Escape}")
750
+ const prompt = await screen.findByRole("alertdialog")
751
+ expect(within(prompt).queryByRole("button", { name: "Save" })).toBeNull()
752
+ })
753
+ })