David Langley cc0ece9837
Implement the member list with virtuoso (#29869)
* implement basic scrolling and keyboard navigation

* Update focus style and improve keyboard navigation

* lint

* Use avatar tootltip for the title rather than the whole button

It's more performant and feels less glitchy than the button tooltip moving around when you scroll.

* lint

* Add tooltip for invite buttons active state

As we have for other icon based buttons in the right panel/app

* Fix location of scrollToIndex and add useCallback

* Improve voiceover experience

- As well as stylng cells, set the tabIndex(roving)
- Natively focus the div with .focus() so screen reader actually moves over the cells
- improve labels and roles

* Fix jest tests

* Add aria index/counts and remove repeating "Open" string in label

* update snapshot

* Add the rest of the keyboard navigation and handle the case when the list looses focus.

* lint and update snapshot

* lint

* Only focus first/lastFocsed cell if focus.currentTarget is the overall list.

So it isn't erroneously called during onClick of an item.

* Put back overscan and fix formatting

* Extract ListView out of MemberList

* lint and fix e2e test

* Update screenshot

It looks like it is slightly better center aligned in the new list, as if maybe it was 1 px to high with the old one.

* Fix default overscan value and add ListView tests

* Just leave the avatar as it was

* We removed the tooltip that showed power level. Removing string.

* Use key rather than index to track focus.

* Remove overscan, fix typos, fix scrollToItem logic

* Use listbox role for member list and correct position/count values to account for the separator

* Fix inadvertant scrolling of the timeline when using pageUp/pageDown

* Always set the roving tab index regardless of whether we are actually focused.

Fixes the issue of not being able to shift+t

* Add aria-hidden to items within the option to avoid the SR calling it a group.

Also

* Make sure there is a roving tab set if the last one has been removed from the list.

* Update snapshot
2025-07-31 15:49:53 +00:00

378 lines
15 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();
});
});
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");
});
});
});