fix(web,learner): navigation swaps at once; content-only entrance, loading line, focus (R19)
Drops document-level View Transitions and the lesson hold: they dropped clicks for 120-180 ms, froze the screen while route code loaded, and let the dock act on a stale lesson. Navigation now swaps immediately; only the content region enters (opacity and a 6 px rise, 160 ms); a 2 px loading line appears when code or data takes over 150 ms; the dock works from the outline and the URL, so a second Next advances again; focus moves to the new h1 and a polite live region announces it. Route chunks of a feature preload together so the sidebar never drops. Scroll restore stops on user input; pdf.js never starts a load after cleanup. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
305c262ff1
commit
b5e00af4f7
+75
-31
@@ -1,10 +1,10 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { lazy, Suspense, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { createBrowserRouter, createPath, Link, NavLink, Outlet, RouterProvider, useLocation, useNavigationType, type RouteObject } from "react-router-dom";
|
||||
import { Suspense, use, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from "react";
|
||||
import { createBrowserRouter, Link, NavLink, Outlet, RouterProvider, useLocation, useNavigationType, type RouteObject } from "react-router-dom";
|
||||
import type { AuthUser } from "@stc/contracts";
|
||||
import { AppShell, installRouteScroll, reducedMotion, routeScroll } from "@stc/ui";
|
||||
import { AppShell, installRouteScroll, LoadingLine, routeScroll } from "@stc/ui";
|
||||
import { webFeatures } from "./features";
|
||||
import { changesPage, visibleNav } from "./nav";
|
||||
import { visibleNav } from "./nav";
|
||||
|
||||
function Home() {
|
||||
return <p className="work">No features installed yet</p>;
|
||||
@@ -25,18 +25,50 @@ function useSessionUser(): AuthUser | null {
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Scroll per history entry: top on a new page, the saved position on back/forward (a page may defer it). */
|
||||
/** Scroll per history entry: top on a new navigation, the saved position on back/forward. */
|
||||
function useRouteScroll() {
|
||||
const loc = useLocation();
|
||||
const type = useNavigationType();
|
||||
useEffect(() => installRouteScroll(), []);
|
||||
// A layout effect, so with a view transition the scroll is part of the captured new state. Pages' layout
|
||||
// effects run before this one, so a page keeping its old content on screen can defer it first.
|
||||
useLayoutEffect(() => { routeScroll().routeChanged(loc.key, type === "POP", loc.hash); }, [loc.key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a navigation, focus moves to the new page's heading (its first h1, given tabindex -1) once it renders, and
|
||||
* its text goes to a polite live region. The first page load keeps the browser's own focus.
|
||||
*/
|
||||
function useRouteFocus(): string {
|
||||
const loc = useLocation();
|
||||
const [said, setSaid] = useState("");
|
||||
const last = useRef<Element | null>(null);
|
||||
const first = useRef(true);
|
||||
useEffect(() => {
|
||||
const main = document.querySelector(".stc-main");
|
||||
if (!main) return;
|
||||
const initial = first.current;
|
||||
first.current = false;
|
||||
const take = () => {
|
||||
const h = main.querySelector<HTMLElement>("h1");
|
||||
if (!h || h === last.current) return false;
|
||||
last.current = h;
|
||||
if (initial) return true;
|
||||
if (!h.hasAttribute("tabindex")) h.setAttribute("tabindex", "-1");
|
||||
h.focus({ preventScroll: true });
|
||||
setSaid(h.textContent ?? "");
|
||||
return true;
|
||||
};
|
||||
if (take()) return;
|
||||
const mo = new MutationObserver(() => { if (take()) mo.disconnect(); });
|
||||
mo.observe(main, { childList: true, subtree: true });
|
||||
const stop = setTimeout(() => mo.disconnect(), 10_000);
|
||||
return () => { mo.disconnect(); clearTimeout(stop); };
|
||||
}, [loc.key]);
|
||||
return said;
|
||||
}
|
||||
|
||||
function Layout() {
|
||||
useRouteScroll();
|
||||
const said = useRouteFocus();
|
||||
// Links the user cannot use are hidden; the pages and APIs still enforce access themselves.
|
||||
const nav = visibleNav(webFeatures, useSessionUser());
|
||||
const header = (
|
||||
@@ -50,35 +82,47 @@ function Layout() {
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<AppShell header={header}>
|
||||
<Suspense fallback={null}><Outlet /></Suspense>
|
||||
</AppShell>
|
||||
<>
|
||||
<AppShell header={header}>
|
||||
<Suspense fallback={<LoadingLine />}><Outlet /></Suspense>
|
||||
</AppShell>
|
||||
<div className="sr" role="status" aria-live="polite" aria-atomic="true">{said}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const featureRoutes: RouteObject[] = webFeatures.flatMap((f) =>
|
||||
f.routes.map((r) => {
|
||||
const Element = lazy(r.element);
|
||||
return { path: r.path, element: <Element /> };
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* A route component loaded on first use, like React.lazy, but preloadable: once one route of a feature has
|
||||
* rendered, the feature's other routes load straight after. Moving between them (course page to lesson) then never
|
||||
* suspends, so the course sidebar is not dropped for a frame on the first visit.
|
||||
*/
|
||||
function lazyRoute(load: () => Promise<{ default: ComponentType }>, siblings: (() => unknown)[]) {
|
||||
let Loaded: ComponentType | null = null;
|
||||
let pending: Promise<unknown> | null = null;
|
||||
const preload = () => (pending ??= load().then((m) => { Loaded = m.default; }));
|
||||
function Route() {
|
||||
if (!Loaded) use(preload());
|
||||
useEffect(() => { siblings.forEach((p) => void p()); }, []);
|
||||
const C = Loaded!;
|
||||
return <C />;
|
||||
}
|
||||
return { Route, preload };
|
||||
}
|
||||
|
||||
const featureRoutes: RouteObject[] = webFeatures.flatMap((f) => {
|
||||
const siblings: (() => unknown)[] = [];
|
||||
return f.routes.map((r) => {
|
||||
const { Route, preload } = lazyRoute(r.element, siblings);
|
||||
siblings.push(preload);
|
||||
return { path: r.path, element: <Route /> };
|
||||
});
|
||||
});
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ element: <Layout />, children: [...featureRoutes, ...(featureRoutes.some((r) => r.path === "/") ? [] : [{ path: "*", element: <Home /> }])] },
|
||||
]);
|
||||
|
||||
// Navigating to another kind of page (library, course, lesson, account, studio…) runs as a view transition: the old
|
||||
// page dissolves while the new one's content rises in (styles in @stc/ui tokens.css). Steps within a page (lesson
|
||||
// to lesson) are animated by the page itself. Reduced motion and browsers without the API navigate plainly.
|
||||
// Router updates run in a React transition (v7_startTransition), so a route whose code is still loading keeps the
|
||||
// current page up instead of falling back to nothing; the view transition captures the new page once it renders
|
||||
// (with the course sidebar still in place).
|
||||
const routePatterns = featureRoutes.map((r) => ({ path: r.path }));
|
||||
const plainNavigate = router.navigate.bind(router);
|
||||
router.navigate = ((to: Parameters<typeof plainNavigate>[0], opts?: Parameters<typeof plainNavigate>[1]) => {
|
||||
const animate = typeof to !== "number" && to !== null && "startViewTransition" in document && !reducedMotion()
|
||||
&& changesPage(routePatterns, router.state.location.pathname, typeof to === "string" ? to : createPath(to));
|
||||
return plainNavigate(to as never, animate ? { ...opts, viewTransition: opts?.viewTransition ?? true } : opts);
|
||||
}) as typeof router.navigate;
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<RouterProvider router={router} future={{ v7_startTransition: true }} />);
|
||||
// Navigation swaps at once (no View Transitions, no holding the old page, ruling R19); the new content region
|
||||
// plays its CSS entrance (@stc/ui tokens.css) and a loading line shows if code or data takes over 150 ms.
|
||||
createRoot(document.getElementById("root")!).render(<RouterProvider router={router} />);
|
||||
|
||||
@@ -20,15 +20,3 @@ test("features.ts imports only browser entries, by package name", () => {
|
||||
expect(specs.filter((s) => s.endsWith("/web")).length).toBe(4);
|
||||
});
|
||||
|
||||
test("changesPage: a view transition for a new kind of page, none between steps of the same page", async () => {
|
||||
const { changesPage } = await import("./nav");
|
||||
const routes = webFeatures.flatMap((f) => f.routes.map((r) => ({ path: r.path })));
|
||||
expect(changesPage(routes, "/", "/c/sample")).toBe(true);
|
||||
expect(changesPage(routes, "/c/sample", "/c/sample/m1-intro")).toBe(true);
|
||||
expect(changesPage(routes, "/c/sample/m1-intro", "/c/sample/notes")).toBe(true);
|
||||
expect(changesPage(routes, "/c/sample/m1-intro", "/c/sample/m1-code")).toBe(false);
|
||||
expect(changesPage(routes, "/c/sample/m1-intro", "/c/other/x?t=3")).toBe(false);
|
||||
expect(changesPage(routes, "/c/sample/m1-video", "/c/sample/m1-video?deck=d1&slide=2")).toBe(false);
|
||||
expect(changesPage(routes, "/c/a%20b/x", "/c/a%20b/y")).toBe(false);
|
||||
expect(changesPage(routes, "/c/sample", "/nowhere/at/all/deep")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { matchRoutes } from "react-router-dom";
|
||||
import type { AuthUser, Role, WebFeature, WebRoute } from "@stc/contracts";
|
||||
|
||||
/** Admin-area links default to the owner; header links default to everyone. `nav.roles` overrides both. */
|
||||
@@ -15,14 +14,3 @@ export function visibleNav(features: WebFeature[], user: Pick<AuthUser, "roles">
|
||||
return [...all.filter((r) => r.nav!.area === "header" && allowed(r)), ...all.filter((r) => r.nav!.area === "admin" && allowed(r))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether navigating from `fromPath` to `to` shows another kind of page (another route pattern), which gets a
|
||||
* view transition. Steps within one page (lesson to lesson, a query change) do not: the page animates them itself.
|
||||
*/
|
||||
export function changesPage(routes: { path?: string }[], fromPath: string, to: string): boolean {
|
||||
const pattern = (path: string) => matchRoutes(routes, path)?.at(-1)?.route.path ?? null;
|
||||
let toPath: string;
|
||||
try { toPath = new URL(to, `http://x${fromPath}`).pathname; } catch { return true; }
|
||||
const a = pattern(fromPath), b = pattern(toPath);
|
||||
return a === null || b === null || a !== b;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user