mirror of
https://github.com/vector-im/element-web.git
synced 2025-11-08 20:21:42 +01:00
* 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
378 lines
15 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|