Skip to content

Commit

web: SubmitButton and usePending, a button that says it is working until the page has loaded what it changed

syntaqxcommitted Parentead0d01Browse files
3 files+129−20/3 viewed
+67−2
1−import { Check, Copy } from "lucide-react";
1+import { Check, Copy, LoaderCircle } from "lucide-react";
22 import { type ComponentProps, type ReactNode, useState } from "react";
3−import { Link, type LinkProps, NavLink, useLocation } from "react-router";
3+import { Link, type LinkProps, NavLink, useLocation, useNavigation } from "react-router";
44
55 import { isWaitingMessage, linkPaths } from "../../lib/compute";
6+import { type Submission, isPending } from "../../lib/pending";
67 import { Mark } from "../logo";
78
89 export function Field({
124125 );
125126 }
126127
128+/**
129+ * Whether the submission `fields` names is still working (lib/pending.ts):
130+ * the page's own navigation, or `fetcher`'s when the form is a fetcher's.
131+ */
132+export function usePending(fields?: Record<string, string | null | undefined>, fetcher?: Submission): boolean {
133+ const navigation = useNavigation();
134+ return isPending(fetcher ?? navigation, fields);
135+}
136+
137+/**
138+ * A form's submit button that says it is working: turned off, with a
139+ * spinner and `pending` ("Saving…") in place of its words, from the moment
140+ * it is pressed until the page has loaded what it changed. Its own
141+ * `name`/`value` say which submission is its; `match` names it otherwise,
142+ * such as the form's hidden `intent`. `fetcher` when the form is a
143+ * fetcher's. `className` replaces the button look, for icon buttons, and
144+ * `icon` says the spinner takes the place of everything inside it. Words
145+ * with a leading icon should pass `pending`, so the spinner replaces both.
146+ */
147+export function SubmitButton({
148+ variant = "primary",
149+ pending,
150+ match,
151+ fetcher,
152+ busy,
153+ icon,
154+ disabled,
155+ className,
156+ children,
157+ ...props
158+}: Omit<ComponentProps<"button">, "type"> & {
159+ variant?: Variant;
160+ /** The words while it works, such as "Saving…"; its own words when absent. */
161+ pending?: ReactNode;
162+ match?: Record<string, string | null | undefined>;
163+ fetcher?: Submission;
164+ /** Working for a reason this button cannot see by itself. */
165+ busy?: boolean;
166+ /** An icon button: while it works, the spinner is all it shows. */
167+ icon?: boolean;
168+}) {
169+ const own =
170+ typeof props.name === "string" && props.value != null ? { [props.name]: String(props.value) } : undefined;
171+ const working = usePending({ ...own, ...match }, fetcher) || Boolean(busy);
172+ return (
173+ <button
174+ {...props}
175+ type="submit"
176+ disabled={disabled || working}
177+ aria-busy={working || undefined}
178+ className={className ?? `${BUTTON_BASE} ${BUTTON_VARIANTS[variant]}`}
179+ >
180+ {working ? (
181+ <>
182+ <LoaderCircle size={14} aria-hidden="true" className="shrink-0 animate-spin" />
183+ {icon ? null : (pending ?? children)}
184+ </>
185+ ) : (
186+ children
187+ )}
188+ </button>
189+ );
190+}
191+
127192 /** A link that looks like a button. */
128193 export function ButtonLink({
129194 variant = "primary",
+38−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { isPending } from "./pending.ts";
5+
6+const posted = (entries: Record<string, string>) => {
7+ const form = new FormData();
8+ for (const [name, value] of Object.entries(entries)) form.set(name, value);
9+ return form;
10+};
11+
12+test("nothing is pending while the page is idle", () => {
13+ assert.equal(isPending({ state: "idle" }), false);
14+ assert.equal(isPending({ state: "idle", formMethod: "POST", formData: posted({ intent: "save" }) }, { intent: "save" }), false);
15+});
16+
17+test("a post is pending while it is sent and while the page reloads after it", () => {
18+ const formData = posted({ intent: "save" });
19+ assert.equal(isPending({ state: "submitting", formMethod: "POST", formData }), true);
20+ assert.equal(isPending({ state: "loading", formMethod: "POST", formData }), true);
21+ assert.equal(isPending({ state: "loading", formMethod: "post", formData }, { intent: "save" }), true);
22+});
23+
24+test("following a link, or a GET form, is not a pending write", () => {
25+ assert.equal(isPending({ state: "loading" }), false);
26+ assert.equal(isPending({ state: "loading", formMethod: "GET", formData: posted({ q: "x" }) }), false);
27+ assert.equal(isPending({ state: "submitting", formMethod: "GET", formData: posted({ q: "x" }) }), false);
28+});
29+
30+test("only the submission that matches is pending, so one row's button does not speak for another's", () => {
31+ const formData = posted({ intent: "delete", id: "hook_1" });
32+ const sending = { state: "submitting" as const, formMethod: "POST", formData };
33+ assert.equal(isPending(sending, { intent: "delete", id: "hook_1" }), true);
34+ assert.equal(isPending(sending, { intent: "delete", id: "hook_2" }), false);
35+ assert.equal(isPending(sending, { intent: "ping" }), false);
36+ // A field left out (undefined) does not have to match.
37+ assert.equal(isPending(sending, { intent: "delete", id: undefined }), true);
38+});
+24−0
1+/**
2+ * Whether a button's submission is still working: posted and not yet
3+ * answered, or answered and the page still loading what it changed. A
4+ * button that only waits for "submitting" turns back on while the page
5+ * still shows the old figures, so it looks as if nothing happened.
6+ *
7+ * `fields` names the submission, such as `{ intent: "delete", id }`: every
8+ * one must match what was posted, so one row's button does not say
9+ * "Deleting…" while another row's is the one going. With no `fields`, any
10+ * submission that writes counts. A GET form (a search, a filter) is never
11+ * pending here: it only reads.
12+ */
13+export type Submission = {
14+ state: "idle" | "submitting" | "loading";
15+ formMethod?: string | null;
16+ formData?: FormData | null;
17+};
18+
19+export function isPending(submission: Submission, fields?: Record<string, string | null | undefined>): boolean {
20+ if (submission.state === "idle" || !submission.formData) return false;
21+ if (!submission.formMethod || submission.formMethod.toUpperCase() === "GET") return false;
22+ const posted = submission.formData;
23+ return Object.entries(fields ?? {}).every(([name, value]) => value == null || posted.get(name) === value);
24+}