Room list: fix keyboard navigation on sections (#33809)

* fix: keyboard navigation on sections

* test: add e2e test for keyboard navigation

* fix: add more keyboard navigation

* test: add e2e tests

* chore: remove useless check
This commit is contained in:
Florian Duros
2026-06-11 16:14:36 +00:00
committed by GitHub
parent e441ca5d7c
commit 36a39f7fd6
6 changed files with 311 additions and 12 deletions
@@ -290,4 +290,131 @@ test.describe("Room list sections", () => {
await expect(roomList.getByRole("row", { name: "no unread room" })).not.toBeVisible(); await expect(roomList.getByRole("row", { name: "no unread room" })).not.toBeVisible();
}); });
}); });
test.describe("Section keyboard navigation", () => {
test.beforeEach(async ({ app }) => {
// A favourite room forces section mode and gives us a non-trivial first section.
const favouriteId = await app.client.createRoom({ name: "favourite room" });
await app.client.evaluate(async (client, roomId) => {
await client.setRoomTag(roomId, "m.favourite");
}, favouriteId);
// A chats-section room so we have a second section to navigate to.
await app.client.createRoom({ name: "chat room" });
});
test("Arrow Down/Up move focus through sections and rooms", async ({ page }) => {
const roomList = getRoomList(page);
const favouritesHeader = getSectionHeader(page, "Favourites");
// In treegrid mode, a room renders as <div role="row"><div role="gridcell"><button …></button></div></div>.
// Only the inner <button> is focusable, so target it by role for focus assertions.
const favRoomButton = roomList.getByRole("button", { name: "Open room favourite room" });
const chatsHeader = getSectionHeader(page, "Chats");
await expect(favouritesHeader).toBeVisible();
await expect(favRoomButton).toBeVisible();
await expect(chatsHeader).toBeVisible();
await favouritesHeader.focus();
await expect(favouritesHeader).toBeFocused();
// Down moves into the favourite section's room.
await page.keyboard.press("ArrowDown");
await expect(favRoomButton).toBeFocused();
// Down again jumps to the next section header.
await page.keyboard.press("ArrowDown");
await expect(chatsHeader).toBeFocused();
// Up reverses the traversal.
await page.keyboard.press("ArrowUp");
await expect(favRoomButton).toBeFocused();
await page.keyboard.press("ArrowUp");
await expect(favouritesHeader).toBeFocused();
});
test("Arrow Right expands a collapsed section", async ({ page }) => {
const favouritesHeader = getSectionHeader(page, "Favourites");
const favRoom = getRoomList(page).getByRole("row", { name: "Open room favourite room" });
// Collapse the section via click so we know we start expanded=false.
await favouritesHeader.click();
await expect(favouritesHeader).toHaveAttribute("aria-expanded", "false");
await expect(favRoom).not.toBeVisible();
await favouritesHeader.focus();
await page.keyboard.press("ArrowRight");
await expect(favouritesHeader).toHaveAttribute("aria-expanded", "true");
await expect(favRoom).toBeVisible();
});
test("Arrow Right is a no-op on an already-expanded section", async ({ page }) => {
const favouritesHeader = getSectionHeader(page, "Favourites");
await expect(favouritesHeader).toHaveAttribute("aria-expanded", "true");
await favouritesHeader.focus();
await page.keyboard.press("ArrowRight");
await expect(favouritesHeader).toHaveAttribute("aria-expanded", "true");
});
test("Arrow Left collapses an expanded section", async ({ page }) => {
const favouritesHeader = getSectionHeader(page, "Favourites");
const favRoom = getRoomList(page).getByRole("row", { name: "Open room favourite room" });
await expect(favouritesHeader).toHaveAttribute("aria-expanded", "true");
await expect(favRoom).toBeVisible();
await favouritesHeader.focus();
await page.keyboard.press("ArrowLeft");
await expect(favouritesHeader).toHaveAttribute("aria-expanded", "false");
await expect(favRoom).not.toBeVisible();
});
test("Arrow Left is a no-op on an already-collapsed section", async ({ page }) => {
const favouritesHeader = getSectionHeader(page, "Favourites");
await favouritesHeader.click();
await expect(favouritesHeader).toHaveAttribute("aria-expanded", "false");
await favouritesHeader.focus();
await page.keyboard.press("ArrowLeft");
await expect(favouritesHeader).toHaveAttribute("aria-expanded", "false");
});
test("Arrow Right on an expanded section with rooms moves focus to its first room", async ({ page }) => {
const favouritesHeader = getSectionHeader(page, "Favourites");
const favRoomButton = getRoomList(page).getByRole("button", { name: "Open room favourite room" });
await expect(favouritesHeader).toHaveAttribute("aria-expanded", "true");
await favouritesHeader.focus();
await expect(favouritesHeader).toBeFocused();
await page.keyboard.press("ArrowRight");
// Focus must move to the first room in the section, not the next section header.
await expect(favRoomButton).toBeFocused();
// The section should remain expanded.
await expect(favouritesHeader).toHaveAttribute("aria-expanded", "true");
});
test("Arrow Left on the first room of a section moves focus back to the section header", async ({ page }) => {
const favouritesHeader = getSectionHeader(page, "Favourites");
const favRoomButton = getRoomList(page).getByRole("button", { name: "Open room favourite room" });
await expect(favouritesHeader).toHaveAttribute("aria-expanded", "true");
await favRoomButton.focus();
await expect(favRoomButton).toBeFocused();
await page.keyboard.press("ArrowLeft");
await expect(favouritesHeader).toBeFocused();
});
});
}); });
@@ -0,0 +1,61 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect, vi } from "vitest";
import * as stories from "./RoomListItemWrapper.stories";
const { Sections } = composeStories(stories);
describe("<RoomListItemWrapper /> keyboard re-dispatch", () => {
it("ArrowLeft on the first room of a section re-dispatches as ArrowUp", async () => {
const user = userEvent.setup();
const onKeyDown = vi.fn();
render(
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div onKeyDown={onKeyDown}>
<Sections roomIndexInSection={0} isFocused={true} />
</div>,
);
// Make sure the room's button is focused before sending the key event.
const button = screen.getByRole("button", { name: /Room name/ });
button.focus();
await user.keyboard("{ArrowLeft}");
const arrowUpEvents = onKeyDown.mock.calls.filter(([event]) => event.code === "ArrowUp");
expect(arrowUpEvents).toHaveLength(1);
});
it("ArrowLeft on a non-first room of a section does NOT re-dispatch", async () => {
const user = userEvent.setup();
const onKeyDown = vi.fn();
render(
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div onKeyDown={onKeyDown}>
<Sections roomIndexInSection={2} isFocused={true} />
</div>,
);
const button = screen.getByRole("button", { name: /Room name/ });
button.focus();
await user.keyboard("{ArrowLeft}");
const arrowUpEvents = onKeyDown.mock.calls.filter(([event]) => event.code === "ArrowUp");
expect(arrowUpEvents).toHaveLength(0);
// The original ArrowLeft event should still bubble (the handler is a no-op for non-first items).
const arrowLeftEvents = onKeyDown.mock.calls.filter(([event]) => event.code === "ArrowLeft");
expect(arrowLeftEvents.length).toBeGreaterThan(0);
});
});
@@ -41,10 +41,27 @@ export const RoomListItemWrapper = memo(function RoomListItemWrapper({
return <RoomListItemView {...rest} {...getItemAccessibleProps("listbox", roomIndex, roomCount)} />; return <RoomListItemView {...rest} {...getItemAccessibleProps("listbox", roomIndex, roomCount)} />;
} }
const isFirstInSection = roomIndexInSection === 0;
return ( return (
<div {...getItemAccessibleProps("treegrid", roomIndex, roomIndexInSection)}> <div {...getItemAccessibleProps("treegrid", roomIndex, roomIndexInSection)}>
<div role="gridcell" aria-selected={rest.isSelected}> <div role="gridcell" aria-selected={rest.isSelected}>
<DraggableWrapper {...rest} /> <DraggableWrapper
{...rest}
onKeyDown={(e) => {
if (e.code === "ArrowLeft" && isFirstInSection) {
// Move focus to the section header
e.preventDefault();
e.stopPropagation();
e.currentTarget.dispatchEvent(
new KeyboardEvent("keydown", {
code: "ArrowUp",
key: "ArrowUp",
bubbles: true,
}),
);
}
}}
/>
</div> </div>
</div> </div>
); );
@@ -17,7 +17,6 @@
letter-spacing: var(--cpd-font-letter-spacing-body-sm); letter-spacing: var(--cpd-font-letter-spacing-body-sm);
color: var(--cpd-color-text-secondary); color: var(--cpd-color-text-secondary);
padding: var(--cpd-space-1x) 0; padding: var(--cpd-space-1x) 0;
background-color: var(--cpd-color-bg-canvas-default);
&:hover, &:hover,
&:focus-visible, &:focus-visible,
@@ -82,6 +81,12 @@
.firstHeader { .firstHeader {
padding-top: 0; padding-top: 0;
/* The first item sits flush with the scroller's top edge, so the default outline
would be clipped by overflow:auto. Inset it so it stays inside the button. */
&:focus-visible {
outline-offset: -2px;
}
} }
.lastHeader { .lastHeader {
@@ -6,16 +6,24 @@
*/ */
import React from "react"; import React from "react";
import { render } from "@test-utils"; import { render, screen } from "@test-utils";
import { composeStories } from "@storybook/react-vite"; import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest"; import { describe, it, expect, type Mock, afterEach, vi } from "vitest";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import * as stories from "./RoomListSectionHeaderView.stories"; import * as stories from "./RoomListSectionHeaderView.stories";
const { Default } = composeStories(stories); const { Default, Collapsed } = composeStories(stories);
const HEADER_NAME = "Toggle Favourites section";
describe("<RoomListSectionHeaderView /> stories", () => { describe("<RoomListSectionHeaderView /> stories", () => {
afterEach(() => {
// Storybook's fn() mocks aren't reset by vi.clearAllMocks; clear them by hand.
(Default.args.onClick as Mock).mockClear();
(Collapsed.args.onClick as Mock).mockClear();
});
it("renders Default story", () => { it("renders Default story", () => {
const { container } = render(<Default />); const { container } = render(<Default />);
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
@@ -24,9 +32,63 @@ describe("<RoomListSectionHeaderView /> stories", () => {
it("should call onClick when the header is clicked", async () => { it("should call onClick when the header is clicked", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { getByRole } = render(<Default />); render(<Default />);
const button = getByRole("gridcell", { name: "Toggle Favourites section" }); const button = screen.getByRole("gridcell", { name: HEADER_NAME });
await user.click(button); await user.click(button);
expect(Default.args.onClick).toHaveBeenCalled(); expect(Default.args.onClick).toHaveBeenCalled();
}); });
it("focuses the button when isFocused is true", () => {
render(<Default isFocused={true} />);
const button = screen.getByRole("gridcell", { name: HEADER_NAME });
expect(document.activeElement).toBe(button);
});
it("expands a collapsed section on ArrowRight", async () => {
const user = userEvent.setup();
render(<Collapsed isFocused={true} />);
await user.keyboard("{ArrowRight}");
expect(Collapsed.args.onClick).toHaveBeenCalledTimes(1);
});
it("does not call onClick on ArrowRight when already expanded", async () => {
const user = userEvent.setup();
render(<Default isFocused={true} />);
await user.keyboard("{ArrowRight}");
expect(Default.args.onClick).not.toHaveBeenCalled();
});
it("collapses an expanded section on ArrowLeft", async () => {
const user = userEvent.setup();
render(<Default isFocused={true} />);
await user.keyboard("{ArrowLeft}");
expect(Default.args.onClick).toHaveBeenCalledTimes(1);
});
it("does not call onClick on ArrowLeft when already collapsed", async () => {
const user = userEvent.setup();
render(<Collapsed isFocused={true} />);
await user.keyboard("{ArrowLeft}");
expect(Collapsed.args.onClick).not.toHaveBeenCalled();
});
it("ArrowRight on an expanded section re-dispatches as ArrowDown", async () => {
const user = userEvent.setup();
const onKeyDown = vi.fn();
render(
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div onKeyDown={onKeyDown}>
<Default isFocused={true} />
</div>,
);
await user.keyboard("{ArrowRight}");
// The re-dispatched ArrowDown should bubble up to the parent listener.
const arrowDownEvents = onKeyDown.mock.calls.filter(([event]) => event.code === "ArrowDown");
expect(arrowDownEvents).toHaveLength(1);
// The original ArrowRight handler called preventDefault/stopPropagation, so
// it should not have called onClick (which is reserved for the toggle branch).
expect(Default.args.onClick).not.toHaveBeenCalled();
});
}); });
@@ -5,12 +5,13 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import React, { memo, type JSX, type FocusEvent, type MouseEventHandler, useState } from "react"; import React, { memo, type JSX, type FocusEvent, useEffect, useRef, useState } from "react";
import ChevronRightIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-right"; import ChevronRightIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-right";
import classNames from "classnames"; import classNames from "classnames";
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web"; import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
import { OverflowHorizontalIcon, EditIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { OverflowHorizontalIcon, EditIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useDroppable } from "@dnd-kit/react"; import { useDroppable } from "@dnd-kit/react";
import { useMergeRefs } from "react-merge-refs";
import { useViewModel, type ViewModel } from "../../../core/viewmodel"; import { useViewModel, type ViewModel } from "../../../core/viewmodel";
import styles from "./RoomListSectionHeaderView.module.css"; import styles from "./RoomListSectionHeaderView.module.css";
@@ -39,8 +40,8 @@ export interface RoomListSectionHeaderViewSnapshot {
* Actions that can be performed on a room list section header. * Actions that can be performed on a room list section header.
*/ */
export interface RoomListSectionHeaderActions { export interface RoomListSectionHeaderActions {
/** Handler invoked when the section header is clicked (toggles expand/collapse). */ /** Handler invoked when the section header is clicked or keyboard-toggled (toggles expand/collapse). */
onClick: MouseEventHandler<HTMLButtonElement>; onClick: () => void;
/** Handler invoked when the edit section button is clicked */ /** Handler invoked when the edit section button is clicked */
editSection: () => void; editSection: () => void;
/** Handler invoked when the remove section button is clicked */ /** Handler invoked when the remove section button is clicked */
@@ -104,9 +105,17 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
const { id, title, isExpanded, isUnread, displaySectionMenu } = useViewModel(vm); const { id, title, isExpanded, isUnread, displaySectionMenu } = useViewModel(vm);
const isLastSection = sectionIndex === sectionCount - 1; const isLastSection = sectionIndex === sectionCount - 1;
const { ref, isDropTarget } = useDroppable({ const { ref: droppableRef, isDropTarget } = useDroppable({
id, id,
}); });
const internalRef = useRef<HTMLButtonElement>(null);
const mergedRef = useMergeRefs<HTMLButtonElement>([droppableRef, internalRef]);
useEffect(() => {
if (isFocused) {
internalRef.current?.focus({ preventScroll: true });
}
}, [isFocused]);
return ( return (
<div <div
@@ -114,7 +123,7 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
{...getGroupHeaderAccessibleProps(indexInList, sectionIndex, roomCountInSection)} {...getGroupHeaderAccessibleProps(indexInList, sectionIndex, roomCountInSection)}
> >
<button <button
ref={ref} ref={mergedRef}
type="button" type="button"
role="gridcell" role="gridcell"
className={classNames(styles.header, { className={classNames(styles.header, {
@@ -124,6 +133,24 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
[styles.unread]: isUnread, [styles.unread]: isUnread,
})} })}
onClick={vm.onClick} onClick={vm.onClick}
onKeyDown={(e) => {
if ((e.code === "ArrowRight" && !isExpanded) || (e.code === "ArrowLeft" && isExpanded)) {
e.preventDefault();
e.stopPropagation();
vm.onClick();
} else if (e.code === "ArrowRight" && isExpanded && roomCountInSection > 0) {
// Move focus to the first room in the section
e.preventDefault();
e.stopPropagation();
e.currentTarget.dispatchEvent(
new KeyboardEvent("keydown", {
code: "ArrowDown",
key: "ArrowDown",
bubbles: true,
}),
);
}
}}
aria-expanded={isExpanded} aria-expanded={isExpanded}
onFocus={(e) => onFocus(id, e)} onFocus={(e) => onFocus(id, e)}
tabIndex={isFocused ? 0 : -1} tabIndex={isFocused ? 0 : -1}