From e1ddf3d0a665c44b24ed68f1c20a6affe3406cdb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 07:10:35 +0000 Subject: [PATCH 01/16] Header standard: tabs return on scroll up everywhere, one Back in the top bar, Psychiatry uses the bottom search box - Psychiatry, Medicines and First Nations bands pin their tab row under the top bar, reusing the Work band mechanism, so scrolling up brings back the bar and the tabs (they used to stay scrolled away). - Record and inner pages draw Back in the top bar's round left button. A record with a tab rail lets its title row scroll away and pins the rail; a row with no section navigation scrolls away like a band title. - Psychiatry's home drops its own question box at the top and takes the shared Clinical box: the bottom box on phones, under the band from tablet up. A question still goes to Answer. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0175avu5Qu1RGiXgvKaEdU5k --- src/app/mode-band.css | 39 ++++++ .../global-search-shell.tsx | 23 +++- .../factsheets/factsheet-nav-header.tsx | 1 - .../in-page-nav/in-page-nav-header.tsx | 92 +++++++------- src/components/mode-band/mode-band.tsx | 13 +- src/components/psychiatry/psychiatry-home.tsx | 114 +++++------------- src/lib/app-modes.ts | 11 +- src/lib/search-route-ownership.ts | 12 ++ tests/factsheet-detail-header.dom.test.tsx | 8 +- tests/in-page-nav-header.dom.test.tsx | 25 ++-- tests/psychiatry-home.dom.test.tsx | 19 ++- 11 files changed, 194 insertions(+), 163 deletions(-) diff --git a/src/app/mode-band.css b/src/app/mode-band.css index 9bcef67ed2..e3070b9b6e 100644 --- a/src/app/mode-band.css +++ b/src/app/mode-band.css @@ -469,3 +469,42 @@ html:not([data-mode-band]) .universal-header[data-clinical-band]:not(:has(~ #phone-header-collapse-addon-slot .mode-nav-rail)) { box-shadow: inset 0 -1px 0 var(--clinical-band-line); } + +/* Pinned tabs on Clinical pages (header standard, 13 Oct 2026). Psychiatry, + Medicines and First Nations bands, and record pages with a tab rail, behave + as a Work band does: once the title has scrolled under the top bar, only the + tab row stays, pinned just below the bar, and it slides away and back with + the bar. use-sticky-work-tabs.ts measures the offsets; the rules mirror + `.work-band` in work-mode.css, which also sets `--work-chrome-h` for Work + pages. Every other page takes it from here. */ +html { + --work-chrome-h: calc(var(--safe-area-top, 0px) + var(--work-bar-h, var(--shell-header-h))); +} + +@media (max-width: 639px) { + html { + --work-chrome-h: var(--phone-overlay-chrome-h); + } +} + +:is(.mode-band, .inpage-nav-header)[data-pinned] { + position: sticky; + top: calc(var(--work-chrome-h) - var(--work-band-tabs-top, 0px)); + z-index: calc(var(--z-raised, 10) + 1); +} + +:is(.mode-band, .inpage-nav-header)[data-stuck] { + clip-path: inset(var(--work-band-tabs-top, 0px) 0 0 0); +} + +.mode-band[data-stuck] .mode-band__mark { + opacity: 0; +} + +:is(.mode-band, .inpage-nav-header)[data-pinned][data-bar-hidden]:not(:focus-within) { + top: calc(-1 * (var(--work-band-tabs-top, 0px) + var(--work-tabs-h, 0px))); +} + +:is(.mode-band, .inpage-nav-header)[data-stuck][data-bar-hidden]:not(:focus-within) { + pointer-events: none; +} diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 67ef177caa..627da85cd9 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -135,6 +135,7 @@ import { isAlwaysStandaloneShellPath, isDashboardOwnedModeHomePath, isDictionaryCataloguePath, + isDockedModeHomePath, isStandaloneModeHomePath, shouldRenderClinicalDashboard, shouldRenderDashboardSearch, @@ -513,7 +514,13 @@ function GlobalStandaloneSearchShellBody({ // Pathname-only: do not require searchMode === route. changeMode used to set // searchMode before router.push landed, which made isStandaloneModeHome false // for one frame (dock reserve + 200ms padding transition = choppy resize). - const isStandaloneModeHome = !hasSubmittedModeSearch && !rendersDashboardSearch && isStandaloneModeHomePath(pathname); + // A docked mode home (Psychiatry) takes the results-page search box: the + // compact bottom dock on phones and the page slot from tablet up. + const isStandaloneModeHome = + !hasSubmittedModeSearch && + !rendersDashboardSearch && + isStandaloneModeHomePath(pathname) && + !isDockedModeHomePath(pathname); const isDictionaryCatalogue = isDictionaryCataloguePath(pathname); const isDifferentialPresentationWorkflow = pathname.startsWith("/differentials/presentations/"); const shouldShowDesktopSidebar = !hideDesktopSidebar; @@ -545,7 +552,9 @@ function GlobalStandaloneSearchShellBody({ }; const effectiveSidebarCollapsed = isDifferentialPresentationWorkflow || workSideNav ? true : sidebarCollapsed; const effectiveSidebarWidth = shouldShowDesktopSidebar ? (effectiveSidebarCollapsed ? "5.25rem" : "20rem") : "0px"; - const isInfoPage = isInformationPage(pathname); + // Psychiatry stays an information page for its navigation, but its home now + // takes the search box (see `dockedModeHomePaths`). + const isInfoPage = isInformationPage(pathname) && !isDockedModeHomePath(pathname); // Information pages are read surfaces: the record has already been found, so a // composer there is chrome you cannot use without leaving the page. No mode is // an exception and no breakpoint is — the phone dock, the tablet/desktop page @@ -769,7 +778,9 @@ function GlobalStandaloneSearchShellBody({ function submitSearch(queryOverride?: string) { const trimmedQuery = (queryOverride ?? query).trim(); - navigateToMode(searchMode, { + // Psychiatry has no results page, so its box asks the question in Answer, + // as its old in-page box did. + navigateToMode(isDockedModeHomePath(pathname) && trimmedQuery ? "answer" : searchMode, { query: trimmedQuery || undefined, run: Boolean(trimmedQuery), // Running a search must not carry focus into the result dock — that pin @@ -1203,7 +1214,11 @@ function GlobalStandaloneSearchShellBody({ {/* The dictionary catalogue renders this same slot id itself, under its own mode nav and above the Filter band, so the shell must not emit a second element carrying it — one id, one portal host. */} - {shouldShowSearchComposer && !isStandaloneModeHome && !isDictionaryCatalogue ? ( + {/* Psychiatry's home renders it too, under its band. */} + {shouldShowSearchComposer && + !isStandaloneModeHome && + !isDictionaryCatalogue && + !isDockedModeHomePath(pathname) ? ( (null); + const railRef = useRef(null); + useStickyWorkTabs(pinRail ? pinnedHeader : null, railRef); + // A row with no section navigation (a title and its actions) is a heading, + // not navigation: it scrolls away with the page, as a Work band's title does, + // so scrolling up brings back the top bar alone. + const scrollsAway = !rail && documentSections.length === 0; + const HeaderHost = pinRail || scrollsAway ? InFlowHeader : PhoneHeaderCollapsePortal; return ( <> - + {back ? : null} +
- {back ? ( - - - {showBackLabel ? {back.label} : null} - - ) : null} {rail && !titleHidden ? ( // With a rail, every section is already named in the row below, so // from `sm` — where the whole rail fits — the disclosure would open @@ -502,24 +497,26 @@ export function InPageNavHeader(props: InPageNavHeaderProps) {
) : null} {rail ? ( - onSelectSection?.(id)} - onOpenSectionSheet={openSectionSheet} - sectionSheetOpen={sectionSheetOpen} - label={rail.label} - density={rail.density} - countedLabels={rail.countedLabels} - modeIdentity={rail.modeIdentity} - flush={!rowHasContent} - testIdPrefix={testIdPrefix} - /> +
+ onSelectSection?.(id)} + onOpenSectionSheet={openSectionSheet} + sectionSheetOpen={sectionSheetOpen} + label={rail.label} + density={rail.density} + countedLabels={rail.countedLabels} + modeIdentity={rail.modeIdentity} + flush={!rowHasContent} + testIdPrefix={testIdPrefix} + /> +
) : documentSections.length > 0 ? ( ) : null} -
+ {/* Both sheets are siblings of the portal, never children of it: a sheet inside the collapse row would be carried away with the header when the shared chrome scroll-hides. */} @@ -608,3 +605,8 @@ export function InPageNavHeader(props: InPageNavHeaderProps) { ); } + +/** Keeps a pinned-rail header in the page, where its rail can stick under the top bar. */ +function InFlowHeader({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/src/components/mode-band/mode-band.tsx b/src/components/mode-band/mode-band.tsx index 0e5cf58711..1bb843f8c6 100644 --- a/src/components/mode-band/mode-band.tsx +++ b/src/components/mode-band/mode-band.tsx @@ -11,6 +11,7 @@ import { useState, useLayoutEffect, useMemo, + useRef, useSyncExternalStore, Suspense, type CSSProperties, @@ -43,6 +44,7 @@ import { } from "@/lib/work-frame/areas"; import { ExampleDataBanner } from "@/components/example-data/example-data-banner"; import { WorkFrameHeader } from "@/components/work-frame/work-frame-header"; +import { useStickyWorkTabs } from "@/components/work-frame/use-sticky-work-tabs"; import { useWorkFrameAction } from "@/components/work-frame/work-frame-store"; import { WorkHelpHost } from "@/components/work-help/work-help-host"; import { openWorkHelp } from "@/components/work-help/work-help-store"; @@ -576,6 +578,10 @@ function ModeBandHeader({ }, [tabs, modeId, teachingRoles, rosterHasTeam, openShiftsPoster]); usePublishBandSurface(band, modeId); + // The tab row pins under the top bar and slides away and back with it, the + // same mechanism as a Work band (header standard, 13 Oct 2026). + const bandTabsRef = useRef(null); + useStickyWorkTabs(band, bandTabsRef); const tabRow = useCurrentTabInView(activeId, tabEntries.map((entry) => entry.id).join(" ")); const Icon = appModeIcons[modeId]; @@ -639,7 +645,12 @@ function ModeBandHeader({ ) : null} {tabEntries.length > 1 ? ( -