You've already forked element-web
mirror of
https://github.com/element-hq/element-web.git
synced 2025-08-09 14:42:51 +03:00
454 lines
19 KiB
TypeScript
454 lines
19 KiB
TypeScript
/*
|
|
Copyright 2024 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 { render, screen, fireEvent } from "jest-matrix-react";
|
|
import { VirtuosoMockContext } from "react-virtuoso";
|
|
|
|
import { ListView, type IListViewProps } from "../../../../../src/components/utils/ListView";
|
|
|
|
interface TestItem {
|
|
id: string;
|
|
name: string;
|
|
isFocusable?: boolean;
|
|
}
|
|
|
|
const SEPARATOR_ITEM = "SEPARATOR" as const;
|
|
type TestItemWithSeparator = TestItem | typeof SEPARATOR_ITEM;
|
|
|
|
describe("ListView", () => {
|
|
const mockOnSelectItem = jest.fn();
|
|
const mockGetItemComponent = jest.fn();
|
|
const mockIsItemFocusable = jest.fn();
|
|
|
|
const defaultItems: TestItemWithSeparator[] = [
|
|
{ id: "1", name: "Item 1" },
|
|
SEPARATOR_ITEM,
|
|
{ id: "2", name: "Item 2" },
|
|
{ id: "3", name: "Item 3" },
|
|
];
|
|
|
|
const defaultProps: IListViewProps<TestItemWithSeparator, any> = {
|
|
items: defaultItems,
|
|
onSelectItem: mockOnSelectItem,
|
|
getItemComponent: mockGetItemComponent,
|
|
isItemFocusable: mockIsItemFocusable,
|
|
getItemKey: (item) => (typeof item === "string" ? item : item.id),
|
|
};
|
|
|
|
const getListViewComponent = (props: Partial<IListViewProps<TestItemWithSeparator, any>> = {}) => {
|
|
const mergedProps = { ...defaultProps, ...props };
|
|
return <ListView {...mergedProps} role="grid" aria-rowcount={props.items?.length} aria-colcount={1} />;
|
|
};
|
|
|
|
const renderListViewWithHeight = (props: Partial<IListViewProps<TestItemWithSeparator, any>> = {}) => {
|
|
const mergedProps = { ...defaultProps, ...props };
|
|
return render(getListViewComponent(mergedProps), {
|
|
wrapper: ({ children }) => (
|
|
<VirtuosoMockContext.Provider value={{ viewportHeight: 400, itemHeight: 56 }}>
|
|
{children}
|
|
</VirtuosoMockContext.Provider>
|
|
),
|
|
});
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockGetItemComponent.mockImplementation((index: number, item: TestItemWithSeparator, context: any) => {
|
|
const itemKey = typeof item === "string" ? item : item.id;
|
|
const isFocused = context.tabIndexKey === itemKey;
|
|
return (
|
|
<div className="mx_item" data-testid={`row-${index}`} tabIndex={isFocused ? 0 : -1}>
|
|
{item === SEPARATOR_ITEM ? "---" : (item as TestItem).name}
|
|
</div>
|
|
);
|
|
});
|
|
mockIsItemFocusable.mockImplementation((item: TestItemWithSeparator) => item !== SEPARATOR_ITEM);
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
describe("Rendering", () => {
|
|
it("should render the ListView component", () => {
|
|
renderListViewWithHeight();
|
|
expect(screen.getByRole("grid")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render with empty items array", () => {
|
|
renderListViewWithHeight({ items: [] });
|
|
expect(screen.getByRole("grid")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("Keyboard Navigation", () => {
|
|
it("should handle Enter key and call onSelectItem when focused", async () => {
|
|
renderListViewWithHeight();
|
|
const container = screen.getByRole("grid");
|
|
|
|
expect(container.querySelectorAll(".mx_item")).toHaveLength(4);
|
|
|
|
// Focus to activate the list and navigate to first focusable item
|
|
fireEvent.focus(container);
|
|
|
|
fireEvent.keyDown(container, { code: "Enter" });
|
|
|
|
expect(mockOnSelectItem).toHaveBeenCalledWith(defaultItems[0]);
|
|
});
|
|
|
|
it("should handle Space key and call onSelectItem when focused", () => {
|
|
renderListViewWithHeight();
|
|
const container = screen.getByRole("grid");
|
|
|
|
expect(container.querySelectorAll(".mx_item")).toHaveLength(4);
|
|
// Focus to activate the list and navigate to first focusable item
|
|
fireEvent.focus(container);
|
|
|
|
fireEvent.keyDown(container, { code: "Space" });
|
|
|
|
expect(mockOnSelectItem).toHaveBeenCalledWith(defaultItems[0]);
|
|
});
|
|
|
|
it("should handle ArrowDown key navigation", () => {
|
|
renderListViewWithHeight();
|
|
const container = screen.getByRole("grid");
|
|
|
|
fireEvent.focus(container);
|
|
fireEvent.keyDown(container, { code: "ArrowDown" });
|
|
|
|
// ArrowDown should skip the non-focusable item at index 1 and go to index 2
|
|
const items = container.querySelectorAll(".mx_item");
|
|
expect(items[2]).toHaveAttribute("tabindex", "0");
|
|
expect(items[0]).toHaveAttribute("tabindex", "-1");
|
|
expect(items[1]).toHaveAttribute("tabindex", "-1");
|
|
});
|
|
|
|
it("should handle ArrowUp key navigation", () => {
|
|
renderListViewWithHeight();
|
|
const container = screen.getByRole("grid");
|
|
|
|
// First focus and navigate down to second item
|
|
fireEvent.focus(container);
|
|
fireEvent.keyDown(container, { code: "ArrowDown" });
|
|
|
|
// Then navigate back up
|
|
fireEvent.keyDown(container, { code: "ArrowUp" });
|
|
|
|
// Verify focus moved back to first item
|
|
const items = container.querySelectorAll(".mx_item");
|
|
expect(items[0]).toHaveAttribute("tabindex", "0");
|
|
expect(items[1]).toHaveAttribute("tabindex", "-1");
|
|
});
|
|
|
|
it("should handle Home key navigation", () => {
|
|
renderListViewWithHeight();
|
|
const container = screen.getByRole("grid");
|
|
|
|
// First focus and navigate to a later item
|
|
fireEvent.focus(container);
|
|
fireEvent.keyDown(container, { code: "ArrowDown" });
|
|
fireEvent.keyDown(container, { code: "ArrowDown" });
|
|
|
|
// Then press Home to go to first item
|
|
fireEvent.keyDown(container, { code: "Home" });
|
|
|
|
// Verify focus moved to first item
|
|
const items = container.querySelectorAll(".mx_item");
|
|
expect(items[0]).toHaveAttribute("tabindex", "0");
|
|
// Check that other items are not focused
|
|
for (let i = 1; i < items.length; i++) {
|
|
expect(items[i]).toHaveAttribute("tabindex", "-1");
|
|
}
|
|
});
|
|
|
|
it("should handle End key navigation", () => {
|
|
renderListViewWithHeight();
|
|
const container = screen.getByRole("grid");
|
|
|
|
// First focus on the list (starts at first item)
|
|
fireEvent.focus(container);
|
|
|
|
// Then press End to go to last item
|
|
fireEvent.keyDown(container, { code: "End" });
|
|
|
|
// Verify focus moved to last visible item
|
|
const items = container.querySelectorAll(".mx_item");
|
|
// Should focus on the last visible item
|
|
const lastIndex = items.length - 1;
|
|
expect(items[lastIndex]).toHaveAttribute("tabindex", "0");
|
|
// Check that other items are not focused
|
|
for (let i = 0; i < lastIndex; i++) {
|
|
expect(items[i]).toHaveAttribute("tabindex", "-1");
|
|
}
|
|
});
|
|
|
|
it("should handle PageDown key navigation", () => {
|
|
renderListViewWithHeight();
|
|
const container = screen.getByRole("grid");
|
|
|
|
// First focus on the list (starts at first item)
|
|
fireEvent.focus(container);
|
|
|
|
// Then press PageDown to jump down by viewport size
|
|
fireEvent.keyDown(container, { code: "PageDown" });
|
|
|
|
// Verify focus moved down
|
|
const items = container.querySelectorAll(".mx_item");
|
|
// PageDown should move to the last visible item since we only have 4 items
|
|
const lastIndex = items.length - 1;
|
|
expect(items[lastIndex]).toHaveAttribute("tabindex", "0");
|
|
expect(items[0]).toHaveAttribute("tabindex", "-1");
|
|
});
|
|
|
|
it("should handle PageUp key navigation", () => {
|
|
renderListViewWithHeight();
|
|
const container = screen.getByRole("grid");
|
|
|
|
// First focus and navigate to last item to have something to page up from
|
|
fireEvent.focus(container);
|
|
fireEvent.keyDown(container, { code: "End" });
|
|
|
|
// Then press PageUp to jump up by viewport size
|
|
fireEvent.keyDown(container, { code: "PageUp" });
|
|
|
|
// Verify focus moved up
|
|
const items = container.querySelectorAll(".mx_item");
|
|
// PageUp should move back to the first item since we only have 4 items
|
|
expect(items[0]).toHaveAttribute("tabindex", "0");
|
|
const lastIndex = items.length - 1;
|
|
expect(items[lastIndex]).toHaveAttribute("tabindex", "-1");
|
|
});
|
|
|
|
it("should skip non-focusable items when navigating down", async () => {
|
|
// Create items where every other item is not focusable
|
|
const mixedItems = [
|
|
{ id: "1", name: "Item 1", isFocusable: true },
|
|
{ id: "2", name: "Item 2", isFocusable: false },
|
|
{ id: "3", name: "Item 3", isFocusable: true },
|
|
SEPARATOR_ITEM,
|
|
{ id: "4", name: "Item 4", isFocusable: true },
|
|
];
|
|
|
|
mockIsItemFocusable.mockImplementation((item: TestItemWithSeparator) => {
|
|
if (item === SEPARATOR_ITEM) return false;
|
|
return (item as TestItem).isFocusable !== false;
|
|
});
|
|
|
|
renderListViewWithHeight({ items: mixedItems });
|
|
const container = screen.getByRole("grid");
|
|
|
|
fireEvent.focus(container);
|
|
fireEvent.keyDown(container, { code: "ArrowDown" });
|
|
|
|
// Verify it skipped the non-focusable item at index 1
|
|
// and went directly to the focusable item at index 2
|
|
const items = container.querySelectorAll(".mx_item");
|
|
expect(items[2]).toHaveAttribute("tabindex", "0"); // Item 3 is focused
|
|
expect(items[0]).toHaveAttribute("tabindex", "-1"); // Item 1 is not focused
|
|
expect(items[1]).toHaveAttribute("tabindex", "-1"); // Item 2 (non-focusable) is not focused
|
|
});
|
|
|
|
it("should skip non-focusable items when navigating up", () => {
|
|
const mixedItems = [
|
|
{ id: "1", name: "Item 1", isFocusable: true },
|
|
SEPARATOR_ITEM,
|
|
{ id: "2", name: "Item 2", isFocusable: false },
|
|
{ id: "3", name: "Item 3", isFocusable: true },
|
|
];
|
|
|
|
mockIsItemFocusable.mockImplementation((item: TestItemWithSeparator) => {
|
|
if (item === SEPARATOR_ITEM) return false;
|
|
return (item as TestItem).isFocusable !== false;
|
|
});
|
|
|
|
renderListViewWithHeight({ items: mixedItems });
|
|
const container = screen.getByRole("grid");
|
|
|
|
// Focus and go to last item first, then navigate up
|
|
fireEvent.focus(container);
|
|
fireEvent.keyDown(container, { code: "End" });
|
|
fireEvent.keyDown(container, { code: "ArrowUp" });
|
|
|
|
// Verify it skipped non-focusable items
|
|
// and went to the first focusable item
|
|
const items = container.querySelectorAll(".mx_item");
|
|
expect(items[0]).toHaveAttribute("tabindex", "0"); // Item 1 is focused
|
|
expect(items[3]).toHaveAttribute("tabindex", "-1"); // Item 3 is not focused anymore
|
|
});
|
|
});
|
|
|
|
describe("Focus Management", () => {
|
|
it("should focus first item when list gains focus for the first time", () => {
|
|
renderListViewWithHeight();
|
|
const container = screen.getByRole("grid");
|
|
|
|
// Initial focus should go to first item
|
|
fireEvent.focus(container);
|
|
|
|
// Verify first item gets focus
|
|
const items = container.querySelectorAll(".mx_item");
|
|
expect(items[0]).toHaveAttribute("tabindex", "0");
|
|
// Other items should not be focused
|
|
for (let i = 1; i < items.length; i++) {
|
|
expect(items[i]).toHaveAttribute("tabindex", "-1");
|
|
}
|
|
});
|
|
|
|
it("should restore last focused item when regaining focus", () => {
|
|
renderListViewWithHeight();
|
|
const container = screen.getByRole("grid");
|
|
|
|
// Focus and navigate to simulate previous usage
|
|
fireEvent.focus(container);
|
|
fireEvent.keyDown(container, { code: "ArrowDown" });
|
|
|
|
// Verify item 2 is focused
|
|
let items = container.querySelectorAll(".mx_item");
|
|
expect(items[2]).toHaveAttribute("tabindex", "0"); // ArrowDown skips to item 2
|
|
|
|
// Simulate blur by focusing elsewhere
|
|
fireEvent.blur(container);
|
|
|
|
// Regain focus should restore last position
|
|
fireEvent.focus(container);
|
|
|
|
// Verify focus is restored to the previously focused item
|
|
items = container.querySelectorAll(".mx_item");
|
|
expect(items[2]).toHaveAttribute("tabindex", "0"); // Should still be item 2
|
|
});
|
|
|
|
it("should not interfere with focus if item is already focused", () => {
|
|
renderListViewWithHeight();
|
|
const container = screen.getByRole("grid");
|
|
|
|
// Focus once
|
|
fireEvent.focus(container);
|
|
|
|
// Focus again when already focused
|
|
fireEvent.focus(container);
|
|
|
|
expect(container).toBeInTheDocument();
|
|
});
|
|
|
|
it("should not scroll to top when clicking an item after manual scroll", () => {
|
|
// Create a larger list to enable meaningful scrolling
|
|
const largerItems = Array.from({ length: 50 }, (_, i) => ({
|
|
id: `item-${i}`,
|
|
name: `Item ${i}`,
|
|
}));
|
|
|
|
const mockOnClick = jest.fn();
|
|
|
|
mockGetItemComponent.mockImplementation(
|
|
(index: number, item: TestItemWithSeparator, context: any, onFocus: (e: React.FocusEvent) => void) => {
|
|
const itemKey = typeof item === "string" ? item : item.id;
|
|
const isFocused = context.tabIndexKey === itemKey;
|
|
return (
|
|
<div
|
|
className="mx_item"
|
|
data-testid={`row-${index}`}
|
|
tabIndex={isFocused ? 0 : -1}
|
|
onClick={() => mockOnClick(item)}
|
|
onFocus={onFocus}
|
|
>
|
|
{item === SEPARATOR_ITEM ? "---" : (item as TestItem).name}
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
const { container } = renderListViewWithHeight({ items: largerItems });
|
|
const listContainer = screen.getByRole("grid");
|
|
|
|
// Step 1: Focus the list initially (this sets tabIndexKey to first item: "item-0")
|
|
fireEvent.focus(listContainer);
|
|
|
|
// Verify first item is focused initially and tabIndexKey is set to first item
|
|
let items = container.querySelectorAll(".mx_item");
|
|
expect(items[0]).toHaveAttribute("tabindex", "0");
|
|
expect(items[0]).toHaveAttribute("data-testid", "row-0");
|
|
|
|
// Step 2: Simulate manual scrolling (mouse wheel, scroll bar drag, etc.)
|
|
// This changes which items are visible but DOES NOT change tabIndexKey
|
|
// tabIndexKey should still point to "item-0" but "item-0" is no longer visible
|
|
fireEvent.scroll(listContainer, { target: { scrollTop: 300 } });
|
|
|
|
// Step 3: After scrolling, different items should now be visible
|
|
// but tabIndexKey should still point to "item-0" (which is no longer visible)
|
|
items = container.querySelectorAll(".mx_item");
|
|
|
|
// Verify that item-0 is no longer in the DOM (because it's scrolled out of view)
|
|
const item0 = container.querySelector("[data-testid='row-0']");
|
|
expect(item0).toBeNull();
|
|
|
|
// Find a visible item to click on (should be items from further down the list)
|
|
const visibleItems = container.querySelectorAll(".mx_item");
|
|
expect(visibleItems.length).toBeGreaterThan(0);
|
|
const clickTargetItem = visibleItems[0]; // Click on the first visible item
|
|
|
|
// Click on the visible item
|
|
fireEvent.click(clickTargetItem);
|
|
|
|
// The click should trigger the onFocus callback, which updates the tabIndexKey
|
|
// This simulates the real user interaction where clicking an item focuses it
|
|
fireEvent.focus(clickTargetItem);
|
|
|
|
// Verify the click was handled
|
|
expect(mockOnClick).toHaveBeenCalled();
|
|
|
|
// With the fix applied: the clicked item should become focused (tabindex="0")
|
|
// This validates that the fix prevents unwanted scrolling back to the top
|
|
expect(clickTargetItem).toHaveAttribute("tabindex", "0");
|
|
|
|
// The key validation: ensure we haven't scrolled back to the top
|
|
// item-0 should still not be visible (if the fix is working)
|
|
const item0AfterClick = container.querySelector("[data-testid='row-0']");
|
|
expect(item0AfterClick).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Accessibility", () => {
|
|
it("should set correct ARIA attributes", () => {
|
|
renderListViewWithHeight();
|
|
const container = screen.getByRole("grid");
|
|
|
|
expect(container).toHaveAttribute("role", "grid");
|
|
expect(container).toHaveAttribute("aria-rowcount", "4");
|
|
expect(container).toHaveAttribute("aria-colcount", "1");
|
|
});
|
|
|
|
it("should update aria-rowcount when items change", () => {
|
|
const { rerender } = renderListViewWithHeight();
|
|
let container = screen.getByRole("grid");
|
|
expect(container).toHaveAttribute("aria-rowcount", "4");
|
|
|
|
// Update with fewer items
|
|
const fewerItems = [
|
|
{ id: "1", name: "Item 1" },
|
|
{ id: "2", name: "Item 2" },
|
|
];
|
|
rerender(
|
|
getListViewComponent({
|
|
...defaultProps,
|
|
items: fewerItems,
|
|
}),
|
|
);
|
|
|
|
container = screen.getByRole("grid");
|
|
expect(container).toHaveAttribute("aria-rowcount", "2");
|
|
});
|
|
|
|
it("should handle custom ARIA label", () => {
|
|
renderListViewWithHeight({ "aria-label": "Custom list label" });
|
|
const container = screen.getByRole("grid");
|
|
|
|
expect(container).toHaveAttribute("aria-label", "Custom list label");
|
|
});
|
|
});
|
|
});
|