@zeno-lib/forms 0.4.0 → 0.5.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,22 @@
1
+ import { afterEach, describe, expect, test } from "vitest"
2
+ import { focusFirstInvalid } from "./focus-first-invalid"
3
+
4
+ afterEach(() => {
5
+ document.body.innerHTML = ""
6
+ })
7
+
8
+ describe("focusFirstInvalid", () => {
9
+ test("skips an invalid control that won't take focus", () => {
10
+ document.body.innerHTML = `
11
+ <form>
12
+ <input aria-invalid="true" disabled name="locked" />
13
+ <div aria-invalid="true" role="radiogroup">
14
+ <button disabled role="radio" type="button">A</button>
15
+ <button role="radio" type="button">B</button>
16
+ </div>
17
+ </form>`
18
+ const focused = focusFirstInvalid(document.querySelector("form"))
19
+ expect(focused?.textContent).toBe("B")
20
+ expect(document.activeElement).toBe(focused)
21
+ })
22
+ })
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Move focus to the first invalid control inside `root` (a `<form>`): the
3
+ * first element with `aria-invalid="true"`, or, for a group root that can't
4
+ * take focus itself (radio group, slider), its first tabbable descendant. An
5
+ * invalid control that won't take focus (disabled, hidden) is skipped for the
6
+ * next one. Call it after `await form.handleSubmit()` leaves the form invalid.
7
+ * Searching only `root` keeps focus inside the submitted form, so a dialog's
8
+ * form never sends it to the page behind. Returns the focused element, or
9
+ * `null`.
10
+ */
11
+ function focusFirstInvalid(
12
+ root: ParentNode | null | undefined
13
+ ): HTMLElement | null {
14
+ const invalid = root?.querySelectorAll<HTMLElement>('[aria-invalid="true"]')
15
+ for (const element of invalid ?? []) {
16
+ for (const target of [
17
+ element,
18
+ ...element.querySelectorAll<HTMLElement>("*"),
19
+ ]) {
20
+ if (target.tabIndex < 0) {
21
+ continue
22
+ }
23
+ target.focus()
24
+ if (target.ownerDocument.activeElement === target) {
25
+ return target
26
+ }
27
+ }
28
+ }
29
+ return null
30
+ }
31
+
32
+ /**
33
+ * Give focus back to `element`, what had it when the submit started, if the
34
+ * submit dropped it to the body. A submit button that disables itself while
35
+ * submitting does that in browsers, so a failed submit with no invalid field
36
+ * to focus would otherwise leave the user nowhere. It doesn't scroll, so a
37
+ * user who scrolled away during a slow submit stays put. Returns whether it
38
+ * moved focus.
39
+ */
40
+ function restoreFocus(element: Element | null): boolean {
41
+ if (!(element instanceof HTMLElement && element.isConnected)) {
42
+ return false
43
+ }
44
+ const active = element.ownerDocument.activeElement
45
+ if (active !== null && active !== element.ownerDocument.body) {
46
+ return false
47
+ }
48
+ element.focus({ preventScroll: true })
49
+ return element.ownerDocument.activeElement === element
50
+ }
51
+
52
+ export { focusFirstInvalid, restoreFocus }
@@ -0,0 +1,133 @@
1
+ "use client"
2
+
3
+ import { useCallback, useSyncExternalStore } from "react"
4
+
5
+ // A submit whose handler threw (a network failure, an unexpected server
6
+ // error), kept per form so `FormError` can show it.
7
+ //
8
+ // Not written to TanStack's `errorMap`: any entry there makes the form
9
+ // invalid, which disables `SubmitButton`, and `handleSubmit` then skips
10
+ // `onSubmit` until an edit clears the entry. The user couldn't retry a failed
11
+ // save as is.
12
+
13
+ type SubmitErrorState = {
14
+ readonly submissionAttempts: number
15
+ readonly values: unknown
16
+ }
17
+
18
+ // The slice of a form the helpers read. Keyed by `store`, as the per-form
19
+ // Zeno state is (`validation-modes.ts`): it is the one reference shared by
20
+ // the `useForm()` result and the form API that fields and context receive.
21
+ type SubmitErrorForm = {
22
+ readonly state: SubmitErrorState
23
+ readonly store: {
24
+ subscribe: (listener: () => void) => { unsubscribe: () => void }
25
+ }
26
+ }
27
+
28
+ type SubmitError = {
29
+ /** What the submit handler threw. */
30
+ readonly error: unknown
31
+ }
32
+
33
+ type Entry = {
34
+ readonly submitError: SubmitError
35
+ readonly unsubscribe: () => void
36
+ }
37
+
38
+ const entries = new WeakMap<object, Entry>()
39
+ const listeners = new WeakMap<object, Set<() => void>>()
40
+
41
+ function notify(key: object): void {
42
+ for (const listener of listeners.get(key) ?? []) {
43
+ listener()
44
+ }
45
+ }
46
+
47
+ function clear(key: object): void {
48
+ const entry = entries.get(key)
49
+ if (entry) {
50
+ entry.unsubscribe()
51
+ entries.delete(key)
52
+ notify(key)
53
+ }
54
+ }
55
+
56
+ // Next.js `redirect()`, `notFound()`, `forbidden()` and `unauthorized()` throw
57
+ // an error whose `digest` starts with `NEXT_`, and a server action that calls
58
+ // one rejects with it on the client. Next navigates on its own: no failure.
59
+ function isNavigationError(error: unknown): boolean {
60
+ if (typeof error !== "object" || error === null || !("digest" in error)) {
61
+ return false
62
+ }
63
+ return typeof error.digest === "string" && error.digest.startsWith("NEXT_")
64
+ }
65
+
66
+ /**
67
+ * Record that `form`'s submit threw `error`. `useForm` calls it for what
68
+ * `onSubmit` throws; call it for a failure your own submit code catches
69
+ * outside `onSubmit`. The error lasts until the values change or the next
70
+ * submit starts (`reset()` does both), so an edit or a retry clears it. A
71
+ * Next.js navigation (`redirect()`, `notFound()`) is ignored.
72
+ */
73
+ function setSubmitError(form: SubmitErrorForm, error: unknown): void {
74
+ if (isNavigationError(error)) {
75
+ return
76
+ }
77
+ const key = form.store
78
+ entries.get(key)?.unsubscribe()
79
+ const { submissionAttempts, values } = form.state
80
+ // Dropped, not just hidden, once stale: a reset restores the attempt count
81
+ // and can restore the same values object, which would bring it back.
82
+ const subscription = form.store.subscribe(() => {
83
+ const { state } = form
84
+ if (
85
+ state.values !== values ||
86
+ state.submissionAttempts !== submissionAttempts
87
+ ) {
88
+ clear(key)
89
+ }
90
+ })
91
+ entries.set(key, {
92
+ submitError: { error },
93
+ unsubscribe: subscription.unsubscribe,
94
+ })
95
+ notify(key)
96
+ }
97
+
98
+ /**
99
+ * Report a submit rejection that is caught so the user can retry, as
100
+ * `<Form>` and `FormDialog` do: `reportError` logs it and passes it to the
101
+ * page's `error` listeners (where error monitoring hooks in), as if it were
102
+ * uncaught. A Next.js navigation is skipped.
103
+ */
104
+ function reportSubmitError(error: unknown): void {
105
+ if (!isNavigationError(error)) {
106
+ // Missing in jsdom and older browsers.
107
+ globalThis.reportError?.(error)
108
+ }
109
+ }
110
+
111
+ /** The error `form`'s last submit threw, until it went stale. */
112
+ function useSubmitError(form: SubmitErrorForm): SubmitError | undefined {
113
+ const key = form.store
114
+ const subscribe = useCallback(
115
+ (listener: () => void) => {
116
+ const set = listeners.get(key) ?? new Set()
117
+ listeners.set(key, set)
118
+ set.add(listener)
119
+ return () => {
120
+ set.delete(listener)
121
+ }
122
+ },
123
+ [key]
124
+ )
125
+ return useSyncExternalStore(
126
+ subscribe,
127
+ () => entries.get(key)?.submitError,
128
+ () => undefined
129
+ )
130
+ }
131
+
132
+ export type { SubmitError, SubmitErrorForm }
133
+ export { reportSubmitError, setSubmitError, useSubmitError }
@@ -97,6 +97,11 @@ function useFormDialog<TValues>(
97
97
  type UseLeaveGuardOptions = {
98
98
  /** Whether leaving now would lose changes. */
99
99
  hasUnsavedChanges: boolean
100
+ /**
101
+ * Save the changes and resolve whether that worked. Backs `saveAndLeave`,
102
+ * so the confirm UI can offer Save next to Discard.
103
+ */
104
+ onSave?: () => boolean | Promise<boolean>
100
105
  }
101
106
 
102
107
  type LeaveGuard = {
@@ -111,14 +116,25 @@ type LeaveGuard = {
111
116
  confirmLeave: () => void
112
117
  /** The user chose to stay: drop the held leave. */
113
118
  cancelLeave: () => void
119
+ /**
120
+ * The user chose to save: run `onSave`, then the held leave if it saved.
121
+ * Either way the confirm UI closes; after a failed save the user stays.
122
+ * Resolves whether it saved. A rejection from `onSave` keeps the leave held
123
+ * and propagates.
124
+ */
125
+ saveAndLeave: () => Promise<boolean>
126
+ /** Whether `saveAndLeave` is waiting on `onSave`. */
127
+ isSaving: boolean
114
128
  }
115
129
 
116
130
  /** Ask before a leave (close, navigate, switch record) drops unsaved changes. */
117
131
  function useLeaveGuard({
118
132
  hasUnsavedChanges,
133
+ onSave,
119
134
  }: UseLeaveGuardOptions): LeaveGuard {
120
135
  const pending = useRef<(() => void) | null>(null)
121
136
  const [isConfirming, setIsConfirming] = useState(false)
137
+ const [isSaving, setIsSaving] = useState(false)
122
138
 
123
139
  const requestLeave = useCallback(
124
140
  (proceed: () => void) => {
@@ -144,7 +160,39 @@ function useLeaveGuard({
144
160
  setIsConfirming(false)
145
161
  }, [])
146
162
 
147
- return { cancelLeave, confirmLeave, isConfirming, requestLeave }
163
+ const saveAndLeave = useCallback(async () => {
164
+ const proceed = pending.current
165
+ if (!(proceed && onSave)) {
166
+ return false
167
+ }
168
+ setIsSaving(true)
169
+ let saved = false
170
+ try {
171
+ saved = await onSave()
172
+ } finally {
173
+ setIsSaving(false)
174
+ }
175
+ // Answered otherwise while saving (cancelled, or a newer leave replaced
176
+ // this one): leave that answer alone.
177
+ if (pending.current !== proceed) {
178
+ return saved
179
+ }
180
+ pending.current = null
181
+ setIsConfirming(false)
182
+ if (saved) {
183
+ proceed()
184
+ }
185
+ return saved
186
+ }, [onSave])
187
+
188
+ return {
189
+ cancelLeave,
190
+ confirmLeave,
191
+ isConfirming,
192
+ isSaving,
193
+ requestLeave,
194
+ saveAndLeave,
195
+ }
148
196
  }
149
197
 
150
198
  const FOCUSABLE =
@@ -9,6 +9,7 @@ import type { ReactNode } from "react"
9
9
  import { afterEach, describe, expect, test, vi } from "vitest"
10
10
  import { z } from "zod"
11
11
  import { Form, FormProvider, RadioGroupFieldItem, useForm } from "./create-form"
12
+ import { submitAction } from "./lib/submit-action"
12
13
 
13
14
  afterEach(() => {
14
15
  cleanup()
@@ -103,6 +104,39 @@ describe("<Form> — focus the first invalid field on a failed submit", () => {
103
104
  )
104
105
  })
105
106
 
107
+ test("focuses a field error returned through submitAction", async () => {
108
+ const user = userEvent.setup()
109
+ function Harness() {
110
+ const form = useForm({
111
+ defaultValues: { email: "ada@example.com", name: "Ada" },
112
+ onSubmit: (submit) =>
113
+ submitAction(submit, () =>
114
+ Promise.resolve({
115
+ error: { fieldErrors: { name: ["Taken"] }, formErrors: [] },
116
+ ok: false as const,
117
+ })
118
+ ),
119
+ schema,
120
+ })
121
+ const { EmailField, InputField, SubmitButton } = form
122
+ return (
123
+ <FormProvider form={form}>
124
+ <Form aria-label="Profile">
125
+ <EmailField name="email" />
126
+ <InputField label="Name" name="name" />
127
+ <SubmitButton>Save</SubmitButton>
128
+ </Form>
129
+ </FormProvider>
130
+ )
131
+ }
132
+ render(<Harness />)
133
+ await user.click(screen.getByRole("button", { name: "Save" }))
134
+ expect(await screen.findByText("Taken")).toBeTruthy()
135
+ await waitFor(() =>
136
+ expect(document.activeElement).toBe(control("Profile", "name"))
137
+ )
138
+ })
139
+
106
140
  test("a radio group or slider root hands focus to its tabbable child", async () => {
107
141
  const user = userEvent.setup()
108
142
  function Harness({ sliderFirst }: { sliderFirst: boolean }) {