Skip to content

Commit 13bec8c

Browse files
committed
frontend/aria: make top projects tab a landmark and make tab+return work
1 parent daa7260 commit 13bec8c

File tree

11 files changed

+221
-54
lines changed

11 files changed

+221
-54
lines changed

src/dev/ARIA.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1482,6 +1482,88 @@ const { label, icon, tooltip, onClick, isRunning } = getRunStopButton();
14821482

14831483
3. **Live Regions**: Use `aria-live="polite"` for status updates and `aria-live="assertive"` only for urgent alerts. Always test with screen readers to ensure announcements are clear.
14841484

1485+
## Keyboard Event Handling & Event Propagation ✅ (2025-11-06)
1486+
1487+
### Problem Identified
1488+
1489+
When keyboard events activate menu items or navigation tabs, events were bubbling up to parent elements, causing:
1490+
1491+
1. Multiple handlers to trigger for a single keyboard action
1492+
2. Menu items activating while also triggering parent keyboard shortcuts
1493+
3. Return/Enter key causing unexpected behavior in editor context
1494+
1495+
### Solution Implemented
1496+
1497+
#### 1. Enhanced `ariaKeyDown()` Handler
1498+
1499+
**File**: `packages/frontend/app/aria.tsx`
1500+
1501+
```tsx
1502+
export function ariaKeyDown(
1503+
handler: (e?: React.KeyboardEvent | React.MouseEvent) => void,
1504+
stopPropagation: boolean = true, // ← New parameter (default: true)
1505+
): (e: React.KeyboardEvent) => void {
1506+
return (e: React.KeyboardEvent) => {
1507+
if (e.key === "Enter" || e.key === " ") {
1508+
e.preventDefault();
1509+
if (stopPropagation) {
1510+
e.stopPropagation(); // ← Prevents event bubbling
1511+
}
1512+
handler(e);
1513+
}
1514+
};
1515+
}
1516+
```
1517+
1518+
**Impact**: All navigation buttons, tabs, and custom button elements now prevent keyboard event bubbling by default. Optional parameter allows disabling if needed (backwards compatible).
1519+
1520+
#### 2. Menu Item Click Handlers
1521+
1522+
**File**: `packages/frontend/frame-editors/frame-tree/commands/manage.tsx` (line 541+)
1523+
1524+
```tsx
1525+
const onClick = async (event) => {
1526+
// Prevent event bubbling from menu item clicks
1527+
event?.stopPropagation?.();
1528+
event?.preventDefault?.();
1529+
// ... rest of handler
1530+
};
1531+
```
1532+
1533+
**Impact**: Menu items from all editor types (File, Edit, View menus, etc.) now prevent event propagation when activated.
1534+
1535+
#### 3. DropdownMenu Handler
1536+
1537+
**File**: `packages/frontend/components/dropdown-menu.tsx` (line 99+)
1538+
1539+
```tsx
1540+
const handleMenuClick: MenuProps["onClick"] = (e) => {
1541+
// Prevent event bubbling from menu clicks
1542+
e?.domEvent?.stopPropagation?.();
1543+
e?.domEvent?.preventDefault?.();
1544+
// ... rest of handler
1545+
};
1546+
```
1547+
1548+
**Impact**: Ant Design's menu click events are properly contained and don't bubble to parent components.
1549+
1550+
### Benefits
1551+
1552+
- ✅ Menu items activate correctly without side effects
1553+
- ✅ Keyboard navigation (Enter/Space) is isolated to the activated element
1554+
- ✅ Return key in menus doesn't trigger editor keyboard shortcuts
1555+
- ✅ Navigation tabs don't interfere with other page interactions
1556+
- ✅ Backwards compatible - existing code works unchanged
1557+
1558+
### Testing Notes
1559+
1560+
When keyboard testing menus:
1561+
1562+
1. Open a menu with mouse click
1563+
2. Navigate with arrow keys (Ant Design handles this)
1564+
3. Press Enter to activate item - should NOT trigger parent handlers
1565+
4. Verify the menu closes and the action executes cleanly
1566+
14851567
## Session Summary - October 28, 2025
14861568

