Skip to content

Commit daa7260

Browse files
committed
frontend/aria: ARIA for app itself, phase 12/P1
1 parent 7ea5f91 commit daa7260

File tree

14 files changed

+244
-50
lines changed

14 files changed

+244
-50
lines changed

src/dev/ARIA.md

Lines changed: 98 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,65 @@ The frontend has a **three-level hierarchy**:
6969
- Sections with counts: `"Projects list (5 total)"`, `"Issues (2e 1w): {path}"`
7070
- Conditional content: Update aria-label when content changes
7171

72+
### Pattern: Keyboard Accessibility for Custom Interactive Elements
73+
74+
When creating interactive elements with `role="button"`, `role="tab"`, or other interactive roles using `<div>` or other non-button elements, you must provide keyboard support via the `ariaKeyDown` utility from `@cocalc/frontend/app/aria`.
75+
76+
**Why this is needed:**
77+
78+
- Native `<button>` elements support Enter and Space keys automatically
79+
- Custom elements with `role="button"` do not have native keyboard support
80+
- Screen reader users and keyboard-only users rely on this keyboard behavior
81+
82+
**Usage pattern:**
83+
84+
```tsx
85+
import { ariaKeyDown } from "@cocalc/frontend/app/aria";
86+
87+
// For button-like divs
88+
<div
89+
role="button"
90+
tabIndex={0}
91+
onClick={handleClick}
92+
onKeyDown={ariaKeyDown(handleClick)}
93+
aria-label="Delete item"
94+
>
95+
Delete
96+
</div>
97+
98+
// For tab-like divs
99+
<div
100+
role="tab"
101+
tabIndex={0}
102+
onClick={handleSelect}
103+
onKeyDown={ariaKeyDown(handleSelect)}
104+
aria-selected={isActive}
105+
>
106+
Tab label
107+
</div>
108+
```
109+
110+
**What ariaKeyDown does:**
111+
112+
- Activates the handler when Enter or Space keys are pressed
113+
- Prevents default browser behavior (form submission, page scroll)
114+
- Provides the same keyboard experience as native buttons
115+
- Single source of truth for this common accessibility pattern
116+
117+
**Implementation note:**
118+
Always use `ariaKeyDown` when you have:
119+
120+
- `role="button"`, `role="tab"`, or other interactive roles on non-button elements
121+
- An `onClick` handler that should also work with keyboard
122+
- A `tabIndex={0}` to make the element focusable
123+
124+
See `packages/frontend/app/aria.tsx` for the implementation and usage in:
125+
126+
- `packages/frontend/app/nav-tab.tsx` - Navigation tabs
127+
- `packages/frontend/app/connection-indicator.tsx` - Status indicator
128+
- `packages/frontend/app/notifications.tsx` - Notification badges
129+
- `packages/frontend/frame-editors/frame-tree/status-bar.tsx` - Status bar close button
130+
72131
## Split Editors with Multiple Frames
73132

74133
When editors are split into multiple frames, use nested regions with clear labels:
@@ -412,8 +471,9 @@ Location: `packages/frontend/project/page/`
412471
**Completed** ✅:
413472

414473
- [x] **page.tsx** - Main project workspace
415-
- [x] Main content area: `<div role="main" aria-label="Content: {currentFilename}">` (line 389-392)
416-
- [x] Activity bar sidebar: `<aside role="complementary" aria-label="Project activity bar">` (line 356-371)
474+
- [x] Root container: `<div role="region" aria-label="Project: {projectTitle}">` (line 420-425)
475+
- [x] Main content area: `<div role="main" aria-label="Content: {currentFilename}">` (line 395-404)
476+
- [x] Activity bar sidebar: `<aside role="complementary" aria-label="Project activity bar">` (line 362-376)
417477
- [x] File tabs navigation: `<nav aria-label="Open files">` (line 307-313)
418478
- [x] Flyout sidebar: `<aside role="complementary" aria-label="Project sidebar">` (line 262-278)
419479

@@ -503,28 +563,42 @@ Location: `packages/frontend/app/`
503563
- `packages/frontend/app/connection-indicator.tsx` - Status live region with i18n labels
504564
- `packages/frontend/i18n/common.ts` - Added labels.connected
505565

