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 <aside
role="navigation" role="navigation"
aria-label="Clinical record 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="p-2 border-b border-white/10">
<div className="font-inter font-medium text-[10px] text-white/50 text-center leading-tight"> <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 <aside
role="navigation" role="navigation"
aria-label="Clinical record 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="p-4 border-b border-white/10">
<div className="font-inter font-medium text-[13px] text-white/50 leading-tight"> <div className="font-inter font-medium text-[13px] text-white/50 leading-tight">
+27 -17
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 { motion, Variants } from 'framer-motion'
import { Search, X, ArrowLeft } from 'lucide-react' import { Search, X, ArrowLeft } from 'lucide-react'
import type { ViewId } from '../types/pmr' import type { ViewId } from '../types/pmr'
@@ -14,6 +14,7 @@ import { DocumentsView } from './views/DocumentsView'
import { ReferralsView } from './views/ReferralsView' import { ReferralsView } from './views/ReferralsView'
import { useAccessibility } from '../contexts/AccessibilityContext' import { useAccessibility } from '../contexts/AccessibilityContext'
import { useBreakpoint } from '../hooks/useBreakpoint' import { useBreakpoint } from '../hooks/useBreakpoint'
import { useScrollCondensation } from '../hooks/useScrollCondensation'
interface PMRInterfaceProps { interface PMRInterfaceProps {
children?: React.ReactNode children?: React.ReactNode
@@ -37,8 +38,13 @@ function PMRContent({ children }: PMRInterfaceProps) {
const [mobileSearchQuery, setMobileSearchQuery] = useState('') const [mobileSearchQuery, setMobileSearchQuery] = useState('')
const viewHeadingRef = useRef<HTMLDivElement>(null) 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 { requestFocusAfterViewChange, expandedItemId, setExpandedItem } = useAccessibility()
const { isMobile, isTablet } = useBreakpoint() const { isMobile, isTablet } = useBreakpoint()
const { isCondensed } = useScrollCondensation({ threshold: 100, scrollContainer })
const prefersReducedMotion = typeof window !== 'undefined' const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
@@ -148,31 +154,35 @@ function PMRContent({ children }: PMRInterfaceProps) {
return ( return (
<motion.div <motion.div
className="min-h-screen bg-pmr-content" className="flex h-screen overflow-hidden bg-pmr-content"
initial="hidden" initial="hidden"
animate="visible" animate="visible"
> >
<motion.div variants={bannerVariants}> {/* Fixed sidebar */}
<PatientBanner isMobile={isMobile} isTablet={isTablet} /> {!isMobile && (
</motion.div> <motion.div variants={sidebarVariants} className="flex-shrink-0">
<div className="flex"> <ClinicalSidebar
{!isMobile && ( activeView={activeView}
<motion.div variants={sidebarVariants}> onViewChange={handleViewChange}
<ClinicalSidebar isTablet={isTablet}
activeView={activeView} />
onViewChange={handleViewChange} </motion.div>
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 <motion.main
ref={scrollContainerCallbackRef}
variants={contentVariants} variants={contentVariants}
role="main" role="main"
aria-label={`${activeView} view`} aria-label={`${activeView} view`}
className={` className={`
flex-1 p-4 md:p-6 flex-1 overflow-y-auto p-4 md:p-6
${isMobile ? 'pb-20' : ''} ${isMobile ? 'pb-20' : ''}
${isTablet ? 'min-h-[calc(100vh-48px)]' : 'min-h-[calc(100vh-80px)]'}
`} `}
> >
{isMobile && ( {isMobile && (
+40 -58
View File
@@ -2,79 +2,61 @@ import { Download, Mail, Linkedin, MoreHorizontal } from 'lucide-react'
import { useState, useRef, useEffect, useCallback } from 'react' import { useState, useRef, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { patient } from '@/data/patient' import { patient } from '@/data/patient'
import { useScrollCondensation } from '@/hooks/useScrollCondensation'
interface PatientBannerProps { interface PatientBannerProps {
isMobile?: boolean isMobile?: boolean
isTablet?: boolean isTablet?: boolean
isCondensed?: boolean
} }
export function PatientBanner({ isMobile = false, isTablet = false }: PatientBannerProps) { export function PatientBanner({ isMobile = false, isTablet = false, isCondensed = false }: PatientBannerProps) {
const { isCondensed, sentinelRef } = useScrollCondensation({ threshold: 100 })
const prefersReducedMotion = typeof window !== 'undefined' const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false : false
if (isMobile) { if (isMobile) {
return ( return <MobileBanner />
<>
<div
ref={sentinelRef}
className="h-0 w-full absolute top-0"
aria-hidden="true"
/>
<MobileBanner />
</>
)
} }
const shouldCondense = isTablet || isCondensed const shouldCondense = isTablet || isCondensed
return ( return (
<> <header
<div className={`
ref={sentinelRef} w-full z-40
className="h-0 w-full absolute top-0" bg-pmr-banner border-b border-slate-600
aria-hidden="true" shadow-pmr-banner
/> transition-all duration-200 ease-out
<header ${shouldCondense ? 'h-12' : 'h-20'}
className={` `}
sticky top-0 z-40 w-full role="banner"
bg-pmr-banner border-b border-slate-600 >
shadow-pmr-banner <AnimatePresence mode="wait" initial={false}>
transition-all duration-200 ease-out {shouldCondense ? (
${shouldCondense ? 'h-12' : 'h-20'} <motion.div
`} key="condensed"
role="banner" initial={prefersReducedMotion ? false : { opacity: 0 }}
> animate={{ opacity: 1 }}
<AnimatePresence mode="wait" initial={false}> exit={prefersReducedMotion ? undefined : { opacity: 0 }}
{shouldCondense ? ( transition={{ duration: 0.15 }}
<motion.div className="h-full"
key="condensed" >
initial={prefersReducedMotion ? false : { opacity: 0 }} <CondensedBanner />
animate={{ opacity: 1 }} </motion.div>
exit={prefersReducedMotion ? undefined : { opacity: 0 }} ) : (
transition={{ duration: 0.15 }} <motion.div
className="h-full" key="full"
> initial={prefersReducedMotion ? false : { opacity: 0 }}
<CondensedBanner /> animate={{ opacity: 1 }}
</motion.div> exit={prefersReducedMotion ? undefined : { opacity: 0 }}
) : ( transition={{ duration: 0.15 }}
<motion.div className="h-full"
key="full" >
initial={prefersReducedMotion ? false : { opacity: 0 }} <FullBanner />
animate={{ opacity: 1 }} </motion.div>
exit={prefersReducedMotion ? undefined : { opacity: 0 }} )}
transition={{ duration: 0.15 }} </AnimatePresence>
className="h-full" </header>
>
<FullBanner />
</motion.div>
)}
</AnimatePresence>
</header>
</>
) )
} }
@@ -97,7 +79,7 @@ function MobileBanner() {
return ( return (
<header <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" role="banner"
> >
<div className="h-full px-3 flex items-center justify-between gap-2"> <div className="h-full px-3 flex items-center justify-between gap-2">
+15 -20
View File
@@ -1,35 +1,30 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useCallback } from 'react'
interface UseScrollCondensationOptions { interface UseScrollCondensationOptions {
threshold?: number threshold?: number
scrollContainer?: HTMLElement | null
} }
export function useScrollCondensation(options: UseScrollCondensationOptions = {}) { export function useScrollCondensation(options: UseScrollCondensationOptions = {}) {
const { threshold = 100 } = options const { threshold = 100, scrollContainer } = options
const [isCondensed, setIsCondensed] = useState(false) const [isCondensed, setIsCondensed] = useState(false)
const sentinelRef = useRef<HTMLDivElement>(null)
const handleScroll = useCallback(() => {
if (!scrollContainer) return
setIsCondensed(scrollContainer.scrollTop >= threshold)
}, [scrollContainer, threshold])
useEffect(() => { useEffect(() => {
const sentinel = sentinelRef.current if (!scrollContainer) return
if (!sentinel) return
const observer = new IntersectionObserver( scrollContainer.addEventListener('scroll', handleScroll, { passive: true })
(entries) => { // Check initial state
const [entry] = entries handleScroll()
setIsCondensed(!entry.isIntersecting)
},
{
rootMargin: `-${threshold}px 0px 0px 0px`,
threshold: 0,
}
)
observer.observe(sentinel)
return () => { return () => {
observer.disconnect() scrollContainer.removeEventListener('scroll', handleScroll)
} }
}, [threshold]) }, [scrollContainer, handleScroll])
return { isCondensed, sentinelRef } return { isCondensed }
} }