@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.
Files changed (63) hide show
  1. package/package.json +23 -11
  2. package/src/addons/validation-spinner.tsx +3 -4
  3. package/src/create-form.tsx +59 -0
  4. package/src/create-zeno-form.tsx +531 -0
  5. package/src/fields/checkbox-field.tsx +15 -12
  6. package/src/fields/checkbox-group-field.tsx +160 -0
  7. package/src/fields/combobox-field.tsx +13 -14
  8. package/src/fields/date-picker-field.tsx +18 -15
  9. package/src/fields/index.ts +5 -0
  10. package/src/fields/input-field.tsx +12 -13
  11. package/src/fields/money-field.tsx +123 -0
  12. package/src/fields/multi-select-field.tsx +193 -0
  13. package/src/fields/number-field.tsx +1 -2
  14. package/src/fields/otp-field.tsx +15 -12
  15. package/src/fields/percentage-field.tsx +73 -0
  16. package/src/fields/radio-group-field.tsx +12 -13
  17. package/src/fields/reset-button.tsx +2 -3
  18. package/src/fields/select-field.tsx +12 -13
  19. package/src/fields/slider-field.tsx +11 -12
  20. package/src/fields/submit-button.tsx +3 -4
  21. package/src/fields/switch-field.tsx +15 -12
  22. package/src/fields/textarea-field.tsx +12 -13
  23. package/src/fields/year-field.tsx +56 -0
  24. package/src/form-dialog.test.tsx +161 -0
  25. package/src/form-dialog.tsx +253 -0
  26. package/src/form-element.tsx +51 -0
  27. package/src/formatted-number-fields.test.tsx +236 -0
  28. package/src/index.ts +27 -0
  29. package/src/lib/action-result.ts +61 -0
  30. package/src/lib/apply-validation-error.test.ts +16 -6
  31. package/src/lib/apply-validation-error.ts +38 -6
  32. package/src/lib/formatted-number.test.ts +157 -0
  33. package/src/lib/formatted-number.ts +307 -0
  34. package/src/lib/required-indicator.tsx +1 -1
  35. package/src/lib/schema-defaults.test.ts +1 -2
  36. package/src/lib/schema-required.test.ts +98 -6
  37. package/src/lib/schema-required.ts +126 -22
  38. package/src/lib/submit-action.test-d.ts +83 -0
  39. package/src/lib/submit-action.ts +178 -0
  40. package/src/lib/use-form-dialog.ts +184 -0
  41. package/src/lib/use-formatted-number.ts +235 -0
  42. package/src/lib/use-is-invalid.ts +4 -4
  43. package/src/lib/use-rebased-default-values.ts +42 -0
  44. package/src/lib/use-unsaved-changes-warning.ts +3 -3
  45. package/src/lib/validation-error.test.ts +1 -1
  46. package/src/lib/validation-logic.test.ts +1 -1
  47. package/src/lib/validation-modes.test-d.ts +1 -1
  48. package/src/lib/validation-modes.test.ts +12 -2
  49. package/src/lib/validation-modes.ts +3 -1
  50. package/src/multi-value-fields.test.tsx +116 -0
  51. package/src/submit-action.test.tsx +421 -0
  52. package/src/use-app-fields.test-d.ts +8 -25
  53. package/src/use-app-fields.test.tsx +8 -32
  54. package/src/use-form.cascade.test.tsx +4 -6
  55. package/src/use-form.focus-invalid.test.tsx +198 -0
  56. package/src/use-form.reset-defaults.test.tsx +125 -0
  57. package/src/use-form.test-d.ts +2 -3
  58. package/src/use-form.test.tsx +12 -14
  59. package/src/use-form.unsaved-warning.test.tsx +4 -13
  60. package/src/use-form.validation-modes.test.tsx +10 -8
  61. package/src/form.tsx +0 -105
  62. package/src/use-app-fields.tsx +0 -272
  63. package/src/use-form.tsx +0 -450
@@ -6,15 +6,29 @@
6
6
  // issue with a `path` we can record. Fields with defaults or `.optional()` stay
7
7
  // silent because the schema considers them satisfied.
8
8
  //
9
+ // A missing parent hides its children (the schema stops at the missing key), so
10
+ // the probe descends: when an issue says it expected an object or an array
11
+ // (Zod and Valibot both put that on `issue.expected`), the next pass fills that
12
+ // path with `{}` / `[{}]` and validates again. Array rows are probed through
13
+ // index `0` and recorded index-agnostically, so `members[0].name` in the set
14
+ // matches every row's `members[3].name` via `toRequiredPathKey`.
15
+ //
16
+ // Paths use TanStack Form's field-name syntax: dots for object keys, brackets
17
+ // for array indices (`members[0].name`, not `members.0.name`).
18
+ //
9
19
  // Limitations:
10
20
  // - Async schemas are skipped (returns an empty set). Required-ness is a
11
21
  // visual hint; the validator itself still runs at submit time.
12
- // - Cross-field refinements that fail on `{}` may flag fields that aren't
13
- // intrinsically required. In practice this is the same heuristic users
14
- // would apply by eye.
22
+ // - Cross-field refinements that fail on the probe value may flag fields that
23
+ // aren't intrinsically required. In practice this is the same heuristic
24
+ // users would apply by eye.
25
+ // - Schemas whose issues carry no `expected` hint stop at the top level.
26
+
27
+ type PathPart = PropertyKey | { readonly key: PropertyKey }
15
28
 
