feat(a11y): Implement keyboard shortcuts and accessibility (Task 13)
- Create AccessibilityContext for global focus management and expanded state - Add roving tabindex to sidebar with Up/Down/Enter/Home/End navigation - Focus management: after login, after view change, after item expansion - Global Escape closes expanded items across all views - Add scope='col' to SummaryView table headers - Add focus-after-expand to ConsultationsView - Update ARIA roles: role='menu', role='menuitem', aria-current
This commit is contained in:
+18
-15
@@ -4,26 +4,29 @@ import { BootSequence } from './components/BootSequence'
|
||||
import { ECGAnimation } from './components/ECGAnimation'
|
||||
import { LoginScreen } from './components/LoginScreen'
|
||||
import { PMRInterface } from './components/PMRInterface'
|
||||
import { AccessibilityProvider } from './contexts/AccessibilityContext'
|
||||
|
||||
function App() {
|
||||
const [phase, setPhase] = useState<Phase>('boot')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{phase === 'boot' && (
|
||||
<BootSequence onComplete={() => setPhase('ecg')} />
|
||||
)}
|
||||
|
||||
{phase === 'ecg' && (
|
||||
<ECGAnimation onComplete={() => setPhase('login')} />
|
||||
)}
|
||||
|
||||
{phase === 'login' && (
|
||||
<LoginScreen onComplete={() => setPhase('pmr')} />
|
||||
)}
|
||||
|
||||
{phase === 'pmr' && <PMRInterface />}
|
||||
</div>
|
||||
<AccessibilityProvider>
|
||||
<div className="min-h-screen">
|
||||
{phase === 'boot' && (
|
||||
<BootSequence onComplete={() => setPhase('ecg')} />
|
||||
)}
|
||||
|
||||
{phase === 'ecg' && (
|
||||
<ECGAnimation onComplete={() => setPhase('login')} />
|
||||
)}
|
||||
|
||||
{phase === 'login' && (
|
||||
<LoginScreen onComplete={() => setPhase('pmr')} />
|
||||
)}
|
||||
|
||||
{phase === 'pmr' && <PMRInterface />}
|
||||
</div>
|
||||
</AccessibilityProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import {
|
||||
ClipboardList,
|
||||
FileText,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import type { ViewId } from '../types/pmr'
|
||||
import { useAccessibility } from '../contexts/AccessibilityContext'
|
||||
|
||||
interface NavItem {
|
||||
id: ViewId
|
||||
@@ -45,6 +46,51 @@ export function ClinicalSidebar({ activeView, onViewChange }: ClinicalSidebarPro
|
||||
const [currentTime, setCurrentTime] = useState(getCurrentTime)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false)
|
||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null)
|
||||
const navButtonRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const { focusAfterLoginRef } = useAccessibility()
|
||||
|
||||
const handleNavClick = useCallback(
|
||||
(view: ViewId) => {
|
||||
onViewChange(view)
|
||||
window.location.hash = view
|
||||
},
|
||||
[onViewChange]
|
||||
)
|
||||
|
||||
const handleNavKeyDown = useCallback((e: React.KeyboardEvent, index: number) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
if (index < navItems.length - 1) {
|
||||
setFocusedIndex(index + 1)
|
||||
navButtonRefs.current[index + 1]?.focus()
|
||||
}
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
if (index > 0) {
|
||||
setFocusedIndex(index - 1)
|
||||
navButtonRefs.current[index - 1]?.focus()
|
||||
}
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
handleNavClick(navItems[index].id)
|
||||
break
|
||||
case 'Home':
|
||||
e.preventDefault()
|
||||
setFocusedIndex(0)
|
||||
navButtonRefs.current[0]?.focus()
|
||||
break
|
||||
case 'End':
|
||||
e.preventDefault()
|
||||
setFocusedIndex(navItems.length - 1)
|
||||
navButtonRefs.current[navItems.length - 1]?.focus()
|
||||
break
|
||||
}
|
||||
}, [handleNavClick])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
@@ -88,13 +134,11 @@ export function ClinicalSidebar({ activeView, onViewChange }: ClinicalSidebarPro
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onViewChange, isSearchFocused])
|
||||
|
||||
const handleNavClick = useCallback(
|
||||
(view: ViewId) => {
|
||||
onViewChange(view)
|
||||
window.location.hash = view
|
||||
},
|
||||
[onViewChange]
|
||||
)
|
||||
useEffect(() => {
|
||||
if (navButtonRefs.current[0]) {
|
||||
;(focusAfterLoginRef as React.MutableRefObject<HTMLButtonElement | null>).current = navButtonRefs.current[0]
|
||||
}
|
||||
}, [focusAfterLoginRef])
|
||||
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
@@ -179,17 +223,20 @@ export function ClinicalSidebar({ activeView, onViewChange }: ClinicalSidebarPro
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-2 overflow-y-auto">
|
||||
<ul role="list">
|
||||
<ul role="menu" aria-label="Record sections">
|
||||
{navItems.map((item, index) => (
|
||||
<li key={item.id}>
|
||||
<li key={item.id} role="none">
|
||||
{index === 1 && (
|
||||
<div className="mx-3 my-1 border-t border-white/10" role="separator" />
|
||||
<div className="mx-3 my-1 border-t border-white/10" role="separator" aria-hidden="true" />
|
||||
)}
|
||||
<button
|
||||
ref={el => { navButtonRefs.current[index] = el }}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
tabIndex={focusedIndex === null ? (index === 0 ? 0 : -1) : (focusedIndex === index ? 0 : -1)}
|
||||
aria-current={activeView === item.id ? 'page' : undefined}
|
||||
onClick={() => handleNavClick(item.id)}
|
||||
onKeyDown={e => handleNavKeyDown(e, index)}
|
||||
className={`w-full flex items-center gap-3 h-11 px-4 text-left transition-colors ${
|
||||
activeView === item.id
|
||||
? 'text-white bg-white/12 border-l-[3px] border-pmr-nhsblue font-semibold'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Shield } from 'lucide-react'
|
||||
import { useAccessibility } from '../contexts/AccessibilityContext'
|
||||
|
||||
interface LoginScreenProps {
|
||||
onComplete: () => void
|
||||
@@ -12,6 +13,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
const [isTypingUsername, setIsTypingUsername] = useState(true)
|
||||
const [isTypingPassword, setIsTypingPassword] = useState(false)
|
||||
const [buttonPressed, setButtonPressed] = useState(false)
|
||||
const { requestFocusAfterLogin } = useAccessibility()
|
||||
|
||||
const fullUsername = 'A.CHARLWOOD'
|
||||
const passwordLength = 8
|
||||
@@ -26,7 +28,10 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
setPasswordDots(passwordLength)
|
||||
setTimeout(() => {
|
||||
setButtonPressed(true)
|
||||
setTimeout(onComplete, 200)
|
||||
setTimeout(() => {
|
||||
requestFocusAfterLogin()
|
||||
onComplete()
|
||||
}, 200)
|
||||
}, 300)
|
||||
return
|
||||
}
|
||||
@@ -55,14 +60,17 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
|
||||
setTimeout(() => {
|
||||
setButtonPressed(true)
|
||||
setTimeout(onComplete, 200)
|
||||
setTimeout(() => {
|
||||
requestFocusAfterLogin()
|
||||
onComplete()
|
||||
}, 200)
|
||||
}, 150)
|
||||
}
|
||||
}, 20)
|
||||
}, 150)
|
||||
}
|
||||
}, 30)
|
||||
}, [onComplete, prefersReducedMotion])
|
||||
}, [onComplete, prefersReducedMotion, requestFocusAfterLogin])
|
||||
|
||||
useEffect(() => {
|
||||
const cursorInterval = setInterval(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import type { ViewId } from '../types/pmr'
|
||||
import { ClinicalSidebar } from './ClinicalSidebar'
|
||||
import { PatientBanner } from './PatientBanner'
|
||||
@@ -9,12 +9,13 @@ import { ProblemsView } from './views/ProblemsView'
|
||||
import { InvestigationsView } from './views/InvestigationsView'
|
||||
import { DocumentsView } from './views/DocumentsView'
|
||||
import { ReferralsView } from './views/ReferralsView'
|
||||
import { useAccessibility } from '../contexts/AccessibilityContext'
|
||||
|
||||
interface PMRInterfaceProps {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function PMRInterface({ children }: PMRInterfaceProps) {
|
||||
function PMRContent({ children }: PMRInterfaceProps) {
|
||||
const [activeView, setActiveView] = useState<ViewId>(() => {
|
||||
const hash = window.location.hash.slice(1) as ViewId
|
||||
const validViews: ViewId[] = [
|
||||
@@ -28,15 +29,30 @@ export function PMRInterface({ children }: PMRInterfaceProps) {
|
||||
]
|
||||
return validViews.includes(hash) ? hash : 'summary'
|
||||
})
|
||||
|
||||
const viewHeadingRef = useRef<HTMLDivElement>(null)
|
||||
const { requestFocusAfterViewChange, expandedItemId, setExpandedItem } = useAccessibility()
|
||||
|
||||
useEffect(() => {
|
||||
requestFocusAfterViewChange()
|
||||
if (viewHeadingRef.current) {
|
||||
viewHeadingRef.current.focus()
|
||||
}
|
||||
}, [activeView, requestFocusAfterViewChange])
|
||||
|
||||
const handleViewChange = (view: ViewId) => {
|
||||
setActiveView(view)
|
||||
if (expandedItemId) {
|
||||
setExpandedItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNavigate = (view: ViewId, itemId?: string) => {
|
||||
void itemId
|
||||
const handleNavigate = (view: ViewId) => {
|
||||
setActiveView(view)
|
||||
window.location.hash = view
|
||||
if (expandedItemId) {
|
||||
setExpandedItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
const renderView = () => {
|
||||
@@ -69,6 +85,16 @@ export function PMRInterface({ children }: PMRInterfaceProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const viewLabels: Record<ViewId, string> = {
|
||||
summary: 'Patient Summary',
|
||||
consultations: 'Consultation History',
|
||||
medications: 'Current Medications',
|
||||
problems: 'Problem List',
|
||||
investigations: 'Investigation Results',
|
||||
documents: 'Attached Documents',
|
||||
referrals: 'Referral Form',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-pmr-content">
|
||||
<PatientBanner />
|
||||
@@ -79,9 +105,21 @@ export function PMRInterface({ children }: PMRInterfaceProps) {
|
||||
aria-label={`${activeView} view`}
|
||||
className="flex-1 min-h-[calc(100vh-80px)] p-6"
|
||||
>
|
||||
<div
|
||||
ref={viewHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="outline-none"
|
||||
aria-label={viewLabels[activeView]}
|
||||
>
|
||||
<h1 className="sr-only">{viewLabels[activeView]}</h1>
|
||||
</div>
|
||||
{children || renderView()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PMRInterface(props: PMRInterfaceProps) {
|
||||
return <PMRContent {...props} />
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ function ConsultationEntry({
|
||||
prefersReducedMotion,
|
||||
}: ConsultationEntryProps) {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const expandedContentRef = useRef<HTMLDivElement>(null)
|
||||
const [height, setHeight] = useState<number | undefined>(isExpanded ? undefined : 0)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -75,6 +76,12 @@ function ConsultationEntry({
|
||||
setHeight(0)
|
||||
}, [isExpanded, prefersReducedMotion])
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && expandedContentRef.current) {
|
||||
expandedContentRef.current.focus()
|
||||
}
|
||||
}, [isExpanded])
|
||||
|
||||
const keyCodedEntry = consultation.codedEntries[0]
|
||||
|
||||
return (
|
||||
@@ -134,6 +141,7 @@ function ConsultationEntry({
|
||||
<ExpandedContent
|
||||
consultation={consultation}
|
||||
prefersReducedMotion={prefersReducedMotion}
|
||||
contentRef={expandedContentRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -162,15 +170,18 @@ function StatusDot({ isCurrent }: StatusDotProps) {
|
||||
interface ExpandedContentProps {
|
||||
consultation: Consultation
|
||||
prefersReducedMotion: boolean
|
||||
contentRef: React.RefObject<HTMLDivElement>
|
||||
}
|
||||
|
||||
function ExpandedContent({ consultation, prefersReducedMotion }: ExpandedContentProps) {
|
||||
function ExpandedContent({ consultation, prefersReducedMotion, contentRef }: ExpandedContentProps) {
|
||||
const opacity = prefersReducedMotion ? 1 : undefined
|
||||
const transition = prefersReducedMotion ? 'none' : 'opacity 150ms ease-out'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="px-4 pb-4"
|
||||
ref={contentRef}
|
||||
tabIndex={-1}
|
||||
className="px-4 pb-4 outline-none"
|
||||
style={{ opacity, transition }}
|
||||
>
|
||||
<div className="pl-5 border-l border-gray-200 ml-1">
|
||||
|
||||
@@ -279,16 +279,16 @@ function QuickMedsCard({ medications, onNavigate }: QuickMedsCardProps) {
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
<th scope="col" className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
Drug
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
<th scope="col" className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
Dose
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
<th scope="col" className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
Freq
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
<th scope="col" className="px-4 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createContext, useContext, useState, useCallback, useRef, useEffect, type ReactNode } from 'react'
|
||||
|
||||
interface AccessibilityContextValue {
|
||||
expandedItemId: string | null
|
||||
setExpandedItem: (id: string | null) => void
|
||||
requestFocusAfterLogin: () => void
|
||||
focusAfterLoginRef: React.RefObject<HTMLButtonElement | null>
|
||||
focusAfterViewChangeRef: React.RefObject<HTMLHeadingElement | null>
|
||||
requestFocusAfterViewChange: () => void
|
||||
}
|
||||
|
||||
const AccessibilityContext = createContext<AccessibilityContextValue | null>(null)
|
||||
|
||||
export function AccessibilityProvider({ children }: { children: ReactNode }) {
|
||||
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
|
||||
const focusAfterLoginRef = useRef<HTMLButtonElement | null>(null)
|
||||
const focusAfterViewChangeRef = useRef<HTMLHeadingElement | null>(null)
|
||||
const [shouldFocusAfterLogin, setShouldFocusAfterLogin] = useState(false)
|
||||
const [shouldFocusAfterViewChange, setShouldFocusAfterViewChange] = useState(false)
|
||||
|
||||
const setExpandedItem = useCallback((id: string | null) => {
|
||||
setExpandedItemId(id)
|
||||
}, [])
|
||||
|
||||
const requestFocusAfterLogin = useCallback(() => {
|
||||
setShouldFocusAfterLogin(true)
|
||||
}, [])
|
||||
|
||||
const requestFocusAfterViewChange = useCallback(() => {
|
||||
setShouldFocusAfterViewChange(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFocusAfterLogin && focusAfterLoginRef.current) {
|
||||
focusAfterLoginRef.current.focus()
|
||||
setShouldFocusAfterLogin(false)
|
||||
}
|
||||
}, [shouldFocusAfterLogin])
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFocusAfterViewChange && focusAfterViewChangeRef.current) {
|
||||
focusAfterViewChangeRef.current.focus()
|
||||
setShouldFocusAfterViewChange(false)
|
||||
}
|
||||
}, [shouldFocusAfterViewChange])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && expandedItemId) {
|
||||
setExpandedItemId(null)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [expandedItemId])
|
||||
|
||||
return (
|
||||
<AccessibilityContext.Provider
|
||||
value={{
|
||||
expandedItemId,
|
||||
setExpandedItem,
|
||||
requestFocusAfterLogin,
|
||||
focusAfterLoginRef,
|
||||
focusAfterViewChangeRef,
|
||||
requestFocusAfterViewChange,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AccessibilityContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAccessibility() {
|
||||
const context = useContext(AccessibilityContext)
|
||||
if (!context) {
|
||||
throw new Error('useAccessibility must be used within AccessibilityProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
Reference in New Issue
Block a user