506-
#### **P1 - Important Improvements** ⏳ PENDING
507-
508-
- [ ] **active-content.tsx** - Content router
509-
- [ ] Dynamic content: Announce when switching pages
510-
- [ ] Loading states: `aria-busy` indication
511-
- [ ] Error states: ARIA alert or live region
512-
513-
- [ ] **Banners** - Informational/warning banners (5 files)
514-
- [ ] All banners: `role="region" aria-label="..."`
515-
- [ ] `i18n-banner.tsx` - Language selection
516-
- [ ] `verify-email-banner.tsx` - Email verification
517-
- [ ] `version-warning.tsx` - Version alerts
518-
- [ ] `insecure-test-mode-banner.tsx` - Test mode warning
519-
- [ ] `warnings.tsx` - Cookie/storage warnings
520-
521-
- [ ] **Notifications** - Notification indicators
522-
- [ ] Notification badges: `aria-label` with count
523-
- [ ] Live region: `aria-live="polite"` for count changes
524-
525-
- [ ] **projects-nav.tsx** - Project tabs navigation
526-
- [ ] Container: `aria-label="Open projects"`
527-
- [ ] Tab semantics already handled by Ant Design Tabs
566+
#### **P1 - Important Improvements** ✅ COMPLETED
567+
568+
- [x] **active-content.tsx** - Content router
569+
- [x] Decision: Each active content page should have its own aria-labels (not wrapped in single region)
570+
- [x] Left as `<>{v}</>` - ProjectPage, ProjectsPage, AccountPage, etc. handle their own landmarks
571+
572+
- [x] **Banners** - Informational/warning banners (5 files)
573+
- [x] **i18n-banner.tsx** - `role="region" aria-label="Language selection" aria-live="polite"`
574+
- [x] **verify-email-banner.tsx** - `aria-label="Email verification required"` on Modal
575+
- [x] **version-warning.tsx** - `role="region" aria-label="Version warning"` with dynamic aria-live (assertive if critical)
576+
- [x] **insecure-test-mode-banner.tsx** - `role="region" aria-label="Test mode warning" aria-live="assertive"` on Alert
577+
- [x] **warnings.tsx** - Both CookieWarning and LocalStorageWarning:
578+
- `role="region" aria-label="Cookie warning" aria-live="assertive"`
579+
- `role="region" aria-label="Local storage warning" aria-live="assertive"`
580+
581+
- [x] **notifications.tsx** - Notification indicators with keyboard support
582+
- [x] Added `getAriaLabel()` function for dynamic labels:
583+
- Bell: `"File use notifications: {count} new"`
584+
- Notifications: `"Messages and mentions: {unreadMessages} unread, {count} mentions, {newsUnread} news"`
585+
- [x] Added `role="button"` for keyboard accessibility
586+
- [x] Added `aria-live="polite"` to announce count changes
587+
- [x] Added `tabIndex={0}` and `onKeyDown` for Enter/Space activation
588+
589+
- [x] **projects-nav.tsx** - Project tabs navigation
590+
- [x] Added `aria-label="Open projects"` to Ant Design Tabs container
591+
- [x] Tab semantics already handled by Ant Design Tabs component
592+
593+
**Files Modified (P1)**:
594+
595+
- `packages/frontend/app/i18n-banner.tsx`
596+
- `packages/frontend/app/verify-email-banner.tsx`
597+
- `packages/frontend/app/version-warning.tsx`
598+
- `packages/frontend/app/warnings.tsx`
599+
- `packages/frontend/app/insecure-test-mode-banner.tsx`
600+
- `packages/frontend/app/notifications.tsx`
601+
- `packages/frontend/projects/projects-nav.tsx`
528602

529603
### Phase 13: Forms & Settings ⏳ PENDING
530604

src/packages/frontend/app/active-content.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export const ActiveContent: React.FC = React.memo(() => {
5454
}, [is_logged_in, notSignedIn]);
5555

