fix(ui): four hand-rolled page titles go through PageHeader

The insights sub-page shell (text-xl sm:text-2xl semibold), the
medication detail, the check-in screen and its result (semibold) each
read a step lighter than every other page title. They now render
through PageHeader, which gains a focusable heading with a visible ring
for screens that move focus to their title, and an accessory slot
beside the H1 that stays outside its accessible name. A structural test
lists where a different H1 is the point (heroes, sign-in, error and
prose pages, the first-run steps) and refuses any other.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:40:40 +02:00
1 parent 9d44d1c47f
commit 6885377f35
7 files changed
+261 -111

No files matched your search

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { createRef } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { PageHeader } from "../page-header";
describe("<PageHeader> heading options", () => {
it("is a plain h1 by default, with no tab stop", () => {
const html = renderToStaticMarkup(<PageHeader title="Pulse" />);
expect(html).toMatch(
/<h1[^>]*class="[^"]*text-2xl font-bold tracking-tight/,
);
expect(html).not.toContain("tabindex");
});
it("becomes a programmatic focus target with a visible ring", () => {
const ref = createRef<HTMLHeadingElement>();
const html = renderToStaticMarkup(
<PageHeader title="Pulse" titleId="t" headingRef={ref} focusable />,
);
expect(html).toMatch(/<h1[^>]*tabindex="-1"/);
expect(html).toContain("focus-visible:ring-2");
});
it("keeps the accessory beside the heading, outside its name", () => {
const html = renderToStaticMarkup(
<PageHeader title="Pulse" titleAccessory={<span>Beta</span>} />,
);
expect(html).toMatch(/<h1[^>]*>Pulse<\/h1><span>Beta<\/span>/);
});
});
+37 -3
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import type { ReactNode, Ref } from "react";
import { BackLink } from "@/components/ui/back-link";
import { cn } from "@/lib/utils";
@@ -25,6 +25,14 @@ import { cn } from "@/lib/utils";
* row — and only one of the two may be a real `<h1>`. Without this the two
* shells hand-rolled the whole block to get the second copy, which is how
* they drifted apart from every module page in the first place.
*
* `focusable` makes the H1 a programmatic focus target (`tabIndex={-1}`
* with a visible ring) and `headingRef` hands the caller the element: a
* sub-page or a result screen moves focus to its title after a client
* navigation. `titleAccessory` sits beside the H1 and
* wraps with it (a nudge glyph, a badge) without becoming part of the
* heading's accessible name. Both exist so the insights sub-page shell and
* the check-in screens stop hand-rolling their own heading.
*/
/**
* A long one-word German title (the illness page was once
@@ -44,6 +52,9 @@ export function PageHeader({
topSlot,
actions,
stackActionsOnPhone = false,
headingRef,
focusable = false,
titleAccessory,
className,
}: {
title: ReactNode;
@@ -61,6 +72,12 @@ export function PageHeader({
* the title and leave this off.
*/
stackActionsOnPhone?: boolean;
/** The H1 element, for a caller that moves focus to it. */
headingRef?: Ref<HTMLHeadingElement>;
/** Makes the H1 a programmatic focus target with a visible ring. */
focusable?: boolean;
/** Beside the H1, outside its accessible name (a nudge glyph, a badge). */
titleAccessory?: ReactNode;
className?: string;
}) {
const heading =
@@ -69,10 +86,27 @@ export function PageHeader({
{title}
</div>
) : (
<h1 id={titleId} className={HEADING_CLASS}>
<h1
ref={headingRef}
id={titleId}
tabIndex={focusable ? -1 : undefined}
className={cn(
HEADING_CLASS,
focusable &&
"focus-visible:ring-ring/50 rounded-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
)}
>
{title}
</h1>
);
const titleBlock = titleAccessory ? (
<div className="flex min-w-0 flex-wrap items-center gap-2">
{heading}
{titleAccessory}
</div>
) : (
heading
);
// Title, description and actions share one grid. On a phone the
// description spans the full row under the title + actions: a header with
@@ -91,7 +125,7 @@ export function PageHeader({
actions && stackActionsOnPhone && "max-sm:grid-cols-1",
)}
>
{heading}
{titleBlock}
{actions ? (
<div
className={cn(