feat(pmr): add interface materialization animations

- Login card fades out with scale animation (200ms)
- Patient banner slides down from top (200ms)
- Sidebar slides in from left (250ms, 50ms delay)
- Main content fades in (300ms, 150ms delay)
- Mobile nav slides up (200ms)
- All animations respect prefers-reduced-motion
- Mark Task 15 complete in IMPLEMENTATION_PLAN.md
This commit is contained in:
2026-02-11 03:22:29 +00:00
parent ef5bc9c3a6
commit 06ebef80c1
4 changed files with 112 additions and 25 deletions
+19 -9
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import { Shield } from 'lucide-react'
import { useAccessibility } from '../contexts/AccessibilityContext'
@@ -13,6 +14,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
const [isTypingUsername, setIsTypingUsername] = useState(true)
const [isTypingPassword, setIsTypingPassword] = useState(false)
const [buttonPressed, setButtonPressed] = useState(false)
const [isExiting, setIsExiting] = useState(false)
const { requestFocusAfterLogin } = useAccessibility()
const fullUsername = 'A.CHARLWOOD'
@@ -22,6 +24,14 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
const triggerComplete = useCallback(() => {
setIsExiting(true)
setTimeout(() => {
requestFocusAfterLogin()
onComplete()
}, prefersReducedMotion ? 0 : 200)
}, [onComplete, requestFocusAfterLogin, prefersReducedMotion])
const startLoginSequence = useCallback(() => {
if (prefersReducedMotion) {
setUsername(fullUsername)
@@ -29,9 +39,8 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
setTimeout(() => {
setButtonPressed(true)
setTimeout(() => {
requestFocusAfterLogin()
onComplete()
}, 200)
triggerComplete()
}, 100)
}, 300)
return
}
@@ -61,16 +70,15 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
setTimeout(() => {
setButtonPressed(true)
setTimeout(() => {
requestFocusAfterLogin()
onComplete()
}, 200)
triggerComplete()
}, 100)
}, 150)
}
}, 20)
}, 150)
}
}, 30)
}, [onComplete, prefersReducedMotion, requestFocusAfterLogin])
}, [triggerComplete, prefersReducedMotion])
useEffect(() => {
const cursorInterval = setInterval(() => {
@@ -87,13 +95,15 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: '#1E293B' }}
>
<div
<motion.div
className="bg-white rounded-xl shadow-lg p-8"
style={{
width: '320px',
borderRadius: '12px',
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.3)',
}}
animate={isExiting ? { scale: 1.03, opacity: 0 } : { scale: 1, opacity: 1 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<div className="flex flex-col items-center mb-6">
<div
@@ -196,7 +206,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
Secure clinical system login
</p>
</div>
</div>
</motion.div>
</div>
)
}
+66 -15
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useMemo } from 'react'
import { motion, Variants } from 'framer-motion'
import { Search, X, ArrowLeft } from 'lucide-react'
import type { ViewId } from '../types/pmr'
import { ClinicalSidebar } from './ClinicalSidebar'
@@ -39,6 +40,45 @@ function PMRContent({ children }: PMRInterfaceProps) {
const { requestFocusAfterViewChange, expandedItemId, setExpandedItem } = useAccessibility()
const { isMobile, isTablet } = useBreakpoint()
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
const bannerVariants = useMemo<Variants>(() => ({
hidden: prefersReducedMotion ? {} : { y: -80, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
}
}), [prefersReducedMotion])
const sidebarVariants = useMemo<Variants>(() => ({
hidden: prefersReducedMotion ? {} : { x: -220, opacity: 0 },
visible: {
x: 0,
opacity: 1,
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.25, ease: 'easeOut', delay: 0.05 }
}
}), [prefersReducedMotion])
const contentVariants = useMemo<Variants>(() => ({
hidden: prefersReducedMotion ? {} : { opacity: 0 },
visible: {
opacity: 1,
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.3, delay: 0.15 }
}
}), [prefersReducedMotion])
const mobileNavVariants = useMemo<Variants>(() => ({
hidden: prefersReducedMotion ? {} : { y: 56, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
}
}), [prefersReducedMotion])
useEffect(() => {
requestFocusAfterViewChange()
if (viewHeadingRef.current) {
@@ -107,17 +147,26 @@ function PMRContent({ children }: PMRInterfaceProps) {
}
return (
<div className="min-h-screen bg-pmr-content">
<PatientBanner isMobile={isMobile} isTablet={isTablet} />
<motion.div
className="min-h-screen bg-pmr-content"
initial="hidden"
animate="visible"
>
<motion.div variants={bannerVariants}>
<PatientBanner isMobile={isMobile} isTablet={isTablet} />
</motion.div>
<div className="flex">
{!isMobile && (
<ClinicalSidebar
activeView={activeView}
onViewChange={handleViewChange}
isTablet={isTablet}
/>
<motion.div variants={sidebarVariants}>
<ClinicalSidebar
activeView={activeView}
onViewChange={handleViewChange}
isTablet={isTablet}
/>
</motion.div>
)}
<main
<motion.main
variants={contentVariants}
role="main"
aria-label={`${activeView} view`}
className={`
@@ -154,16 +203,18 @@ function PMRContent({ children }: PMRInterfaceProps) {
)}
{children || renderView()}
</main>
</motion.main>
</div>
{isMobile && (
<MobileBottomNav
activeView={activeView}
onViewChange={handleViewChange}
/>
<motion.div variants={mobileNavVariants}>
<MobileBottomNav
activeView={activeView}
onViewChange={handleViewChange}
/>
</motion.div>
)}
</div>
</motion.div>
)
}