Task 15: Accessibility audit complete

- Sidebar: Replace <aside role="navigation"> with <nav> to avoid conflicting roles
- Sidebar search: Add combobox role, aria-expanded, aria-controls, aria-autocomplete
- Search results: Add listbox/option roles, group labels for screen reader navigation
- PMRInterface: Remove redundant role="main", fix aria-label to use CV-friendly labels
- Mobile search: Add aria-label and type="search" for proper semantics
- Breadcrumb: Add aria-current="page" to current item, aria-hidden on separators
- Clinical alert: Add aria-label="Acknowledge clinical alert" on button per spec
- Patient banner: Change focus:ring to focus-visible:ring on action buttons
- Patient banner: Add role="img" to StatusDot for aria-label accessibility
- Login screen: Change role="status" to role="dialog" with aria-modal
- Login screen: Add loginButtonRef with auto-focus when typing completes
- Login screen: Add focus-visible ring style to Log In button
- Medications tabs: Add id="tab-{id}" to tab buttons, fix aria-labelledby on panels
- Consultations: Wrap entries in <article> per semantic HTML spec
- Problems: Change TrafficLight dot from role="img" to aria-hidden (text label handles it)
- App: Add sr-only live region announcing "Patient Record for Charlwood, Andrew" on PMR entry
- Skip button: Add focus-visible ring for keyboard users

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 01:42:05 +00:00
parent b3ebff26bf
commit c3316b9c45
10 changed files with 59 additions and 29 deletions
+8 -1
View File
@@ -18,7 +18,7 @@ function SkipButton({ onSkip }: { onSkip: () => void }) {
<button <button
onClick={onSkip} onClick={onSkip}
aria-label="Skip intro animation" aria-label="Skip intro animation"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[100] px-4 py-1.5 text-xs tracking-widest uppercase font-mono border rounded transition-all duration-700 cursor-pointer select-none" className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[100] px-4 py-1.5 text-xs tracking-widest uppercase font-mono border rounded transition-all duration-700 cursor-pointer select-none focus:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
style={{ style={{
color: '#555', color: '#555',
borderColor: '#333', borderColor: '#333',
@@ -51,6 +51,13 @@ function App() {
return ( return (
<AccessibilityProvider> <AccessibilityProvider>
<div className="min-h-screen bg-black"> <div className="min-h-screen bg-black">
{/* Screen reader announcement for PMR phase */}
{phase === 'pmr' && (
<div className="sr-only" role="status" aria-live="polite" aria-atomic="true">
Patient Record for Charlwood, Andrew. Summary view.
</div>
)}
{phase === 'boot' && ( {phase === 'boot' && (
<BootSequence <BootSequence
onComplete={() => setPhase('ecg')} onComplete={() => setPhase('ecg')}
+4 -4
View File
@@ -56,7 +56,7 @@ export function Breadcrumb({
</button> </button>
</li> </li>
<li> <li aria-hidden="true">
<ChevronRight size={14} className="text-gray-300" /> <ChevronRight size={14} className="text-gray-300" />
</li> </li>
@@ -71,7 +71,7 @@ export function Breadcrumb({
{viewLabels[currentView]} {viewLabels[currentView]}
</button> </button>
) : ( ) : (
<span className="text-[13px] font-ui font-normal text-gray-600"> <span className="text-[13px] font-ui font-normal text-gray-600" aria-current="page">
{viewLabels[currentView]} {viewLabels[currentView]}
</span> </span>
)} )}
@@ -80,11 +80,11 @@ export function Breadcrumb({
{/* Expanded item (if any) */} {/* Expanded item (if any) */}
{expandedItem && ( {expandedItem && (
<> <>
<li> <li aria-hidden="true">
<ChevronRight size={14} className="text-gray-300" /> <ChevronRight size={14} className="text-gray-300" />
</li> </li>
<li> <li>
<span className="text-[13px] font-ui font-normal text-gray-600"> <span className="text-[13px] font-ui font-normal text-gray-600" aria-current="page">
{expandedItem.name} {expandedItem.name}
</span> </span>
</li> </li>
+24 -14
View File
@@ -195,8 +195,7 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
// ── Tablet: 56px icon-only sidebar ── // ── Tablet: 56px icon-only sidebar ──
if (isTablet) { if (isTablet) {
return ( return (
<aside <nav
role="navigation"
aria-label="Clinical record navigation" aria-label="Clinical record navigation"
className="hidden md:flex lg:hidden flex-col w-14 h-full bg-pmr-sidebar border-r border-[#334155] text-white" className="hidden md:flex lg:hidden flex-col w-14 h-full bg-pmr-sidebar border-r border-[#334155] text-white"
> >
@@ -208,7 +207,7 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 py-2 overflow-y-auto"> <div className="flex-1 py-2 overflow-y-auto">
<ul role="menu" aria-label="Record sections"> <ul role="menu" aria-label="Record sections">
{navItems.map((item, index) => ( {navItems.map((item, index) => (
<li key={item.id} role="none" className="relative"> <li key={item.id} role="none" className="relative">
@@ -248,7 +247,7 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
</li> </li>
))} ))}
</ul> </ul>
</nav> </div>
{/* Footer */} {/* Footer */}
<div className="p-2 border-t border-white/10"> <div className="p-2 border-t border-white/10">
@@ -257,14 +256,13 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
<div>{currentTime}</div> <div>{currentTime}</div>
</div> </div>
</div> </div>
</aside> </nav>
) )
} }
// ── Desktop: 220px full sidebar ── // ── Desktop: 220px full sidebar ──
return ( return (
<aside <nav
role="navigation"
aria-label="Clinical record navigation" aria-label="Clinical record navigation"
className="hidden lg:flex flex-col w-[220px] h-full bg-pmr-sidebar border-r border-[#334155] text-white" className="hidden lg:flex flex-col w-[220px] h-full bg-pmr-sidebar border-r border-[#334155] text-white"
> >
@@ -285,7 +283,12 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
/> />
<input <input
id="sidebar-search" id="sidebar-search"
type="text" type="search"
role="combobox"
aria-label="Search record"
aria-expanded={searchQuery.trim().length >= 2 && groupedResults.size > 0}
aria-controls="search-results-listbox"
aria-autocomplete="list"
placeholder="Search record..." placeholder="Search record..."
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
@@ -306,16 +309,21 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
)} )}
{/* Search results dropdown — grouped by section */} {/* Search results dropdown — grouped by section */}
{searchQuery.trim().length >= 2 && groupedResults.size > 0 && ( {searchQuery.trim().length >= 2 && groupedResults.size > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-pmr-sidebar border border-white/10 rounded overflow-hidden z-50 max-h-[400px] overflow-y-auto shadow-lg"> <div
id="search-results-listbox"
role="listbox"
aria-label="Search results"
className="absolute top-full left-0 right-0 mt-1 bg-pmr-sidebar border border-white/10 rounded overflow-hidden z-50 max-h-[400px] overflow-y-auto shadow-lg"
>
{Array.from(groupedResults.entries()).map(([sectionLabel, results]) => { {Array.from(groupedResults.entries()).map(([sectionLabel, results]) => {
// Find section icon // Find section icon
const navItem = navItems.find(item => item.label === sectionLabel) const navItem = navItems.find(item => item.label === sectionLabel)
return ( return (
<div key={sectionLabel}> <div key={sectionLabel} role="group" aria-label={sectionLabel}>
{/* Section header */} {/* Section header */}
<div className="px-3 py-1.5 bg-white/[0.05] border-b border-white/10"> <div className="px-3 py-1.5 bg-white/[0.05] border-b border-white/10">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{navItem && <span className="text-white/40">{navItem.icon}</span>} {navItem && <span className="text-white/40" aria-hidden="true">{navItem.icon}</span>}
<span className="font-ui text-xs font-semibold uppercase tracking-wide text-white/50"> <span className="font-ui text-xs font-semibold uppercase tracking-wide text-white/50">
{sectionLabel} {sectionLabel}
</span> </span>
@@ -329,6 +337,8 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
<button <button
key={result.item.id} key={result.item.id}
type="button" type="button"
role="option"
aria-selected={false}
onClick={() => handleSearchResultClick(result)} onClick={() => handleSearchResultClick(result)}
className="w-full px-3 py-2.5 text-left hover:bg-white/[0.10] transition-colors border-b border-white/5 last:border-b-0" className="w-full px-3 py-2.5 text-left hover:bg-white/[0.10] transition-colors border-b border-white/5 last:border-b-0"
> >
@@ -349,7 +359,7 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
</div> </div>
{/* Navigation items */} {/* Navigation items */}
<nav className="flex-1 py-2 overflow-y-auto"> <div className="flex-1 py-2 overflow-y-auto">
<ul role="menu" aria-label="Record sections"> <ul role="menu" aria-label="Record sections">
{navItems.map((item, index) => ( {navItems.map((item, index) => (
<li key={item.id} role="none"> <li key={item.id} role="none">
@@ -382,7 +392,7 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
</li> </li>
))} ))}
</ul> </ul>
</nav> </div>
{/* Footer: session info */} {/* Footer: session info */}
<div className="p-4 border-t border-white/10"> <div className="p-4 border-t border-white/10">
@@ -391,6 +401,6 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
<div>Logged in: {currentTime}</div> <div>Logged in: {currentTime}</div>
</div> </div>
</div> </div>
</aside> </nav>
) )
} }
+12 -1
View File
@@ -30,6 +30,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
const passwordIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null) const passwordIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const cursorIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null) const cursorIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const timeoutRefs = useRef<ReturnType<typeof setTimeout>[]>([]) const timeoutRefs = useRef<ReturnType<typeof setTimeout>[]>([])
const loginButtonRef = useRef<HTMLButtonElement>(null)
const addTimeout = useCallback((fn: () => void, delay: number) => { const addTimeout = useCallback((fn: () => void, delay: number) => {
const id = setTimeout(fn, delay) const id = setTimeout(fn, delay)
@@ -92,6 +93,13 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
}, 80) }, 80)
}, [prefersReducedMotion, addTimeout]) }, [prefersReducedMotion, addTimeout])
// Focus the login button when typing completes for keyboard accessibility
useEffect(() => {
if (typingComplete && loginButtonRef.current) {
loginButtonRef.current.focus()
}
}, [typingComplete])
useEffect(() => { useEffect(() => {
// Cursor blink: 530ms interval // Cursor blink: 530ms interval
cursorIntervalRef.current = setInterval(() => { cursorIntervalRef.current = setInterval(() => {
@@ -125,8 +133,9 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
<div <div
className="fixed inset-0 flex items-center justify-center z-50" className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: '#1E293B' }} style={{ backgroundColor: '#1E293B' }}
role="status" role="dialog"
aria-label="Clinical system login" aria-label="Clinical system login"
aria-modal="true"
> >
<motion.div <motion.div
className="bg-white" className="bg-white"
@@ -273,10 +282,12 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
{/* Log In Button — user clicks to proceed */} {/* Log In Button — user clicks to proceed */}
<button <button
ref={loginButtonRef}
onClick={handleLogin} onClick={handleLogin}
disabled={!typingComplete} disabled={!typingComplete}
onMouseEnter={() => setButtonHovered(true)} onMouseEnter={() => setButtonHovered(true)}
onMouseLeave={() => setButtonHovered(false)} onMouseLeave={() => setButtonHovered(false)}
className="focus-visible:ring-2 focus-visible:ring-[#005EB8]/40 focus-visible:ring-offset-2 focus:outline-none"
style={{ style={{
width: '100%', width: '100%',
padding: '10px 16px', padding: '10px 16px',
+3 -3
View File
@@ -179,8 +179,7 @@ function PMRContent({ children }: PMRInterfaceProps) {
<motion.main <motion.main
ref={scrollContainerCallbackRef} ref={scrollContainerCallbackRef}
variants={contentVariants} variants={contentVariants}
role="main" aria-label={`${viewLabels[activeView]} view`}
aria-label={`${activeView} view`}
className={` className={`
flex-1 overflow-y-auto p-4 md:p-6 flex-1 overflow-y-auto p-4 md:p-6
${isMobile ? 'pb-20' : ''} ${isMobile ? 'pb-20' : ''}
@@ -258,7 +257,8 @@ function MobileSearchBar({ query, onChange }: MobileSearchBarProps) {
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
/> />
<input <input
type="text" type="search"
aria-label="Search record"
placeholder="Search record..." placeholder="Search record..."
value={query} value={query}
onChange={e => onChange(e.target.value)} onChange={e => onChange(e.target.value)}
+2 -1
View File
@@ -330,6 +330,7 @@ function StatusDot({ status }: StatusDotProps) {
return ( return (
<span <span
className={`w-2 h-2 rounded-full ${colorClass} flex-shrink-0`} className={`w-2 h-2 rounded-full ${colorClass} flex-shrink-0`}
role="img"
aria-label={`Status: ${status}`} aria-label={`Status: ${status}`}
/> />
) )
@@ -368,7 +369,7 @@ function ActionButton({ icon, label, href, external, compact }: ActionButtonProp
transition-colors duration-150 transition-colors duration-150
rounded rounded
font-ui font-medium font-ui font-medium
focus:outline-none focus:ring-2 focus:ring-pmr-nhsblue/40 focus:ring-offset-1 focus:ring-offset-pmr-banner focus:outline-none focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:ring-offset-1 focus-visible:ring-offset-pmr-banner
${compact ? 'px-2 py-1 text-xs' : 'px-3 py-1.5 text-sm'} ${compact ? 'px-2 py-1 text-xs' : 'px-3 py-1.5 text-sm'}
`} `}
> >
+2 -2
View File
@@ -66,7 +66,7 @@ function ConsultationEntry({
const keyCodedEntry = consultation.codedEntries[0] const keyCodedEntry = consultation.codedEntries[0]
return ( return (
<div <article
className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden" className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden"
style={{ borderLeftWidth: '3px', borderLeftColor: consultation.orgColor }} style={{ borderLeftWidth: '3px', borderLeftColor: consultation.orgColor }}
> >
@@ -136,7 +136,7 @@ function ConsultationEntry({
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</div> </article>
) )
} }
+2 -1
View File
@@ -119,6 +119,7 @@ export function MedicationsView() {
{categoryTabs.map((tab) => ( {categoryTabs.map((tab) => (
<button <button
key={tab.id} key={tab.id}
id={`tab-${tab.id}`}
role="tab" role="tab"
aria-selected={activeTab === tab.id} aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`} aria-controls={`panel-${tab.id}`}
@@ -159,7 +160,7 @@ export function MedicationsView() {
<div <div
id={`panel-${activeTab}`} id={`panel-${activeTab}`}
role="tabpanel" role="tabpanel"
aria-labelledby={activeTab} aria-labelledby={`tab-${activeTab}`}
> >
{isMobile ? ( {isMobile ? (
<MobileMedicationList <MobileMedicationList
+1 -2
View File
@@ -26,8 +26,7 @@ function TrafficLight({ status }: { status: ProblemStatus }) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={`w-2 h-2 rounded-full ${bg}`} className={`w-2 h-2 rounded-full ${bg}`}
aria-label={`Status: ${label}`} aria-hidden="true"
role="img"
/> />
<span className="font-ui text-xs text-gray-600">{label}</span> <span className="font-ui text-xs text-gray-600">{label}</span>
</div> </div>
+1
View File
@@ -170,6 +170,7 @@ function ClinicalAlert({
type="button" type="button"
onClick={onAcknowledge} onClick={onAcknowledge}
disabled={isAcknowledging} disabled={isAcknowledging}
aria-label="Acknowledge clinical alert"
className="flex-shrink-0 px-3 py-1.5 text-xs font-ui font-medium border rounded transition-colors duration-100 hover:bg-[#F59E0B] hover:text-white disabled:opacity-50" className="flex-shrink-0 px-3 py-1.5 text-xs font-ui font-medium border rounded transition-colors duration-100 hover:bg-[#F59E0B] hover:text-white disabled:opacity-50"
style={{ style={{
borderColor: '#F59E0B', borderColor: '#F59E0B',