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:
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -147,32 +153,36 @@ 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 && (
|
||||||
@@ -181,7 +191,7 @@ function PMRContent({ children }: PMRInterfaceProps) {
|
|||||||
onChange={setMobileSearchQuery}
|
onChange={setMobileSearchQuery}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={viewHeadingRef}
|
ref={viewHeadingRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -190,7 +200,7 @@ function PMRContent({ children }: PMRInterfaceProps) {
|
|||||||
>
|
>
|
||||||
<h1 className="sr-only">{viewLabels[activeView]}</h1>
|
<h1 className="sr-only">{viewLabels[activeView]}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile && activeView !== 'summary' && (
|
{isMobile && activeView !== 'summary' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -201,15 +211,15 @@ function PMRContent({ children }: PMRInterfaceProps) {
|
|||||||
Back to Summary
|
Back to Summary
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{children || renderView()}
|
{children || renderView()}
|
||||||
</motion.main>
|
</motion.main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<motion.div variants={mobileNavVariants}>
|
<motion.div variants={mobileNavVariants}>
|
||||||
<MobileBottomNav
|
<MobileBottomNav
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
onViewChange={handleViewChange}
|
onViewChange={handleViewChange}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user