- Notifications
You must be signed in to change notification settings - Fork 0
feat(navigation): standardise multi-page mode menus in the header#1645
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
901c3364dddce22e91af9e135e45d730792a8b4e107fdda1c50a1bdbc956fc3075f0f5fe56d9843a8318525be312a71d46af3a89d9a91b5c9958a5bc8c3c88File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, LucideIcon> = { | ||
| 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<ModeNavItem>((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 <ModeNav items={items} label={`${appModeDefinition(modeId).label} pages`} activeId={activeId} />; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<SecondaryNavigationItem[]>( | ||
| () => | ||
| 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 <AvailableInformationPageNavigation definitions={informationDefinitions} sticky={sticky} />; | ||
| } | ||
| if (!isModeSecondaryNavigationRoute({ modeId, pathname, hasSubmittedSearch })) return null; | ||
| return ( | ||
| <SecondaryNavigation | ||
| ariaLabel={modeAriaLabel} | ||
| items={modeItems} | ||
| activeId={activeId} | ||
| sticky={sticky} | ||
| stickyTop={stickyTop} | ||
| /> | ||
| ); | ||
| return <RegistryModeNav modeId={modeId} activeId={activeId} searchParamString={searchParamString} />; | ||
devin-ai-integration[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <nav | ||
| aria-label="Specifier tools" | ||
| className="polished-scroll flex max-w-full gap-1 overflow-x-auto rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-raised)] p-1 shadow-[var(--shadow-inset)]" | ||
| > | ||
| {items.map((item) => ( | ||
| <Link | ||
| key={item.id} | ||
| href={item.href} | ||
| aria-label={item.label} | ||
| aria-current={active === item.id ? "page" : undefined} | ||
| className={cn( | ||
| "inline-flex min-h-tap shrink-0 items-center justify-center rounded-md px-3 text-xs font-bold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:text-sm", | ||
| active === item.id | ||
| ? "bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)]" | ||
| : "text-[color:var(--text-muted)] hover:bg-[color:var(--surface)] hover:text-[color:var(--text)]", | ||
| )} | ||
| > | ||
| <span className="sm:hidden" aria-hidden> | ||
| {item.shortLabel} | ||
| </span> | ||
| <span className="hidden sm:inline" aria-hidden> | ||
| {item.label} | ||
| </span> | ||
| </Link> | ||
| ))} | ||
| </nav> | ||
| ); | ||
| return <RegistryModeNav modeId="specifiers" activeId={active} />; | ||
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Pass the current query string to This call uses the adapter default of Thread the encoded route query string through 🤖 Prompt for AI Agents | ||
| } | ||
Comment on lines
23
to
25
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Selections are dropped when moving between Specifiers or Formulation workflow pages The Specifiers and Formulation page menus are built without the page's current query string ( Why the state-carrying helper never runs for these two modes
The shared shell path does pass the bridged query string ( Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. | ||
| const familyChipBase = | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Pass the current query string to
RegistryModeNav.This call uses the adapter default of
"". A Formulation workflow URL withmechanism,a,b, ortemplateloses that state when a user selects another mode-navigation item.Thread the encoded route query string through
FormulationSubnavand its callers. Add a navigation test that starts with selected mechanisms and a template.🤖 Prompt for AI Agents