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
co-authored by Michael Telatynski
parent b720a74eef
commit d6f11d828b
17 changed files with 245 additions and 113 deletions
@@ -56,6 +56,12 @@ jobs:
working-directory: packages/shared-components working-directory: packages/shared-components
run: "yarn test:storybook --run" run: "yarn test:storybook --run"
# Workaround for vis silently adding new baselines if they didn't exist
# Can be removed once https://github.com/repobuddy/visual-testing/issues/516 is released
- run: |
git add -N .
git diff --exit-code
- name: Upload received images & diffs - name: Upload received images & diffs
if: always() if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
+1 -1
View File
@@ -43,7 +43,7 @@ const config: Config = {
"@vector-im/compound-web": "<rootDir>/node_modules/@vector-im/compound-web", "@vector-im/compound-web": "<rootDir>/node_modules/@vector-im/compound-web",
}, },
transformIgnorePatterns: [ transformIgnorePatterns: [
"/node_modules/(?!(mime|matrix-js-sdk|uuid|p-retry|is-network-error|react-merge-refs|is-ip|ip-regex|super-regex|function-timeout|time-span|convert-hrtime|clone-regexp|is-regexp|matrix-web-i18n|await-lock)).+$", "/node_modules/(?!(mime|matrix-js-sdk|uuid|p-retry|is-network-error|react-merge-refs|is-ip|ip-regex|super-regex|function-timeout|time-span|convert-hrtime|clone-regexp|is-regexp|matrix-web-i18n|await-lock|@element-hq/web-shared-components|react-virtuoso)).+$",
], ],
collectCoverageFrom: [ collectCoverageFrom: [
"<rootDir>/src/**/*.{js,ts,tsx}", "<rootDir>/src/**/*.{js,ts,tsx}",
-1
View File
@@ -148,7 +148,6 @@
"react-focus-lock": "^2.5.1", "react-focus-lock": "^2.5.1",
"react-string-replace": "^2.0.0", "react-string-replace": "^2.0.0",
"react-transition-group": "^4.4.1", "react-transition-group": "^4.4.1",
"react-virtuoso": "^4.14.0",
"rfc4648": "^1.4.0", "rfc4648": "^1.4.0",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"sanitize-html": "2.17.0", "sanitize-html": "2.17.0",
+3
View File
@@ -5,3 +5,6 @@
/__vis__/**/__diffs__ /__vis__/**/__diffs__
/__vis__/**/__results__ /__vis__/**/__results__
/__vis__/local /__vis__/local
# Ignore coverage report
/coverage/
+1
View File
@@ -59,6 +59,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"matrix-web-i18n": "3.6.0", "matrix-web-i18n": "3.6.0",
"react-merge-refs": "^3.0.2", "react-merge-refs": "^3.0.2",
"react-virtuoso": "^4.14.0",
"temporal-polyfill": "^0.3.0" "temporal-polyfill": "^0.3.0"
}, },
"devDependencies": { "devDependencies": {
+1
View File
@@ -24,6 +24,7 @@ export * from "./room-list/RoomListHeaderView";
export * from "./room-list/RoomListSearchView"; export * from "./room-list/RoomListSearchView";
export * from "./utils/Box"; export * from "./utils/Box";
export * from "./utils/Flex"; export * from "./utils/Flex";
export * from "./utils/VirtualizedList";
// Utils // Utils
export * from "./utils/i18n"; export * from "./utils/i18n";
@@ -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 = {};
@@ -1,15 +1,24 @@
/* /*
Copyright 2024 New Vector Ltd. * 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.
*/
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial import React, { type PropsWithChildren } from "react";
Please see LICENSE files in the repository root for full details. import { render, screen, fireEvent } from "@test-utils";
*/
import React from "react";
import { render, screen, fireEvent } from "jest-matrix-react";
import { VirtuosoMockContext } from "react-virtuoso"; import { VirtuosoMockContext } from "react-virtuoso";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { ListView, type IListViewProps } from "../../../../../src/components/utils/ListView"; 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 { interface TestItem {
id: string; id: string;
@@ -20,9 +29,9 @@ interface TestItem {
const SEPARATOR_ITEM = "SEPARATOR" as const; const SEPARATOR_ITEM = "SEPARATOR" as const;
type TestItemWithSeparator = TestItem | typeof SEPARATOR_ITEM; type TestItemWithSeparator = TestItem | typeof SEPARATOR_ITEM;
describe("ListView", () => { describe("VirtualizedList", () => {
const mockGetItemComponent = jest.fn(); const mockGetItemComponent = vi.fn();
const mockIsItemFocusable = jest.fn(); const mockIsItemFocusable = vi.fn();
const defaultItems: TestItemWithSeparator[] = [ const defaultItems: TestItemWithSeparator[] = [
{ id: "1", name: "Item 1" }, { id: "1", name: "Item 1" },
@@ -31,22 +40,26 @@ describe("ListView", () => {
{ id: "3", name: "Item 3" }, { id: "3", name: "Item 3" },
]; ];
const defaultProps: IListViewProps<TestItemWithSeparator, any> = { const defaultProps: IVirtualizedListProps<TestItemWithSeparator, any> = {
items: defaultItems, items: defaultItems,
getItemComponent: mockGetItemComponent, getItemComponent: mockGetItemComponent,
isItemFocusable: mockIsItemFocusable, isItemFocusable: mockIsItemFocusable,
getItemKey: (item) => (typeof item === "string" ? item : item.id), getItemKey: (item) => (typeof item === "string" ? item : item.id),
}; };
const getListViewComponent = (props: Partial<IListViewProps<TestItemWithSeparator, any>> = {}) => { const getListComponent = (
props: Partial<IVirtualizedListProps<TestItemWithSeparator, any>> = {},
): React.JSX.Element => {
const mergedProps = { ...defaultProps, ...props }; const mergedProps = { ...defaultProps, ...props };
return <ListView {...mergedProps} role="grid" aria-rowcount={props.items?.length} aria-colcount={1} />; return <VirtualizedList {...mergedProps} role="grid" aria-rowcount={props.items?.length} aria-colcount={1} />;
}; };
const renderListViewWithHeight = (props: Partial<IListViewProps<TestItemWithSeparator, any>> = {}) => { const renderListWithHeight = (
props: Partial<IVirtualizedListProps<TestItemWithSeparator, any>> = {},
): ReturnType<typeof render> => {
const mergedProps = { ...defaultProps, ...props }; const mergedProps = { ...defaultProps, ...props };
return render(getListViewComponent(mergedProps), { return render(getListComponent(mergedProps), {
wrapper: ({ children }) => ( wrapper: ({ children }: PropsWithChildren) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 400, itemHeight: 56 }}> <VirtuosoMockContext.Provider value={{ viewportHeight: 400, itemHeight: 56 }}>
<>{children}</> <>{children}</>
</VirtuosoMockContext.Provider> </VirtuosoMockContext.Provider>
@@ -55,12 +68,12 @@ describe("ListView", () => {
}; };
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); vi.clearAllMocks();
mockGetItemComponent.mockImplementation((index: number, item: TestItemWithSeparator, context: any) => { mockGetItemComponent.mockImplementation((index: number, item: TestItemWithSeparator, context: any) => {
const itemKey = typeof item === "string" ? item : item.id; const itemKey = typeof item === "string" ? item : item.id;
const isFocused = context.tabIndexKey === itemKey; const isFocused = context.tabIndexKey === itemKey;
return ( return (
<div className="mx_item" data-testid={`row-${index}`} tabIndex={isFocused ? 0 : -1}> <div className="mx_item" data-testid={`row-${index}`} tabIndex={isFocused ? 0 : -1} role="gridcell">
{item === SEPARATOR_ITEM ? "---" : (item as TestItem).name} {item === SEPARATOR_ITEM ? "---" : (item as TestItem).name}
</div> </div>
); );
@@ -69,24 +82,24 @@ describe("ListView", () => {
}); });
afterEach(() => { afterEach(() => {
jest.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe("Rendering", () => { describe("Rendering", () => {
it("should render the ListView component", () => { it("should render the VirtualizedList component", () => {
renderListViewWithHeight(); renderListWithHeight();
expect(screen.getByRole("grid")).toBeInTheDocument(); expect(screen.getByRole("grid")).toBeDefined();
}); });
it("should render with empty items array", () => { it("should render with empty items array", () => {
renderListViewWithHeight({ items: [] }); renderListWithHeight({ items: [] });
expect(screen.getByRole("grid")).toBeInTheDocument(); expect(screen.getByRole("grid")).toBeDefined();
}); });
}); });
describe("Keyboard Navigation", () => { describe("Keyboard Navigation", () => {
it("should handle ArrowDown key navigation", () => { it("should handle ArrowDown key navigation", () => {
renderListViewWithHeight(); renderListWithHeight();
const container = screen.getByRole("grid"); const container = screen.getByRole("grid");
fireEvent.focus(container); fireEvent.focus(container);
@@ -94,13 +107,13 @@ describe("ListView", () => {
// ArrowDown should skip the non-focusable item at index 1 and go to index 2 // ArrowDown should skip the non-focusable item at index 1 and go to index 2
const items = container.querySelectorAll(".mx_item"); const items = container.querySelectorAll(".mx_item");
expect(items[2]).toHaveAttribute("tabindex", "0"); expectTabIndex(items[2], "0");
expect(items[0]).toHaveAttribute("tabindex", "-1"); expectTabIndex(items[0], "-1");
expect(items[1]).toHaveAttribute("tabindex", "-1"); expectTabIndex(items[1], "-1");
}); });
it("should handle ArrowUp key navigation", () => { it("should handle ArrowUp key navigation", () => {
renderListViewWithHeight(); renderListWithHeight();
const container = screen.getByRole("grid"); const container = screen.getByRole("grid");
// First focus and navigate down to second item // First focus and navigate down to second item
@@ -112,12 +125,12 @@ describe("ListView", () => {
// Verify focus moved back to first item // Verify focus moved back to first item
const items = container.querySelectorAll(".mx_item"); const items = container.querySelectorAll(".mx_item");
expect(items[0]).toHaveAttribute("tabindex", "0"); expectTabIndex(items[0], "0");
expect(items[1]).toHaveAttribute("tabindex", "-1"); expectTabIndex(items[1], "-1");
}); });
it("should handle Home key navigation", () => { it("should handle Home key navigation", () => {
renderListViewWithHeight(); renderListWithHeight();
const container = screen.getByRole("grid"); const container = screen.getByRole("grid");
// First focus and navigate to a later item // First focus and navigate to a later item
@@ -130,15 +143,15 @@ describe("ListView", () => {
// Verify focus moved to first item // Verify focus moved to first item
const items = container.querySelectorAll(".mx_item"); const items = container.querySelectorAll(".mx_item");
expect(items[0]).toHaveAttribute("tabindex", "0"); expectTabIndex(items[0], "0");
// Check that other items are not focused // Check that other items are not focused
for (let i = 1; i < items.length; i++) { for (let i = 1; i < items.length; i++) {
expect(items[i]).toHaveAttribute("tabindex", "-1"); expectTabIndex(items[i], "-1");
} }
}); });
it("should handle End key navigation", () => { it("should handle End key navigation", () => {
renderListViewWithHeight(); renderListWithHeight();
const container = screen.getByRole("grid"); const container = screen.getByRole("grid");
// First focus on the list (starts at first item) // First focus on the list (starts at first item)
@@ -151,15 +164,15 @@ describe("ListView", () => {
const items = container.querySelectorAll(".mx_item"); const items = container.querySelectorAll(".mx_item");
// Should focus on the last visible item // Should focus on the last visible item
const lastIndex = items.length - 1; const lastIndex = items.length - 1;
expect(items[lastIndex]).toHaveAttribute("tabindex", "0"); expectTabIndex(items[lastIndex], "0");
// Check that other items are not focused // Check that other items are not focused
for (let i = 0; i < lastIndex; i++) { for (let i = 0; i < lastIndex; i++) {
expect(items[i]).toHaveAttribute("tabindex", "-1"); expectTabIndex(items[i], "-1");
} }
}); });
it("should handle PageDown key navigation", () => { it("should handle PageDown key navigation", () => {
renderListViewWithHeight(); renderListWithHeight();
const container = screen.getByRole("grid"); const container = screen.getByRole("grid");
// First focus on the list (starts at first item) // First focus on the list (starts at first item)
@@ -172,12 +185,12 @@ describe("ListView", () => {
const items = container.querySelectorAll(".mx_item"); const items = container.querySelectorAll(".mx_item");
// PageDown should move to the last visible item since we only have 4 items // PageDown should move to the last visible item since we only have 4 items
const lastIndex = items.length - 1; const lastIndex = items.length - 1;
expect(items[lastIndex]).toHaveAttribute("tabindex", "0"); expectTabIndex(items[lastIndex], "0");
expect(items[0]).toHaveAttribute("tabindex", "-1"); expectTabIndex(items[0], "-1");
}); });
it("should handle PageUp key navigation", () => { it("should handle PageUp key navigation", () => {
renderListViewWithHeight(); renderListWithHeight();
const container = screen.getByRole("grid"); const container = screen.getByRole("grid");
// First focus and navigate to last item to have something to page up from // First focus and navigate to last item to have something to page up from
@@ -190,56 +203,56 @@ describe("ListView", () => {
// Verify focus moved up // Verify focus moved up
const items = container.querySelectorAll(".mx_item"); const items = container.querySelectorAll(".mx_item");
// PageUp should move back to the first item since we only have 4 items // PageUp should move back to the first item since we only have 4 items
expect(items[0]).toHaveAttribute("tabindex", "0"); expectTabIndex(items[0], "0");
const lastIndex = items.length - 1; const lastIndex = items.length - 1;
expect(items[lastIndex]).toHaveAttribute("tabindex", "-1"); expectTabIndex(items[lastIndex], "-1");
}); });
it("should not handle keyboard navigation when modifier keys are pressed", () => { it("should not handle keyboard navigation when modifier keys are pressed", () => {
renderListViewWithHeight(); renderListWithHeight();
const container = screen.getByRole("grid"); const container = screen.getByRole("grid");
fireEvent.focus(container); fireEvent.focus(container);
// Store initial state - first item should be focused // Store initial state - first item should be focused
const initialItems = container.querySelectorAll(".mx_item"); const initialItems = container.querySelectorAll(".mx_item");
expect(initialItems[0]).toHaveAttribute("tabindex", "0"); expectTabIndex(initialItems[0], "0");
expect(initialItems[2]).toHaveAttribute("tabindex", "-1"); expectTabIndex(initialItems[2], "-1");
// Test ArrowDown with Ctrl modifier - should NOT navigate // Test ArrowDown with Ctrl modifier - should NOT navigate
fireEvent.keyDown(container, { code: "ArrowDown", ctrlKey: true }); fireEvent.keyDown(container, { code: "ArrowDown", ctrlKey: true });
let items = container.querySelectorAll(".mx_item"); let items = container.querySelectorAll(".mx_item");
expect(items[0]).toHaveAttribute("tabindex", "0"); // Should still be on first item expectTabIndex(items[0], "0"); // Should still be on first item
expect(items[2]).toHaveAttribute("tabindex", "-1"); // Should not have moved to third item expectTabIndex(items[2], "-1"); // Should not have moved to third item
// Test ArrowDown with Alt modifier - should NOT navigate // Test ArrowDown with Alt modifier - should NOT navigate
fireEvent.keyDown(container, { code: "ArrowDown", altKey: true }); fireEvent.keyDown(container, { code: "ArrowDown", altKey: true });
items = container.querySelectorAll(".mx_item"); items = container.querySelectorAll(".mx_item");
expect(items[0]).toHaveAttribute("tabindex", "0"); // Should still be on first item expectTabIndex(items[0], "0"); // Should still be on first item
expect(items[2]).toHaveAttribute("tabindex", "-1"); // Should not have moved to third item expectTabIndex(items[2], "-1"); // Should not have moved to third item
// Test ArrowDown with Shift modifier - should NOT navigate // Test ArrowDown with Shift modifier - should NOT navigate
fireEvent.keyDown(container, { code: "ArrowDown", shiftKey: true }); fireEvent.keyDown(container, { code: "ArrowDown", shiftKey: true });
items = container.querySelectorAll(".mx_item"); items = container.querySelectorAll(".mx_item");
expect(items[0]).toHaveAttribute("tabindex", "0"); // Should still be on first item expectTabIndex(items[0], "0"); // Should still be on first item
expect(items[2]).toHaveAttribute("tabindex", "-1"); // Should not have moved to third item expectTabIndex(items[2], "-1"); // Should not have moved to third item
// Test ArrowDown with Meta/Cmd modifier - should NOT navigate // Test ArrowDown with Meta/Cmd modifier - should NOT navigate
fireEvent.keyDown(container, { code: "ArrowDown", metaKey: true }); fireEvent.keyDown(container, { code: "ArrowDown", metaKey: true });
items = container.querySelectorAll(".mx_item"); items = container.querySelectorAll(".mx_item");
expect(items[0]).toHaveAttribute("tabindex", "0"); // Should still be on first item expectTabIndex(items[0], "0"); // Should still be on first item
expect(items[2]).toHaveAttribute("tabindex", "-1"); // Should not have moved to third item expectTabIndex(items[2], "-1"); // Should not have moved to third item
// Test normal ArrowDown without modifiers - SHOULD navigate // Test normal ArrowDown without modifiers - SHOULD navigate
fireEvent.keyDown(container, { code: "ArrowDown" }); fireEvent.keyDown(container, { code: "ArrowDown" });
items = container.querySelectorAll(".mx_item"); items = container.querySelectorAll(".mx_item");
expect(items[0]).toHaveAttribute("tabindex", "-1"); // Should have moved from first item expectTabIndex(items[0], "-1"); // Should have moved from first item
expect(items[2]).toHaveAttribute("tabindex", "0"); // Should have moved to third item (skipping separator) expectTabIndex(items[2], "0"); // Should have moved to third item (skipping separator)
}); });
it("should skip non-focusable items when navigating down", async () => { it("should skip non-focusable items when navigating down", async () => {
@@ -257,7 +270,7 @@ describe("ListView", () => {
return (item as TestItem).isFocusable !== false; return (item as TestItem).isFocusable !== false;
}); });
renderListViewWithHeight({ items: mixedItems }); renderListWithHeight({ items: mixedItems });
const container = screen.getByRole("grid"); const container = screen.getByRole("grid");
fireEvent.focus(container); fireEvent.focus(container);
@@ -266,9 +279,9 @@ describe("ListView", () => {
// Verify it skipped the non-focusable item at index 1 // Verify it skipped the non-focusable item at index 1
// and went directly to the focusable item at index 2 // and went directly to the focusable item at index 2
const items = container.querySelectorAll(".mx_item"); const items = container.querySelectorAll(".mx_item");
expect(items[2]).toHaveAttribute("tabindex", "0"); // Item 3 is focused expectTabIndex(items[2], "0"); // Item 3 is focused
expect(items[0]).toHaveAttribute("tabindex", "-1"); // Item 1 is not focused expectTabIndex(items[0], "-1"); // Item 1 is not focused
expect(items[1]).toHaveAttribute("tabindex", "-1"); // Item 2 (non-focusable) is not focused expectTabIndex(items[1], "-1"); // Item 2 (non-focusable) is not focused
}); });
it("should skip non-focusable items when navigating up", () => { it("should skip non-focusable items when navigating up", () => {
@@ -284,7 +297,7 @@ describe("ListView", () => {
return (item as TestItem).isFocusable !== false; return (item as TestItem).isFocusable !== false;
}); });
renderListViewWithHeight({ items: mixedItems }); renderListWithHeight({ items: mixedItems });
const container = screen.getByRole("grid"); const container = screen.getByRole("grid");
// Focus and go to last item first, then navigate up // Focus and go to last item first, then navigate up
@@ -295,14 +308,14 @@ describe("ListView", () => {
// Verify it skipped non-focusable items // Verify it skipped non-focusable items
// and went to the first focusable item // and went to the first focusable item
const items = container.querySelectorAll(".mx_item"); const items = container.querySelectorAll(".mx_item");
expect(items[0]).toHaveAttribute("tabindex", "0"); // Item 1 is focused expectTabIndex(items[0], "0"); // Item 1 is focused
expect(items[3]).toHaveAttribute("tabindex", "-1"); // Item 3 is not focused anymore expectTabIndex(items[3], "-1"); // Item 3 is not focused anymore
}); });
}); });
describe("Focus Management", () => { describe("Focus Management", () => {
it("should focus first item when list gains focus for the first time", () => { it("should focus first item when list gains focus for the first time", () => {
renderListViewWithHeight(); renderListWithHeight();
const container = screen.getByRole("grid"); const container = screen.getByRole("grid");
// Initial focus should go to first item // Initial focus should go to first item
@@ -310,15 +323,15 @@ describe("ListView", () => {
// Verify first item gets focus // Verify first item gets focus
const items = container.querySelectorAll(".mx_item"); const items = container.querySelectorAll(".mx_item");
expect(items[0]).toHaveAttribute("tabindex", "0"); expectTabIndex(items[0], "0");
// Other items should not be focused // Other items should not be focused
for (let i = 1; i < items.length; i++) { for (let i = 1; i < items.length; i++) {
expect(items[i]).toHaveAttribute("tabindex", "-1"); expectTabIndex(items[i], "-1");
} }
}); });
it("should restore last focused item when regaining focus", () => { it("should restore last focused item when regaining focus", () => {
renderListViewWithHeight(); renderListWithHeight();
const container = screen.getByRole("grid"); const container = screen.getByRole("grid");
// Focus and navigate to simulate previous usage // Focus and navigate to simulate previous usage
@@ -327,7 +340,7 @@ describe("ListView", () => {
// Verify item 2 is focused // Verify item 2 is focused
let items = container.querySelectorAll(".mx_item"); let items = container.querySelectorAll(".mx_item");
expect(items[2]).toHaveAttribute("tabindex", "0"); // ArrowDown skips to item 2 expectTabIndex(items[2], "0"); // ArrowDown skips to item 2
// Simulate blur by focusing elsewhere // Simulate blur by focusing elsewhere
fireEvent.blur(container); fireEvent.blur(container);
@@ -337,11 +350,11 @@ describe("ListView", () => {
// Verify focus is restored to the previously focused item // Verify focus is restored to the previously focused item
items = container.querySelectorAll(".mx_item"); items = container.querySelectorAll(".mx_item");
expect(items[2]).toHaveAttribute("tabindex", "0"); // Should still be item 2 expectTabIndex(items[2], "0"); // Should still be item 2
}); });
it("should not interfere with focus if item is already focused", () => { it("should not interfere with focus if item is already focused", () => {
renderListViewWithHeight(); renderListWithHeight();
const container = screen.getByRole("grid"); const container = screen.getByRole("grid");
// Focus once // Focus once
@@ -350,7 +363,7 @@ describe("ListView", () => {
// Focus again when already focused // Focus again when already focused
fireEvent.focus(container); fireEvent.focus(container);
expect(container).toBeInTheDocument(); expect(container).toBeDefined();
}); });
it("should not scroll to top when clicking an item after manual scroll", () => { it("should not scroll to top when clicking an item after manual scroll", () => {
@@ -360,7 +373,7 @@ describe("ListView", () => {
name: `Item ${i}`, name: `Item ${i}`,
})); }));
const mockOnClick = jest.fn(); const mockOnClick = vi.fn();
mockGetItemComponent.mockImplementation( mockGetItemComponent.mockImplementation(
( (
@@ -376,7 +389,13 @@ describe("ListView", () => {
className="mx_item" className="mx_item"
data-testid={`row-${index}`} data-testid={`row-${index}`}
tabIndex={isFocused ? 0 : -1} tabIndex={isFocused ? 0 : -1}
role="button"
onClick={() => mockOnClick(item)} onClick={() => mockOnClick(item)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
mockOnClick(item);
}
}}
onFocus={(e) => onFocus(item, e)} onFocus={(e) => onFocus(item, e)}
> >
{item === SEPARATOR_ITEM ? "---" : (item as TestItem).name} {item === SEPARATOR_ITEM ? "---" : (item as TestItem).name}
@@ -385,7 +404,7 @@ describe("ListView", () => {
}, },
); );
const { container } = renderListViewWithHeight({ items: largerItems }); const { container } = renderListWithHeight({ items: largerItems });
const listContainer = screen.getByRole("grid"); const listContainer = screen.getByRole("grid");
// Step 1: Focus the list initially (this sets tabIndexKey to first item: "item-0") // Step 1: Focus the list initially (this sets tabIndexKey to first item: "item-0")
@@ -393,8 +412,8 @@ describe("ListView", () => {
// Verify first item is focused initially and tabIndexKey is set to first item // Verify first item is focused initially and tabIndexKey is set to first item
let items = container.querySelectorAll(".mx_item"); let items = container.querySelectorAll(".mx_item");
expect(items[0]).toHaveAttribute("tabindex", "0"); expectTabIndex(items[0], "0");
expect(items[0]).toHaveAttribute("data-testid", "row-0"); expectAttribute(items[0], "data-testid", "row-0");
// Step 2: Simulate manual scrolling (mouse wheel, scroll bar drag, etc.) // Step 2: Simulate manual scrolling (mouse wheel, scroll bar drag, etc.)
// This changes which items are visible but DOES NOT change tabIndexKey // This changes which items are visible but DOES NOT change tabIndexKey
@@ -426,7 +445,7 @@ describe("ListView", () => {
// With the fix applied: the clicked item should become focused (tabindex="0") // With the fix applied: the clicked item should become focused (tabindex="0")
// This validates that the fix prevents unwanted scrolling back to the top // This validates that the fix prevents unwanted scrolling back to the top
expect(clickTargetItem).toHaveAttribute("tabindex", "0"); expectTabIndex(clickTargetItem, "0");
// The key validation: ensure we haven't scrolled back to the top // The key validation: ensure we haven't scrolled back to the top
// item-0 should still not be visible (if the fix is working) // item-0 should still not be visible (if the fix is working)
@@ -437,18 +456,18 @@ describe("ListView", () => {
describe("Accessibility", () => { describe("Accessibility", () => {
it("should set correct ARIA attributes", () => { it("should set correct ARIA attributes", () => {
renderListViewWithHeight(); renderListWithHeight();
const container = screen.getByRole("grid"); const container = screen.getByRole("grid");
expect(container).toHaveAttribute("role", "grid"); expectAttribute(container, "role", "grid");
expect(container).toHaveAttribute("aria-rowcount", "4"); expectAttribute(container, "aria-rowcount", "4");
expect(container).toHaveAttribute("aria-colcount", "1"); expectAttribute(container, "aria-colcount", "1");
}); });
it("should update aria-rowcount when items change", () => { it("should update aria-rowcount when items change", () => {
const { rerender } = renderListViewWithHeight(); const { rerender } = renderListWithHeight();
let container = screen.getByRole("grid"); let container = screen.getByRole("grid");
expect(container).toHaveAttribute("aria-rowcount", "4"); expectAttribute(container, "aria-rowcount", "4");
// Update with fewer items // Update with fewer items
const fewerItems = [ const fewerItems = [
@@ -456,21 +475,21 @@ describe("ListView", () => {
{ id: "2", name: "Item 2" }, { id: "2", name: "Item 2" },
]; ];
rerender( rerender(
getListViewComponent({ getListComponent({
...defaultProps, ...defaultProps,
items: fewerItems, items: fewerItems,
}), }),
); );
container = screen.getByRole("grid"); container = screen.getByRole("grid");
expect(container).toHaveAttribute("aria-rowcount", "2"); expectAttribute(container, "aria-rowcount", "2");
}); });
it("should handle custom ARIA label", () => { it("should handle custom ARIA label", () => {
renderListViewWithHeight({ "aria-label": "Custom list label" }); renderListWithHeight({ "aria-label": "Custom list label" });
const container = screen.getByRole("grid"); const container = screen.getByRole("grid");
expect(container).toHaveAttribute("aria-label", "Custom list label"); expectAttribute(container, "aria-label", "Custom list label");
}); });
}); });
}); });
@@ -8,12 +8,32 @@ Please see LICENSE files in the repository root for full details.
import React, { useRef, type JSX, useCallback, useEffect, useState, useMemo } from "react"; import React, { useRef, type JSX, useCallback, useEffect, useState, useMemo } from "react";
import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso"; import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso";
import { isModifiedKeyEvent, Key } from "../../Keyboard"; /**
* 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 * Context object passed to each list item containing the currently focused key
* and any additional context data from the parent component. * and any additional context data from the parent component.
*/ */
export type ListContext<Context> = { export type VirtualizedListContext<Context> = {
/** The key of item that should have tabIndex == 0 */ /** The key of item that should have tabIndex == 0 */
tabIndexKey?: string; tabIndexKey?: string;
/** Whether an item in the list is currently focused */ /** Whether an item in the list is currently focused */
@@ -22,8 +42,8 @@ export type ListContext<Context> = {
context: Context; context: Context;
}; };
export interface IListViewProps<Item, Context> extends Omit< export interface IVirtualizedListProps<Item, Context> extends Omit<
VirtuosoProps<Item, ListContext<Context>>, VirtuosoProps<Item, VirtualizedListContext<Context>>,
"data" | "itemContent" | "context" "data" | "itemContent" | "context"
> { > {
/** /**
@@ -43,13 +63,13 @@ export interface IListViewProps<Item, Context> extends Omit<
getItemComponent: ( getItemComponent: (
index: number, index: number,
item: Item, item: Item,
context: ListContext<Context>, context: VirtualizedListContext<Context>,
onFocus: (item: Item, e: React.FocusEvent) => void, onFocus: (item: Item, e: React.FocusEvent) => void,
) => JSX.Element; ) => JSX.Element;
/** /**
* Optional additional context data to pass to each rendered item. * Optional additional context data to pass to each rendered item.
* This will be available in the ListContext passed to getItemComponent. * This will be available in the VirtualizedListContext passed to getItemComponent.
*/ */
context?: Context; context?: Context;
@@ -66,9 +86,10 @@ export interface IListViewProps<Item, Context> extends Omit<
* @return The key to use for focusing the item * @return The key to use for focusing the item
*/ */
getItemKey: (item: Item) => string; getItemKey: (item: Item) => string;
/** /**
* Callback function to handle key down events on the list container. * Callback function to handle key down events on the list container.
* ListView handles keyboard navigation for focus(up, down, home, end, pageUp, pageDown) * 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. * and stops propagation otherwise the event bubbles and this callback is called for the use of the parent.
* @param e - The keyboard event * @param e - The keyboard event
* @returns * @returns
@@ -80,7 +101,7 @@ export interface IListViewProps<Item, Context> extends Omit<
* Utility type for the prop scrollIntoViewOnChange allowing it to be memoised by a caller without repeating types * Utility type for the prop scrollIntoViewOnChange allowing it to be memoised by a caller without repeating types
*/ */
export type ScrollIntoViewOnChange<Item, Context = any> = NonNullable< export type ScrollIntoViewOnChange<Item, Context = any> = NonNullable<
VirtuosoProps<Item, ListContext<Context>>["scrollIntoViewOnChange"] VirtuosoProps<Item, VirtualizedListContext<Context>>["scrollIntoViewOnChange"]
>; >;
/** /**
@@ -90,7 +111,7 @@ export type ScrollIntoViewOnChange<Item, Context = any> = NonNullable<
* @template Item - The type of data items in the list * @template Item - The type of data items in the list
* @template Context - The type of additional context data passed to items * @template Context - The type of additional context data passed to items
*/ */
export function ListView<Item, Context = any>(props: IListViewProps<Item, Context>): React.ReactElement { export function VirtualizedList<Item, Context = any>(props: IVirtualizedListProps<Item, Context>): React.ReactElement {
// Extract our custom props to avoid conflicts with Virtuoso props // Extract our custom props to avoid conflicts with Virtuoso props
const { items, getItemComponent, isItemFocusable, getItemKey, context, onKeyDown, ...virtuosoProps } = props; const { items, getItemComponent, isItemFocusable, getItemKey, context, onKeyDown, ...virtuosoProps } = props;
/** Reference to the Virtuoso component for programmatic scrolling */ /** Reference to the Virtuoso component for programmatic scrolling */
@@ -213,11 +234,11 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
handled = true; handled = true;
} else if (e.code === Key.PAGE_DOWN && visibleRange && currentIndex !== undefined) { } else if (e.code === Key.PAGE_DOWN && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex; const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, `start`); scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, "start");
handled = true; handled = true;
} else if (e.code === Key.PAGE_UP && visibleRange && currentIndex !== undefined) { } else if (e.code === Key.PAGE_UP && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex; const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.max(currentIndex - numberDisplayed, 0), false, `start`); scrollToItem(Math.max(currentIndex - numberDisplayed, 0), false, "start");
handled = true; handled = true;
} }
@@ -246,7 +267,7 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
const onFocusForGetItemComponent = useCallback( const onFocusForGetItemComponent = useCallback(
(item: Item, e: React.FocusEvent) => { (item: Item, e: React.FocusEvent) => {
// If one of the item components has been focused directly, set the focused and tabIndex state // If one of the item components has been focused directly, set the focused and tabIndex state
// and stop propagation so the ListViews onFocus doesn't also handle it. // and stop propagation so the List's onFocus doesn't also handle it.
const key = getItemKey(item); const key = getItemKey(item);
setIsFocused(true); setIsFocused(true);
setTabIndexKey(key); setTabIndexKey(key);
@@ -256,10 +277,11 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
); );
const getItemComponentInternal = useCallback( const getItemComponentInternal = useCallback(
(index: number, item: Item, context: ListContext<Context>): JSX.Element => (index: number, item: Item, context: VirtualizedListContext<Context>): JSX.Element =>
getItemComponent(index, item, context, onFocusForGetItemComponent), getItemComponent(index, item, context, onFocusForGetItemComponent),
[getItemComponent, onFocusForGetItemComponent], [getItemComponent, onFocusForGetItemComponent],
); );
/** /**
* Handles focus events on the list. * Handles focus events on the list.
* Sets the focused state and scrolls to the focused item if it is not currently visible. * Sets the focused state and scrolls to the focused item if it is not currently visible.
@@ -293,7 +315,7 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
} }
}, []); }, []);
const listContext: ListContext<Context> = useMemo( const listContext: VirtualizedListContext<Context> = useMemo(
() => ({ () => ({
tabIndexKey: tabIndexKey, tabIndexKey: tabIndexKey,
focused: isFocused, focused: isFocused,
@@ -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";
+7 -1
View File
@@ -25,7 +25,13 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
// make sure to externalize deps that shouldn't be bundled // make sure to externalize deps that shouldn't be bundled
// into your library // 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: { output: {
// Provide global variables to use in the UMD build // Provide global variables to use in the UMD build
// for externalized deps // for externalized deps
+5
View File
@@ -5634,6 +5634,11 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
get-nonce "^1.0.0" get-nonce "^1.0.0"
tslib "^2.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": "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0":
version "19.2.3" version "19.2.3"
resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8" resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8"
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import { Form } from "@vector-im/compound-web"; import { Form } from "@vector-im/compound-web";
import React, { type JSX, useCallback } from "react"; import React, { type JSX, useCallback } from "react";
import { Flex } from "@element-hq/web-shared-components"; import { Flex, type VirtualizedListContext, VirtualizedList } from "@element-hq/web-shared-components";
import { import {
type MemberWithSeparator, type MemberWithSeparator,
@@ -19,7 +19,6 @@ import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView";
import { MemberListHeaderView } from "./MemberListHeaderView"; import { MemberListHeaderView } from "./MemberListHeaderView";
import BaseCard from "../../right_panel/BaseCard"; import BaseCard from "../../right_panel/BaseCard";
import { _t } from "../../../../languageHandler"; import { _t } from "../../../../languageHandler";
import { type ListContext, ListView } from "../../../utils/ListView";
interface IProps { interface IProps {
roomId: string; roomId: string;
@@ -54,7 +53,7 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
( (
index: number, index: number,
item: MemberWithSeparator, item: MemberWithSeparator,
context: ListContext<any>, context: VirtualizedListContext<any>,
onFocus: (item: MemberWithSeparator, e: React.FocusEvent) => void, onFocus: (item: MemberWithSeparator, e: React.FocusEvent) => void,
): JSX.Element => { ): JSX.Element => {
const itemKey = getItemKey(item); const itemKey = getItemKey(item);
@@ -109,7 +108,7 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
<Form.Root onSubmit={(e) => e.preventDefault()}> <Form.Root onSubmit={(e) => e.preventDefault()}>
<MemberListHeaderView vm={vm} /> <MemberListHeaderView vm={vm} />
</Form.Root> </Form.Root>
<ListView <VirtualizedList
items={vm.members} items={vm.members}
getItemComponent={getItemComponent} getItemComponent={getItemComponent}
getItemKey={getItemKey} getItemKey={getItemKey}
@@ -8,11 +8,15 @@
import React, { useCallback, useRef, type JSX, useMemo } from "react"; import React, { useCallback, useRef, type JSX, useMemo } from "react";
import { type Room } from "matrix-js-sdk/src/matrix"; import { type Room } from "matrix-js-sdk/src/matrix";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import {
type VirtualizedListContext,
VirtualizedList,
type ScrollIntoViewOnChange,
} from "@element-hq/web-shared-components";
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel"; import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { _t } from "../../../../languageHandler"; import { _t } from "../../../../languageHandler";
import { RoomListItemView } from "./RoomListItemView"; import { RoomListItemView } from "./RoomListItemView";
import { type ListContext, ListView, type ScrollIntoViewOnChange } from "../../../utils/ListView";
import { type FilterKey } from "../../../../stores/room-list-v3/skip-list/filters"; import { type FilterKey } from "../../../../stores/room-list-v3/skip-list/filters";
import { getKeyBindingsManager } from "../../../../KeyBindingsManager"; import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts"; import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
@@ -53,7 +57,7 @@ export function RoomList({ vm: { roomsResult, activeIndex } }: RoomListProps): J
( (
index: number, index: number,
item: Room, item: Room,
context: ListContext<Context>, context: VirtualizedListContext<Context>,
onFocus: (item: Room, e: React.FocusEvent) => void, onFocus: (item: Room, e: React.FocusEvent) => void,
): JSX.Element => { ): JSX.Element => {
const itemKey = item.roomId; const itemKey = item.roomId;
@@ -118,7 +122,7 @@ export function RoomList({ vm: { roomsResult, activeIndex } }: RoomListProps): J
); );
return ( return (
<ListView <VirtualizedList
context={context} context={context}
scrollIntoViewOnChange={scrollIntoViewOnChange} scrollIntoViewOnChange={scrollIntoViewOnChange}
initialTopMostItemIndex={activeIndex} initialTopMostItemIndex={activeIndex}
@@ -9,7 +9,7 @@ import React from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { render } from "jest-matrix-react"; import { render } from "jest-matrix-react";
import { fireEvent } from "@testing-library/dom"; import { fireEvent } from "@testing-library/dom";
import { VirtuosoMockContext } from "react-virtuoso"; import { VirtuosoMockContext } from "@element-hq/web-shared-components";
import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel"; import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
import { RoomList } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomList"; import { RoomList } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomList";
@@ -9,7 +9,9 @@ Please see LICENSE files in the repository root for full details.
import React, { act } from "react"; import React, { act } from "react";
import { render, type RenderResult, waitFor } from "jest-matrix-react"; import { render, type RenderResult, waitFor } from "jest-matrix-react";
import { VirtuosoMockContext } from "react-virtuoso"; // Import VirtuosoMockContext from shared-components to ensure context compatibility
// with the ListView component which also imports from shared-components
import { VirtuosoMockContext } from "@element-hq/web-shared-components";
import { import {
Room, Room,
type MatrixClient, type MatrixClient,