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(); }); });