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:
2026-02-11 02:49:51 +00:00
parent fc3c0659b2
commit f7f7e0db8c
9 changed files with 258 additions and 40 deletions
+18 -15
View File
@@ -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>
)
}
+58 -11
View File
@@ -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'
+11 -3
View File
@@ -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(() => {
+42 -4
View File
@@ -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} />
}
+13 -2
View File
@@ -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">
+4 -4
View File
@@ -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>
+80
View File
@@ -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
}