5656
const v: React.JSX.Element[] = [];
57+
5758
open_projects?.forEach((project_id: string) => {
5859
const is_active = project_id === active_top_tab;
5960
const x = <ProjectPage project_id={project_id} is_active={is_active} />;
@@ -64,7 +65,7 @@ export const ActiveContent: React.FC = React.memo(() => {
6465
v.push(
6566
<div key={project_id} className={cls}>
6667
{x}
67-
</div>
68+
</div>,
6869
);
6970
});
7071

@@ -100,7 +101,7 @@ export const ActiveContent: React.FC = React.memo(() => {
100101
</A>
101102
.
102103
</Alert>
103-
</div>
104+
</div>,
104105
);
105106
}
106107
}

src/packages/frontend/app/aria.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* This file is part of CoCalc: Copyright © 2025 Sagemath, Inc.
3+
* License: MS-RSL – see LICENSE.md for details.
4+
*/
5+
6+
/**
7+
* ARIA Keyboard Activation Handler
8+
*
9+
* This module provides utilities for making custom interactive elements
10+
* (divs, spans, etc. with role="button" or role="tab") fully accessible
11+
* to keyboard users.
12+
*
13+
* Why this is needed:
14+
* - Native HTML <button> elements work with Enter and Space keys automatically
15+
* - When using <div role="button"> (common with styled frameworks), keyboard
16+
* support must be manually implemented
17+
* - This handler provides the standard keyboard behavior expected by assistive
18+
* technology users and keyboard navigators
19+
*
20+
* Usage:
21+
* <div
22+
* role="button"
23+
* tabIndex={0}
24+
* onClick={handleClick}
25+
* onKeyDown={(e) => ariaKeyDown(e, handleClick)}
26+
* >
27+
* Click me or press Enter/Space
28+
* </div>
29+
*
30+
* Benefits:
31+
* - Consistent keyboard behavior across custom interactive components
32+
* - Works with Enter key (standard for buttons) and Space (acceptable alternative)
33+
* - Prevents default browser behavior (e.g., page scroll on Space)
34+
* - Single source of truth for this common accessibility pattern
35+
*/
36+
37+
/**
38+
* Create a keyboard event handler for ARIA interactive elements
39+
*
40+
* Returns a handler that activates click handlers when users press Enter or Space keys,
41+
* mimicking native button behavior for custom interactive elements
42+
* with role="button", role="tab", role="region", etc.
43+
*
44+
* @param handler - The click handler to invoke (typically your onClick function)
45+
* @returns A keyboard event handler function
46+
*
47+
* @example
48+
* // In your component:
49+
* <div
50+
* role="button"
51+
* tabIndex={0}
52+
* onClick={handleDelete}
53+
* onKeyDown={ariaKeyDown(handleDelete)}
54+
* >
55+
* Delete
56+
* </div>
57+
*/
58+
export function ariaKeyDown(
59+
handler: (e?: React.KeyboardEvent | React.MouseEvent) => void,
60+
): (e: React.KeyboardEvent) => void {
61+
return (e: React.KeyboardEvent) => {
62+
// Activate on Enter (standard button behavior) or Space (accessible alternative)
63+
if (e.key === "Enter" || e.key === " ") {
64+
// Prevent default browser behavior:
65+
// - Enter: prevents form submission or other default actions
66+
// - Space: prevents page scroll
67+
e.preventDefault();
68+
// Call the handler (usually onClick)
69+
handler(e);
70+
}
71+
};
72+
}

src/packages/frontend/app/connection-indicator.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Icon } from "@cocalc/frontend/components";
1515
import { labels } from "@cocalc/frontend/i18n";
1616
import track from "@cocalc/frontend/user-tracking";
1717
import { COLORS } from "@cocalc/util/theme";
18+
import { ariaKeyDown } from "./aria";
1819
import {
1920
FONT_SIZE_ICONS_NORMAL,
2021
PageStyle,
@@ -122,12 +123,7 @@ export const ConnectionIndicator: React.FC<Props> = React.memo(
122123
aria-busy={connection_status === "connecting"}
123124
style={outer_style}
124125
onClick={connection_click}
125-
onKeyDown={(e) => {
126-
if (e.key === "Enter" || e.key === " ") {
127-
e.preventDefault();
128-
connection_click();
129-
}
130-
}}
126+
onKeyDown={ariaKeyDown(connection_click)}
131127
tabIndex={0}
132128
>
133129
{render_connection_status()}

src/packages/frontend/app/i18n-banner.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,12 @@ export const I18NBanner: React.FC<{}> = () => {
8787
if (!loaded) return;
8888

8989
return (
90-
<div style={I18N_BANNER_STYLE}>
90+
<div
91+
role="region"
92+
aria-label="Language selection"
93+
aria-live="polite"
94+
style={I18N_BANNER_STYLE}
95+
>
9196
<Text strong>
9297
<Icon name={"translation-outlined"} /> Use <SiteName /> in a different
9398
language:

src/packages/frontend/app/insecure-test-mode-banner.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ export default function InsecureTestModeBanner() {
77
banner
88
type="warning"
99
showIcon
10+
role="region"
11+
aria-label="Test mode warning"
12+
aria-live="assertive"
1013
style={{ background: "darkred", color: "white" }}
1114
message={
1215
<div style={{ textAlign: "center" }}>

src/packages/frontend/app/nav-tab.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CSS, React, useActions } from "@cocalc/frontend/app-framework";
99
import { Icon, IconName } from "@cocalc/frontend/components";
1010
import track from "@cocalc/frontend/user-tracking";
1111
import { COLORS } from "@cocalc/util/theme";
12+
import { ariaKeyDown } from "./aria";
1213
import { TOP_BAR_ELEMENT_CLASS } from "./top-nav-consts";
1314

1415
const ACTIVE_BG_COLOR = COLORS.TOP_BAR.ACTIVE;
@@ -143,12 +144,7 @@ export const NavTab: React.FC<Props> = React.memo((props: Props) => {
143144
return (
144145
<div
145146
onClick={onClick}
146-
onKeyDown={(e) => {
147-
if (e.key === "Enter" || e.key === " ") {
148-
e.preventDefault();
149-
onClick();
150-
}
151-
}}
147+
onKeyDown={ariaKeyDown(onClick)}
152148
role={props.role ?? "button"}
153149
aria-label={props["aria-label"]}
154150
tabIndex={0}

src/packages/frontend/app/notifications.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Icon } from "@cocalc/frontend/components";
1616
import { unreachable } from "@cocalc/util/misc";
1717
import { COLORS } from "@cocalc/util/theme";
1818
import track from "@cocalc/frontend/user-tracking";
19+
import { ariaKeyDown } from "./aria";
1920
import { PageStyle, TOP_BAR_ELEMENT_CLASS } from "./top-nav-consts";
2021
import { blur_active_element } from "./util";
2122
import { useEffect, useMemo } from "react";
@@ -148,8 +149,29 @@ export const Notification: React.FC<Props> = React.memo((props: Props) => {
148149

149150
const className = TOP_BAR_ELEMENT_CLASS + (active ? " active" : "");
150151

152+
const getAriaLabel = (): string => {
153+
switch (type) {
154+
case "bell":
155+
return `File use notifications: ${count} new`;
156+
case "notifications":
157+
return `Messages and mentions: ${unread_message_count} unread messages, ${count} mentions, ${news_unread} news items`;
158+
default:
159+
unreachable(type);
160+
return "";
161+
}
162+
};
163+
151164
return (
152-
<div style={outer_style} onClick={onClick} className={className}>
165+
<div
166+
role="button"
167+
aria-label={getAriaLabel()}
168+
aria-live="polite"
169+
tabIndex={0}
170+
style={outer_style}
171+
onClick={onClick}
172+
onKeyDown={ariaKeyDown(onClick)}
173+
className={className}
174+
>
153175
<div style={inner_style}>{renderBadge()}</div>
154176
</div>
155177
);

src/packages/frontend/app/verify-email-banner.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ function VerifyEmailModal({
168168
open={true}
169169
onCancel={() => doDismiss()}
170170
footer={renderFooter()}
171+
aria-label="Email verification required"
171172
>
172173
{renderBanner()}
173174
</Modal>

src/packages/frontend/app/version-warning.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,12 @@ export default function VersionWarning() {
112112
}
113113

114114
return (
115-
<div style={style}>
115+
<div
116+
role="region"
117+
aria-label="Version warning"
118+
aria-live={version < minVersion ? "assertive" : "polite"}
119+
style={style}
120+
>
116121
{render_suggested()}
117122
{render_critical()}
118123
</div>

0 commit comments

Comments
 (0)