| 1 | /** |
| 2 | * The page on its way out while the next one loads, as apps/web's shell |
| 3 | * has it: a bar across the top, and the page dimming after a moment. |
| 4 | */ |
| 5 | import { useEffect, useRef, useState } from "react"; |
| 6 | import { useNavigation } from "react-router"; |
| 7 | |
| 8 | /** |
| 9 | * The page while the next one loads: after a moment it dims, under the |
| 10 | * bar below, so it is plainly on its way out rather than frozen, and the |
| 11 | * next page fades up from there. Sending a form does not dim it: the |
| 12 | * form's button says it is working. Spread on the element holding the page. |
| 13 | */ |
| 14 | export function useLeaving() { |
| 15 | const navigation = useNavigation(); |
| 16 | const method = navigation.formMethod?.toUpperCase(); |
| 17 | const leaving = navigation.state === "loading" && (method == null || method === "GET"); |
| 18 | return { |
| 19 | "aria-busy": leaving || undefined, |
| 20 | className: `transition-opacity motion-reduce:transition-none ${leaving ? "opacity-60 delay-200 duration-300" : "duration-150"}`, |
| 21 | }; |
| 22 | } |
| 23 | |
| 24 | /** |
| 25 | * A form React Router sent (<Form>) leaves the page as a fresh one would: |
| 26 | * once the answer is in, the form goes back to what the page now says |
| 27 | * and its button lets go of focus, so app.css's "working" spinner on a |
| 28 | * focused submit button stops. A plain <form> loads a new page anyway. |
| 29 | */ |
| 30 | export function useSettledForms() { |
| 31 | const navigation = useNavigation(); |
| 32 | const sent = useRef<HTMLFormElement | null>(null); |
| 33 | useEffect(() => { |
| 34 | const remember = (event: SubmitEvent) => { |
| 35 | if (event.target instanceof HTMLFormElement) sent.current = event.target; |
| 36 | }; |
| 37 | document.addEventListener("submit", remember); |
| 38 | return () => document.removeEventListener("submit", remember); |
| 39 | }, []); |
| 40 | useEffect(() => { |
| 41 | const form = sent.current; |
| 42 | if (navigation.state !== "idle" || !form) return; |
| 43 | sent.current = null; |
| 44 | if (!form.isConnected) return; |
| 45 | form.reset(); |
| 46 | if (document.activeElement instanceof HTMLElement && form.contains(document.activeElement)) document.activeElement.blur(); |
| 47 | }, [navigation.state]); |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * A bar across the top of the page while the next one loads: it appears |
| 52 | * at once, creeps towards the end while waiting, then fills and fades when |
| 53 | * the page arrives. At rest it has no width and no style attribute, so the |
| 54 | * server's page carries no inline style for the policy to refuse; React |
| 55 | * sets the width through the DOM once it moves. |
| 56 | */ |
| 57 | export function Progress() { |
| 58 | const navigation = useNavigation(); |
| 59 | const busy = navigation.state !== "idle"; |
| 60 | const [width, setWidth] = useState(0); |
| 61 | const [visible, setVisible] = useState(false); |
| 62 | useEffect(() => { |
| 63 | if (busy) { |
| 64 | setVisible(true); |
| 65 | setWidth(12); |
| 66 | // Slows as it goes, never quite arriving until the page does. |
| 67 | const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200); |
| 68 | return () => clearInterval(timer); |
| 69 | } |
| 70 | setWidth((now) => (now > 0 ? 100 : 0)); |
| 71 | const fade = setTimeout(() => setVisible(false), 250); |
| 72 | const reset = setTimeout(() => setWidth(0), 550); |
| 73 | return () => { |
| 74 | clearTimeout(fade); |
| 75 | clearTimeout(reset); |
| 76 | }; |
| 77 | }, [busy]); |
| 78 | return ( |
| 79 | <div |
| 80 | aria-hidden="true" |
| 81 | className={`pointer-events-none fixed inset-x-0 top-0 z-60 h-0.75 transition-opacity duration-300 ${ |
| 82 | visible ? "opacity-100" : "opacity-0" |
| 83 | }`} |
| 84 | > |
| 85 | <div |
| 86 | className="h-full w-0 bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out" |
| 87 | style={width > 0 ? { width: `${width}%` } : undefined} |
| 88 | /> |
| 89 | </div> |
| 90 | ); |
| 91 | } |