@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.
@@ -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 }