Skip to content

Commit 786ad00

Browse files
corvid-agentclaude
andauthored
a11y: add focus trap and dialog role to mobile sidebar (#56)
- Sidebar uses role="dialog" + aria-modal="true" when open on mobile, reverts to role="complementary" on desktop - Main content gets inert attribute when sidebar dialog is open, preventing focus from escaping the sidebar - Escape key closes the sidebar - Focus moves to close button on open, returns to trigger on close - Added aria-labelledby pointing to the sidebar title Closes #48 Co-authored-by: Claude Opus 4.6 <[email protected]>
1 parent a9fe50d commit 786ad00

2 files changed

Lines changed: 53 additions & 5 deletions

File tree

src/app/components/shell/shell.html

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
<div class="shell">
22
<aside
3+
#sidebar
34
class="sidebar"
45
[class.sidebar-open]="sidebarOpen()"
5-
role="complementary"
6-
aria-label="Spec navigation"
6+
[attr.role]="isDialog() ? 'dialog' : 'complementary'"
7+
[attr.aria-modal]="isDialog() ? 'true' : null"
8+
[attr.aria-label]="isDialog() ? null : 'Spec navigation'"
9+
[attr.aria-labelledby]="isDialog() ? 'sidebar-title' : null"
710
>
811
<div class="sidebar-header">
9-
<h1 class="logo">Specl</h1>
12+
<h1 id="sidebar-title" class="logo">Specl</h1>
1013
<button class="btn-icon sidebar-close" (click)="toggleSidebar()" aria-label="Close sidebar">
1114
&times;
1215
</button>
@@ -19,7 +22,12 @@ <h1 class="logo">Specl</h1>
1922
(click)="toggleSidebar()"
2023
aria-hidden="true"
2124
></div>
22-
<main id="main-content" class="content" role="main">
25+
<main
26+
id="main-content"
27+
class="content"
28+
role="main"
29+
[attr.inert]="isDialog() ? '' : null"
30+
>
2331
<button
2432
class="btn-icon mobile-menu-btn"
2533
(click)="toggleSidebar()"

src/app/components/shell/shell.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component, signal, inject } from '@angular/core';
1+
import { Component, signal, computed, inject, effect, ElementRef, HostListener, ViewChild } from '@angular/core';
22
import { RouterOutlet, Router, NavigationEnd } from '@angular/router';
33
import { SpecListComponent } from '../spec-list/spec-list';
44
import { filter } from 'rxjs';
@@ -16,6 +16,17 @@ export class ShellComponent {
1616
/** Whether the mobile sidebar is visible */
1717
readonly sidebarOpen = signal(true);
1818

19+
/** Track mobile breakpoint for dialog semantics */
20+
readonly isMobile = signal(window.innerWidth < 768);
21+
22+
/** Sidebar acts as a dialog on mobile when open */
23+
readonly isDialog = computed(() => this.isMobile() && this.sidebarOpen());
24+
25+
@ViewChild('sidebar') private sidebarRef?: ElementRef<HTMLElement>;
26+
27+
/** Element that had focus before sidebar opened (for focus restoration) */
28+
private previousFocus: HTMLElement | null = null;
29+
1930
constructor() {
2031
// Auto-close sidebar on navigation (mobile: user selected a spec)
2132
this.router.events
@@ -25,6 +36,35 @@ export class ShellComponent {
2536
this.sidebarOpen.set(false);
2637
}
2738
});
39+
40+
// Manage focus when sidebar opens/closes on mobile
41+
effect(() => {
42+
const open = this.sidebarOpen();
43+
const mobile = this.isMobile();
44+
if (mobile && open) {
45+
this.previousFocus = document.activeElement as HTMLElement | null;
46+
// Move focus into sidebar after Angular renders
47+
queueMicrotask(() => {
48+
const closeBtn = this.sidebarRef?.nativeElement.querySelector<HTMLElement>('.sidebar-close');
49+
closeBtn?.focus();
50+
});
51+
} else if (this.previousFocus) {
52+
this.previousFocus.focus();
53+
this.previousFocus = null;
54+
}
55+
});
56+
}
57+
58+
@HostListener('window:resize')
59+
onResize(): void {
60+
this.isMobile.set(window.innerWidth < 768);
61+
}
62+
63+
@HostListener('keydown.escape')
64+
onEscape(): void {
65+
if (this.isDialog()) {
66+
this.sidebarOpen.set(false);
67+
}
2868
}
2969

3070
toggleSidebar(): void {

0 commit comments

Comments
 (0)