@zeno-lib/forms 0.1.0 → 0.2.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 (63) hide show
  1. package/dist/create-zeno-form.d.mts +118 -0
  2. package/dist/create-zeno-form.mjs +121 -0
  3. package/dist/form-element.d.mts +13 -0
  4. package/dist/form-element.mjs +31 -0
  5. package/dist/index.d.mts +11 -0
  6. package/dist/index.mjs +10 -0
  7. package/dist/lib/action-result.d.mts +22 -0
  8. package/dist/lib/action-result.mjs +32 -0
  9. package/dist/lib/apply-validation-error.d.mts +6 -0
  10. package/dist/lib/apply-validation-error.mjs +41 -0
  11. package/dist/lib/aria.d.mts +4 -0
  12. package/dist/lib/aria.mjs +7 -0
  13. package/dist/lib/contexts.d.mts +5 -0
  14. package/dist/lib/contexts.mjs +7 -0
  15. package/dist/lib/formatted-number.d.mts +56 -0
  16. package/dist/lib/formatted-number.mjs +138 -0
  17. package/dist/lib/schema-defaults.d.mts +17 -0
  18. package/dist/lib/schema-defaults.mjs +75 -0
  19. package/dist/lib/schema-required.d.mts +25 -0
  20. package/dist/lib/schema-required.mjs +72 -0
  21. package/dist/lib/submit-action.d.mts +64 -0
  22. package/dist/lib/submit-action.mjs +43 -0
  23. package/dist/lib/use-form-dialog.d.mts +73 -0
  24. package/dist/lib/use-form-dialog.mjs +90 -0
  25. package/dist/lib/use-formatted-number.d.mts +61 -0
  26. package/dist/lib/use-formatted-number.mjs +104 -0
  27. package/dist/lib/use-is-invalid.d.mts +7 -0
  28. package/dist/lib/use-is-invalid.mjs +31 -0
  29. package/dist/lib/use-rebased-default-values.d.mts +10 -0
  30. package/dist/lib/use-rebased-default-values.mjs +21 -0
  31. package/dist/lib/use-unsaved-changes-warning.d.mts +6 -0
  32. package/dist/lib/use-unsaved-changes-warning.mjs +24 -0
  33. package/dist/lib/validation-error.d.mts +12 -0
  34. package/dist/lib/validation-error.mjs +11 -0
  35. package/dist/lib/validation-logic.d.mts +5 -0
  36. package/dist/lib/validation-logic.mjs +114 -0
  37. package/dist/lib/validation-modes.d.mts +16 -0
  38. package/dist/lib/validation-modes.mjs +38 -0
  39. package/dist/tanstack.d.mts +1 -0
  40. package/dist/tanstack.mjs +2 -0
  41. package/package.json +25 -7
  42. package/src/create-form.tsx +10 -0
  43. package/src/fields/checkbox-group-field.tsx +160 -0
  44. package/src/fields/index.ts +5 -0
  45. package/src/fields/money-field.tsx +123 -0
  46. package/src/fields/multi-select-field.tsx +193 -0
  47. package/src/fields/percentage-field.tsx +73 -0
  48. package/src/fields/year-field.tsx +56 -0
  49. package/src/form-dialog.test.tsx +161 -0
  50. package/src/form-dialog.tsx +253 -0
  51. package/src/formatted-number-fields.test.tsx +236 -0
  52. package/src/index.ts +13 -0
  53. package/src/lib/action-result.ts +61 -0
  54. package/src/lib/apply-validation-error.test.ts +15 -4
  55. package/src/lib/apply-validation-error.ts +38 -6
  56. package/src/lib/formatted-number.test.ts +157 -0
  57. package/src/lib/formatted-number.ts +307 -0
  58. package/src/lib/submit-action.test-d.ts +83 -0
  59. package/src/lib/submit-action.ts +178 -0
  60. package/src/lib/use-form-dialog.ts +184 -0
  61. package/src/lib/use-formatted-number.ts +235 -0
  62. package/src/multi-value-fields.test.tsx +116 -0
  63. package/src/submit-action.test.tsx +421 -0
