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