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
+4 -4
View File
@@ -56,7 +56,7 @@ export function Breadcrumb({
</button>
</li>
<li>
<li aria-hidden="true">
<ChevronRight size={14} className="text-gray-300" />
</li>
@@ -71,7 +71,7 @@ export function Breadcrumb({
{viewLabels[currentView]}
</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]}
</span>
)}
@@ -80,11 +80,11 @@ export function Breadcrumb({
{/* Expanded item (if any) */}
{expandedItem && (
<>
<li>
<li aria-hidden="true">
<ChevronRight size={14} className="text-gray-300" />
</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}
</span>
</li>
+24 -14
View File
@@ -195,8 +195,7 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
// ── Tablet: 56px icon-only sidebar ──
if (isTablet) {
return (
<aside
role="navigation"
<nav
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"
>
@@ -208,7 +207,7 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
</div>
{/* 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">
{navItems.map((item, index) => (
<li key={item.id} role="none" className="relative">
@@ -248,7 +247,7 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
</li>
))}
</ul>
</nav>
</div>
{/* Footer */}
<div className="p-2 border-t border-white/10">
@@ -257,14 +256,13 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
<div>{currentTime}</div>
</div>
</div>
</aside>
</nav>
)
}
// ── Desktop: 220px full sidebar ──
return (
<aside
role="navigation"
<nav
aria-label="Clinical record navigation"
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
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..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
@@ -306,16 +309,21 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
)}
{/* Search results dropdown — grouped by section */}
{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]) => {
// Find section icon
const navItem = navItems.find(item => item.label === sectionLabel)
return (
<div key={sectionLabel}>
<div key={sectionLabel} role="group" aria-label={sectionLabel}>
{/* Section header */}
<div className="px-3 py-1.5 bg-white/[0.05] border-b border-white/10">
<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">
{sectionLabel}
</span>
@@ -329,6 +337,8 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
<button
key={result.item.id}
type="button"
role="option"
aria-selected={false}
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"
>
@@ -349,7 +359,7 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
</div>
{/* 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">
{navItems.map((item, index) => (
<li key={item.id} role="none">
@@ -382,7 +392,7 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
</li>
))}
</ul>
</nav>
</div>
{/* Footer: session info */}
<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>
</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 cursorIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const timeoutRefs = useRef<ReturnType<typeof setTimeout>[]>([])
const loginButtonRef = useRef<HTMLButtonElement>(null)
const addTimeout = useCallback((fn: () => void, delay: number) => {
const id = setTimeout(fn, delay)
@@ -92,6 +93,13 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
}, 80)
}, [prefersReducedMotion, addTimeout])
// Focus the login button when typing completes for keyboard accessibility
useEffect(() => {
if (typingComplete && loginButtonRef.current) {
loginButtonRef.current.focus()
}
}, [typingComplete])
useEffect(() => {
// Cursor blink: 530ms interval
cursorIntervalRef.current = setInterval(() => {
@@ -125,8 +133,9 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: '#1E293B' }}
role="status"
role="dialog"
aria-label="Clinical system login"
aria-modal="true"
>
<motion.div
className="bg-white"
@@ -273,10 +282,12 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
{/* Log In Button — user clicks to proceed */}
<button
ref={loginButtonRef}
onClick={handleLogin}
disabled={!typingComplete}
onMouseEnter={() => setButtonHovered(true)}
onMouseLeave={() => setButtonHovered(false)}
className="focus-visible:ring-2 focus-visible:ring-[#005EB8]/40 focus-visible:ring-offset-2 focus:outline-none"
style={{
width: '100%',
padding: '10px 16px',
+3 -3
View File
@@ -179,8 +179,7 @@ function PMRContent({ children }: PMRInterfaceProps) {
<motion.main
ref={scrollContainerCallbackRef}
variants={contentVariants}
role="main"
aria-label={`${activeView} view`}
aria-label={`${viewLabels[activeView]} view`}
className={`
flex-1 overflow-y-auto p-4 md:p-6
${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"
/>
<input
type="text"
type="search"
aria-label="Search record"
placeholder="Search record..."
value={query}
onChange={e => onChange(e.target.value)}
+2 -1
View File
@@ -330,6 +330,7 @@ function StatusDot({ status }: StatusDotProps) {
return (
<span
className={`w-2 h-2 rounded-full ${colorClass} flex-shrink-0`}
role="img"
aria-label={`Status: ${status}`}
/>
)
@@ -368,7 +369,7 @@ function ActionButton({ icon, label, href, external, compact }: ActionButtonProp
transition-colors duration-150
rounded
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'}
`}
>
+2 -2
View File
@@ -66,7 +66,7 @@ function ConsultationEntry({
const keyCodedEntry = consultation.codedEntries[0]
return (
<div
<article
className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden"
style={{ borderLeftWidth: '3px', borderLeftColor: consultation.orgColor }}
>
@@ -136,7 +136,7 @@ function ConsultationEntry({
</motion.div>
)}
</AnimatePresence>
</div>
</article>
)
}
+2 -1
View File
@@ -119,6 +119,7 @@ export function MedicationsView() {
{categoryTabs.map((tab) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
@@ -159,7 +160,7 @@ export function MedicationsView() {
<div
id={`panel-${activeTab}`}
role="tabpanel"
aria-labelledby={activeTab}
aria-labelledby={`tab-${activeTab}`}
>
{isMobile ? (
<MobileMedicationList
+1 -2
View File
@@ -26,8 +26,7 @@ function TrafficLight({ status }: { status: ProblemStatus }) {
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${bg}`}
aria-label={`Status: ${label}`}
role="img"
aria-hidden="true"
/>
<span className="font-ui text-xs text-gray-600">{label}</span>
</div>
+1
View File
@@ -170,6 +170,7 @@ function ClinicalAlert({
type="button"
onClick={onAcknowledge}
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"
style={{
borderColor: '#F59E0B',