@@ -0,0 +1,193 @@
1
+ "use client"
2
+
3
+ import { describedBy } from "@zeno-lib/forms/lib/aria"
4
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
5
+ import {
6
+ useHideFieldErrors,
7
+ useIsFieldRequired,
8
+ useIsInvalid,
9
+ } from "@zeno-lib/forms/lib/use-is-invalid"
10
+ import { XIcon } from "lucide-react"
11
+ import type { ReactNode } from "react"
12
+ import { Button } from "@/components/ui/button"
13
+ import {
14
+ Combobox,
15
+ ComboboxChip,
16
+ ComboboxChips,
17
+ ComboboxChipsInput,
18
+ ComboboxContent,
19
+ ComboboxEmpty,
20
+ ComboboxItem,
21
+ ComboboxList,
22
+ ComboboxValue,
23
+ useComboboxAnchor,
24
+ } from "@/components/ui/combobox"
25
+ import {
26
+ Field,
27
+ FieldDescription,
28
+ FieldError,
29
+ FieldLabel,
30
+ } from "@/components/ui/field"
31
+ import { RequiredIndicator } from "../lib/required-indicator"
32
+
33
+ type MultiSelectItemObject<V> = { value: V; label: string }
34
+
35
+ /** The value stored per selected item: `item.value` for objects, else the item. */
36
+ type MultiSelectValue<T> = T extends MultiSelectItemObject<infer V> ? V : T
37
+
38
+ type MultiSelectFieldProps<T = string> = {
39
+ /**
40
+ * Options shown in the dropdown. Pass plain strings or numbers (value ===
41
+ * label), or `{ value, label }` objects. The form value is an array of the
42
+ * selected values (`item.value` for objects).
43
+ */
44
+ items: readonly T[]
45
+ description?: ReactNode
46
+ label?: ReactNode
47
+ /** Shown in the search input while nothing is selected. */
48
+ placeholder?: string
49
+ /** Message shown when filtering produces zero matches. */
50
+ emptyMessage?: ReactNode
51
+ /** Override per-row rendering. Default: the item's label. */
52
+ renderItem?: (item: T) => ReactNode
53
+ /** Show a button that clears every selection. Defaults to `true`. */
54
+ showClear?: boolean
55
+ /** Accessible label of the clear button. Defaults to `"Clear"`. */
56
+ clearLabel?: string
57
+ disabled?: boolean
58
+ className?: string
59
+ /** Force the required `*` indicator on or off. Defaults to schema-derived. */
60
+ required?: boolean
61
+ }
62
+
63
+ function isItemObject(item: unknown): item is MultiSelectItemObject<unknown> {
64
+ return (
65
+ typeof item === "object" &&
66
+ item !== null &&
67
+ "value" in item &&
68
+ "label" in item
69
+ )
70
+ }
71
+
72
+ function itemValue(item: unknown): unknown {
73
+ return isItemObject(item) ? item.value : item
74
+ }
75
+
76
+ function itemLabel(item: unknown): string {
77
+ return isItemObject(item) ? item.label : String(item)
78
+ }
79
+
80
+ /**
81
+ * Multi-value combobox: the selection renders as removable chips and the form
82
+ * value is an array (`[]` when empty).
83
+ */
84
+ function MultiSelectField<T = string>({
85
+ className,
86
+ clearLabel = "Clear",
87
+ description,
88
+ disabled,
89
+ emptyMessage = "No results.",
90
+ items,
91
+ label,
92
+ placeholder,
93
+ renderItem,
94
+ required,
95
+ showClear = true,
96
+ }: MultiSelectFieldProps<T>) {
97
+ const field = useFieldContext<MultiSelectValue<T>[] | null | undefined>()
98
+ const anchor = useComboboxAnchor()
99
+ const errorId = `${field.name}-error`
100
+ const descriptionId = `${field.name}-description`
101
+ const isInvalid = useIsInvalid(field)
102
+ const hideErrors = useHideFieldErrors(field)
103
+ const showError = isInvalid && !hideErrors
104
+ const schemaRequired = useIsFieldRequired(field)
105
+ const isRequired = required ?? schemaRequired
106
+
107
+ const values = field.state.value ?? []
108
+ // Map stored values back to item identities, in selection order, so Base UI
109
+ // compares by reference and the chips keep the order the user picked.
110
+ const selected = values
111
+ .map((value) => items.find((item) => Object.is(itemValue(item), value)))
112
+ .filter((item): item is T => item !== undefined)
113
+
114
+ return (
115
+ <Field data-field={field.name} data-invalid={isInvalid}>
116
+ {label && (
117
+ <FieldLabel htmlFor={field.name}>
118
+ {label}
119
+ {isRequired && <RequiredIndicator />}
120
+ </FieldLabel>
121
+ )}
122
+ <Combobox
123
+ disabled={disabled}
124
+ items={items}
125
+ multiple
126
+ onValueChange={(next: T[]) =>
127
+ field.handleChange(next.map(itemValue) as MultiSelectValue<T>[])
128
+ }
129
+ value={selected}
130
+ >
131
+ <ComboboxChips className={className} ref={anchor}>
132
+ <ComboboxValue>
133
+ {(chips: T[]) => (
134
+ <>
135
+ {chips.map((item) => (
136
+ <ComboboxChip key={String(itemValue(item))}>
137
+ {itemLabel(item)}
138
+ </ComboboxChip>
139
+ ))}
140
+ <ComboboxChipsInput
141
+ aria-describedby={describedBy(
142
+ [description, descriptionId],
143
+ [showError, errorId]
144
+ )}
145
+ aria-invalid={isInvalid || undefined}
146
+ id={field.name}
147
+ name={field.name}
148
+ onBlur={field.handleBlur}
149
+ placeholder={chips.length === 0 ? placeholder : undefined}
150
+ />
151
+ </>
152
+ )}
153
+ </ComboboxValue>
154
+ {showClear && selected.length > 0 && !disabled && (
155
+ <Button
156
+ aria-label={clearLabel}
157
+ className="ml-auto"
158
+ onClick={() => field.handleChange([])}
159
+ size="icon-xs"
160
+ type="button"
161
+ variant="ghost"
162
+ >
163
+ <XIcon />
164
+ </Button>
165
+ )}
166
+ </ComboboxChips>
167
+ <ComboboxContent anchor={anchor}>
168
+ <ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
169
+ <ComboboxList>
170
+ {(item: T) =>
171
+ renderItem ? (
172
+ renderItem(item)
173
+ ) : (
174
+ <ComboboxItem key={String(itemValue(item))} value={item}>
175
+ {itemLabel(item)}
176
+ </ComboboxItem>
177
+ )
178
+ }
179
+ </ComboboxList>
180
+ </ComboboxContent>
181
+ </Combobox>
182
+ {description && (
183
+ <FieldDescription id={descriptionId}>{description}</FieldDescription>
184
+ )}
185
+ {showError && (
186
+ <FieldError errors={field.state.meta.errors} id={errorId} />
187
+ )}
188
+ </Field>
189
+ )
190
+ }
191
+
192
+ export type { MultiSelectFieldProps, MultiSelectItemObject, MultiSelectValue }
193
+ export { MultiSelectField }
@@ -0,0 +1,73 @@
1
+ "use client"
2
+
3
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
4
+ import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
5
+ import { InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
6
+ import { InputField, type InputFieldProps } from "./input-field"
7
+
8
+ type PercentageFieldProps = Omit<
9
+ InputFieldProps,
10
+ "defaultValue" | "inputMode" | "onChange" | "onPaste" | "type" | "value"
11
+ > & {
12
+ /**
13
+ * How the form value relates to what the user types:
14
+ * - `"percent"` (default): the value is the number shown, `12.5` ↔ `12.5 %`.
15
+ * - `"fraction"`: the value is a ratio, `0.125` ↔ `12.5 %`.
16
+ */
17
+ scale?: "fraction" | "percent"
18
+ /** BCP 47 locale driving the decimal separator. Defaults to the runtime locale. */
19
+ locale?: string
20
+ /** Digits allowed after the decimal separator (of the shown percentage). Defaults to `2`. */
21
+ fractionDigits?: number
22
+ /** Accept negative percentages. Defaults to `false`. */
23
+ allowNegative?: boolean
24
+ /** Clamp on blur, in the form value's scale. */
25
+ min?: number
26
+ /** Clamp on blur, in the form value's scale. */
27
+ max?: number
28
+ }
29
+
30
+ const toPercent = (value: number) => value * 100
31
+ const fromPercent = (value: number) => value / 100
32
+
33
+ /**
34
+ * Percentage input with a `%` add-on. The form value is `number | null`, on
35
+ * the `scale` you pick (`0–100` by default, `0–1` with `scale="fraction"`).
36
+ */
37
+ function PercentageField({
38
+ allowNegative = false,
39
+ children,
40
+ fractionDigits = 2,
41
+ locale,
42
+ max,
43
+ min,
44
+ scale = "percent",
45
+ ...props
46
+ }: PercentageFieldProps) {
47
+ const field = useFieldContext<number | null | undefined>()
48
+ const isFraction = scale === "fraction"
49
+
50
+ const inputProps = useFormattedNumber({
51
+ allowNegative,
52
+ locale,
53
+ max,
54
+ maximumFractionDigits: fractionDigits,
55
+ min,
56
+ onBlur: field.handleBlur,
57
+ onValueChange: (next) => field.handleChange(next),
58
+ value: field.state.value,
59
+ ...(isFraction ? { fromDisplay: fromPercent, toDisplay: toPercent } : {}),
60
+ })
61
+
62
+ return (
63
+ <InputField {...props} {...inputProps}>
64
+ <InputGroupAddon align="inline-end">
65
+ <InputGroupText>%</InputGroupText>
66
+ </InputGroupAddon>
67
+ {children}
68
+ </InputField>
69
+ )
70
+ }
71
+
72
+ export type { PercentageFieldProps }
73
+ export { PercentageField }
@@ -0,0 +1,56 @@
1
+ "use client"
2
+
3
+ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
4
+ import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
5
+ import { InputField, type InputFieldProps } from "./input-field"
6
+
7
+ type YearFieldProps = Omit<
8
+ InputFieldProps,
9
+ | "defaultValue"
10
+ | "inputMode"
11
+ | "max"
12
+ | "maxLength"
13
+ | "min"
14
+ | "onChange"
15
+ | "onPaste"
16
+ | "type"
17
+ | "value"
18
+ > & {
19
+ /** Earliest accepted year. The value is clamped to it on blur. */
20
+ min?: number
21
+ /** Latest accepted year. The value is clamped to it on blur. */
22
+ max?: number
23
+ }
24
+
25
+ /**
26
+ * Four-digit year input. The form value is an integer year or `null`. No
27
+ * thousands separator, digits only, clamped to `min`/`max` on blur.
28
+ */
29
+ function YearField({ max, min, ...props }: YearFieldProps) {
30
+ const field = useFieldContext<number | null | undefined>()
31
+
32
+ const inputProps = useFormattedNumber({
33
+ allowNegative: false,
34
+ max,
35
+ maxIntegerDigits: 4,
36
+ maximumFractionDigits: 0,
37
+ min,
38
+ onBlur: field.handleBlur,
39
+ onValueChange: (next) => field.handleChange(next),
40
+ useGrouping: false,
41
+ value: field.state.value,
42
+ })
43
+
44
+ return (
45
+ <InputField
46
+ autoComplete="off"
47
+ maxLength={4}
48
+ placeholder="YYYY"
49
+ {...props}
50
+ {...inputProps}
51
+ />
52
+ )
53
+ }
54
+
55
+ export type { YearFieldProps }
56
+ export { YearField }
@@ -0,0 +1,161 @@
1
+ import {
2
+ cleanup,
3
+ render,
4
+ screen,
5
+ waitFor,
6
+ } from "@zeno-lib/test/testing-library"
7
+ import userEvent from "@zeno-lib/test/user-event"
8
+ import { afterEach, describe, expect, test, vi } from "vitest"
9
+ import { z } from "zod"
10
+ import { useForm } from "./create-form"
11
+ import { FormDialog } from "./form-dialog"
12
+ import { useFormDialog } from "./lib/use-form-dialog"
13
+
14
+ const NAME = /Name/
15
+ const EMAIL = /Email/
16
+
17
+ afterEach(() => {
18
+ cleanup()
19
+ })
20
+
21
+ const schema = z.object({
22
+ email: z.string(),
23
+ name: z.string().min(1, "Required"),
24
+ })
25
+ type Values = z.infer<typeof schema>
26
+ const BLANK: Values = { email: "", name: "" }
27
+ const ROW: Values = { email: "ada@example.com", name: "Ada" }
28
+
29
+ function Harness({
30
+ onSubmit = vi.fn(),
31
+ closeOnSubmit,
32
+ }: {
33
+ onSubmit?: (values: Values) => unknown
34
+ closeOnSubmit?: boolean
35
+ }) {
36
+ const dialog = useFormDialog({ defaultValues: BLANK })
37
+ const form = useForm({
38
+ defaultValues: dialog.defaultValues,
39
+ onSubmit: ({ value }) => onSubmit(value),
40
+ schema,
41
+ })
42
+ const { InputField } = form
43
+ return (
44
+ <>
45
+ <button onClick={() => dialog.open()} type="button">
46
+ New
47
+ </button>
48
+ <button
49
+ onClick={() => dialog.open({ defaultValues: ROW, focus: "email" })}
50
+ type="button"
51
+ >
52
+ Edit
53
+ </button>
54
+ <FormDialog
55
+ closeOnSubmit={closeOnSubmit}
56
+ dialog={dialog}
57
+ form={form}
58
+ title="Person"
59
+ >
60
+ <InputField label="Name" name="name" />
61
+ <InputField label="Email" name="email" />
62
+ </FormDialog>
63
+ </>
64
+ )
65
+ }
66
+
67
+ // Open the "Edit" session and wait for its initial focus (on Email) to land,
68
+ // so later typing isn't raced by the dialog's focus management.
69
+ async function openEdit(user: ReturnType<typeof userEvent.setup>) {
70
+ await user.click(screen.getByRole("button", { name: "Edit" }))
71
+ const email = await screen.findByLabelText(EMAIL)
72
+ await waitFor(() => expect(document.activeElement).toBe(email))
73
+ return screen.getByLabelText(NAME) as HTMLInputElement
74
+ }
75
+
76
+ describe("FormDialog", () => {
77
+ test("opens with per-session defaults and focuses the requested field", async () => {
78
+ const user = userEvent.setup()
79
+ render(<Harness />)
80
+ const name = await openEdit(user)
81
+ expect(name.value).toBe("Ada")
82
+ })
83
+
84
+ test("closes without asking when nothing changed", async () => {
85
+ const user = userEvent.setup()
86
+ render(<Harness />)
87
+ await openEdit(user)
88
+ await user.click(screen.getByRole("button", { name: "Cancel" }))
89
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
90
+ })
91
+
92
+ test("asks before discarding, keeps editing on cancel, resets on discard", async () => {
93
+ const user = userEvent.setup()
94
+ render(<Harness />)
95
+ const name = await openEdit(user)
96
+ await user.type(name, "!")
97
+ await user.keyboard("{Escape}")
98
+
99
+ await screen.findByRole("alertdialog")
100
+ await user.click(screen.getByRole("button", { name: "Keep editing" }))
101
+ await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
102
+ expect((screen.getByLabelText(NAME) as HTMLInputElement).value).toBe("Ada!")
103
+
104
+ await user.click(screen.getByRole("button", { name: "Cancel" }))
105
+ await user.click(await screen.findByRole("button", { name: "Discard" }))
106
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
107
+
108
+ // Reopening a blank session shows the blank defaults, not the old edit.
109
+ await user.click(screen.getByRole("button", { name: "New" }))
110
+ expect(
111
+ ((await screen.findByLabelText(NAME)) as HTMLInputElement).value
112
+ ).toBe("")
113
+ })
114
+
115
+ test("reverting an edit counts as unchanged (isDefaultValue, not isDirty)", async () => {
116
+ const user = userEvent.setup()
117
+ render(<Harness />)
118
+ const name = await openEdit(user)
119
+ await user.type(name, "x{Backspace}")
120
+ await user.click(screen.getByRole("button", { name: "Cancel" }))
121
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
122
+ expect(screen.queryByRole("alertdialog")).toBeNull()
123
+ })
124
+
125
+ test("submits from the footer button and closes on success", async () => {
126
+ const user = userEvent.setup()
127
+ const onSubmit = vi.fn()
128
+ render(<Harness onSubmit={onSubmit} />)
129
+ const name = await openEdit(user)
130
+ await user.type(name, "!")
131
+ const save = screen.getByRole("button", { name: "Save" })
132
+ expect(save.closest("form")).toBeNull()
133
+ await user.click(save)
134
+ await waitFor(() =>
135
+ expect(onSubmit).toHaveBeenCalledWith({
136
+ email: "ada@example.com",
137
+ name: "Ada!",
138
+ })
139
+ )
140
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
141
+ expect(screen.queryByRole("alertdialog")).toBeNull()
142
+ })
143
+
144
+ test("stays open when validation fails or the submit throws", async () => {
145
+ const user = userEvent.setup()
146
+ const onSubmit = vi.fn(() => {
147
+ throw new Error("nope")
148
+ })
149
+ render(<Harness onSubmit={onSubmit} />)
150
+ await user.click(screen.getByRole("button", { name: "New" }))
151
+ await screen.findByLabelText(NAME)
152
+ await user.click(screen.getByRole("button", { name: "Save" }))
153
+ expect(onSubmit).not.toHaveBeenCalled()
154
+ expect(await screen.findByText("Required")).toBeTruthy()
155
+
156
+ await user.type(screen.getByLabelText(NAME), "Bob")
157
+ await user.click(screen.getByRole("button", { name: "Save" }))
158
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
159
+ expect(screen.getByRole("dialog")).toBeTruthy()
160
+ })
161
+ })