From 172dcb466b1002abc996f5cb3a76ede8b94f2136 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:58:11 +0800 Subject: [PATCH] fix: keep desktop search in page flow --- docs/search-chrome-behaviour.md | 42 +++--- src/components/ClinicalDashboard.tsx | 16 ++- .../global-search-shell.tsx | 19 ++- .../master-search-header.tsx | 136 ++++++++++-------- .../services/services-navigator-page.tsx | 2 +- src/lib/mode-home-composer.ts | 1 + tests/header-scroll-hide-contract.test.ts | 35 +++-- tests/ui-chrome-scroll.spec.ts | 73 +++++++--- tests/ui-tools.spec.ts | 13 +- 9 files changed, 218 insertions(+), 119 deletions(-) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index e94367bb..241c3d17 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -4,14 +4,14 @@ This repo uses one shared search experience across the global shell, dashboard r ## Page ownership model -| Page state | Composer placement | Reserve owner | -| ----------------------------------- | ------------------------------------------------------------------------ | --------------------------------------------- | -| Answer home / standalone mode homes | In-flow hero composer on phones and larger breakpoints | Page content; no fixed phone dock reserve | -| Submitted/search-result views | Compact bottom dock on phones; header/inline placement on larger screens | Shell/dashboard `--mobile-composer-reserve` | -| Answer result view | Overlaid glass header plus answer composer dock | Dashboard `#main-content` top/bottom reserves | -| Document detail/source routes | `DocumentViewer` floating composer | `DocumentViewer` content padding | -| Calculators (`/calculators`) | Page-owned composer (desktop top + phone bottom dock) | Calculators page pad; shell reserve stays `0` | -| Info/detail pages with no composer | No fixed composer | Idle shell padding only | +| Page state | Composer placement | Reserve owner | +| ----------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| Answer home / standalone mode homes | In-flow hero composer on phones and larger breakpoints | Page content; no fixed phone dock reserve | +| Submitted/search-result views | Compact bottom dock on phones; pinned below the header on tablets; in normal page flow on desktop | Shell/dashboard `--mobile-composer-reserve` on phones; page content on desktop | +| Answer result view | Overlaid glass header plus answer composer dock | Dashboard `#main-content` top/bottom reserves | +| Document detail/source routes | `DocumentViewer` floating composer | `DocumentViewer` content padding | +| Calculators (`/calculators`) | Page-owned composer (desktop top + phone bottom dock) | Calculators page pad; shell reserve stays `0` | +| Info/detail pages with no composer | No fixed composer | Idle shell padding only | ## Invariants @@ -20,7 +20,7 @@ This repo uses one shared search experience across the global shell, dashboard r 3. A visible fixed phone dock may include `var(--safe-area-bottom)` so the pill clears the home indicator. 4. A hidden phone dock must release the content-facing reserve to `0rem`; do not use `env(safe-area-inset-bottom)` or `var(--safe-area-bottom)` for hidden content padding. 5. Edge-to-edge phone dock mode is `left: 0; right: 0; bottom: 0; width: 100%`; inset the pill with padding, not with a non-zero bottom offset. -6. Header and footer chrome that share the same scroll signal should hide/reveal symmetrically for the surfaces that actually hide: when the top bar is hidden, underlying content must be visible to the viewport edge. The OS top safe-area band (`chrome-safe-area-top` / `var(--safe-area-top)`) is never released — scrolled text must not paint under the status-bar icons. Top-bar hide/reveal is cross-breakpoint; the search field stays on tablet/desktop; the bottom search dock is phone-only. Hidden bottom dock reserve stays `0rem` (invariant 4). Read "Scroll hide/reveal" below before changing either. +6. Header and footer chrome that share the same scroll signal should hide/reveal symmetrically for the surfaces that actually hide: when the top bar is hidden, underlying content must be visible to the viewport edge. The OS top safe-area band (`chrome-safe-area-top` / `var(--safe-area-top)`) is never released — scrolled text must not paint under the status-bar icons. Top-bar hide/reveal is cross-breakpoint; the search field stays pinned on tablets, while desktop search belongs to page flow and scrolls away naturally; the bottom search dock is phone-only. Hidden bottom dock reserve stays `0rem` (invariant 4). Read "Scroll hide/reveal" below before changing either. 7. Do not add page-local dock-sized `pb-[calc(...safe-area...)]` under a shell-owned dock. Put clearance in the shared reserve or the page-owned composer, never both. 8. `GlobalSearchShell` uses an inner `mobile-composer-reserve-pad` so phone padding contributes to scroll height; do not move phone shell clearance back to scrollport padding without a browser proof. 9. Keep collapse-budget policy geometry-aware: an in-flow collapsing header needs enough remaining runway to absorb header + dock clearance, while a fixed overlay that only releases bottom reserve may hide when its post-collapse range retains the top reveal band plus deliberate hide intent _and_ the current offset already fits that post-collapse range (no material near-bottom clamp). Do not use synthetic page padding to make the stricter gate pass. @@ -28,31 +28,31 @@ This repo uses one shared search experience across the global shell, dashboard r ## Scroll hide/reveal -The universal **top bar** (mode, new chat, menu) hides on a deliberate scroll down and returns on a deliberate scroll up at **every** breakpoint. The search field does **not** hide with it on tablet or desktop — only the phone bottom search dock still scroll-hides, and that stays phone-only. Both the top bar and the phone dock read one `useScrollHideReporter` per host, so they can never disagree about direction. +The universal **top bar** (mode, new chat, menu) is the only sticky desktop chrome: it hides on a deliberate scroll down and returns on a deliberate scroll up at **every** breakpoint. Tablet search stays pinned below it. Desktop search is mounted at the top of normal page content, so it scrolls away with that content and is independent of the header's hide state. Only the phone bottom search dock scroll-hides, and that stays phone-only. The top bar and phone dock read one `useScrollHideReporter` per host, so they can never disagree about direction. Choose the hide mechanism from where the host's scrollport lives, because that decides what hiding costs the reader: -| Host | Scrollport | `hideOnScroll` | Mechanism | -| --------------------------------- | --------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------- | -| `ClinicalDashboard` (answer view) | `
` at every width | `strategy: "overlay", allBreakpoints` | Absolute glass top bar translates off; `
` keeps its top reserve; search stays | -| `ClinicalDashboard` (other modes) | `
` at every width | `strategy: "collapse", wide: "collapse"` | Top-bar 1fr -> 0fr grid row; released strip goes to content; search stays as a sibling | -| `GlobalSearchShell` | `#main-content` on phones, the document above | `strategy: "collapse", wide: "sticky"` | Sticky stack of [top bar \| search]; only the top-bar row collapses so search rises to the top | +| Host | Scrollport | `hideOnScroll` | Mechanism | +| --------------------------------- | --------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `ClinicalDashboard` (answer view) | `
` at every width | `strategy: "overlay", allBreakpoints` | Absolute glass top bar translates off; `
` keeps its top reserve; search stays | +| `ClinicalDashboard` (other modes) | `
` at every width | `strategy: "collapse", wide: "collapse"` | Top-bar row collapses; tablet search stays sticky; desktop search portals into `
` page flow | +| `GlobalSearchShell` | `#main-content` on phones, the document above | `strategy: "collapse", wide: "sticky"` | Tablet pins [top bar \| search]; desktop portals search into `#main-content`, leaving a sticky auto-hiding top bar | Rules that keep this working: -- **Hide the top bar, not the search field, above phones.** The collapse wrapper (`data-testid="universal-header-collapse"`) wraps `header#search` plus optional page chrome that must match top-bar hide/reveal (via `headerCollapseAddonSlotId`). Putting the inline search composer inside that wrapper is what made tablet/desktop search disappear with the mode bar — keep composers outside the collapse row. +- **Hide the top bar, not the search field.** The collapse wrapper (`data-testid="universal-header-collapse"`) wraps `header#search` plus optional page chrome that must match top-bar hide/reveal (via `headerCollapseAddonSlotId`). Keep composers outside the collapse row: tablet search stays pinned independently, and desktop search scrolls with page content rather than being translated by the header. - **Page chrome that must match the top bar portals into the collapse host.** Do not pin tool secondary nav with `position: sticky` inside `#main-content` on phones — after the header collapses that sticky row becomes a second stuck header. Therapy's section strip portals into `#therapy-header-collapse-addon-slot` below `max-sm` and stays in-flow sticky above that. - **Feed the reporter from the element that actually scrolls.** `GlobalSearchShell`'s `#main-content` is the scrollport only on phones, so above that it also runs `useDocumentScrollHideReporter`. That hook self-gates: the phone shell is `fixed inset-0`, so the document cannot scroll and never fires. -- **Sticky belongs on the outer [top bar \| search] stack, not on `header#search`.** The top bar sits inside header-height boxes, which leaves a sticky rule on it zero travel. For the same reason the stack's ancestor in `GlobalSearchShell` is `display: contents` above the phone breakpoint rather than a block, and collapse returns a fragment (safe-area spacer + stack) rather than a single root box. -- **Collapse only the top-bar row inside a sticky stack.** Translating the whole stack would take the search field off-screen; collapsing just the top bar lets search stay pinned at the viewport top below the always-on safe-area spacer. +- **Tablet stickiness belongs on the outer [top bar \| search] stack, not on `header#search`.** The top bar sits inside header-height boxes, which leaves a sticky rule on it zero travel. For the same reason the stack's ancestor in `GlobalSearchShell` is `display: contents` above the phone breakpoint rather than a block, and collapse returns a fragment (safe-area spacer + stack) rather than a single root box. At desktop widths the search portal leaves that same outer stack holding only the top bar. +- **Collapse only the top-bar row inside a sticky stack.** On tablets, translating the whole stack would take the search field off-screen; collapsing just the top bar lets search stay pinned at the viewport top below the always-on safe-area spacer. On desktop, the page-flow search is outside the stack entirely. - **Keep `chrome-safe-area-top` outside the hide mechanism.** Collapse hosts render an always-on `h-[var(--safe-area-top)]` spacer sibling; the 0fr grid only moves the top-bar controls. Sticky chrome pins at `top: var(--safe-area-top)` so the spacer stays put while the bar collapses. -- **Do not double-sticky the search inside an outer sticky stack.** When `wide: "sticky"` owns an outer stack, the composer stays `relative` in that stack. A second sticky search with its own `top` overlays page controls (and blocks clicks) once the top bar collapses. -- **Pinned search can cover wide side rails.** The sticky stack stays centred and can geometrically overlap `xl` decision rails (Services). Prefer `scroll-margin-top` on rail controls and centre scroll-into-view in tests; do not put the composer back to `fixed`/`sticky` inside the stack to “win” that fight. +- **Do not double-sticky tablet search inside an outer sticky stack.** When `wide: "sticky"` owns the tablet stack, the composer stays `relative` in that stack. A second sticky search with its own `top` overlays page controls (and blocks clicks) once the top bar collapses. +- **Desktop search is page-owned.** `desktop-page-search-composer-slot` is rendered at the top of normal shell/dashboard content and accepts the shared composer only at `min-width: 1024px`. The mode-home hero slot takes precedence. Never give the desktop page composer, its slot, or an ancestor `fixed`/`sticky` positioning. - **Collapse-everywhere hosts still drop their own sticky search offset while the top bar is hidden.** Dashboard result composers that clear a visible top bar with `top: 4.75rem + safe-area` must switch to `top: 0` when collapse hide is active — otherwise a dead band the height of the mode bar remains above the search field. - **Rebase the reporter on geometry switches.** Pass `resetKey` when the host changes the scrollport under it (`ClinicalDashboard` passes `searchMode`, which swaps `
`'s header reserve); otherwise the carried-over offset spends the first post-switch scroll on a spurious hide or reveal. - **Do not carry composer focus into submitted result views.** Focus pins both chrome edges for keyboard safety. `GlobalSearchShell` must not pass `focus: true` with `run: true`, must gate `queryInputAutoFocus` on `!hasSubmittedModeSearch`, and must blur the dock input when the result canvas scrolls so hide-on-scroll can reclaim the header and the bottom dock (including its white safe-area rail). -Coverage: `tests/header-scroll-hide-contract.test.ts` (wiring), `tests/use-hide-on-scroll.test.ts` (decision logic), `tests/ui-chrome-scroll.spec.ts` (tablet/desktop top-bar hide/reveal with search still visible), `tests/ui-phone-scroll.spec.ts` (phone scroll geometry), `tests/ui-therapy-nav-scroll.spec.ts` (Therapy section nav hide/reveal with the top bar). +Coverage: `tests/header-scroll-hide-contract.test.ts` (wiring), `tests/use-hide-on-scroll.test.ts` (decision logic), `tests/ui-chrome-scroll.spec.ts` (tablet pinned-search behaviour and desktop page-flow search plus top-bar hide/reveal), `tests/ui-phone-scroll.spec.ts` (phone scroll geometry), `tests/ui-therapy-nav-scroll.spec.ts` (Therapy section nav hide/reveal with the top bar). ## Change checklist diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 1c7dfb65..3cac7cf5 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -201,7 +201,11 @@ import type { DocumentLabel, } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; -import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; +import { + desktopPageComposerSlotId, + differentialsMobileCompareAddonSlotId, + modeHomeDesktopComposerSlotId, +} from "@/lib/mode-home-composer"; import { toolCatalogRecords } from "@/lib/tools-catalog"; import { createQuoteFollowUp, type AnswerViewMode, shouldPollForUpdates } from "@/lib/ward-output"; import { @@ -3057,6 +3061,8 @@ export function ClinicalDashboard({ !modeSearchSubmitted && !(query.trim() && documentMatches.length > 0))))); const desktopHomeComposerSlotId = showDesktopHomeComposer ? modeHomeDesktopComposerSlotId : undefined; + const desktopResultComposerSlotId = + !desktopHomeComposerSlotId && searchMode !== "answer" ? desktopPageComposerSlotId : undefined; const heroComposerBreakpoint = showDesktopHomeComposer || showAnswerHome ? "all" : "sm-up"; const heroOwnsPhoneComposer = Boolean(desktopHomeComposerSlotId) && heroComposerBreakpoint === "all"; const hasMobileBottomSearch = searchMode !== "answer" && !heroOwnsPhoneComposer; @@ -3355,6 +3361,7 @@ export function ClinicalDashboard({ differentialsCompareAddonActive ? differentialsMobileCompareAddonSlotId : undefined } desktopHomeComposerSlotId={desktopHomeComposerSlotId} + desktopPageComposerSlotId={desktopResultComposerSlotId} // Mode homes keep the composer in the centred hero slot at every // breakpoint; documents, therapy, and other homes share the phone/tablet structure. heroComposerBreakpoint={heroComposerBreakpoint} @@ -3468,6 +3475,13 @@ export function ClinicalDashboard({ : "pb-8 sm:pb-10 lg:pb-12", )} > + {desktopResultComposerSlotId ? ( +
+ ) : null} {actionNotice && ( setActionNotice(null)} animated> {actionNotice.message} diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index c18a2739..59d59b8d 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -58,6 +58,7 @@ import { isLocalNoAuthMode, resolveClientDemoMode } from "@/lib/client-env"; import { documentsSearchHref } from "@/lib/document-flow-routes"; import { isInformationPage } from "@/lib/information-pages"; import { + desktopPageComposerSlotId, differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId, therapyHeaderCollapseAddonSlotId, @@ -744,6 +745,9 @@ function GlobalStandaloneSearchShellClient({ desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"} searchComposerVisible={shouldShowSearchComposer} desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} + desktopPageComposerSlotId={ + shouldShowSearchComposer && !isStandaloneModeHome ? desktopPageComposerSlotId : undefined + } // Standalone mode homes keep the in-flow hero pill at every width, // phones included — the composer sits in the middle of the hero and // scrolls with the content, matching the answer home rather than @@ -751,9 +755,9 @@ function GlobalStandaloneSearchShellClient({ heroComposerBreakpoint="all" // Phones: #main-content owns vertical scroll, so hide-on-scroll // collapses the top bar to hand space back to content. - // Tablet/desktop: the document scrolls, so an outer sticky stack - // pins [top bar | search] and only the top-bar row collapses — - // translating the whole stack would take the search field with it. + // Tablet: the document scrolls, so an outer sticky stack pins + // [top bar | search]. Desktop portals search into normal page flow, + // leaving this stack to own only the auto-hiding top bar. hideOnScroll={{ strategy: "collapse", wide: "sticky", scrollHidden: chromeScrollHide.hidden }} onBottomComposerHiddenChange={setBottomComposerHidden} queryInputAutoFocus={requestedFocus && !hasSubmittedModeSearch} @@ -783,7 +787,7 @@ function GlobalStandaloneSearchShellClient({ ? "sm:pb-[calc(9rem+var(--safe-area-bottom))]" : useCompactBottomSearch ? "sm:pb-8" - : "sm:pb-[calc(9rem+var(--safe-area-bottom))]", + : "sm:pb-[calc(9rem+var(--safe-area-bottom))] lg:pb-8", )} > {/* @@ -793,6 +797,13 @@ function GlobalStandaloneSearchShellClient({ its height, so end-of-page content clears the visible dock. */}
+ {shouldShowSearchComposer && !isStandaloneModeHome ? ( +
+ ) : null} } > diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 09d18a4e..e0c5a015 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -78,7 +78,7 @@ const composerPrivacyWarningId = "answer-composer-privacy-warning"; const phoneSearchLayoutMediaQuery = "(max-width: 639px)"; const scopeSheetMediaQuery = "(max-width: 1023px)"; -const desktopHomeComposerMediaQuery = "(min-width: 1024px)"; +const desktopPageComposerMediaQuery = "(min-width: 1024px)"; const modeHomeComposerMediaQuery = "(min-width: 0px)"; const modeHomeComposerSmUpMediaQuery = "(min-width: 640px)"; @@ -183,6 +183,7 @@ export function MasterSearchHeader({ desktopSearchPlacement = "default", searchComposerVisible = true, desktopHomeComposerSlotId, + desktopPageComposerSlotId, heroComposerBreakpoint = "all", mobileBottomSearchAddonSlotId, headerCollapseAddonSlotId, @@ -244,6 +245,9 @@ export function MasterSearchHeader({ * middle of the hero instead of docking to the bottom edge. Which widths the * hero owns is controlled by `heroComposerBreakpoint`. */ desktopHomeComposerSlotId?: string; + /** Normal-flow page slot used by submitted/search views on desktop only. + * Below lg the existing phone dock/tablet sticky composer remains the owner. */ + desktopPageComposerSlotId?: string; /** Widths where the mode-home hero slot hosts the composer. "all" keeps the * hero pill on phones too (the answer home); "sm-up" reserves the hero for * sm+ widths and hands phones the compact bottom dock instead. */ @@ -281,11 +285,11 @@ export function MasterSearchHeader({ * whose scrollport is an internal element at every width (ClinicalDashboard's * `
`), where the released strip goes straight to the content. * - * "sticky" pins an outer stack (top bar + search) to the viewport top above - * phones and still collapses only the top-bar row inside that stack — for - * hosts that hand scrolling back to the document (GlobalSearchShell). The - * search composer must stay outside the collapsing row so tablet/desktop - * keep a usable search field. + * "sticky" pins an outer stack to the viewport top above phones and still + * collapses only the top-bar row inside that stack — for hosts that hand + * scrolling back to the document (GlobalSearchShell). Tablet search stays + * in that stack; desktop result search may portal into page flow, leaving + * the same stack to own only the top bar. */ wide?: "collapse" | "sticky"; /** Parent-owned hidden state for hosts that report scroll via React `onScroll`. */ @@ -356,13 +360,13 @@ export function MasterSearchHeader({ // unavailable on the server). Sync from matchMedia after mount; Mode open // paths also refresh from the live query so the first tap still picks Sheet. const [usesPhoneSearchLayout, setUsesPhoneSearchLayout] = useState(false); - const [desktopHomeComposerActive, setDesktopHomeComposerActive] = useState(false); - // True once the hero portal is conclusively unavailable — the media query + const [desktopComposerPortalActive, setDesktopComposerPortalActive] = useState(false); + // True once the requested page/hero portal is conclusively unavailable — the media query // does not match, or the slot never appeared after the retry budget. While a // slot id is present and this is false the inline composer stays suppressed // (no flash while the portal mounts); once it flips true the inline composer // renders, so the search can never vanish from the page at any width. - const [desktopHomeComposerFallback, setDesktopHomeComposerFallback] = useState(false); + const [desktopComposerPortalFallback, setDesktopComposerPortalFallback] = useState(false); // Phone-only hide-on-scroll: never hide while a header-owned surface is open // or while focus sits inside the header chrome (keyboard users must not tab // into invisible controls). @@ -426,7 +430,7 @@ export function MasterSearchHeader({ // Stable, header-owned element the composer is portaled into; we move it in and // out of the page-owned slot rather than portaling into the slot directly. - const [desktopHomeComposerHost, setDesktopHomeComposerHost] = useState(null); + const [desktopComposerPortalHost, setDesktopComposerPortalHost] = useState(null); const modeMenuRef = useRef(null); const modeButtonRef = useRef(null); const modeOptionRefs = useRef>([]); @@ -981,16 +985,22 @@ export function MasterSearchHeader({ }, [onScopeOpenChange, scopeOpen, scopeSheetOpen]); useEffect(() => { - if (!desktopHomeComposerSlotId) { - // No hero slot at this route: reset the portal state. Deferred to a + // A mode-home hero always takes precedence over the generic desktop page + // slot. Hosts normally pass only one, but this keeps ownership deterministic + // during route transitions where both slots can briefly coexist. + const composerSlotId = desktopHomeComposerSlotId ?? desktopPageComposerSlotId; + const composerSlotKind = desktopHomeComposerSlotId ? "home" : "page"; + + if (!composerSlotId) { + // No page-owned slot at this route: reset the portal state. Deferred to a // microtask (not requestAnimationFrame) so it stays off the synchronous // effect body without being frame-gated — headless CI can starve rAF. let cancelled = false; queueMicrotask(() => { if (cancelled) return; - setDesktopHomeComposerActive(false); - setDesktopHomeComposerFallback(false); - setDesktopHomeComposerHost(null); + setDesktopComposerPortalActive(false); + setDesktopComposerPortalFallback(false); + setDesktopComposerPortalHost(null); }); return () => { cancelled = true; @@ -999,24 +1009,22 @@ export function MasterSearchHeader({ // The composer is portaled into a stable host we own, and we move that host // in and out of the page-owned slot as it appears/disappears. The slot is - // rendered by mode-home pages and unmounts on navigation; portaling directly + // rendered by page content and unmounts on navigation; portaling directly // into it made React reconcile the portal against a container that another // part of the tree had already removed, throwing a null-parentNode error. // Because the host is stable, React's portal container never disappears. - // heroComposerBreakpoint decides which widths use the slot: "all" keeps the - // composer in the middle of the hero on phones too (the answer home), while - // "sm-up" leaves phones to the compact bottom dock and reserves the hero - // for sm+ widths. + // Hero slots retain their existing all/sm-up ownership. Generic page slots + // are desktop-only so phone docks and tablet sticky composers do not change. const host = document.createElement("div"); // Layout-transparent so the composer lays out as a direct child of the slot. host.style.display = "contents"; const mediaQuery = window.matchMedia( - desktopHomeComposerSlotId + composerSlotKind === "home" ? heroComposerBreakpoint === "sm-up" ? modeHomeComposerSmUpMediaQuery : modeHomeComposerMediaQuery - : desktopHomeComposerMediaQuery, + : desktopPageComposerMediaQuery, ); let retryTimeout: number | null = null; @@ -1032,16 +1040,16 @@ export function MasterSearchHeader({ window.clearTimeout(retryTimeout); retryTimeout = null; } - const slot = mediaQuery.matches ? document.getElementById(desktopHomeComposerSlotId) : null; + const slot = mediaQuery.matches ? document.getElementById(composerSlotId) : null; if (slot) { portalRetryCount = 0; if (host.parentNode !== slot) slot.appendChild(host); - setDesktopHomeComposerHost(host); - setDesktopHomeComposerActive(true); - setDesktopHomeComposerFallback(false); + setDesktopComposerPortalHost(host); + setDesktopComposerPortalActive(true); + setDesktopComposerPortalFallback(false); } else { host.parentNode?.removeChild(host); - setDesktopHomeComposerActive(false); + setDesktopComposerPortalActive(false); if (mediaQuery.matches && portalRetryCount < 24) { portalRetryCount += 1; retryTimeout = window.setTimeout(syncTarget, Math.min(40 * portalRetryCount, 400)); @@ -1050,7 +1058,7 @@ export function MasterSearchHeader({ // appeared within the retry budget: release the inline fallback so // the search cannot vanish. The MutationObserver keeps watching, so // a slot that shows up later still reclaims the portal. - setDesktopHomeComposerFallback(true); + setDesktopComposerPortalFallback(true); } } }; @@ -1064,11 +1072,11 @@ export function MasterSearchHeader({ observer.disconnect(); mediaQuery.removeEventListener("change", syncTarget); host.parentNode?.removeChild(host); - setDesktopHomeComposerActive(false); - setDesktopHomeComposerFallback(false); - setDesktopHomeComposerHost(null); + setDesktopComposerPortalActive(false); + setDesktopComposerPortalFallback(false); + setDesktopComposerPortalHost(null); }; - }, [desktopHomeComposerSlotId, heroComposerBreakpoint]); + }, [desktopHomeComposerSlotId, desktopPageComposerSlotId, heroComposerBreakpoint]); const dismissModeMenu = useCallback(() => setModeMenuOpen(false), []); function dismissScope(reason: "outside" | "escape") { @@ -1394,8 +1402,9 @@ export function MasterSearchHeader({ const collapsesAtEveryWidth = wideCollapseBehaviour === "collapse"; const sticksAbovePhones = wideCollapseBehaviour === "sticky"; - function renderSearchComposer(placement: "default" | "desktop-home") { + function renderSearchComposer(placement: "default" | "desktop-home" | "desktop-page") { const isDesktopHomeComposer = placement === "desktop-home"; + const isDesktopPageComposer = placement === "desktop-page"; const usesAnswerFooterStyle = isAnswerFooterComposer && !isDesktopHomeComposer; const usesMobileBottomStyle = isMobileBottomComposer && !isDesktopHomeComposer; const usesBottomComposerPlacement = usesAnswerFooterStyle || (usesMobileBottomStyle && usesPhoneSearchLayout); @@ -1447,6 +1456,7 @@ export function MasterSearchHeader({ return (
{ touchStartY.current = e.touches[0].clientY; }} @@ -1478,27 +1488,29 @@ export function MasterSearchHeader({ className={cn( isDesktopHomeComposer ? "universal-home-search-edge mx-auto w-full" - : usesAnswerFooterStyle - ? "floating-composer-edge dashboard-composer-edge fixed bottom-0 z-40 mx-auto max-w-3xl lg:max-w-4xl" - : usesMobileBottomStyle - ? cn( - usesPhoneFooterDock - ? "document-mobile-search-edge universal-top-search-edge fixed z-40 w-full" - : cn( - "document-mobile-search-edge universal-top-search-edge z-40 mx-auto max-w-3xl sm:z-20 sm:w-full sm:px-4 sm:py-3 lg:max-w-4xl", - // Sticky-stack hosts already pin [top bar | search]. Never - // leave a `fixed`/`sticky` composer in that stack — it - // overlays page controls (Services decision rail). - stickySearchOwnedByOuterStack - ? "relative" - : cn("fixed", isHeroDesktopComposer ? "sm:hidden" : stickySearchPositionClass), - stickySearchOwnedByOuterStack && isHeroDesktopComposer && "sm:hidden", - ), - ) - : cn( - "universal-top-search-edge mx-auto box-border w-full px-3 py-3 sm:px-4", - stickySearchOwnedByOuterStack ? "relative z-20" : cn("sticky z-20", stickySearchTopClass), - ), + : isDesktopPageComposer + ? "document-mobile-search-edge universal-top-search-edge relative z-20 mx-auto w-full max-w-3xl px-4 py-3 lg:max-w-4xl" + : usesAnswerFooterStyle + ? "floating-composer-edge dashboard-composer-edge fixed bottom-0 z-40 mx-auto max-w-3xl lg:max-w-4xl" + : usesMobileBottomStyle + ? cn( + usesPhoneFooterDock + ? "document-mobile-search-edge universal-top-search-edge fixed z-40 w-full" + : cn( + "document-mobile-search-edge universal-top-search-edge z-40 mx-auto max-w-3xl sm:z-20 sm:w-full sm:px-4 sm:py-3 lg:max-w-4xl", + // Sticky-stack hosts already pin [top bar | search]. Never + // leave a `fixed`/`sticky` composer in that stack — it + // overlays page controls (Services decision rail). + stickySearchOwnedByOuterStack + ? "relative" + : cn("fixed", isHeroDesktopComposer ? "sm:hidden" : stickySearchPositionClass), + stickySearchOwnedByOuterStack && isHeroDesktopComposer && "sm:hidden", + ), + ) + : cn( + "universal-top-search-edge mx-auto box-border w-full px-3 py-3 sm:px-4", + stickySearchOwnedByOuterStack ? "relative z-20" : cn("sticky z-20", stickySearchTopClass), + ), usesBottomComposerPlacement && "answer-footer-search-edge", usesPhoneFooterDock && "answer-footer-search-dock", usesCompactMobileBottomStyle && "document-mobile-search-compact", @@ -1997,14 +2009,16 @@ export function MasterSearchHeader({ ); + const composerPortalRequested = desktopHomeComposerSlotId ?? desktopPageComposerSlotId; + const portalPlacement = desktopHomeComposerSlotId ? "desktop-home" : "desktop-page"; const searchComposer = searchComposerVisible ? ( <> - {(desktopHomeComposerActive && desktopHomeComposerHost) || - (desktopHomeComposerSlotId && !desktopHomeComposerFallback) + {(desktopComposerPortalActive && desktopComposerPortalHost) || + (composerPortalRequested && !desktopComposerPortalFallback) ? null : renderSearchComposer("default")} - {desktopHomeComposerActive && desktopHomeComposerHost - ? createPortal(renderSearchComposer("desktop-home"), desktopHomeComposerHost) + {desktopComposerPortalActive && desktopComposerPortalHost + ? createPortal(renderSearchComposer(portalPlacement), desktopComposerPortalHost) : null} ) : null; @@ -2018,9 +2032,9 @@ export function MasterSearchHeader({ // tree via `position: fixed`; hero composers portal out. // // Above the phone breakpoint a `wide: "sticky"` host scrolls the document, - // so an outer sticky stack pins [top bar | search] below the always-on - // safe-area spacer. Only the top-bar row collapses — translating the whole - // stack would take the search field with it. Return a fragment (never a + // so an outer sticky stack pins its chrome below the always-on safe-area + // spacer. Tablet search remains in that stack; desktop result search portals + // into page flow, so the stack contains only the top bar there. Return a fragment (never a // wrapping block): GlobalSearchShell uses `sm:contents` on the chrome // parent so sticky can travel against the viewport. const collapsingTopBar = ( diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index a44d92c0..ab42846c 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -398,7 +398,7 @@ function RightRail({ ))}