16
29
  type StandardIssue = {
17
- readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }>
30
+ readonly path?: readonly PathPart[]
31
+ readonly expected?: unknown
18
32
  }
19
33
 
20
34
  type StandardSchemaLike = {
@@ -28,37 +42,127 @@ type StandardSchemaLike = {
28
42
  }
29
43
  }
30
44
 
31
- function pathSegment(
32
- part: PropertyKey | { readonly key: PropertyKey }
33
- ): string {
45
+ const MAX_PROBE_DEPTH = 8
46
+
47
+ const INDEX_SEGMENT = /^\d+$/
48
+
49
+ // Matches `[3]` and `.3` index segments in a field name.
50
+ const INDEX_IN_NAME = /\[\d+\]|\.(\d+)(?=\.|\[|$)/g
51
+
52
+ function pathKey(part: PathPart): PropertyKey {
34
53
  if (typeof part === "object" && part !== null && "key" in part) {
35
- return String(part.key)
54
+ return part.key
36
55
  }
37
- return String(part)
56
+ return part
38
57
  }
39
58
 
40
- function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
41
- const required = new Set<string>()
59
+ function isIndex(key: PropertyKey): boolean {
60
+ return (
61
+ typeof key === "number" ||
62
+ (typeof key === "string" && INDEX_SEGMENT.test(key))
63
+ )
64
+ }
65
+
66
+ // Render issue path segments as a TanStack field name, with every array index
67
+ // normalised to `0` so one entry covers all rows.
68
+ function joinPath(keys: readonly PropertyKey[]): string {
69
+ let out = ""
70
+ for (const key of keys) {
71
+ if (isIndex(key)) {
72
+ out += "[0]"
73
+ } else {
74
+ out += out === "" ? String(key) : `.${String(key)}`
75
+ }
76
+ }
77
+ return out
78
+ }
79
+
80
+ /**
81
+ * Normalise a field name (`members[3].name` or `members.3.name`) to the key
82
+ * `getRequiredPaths` records (`members[0].name`).
83
+ */
84
+ function toRequiredPathKey(name: string): string {
85
+ return name.replace(INDEX_IN_NAME, "[0]")
86
+ }
87
+
88
+ function probeContainer(expected: unknown): unknown {
89
+ if (typeof expected !== "string") {
90
+ return
91
+ }
92
+ const kind = expected.toLowerCase()
93
+ if (kind === "object") {
94
+ return {}
95
+ }
96
+ if (kind === "array") {
97
+ return [{}]
98
+ }
99
+ return
100
+ }
101
+
102
+ // Write `value` at `keys` inside `root`, only where nothing is set yet.
103
+ function fillAt(
104
+ root: Record<PropertyKey, unknown>,
105
+ keys: readonly PropertyKey[],
106
+ value: unknown
107
+ ): boolean {
108
+ let node: Record<PropertyKey, unknown> = root
109
+ for (const key of keys.slice(0, -1)) {
110
+ const next = node[key]
111
+ if (typeof next !== "object" || next === null) {
112
+ return false
113
+ }
114
+ node = next as Record<PropertyKey, unknown>
115
+ }
116
+ const last = keys.at(-1) as PropertyKey
117
+ if (node[last] !== undefined) {
118
+ return false
119
+ }
120
+ node[last] = value
121
+ return true
122
+ }
123
+
124
+ function validateSync(
125
+ schema: StandardSchemaLike,
126
+ value: unknown
127
+ ): readonly StandardIssue[] | undefined {
42
128
  let result: ReturnType<StandardSchemaLike["~standard"]["validate"]>
43
129
  try {
44
- result = schema["~standard"].validate({})
130
+ result = schema["~standard"].validate(value)
45
131
  } catch {
46
- return required
132
+ return
47
133
  }
48
134
  if (result instanceof Promise) {
49
- return required
50
- }
51
- if (!("issues" in result && result.issues)) {
52
- return required
135
+ return
53
136
  }
54
- for (const issue of result.issues) {
55
- const segments = issue.path?.map(pathSegment) ?? []
56
- if (segments.length > 0) {
57
- required.add(segments.join("."))
137
+ return "issues" in result && result.issues ? result.issues : []
138
+ }
139
+
140
+ function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
141
+ const required = new Set<string>()
142
+ const probe: Record<PropertyKey, unknown> = {}
143
+ for (let depth = 0; depth < MAX_PROBE_DEPTH; depth++) {
144
+ const issues = validateSync(schema, probe)
145
+ if (!issues) {
146
+ return required
147
+ }
148
+ let descended = false
149
+ for (const issue of issues) {
150
+ const keys = issue.path?.map(pathKey) ?? []
151
+ if (keys.length === 0) {
152
+ continue
153
+ }
154
+ required.add(joinPath(keys))
155
+ const container = probeContainer(issue.expected)
156
+ if (container !== undefined && fillAt(probe, keys, container)) {
157
+ descended = true
158
+ }
159
+ }
160
+ if (!descended) {
161
+ break
58
162
  }
59
163
  }
60
164
  return required
61
165
  }
62
166
 
63
167
  export type { StandardSchemaLike }
64
- export { getRequiredPaths }
168
+ export { getRequiredPaths, toRequiredPathKey }
@@ -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 }