diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx
index 0f7451bb31..7c05bc1c98 100644
--- a/src/components/clinical-dashboard/global-search-shell.tsx
+++ b/src/components/clinical-dashboard/global-search-shell.tsx
@@ -999,7 +999,8 @@ function GlobalStandaloneSearchShellBody({
section ids. Specifiers and Formulation keep their existing local
Subnav (SpecifierSubnav / FormulationSubnav), so the shared mode bar
is skipped for them to avoid a duplicate row on their workflow routes.
- Rendered in normal flow (sticky={false}) so it never contends with
+ Mode navigation portals into the collapsing header; the "On this page"
+ bar renders in normal flow (sticky={false}) so it never contends with
the universal collapsing header or page-flow search chrome.
*/}
{!pendingModeNavigation && searchMode !== "specifiers" && searchMode !== "formulation" ? (
@@ -1008,7 +1009,6 @@ function GlobalStandaloneSearchShellBody({
pathname={pathname}
hasSubmittedSearch={hasSubmittedModeSearch}
searchParamString={searchParamString}
- onSearch={() => inputRef.current?.focus({ preventScroll: true })}
sticky={false}
/>
) : null}
diff --git a/src/components/formulation/formulation-ui.tsx b/src/components/formulation/formulation-ui.tsx
index 9a48393103..4700e5c9cc 100644
--- a/src/components/formulation/formulation-ui.tsx
+++ b/src/components/formulation/formulation-ui.tsx
@@ -1,8 +1,8 @@
-import Link from "next/link";
import type { ReactNode } from "react";
import { Info, Network, ShieldCheck } from "lucide-react";
import { InformationPageBreadcrumbs, InformationPageShell } from "@/components/information-page-shell";
+import { RegistryModeNav } from "@/components/mode-nav/registry-mode-nav";
import { cn, eyebrowText } from "@/components/ui-primitives";
export const formulationCard =
@@ -17,36 +17,7 @@ export function FormulationBreadcrumbs({ current }: { current?: string }) {
}
export function FormulationSubnav({ active }: { active: "search" | "builder" | "compare" | "map" }) {
- const items = [
- { id: "search" as const, label: "Find mechanisms", shortLabel: "Find", href: "/formulation" },
- { id: "builder" as const, label: "Build formulation", shortLabel: "Build", href: "/formulation/builder" },
- { id: "compare" as const, label: "Compare", shortLabel: "Compare", href: "/formulation/compare" },
- { id: "map" as const, label: "Mechanism map", shortLabel: "Map", href: "/formulation/map" },
- ];
-
- return (
-
- );
+ return ;
}
export function MechanismDomainChips({ values, limit }: { values: string[]; limit?: number }) {
diff --git a/src/components/mode-nav/mode-nav-portal.tsx b/src/components/mode-nav/mode-nav-portal.tsx
index 63913ca14b..1484ecb5d2 100644
--- a/src/components/mode-nav/mode-nav-portal.tsx
+++ b/src/components/mode-nav/mode-nav-portal.tsx
@@ -25,11 +25,10 @@ import { phoneHeaderCollapseAddonSlotId } from "@/lib/mode-home-composer";
* Falls back to normal flow when no host exists — routes rendered without the
* universal header, and the server pass — so navigation is never lost.
*
- * Slot ownership: the addon slot holds ONE page-owned header. `DocumentViewer`
- * and the differentials detail page already claim it on phones, so a mode whose
- * routes include those pages must not also mount a bar there. `ModeNav` renders
- * nothing below two destinations, which is what keeps those modes clear today;
- * `tests/mode-nav-contract.test.ts` fails if that stops being true.
+ * Slot ownership: the addon slot holds ONE page-owned header. Document and
+ * detail-page owners claim it only on routes where the shared shell suppresses
+ * ModeNav first; one-destination modes also render nothing below the two-item
+ * minimum. Contract and route-level tests fail if those ownership gates drift.
*/
export function ModeNavHeaderPortal({ children }: { children: ReactNode }) {
const [host, setHost] = useState(null);
diff --git a/src/components/mode-nav/registry-mode-nav.tsx b/src/components/mode-nav/registry-mode-nav.tsx
new file mode 100644
index 0000000000..4b4ab12675
--- /dev/null
+++ b/src/components/mode-nav/registry-mode-nav.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { FileText, GitCompareArrows, ListChecks, Network, Search, type LucideIcon } from "lucide-react";
+
+import { ModeNav, type ModeNavItem } from "@/components/mode-nav/mode-nav";
+import { appModeDefinition, type AppModeId } from "@/lib/app-modes";
+import { modeSecondaryNavigationEntries, modeSecondaryNavigationHref } from "@/lib/mode-secondary-navigation";
+
+const iconByItemId: Record = {
+ search: Search,
+ diagnoses: FileText,
+ compare: GitCompareArrows,
+ builder: ListChecks,
+ map: Network,
+};
+
+/**
+ * Adapts the canonical route registry to the universal header-integrated mode
+ * navigation. Keeping this mapping in one component prevents the page shell,
+ * Specifiers, and Formulation from drifting onto different labels or URLs.
+ */
+export function RegistryModeNav({
+ modeId,
+ activeId,
+ searchParamString = "",
+}: {
+ modeId: AppModeId;
+ activeId: string;
+ searchParamString?: string;
+}) {
+ const currentSearchParams = new URLSearchParams(searchParamString);
+ const items = modeSecondaryNavigationEntries(modeId).flatMap((entry) => {
+ if (!entry.href) return [];
+ return [
+ {
+ id: entry.id,
+ label: entry.label,
+ href: modeSecondaryNavigationHref({
+ modeId,
+ itemId: entry.id,
+ href: entry.href,
+ currentSearchParams,
+ }),
+ icon: iconByItemId[entry.id] ?? FileText,
+ },
+ ];
+ });
+
+ return ;
+}
diff --git a/src/components/page-secondary-navigation.tsx b/src/components/page-secondary-navigation.tsx
index 54bc1115ea..a506f9cf3c 100644
--- a/src/components/page-secondary-navigation.tsx
+++ b/src/components/page-secondary-navigation.tsx
@@ -1,21 +1,13 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useState } from "react";
import { isDocumentViewerOwnedRoute } from "@/components/clinical-dashboard/mobile-composer-reserve";
-import {
- SecondaryNavigation,
- type SecondaryNavigationItem,
- type SecondaryNavigationSectionItem,
-} from "@/components/secondary-navigation";
-import { appModeDefinition, type AppModeId } from "@/lib/app-modes";
+import { RegistryModeNav } from "@/components/mode-nav/registry-mode-nav";
+import { SecondaryNavigation, type SecondaryNavigationSectionItem } from "@/components/secondary-navigation";
+import type { AppModeId } from "@/lib/app-modes";
import { isInformationPage } from "@/lib/information-pages";
-import {
- activeModeSecondaryNavigationId,
- isModeSecondaryNavigationRoute,
- modeSecondaryNavigationEntries,
- modeSecondaryNavigationHref,
-} from "@/lib/mode-secondary-navigation";
+import { activeModeSecondaryNavigationId, isModeSecondaryNavigationRoute } from "@/lib/mode-secondary-navigation";
export type InformationPageSectionDefinition = {
id: string;
@@ -152,15 +144,19 @@ export function informationPageSectionDefinitions(pathname: string): readonly In
if (
pathname.startsWith("/specifiers/") &&
!["/specifiers/builder", "/specifiers/compare", "/specifiers/map"].includes(pathname)
- )
+ ) {
return specifierSections;
+ }
if (
pathname.startsWith("/formulation/") &&
!["/formulation/builder", "/formulation/compare", "/formulation/map"].includes(pathname)
- )
+ ) {
return formulationSections;
+ }
if (pathname.startsWith("/differentials/presentations/")) return differentialPresentationSections;
- if (pathname.endsWith("/differentials") && pathname.startsWith("/dsm/diagnoses/")) return dsmDifferentialSections;
+ if (pathname.endsWith("/differentials") && pathname.startsWith("/dsm/diagnoses/")) {
+ return dsmDifferentialSections;
+ }
if (pathname.startsWith("/dsm/diagnoses/")) return dsmDiagnosisSections;
if (pathname.startsWith("/documents/") && pathname !== "/documents/search") return documentSections;
return [];
@@ -248,57 +244,28 @@ export function PageSecondaryNavigation({
modeId,
pathname,
hasSubmittedSearch,
- onSearch,
/**
* Bridged query string from GlobalStandaloneSearchShellBody. Must not call
* useSearchParams here — that reintroduces a nested Suspense boundary under
* the standalone shell body (search-chrome invariant 17).
*/
searchParamString = "",
+ /**
+ * Only reaches the "On this page" bar. The mode bar is the header-integrated
+ * `ModeNav`, which portals into the collapsing header and owns its own
+ * placement, so no positioning prop is accepted for it.
+ */
sticky = true,
- stickyTop,
}: {
modeId: AppModeId;
pathname: string;
hasSubmittedSearch: boolean;
- onSearch: () => void;
searchParamString?: string;
sticky?: boolean;
- stickyTop?: number | string;
}) {
const informationDefinitions = informationPageSectionDefinitions(pathname);
const locallyOwnedInformationNavigation = hasLocalInformationPageNavigation(pathname);
const activeId = activeModeSecondaryNavigationId(modeId, pathname);
- const modeLabel = appModeDefinition(modeId).label;
- const modeAriaLabel = modeLabel.toLowerCase().endsWith("mode") ? modeLabel : `${modeLabel} mode`;
- const modeItems = useMemo(
- () =>
- modeSecondaryNavigationEntries(modeId).map((entry) =>
- entry.href
- ? {
- kind: "route" as const,
- id: entry.id,
- label: entry.label,
- shortLabel: entry.shortLabel,
- href: modeSecondaryNavigationHref({
- modeId,
- itemId: entry.id,
- href: entry.href,
- currentSearchParams: new URLSearchParams(searchParamString),
- }),
- current: entry.id === activeId,
- }
- : {
- kind: "action" as const,
- id: entry.id,
- label: entry.label,
- shortLabel: entry.shortLabel,
- onSelect: onSearch,
- current: entry.id === activeId,
- },
- ),
- [activeId, modeId, onSearch, searchParamString],
- );
// Therapy Compass owns both its workflow bindings and its dynamic detail
// sections inside TcProvider; rendering the shell registry as well would
@@ -309,13 +276,5 @@ export function PageSecondaryNavigation({
return ;
}
if (!isModeSecondaryNavigationRoute({ modeId, pathname, hasSubmittedSearch })) return null;
- return (
-
- );
+ return ;
}
diff --git a/src/components/specifiers/specifier-ui.tsx b/src/components/specifiers/specifier-ui.tsx
index ea26732919..1dfc832c5d 100644
--- a/src/components/specifiers/specifier-ui.tsx
+++ b/src/components/specifiers/specifier-ui.tsx
@@ -3,6 +3,7 @@ import type { ComponentType, CSSProperties, ReactNode } from "react";
import { ArrowRight, CheckCircle2, ChevronsUpDown, Info, Minus, ShieldAlert, Tags } from "lucide-react";
import { InformationPageBreadcrumbs, InformationPageShell } from "@/components/information-page-shell";
+import { RegistryModeNav } from "@/components/mode-nav/registry-mode-nav";
import { cn, eyebrowText } from "@/components/ui-primitives";
import type { SpecifierFamily, SpecifierRecord } from "@/lib/specifiers";
import { specifierFamilies } from "@/lib/specifiers";
@@ -20,41 +21,7 @@ export function SpecifierBreadcrumbs({ current }: { current?: string }) {
}
export function SpecifierSubnav({ active }: { active: "search" | "builder" | "compare" | "map" }) {
- const items = [
- { id: "search" as const, label: "Find", shortLabel: "Find", href: "/specifiers" },
- { id: "builder" as const, label: "Build wording", shortLabel: "Build", href: "/specifiers/builder" },
- { id: "compare" as const, label: "Compare", shortLabel: "Compare", href: "/specifiers/compare" },
- { id: "map" as const, label: "Map", shortLabel: "Map", href: "/specifiers/map" },
- ];
-
- return (
-
- );
+ return ;
}
const familyChipBase =
diff --git a/src/lib/mode-secondary-navigation.ts b/src/lib/mode-secondary-navigation.ts
index 13c04bc32f..2a1cb10242 100644
--- a/src/lib/mode-secondary-navigation.ts
+++ b/src/lib/mode-secondary-navigation.ts
@@ -91,25 +91,42 @@ export function isModeSecondaryNavigationRoute(params: {
hasSubmittedSearch: boolean;
}): boolean {
const { modeId, pathname, hasSubmittedSearch } = params;
+
+ // A one-destination mode has no meaningful secondary choice. Suppress it
+ // even after search submission rather than rendering a redundant one-item
+ // strip beneath the universal header.
+ if (modeSecondaryNavigationRegistry[modeId].length < 2) return false;
if (hasSubmittedSearch) return true;
- // /documents/search is the documents mode home (composer already visible); do
- // not add a lone Search focus control until a query has been submitted.
- if (modeId === "documents") return false;
if (modeId === "differentials") {
- return pathname === "/differentials/diagnoses" || pathname === "/differentials/presentations";
+ return (
+ pathname === "/differentials" ||
+ pathname === "/differentials/diagnoses" ||
+ pathname === "/differentials/presentations"
+ );
+ }
+ if (modeId === "dsm") {
+ return pathname === "/dsm" || pathname === "/dsm/search" || pathname === "/dsm/compare";
}
- if (modeId === "dsm") return pathname === "/dsm/search" || pathname === "/dsm/compare";
if (modeId === "specifiers") {
- return pathname === "/specifiers/builder" || pathname === "/specifiers/compare" || pathname === "/specifiers/map";
+ return (
+ pathname === "/specifiers" ||
+ pathname === "/specifiers/builder" ||
+ pathname === "/specifiers/compare" ||
+ pathname === "/specifiers/map"
+ );
}
if (modeId === "formulation") {
return (
- pathname === "/formulation/builder" || pathname === "/formulation/compare" || pathname === "/formulation/map"
+ pathname === "/formulation" ||
+ pathname === "/formulation/builder" ||
+ pathname === "/formulation/compare" ||
+ pathname === "/formulation/map"
);
}
- if (modeId === "factsheets") return pathname === "/factsheets/search";
- if (modeId === "therapy-compass") return pathname !== "/therapy-compass";
+ if (modeId === "therapy-compass") {
+ return pathname === "/therapy-compass" || pathname.startsWith("/therapy-compass/");
+ }
return false;
}
@@ -172,11 +189,12 @@ export function modeSecondaryNavigationHref(params: {
currentSearchParams.get("b"),
currentSearchParams.get("selected"),
]);
- if (itemId === "builder")
+ if (itemId === "builder") {
return navigationHrefWithParams(
href,
selections.map((value) => ["specifier", value] as const),
);
+ }
if (itemId === "compare") {
return navigationHrefWithParams(
href,
@@ -203,11 +221,12 @@ export function modeSecondaryNavigationHref(params: {
]);
const template = currentSearchParams.get("template");
const templateEntry: Array = template ? [["template", template]] : [];
- if (itemId === "builder")
+ if (itemId === "builder") {
return navigationHrefWithParams(href, [
...selections.map((value) => ["mechanism", value] as const),
...templateEntry,
]);
+ }
if (itemId === "compare") {
return navigationHrefWithParams(href, [
...selections.slice(0, 2).map((value, index) => [index === 0 ? "a" : "b", value] as const),
diff --git a/tests/mode-secondary-navigation.test.ts b/tests/mode-secondary-navigation.test.ts
index 2030994814..4328f936aa 100644
--- a/tests/mode-secondary-navigation.test.ts
+++ b/tests/mode-secondary-navigation.test.ts
@@ -40,6 +40,8 @@ const cleanLandingPath: Record = {
factsheets: "/factsheets",
};
+const multiPageModes = new Set(["differentials", "dsm", "specifiers", "formulation", "therapy-compass"]);
+
describe("mode secondary navigation registry", () => {
it("covers all 13 modes with the approved destinations and no Home item", () => {
expect(Object.keys(modeSecondaryNavigationRegistry).sort()).toEqual([...appModeIds].sort());
@@ -52,14 +54,23 @@ describe("mode secondary navigation registry", () => {
}
});
- it("suppresses clean landing pages but renders after a submitted mode search", () => {
+ it("shows clean homes only for multi-page modes and suppresses every one-destination mode", () => {
for (const modeId of appModeIds) {
+ const expected = multiPageModes.has(modeId);
expect(
- isModeSecondaryNavigationRoute({ modeId, pathname: cleanLandingPath[modeId], hasSubmittedSearch: false }),
- ).toBe(false);
+ isModeSecondaryNavigationRoute({
+ modeId,
+ pathname: cleanLandingPath[modeId],
+ hasSubmittedSearch: false,
+ }),
+ ).toBe(expected);
expect(
- isModeSecondaryNavigationRoute({ modeId, pathname: cleanLandingPath[modeId], hasSubmittedSearch: true }),
- ).toBe(true);
+ isModeSecondaryNavigationRoute({
+ modeId,
+ pathname: cleanLandingPath[modeId],
+ hasSubmittedSearch: true,
+ }),
+ ).toBe(expected);
}
});
@@ -87,7 +98,7 @@ describe("mode secondary navigation registry", () => {
).toBe(false);
});
- it("suppresses /documents/search until a query is submitted", () => {
+ it("never adds a redundant menu to the single-page Documents workflow", () => {
expect(
isModeSecondaryNavigationRoute({
modeId: "documents",
@@ -101,7 +112,7 @@ describe("mode secondary navigation registry", () => {
pathname: "/documents/search",
hasSubmittedSearch: true,
}),
- ).toBe(true);
+ ).toBe(false);
});
it("translates compatible workflow selection state into each destination URL", () => {
diff --git a/tests/page-secondary-navigation.dom.test.tsx b/tests/page-secondary-navigation.dom.test.tsx
index f46a73de78..acb3e9853c 100644
--- a/tests/page-secondary-navigation.dom.test.tsx
+++ b/tests/page-secondary-navigation.dom.test.tsx
@@ -2,13 +2,36 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { render, screen, waitFor } from "@testing-library/react";
+import type { AnchorHTMLAttributes, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
+vi.mock("next/navigation", () => ({
+ usePathname: () => "/",
+}));
+
+type MockLinkProps = AnchorHTMLAttributes & {
+ children: ReactNode;
+ href: string;
+};
+
+vi.mock("next/link", async () => {
+ const { forwardRef } = await import("react");
+ const MockLink = forwardRef(({ children, href, ...rest }, ref) => (
+
+ {children}
+
+ ));
+ MockLink.displayName = "MockNextLink";
+ return { __esModule: true, default: MockLink };
+});
+
+import { FormulationSubnav } from "@/components/formulation/formulation-ui";
import {
hasLocalInformationPageNavigation,
informationPageSectionDefinitions,
PageSecondaryNavigation,
} from "@/components/page-secondary-navigation";
+import { SpecifierSubnav } from "@/components/specifiers/specifier-ui";
describe("PageSecondaryNavigation", () => {
beforeEach(() => {
@@ -80,40 +103,65 @@ describe("PageSecondaryNavigation", () => {
}
});
- it("does not add a navigation row to a clean no-query landing page", () => {
- render(
- ,
- );
+ it("does not add a navigation row to a clean one-destination landing page", () => {
+ render();
expect(screen.queryByTestId("secondary-navigation")).toBeNull();
+ expect(screen.queryByTestId("mode-nav")).toBeNull();
});
- it("renders mode navigation after submission and on explicit workflow routes", () => {
- const { rerender } = render(
- ,
- );
- expect(screen.getByRole("button", { name: "Ask" })).toHaveAttribute("aria-current", "page");
+ it.each([
+ ["answer", "/"],
+ ["documents", "/documents/search"],
+ ["services", "/services"],
+ ["forms", "/forms"],
+ ["favourites", "/favourites"],
+ ["prescribing", "/medications"],
+ ["tools", "/tools"],
+ ["factsheets", "/factsheets/search"],
+ ] as const)("keeps the one-destination %s mode free of a redundant menu", (modeId, pathname) => {
+ render();
- rerender(
- ,
+ expect(screen.queryByTestId("secondary-navigation")).toBeNull();
+ expect(screen.queryByTestId("mode-nav")).toBeNull();
+ });
+
+ it.each([
+ ["differentials", "/differentials", "Differentials pages", "Search", "/differentials?focus=1"],
+ ["differentials", "/differentials/diagnoses", "Differentials pages", "Diagnoses", "/differentials/diagnoses"],
+ ["dsm", "/dsm", "DSM-5 Diagnosis pages", "Search", "/dsm?focus=1"],
+ ["dsm", "/dsm/compare", "DSM-5 Diagnosis pages", "Compare", "/dsm/compare"],
+ ] as const)(
+ "renders the %s workflow as the shared header-integrated mode nav",
+ (modeId, pathname, ariaLabel, activeLabel, activeHref) => {
+ render();
+
+ expect(screen.getByRole("navigation", { name: ariaLabel })).toHaveAttribute("data-testid", "mode-nav");
+ expect(screen.getByRole("link", { name: activeLabel })).toHaveAttribute("aria-current", "page");
+ expect(screen.getByRole("link", { name: activeLabel })).toHaveAttribute("href", activeHref);
+ expect(screen.queryByTestId("secondary-navigation")).toBeNull();
+ },
+ );
+
+ it.each([
+ ["specifiers", "Build", "/specifiers/builder"],
+ ["formulation", "Map", "/formulation/map"],
+ ] as const)("renders the real %s workflow owner through ModeNav", (modeId, activeLabel, activeHref) => {
+ render(modeId === "specifiers" ? : );
+
+ expect(screen.getByTestId("mode-nav")).toHaveAttribute(
+ "aria-label",
+ modeId === "specifiers" ? "Specifiers pages" : "Formulation pages",
);
- expect(screen.getByRole("link", { name: "Compare" })).toHaveAttribute("aria-current", "page");
- expect(screen.getByRole("link", { name: "Build" })).toHaveAttribute("href", "/specifiers/builder");
+ expect(screen.getByRole("link", { name: activeLabel })).toHaveAttribute("aria-current", "page");
+ expect(screen.getByRole("link", { name: activeLabel })).toHaveAttribute("href", activeHref);
+ expect(screen.queryByRole("navigation", { name: /tools/i })).toBeNull();
+ expect(screen.queryByTestId("secondary-navigation")).toBeNull();
});
it("replaces mode navigation with only the information sections present in the record", async () => {
render(
,
@@ -125,37 +173,34 @@ describe("PageSecondaryNavigation", () => {
expect(screen.getByRole("link", { name: "Criteria" })).toHaveAttribute("href", "#service-criteria");
expect(screen.queryByRole("link", { name: "Quick facts" })).toBeNull();
expect(screen.queryByRole("button", { name: "Search" })).toBeNull();
+ expect(screen.queryByTestId("mode-nav")).toBeNull();
});
it("leaves locally controlled information and Therapy workflow navigation to their page owners", async () => {
const { rerender } = render(
- ,
+ ,
);
await waitFor(() => expect(screen.queryByTestId("secondary-navigation")).toBeNull());
+ expect(screen.queryByTestId("mode-nav")).toBeNull();
rerender(
,
);
expect(screen.queryByTestId("secondary-navigation")).toBeNull();
+ expect(screen.queryByTestId("mode-nav")).toBeNull();
rerender(
,
);
expect(screen.queryByTestId("secondary-navigation")).toBeNull();
+ expect(screen.queryByTestId("mode-nav")).toBeNull();
});
});