@zeno-lib/forms 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +23 -11
- package/src/addons/validation-spinner.tsx +3 -4
- package/src/create-form.tsx +59 -0
- package/src/create-zeno-form.tsx +531 -0
- package/src/fields/checkbox-field.tsx +15 -12
- package/src/fields/checkbox-group-field.tsx +160 -0
- package/src/fields/combobox-field.tsx +13 -14
- package/src/fields/date-picker-field.tsx +18 -15
- package/src/fields/index.ts +5 -0
- package/src/fields/input-field.tsx +12 -13
- package/src/fields/money-field.tsx +123 -0
- package/src/fields/multi-select-field.tsx +193 -0
- package/src/fields/number-field.tsx +1 -2
- package/src/fields/otp-field.tsx +15 -12
- package/src/fields/percentage-field.tsx +73 -0
- package/src/fields/radio-group-field.tsx +12 -13
- package/src/fields/reset-button.tsx +2 -3
- package/src/fields/select-field.tsx +12 -13
- package/src/fields/slider-field.tsx +11 -12
- package/src/fields/submit-button.tsx +3 -4
- package/src/fields/switch-field.tsx +15 -12
- package/src/fields/textarea-field.tsx +12 -13
- package/src/fields/year-field.tsx +56 -0
- package/src/form-dialog.test.tsx +161 -0
- package/src/form-dialog.tsx +253 -0
- package/src/form-element.tsx +51 -0
- package/src/formatted-number-fields.test.tsx +236 -0
- package/src/index.ts +27 -0
- package/src/lib/action-result.ts +61 -0
- package/src/lib/apply-validation-error.test.ts +16 -6
- package/src/lib/apply-validation-error.ts +38 -6
- package/src/lib/formatted-number.test.ts +157 -0
- package/src/lib/formatted-number.ts +307 -0
- package/src/lib/required-indicator.tsx +1 -1
- package/src/lib/schema-defaults.test.ts +1 -2
- package/src/lib/schema-required.test.ts +98 -6
- package/src/lib/schema-required.ts +126 -22
- package/src/lib/submit-action.test-d.ts +83 -0
- package/src/lib/submit-action.ts +178 -0
- package/src/lib/use-form-dialog.ts +184 -0
- package/src/lib/use-formatted-number.ts +235 -0
- package/src/lib/use-is-invalid.ts +4 -4
- package/src/lib/use-rebased-default-values.ts +42 -0
- package/src/lib/use-unsaved-changes-warning.ts +3 -3
- package/src/lib/validation-error.test.ts +1 -1
- package/src/lib/validation-logic.test.ts +1 -1
- package/src/lib/validation-modes.test-d.ts +1 -1
- package/src/lib/validation-modes.test.ts +12 -2
- package/src/lib/validation-modes.ts +3 -1
- package/src/multi-value-fields.test.tsx +116 -0
- package/src/submit-action.test.tsx +421 -0
- package/src/use-app-fields.test-d.ts +8 -25
- package/src/use-app-fields.test.tsx +8 -32
- package/src/use-form.cascade.test.tsx +4 -6
- package/src/use-form.focus-invalid.test.tsx +198 -0
- package/src/use-form.reset-defaults.test.tsx +125 -0
- package/src/use-form.test-d.ts +2 -3
- package/src/use-form.test.tsx +12 -14
- package/src/use-form.unsaved-warning.test.tsx +4 -13
- package/src/use-form.validation-modes.test.tsx +10 -8
- package/src/form.tsx +0 -105
- package/src/use-app-fields.tsx +0 -272
- package/src/use-form.tsx +0 -450
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type ChangeEvent,
|
|
5
|
+
type ClipboardEvent,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useReducer,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react"
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
formatNumber,
|
|
14
|
+
mapCaret,
|
|
15
|
+
parseLocaleNumber,
|
|
16
|
+
parseTypedNumber,
|
|
17
|
+
roundTo,
|
|
18
|
+
} from "./formatted-number"
|
|
19
|
+
|
|
20
|
+
type UseFormattedNumberOptions = {
|
|
21
|
+
/** The stored value. `null`/`undefined` renders an empty input. */
|
|
22
|
+
value: number | null | undefined
|
|
23
|
+
/** Called with the parsed value when the user types, pastes, or blurs. */
|
|
24
|
+
onValueChange: (value: number | null) => void
|
|
25
|
+
/** Called after the blur re-format (wire the field's `handleBlur` here). */
|
|
26
|
+
onBlur?: () => void
|
|
27
|
+
/** BCP 47 locale for separators. Defaults to the runtime locale. */
|
|
28
|
+
locale?: string
|
|
29
|
+
/** Digits allowed after the decimal separator. Defaults to `2`. */
|
|
30
|
+
maximumFractionDigits?: number
|
|
31
|
+
/**
|
|
32
|
+
* Pad a non-integer value to `maximumFractionDigits` on blur (`12.5` →
|
|
33
|
+
* `12.50`). Integers stay bare (`12`). Defaults to `false`.
|
|
34
|
+
*/
|
|
35
|
+
padFraction?: boolean
|
|
36
|
+
/** Insert thousands separators. Defaults to `true`. */
|
|
37
|
+
useGrouping?: boolean
|
|
38
|
+
/** Accept a leading minus. Defaults to `true`. */
|
|
39
|
+
allowNegative?: boolean
|
|
40
|
+
/** Drop integer digits beyond this count while typing. */
|
|
41
|
+
maxIntegerDigits?: number
|
|
42
|
+
/** Clamp the stored value on blur. */
|
|
43
|
+
min?: number
|
|
44
|
+
/** Clamp the stored value on blur. */
|
|
45
|
+
max?: number
|
|
46
|
+
/**
|
|
47
|
+
* Map the stored value to the number shown in the input, e.g.
|
|
48
|
+
* `(v) => v * 100` to edit a `0–1` fraction as a percentage. Pair with
|
|
49
|
+
* `fromDisplay`.
|
|
50
|
+
*/
|
|
51
|
+
toDisplay?: (value: number) => number
|
|
52
|
+
/** Inverse of `toDisplay`. */
|
|
53
|
+
fromDisplay?: (display: number) => number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type FormattedNumberInputProps = {
|
|
57
|
+
inputMode: "decimal" | "numeric"
|
|
58
|
+
onBlur: () => void
|
|
59
|
+
onChange: (event: ChangeEvent<HTMLInputElement>) => void
|
|
60
|
+
onPaste: (event: ClipboardEvent<HTMLInputElement>) => void
|
|
61
|
+
type: "text"
|
|
62
|
+
value: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const identity = (value: number) => value
|
|
66
|
+
|
|
67
|
+
function clamp(value: number, min?: number, max?: number): number {
|
|
68
|
+
let next = value
|
|
69
|
+
if (min !== undefined && next < min) {
|
|
70
|
+
next = min
|
|
71
|
+
}
|
|
72
|
+
if (max !== undefined && next > max) {
|
|
73
|
+
next = max
|
|
74
|
+
}
|
|
75
|
+
return next
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Headless, locale-aware formatted number input.
|
|
80
|
+
*
|
|
81
|
+
* Keeps the input's text in local state so the user can type freely
|
|
82
|
+
* (`"1’2"`, `"12."`), re-inserting group separators as they type while
|
|
83
|
+
* holding the caret in place, and parses the text into a `number | null`
|
|
84
|
+
* for the form. Pasted text is parsed leniently (`"CHF 1'234.50"`,
|
|
85
|
+
* `"1.234,50"`). On blur the value is clamped to `min`/`max` and the text
|
|
86
|
+
* re-formatted. External value changes (e.g. `form.reset`) re-sync the text.
|
|
87
|
+
*
|
|
88
|
+
* Returns props to spread on an `<input>`; UI-free, so any input component
|
|
89
|
+
* can use it.
|
|
90
|
+
*/
|
|
91
|
+
function useFormattedNumber({
|
|
92
|
+
value,
|
|
93
|
+
onValueChange,
|
|
94
|
+
onBlur,
|
|
95
|
+
locale,
|
|
96
|
+
maximumFractionDigits = 2,
|
|
97
|
+
padFraction = false,
|
|
98
|
+
useGrouping = true,
|
|
99
|
+
allowNegative = true,
|
|
100
|
+
maxIntegerDigits,
|
|
101
|
+
min,
|
|
102
|
+
max,
|
|
103
|
+
toDisplay = identity,
|
|
104
|
+
fromDisplay = identity,
|
|
105
|
+
}: UseFormattedNumberOptions): FormattedNumberInputProps {
|
|
106
|
+
const current = value ?? null
|
|
107
|
+
|
|
108
|
+
const display = (stored: number | null): string => {
|
|
109
|
+
if (stored === null) {
|
|
110
|
+
return ""
|
|
111
|
+
}
|
|
112
|
+
const shown = roundTo(toDisplay(stored), maximumFractionDigits)
|
|
113
|
+
return formatNumber(shown, {
|
|
114
|
+
locale,
|
|
115
|
+
maximumFractionDigits,
|
|
116
|
+
minimumFractionDigits:
|
|
117
|
+
padFraction && !Number.isInteger(shown) ? maximumFractionDigits : 0,
|
|
118
|
+
useGrouping,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const [text, setText] = useState(() => display(current))
|
|
123
|
+
// The value the text was last derived from or parsed into. When the prop
|
|
124
|
+
// drifts from it, something outside the input changed the value.
|
|
125
|
+
const [synced, setSynced] = useState<number | null>(current)
|
|
126
|
+
if (!Object.is(current, synced)) {
|
|
127
|
+
setSynced(current)
|
|
128
|
+
setText(display(current))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Re-render even when the re-formatted text is unchanged (a rejected
|
|
132
|
+
// keystroke), so the layout effect can put the caret back after React
|
|
133
|
+
// restores the controlled value.
|
|
134
|
+
const [, forceRender] = useReducer((n: number) => n + 1, 0)
|
|
135
|
+
const pendingCaret = useRef<{ input: HTMLInputElement; at: number } | null>(
|
|
136
|
+
null
|
|
137
|
+
)
|
|
138
|
+
useLayoutEffect(() => {
|
|
139
|
+
const pending = pendingCaret.current
|
|
140
|
+
if (!pending) {
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
pendingCaret.current = null
|
|
144
|
+
if (pending.input.ownerDocument.activeElement === pending.input) {
|
|
145
|
+
pending.input.setSelectionRange(pending.at, pending.at)
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const fromDisplayRounded = (shown: number) =>
|
|
150
|
+
fromDisplay === identity
|
|
151
|
+
? shown
|
|
152
|
+
: roundTo(fromDisplay(shown), maximumFractionDigits + 4)
|
|
153
|
+
|
|
154
|
+
const commit = (nextText: string, nextValue: number | null) => {
|
|
155
|
+
setText(nextText)
|
|
156
|
+
setSynced(nextValue)
|
|
157
|
+
if (!Object.is(nextValue, current)) {
|
|
158
|
+
onValueChange(nextValue)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
163
|
+
const input = event.target
|
|
164
|
+
const raw = input.value
|
|
165
|
+
const typed = parseTypedNumber(raw, {
|
|
166
|
+
allowNegative,
|
|
167
|
+
locale,
|
|
168
|
+
maxIntegerDigits,
|
|
169
|
+
maximumFractionDigits,
|
|
170
|
+
useGrouping,
|
|
171
|
+
})
|
|
172
|
+
const caret = input.selectionStart ?? raw.length
|
|
173
|
+
pendingCaret.current = {
|
|
174
|
+
at: mapCaret(raw, caret, typed.text, locale),
|
|
175
|
+
input,
|
|
176
|
+
}
|
|
177
|
+
commit(
|
|
178
|
+
typed.text,
|
|
179
|
+
typed.value === null ? null : fromDisplayRounded(typed.value)
|
|
180
|
+
)
|
|
181
|
+
forceRender()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const onPaste = (event: ClipboardEvent<HTMLInputElement>) => {
|
|
185
|
+
const input = event.currentTarget
|
|
186
|
+
const replacesAll =
|
|
187
|
+
input.value === "" ||
|
|
188
|
+
(input.selectionStart === 0 && input.selectionEnd === input.value.length)
|
|
189
|
+
if (!replacesAll) {
|
|
190
|
+
return // partial paste: let the change handler re-format the result
|
|
191
|
+
}
|
|
192
|
+
const parsed = parseLocaleNumber(
|
|
193
|
+
event.clipboardData.getData("text"),
|
|
194
|
+
locale
|
|
195
|
+
)
|
|
196
|
+
if (parsed === null) {
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
const shown = roundTo(
|
|
200
|
+
allowNegative ? parsed : Math.abs(parsed),
|
|
201
|
+
maximumFractionDigits
|
|
202
|
+
)
|
|
203
|
+
if (
|
|
204
|
+
maxIntegerDigits !== undefined &&
|
|
205
|
+
Math.abs(Math.trunc(shown)) >= 10 ** maxIntegerDigits
|
|
206
|
+
) {
|
|
207
|
+
return // too long: the change handler truncates the pasted digits
|
|
208
|
+
}
|
|
209
|
+
event.preventDefault()
|
|
210
|
+
const stored = fromDisplayRounded(shown)
|
|
211
|
+
commit(display(stored), stored)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const handleBlur = () => {
|
|
215
|
+
if (current === null) {
|
|
216
|
+
setText("")
|
|
217
|
+
} else {
|
|
218
|
+
const clamped = clamp(current, min, max)
|
|
219
|
+
commit(display(clamped), clamped)
|
|
220
|
+
}
|
|
221
|
+
onBlur?.()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
inputMode: maximumFractionDigits > 0 ? "decimal" : "numeric",
|
|
226
|
+
onBlur: handleBlur,
|
|
227
|
+
onChange,
|
|
228
|
+
onPaste,
|
|
229
|
+
type: "text",
|
|
230
|
+
value: text,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export type { FormattedNumberInputProps, UseFormattedNumberOptions }
|
|
235
|
+
export { useFormattedNumber }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { type AnyFieldApi,
|
|
3
|
+
import { type AnyFieldApi, useSelector } from "@tanstack/react-form"
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
getFormHideFieldErrors,
|
|
@@ -15,7 +15,7 @@ const FIELD_LEVEL_CAUSES = ["onChange", "onBlur", "onSubmit"] as const
|
|
|
15
15
|
|
|
16
16
|
// Has the user explicitly opted into a `validators={{ onChange | onBlur |
|
|
17
17
|
// onSubmit }}` cause on this field? If yes, we honour the cause directly —
|
|
18
|
-
// the form's `
|
|
18
|
+
// the form's `validators` mode does not gate per-field validators (a user
|
|
19
19
|
// who wrote `onChange` expects live feedback, even if the form is in
|
|
20
20
|
// `blur-then-change` mode).
|
|
21
21
|
function hasFieldLevelError(
|
|
@@ -46,7 +46,7 @@ function modeAllowsDisplay(
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// Returns the "should we display errors?" gate for a field. The gate matches
|
|
49
|
-
// the form's `
|
|
49
|
+
// the form's `validators` mode (set via `useForm({ validators })`):
|
|
50
50
|
//
|
|
51
51
|
// - `change` → show as soon as the user has typed (live)
|
|
52
52
|
// - `blur` → show after first blur
|
|
@@ -60,7 +60,7 @@ function modeAllowsDisplay(
|
|
|
60
60
|
// `validators={{ onChange }}` shows live (after first keystroke) regardless
|
|
61
61
|
// of the form's gating, because the user explicitly opted into that cause.
|
|
62
62
|
function useIsInvalid(field: AnyFieldApi): boolean {
|
|
63
|
-
const wasSubmitted =
|
|
63
|
+
const wasSubmitted = useSelector(
|
|
64
64
|
field.form.store,
|
|
65
65
|
(state) => state.submissionAttempts > 0
|
|
66
66
|
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { type AnyFormApi, evaluate } from "@tanstack/react-form"
|
|
4
|
+
import { useCallback, useRef } from "react"
|
|
5
|
+
|
|
6
|
+
// TanStack's `useForm` calls `formApi.update(options)` after every render. While
|
|
7
|
+
// the form is untouched, `update` re-applies `options.defaultValues` whenever it
|
|
8
|
+
// deep-differs from the form's live defaults. `formApi.reset(values)` rebases
|
|
9
|
+
// those live defaults, so a caller that keeps passing the same `defaultValues`
|
|
10
|
+
// literal would see the reset undone on the next render (the classic "reset to
|
|
11
|
+
// the saved record after submit" flow).
|
|
12
|
+
//
|
|
13
|
+
// This hook forwards the caller's defaults only when *they* change (deep
|
|
14
|
+
// compare, the same `evaluate` TanStack's `update` uses). Otherwise it forwards
|
|
15
|
+
// the form's live defaults back, so `update` sees no change and a reset sticks.
|
|
16
|
+
// The live FormApi instance comes from the form-level `listeners.onMount`
|
|
17
|
+
// callback: the object `useForm` returns is a spread snapshot whose `options`
|
|
18
|
+
// never updates.
|
|
19
|
+
function useRebasedDefaultValues<T>(callerDefaults: T): {
|
|
20
|
+
defaultValues: T
|
|
21
|
+
onMount: (props: { formApi: AnyFormApi }) => void
|
|
22
|
+
} {
|
|
23
|
+
const liveForm = useRef<AnyFormApi | null>(null)
|
|
24
|
+
const lastCallerDefaults = useRef(callerDefaults)
|
|
25
|
+
|
|
26
|
+
let defaultValues = callerDefaults
|
|
27
|
+
if (evaluate(lastCallerDefaults.current, callerDefaults)) {
|
|
28
|
+
if (liveForm.current) {
|
|
29
|
+
defaultValues = liveForm.current.options.defaultValues as T
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
lastCallerDefaults.current = callerDefaults
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const onMount = useCallback((props: { formApi: AnyFormApi }) => {
|
|
36
|
+
liveForm.current = props.formApi
|
|
37
|
+
}, [])
|
|
38
|
+
|
|
39
|
+
return { defaultValues, onMount }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export { useRebasedDefaultValues }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { type AnyFormApi,
|
|
3
|
+
import { type AnyFormApi, useSelector } from "@tanstack/react-form"
|
|
4
4
|
import { useEffect } from "react"
|
|
5
5
|
|
|
6
6
|
type UnsavedChangesMode = "if-changed" | "if-touched"
|
|
@@ -34,10 +34,10 @@ function useUnsavedChangesWarning(
|
|
|
34
34
|
} else {
|
|
35
35
|
mode = enabled
|
|
36
36
|
}
|
|
37
|
-
const dirty =
|
|
37
|
+
const dirty = useSelector(form.store, (state) =>
|
|
38
38
|
mode === "if-touched" ? state.isDirty : !state.isDefaultValue
|
|
39
39
|
)
|
|
40
|
-
const isSubmitting =
|
|
40
|
+
const isSubmitting = useSelector(form.store, (state) => state.isSubmitting)
|
|
41
41
|
const active = mode !== null && dirty && !isSubmitting
|
|
42
42
|
|
|
43
43
|
useEffect(() => {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { AnyFormApi } from "@tanstack/react-form"
|
|
2
|
-
import { describe, expect, test } from "
|
|
3
|
-
|
|
2
|
+
import { describe, expect, test } from "vitest"
|
|
4
3
|
import {
|
|
5
4
|
DEFAULT_VALIDATION_MODE,
|
|
6
5
|
getFormHideFieldErrors,
|
|
@@ -50,6 +49,17 @@ describe("validation-modes WeakMap state", () => {
|
|
|
50
49
|
expect(isFieldRequired(form, "email")).toBe(false)
|
|
51
50
|
})
|
|
52
51
|
|
|
52
|
+
test("isFieldRequired matches array rows by TanStack field name", () => {
|
|
53
|
+
const form = makeFormStub()
|
|
54
|
+
setFormZenoState(form, {
|
|
55
|
+
requiredFields: new Set(["members[0].name"]),
|
|
56
|
+
requiredIndicator: true,
|
|
57
|
+
})
|
|
58
|
+
expect(isFieldRequired(form, "members[0].name")).toBe(true)
|
|
59
|
+
expect(isFieldRequired(form, "members[4].name")).toBe(true)
|
|
60
|
+
expect(isFieldRequired(form, "members[4].note")).toBe(false)
|
|
61
|
+
})
|
|
62
|
+
|
|
53
63
|
test("two forms keyed on different store references are independent", () => {
|
|
54
64
|
const a = makeFormStub()
|
|
55
65
|
const b = makeFormStub()
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import type { AnyFormApi } from "@tanstack/react-form"
|
|
4
4
|
|
|
5
|
+
import { toRequiredPathKey } from "./schema-required"
|
|
6
|
+
|
|
5
7
|
type ValidationMode = "change" | "blur" | "submit" | "blur-then-change"
|
|
6
8
|
|
|
7
9
|
const DEFAULT_VALIDATION_MODE: ValidationMode = "blur-then-change"
|
|
@@ -60,7 +62,7 @@ function isFieldRequired(form: AnyFormApi, name: string): boolean {
|
|
|
60
62
|
if (!state.requiredIndicator) {
|
|
61
63
|
return false
|
|
62
64
|
}
|
|
63
|
-
return state.requiredFields.has(name)
|
|
65
|
+
return state.requiredFields.has(toRequiredPathKey(name))
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
export {
|
|
@@ -0,0 +1,116 @@
|
|
|
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 { Form, FormProvider, useForm } from "./create-form"
|
|
5
|
+
|
|
6
|
+
const TAGS = /Tags/
|
|
7
|
+
const CLEAR = /Clear/
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
cleanup()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
type Box<T> = { current: T | undefined }
|
|
14
|
+
|
|
15
|
+
describe("MultiSelectField", () => {
|
|
16
|
+
const ITEMS = [
|
|
17
|
+
{ label: "One", value: 1 },
|
|
18
|
+
{ label: "Two", value: 2 },
|
|
19
|
+
{ label: "Three", value: 3 },
|
|
20
|
+
]
|
|
21
|
+
const DEFAULTS = { ids: [3] as number[] }
|
|
22
|
+
|
|
23
|
+
function Harness({ captured }: { captured: Box<number[]> }) {
|
|
24
|
+
const form = useForm({ defaultValues: DEFAULTS, onSubmit: vi.fn() })
|
|
25
|
+
const { MultiSelectField, Subscribe } = form
|
|
26
|
+
return (
|
|
27
|
+
<FormProvider form={form}>
|
|
28
|
+
<Form>
|
|
29
|
+
<MultiSelectField
|
|
30
|
+
items={ITEMS}
|
|
31
|
+
label="Tags"
|
|
32
|
+
name="ids"
|
|
33
|
+
placeholder="Pick"
|
|
34
|
+
/>
|
|
35
|
+
<Subscribe selector={(state) => state.values.ids}>
|
|
36
|
+
{(ids) => {
|
|
37
|
+
captured.current = ids
|
|
38
|
+
return null
|
|
39
|
+
}}
|
|
40
|
+
</Subscribe>
|
|
41
|
+
</Form>
|
|
42
|
+
</FormProvider>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("renders selected values as chips and appends picks as numbers", async () => {
|
|
47
|
+
const user = userEvent.setup()
|
|
48
|
+
const captured: Box<number[]> = { current: undefined }
|
|
49
|
+
const { container } = render(<Harness captured={captured} />)
|
|
50
|
+
expect(container.querySelector("[data-field=ids]")?.textContent).toContain(
|
|
51
|
+
"Three"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
await user.click(screen.getByLabelText(TAGS))
|
|
55
|
+
await user.click(await screen.findByRole("option", { name: "One" }))
|
|
56
|
+
expect(captured.current).toEqual([3, 1])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("the clear button empties the selection", async () => {
|
|
60
|
+
const user = userEvent.setup()
|
|
61
|
+
const captured: Box<number[]> = { current: undefined }
|
|
62
|
+
render(<Harness captured={captured} />)
|
|
63
|
+
await user.click(screen.getByRole("button", { name: CLEAR }))
|
|
64
|
+
expect(captured.current).toEqual([])
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe("CheckboxGroupField", () => {
|
|
69
|
+
const DEFAULTS = { days: [] as string[] }
|
|
70
|
+
|
|
71
|
+
function Harness({ captured }: { captured: Box<string[]> }) {
|
|
72
|
+
const form = useForm({ defaultValues: DEFAULTS, onSubmit: vi.fn() })
|
|
73
|
+
const { CheckboxGroupField, Subscribe } = form
|
|
74
|
+
return (
|
|
75
|
+
<FormProvider form={form}>
|
|
76
|
+
<Form>
|
|
77
|
+
<CheckboxGroupField
|
|
78
|
+
items={[
|
|
79
|
+
"mon",
|
|
80
|
+
"tue",
|
|
81
|
+
{ disabled: true, label: "Wednesday", value: "wed" },
|
|
82
|
+
]}
|
|
83
|
+
label="Days"
|
|
84
|
+
name="days"
|
|
85
|
+
/>
|
|
86
|
+
<Subscribe selector={(state) => state.values.days}>
|
|
87
|
+
{(days) => {
|
|
88
|
+
captured.current = days
|
|
89
|
+
return null
|
|
90
|
+
}}
|
|
91
|
+
</Subscribe>
|
|
92
|
+
</Form>
|
|
93
|
+
</FormProvider>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
test("toggles values and keeps the order of items", async () => {
|
|
98
|
+
const user = userEvent.setup()
|
|
99
|
+
const captured: Box<string[]> = { current: undefined }
|
|
100
|
+
render(<Harness captured={captured} />)
|
|
101
|
+
await user.click(screen.getByRole("checkbox", { name: "tue" }))
|
|
102
|
+
await user.click(screen.getByRole("checkbox", { name: "mon" }))
|
|
103
|
+
expect(captured.current).toEqual(["mon", "tue"])
|
|
104
|
+
await user.click(screen.getByRole("checkbox", { name: "tue" }))
|
|
105
|
+
expect(captured.current).toEqual(["mon"])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("renders a labelled group and honours per-option disabled", () => {
|
|
109
|
+
render(<Harness captured={{ current: undefined }} />)
|
|
110
|
+
expect(screen.getByRole("group", { name: "Days" })).toBeTruthy()
|
|
111
|
+
const wed = screen.getByRole("checkbox", { name: "Wednesday" })
|
|
112
|
+
expect(
|
|
113
|
+
wed.getAttribute("aria-disabled") ?? wed.getAttribute("data-disabled")
|
|
114
|
+
).not.toBeNull()
|
|
115
|
+
})
|
|
116
|
+
})
|