Task 4b: Fix PatientBanner scroll condensation

Root cause: sentinel element with `absolute top-0` inside PatientBanner was
positioned at viewport top, always triggering the IntersectionObserver's
-100px rootMargin threshold — banner was permanently stuck in condensed state.

Fix: Restructured PMRInterface layout from document-scroll to flex container
with explicit scroll container (`overflow-y-auto` on main). Lifted scroll
condensation logic to PMRInterface, passing `isCondensed` prop down to
PatientBanner. Replaced IntersectionObserver with scroll event listener on
the main element for reliable scroll position detection.

Key changes:
- PMRInterface: flex h-screen overflow-hidden layout (sidebar + content column)
- PatientBanner: accepts isCondensed prop, removed sticky/sentinel/hook
- ClinicalSidebar: h-full instead of h-screen sticky (parent handles sizing)
- useScrollCondensation: scroll event on container element via callback ref

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 00:30:23 +00:00
parent b7471c5cf8
commit d16656b954
4 changed files with 91 additions and 104 deletions
+2 -2
View File
@@ -168,7 +168,7 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
<aside
role="navigation"
aria-label="Clinical record navigation"
className="hidden md:flex lg:hidden flex-col w-14 h-screen sticky top-0 bg-pmr-sidebar text-white"
className="hidden md:flex lg:hidden flex-col w-14 h-full bg-pmr-sidebar text-white"
>
<div className="p-2 border-b border-white/10">
<div className="font-inter font-medium text-[10px] text-white/50 text-center leading-tight">
@@ -230,7 +230,7 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
<aside
role="navigation"
aria-label="Clinical record navigation"
className="hidden lg:flex flex-col w-[220px] h-screen sticky top-0 bg-pmr-sidebar text-white"
className="hidden lg:flex flex-col w-[220px] h-full bg-pmr-sidebar text-white"
>
<div className="p-4 border-b border-white/10">
<div className="font-inter font-medium text-[13px] text-white/50 leading-tight">
+34 -24
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import { motion, Variants } from 'framer-motion'
import { Search, X, ArrowLeft } from 'lucide-react'
import type { ViewId } from '../types/pmr'
@@ -14,6 +14,7 @@ import { DocumentsView } from './views/DocumentsView'
import { ReferralsView } from './views/ReferralsView'
import { useAccessibility } from '../contexts/AccessibilityContext'
import { useBreakpoint } from '../hooks/useBreakpoint'
import { useScrollCondensation } from '../hooks/useScrollCondensation'
interface PMRInterfaceProps {
children?: React.ReactNode
@@ -37,8 +38,13 @@ function PMRContent({ children }: PMRInterfaceProps) {
const [mobileSearchQuery, setMobileSearchQuery] = useState('')
const viewHeadingRef = useRef<HTMLDivElement>(null)
const [scrollContainer, setScrollContainer] = useState<HTMLElement | null>(null)
const scrollContainerCallbackRef = useCallback((node: HTMLElement | null) => {
setScrollContainer(node)
}, [])
const { requestFocusAfterViewChange, expandedItemId, setExpandedItem } = useAccessibility()
const { isMobile, isTablet } = useBreakpoint()
const { isCondensed } = useScrollCondensation({ threshold: 100, scrollContainer })
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
@@ -147,32 +153,36 @@ function PMRContent({ children }: PMRInterfaceProps) {
}
return (
<motion.div
className="min-h-screen bg-pmr-content"
<motion.div
className="flex h-screen overflow-hidden bg-pmr-content"
initial="hidden"
animate="visible"
>
<motion.div variants={bannerVariants}>
<PatientBanner isMobile={isMobile} isTablet={isTablet} />
</motion.div>
<div className="flex">
{!isMobile && (
<motion.div variants={sidebarVariants}>
<ClinicalSidebar
activeView={activeView}
onViewChange={handleViewChange}
isTablet={isTablet}
/>
</motion.div>
)}
{/* Fixed sidebar */}
{!isMobile && (
<motion.div variants={sidebarVariants} className="flex-shrink-0">
<ClinicalSidebar
activeView={activeView}
onViewChange={handleViewChange}
isTablet={isTablet}
/>
</motion.div>
)}
{/* Main content column: banner (fixed) + scrollable content */}
<div className="flex-1 flex flex-col min-w-0">
<motion.div variants={bannerVariants} className="flex-shrink-0">
<PatientBanner isMobile={isMobile} isTablet={isTablet} isCondensed={isCondensed} />
</motion.div>
<motion.main
ref={scrollContainerCallbackRef}
variants={contentVariants}
role="main"
aria-label={`${activeView} view`}
className={`
flex-1 p-4 md:p-6
flex-1 overflow-y-auto p-4 md:p-6
${isMobile ? 'pb-20' : ''}
${isTablet ? 'min-h-[calc(100vh-48px)]' : 'min-h-[calc(100vh-80px)]'}
`}
>
{isMobile && (
@@ -181,7 +191,7 @@ function PMRContent({ children }: PMRInterfaceProps) {
onChange={setMobileSearchQuery}
/>
)}
<div
ref={viewHeadingRef}
tabIndex={-1}
@@ -190,7 +200,7 @@ function PMRContent({ children }: PMRInterfaceProps) {
>
<h1 className="sr-only">{viewLabels[activeView]}</h1>
</div>
{isMobile && activeView !== 'summary' && (
<button
type="button"
@@ -201,15 +211,15 @@ function PMRContent({ children }: PMRInterfaceProps) {
Back to Summary
</button>
)}
{children || renderView()}
</motion.main>
</div>
{isMobile && (
<motion.div variants={mobileNavVariants}>
<MobileBottomNav
activeView={activeView}
<MobileBottomNav
activeView={activeView}
onViewChange={handleViewChange}
/>
</motion.div>
+40 -58
View File
@@ -2,79 +2,61 @@ import { Download, Mail, Linkedin, MoreHorizontal } from 'lucide-react'
import { useState, useRef, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { patient } from '@/data/patient'
import { useScrollCondensation } from '@/hooks/useScrollCondensation'
interface PatientBannerProps {
isMobile?: boolean
isTablet?: boolean
isCondensed?: boolean
}
export function PatientBanner({ isMobile = false, isTablet = false }: PatientBannerProps) {
const { isCondensed, sentinelRef } = useScrollCondensation({ threshold: 100 })
export function PatientBanner({ isMobile = false, isTablet = false, isCondensed = false }: PatientBannerProps) {
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
if (isMobile) {
return (
<>
<div
ref={sentinelRef}
className="h-0 w-full absolute top-0"
aria-hidden="true"
/>
<MobileBanner />
</>
)
return <MobileBanner />
}
const shouldCondense = isTablet || isCondensed
return (
<>
<div
ref={sentinelRef}
className="h-0 w-full absolute top-0"
aria-hidden="true"
/>
<header
className={`
sticky top-0 z-40 w-full
bg-pmr-banner border-b border-slate-600
shadow-pmr-banner
transition-all duration-200 ease-out
${shouldCondense ? 'h-12' : 'h-20'}
`}
role="banner"
>
<AnimatePresence mode="wait" initial={false}>
{shouldCondense ? (
<motion.div
key="condensed"
initial={prefersReducedMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={prefersReducedMotion ? undefined : { opacity: 0 }}
transition={{ duration: 0.15 }}
className="h-full"
>
<CondensedBanner />
</motion.div>
) : (
<motion.div
key="full"
initial={prefersReducedMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={prefersReducedMotion ? undefined : { opacity: 0 }}
transition={{ duration: 0.15 }}
className="h-full"
>
<FullBanner />
</motion.div>
)}
</AnimatePresence>
</header>
</>
<header
className={`
w-full z-40
bg-pmr-banner border-b border-slate-600
shadow-pmr-banner
transition-all duration-200 ease-out
${shouldCondense ? 'h-12' : 'h-20'}
`}
role="banner"
>
<AnimatePresence mode="wait" initial={false}>
{shouldCondense ? (
<motion.div
key="condensed"
initial={prefersReducedMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={prefersReducedMotion ? undefined : { opacity: 0 }}
transition={{ duration: 0.15 }}
className="h-full"
>
<CondensedBanner />
</motion.div>
) : (
<motion.div
key="full"
initial={prefersReducedMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={prefersReducedMotion ? undefined : { opacity: 0 }}
transition={{ duration: 0.15 }}
className="h-full"
>
<FullBanner />
</motion.div>
)}
</AnimatePresence>
</header>
)
}
@@ -97,7 +79,7 @@ function MobileBanner() {
return (
<header
className="sticky top-0 z-40 w-full h-12 bg-pmr-banner border-b border-slate-600 shadow-pmr-banner"
className="w-full z-40 h-12 bg-pmr-banner border-b border-slate-600 shadow-pmr-banner"
role="banner"
>
<div className="h-full px-3 flex items-center justify-between gap-2">