Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

91 linesCodeBlameRaw
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 */
5import { useEffect, useRef, useState } from "react";
6import { 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 */
14export 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 */
30export 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 */
57export 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}