Room list: fix keyboard navigation (#32585)
* feat: add visual feedback of selection in `VirtualizedList` story * fix: keyboard navigation when focused element is no longer in the dom * fix: selection delay when the list is scrolled * fix: room list item scroll issue Avoid to use margin https://virtuoso.dev/react-virtuoso/#caveats * test: add tests * test(e2e): update snapshots * test: update room list item screenshot
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
@@ -15,10 +15,11 @@
|
|||||||
/* Needed to position the selected marker */
|
/* Needed to position the selected marker */
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-height: 44px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
/* Gap between items, accounted for in ROOM_LIST_ITEM_HEIGHT */
|
/* Gap between items, accounted for in ROOM_LIST_ITEM_HEIGHT */
|
||||||
margin-bottom: var(--cpd-space-2x);
|
padding-bottom: var(--cpd-space-2x);
|
||||||
|
/* 44px height + padding bottom */
|
||||||
|
min-height: calc(44px + var(--cpd-space-2x));
|
||||||
|
|
||||||
font: var(--cpd-font-body-md-regular);
|
font: var(--cpd-font-body-md-regular);
|
||||||
letter-spacing: var(--cpd-font-letter-spacing-body-md);
|
letter-spacing: var(--cpd-font-letter-spacing-body-md);
|
||||||
@@ -92,9 +93,10 @@
|
|||||||
.container::before {
|
.container::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
/* Marker height is 34px, room list item height is 44px. So position is at 5px and top and bottom */
|
/* Marker height is 34px, room list item height is 44px. So position is at 5px at and bottom */
|
||||||
top: 5px;
|
top: 5px;
|
||||||
bottom: 5px;
|
/* Add the padding bottom to center */
|
||||||
|
bottom: calc(5px + var(--cpd-space-2x));
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 4px;
|
width: 4px;
|
||||||
background-color: var(--cpd-color-bg-accent-rest);
|
background-color: var(--cpd-color-bg-accent-rest);
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export interface VirtualizedRoomListViewProps {
|
|||||||
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Height of a single room list item in pixels (44px item + 8px margin bottom) */
|
/** Height of a single room list item in pixels (44px item + 8px padding bottom) */
|
||||||
const ROOM_LIST_ITEM_HEIGHT = 52;
|
const ROOM_LIST_ITEM_HEIGHT = 52;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { VirtualizedList, type IVirtualizedListProps, type VirtualizedListContext } from "./VirtualizedList";
|
import { VirtualizedList, type IVirtualizedListProps, type VirtualizedListContext } from "./VirtualizedList";
|
||||||
|
import styles from "./story-mock.module.css";
|
||||||
|
|
||||||
interface SimpleItem {
|
interface SimpleItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,16 +32,21 @@ const meta = {
|
|||||||
item: SimpleItem,
|
item: SimpleItem,
|
||||||
context: VirtualizedListContext<undefined>,
|
context: VirtualizedListContext<undefined>,
|
||||||
onFocus: (item: SimpleItem, e: React.FocusEvent) => void,
|
onFocus: (item: SimpleItem, e: React.FocusEvent) => void,
|
||||||
) => (
|
) => {
|
||||||
<div
|
const selected = context.tabIndexKey === item.id;
|
||||||
key={item.id}
|
|
||||||
style={{ padding: "12px 16px", borderBottom: "1px solid #e0e0e0" }}
|
return (
|
||||||
tabIndex={context.tabIndexKey === item.id ? 0 : -1}
|
<button
|
||||||
onFocus={(e) => onFocus(item, e)}
|
type="button"
|
||||||
>
|
key={item.id}
|
||||||
{item.label}
|
className={classNames(styles.item, { [styles.itemSelected]: selected })}
|
||||||
</div>
|
tabIndex={selected ? 0 : -1}
|
||||||
),
|
onFocus={(e) => onFocus(item, e)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
isItemFocusable: () => true,
|
isItemFocusable: () => true,
|
||||||
getItemKey: (item) => item.id,
|
getItemKey: (item) => item.id,
|
||||||
style: { height: "400px" },
|
style: { height: "400px" },
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { type PropsWithChildren } from "react";
|
import React, { type PropsWithChildren } from "react";
|
||||||
import { render, screen, fireEvent } from "@test-utils";
|
import { render, screen, fireEvent, waitFor, act } from "@test-utils";
|
||||||
import { VirtuosoMockContext } from "react-virtuoso";
|
import { VirtuosoMockContext } from "react-virtuoso";
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
@@ -492,4 +492,171 @@ describe("VirtualizedList", () => {
|
|||||||
expectAttribute(container, "aria-label", "Custom list label");
|
expectAttribute(container, "aria-label", "Custom list label");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Focus preservation during keyboard navigation", () => {
|
||||||
|
/**
|
||||||
|
* Renders a 50-item list using real Virtuoso (no mock context) inside a
|
||||||
|
* fixed-height container. Because the tests run in real Chromium,
|
||||||
|
* Virtuoso will measure the viewport, virtualise items, and honour
|
||||||
|
* scrollIntoView calls exactly as it does in production.
|
||||||
|
*/
|
||||||
|
const ITEM_HEIGHT = 52;
|
||||||
|
const VIEWPORT_HEIGHT = 400;
|
||||||
|
|
||||||
|
const renderRealVirtualizedList = (): ReturnType<typeof render> => {
|
||||||
|
const largeItems: TestItemWithSeparator[] = Array.from({ length: 50 }, (_, i) => ({
|
||||||
|
id: `item-${i}`,
|
||||||
|
name: `Item ${i}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mockIsItemFocusable.mockReturnValue(true);
|
||||||
|
|
||||||
|
mockGetItemComponent.mockImplementation(
|
||||||
|
(
|
||||||
|
index: number,
|
||||||
|
item: TestItemWithSeparator,
|
||||||
|
context: any,
|
||||||
|
onFocus: (item: TestItemWithSeparator, e: React.FocusEvent) => void,
|
||||||
|
) => {
|
||||||
|
const itemKey = typeof item === "string" ? item : item.id;
|
||||||
|
const isFocused = context.tabIndexKey === itemKey;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mx_item"
|
||||||
|
data-testid={`row-${index}`}
|
||||||
|
tabIndex={isFocused ? 0 : -1}
|
||||||
|
role="gridcell"
|
||||||
|
style={{ height: `${ITEM_HEIGHT}px`, display: "block", width: "100%" }}
|
||||||
|
onFocus={(e) => onFocus(item, e)}
|
||||||
|
>
|
||||||
|
{item === SEPARATOR_ITEM ? "---" : (item as TestItem).name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<VirtualizedList
|
||||||
|
items={largeItems}
|
||||||
|
getItemComponent={mockGetItemComponent}
|
||||||
|
isItemFocusable={mockIsItemFocusable}
|
||||||
|
getItemKey={(item) => (typeof item === "string" ? item : item.id)}
|
||||||
|
role="grid"
|
||||||
|
style={{ height: `${VIEWPORT_HEIGHT}px` }}
|
||||||
|
fixedItemHeight={ITEM_HEIGHT}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should scroll down through many items with ArrowDown and virtualise earlier items out of the DOM", async () => {
|
||||||
|
const { container } = renderRealVirtualizedList();
|
||||||
|
const listContainer = screen.getByRole("grid");
|
||||||
|
|
||||||
|
// Wait for Virtuoso to finish its initial render.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("row-0")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.focus(listContainer);
|
||||||
|
|
||||||
|
const TARGET_INDEX = 20;
|
||||||
|
|
||||||
|
// Press ArrowDown many times — each press calls scrollIntoView which
|
||||||
|
// makes Virtuoso scroll and re-virtualise automatically.
|
||||||
|
for (let i = 0; i < TARGET_INDEX; i++) {
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(listContainer, { code: "ArrowDown" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The focused item should be item-20.
|
||||||
|
await waitFor(() => {
|
||||||
|
const focused = Array.from(container.querySelectorAll(".mx_item")).find(
|
||||||
|
(el) => el.getAttribute("tabindex") === "0",
|
||||||
|
);
|
||||||
|
expect(focused).toBeDefined();
|
||||||
|
expect(focused!.textContent).toBe(`Item ${TARGET_INDEX}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The first item should have been virtualised out of the DOM.
|
||||||
|
expect(container.querySelector("[data-testid='row-0']")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should move focus from a focused child element to the scroller on keyboard navigation", async () => {
|
||||||
|
renderRealVirtualizedList();
|
||||||
|
const listContainer = screen.getByRole("grid");
|
||||||
|
|
||||||
|
// Wait for Virtuoso to finish its initial render.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("row-0")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Directly focus a child button (not the scroller itself).
|
||||||
|
// This simulates a user clicking/tabbing into a button inside the list.
|
||||||
|
const firstButton = screen.getByTestId("row-0");
|
||||||
|
await act(async () => {
|
||||||
|
firstButton.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the child button has DOM focus, not the scroller.
|
||||||
|
expect(document.activeElement).toBe(firstButton);
|
||||||
|
expect(document.activeElement).not.toBe(listContainer);
|
||||||
|
|
||||||
|
// Press ArrowDown — the handler should detect that a child element
|
||||||
|
// has focus and move it to the scroller before scrolling, so that
|
||||||
|
// Virtuoso unmounting the child doesn't send focus to <body>.
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(listContainer, { code: "ArrowDown" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// After the keyDown, focus should have moved to the scroller element
|
||||||
|
// (not remain on the child button, and not escape to <body>).
|
||||||
|
expect(document.activeElement).toBe(listContainer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should scroll up through many items with ArrowUp and virtualise later items out of the DOM", async () => {
|
||||||
|
const { container } = renderRealVirtualizedList();
|
||||||
|
const listContainer = screen.getByRole("grid");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("row-0")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.focus(listContainer);
|
||||||
|
|
||||||
|
// First navigate down to item-30.
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(listContainer, { code: "ArrowDown" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const focused = Array.from(container.querySelectorAll(".mx_item")).find(
|
||||||
|
(el) => el.getAttribute("tabindex") === "0",
|
||||||
|
);
|
||||||
|
expect(focused!.textContent).toBe("Item 30");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now navigate back up 20 times to item-10.
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(listContainer, { code: "ArrowUp" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The focused item should be item-10.
|
||||||
|
await waitFor(() => {
|
||||||
|
const focused = Array.from(container.querySelectorAll(".mx_item")).find(
|
||||||
|
(el) => el.getAttribute("tabindex") === "0",
|
||||||
|
);
|
||||||
|
expect(focused).toBeDefined();
|
||||||
|
expect(focused!.textContent).toBe("Item 10");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Items near the bottom (e.g. item-30) should have been virtualised out.
|
||||||
|
expect(container.querySelector("[data-testid='row-30']")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -149,8 +149,6 @@ export function VirtualizedList<Item, Context>(props: IVirtualizedListProps<Item
|
|||||||
const [visibleRange, setVisibleRange] = useState<ListRange | undefined>(undefined);
|
const [visibleRange, setVisibleRange] = useState<ListRange | undefined>(undefined);
|
||||||
/** Map from item keys to their indices in the items array */
|
/** Map from item keys to their indices in the items array */
|
||||||
const [keyToIndexMap, setKeyToIndexMap] = useState<Map<string, number>>(new Map());
|
const [keyToIndexMap, setKeyToIndexMap] = useState<Map<string, number>>(new Map());
|
||||||
/** Whether the list is currently scrolling to an item */
|
|
||||||
const isScrollingToItem = useRef<boolean>(false);
|
|
||||||
/** Whether the list is currently focused */
|
/** Whether the list is currently focused */
|
||||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||||
|
|
||||||
@@ -173,28 +171,20 @@ export function VirtualizedList<Item, Context>(props: IVirtualizedListProps<Item
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Scrolls to a specific item index and sets it as focused.
|
* Scrolls to a specific item index and sets it as focused.
|
||||||
* Uses Virtuoso's scrollIntoView method for smooth scrolling.
|
* Updates tabIndexKey immediately so the UI reflects the new focus
|
||||||
|
* synchronously, then asks Virtuoso to scroll the item into view.
|
||||||
*/
|
*/
|
||||||
const scrollToIndex = useCallback(
|
const scrollToIndex = useCallback(
|
||||||
(index: number, align?: "center" | "end" | "start"): void => {
|
(index: number, align?: "center" | "end" | "start"): void => {
|
||||||
// Ensure index is within bounds
|
// Ensure index is within bounds
|
||||||
const clampedIndex = Math.max(0, Math.min(index, items.length - 1));
|
const clampedIndex = Math.max(0, Math.min(index, items.length - 1));
|
||||||
if (isScrollingToItem.current) {
|
|
||||||
// If already scrolling to an item drop this request. Adding further requests
|
|
||||||
// causes the event to bubble up and be handled by other components(unintentional timeline scrolling was observed).
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (items[clampedIndex]) {
|
if (items[clampedIndex]) {
|
||||||
const key = getItemKey(items[clampedIndex]);
|
const key = getItemKey(items[clampedIndex]);
|
||||||
isScrollingToItem.current = true;
|
setTabIndexKey(key);
|
||||||
virtuosoHandleRef.current?.scrollIntoView({
|
virtuosoHandleRef.current?.scrollIntoView({
|
||||||
index: clampedIndex,
|
index: clampedIndex,
|
||||||
align: align,
|
align: align,
|
||||||
behavior: "auto",
|
behavior: "auto",
|
||||||
done: () => {
|
|
||||||
setTabIndexKey(key);
|
|
||||||
isScrollingToItem.current = false;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -266,6 +256,17 @@ export function VirtualizedList<Item, Context>(props: IVirtualizedListProps<Item
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (handled) {
|
if (handled) {
|
||||||
|
// If a child element (e.g. a button) currently has DOM focus rather than the
|
||||||
|
// scroller itself, move focus to the scroller before the scroll takes effect.
|
||||||
|
// Without this, when Virtuoso unmounts the focused child because it has been
|
||||||
|
// scrolled out of the visible range, the browser moves focus to <body> and
|
||||||
|
// subsequent keyboard events no longer reach this handler.
|
||||||
|
if (virtuosoDomRef.current instanceof HTMLElement) {
|
||||||
|
const activeEl = document.activeElement;
|
||||||
|
if (activeEl && activeEl !== virtuosoDomRef.current && virtuosoDomRef.current.contains(activeEl)) {
|
||||||
|
virtuosoDomRef.current.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.item {
|
||||||
|
all: unset;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemSelected {
|
||||||
|
background-color: #559f24;
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |