web: SubmitButton and usePending, a button that says it is working until the page has loaded what it changed
3 files+129−20/3 viewed
| 1 | − | import { Check, Copy } from "lucide-react"; | |
| 1 | + | import { Check, Copy, LoaderCircle } from "lucide-react"; | |
| 2 | 2 | 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"; | |
| 4 | 4 | ||
| 5 | 5 | import { isWaitingMessage, linkPaths } from "../../lib/compute"; | |
| 6 | + | import { type Submission, isPending } from "../../lib/pending"; | |
| 6 | 7 | import { Mark } from "../logo"; | |
| 7 | 8 | ||
| 8 | 9 | export function Field({ | |
| ⋯ | |||
| 124 | 125 | ); | |
| 125 | 126 | } | |
| 126 | 127 | ||
| 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 | + | ||
| 127 | 192 | /** A link that looks like a button. */ | |
| 128 | 193 | export function ButtonLink({ | |
| 129 | 194 | variant = "primary", | |
| 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 | + | }); |
| 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 | + | } |