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:
co-authored by
Michael Telatynski
parent
b720a74eef
commit
d6f11d828b
@@ -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
@@ -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}",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -5,3 +5,6 @@
|
|||||||
/__vis__/**/__diffs__
|
/__vis__/**/__diffs__
|
||||||
/__vis__/**/__results__
|
/__vis__/**/__results__
|
||||||
/__vis__/local
|
/__vis__/local
|
||||||
|
|
||||||
|
# Ignore coverage report
|
||||||
|
/coverage/
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
@@ -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": {
|
||||||
|
|||||||
@@ -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 = {};
|
||||||
+106
-87
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+36
-14
@@ -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";
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user