[]previous and next page

Pages, routes and titles

A single-page app has to do what a page load does.

For agentsUpdated

A single-page app has to do what a page load does. After a router navigation, nothing moves focus or announces the new page. Update document.title (the Title component of @shadow-js/web, or a route's head), and move focus to the new page's heading or announce it. The route announcer (routeAnnouncer() from @shadow-js/router/announcer, on by default in Dusk) does both for you: it announces the title, falls back to the first h1 and then the path, and moves focus to the element a URL hash names. The example below does it by hand:

src/route-focus.tsxtsx
import { useEffect } from "@shadow-js/core";
import {
  createMemoryHistory,
  createRouter,
  defineRoute,
  Outlet,
  RouterProvider,
  useLocation,
} from "@shadow-js/router";

function RouteFocus() {
  const location = useLocation();
  // `defer` skips the first run: the browser already handles the first page load.
  useEffect(
    () => location.pathname,
    () => {
      document.querySelector<HTMLElement>("main h1")?.focus();
    },
    { defer: true },
  );
  return null;
}

function Page(props: { title: string }) {
  return (
    <main>
      <h1 tabIndex={-1}>{props.title}</h1>
    </main>
  );
}

const home = defineRoute("/", { component: () => <Page title="Home" /> });
const about = defineRoute("/about", { component: () => <Page title="About" /> });

export function createAppRouter() {
  return createRouter({
    routes: [home, about],
    history: createMemoryHistory({ initialEntries: ["/"] }),
  });
}

export function App(props: { router: ReturnType<typeof createAppRouter> }) {
  return (
    <RouterProvider router={props.router}>
      <RouteFocus />
      <Outlet />
    </RouterProvider>
  );
}

A tabIndex={-1} heading can be focused by script without joining the tab order.