@zeno-lib/forms 0.1.0 → 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 +3 -1
- package/src/create-form.tsx +10 -0
- package/src/fields/checkbox-group-field.tsx +160 -0
- package/src/fields/index.ts +5 -0
- package/src/fields/money-field.tsx +123 -0
- package/src/fields/multi-select-field.tsx +193 -0
- package/src/fields/percentage-field.tsx +73 -0
- 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/formatted-number-fields.test.tsx +236 -0
- package/src/index.ts +13 -0
- package/src/lib/action-result.ts +61 -0
- package/src/lib/apply-validation-error.test.ts +15 -4
- 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/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/multi-value-fields.test.tsx +116 -0
- package/src/submit-action.test.tsx +421 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { expectTypeOf, test } from "vitest"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import { useForm } from "../create-form"
|
|
4
|
+
import type { ActionResult } from "./action-result"
|
|
5
|
+
import { submitAction } from "./submit-action"
|
|
6
|
+
|
|
7
|
+
const schema = z.object({
|
|
8
|
+
name: z.string(),
|
|
9
|
+
percentage: z.string().transform(Number),
|
|
10
|
+
})
|
|
11
|
+
type Values = z.input<typeof schema>
|
|
12
|
+
|
|
13
|
+
// What `defineFormAction` in `@zeno-lib/db/next` produces, restated here so
|
|
14
|
+
// the structural contract is pinned without depending on that package.
|
|
15
|
+
type ServerActionError = {
|
|
16
|
+
readonly fieldErrors: Record<string, string[]>
|
|
17
|
+
readonly formErrors: string[]
|
|
18
|
+
}
|
|
19
|
+
type ServerActionResult<T> =
|
|
20
|
+
| { readonly ok: true; readonly data: T }
|
|
21
|
+
| { readonly ok: false; readonly error: ServerActionError }
|
|
22
|
+
|
|
23
|
+
declare function saveShare(input: {
|
|
24
|
+
name: string
|
|
25
|
+
percentage: number
|
|
26
|
+
}): Promise<ServerActionResult<{ id: number }>>
|
|
27
|
+
declare function saveRaw(input: Values): Promise<ServerActionResult<Values>>
|
|
28
|
+
|
|
29
|
+
test("infers the data type from the action, with no explicit generics", () => {
|
|
30
|
+
useForm({
|
|
31
|
+
defaultValues: { name: "", percentage: "" },
|
|
32
|
+
onSubmit: async (submit) => {
|
|
33
|
+
const result = await submitAction(submit, saveShare, { schema })
|
|
34
|
+
expectTypeOf(result).toEqualTypeOf<ActionResult<{ id: number }>>()
|
|
35
|
+
if (result.ok) {
|
|
36
|
+
expectTypeOf(result.data).toEqualTypeOf<{ id: number }>()
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("without a schema the action must accept the form values", () => {
|
|
43
|
+
useForm({
|
|
44
|
+
defaultValues: { name: "", percentage: "" },
|
|
45
|
+
onSubmit: (submit) => submitAction(submit, saveRaw, { reset: true }),
|
|
46
|
+
})
|
|
47
|
+
useForm({
|
|
48
|
+
defaultValues: { name: "", percentage: "" },
|
|
49
|
+
// @ts-expect-error: the action wants `percentage: number`; pass `schema` to transform.
|
|
50
|
+
onSubmit: (submit) => submitAction(submit, saveShare),
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("with a schema the action receives the schema output", () => {
|
|
55
|
+
useForm({
|
|
56
|
+
defaultValues: { name: "", percentage: "" },
|
|
57
|
+
onSubmit: (submit) =>
|
|
58
|
+
// @ts-expect-error: the schema outputs `percentage: number`, the action wants a string.
|
|
59
|
+
submitAction(submit, saveRaw, { schema }),
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("reset: true is only offered when the data has the form's shape", () => {
|
|
64
|
+
useForm({
|
|
65
|
+
defaultValues: { name: "", percentage: "" },
|
|
66
|
+
onSubmit: (submit) =>
|
|
67
|
+
// @ts-expect-error: `{ id: number }` is not the form's values.
|
|
68
|
+
submitAction(submit, saveShare, { reset: true, schema }),
|
|
69
|
+
})
|
|
70
|
+
useForm({
|
|
71
|
+
defaultValues: { name: "", percentage: "" },
|
|
72
|
+
onSubmit: (submit) =>
|
|
73
|
+
submitAction(submit, saveShare, { reset: "values", schema }),
|
|
74
|
+
})
|
|
75
|
+
useForm({
|
|
76
|
+
defaultValues: { name: "", percentage: "" },
|
|
77
|
+
onSubmit: (submit) =>
|
|
78
|
+
submitAction(submit, saveShare, {
|
|
79
|
+
reset: (data) => ({ name: String(data.id), percentage: "" }),
|
|
80
|
+
schema,
|
|
81
|
+
}),
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { AnyFormApi } from "@tanstack/react-form"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type ActionError,
|
|
5
|
+
type ActionIssue,
|
|
6
|
+
type ActionResult,
|
|
7
|
+
toActionError,
|
|
8
|
+
} from "./action-result"
|
|
9
|
+
import { applyValidationError } from "./apply-validation-error"
|
|
10
|
+
import { ValidationError } from "./validation-error"
|
|
11
|
+
|
|
12
|
+
// Write an action's failure onto the form. Field messages go through
|
|
13
|
+
// `applyValidationError`, so they live in `errorMap.onChange` and clear the
|
|
14
|
+
// moment the user edits that field (other fields keep theirs). A message
|
|
15
|
+
// keyed by a name no mounted field registered would be invisible yet still
|
|
16
|
+
// mark the form invalid, so it is folded into the form-level message instead,
|
|
17
|
+
// next to `formErrors` (one message per line).
|
|
18
|
+
//
|
|
19
|
+
// Why not `setErrorMap({ onServer: { form, fields } })`: the default
|
|
20
|
+
// `blur-then-change` logic never clears `onServer` on an edit, only on the
|
|
21
|
+
// next submit, and TanStack's `defaultValidationLogic` clears every field's
|
|
22
|
+
// `onServer` error on any single edit. Neither matches "clears when this field
|
|
23
|
+
// changes".
|
|
24
|
+
function applyActionError(formApi: FormApiLike, error: ActionError): void {
|
|
25
|
+
const fields: Record<string, readonly string[]> = {}
|
|
26
|
+
const formErrors = [...error.formErrors]
|
|
27
|
+
const fieldInfo = formApi.fieldInfo as Record<
|
|
28
|
+
string,
|
|
29
|
+
{ instance?: unknown } | undefined
|
|
30
|
+
>
|
|
31
|
+
for (const [name, messages] of Object.entries(error.fieldErrors)) {
|
|
32
|
+
if (messages.length === 0) {
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
if (fieldInfo[name]?.instance) {
|
|
36
|
+
fields[name] = messages
|
|
37
|
+
} else {
|
|
38
|
+
formErrors.push(...messages)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
applyValidationError(
|
|
42
|
+
formApi as AnyFormApi,
|
|
43
|
+
new ValidationError(
|
|
44
|
+
fields,
|
|
45
|
+
formErrors.length > 0 ? { formError: formErrors.join("\n") } : {}
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type SchemaResult<TOutput> =
|
|
51
|
+
| { readonly value: TOutput; readonly issues?: undefined }
|
|
52
|
+
| { readonly issues: readonly ActionIssue[] }
|
|
53
|
+
|
|
54
|
+
// The Standard Schema slice `submitAction` reads: `validate` (never throws)
|
|
55
|
+
// and the output type. Zod 4, Valibot and ArkType schemas all fit.
|
|
56
|
+
type SubmitActionSchema<TOutput> = {
|
|
57
|
+
readonly "~standard": {
|
|
58
|
+
readonly validate: (
|
|
59
|
+
value: unknown
|
|
60
|
+
) => SchemaResult<TOutput> | Promise<SchemaResult<TOutput>>
|
|
61
|
+
readonly types?:
|
|
62
|
+
| { readonly input: unknown; readonly output: TOutput }
|
|
63
|
+
| undefined
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The members of a form API the helpers touch. Typed as a slice rather than
|
|
68
|
+
// `AnyFormApi` so a concrete `FormApi<Values, …, never>` (what `onSubmit`
|
|
69
|
+
// receives) is assignable without a cast.
|
|
70
|
+
type FormApiLike = Pick<
|
|
71
|
+
AnyFormApi,
|
|
72
|
+
| "fieldInfo"
|
|
73
|
+
| "getFieldMeta"
|
|
74
|
+
| "getFieldValue"
|
|
75
|
+
| "setErrorMap"
|
|
76
|
+
| "setFieldMeta"
|
|
77
|
+
| "store"
|
|
78
|
+
>
|
|
79
|
+
|
|
80
|
+
type SubmitProps<TValues> = {
|
|
81
|
+
readonly value: TValues
|
|
82
|
+
readonly formApi: FormApiLike & {
|
|
83
|
+
reset: (values?: TValues, opts?: { keepDefaultValues?: boolean }) => void
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type Action<TInput, TData> = (input: TInput) => Promise<ActionResult<TData>>
|
|
88
|
+
|
|
89
|
+
// `true` rebases the form on the returned data (only offered when the data
|
|
90
|
+
// has the form's shape), `"values"` on the values just submitted, and a
|
|
91
|
+
// function on whatever it maps the data to.
|
|
92
|
+
type ResetOption<TValues, TData> =
|
|
93
|
+
| "values"
|
|
94
|
+
| ((data: TData) => TValues)
|
|
95
|
+
| ([TData] extends [TValues] ? true : never)
|
|
96
|
+
|
|
97
|
+
type SubmitActionOptions<TValues, TData> = {
|
|
98
|
+
/**
|
|
99
|
+
* After a successful action, `formApi.reset(...)` so the saved values
|
|
100
|
+
* become the pristine baseline (clears `isDirty` and the unsaved-changes
|
|
101
|
+
* warning). Off by default.
|
|
102
|
+
*/
|
|
103
|
+
reset?: ResetOption<TValues, TData>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type SubmitActionSchemaOptions<TValues, TOutput, TData> = SubmitActionOptions<
|
|
107
|
+
TValues,
|
|
108
|
+
TData
|
|
109
|
+
> & {
|
|
110
|
+
/**
|
|
111
|
+
* Parsed on the client before the call: `onSubmit` receives the form's
|
|
112
|
+
* input values, and the action gets the schema's output. A failure is
|
|
113
|
+
* applied to the form and the action is never called.
|
|
114
|
+
*/
|
|
115
|
+
schema: SubmitActionSchema<TOutput>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Call a server action from `onSubmit` and route its result back into the
|
|
120
|
+
* form. Pass `onSubmit`'s own argument straight through:
|
|
121
|
+
*
|
|
122
|
+
* ```ts
|
|
123
|
+
* onSubmit: (submit) => submitAction(submit, saveProfile, { reset: true, schema })
|
|
124
|
+
* ```
|
|
125
|
+
*
|
|
126
|
+
* On `{ ok: false }` the field errors land on their fields (array paths like
|
|
127
|
+
* `owners[0].percentage` included) and clear as each field is edited. On
|
|
128
|
+
* `{ ok: true }` the form is optionally reset. Either way the result is
|
|
129
|
+
* returned, so the caller can redirect or toast on `result.ok`. A thrown
|
|
130
|
+
* error (network, auth, a bug) propagates unchanged.
|
|
131
|
+
*/
|
|
132
|
+
function submitAction<TValues, TOutput, TData>(
|
|
133
|
+
props: SubmitProps<TValues>,
|
|
134
|
+
action: Action<TOutput, TData>,
|
|
135
|
+
options: SubmitActionSchemaOptions<TValues, TOutput, TData>
|
|
136
|
+
): Promise<ActionResult<TData>>
|
|
137
|
+
function submitAction<TValues, TData>(
|
|
138
|
+
props: SubmitProps<TValues>,
|
|
139
|
+
action: Action<NoInfer<TValues>, TData>,
|
|
140
|
+
options?: SubmitActionOptions<TValues, TData>
|
|
141
|
+
): Promise<ActionResult<TData>>
|
|
142
|
+
async function submitAction(
|
|
143
|
+
props: SubmitProps<unknown>,
|
|
144
|
+
action: Action<unknown, unknown>,
|
|
145
|
+
options: { reset?: unknown; schema?: SubmitActionSchema<unknown> } = {}
|
|
146
|
+
): Promise<ActionResult<unknown>> {
|
|
147
|
+
const { formApi, value } = props
|
|
148
|
+
let input: unknown = value
|
|
149
|
+
|
|
150
|
+
if (options.schema) {
|
|
151
|
+
const parsed = await options.schema["~standard"].validate(value)
|
|
152
|
+
if (parsed.issues) {
|
|
153
|
+
const error = toActionError(parsed.issues)
|
|
154
|
+
applyActionError(formApi, error)
|
|
155
|
+
return { error, ok: false }
|
|
156
|
+
}
|
|
157
|
+
input = parsed.value
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const result = await action(input)
|
|
161
|
+
if (!result.ok) {
|
|
162
|
+
applyActionError(formApi, result.error)
|
|
163
|
+
return result
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const { reset } = options
|
|
167
|
+
if (reset === true) {
|
|
168
|
+
formApi.reset(result.data)
|
|
169
|
+
} else if (reset === "values") {
|
|
170
|
+
formApi.reset(value)
|
|
171
|
+
} else if (typeof reset === "function") {
|
|
172
|
+
formApi.reset((reset as (data: unknown) => unknown)(result.data))
|
|
173
|
+
}
|
|
174
|
+
return result
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type { FormApiLike, SubmitActionOptions, SubmitActionSchema }
|
|
178
|
+
export { applyActionError, submitAction }
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useState } from "react"
|
|
4
|
+
|
|
5
|
+
type FormDialogOpenOptions<TValues> = {
|
|
6
|
+
/**
|
|
7
|
+
* Values to edit in this session. Omit to start from the hook's
|
|
8
|
+
* `defaultValues` (a blank "create" form).
|
|
9
|
+
*/
|
|
10
|
+
defaultValues?: TValues
|
|
11
|
+
/** Field `name` to focus once the dialog opens, e.g. `"email"`. */
|
|
12
|
+
focus?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type UseFormDialogOptions<TValues> = {
|
|
16
|
+
/** Values a session starts from when `open()` gets none. */
|
|
17
|
+
defaultValues?: TValues
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type FormDialogController<TValues> = {
|
|
21
|
+
/** Whether the dialog is open. */
|
|
22
|
+
isOpen: boolean
|
|
23
|
+
/** Open a new editing session, optionally with its own values and focus. */
|
|
24
|
+
open: (options?: FormDialogOpenOptions<TValues>) => void
|
|
25
|
+
/**
|
|
26
|
+
* Close without the unsaved-changes prompt. Close affordances inside the
|
|
27
|
+
* dialog (Cancel, ×, Escape, outside press) go through the guard instead.
|
|
28
|
+
*/
|
|
29
|
+
close: () => void
|
|
30
|
+
/**
|
|
31
|
+
* The current session's defaults. Pass them to `useForm({ defaultValues })`
|
|
32
|
+
* so the form's defaults follow the session (see the pitfall below).
|
|
33
|
+
*/
|
|
34
|
+
defaultValues: TValues | undefined
|
|
35
|
+
/** Field to focus when the current session opened. */
|
|
36
|
+
focus: string | undefined
|
|
37
|
+
/** Increments on every `open()`; lets the dialog reset once per session. */
|
|
38
|
+
session: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type SessionState<TValues> = {
|
|
42
|
+
isOpen: boolean
|
|
43
|
+
defaultValues: TValues | undefined
|
|
44
|
+
focus: string | undefined
|
|
45
|
+
session: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Open/close state for a dialog-hosted form, one "session" per `open()`.
|
|
50
|
+
*
|
|
51
|
+
* Why the hook owns the defaults: TanStack Form ignores new `defaultValues`
|
|
52
|
+
* once the form is touched, and a `form.reset(values)` is undone on the next
|
|
53
|
+
* render when `useForm` still receives the old `defaultValues` (the form is
|
|
54
|
+
* untouched again after the reset, so TanStack re-applies its options).
|
|
55
|
+
* Passing `dialog.defaultValues` to `useForm` keeps the two in agreement;
|
|
56
|
+
* the `FormDialog` component then calls `form.reset(defaults)` at the start
|
|
57
|
+
* of every session.
|
|
58
|
+
*/
|
|
59
|
+
function useFormDialog<TValues>(
|
|
60
|
+
options: UseFormDialogOptions<TValues> = {}
|
|
61
|
+
): FormDialogController<TValues> {
|
|
62
|
+
const latestDefaults = useRef(options.defaultValues)
|
|
63
|
+
latestDefaults.current = options.defaultValues
|
|
64
|
+
|
|
65
|
+
const [state, setState] = useState<SessionState<TValues>>(() => ({
|
|
66
|
+
defaultValues: options.defaultValues,
|
|
67
|
+
focus: undefined,
|
|
68
|
+
isOpen: false,
|
|
69
|
+
session: 0,
|
|
70
|
+
}))
|
|
71
|
+
|
|
72
|
+
const open = useCallback((openOptions?: FormDialogOpenOptions<TValues>) => {
|
|
73
|
+
setState((previous) => ({
|
|
74
|
+
defaultValues: openOptions?.defaultValues ?? latestDefaults.current,
|
|
75
|
+
focus: openOptions?.focus,
|
|
76
|
+
isOpen: true,
|
|
77
|
+
session: previous.session + 1,
|
|
78
|
+
}))
|
|
79
|
+
}, [])
|
|
80
|
+
|
|
81
|
+
const close = useCallback(() => {
|
|
82
|
+
setState((previous) =>
|
|
83
|
+
previous.isOpen ? { ...previous, isOpen: false } : previous
|
|
84
|
+
)
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
close,
|
|
89
|
+
defaultValues: state.defaultValues,
|
|
90
|
+
focus: state.focus,
|
|
91
|
+
isOpen: state.isOpen,
|
|
92
|
+
open,
|
|
93
|
+
session: state.session,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type UseLeaveGuardOptions = {
|
|
98
|
+
/** Whether leaving now would lose changes. */
|
|
99
|
+
hasUnsavedChanges: boolean
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type LeaveGuard = {
|
|
103
|
+
/**
|
|
104
|
+
* Run `proceed` now when there's nothing to lose, otherwise hold it until
|
|
105
|
+
* the user confirms. A newer request replaces a held one.
|
|
106
|
+
*/
|
|
107
|
+
requestLeave: (proceed: () => void) => void
|
|
108
|
+
/** Whether a leave is waiting on the user's answer (show your confirm UI). */
|
|
109
|
+
isConfirming: boolean
|
|
110
|
+
/** The user chose to discard: run the held leave. */
|
|
111
|
+
confirmLeave: () => void
|
|
112
|
+
/** The user chose to stay: drop the held leave. */
|
|
113
|
+
cancelLeave: () => void
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Ask before a leave (close, navigate, switch record) drops unsaved changes. */
|
|
117
|
+
function useLeaveGuard({
|
|
118
|
+
hasUnsavedChanges,
|
|
119
|
+
}: UseLeaveGuardOptions): LeaveGuard {
|
|
120
|
+
const pending = useRef<(() => void) | null>(null)
|
|
121
|
+
const [isConfirming, setIsConfirming] = useState(false)
|
|
122
|
+
|
|
123
|
+
const requestLeave = useCallback(
|
|
124
|
+
(proceed: () => void) => {
|
|
125
|
+
if (!hasUnsavedChanges) {
|
|
126
|
+
proceed()
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
pending.current = proceed
|
|
130
|
+
setIsConfirming(true)
|
|
131
|
+
},
|
|
132
|
+
[hasUnsavedChanges]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const confirmLeave = useCallback(() => {
|
|
136
|
+
const proceed = pending.current
|
|
137
|
+
pending.current = null
|
|
138
|
+
setIsConfirming(false)
|
|
139
|
+
proceed?.()
|
|
140
|
+
}, [])
|
|
141
|
+
|
|
142
|
+
const cancelLeave = useCallback(() => {
|
|
143
|
+
pending.current = null
|
|
144
|
+
setIsConfirming(false)
|
|
145
|
+
}, [])
|
|
146
|
+
|
|
147
|
+
return { cancelLeave, confirmLeave, isConfirming, requestLeave }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const FOCUSABLE =
|
|
151
|
+
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
152
|
+
|
|
153
|
+
function attributeSelector(attribute: string, value: string): string {
|
|
154
|
+
return `[${attribute}="${value.replace(/["\\]/g, "\\$&")}"]`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Find the element to focus for field `name` inside `root`: the control with
|
|
159
|
+
* `id={name}` (what the shipped fields render), else the first focusable
|
|
160
|
+
* element inside the field root marked `data-field={name}`.
|
|
161
|
+
*/
|
|
162
|
+
function findFieldElement(
|
|
163
|
+
root: ParentNode | null | undefined,
|
|
164
|
+
name: string
|
|
165
|
+
): HTMLElement | null {
|
|
166
|
+
if (!root) {
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
const byId = root.querySelector<HTMLElement>(attributeSelector("id", name))
|
|
170
|
+
if (byId) {
|
|
171
|
+
return byId
|
|
172
|
+
}
|
|
173
|
+
const fieldRoot = root.querySelector(attributeSelector("data-field", name))
|
|
174
|
+
return fieldRoot?.querySelector<HTMLElement>(FOCUSABLE) ?? null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type {
|
|
178
|
+
FormDialogController,
|
|
179
|
+
FormDialogOpenOptions,
|
|
180
|
+
LeaveGuard,
|
|
181
|
+
UseFormDialogOptions,
|
|
182
|
+
UseLeaveGuardOptions,
|
|
183
|
+
}
|
|
184
|
+
export { findFieldElement, useFormDialog, useLeaveGuard }
|
|
@@ -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 }
|