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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user