14871569
### Session Accomplishments

src/packages/frontend/app/aria.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,9 @@
3232
* - Works with Enter key (standard for buttons) and Space (acceptable alternative)
3333
* - Prevents default browser behavior (e.g., page scroll on Space)
3434
* - 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.
4335
*
4436
* @param handler - The click handler to invoke (typically your onClick function)
37+
* @param stopPropagation - Whether to prevent event bubbling (default: true)
4538
* @returns A keyboard event handler function
4639
*
4740
* @example
@@ -57,6 +50,7 @@
5750
*/
5851
export function ariaKeyDown(
5952
handler: (e?: React.KeyboardEvent | React.MouseEvent) => void,
53+
stopPropagation: boolean = true,
6054
): (e: React.KeyboardEvent) => void {
6155
return (e: React.KeyboardEvent) => {
6256
// Activate on Enter (standard button behavior) or Space (accessible alternative)
@@ -65,6 +59,11 @@ export function ariaKeyDown(
6559
// - Enter: prevents form submission or other default actions
6660
// - Space: prevents page scroll
6761
e.preventDefault();
62+
// Stop event bubbling to parent elements (default: true)
63+
// This prevents parent handlers from also triggering
64+
if (stopPropagation) {
65+
e.stopPropagation();
66+
}
6867
// Call the handler (usually onClick)
6968
handler(e);
7069
}

src/packages/frontend/app/page.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -362,11 +362,9 @@ export const Page: React.FC = () => {
362362

363363
// Children must define their own padding from navbar and screen borders
364364
// Note that the parent is a flex container
365-
// ARIA: main element serves as the primary landmark for the entire application
365+
// ARIA: content container (main landmarks are defined at the page level below)
366366
const body = (
367-
<main
368-
role="main"
369-
aria-label={`${site_name} application`}
367+
<div
370368
style={PAGE_STYLE}
371369
onDragOver={(e) => e.preventDefault()}
372370
onDrop={drop}
@@ -408,7 +406,7 @@ export const Page: React.FC = () => {
408406
<PayAsYouGoModal />
409407
<PopconfirmModal />
410408
<SettingsModal />
411-
</main>
409+
</div>
412410
);
413411
return (
414412
<ClientContext.Provider value={{ client: webapp_client }}>

src/packages/frontend/components/dropdown-menu.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ export function DropdownMenu({
9797
}
9898

9999
const handleMenuClick: MenuProps["onClick"] = (e) => {
100+
// Prevent event bubbling from menu clicks
101+
e?.domEvent?.stopPropagation?.();
102+
e?.domEvent?.preventDefault?.();
103+
100104
if (e.key?.includes(STAY_OPEN_ON_CLICK)) {
101105
setOpen(true);
102106
} else {

src/packages/frontend/components/sortable-tabs.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
} from "react";
3131
import useResizeObserver from "use-resize-observer";
3232

33+
import { ariaKeyDown } from "@cocalc/frontend/app/aria";
34+
3335
export { useSortable };
3436

3537
interface Props {
@@ -130,9 +132,22 @@ export function SortableTabs(props: Props) {
130132
);
131133
}
132134

133-
export function SortableTab({ children, id, style }) {
135+
interface SortableTabProps {
136+
children: React.ReactNode;
137+
id: string | number;
138+
style?: CSSProperties;
139+
onKeyReturn?: () => void;
140+
}
141+
142+
export function SortableTab({
143+
children,
144+
id,
145+
style,
146+
onKeyReturn,
147+
}: SortableTabProps) {
134148
const { attributes, listeners, setNodeRef, transform, transition, active } =
135149
useSortable({ id });
150+
136151
return (
137152
<div
138153
ref={setNodeRef}
@@ -146,6 +161,7 @@ export function SortableTab({ children, id, style }) {
146161
}}
147162
{...attributes}
148163
{...listeners}
164+
onKeyDown={onKeyReturn ? ariaKeyDown(onKeyReturn, false) : undefined}
149165
>
150166
{children}
151167
</div>

src/packages/frontend/frame-editors/frame-tree/commands/manage.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,10 @@ export class ManageCommands {
539539
);
540540
}
541541
const onClick = async (event) => {
542+
// Prevent event bubbling from menu item clicks
543+
event?.stopPropagation?.();
544+
event?.preventDefault?.();
545+
542546
let { popconfirm } = cmd;
543547
if (popconfirm != null) {
544548
if (typeof popconfirm === "function") {

src/packages/frontend/project/page/activity-bar-tabs.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,11 @@ export default function ProjectTabs(props: PTProps) {
5454
//if (openFiles.size == 0) return <></>;
5555

5656
return (
57-
<div
57+
<nav
5858
className="smc-file-tabs"
5959
style={{
6060
width: "100%",
6161
height: "40px",
62-
overflow: "hidden",
6362
}}
6463
>
6564
<div style={{ display: "flex" }}>
@@ -86,7 +85,7 @@ export default function ProjectTabs(props: PTProps) {
8685
<ChatIndicatorTab activeTab={activeTab} project_id={project_id} />
8786
</div>
8887
</div>
89-
</div>
88+
</nav>
9089
);
9190
}
9291

@@ -230,6 +229,7 @@ export function VerticalFixedTabs({
230229
role="tab"
231230
aria-selected={isActive}
232231
aria-controls={`activity-panel-${name}`}
232+
tabIndex={0}
233233
/>
234234
);
235235
if (tab != null) items.push(tab);

src/packages/frontend/project/page/file-tab.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
useRedux,
2323
useTypedRedux,
2424
} from "@cocalc/frontend/app-framework";
25+
import { ariaKeyDown } from "@cocalc/frontend/app/aria";
2526
import { Icon, IconName, r_join } from "@cocalc/frontend/components";
2627
import ComputeServerSpendRate from "@cocalc/frontend/compute/spend-rate";
2728
import { IS_MOBILE } from "@cocalc/frontend/feature";
@@ -188,6 +189,7 @@ interface Props0 {
188189
role?: string;
189190
"aria-selected"?: boolean;
190191
"aria-controls"?: string;
192+
tabIndex?: number;
191193
}
192194
interface PropsPath extends Props0 {
193195
path: string;
@@ -300,6 +302,28 @@ export function FileTab(props: Readonly<Props>) {
300302
}
301303
}
302304

305+
function handleKeyActivation() {
306+
if (actions == null) return;
307+
if (path != null) {
308+
actions.set_active_tab(path_to_tab(path));
309+
track("switch-to-file-tab", {
310+
project_id,
311+
path,
312+
how: "keyboard",
313+
});
314+
} else if (name != null) {
315+
if (flyout != null && FIXED_PROJECT_TABS[flyout].noFullPage) {
316+
// this tab can't be opened in a full page
317+
actions?.toggleFlyout(flyout);
318+
} else if (flyout != null && actBar !== "both") {
319+
// keyboard activation just activates, no modifier key logic
320+
setActiveTab(name);
321+
} else {
322+
setActiveTab(name);
323+
}
324+
}
325+
}
326+
303327
function renderFlyoutCaret() {
304328
if (flyout == null || actBar !== "both") return;
305329

@@ -470,6 +494,12 @@ export function FileTab(props: Readonly<Props>) {
470494
cocalc-test={label}
471495
onClick={click}
472496
onMouseUp={onMouseUp}
497+
onKeyDown={
498+
props.tabIndex != null
499+
? ariaKeyDown(handleKeyActivation, false)
500+
: undefined
501+
}
502+
tabIndex={props.tabIndex}
473503
role={props.role}
474504
aria-selected={props["aria-selected"]}
475505
aria-controls={props["aria-controls"]}

0 commit comments

Comments
 (0)