Migrate ListView to shared components (#31860)

* Migrate ListView to shared components

* Add stories

* lint

* Update name of component

* Use compound spacing

* lint

* VirtualizedList

* Simplify story

* Add git diff check before uploading artifacts

* Fix git diff workaround for vis

* Ignore coverage report in .gitignore

Add coverage report to .gitignore

* Add screenshot test

* Fix package and lock files

* clear unneeded lock file changes

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
David Langley
2026-01-26 17:58:46 +00:00
committed by GitHub
parent b720a74eef
commit d6f11d828b
17 changed files with 245 additions and 113 deletions

View File

@@ -5,3 +5,6 @@
/__vis__/**/__diffs__
/__vis__/**/__results__
/__vis__/local
# Ignore coverage report
/coverage/

View File

@@ -59,6 +59,7 @@
"lodash": "^4.17.21",
"matrix-web-i18n": "3.6.0",
"react-merge-refs": "^3.0.2",
"react-virtuoso": "^4.14.0",
"temporal-polyfill": "^0.3.0"
},
"devDependencies": {

View File

@@ -24,6 +24,7 @@ export * from "./room-list/RoomListHeaderView";
export * from "./room-list/RoomListSearchView";
export * from "./utils/Box";
export * from "./utils/Flex";
export * from "./utils/VirtualizedList";
// Utils
export * from "./utils/i18n";

View File

@@ -0,0 +1,52 @@
/*
Copyright 2026 New Vector 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 type { Meta, StoryObj } from "@storybook/react-vite";
import { VirtualizedList, type IVirtualizedListProps, type VirtualizedListContext } from "./VirtualizedList";
interface SimpleItem {
id: string;
label: string;
}
const items: SimpleItem[] = Array.from({ length: 50 }, (_, i) => ({
id: `item-${i}`,
label: `Item ${i + 1}`,
}));
const meta = {
title: "Utils/VirtualizedList",
component: VirtualizedList<SimpleItem, undefined>,
args: {
items,
getItemComponent: (
_index: number,
item: SimpleItem,
context: VirtualizedListContext<undefined>,
onFocus: (item: SimpleItem, e: React.FocusEvent) => void,
) => (
<div
key={item.id}
style={{ padding: "12px 16px", borderBottom: "1px solid #e0e0e0" }}
tabIndex={context.tabIndexKey === item.id ? 0 : -1}
onFocus={(e) => onFocus(item, e)}
>
{item.label}
</div>
),
isItemFocusable: () => true,
getItemKey: (item) => item.id,
style: { height: "400px" },
},
} satisfies Meta<IVirtualizedListProps<SimpleItem, undefined>>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};

View File

@@ -0,0 +1,495 @@
/*
* 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, { type PropsWithChildren } from "react";
import { render, screen, fireEvent } from "@test-utils";
import { VirtuosoMockContext } from "react-virtuoso";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { VirtualizedList, type IVirtualizedListProps } from "./VirtualizedList";
const expectTabIndex = (element: Element, expected: string): void => {
expect(element.getAttribute("tabindex")).toBe(expected);
};
const expectAttribute = (element: Element, attr: string, expected: string): void => {
expect(element.getAttribute(attr)).toBe(expected);
};
interface TestItem {
id: string;
name: string;
isFocusable?: boolean;
}
const SEPARATOR_ITEM = "SEPARATOR" as const;
type TestItemWithSeparator = TestItem | typeof SEPARATOR_ITEM;
describe("VirtualizedList", () => {
const mockGetItemComponent = vi.fn();
const mockIsItemFocusable = vi.fn();
const defaultItems: TestItemWithSeparator[] = [
{ id: "1", name: "Item 1" },
SEPARATOR_ITEM,
{ id: "2", name: "Item 2" },
{ id: "3", name: "Item 3" },
];
const defaultProps: IVirtualizedListProps<TestItemWithSeparator, any> = {
items: defaultItems,
getItemComponent: mockGetItemComponent,
isItemFocusable: mockIsItemFocusable,
getItemKey: (item) => (typeof item === "string" ? item : item.id),
};
const getListComponent = (
props: Partial<IVirtualizedListProps<TestItemWithSeparator, any>> = {},
): React.JSX.Element => {
const mergedProps = { ...defaultProps, ...props };
return <VirtualizedList {...mergedProps} role="grid" aria-rowcount={props.items?.length} aria-colcount={1} />;
};
const renderListWithHeight = (
props: Partial<IVirtualizedListProps<TestItemWithSeparator, any>> = {},
): ReturnType<typeof render> => {
const mergedProps = { ...defaultProps, ...props };
return render(getListComponent(mergedProps), {
wrapper: ({ children }: PropsWithChildren) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 400, itemHeight: 56 }}>
<>{children}</>
</VirtuosoMockContext.Provider>
),
});
};
beforeEach(() => {
vi.clearAllMocks();
mockGetItemComponent.mockImplementation((index: number, item: TestItemWithSeparator, context: any) => {
const itemKey = typeof item === "string" ? item : item.id;
const isFocused = context.tabIndexKey === itemKey;
return (
<div className="mx_item" data-testid={`row-${index}`} tabIndex={isFocused ? 0 : -1} role="gridcell">
{item === SEPARATOR_ITEM ? "---" : (item as TestItem).name}
</div>
);
});
mockIsItemFocusable.mockImplementation((item: TestItemWithSeparator) => item !== SEPARATOR_ITEM);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Rendering", () => {
it("should render the VirtualizedList component", () => {
renderListWithHeight();
expect(screen.getByRole("grid")).toBeDefined();
});
it("should render with empty items array", () => {
renderListWithHeight({ items: [] });
expect(screen.getByRole("grid")).toBeDefined();
});
});
describe("Keyboard Navigation", () => {
it("should handle ArrowDown key navigation", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
// ArrowDown should skip the non-focusable item at index 1 and go to index 2
const items = container.querySelectorAll(".mx_item");
expectTabIndex(items[2], "0");
expectTabIndex(items[0], "-1");
expectTabIndex(items[1], "-1");
});
it("should handle ArrowUp key navigation", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// First focus and navigate down to second item
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
// Then navigate back up
fireEvent.keyDown(container, { code: "ArrowUp" });
// Verify focus moved back to first item
const items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0");
expectTabIndex(items[1], "-1");
});
it("should handle Home key navigation", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// First focus and navigate to a later item
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
fireEvent.keyDown(container, { code: "ArrowDown" });
// Then press Home to go to first item
fireEvent.keyDown(container, { code: "Home" });
// Verify focus moved to first item
const items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0");
// Check that other items are not focused
for (let i = 1; i < items.length; i++) {
expectTabIndex(items[i], "-1");
}
});
it("should handle End key navigation", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// First focus on the list (starts at first item)
fireEvent.focus(container);
// Then press End to go to last item
fireEvent.keyDown(container, { code: "End" });
// Verify focus moved to last visible item
const items = container.querySelectorAll(".mx_item");
// Should focus on the last visible item
const lastIndex = items.length - 1;
expectTabIndex(items[lastIndex], "0");
// Check that other items are not focused
for (let i = 0; i < lastIndex; i++) {
expectTabIndex(items[i], "-1");
}
});
it("should handle PageDown key navigation", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// First focus on the list (starts at first item)
fireEvent.focus(container);
// Then press PageDown to jump down by viewport size
fireEvent.keyDown(container, { code: "PageDown" });
// Verify focus moved down
const items = container.querySelectorAll(".mx_item");
// PageDown should move to the last visible item since we only have 4 items
const lastIndex = items.length - 1;
expectTabIndex(items[lastIndex], "0");
expectTabIndex(items[0], "-1");
});
it("should handle PageUp key navigation", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// First focus and navigate to last item to have something to page up from
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "End" });
// Then press PageUp to jump up by viewport size
fireEvent.keyDown(container, { code: "PageUp" });
// Verify focus moved up
const items = container.querySelectorAll(".mx_item");
// PageUp should move back to the first item since we only have 4 items
expectTabIndex(items[0], "0");
const lastIndex = items.length - 1;
expectTabIndex(items[lastIndex], "-1");
});
it("should not handle keyboard navigation when modifier keys are pressed", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
fireEvent.focus(container);
// Store initial state - first item should be focused
const initialItems = container.querySelectorAll(".mx_item");
expectTabIndex(initialItems[0], "0");
expectTabIndex(initialItems[2], "-1");
// Test ArrowDown with Ctrl modifier - should NOT navigate
fireEvent.keyDown(container, { code: "ArrowDown", ctrlKey: true });
let items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0"); // Should still be on first item
expectTabIndex(items[2], "-1"); // Should not have moved to third item
// Test ArrowDown with Alt modifier - should NOT navigate
fireEvent.keyDown(container, { code: "ArrowDown", altKey: true });
items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0"); // Should still be on first item
expectTabIndex(items[2], "-1"); // Should not have moved to third item
// Test ArrowDown with Shift modifier - should NOT navigate
fireEvent.keyDown(container, { code: "ArrowDown", shiftKey: true });
items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0"); // Should still be on first item
expectTabIndex(items[2], "-1"); // Should not have moved to third item
// Test ArrowDown with Meta/Cmd modifier - should NOT navigate
fireEvent.keyDown(container, { code: "ArrowDown", metaKey: true });
items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0"); // Should still be on first item
expectTabIndex(items[2], "-1"); // Should not have moved to third item
// Test normal ArrowDown without modifiers - SHOULD navigate
fireEvent.keyDown(container, { code: "ArrowDown" });
items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "-1"); // Should have moved from first item
expectTabIndex(items[2], "0"); // Should have moved to third item (skipping separator)
});
it("should skip non-focusable items when navigating down", async () => {
// Create items where every other item is not focusable
const mixedItems = [
{ id: "1", name: "Item 1", isFocusable: true },
{ id: "2", name: "Item 2", isFocusable: false },
{ id: "3", name: "Item 3", isFocusable: true },
SEPARATOR_ITEM,
{ id: "4", name: "Item 4", isFocusable: true },
];
mockIsItemFocusable.mockImplementation((item: TestItemWithSeparator) => {
if (item === SEPARATOR_ITEM) return false;
return (item as TestItem).isFocusable !== false;
});
renderListWithHeight({ items: mixedItems });
const container = screen.getByRole("grid");
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
// Verify it skipped the non-focusable item at index 1
// and went directly to the focusable item at index 2
const items = container.querySelectorAll(".mx_item");
expectTabIndex(items[2], "0"); // Item 3 is focused
expectTabIndex(items[0], "-1"); // Item 1 is not focused
expectTabIndex(items[1], "-1"); // Item 2 (non-focusable) is not focused
});
it("should skip non-focusable items when navigating up", () => {
const mixedItems = [
{ id: "1", name: "Item 1", isFocusable: true },
SEPARATOR_ITEM,
{ id: "2", name: "Item 2", isFocusable: false },
{ id: "3", name: "Item 3", isFocusable: true },
];
mockIsItemFocusable.mockImplementation((item: TestItemWithSeparator) => {
if (item === SEPARATOR_ITEM) return false;
return (item as TestItem).isFocusable !== false;
});
renderListWithHeight({ items: mixedItems });
const container = screen.getByRole("grid");
// Focus and go to last item first, then navigate up
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "End" });
fireEvent.keyDown(container, { code: "ArrowUp" });
// Verify it skipped non-focusable items
// and went to the first focusable item
const items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0"); // Item 1 is focused
expectTabIndex(items[3], "-1"); // Item 3 is not focused anymore
});
});
describe("Focus Management", () => {
it("should focus first item when list gains focus for the first time", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// Initial focus should go to first item
fireEvent.focus(container);
// Verify first item gets focus
const items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0");
// Other items should not be focused
for (let i = 1; i < items.length; i++) {
expectTabIndex(items[i], "-1");
}
});
it("should restore last focused item when regaining focus", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// Focus and navigate to simulate previous usage
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
// Verify item 2 is focused
let items = container.querySelectorAll(".mx_item");
expectTabIndex(items[2], "0"); // ArrowDown skips to item 2
// Simulate blur by focusing elsewhere
fireEvent.blur(container);
// Regain focus should restore last position
fireEvent.focus(container);
// Verify focus is restored to the previously focused item
items = container.querySelectorAll(".mx_item");
expectTabIndex(items[2], "0"); // Should still be item 2
});
it("should not interfere with focus if item is already focused", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// Focus once
fireEvent.focus(container);
// Focus again when already focused
fireEvent.focus(container);
expect(container).toBeDefined();
});
it("should not scroll to top when clicking an item after manual scroll", () => {
// Create a larger list to enable meaningful scrolling
const largerItems = Array.from({ length: 50 }, (_, i) => ({
id: `item-${i}`,
name: `Item ${i}`,
}));
const mockOnClick = vi.fn();
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 (
<div
className="mx_item"
data-testid={`row-${index}`}
tabIndex={isFocused ? 0 : -1}
role="button"
onClick={() => mockOnClick(item)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
mockOnClick(item);
}
}}
onFocus={(e) => onFocus(item, e)}
>
{item === SEPARATOR_ITEM ? "---" : (item as TestItem).name}
</div>
);
},
);
const { container } = renderListWithHeight({ items: largerItems });
const listContainer = screen.getByRole("grid");
// Step 1: Focus the list initially (this sets tabIndexKey to first item: "item-0")
fireEvent.focus(listContainer);
// Verify first item is focused initially and tabIndexKey is set to first item
let items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0");
expectAttribute(items[0], "data-testid", "row-0");
// Step 2: Simulate manual scrolling (mouse wheel, scroll bar drag, etc.)
// This changes which items are visible but DOES NOT change tabIndexKey
// tabIndexKey should still point to "item-0" but "item-0" is no longer visible
fireEvent.scroll(listContainer, { target: { scrollTop: 300 } });
// Step 3: After scrolling, different items should now be visible
// but tabIndexKey should still point to "item-0" (which is no longer visible)
items = container.querySelectorAll(".mx_item");
// Verify that item-0 is no longer in the DOM (because it's scrolled out of view)
const item0 = container.querySelector("[data-testid='row-0']");
expect(item0).toBeNull();
// Find a visible item to click on (should be items from further down the list)
const visibleItems = container.querySelectorAll(".mx_item");
expect(visibleItems.length).toBeGreaterThan(0);
const clickTargetItem = visibleItems[0]; // Click on the first visible item
// Click on the visible item
fireEvent.click(clickTargetItem);
// The click should trigger the onFocus callback, which updates the tabIndexKey
// This simulates the real user interaction where clicking an item focuses it
fireEvent.focus(clickTargetItem);
// Verify the click was handled
expect(mockOnClick).toHaveBeenCalled();
// With the fix applied: the clicked item should become focused (tabindex="0")
// This validates that the fix prevents unwanted scrolling back to the top
expectTabIndex(clickTargetItem, "0");
// The key validation: ensure we haven't scrolled back to the top
// item-0 should still not be visible (if the fix is working)
const item0AfterClick = container.querySelector("[data-testid='row-0']");
expect(item0AfterClick).toBeNull();
});
});
describe("Accessibility", () => {
it("should set correct ARIA attributes", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
expectAttribute(container, "role", "grid");
expectAttribute(container, "aria-rowcount", "4");
expectAttribute(container, "aria-colcount", "1");
});
it("should update aria-rowcount when items change", () => {
const { rerender } = renderListWithHeight();
let container = screen.getByRole("grid");
expectAttribute(container, "aria-rowcount", "4");
// Update with fewer items
const fewerItems = [
{ id: "1", name: "Item 1" },
{ id: "2", name: "Item 2" },
];
rerender(
getListComponent({
...defaultProps,
items: fewerItems,
}),
);
container = screen.getByRole("grid");
expectAttribute(container, "aria-rowcount", "2");
});
it("should handle custom ARIA label", () => {
renderListWithHeight({ "aria-label": "Custom list label" });
const container = screen.getByRole("grid");
expectAttribute(container, "aria-label", "Custom list label");
});
});
});

View File

@@ -0,0 +1,347 @@
/*
Copyright 2025 New Vector 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, { useRef, type JSX, useCallback, useEffect, useState, useMemo } from "react";
import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso";
/**
* Keyboard key codes
*/
export const Key = {
ARROW_UP: "ArrowUp",
ARROW_DOWN: "ArrowDown",
HOME: "Home",
END: "End",
PAGE_UP: "PageUp",
PAGE_DOWN: "PageDown",
ENTER: "Enter",
SPACE: "Space",
} as const;
/**
* Check if a keyboard event includes modifier keys
*/
export function isModifiedKeyEvent(event: React.KeyboardEvent): boolean {
return event.ctrlKey || event.metaKey || event.shiftKey || event.altKey;
}
/**
* Context object passed to each list item containing the currently focused key
* and any additional context data from the parent component.
*/
export type VirtualizedListContext<Context> = {
/** The key of item that should have tabIndex == 0 */
tabIndexKey?: string;
/** Whether an item in the list is currently focused */
focused: boolean;
/** Additional context data passed from the parent component */
context: Context;
};
export interface IVirtualizedListProps<Item, Context> extends Omit<
VirtuosoProps<Item, VirtualizedListContext<Context>>,
"data" | "itemContent" | "context"
> {
/**
* The array of items to display in the virtualized list.
* Each item will be passed to getItemComponent for rendering.
*/
items: Item[];
/**
* Function that renders each list item as a JSX element.
* @param index - The index of the item in the list
* @param item - The data item to render
* @param context - The context object containing the focused key and any additional data
* @param onFocus - A callback that is required to be called when the item component receives focus
* @returns JSX element representing the rendered item
*/
getItemComponent: (
index: number,
item: Item,
context: VirtualizedListContext<Context>,
onFocus: (item: Item, e: React.FocusEvent) => void,
) => JSX.Element;
/**
* Optional additional context data to pass to each rendered item.
* This will be available in the VirtualizedListContext passed to getItemComponent.
*/
context?: Context;
/**
* Function to determine if an item can receive focus during keyboard navigation.
* @param item - The item to check for focusability
* @returns true if the item can be focused, false otherwise
*/
isItemFocusable: (item: Item) => boolean;
/**
* Function to get the key to use for focusing an item.
* @param item - The item to get the key for
* @return The key to use for focusing the item
*/
getItemKey: (item: Item) => string;
/**
* Callback function to handle key down events on the list container.
* List handles keyboard navigation for focus(up, down, home, end, pageUp, pageDown)
* and stops propagation otherwise the event bubbles and this callback is called for the use of the parent.
* @param e - The keyboard event
* @returns
*/
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
}
/**
* Utility type for the prop scrollIntoViewOnChange allowing it to be memoised by a caller without repeating types
*/
export type ScrollIntoViewOnChange<Item, Context = any> = NonNullable<
VirtuosoProps<Item, VirtualizedListContext<Context>>["scrollIntoViewOnChange"]
>;
/**
* A generic virtualized list component built on top of react-virtuoso.
* Provides keyboard navigation and virtualized rendering for performance with large lists.
*
* @template Item - The type of data items in the list
* @template Context - The type of additional context data passed to items
*/
export function VirtualizedList<Item, Context = any>(props: IVirtualizedListProps<Item, Context>): React.ReactElement {
// Extract our custom props to avoid conflicts with Virtuoso props
const { items, getItemComponent, isItemFocusable, getItemKey, context, onKeyDown, ...virtuosoProps } = props;
/** Reference to the Virtuoso component for programmatic scrolling */
const virtuosoHandleRef = useRef<VirtuosoHandle>(null);
/** Reference to the DOM element containing the virtualized list */
const virtuosoDomRef = useRef<HTMLElement | Window>(null);
/** Key of the item that should have tabIndex == 0 */
const [tabIndexKey, setTabIndexKey] = useState<string | undefined>(
props.items[0] ? getItemKey(props.items[0]) : undefined,
);
/** Range of currently visible items in the viewport */
const [visibleRange, setVisibleRange] = useState<ListRange | undefined>(undefined);
/** Map from item keys to their indices in the items array */
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 */
const [isFocused, setIsFocused] = useState<boolean>(false);
// Update the key-to-index mapping whenever items change
useEffect(() => {
const newKeyToIndexMap = new Map<string, number>();
items.forEach((item, index) => {
const key = getItemKey(item);
newKeyToIndexMap.set(key, index);
});
setKeyToIndexMap(newKeyToIndexMap);
}, [items, getItemKey]);
// Ensure the tabIndexKey is set if there is none already or if the existing key is no longer displayed
useEffect(() => {
if (items.length && (!tabIndexKey || keyToIndexMap.get(tabIndexKey) === undefined)) {
setTabIndexKey(getItemKey(items[0]));
}
}, [items, getItemKey, tabIndexKey, keyToIndexMap]);
/**
* Scrolls to a specific item index and sets it as focused.
* Uses Virtuoso's scrollIntoView method for smooth scrolling.
*/
const scrollToIndex = useCallback(
(index: number, align?: "center" | "end" | "start"): void => {
// Ensure index is within bounds
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]) {
const key = getItemKey(items[clampedIndex]);
isScrollingToItem.current = true;
virtuosoHandleRef.current?.scrollIntoView({
index: clampedIndex,
align: align,
behavior: "auto",
done: () => {
setTabIndexKey(key);
isScrollingToItem.current = false;
},
});
}
},
[items, getItemKey],
);
/**
* Scrolls to an item, skipping over non-focusable items if necessary.
* This is used for keyboard navigation to ensure focus lands on valid items.
*/
const scrollToItem = useCallback(
(index: number, isDirectionDown: boolean, align?: "center" | "end" | "start"): void => {
const totalRows = items.length;
let nextIndex: number | undefined;
for (let i = index; isDirectionDown ? i < totalRows : i >= 0; i = i + (isDirectionDown ? 1 : -1)) {
if (isItemFocusable(items[i])) {
nextIndex = i;
break;
}
}
if (nextIndex === undefined) {
return;
}
scrollToIndex(nextIndex, align);
},
[scrollToIndex, items, isItemFocusable],
);
/**
* Handles keyboard navigation for the list.
* Supports Arrow keys, Home, End, Page Up/Down, Enter, and Space.
*/
const keyDownCallback = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const currentIndex = tabIndexKey ? keyToIndexMap.get(tabIndexKey) : undefined;
let handled = false;
// Guard against null/undefined events and modified keys which we don't want to handle here but do
// at the settings level shortcuts(E.g. Select next room, etc )
// Guard against null/undefined events and modified keys
if (!e || isModifiedKeyEvent(e)) {
onKeyDown?.(e);
return;
}
if (e.code === Key.ARROW_UP && currentIndex !== undefined) {
scrollToItem(currentIndex - 1, false);
handled = true;
} else if (e.code === Key.ARROW_DOWN && currentIndex !== undefined) {
scrollToItem(currentIndex + 1, true);
handled = true;
} else if (e.code === Key.HOME) {
scrollToIndex(0);
handled = true;
} else if (e.code === Key.END) {
scrollToIndex(items.length - 1);
handled = true;
} else if (e.code === Key.PAGE_DOWN && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, "start");
handled = true;
} else if (e.code === Key.PAGE_UP && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.max(currentIndex - numberDisplayed, 0), false, "start");
handled = true;
}
if (handled) {
e.stopPropagation();
e.preventDefault();
} else {
onKeyDown?.(e);
}
},
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onKeyDown],
);
/**
* Callback ref for the Virtuoso scroller element.
* Stores the reference for use in focus management.
*/
const scrollerRef = useCallback((element: HTMLElement | Window | null) => {
virtuosoDomRef.current = element;
}, []);
/**
* Focus handler passed to each item component.
* Don't declare inside getItemComponent to avoid re-creating on each render.
*/
const onFocusForGetItemComponent = useCallback(
(item: Item, e: React.FocusEvent) => {
// If one of the item components has been focused directly, set the focused and tabIndex state
// and stop propagation so the List's onFocus doesn't also handle it.
const key = getItemKey(item);
setIsFocused(true);
setTabIndexKey(key);
e.stopPropagation();
},
[getItemKey],
);
const getItemComponentInternal = useCallback(
(index: number, item: Item, context: VirtualizedListContext<Context>): JSX.Element =>
getItemComponent(index, item, context, onFocusForGetItemComponent),
[getItemComponent, onFocusForGetItemComponent],
);
/**
* Handles focus events on the list.
* Sets the focused state and scrolls to the focused item if it is not currently visible.
*/
const onFocus = useCallback(
(e?: React.FocusEvent): void => {
if (e?.currentTarget !== virtuosoDomRef.current || typeof tabIndexKey !== "string") {
return;
}
setIsFocused(true);
const index = keyToIndexMap.get(tabIndexKey);
if (
index !== undefined &&
visibleRange &&
(index < visibleRange.startIndex || index > visibleRange.endIndex)
) {
scrollToIndex(index);
}
e?.stopPropagation();
e?.preventDefault();
},
[keyToIndexMap, visibleRange, scrollToIndex, tabIndexKey],
);
const onBlur = useCallback((event: React.FocusEvent<HTMLDivElement>): void => {
// Only set isFocused to false if the focus is moving outside the list
// This prevents the list from losing focus when interacting with menus inside it
if (!event.currentTarget.contains(event.relatedTarget)) {
setIsFocused(false);
}
}, []);
const listContext: VirtualizedListContext<Context> = useMemo(
() => ({
tabIndexKey: tabIndexKey,
focused: isFocused,
context: props.context || ({} as Context),
}),
[tabIndexKey, isFocused, props.context],
);
return (
<Virtuoso
// note that either the container of direct children must be focusable to be axe
// compliant, so we leave tabIndex as the default so the container can be focused
// (virtuoso wraps the children inside another couple of elements so setting it
// on those doesn't seem to work, unfortunately)
ref={virtuosoHandleRef}
scrollerRef={scrollerRef}
onKeyDown={keyDownCallback}
context={listContext}
rangeChanged={setVisibleRange}
// virtuoso errors internally if you pass undefined.
overscan={props.overscan || 0}
data={props.items}
onFocus={onFocus}
onBlur={onBlur}
itemContent={getItemComponentInternal}
{...virtuosoProps}
/>
);
}

View File

@@ -0,0 +1,13 @@
/*
* 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.
*/
export { VirtualizedList } from "./VirtualizedList";
export type { IVirtualizedListProps, VirtualizedListContext, ScrollIntoViewOnChange } from "./VirtualizedList";
// Re-export VirtuosoMockContext for testing purposes
// Tests should import this from shared-components to ensure context compatibility
export { VirtuosoMockContext } from "react-virtuoso";

View File

@@ -25,7 +25,13 @@ export default defineConfig({
rollupOptions: {
// make sure to externalize deps that shouldn't be bundled
// into your library
external: ["react", "react-dom", "@vector-im/compound-design-tokens", "@vector-im/compound-web"],
external: [
"react",
"react-dom",
"@vector-im/compound-design-tokens",
"@vector-im/compound-web",
"react-virtuoso",
],
output: {
// Provide global variables to use in the UMD build
// for externalized deps

View File

@@ -5634,6 +5634,11 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
get-nonce "^1.0.0"
tslib "^2.0.0"
react-virtuoso@^4.14.0:
version "4.18.1"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.18.1.tgz#3eb7078f2739a31b96c723374019e587deeb6ebc"
integrity sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA==
"react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0":
version "19.2.3"
resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8"