You've already forked element-web
mirror of
https://github.com/element-hq/element-web.git
synced 2025-08-06 16:22:46 +03:00
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
This commit is contained in:
@@ -154,6 +154,7 @@
|
||||
"react-string-replace": "^1.1.1",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"react-virtualized": "^9.22.5",
|
||||
"react-virtuoso": "^4.12.6",
|
||||
"rfc4648": "^1.4.0",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"sanitize-html": "2.17.0",
|
||||
|
@@ -30,6 +30,10 @@ test.describe("Lazy Loading", () => {
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page, homeserver, user, bot, app }) => {
|
||||
// The charlies were running off the bottom of the screen.
|
||||
// We no longer overscan the member list so the result is they are not in the dom.
|
||||
// Increase the viewport size to ensure they are.
|
||||
await page.setViewportSize({ width: 1000, height: 1000 });
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const displayName = `Charly #${i}`;
|
||||
const bot = new Bot(page, homeserver, { displayName, startClient: false, autoAcceptInvites: false });
|
||||
|
@@ -35,7 +35,7 @@ test.describe("Share dialog", () => {
|
||||
|
||||
const rightPanel = await app.toggleRoomInfoPanel();
|
||||
await rightPanel.getByRole("menuitem", { name: "People" }).click();
|
||||
await rightPanel.getByRole("button", { name: `${user.userId} (power 100)` }).click();
|
||||
await rightPanel.getByRole("option", { name: user.displayName }).click();
|
||||
await rightPanel.getByRole("button", { name: "Share profile" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Share User" });
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
272
src/components/utils/ListView.tsx
Normal file
272
src/components/utils/ListView.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
Copyright 2025 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, { useRef, type JSX, useCallback, useEffect, useState } from "react";
|
||||
import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso";
|
||||
|
||||
/**
|
||||
* Context object passed to each list item containing the currently focused key
|
||||
* and any additional context data from the parent component.
|
||||
*/
|
||||
export type ListContext<Context> = {
|
||||
/** The key of item that should have tabIndex == 0 */
|
||||
tabIndexKey?: string;
|
||||
/** Whether an item in the list is currently focused */
|
||||
focused: boolean;
|
||||
/** Additional context data passed from the parent component */
|
||||
context: Context;
|
||||
};
|
||||
|
||||
export interface IListViewProps<Item, Context>
|
||||
extends Omit<VirtuosoProps<Item, ListContext<Context>>, "data" | "itemContent" | "context"> {
|
||||
/**
|
||||
* The array of items to display in the virtualized list.
|
||||
* Each item will be passed to getItemComponent for rendering.
|
||||
*/
|
||||
items: Item[];
|
||||
|
||||
/**
|
||||
* Callback function called when an item is selected (via Enter/Space key).
|
||||
* @param item - The selected item from the items array
|
||||
*/
|
||||
onSelectItem: (item: Item) => void;
|
||||
|
||||
/**
|
||||
* Function that renders each list item as a JSX element.
|
||||
* @param index - The index of the item in the list
|
||||
* @param item - The data item to render
|
||||
* @param context - The context object containing the focused key and any additional data
|
||||
* @returns JSX element representing the rendered item
|
||||
*/
|
||||
getItemComponent: (index: number, item: Item, context: ListContext<Context>) => JSX.Element;
|
||||
|
||||
/**
|
||||
* Optional additional context data to pass to each rendered item.
|
||||
* This will be available in the ListContext passed to getItemComponent.
|
||||
*/
|
||||
context?: Context;
|
||||
|
||||
/**
|
||||
* Function to determine if an item can receive focus during keyboard navigation.
|
||||
* @param item - The item to check for focusability
|
||||
* @returns true if the item can be focused, false otherwise
|
||||
*/
|
||||
isItemFocusable: (item: Item) => boolean;
|
||||
|
||||
/**
|
||||
* Function to get the key to use for focusing an item.
|
||||
* @param item - The item to get the key for
|
||||
* @return The key to use for focusing the item
|
||||
*/
|
||||
getItemKey: (item: Item) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic virtualized list component built on top of react-virtuoso.
|
||||
* Provides keyboard navigation and virtualized rendering for performance with large lists.
|
||||
*
|
||||
* @template Item - The type of data items in the list
|
||||
* @template Context - The type of additional context data passed to items
|
||||
*/
|
||||
export function ListView<Item, Context = any>(props: IListViewProps<Item, Context>): React.ReactElement {
|
||||
// Extract our custom props to avoid conflicts with Virtuoso props
|
||||
const { items, onSelectItem, getItemComponent, isItemFocusable, getItemKey, context, ...virtuosoProps } = props;
|
||||
/** Reference to the Virtuoso component for programmatic scrolling */
|
||||
const virtuosoHandleRef = useRef<VirtuosoHandle>(null);
|
||||
/** Reference to the DOM element containing the virtualized list */
|
||||
const virtuosoDomRef = useRef<HTMLElement | Window>(null);
|
||||
/** Key of the item that should have tabIndex == 0 */
|
||||
const [tabIndexKey, setTabIndexKey] = useState<string | undefined>(
|
||||
props.items[0] ? getItemKey(props.items[0]) : undefined,
|
||||
);
|
||||
/** Range of currently visible items in the viewport */
|
||||
const [visibleRange, setVisibleRange] = useState<ListRange | undefined>(undefined);
|
||||
/** Map from item keys to their indices in the items array */
|
||||
const [keyToIndexMap, setKeyToIndexMap] = useState<Map<string, number>>(new Map());
|
||||
/** Whether the list is currently scrolling to an item */
|
||||
const isScrollingToItem = useRef<boolean>(false);
|
||||
/** Whether the list is currently focused */
|
||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||
|
||||
// Update the key-to-index mapping whenever items change
|
||||
useEffect(() => {
|
||||
const newKeyToIndexMap = new Map<string, number>();
|
||||
items.forEach((item, index) => {
|
||||
const key = getItemKey(item);
|
||||
newKeyToIndexMap.set(key, index);
|
||||
});
|
||||
setKeyToIndexMap(newKeyToIndexMap);
|
||||
}, [items, getItemKey]);
|
||||
|
||||
// Ensure the tabIndexKey is set if there is none already or if the existing key is no longer displayed
|
||||
useEffect(() => {
|
||||
if (items.length && (!tabIndexKey || keyToIndexMap.get(tabIndexKey) === undefined)) {
|
||||
setTabIndexKey(getItemKey(items[0]));
|
||||
}
|
||||
}, [items, getItemKey, tabIndexKey, keyToIndexMap]);
|
||||
|
||||
/**
|
||||
* Scrolls to a specific item index and sets it as focused.
|
||||
* Uses Virtuoso's scrollIntoView method for smooth scrolling.
|
||||
*/
|
||||
const scrollToIndex = useCallback(
|
||||
(index: number, align?: "center" | "end" | "start"): void => {
|
||||
// Ensure index is within bounds
|
||||
const clampedIndex = Math.max(0, Math.min(index, items.length - 1));
|
||||
if (isScrollingToItem.current) {
|
||||
// If already scrolling to an item drop this request. Adding further requests
|
||||
// causes the event to bubble up and be handled by other components(unintentional timeline scrolling was observed).
|
||||
return;
|
||||
}
|
||||
if (items[clampedIndex]) {
|
||||
const key = getItemKey(items[clampedIndex]);
|
||||
setTabIndexKey(key);
|
||||
isScrollingToItem.current = true;
|
||||
virtuosoHandleRef?.current?.scrollIntoView({
|
||||
index: clampedIndex,
|
||||
align: align,
|
||||
behavior: "auto",
|
||||
done: () => {
|
||||
isScrollingToItem.current = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[items, getItemKey],
|
||||
);
|
||||
|
||||
/**
|
||||
* Scrolls to an item, skipping over non-focusable items if necessary.
|
||||
* This is used for keyboard navigation to ensure focus lands on valid items.
|
||||
*/
|
||||
const scrollToItem = useCallback(
|
||||
(index: number, isDirectionDown: boolean, align?: "center" | "end" | "start"): void => {
|
||||
const totalRows = items.length;
|
||||
let nextIndex: number | undefined;
|
||||
|
||||
for (let i = index; isDirectionDown ? i < totalRows : i >= 0; i = i + (isDirectionDown ? 1 : -1)) {
|
||||
if (isItemFocusable(items[i])) {
|
||||
nextIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollToIndex(nextIndex, align);
|
||||
},
|
||||
[scrollToIndex, items, isItemFocusable],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles keyboard navigation for the list.
|
||||
* Supports Arrow keys, Home, End, Page Up/Down, Enter, and Space.
|
||||
*/
|
||||
const keyDownCallback = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!e) return; // Guard against null/undefined events
|
||||
|
||||
const currentIndex = tabIndexKey ? keyToIndexMap.get(tabIndexKey) : undefined;
|
||||
|
||||
let handled = false;
|
||||
if (e.code === "ArrowUp" && currentIndex !== undefined) {
|
||||
scrollToItem(currentIndex - 1, false);
|
||||
handled = true;
|
||||
} else if (e.code === "ArrowDown" && currentIndex !== undefined) {
|
||||
scrollToItem(currentIndex + 1, true);
|
||||
handled = true;
|
||||
} else if ((e.code === "Enter" || e.code === "Space") && currentIndex !== undefined) {
|
||||
const item = items[currentIndex];
|
||||
onSelectItem(item);
|
||||
handled = true;
|
||||
} else if (e.code === "Home") {
|
||||
scrollToIndex(0);
|
||||
handled = true;
|
||||
} else if (e.code === "End") {
|
||||
scrollToIndex(items.length - 1);
|
||||
handled = true;
|
||||
} else if (e.code === "PageDown" && visibleRange && currentIndex !== undefined) {
|
||||
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
|
||||
scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, `start`);
|
||||
handled = true;
|
||||
} else if (e.code === "PageUp" && visibleRange && currentIndex !== undefined) {
|
||||
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
|
||||
scrollToItem(Math.max(currentIndex - numberDisplayed, 0), false, `start`);
|
||||
handled = true;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onSelectItem],
|
||||
);
|
||||
|
||||
/**
|
||||
* Callback ref for the Virtuoso scroller element.
|
||||
* Stores the reference for use in focus management.
|
||||
*/
|
||||
const scrollerRef = useCallback((element: HTMLElement | Window | null) => {
|
||||
virtuosoDomRef.current = element;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handles focus events on the list.
|
||||
* Sets the focused state and scrolls to the focused item if it is not currently visible.
|
||||
*/
|
||||
const onFocus = useCallback(
|
||||
(e?: React.FocusEvent): void => {
|
||||
if (e?.currentTarget !== virtuosoDomRef.current || typeof tabIndexKey !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFocused(true);
|
||||
const index = keyToIndexMap.get(tabIndexKey);
|
||||
if (
|
||||
index !== undefined &&
|
||||
visibleRange &&
|
||||
(index < visibleRange.startIndex || index > visibleRange.endIndex)
|
||||
) {
|
||||
scrollToIndex(index);
|
||||
}
|
||||
e?.stopPropagation();
|
||||
e?.preventDefault();
|
||||
},
|
||||
[keyToIndexMap, visibleRange, scrollToIndex, tabIndexKey],
|
||||
);
|
||||
|
||||
const onBlur = useCallback((): void => {
|
||||
setIsFocused(false);
|
||||
}, []);
|
||||
|
||||
const listContext: ListContext<Context> = {
|
||||
tabIndexKey: tabIndexKey,
|
||||
focused: isFocused,
|
||||
context: props.context || ({} as Context),
|
||||
};
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
tabIndex={props.tabIndex || undefined} // We don't need to focus the container, so leave it undefined by default
|
||||
scrollerRef={scrollerRef}
|
||||
ref={virtuosoHandleRef}
|
||||
onKeyDown={keyDownCallback}
|
||||
context={listContext}
|
||||
rangeChanged={setVisibleRange}
|
||||
// virtuoso errors internally if you pass undefined.
|
||||
overscan={props.overscan || 0}
|
||||
data={props.items}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
itemContent={props.getItemComponent}
|
||||
{...virtuosoProps}
|
||||
/>
|
||||
);
|
||||
}
|
@@ -38,6 +38,8 @@ import { isValid3pidInvite } from "../../../RoomInvite";
|
||||
import { type ThreePIDInvite } from "../../../models/rooms/ThreePIDInvite";
|
||||
import { type XOR } from "../../../@types/common";
|
||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
|
||||
type Member = XOR<{ member: RoomMember }, { threePidInvite: ThreePIDInvite }>;
|
||||
|
||||
@@ -111,6 +113,7 @@ export interface MemberListViewState {
|
||||
shouldShowSearch: boolean;
|
||||
isLoading: boolean;
|
||||
canInvite: boolean;
|
||||
onClickMember: (member: RoomMember | ThreePIDInvite) => void;
|
||||
onInviteButtonClick: (ev: ButtonEvent) => void;
|
||||
}
|
||||
export function useMemberListViewModel(roomId: string): MemberListViewState {
|
||||
@@ -133,6 +136,14 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
|
||||
*/
|
||||
const [memberCount, setMemberCount] = useState(0);
|
||||
|
||||
const onClickMember = (member: RoomMember | ThreePIDInvite): void => {
|
||||
dis.dispatch({
|
||||
action: Action.ViewUser,
|
||||
member: member,
|
||||
push: true,
|
||||
});
|
||||
};
|
||||
|
||||
const loadMembers = useMemo(
|
||||
() =>
|
||||
throttle(
|
||||
@@ -267,6 +278,7 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
|
||||
isPresenceEnabled,
|
||||
isLoading,
|
||||
onInviteButtonClick,
|
||||
onClickMember,
|
||||
shouldShowSearch: totalMemberCount >= 20,
|
||||
canInvite,
|
||||
};
|
||||
|
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RoomStateEvent, type MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
|
||||
import { type UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
@@ -16,7 +16,6 @@ import { asyncSome } from "../../../../utils/arrays";
|
||||
import { getUserDeviceIds } from "../../../../utils/crypto/deviceInfo";
|
||||
import { type RoomMember } from "../../../../models/rooms/RoomMember";
|
||||
import { _t, _td, type TranslationKey } from "../../../../languageHandler";
|
||||
import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier";
|
||||
import { E2EStatus } from "../../../../utils/ShieldUtils";
|
||||
|
||||
interface MemberTileViewModelProps {
|
||||
@@ -28,7 +27,6 @@ export interface MemberTileViewState extends MemberTileViewModelProps {
|
||||
e2eStatus?: E2EStatus;
|
||||
name: string;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
userLabel?: string;
|
||||
}
|
||||
|
||||
@@ -130,15 +128,6 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT
|
||||
}
|
||||
}
|
||||
|
||||
const title = useMemo(() => {
|
||||
return _t("member_list|power_label", {
|
||||
userName: UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, {
|
||||
roomId: member.roomId,
|
||||
}),
|
||||
powerLevelNumber: member.powerLevel,
|
||||
}).trim();
|
||||
}, [member.powerLevel, member.roomId, member.userId]);
|
||||
|
||||
let userLabel;
|
||||
const powerStatus = powerStatusMap.get(powerLevel);
|
||||
if (powerStatus) {
|
||||
@@ -149,7 +138,6 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
member,
|
||||
name,
|
||||
onClick,
|
||||
|
@@ -19,10 +19,10 @@ interface TooltipProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const OptionalTooltip: React.FC<TooltipProps> = ({ canInvite, children }) => {
|
||||
if (canInvite) return children;
|
||||
const InviteTooltip: React.FC<TooltipProps> = ({ canInvite, children }) => {
|
||||
const description: string = canInvite ? _t("action|invite") : _t("member_list|invite_button_no_perms_tooltip");
|
||||
// If the user isn't allowed to invite others to this room, wrap with a relevant tooltip.
|
||||
return <Tooltip description={_t("member_list|invite_button_no_perms_tooltip")}>{children}</Tooltip>;
|
||||
return <Tooltip description={description}>{children}</Tooltip>;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -42,7 +42,7 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
|
||||
if (shouldShowSearch) {
|
||||
/// When rendered alongside a search box, the invite button is just an icon.
|
||||
return (
|
||||
<OptionalTooltip canInvite={vm.canInvite}>
|
||||
<InviteTooltip canInvite={vm.canInvite}>
|
||||
<Button
|
||||
className="mx_MemberListHeaderView_invite_small"
|
||||
kind="secondary"
|
||||
@@ -54,13 +54,13 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
|
||||
aria-label={_t("action|invite")}
|
||||
type="button"
|
||||
/>
|
||||
</OptionalTooltip>
|
||||
</InviteTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Without a search box, invite button is a full size button.
|
||||
return (
|
||||
<OptionalTooltip canInvite={vm.canInvite}>
|
||||
<InviteTooltip canInvite={vm.canInvite}>
|
||||
<Button
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
@@ -72,7 +72,7 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
|
||||
>
|
||||
{_t("action|invite")}
|
||||
</Button>
|
||||
</OptionalTooltip>
|
||||
</InviteTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -6,9 +6,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Form } from "@vector-im/compound-web";
|
||||
import React, { type JSX } from "react";
|
||||
import { List, type ListRowProps } from "react-virtualized/dist/commonjs/List";
|
||||
import { AutoSizer } from "react-virtualized";
|
||||
import React, { type JSX, useCallback } from "react";
|
||||
|
||||
import { Flex } from "../../../../shared-components/utils/Flex";
|
||||
import {
|
||||
@@ -21,7 +19,7 @@ import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView";
|
||||
import { MemberListHeaderView } from "./MemberListHeaderView";
|
||||
import BaseCard from "../../right_panel/BaseCard";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { RovingTabIndexProvider } from "../../../../accessibility/RovingTabIndex";
|
||||
import { type ListContext, ListView } from "../../../utils/ListView";
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
@@ -30,53 +28,67 @@ interface IProps {
|
||||
|
||||
const MemberListView: React.FC<IProps> = (props: IProps) => {
|
||||
const vm = useMemberListViewModel(props.roomId);
|
||||
const { isPresenceEnabled, onClickMember, memberCount } = vm;
|
||||
|
||||
const totalRows = vm.members.length;
|
||||
const getItemKey = useCallback((item: MemberWithSeparator): string => {
|
||||
if (item === SEPARATOR) {
|
||||
return "separator";
|
||||
} else if (item.member) {
|
||||
return `member-${item.member.userId}`;
|
||||
} else {
|
||||
return `threePidInvite-${item.threePidInvite.event.getContent().public_key}`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getRowComponent = (item: MemberWithSeparator): JSX.Element => {
|
||||
const getItemComponent = useCallback(
|
||||
(index: number, item: MemberWithSeparator, context: ListContext<any>): JSX.Element => {
|
||||
const itemKey = getItemKey(item);
|
||||
const isRovingItem = itemKey === context.tabIndexKey;
|
||||
const focused = isRovingItem && context.focused;
|
||||
if (item === SEPARATOR) {
|
||||
return <hr className="mx_MemberListView_separator" />;
|
||||
} else if (item.member) {
|
||||
return <RoomMemberTileView member={item.member} showPresence={vm.isPresenceEnabled} />;
|
||||
} else {
|
||||
return <ThreePidInviteTileView threePidInvite={item.threePidInvite} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getRowHeight = ({ index }: { index: number }): number => {
|
||||
if (vm.members[index] === SEPARATOR) {
|
||||
/**
|
||||
* This is a separator of 2px height rendered between
|
||||
* joined and invited members.
|
||||
*/
|
||||
return 2;
|
||||
} else if (totalRows && index === totalRows) {
|
||||
/**
|
||||
* The empty spacer div rendered at the bottom should
|
||||
* have a height of 32px.
|
||||
*/
|
||||
return 32;
|
||||
} else {
|
||||
/**
|
||||
* The actual member tiles have a height of 56px.
|
||||
*/
|
||||
return 56;
|
||||
}
|
||||
};
|
||||
|
||||
const rowRenderer = ({ key, index, style }: ListRowProps): JSX.Element => {
|
||||
if (index === totalRows) {
|
||||
// We've rendered all the members,
|
||||
// now we render an empty div to add some space to the end of the list.
|
||||
return <div key={key} style={style} />;
|
||||
}
|
||||
const item = vm.members[index];
|
||||
return (
|
||||
<div key={key} style={style}>
|
||||
{getRowComponent(item)}
|
||||
</div>
|
||||
<RoomMemberTileView
|
||||
member={item.member}
|
||||
showPresence={isPresenceEnabled}
|
||||
focused={focused}
|
||||
tabIndex={isRovingItem ? 0 : -1}
|
||||
index={index}
|
||||
memberCount={memberCount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
} else {
|
||||
return (
|
||||
<ThreePidInviteTileView
|
||||
threePidInvite={item.threePidInvite}
|
||||
focused={focused}
|
||||
tabIndex={isRovingItem ? 0 : -1}
|
||||
memberIndex={index - 1} // Adjust as invites are below the separator
|
||||
memberCount={memberCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[isPresenceEnabled, getItemKey, memberCount],
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback(
|
||||
(item: MemberWithSeparator): void => {
|
||||
if (item !== SEPARATOR) {
|
||||
if (item.member) {
|
||||
onClickMember(item.member);
|
||||
} else {
|
||||
onClickMember(item.threePidInvite);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onClickMember],
|
||||
);
|
||||
|
||||
const isItemFocusable = useCallback((item: MemberWithSeparator): boolean => {
|
||||
return item !== SEPARATOR;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
@@ -87,34 +99,20 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
|
||||
header={_t("common|people")}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
<RovingTabIndexProvider handleUpDown scrollIntoView>
|
||||
{({ onKeyDownHandler }) => (
|
||||
<Flex
|
||||
align="stretch"
|
||||
direction="column"
|
||||
className="mx_MemberListView_container"
|
||||
onKeyDown={onKeyDownHandler}
|
||||
>
|
||||
<Flex align="stretch" direction="column" className="mx_MemberListView_container">
|
||||
<Form.Root onSubmit={(e) => e.preventDefault()}>
|
||||
<MemberListHeaderView vm={vm} />
|
||||
</Form.Root>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<List
|
||||
rowRenderer={rowRenderer}
|
||||
rowHeight={getRowHeight}
|
||||
// The +1 refers to the additional empty div that we render at the end of the list.
|
||||
rowCount={totalRows + 1}
|
||||
// Subtract the height of MemberlistHeaderView so that the parent div does not overflow.
|
||||
height={height - 113}
|
||||
width={width}
|
||||
overscanRowCount={15}
|
||||
<ListView
|
||||
items={vm.members}
|
||||
onSelectItem={handleSelectItem}
|
||||
getItemComponent={getItemComponent}
|
||||
getItemKey={getItemKey}
|
||||
isItemFocusable={isItemFocusable}
|
||||
role="listbox"
|
||||
aria-label={_t("member_list|list_title")}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Flex>
|
||||
)}
|
||||
</RovingTabIndexProvider>
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
|
@@ -19,7 +19,11 @@ import { InvitedIconView } from "./common/InvitedIconView";
|
||||
|
||||
interface IProps {
|
||||
member: RoomMember;
|
||||
index: number;
|
||||
memberCount: number;
|
||||
showPresence?: boolean;
|
||||
focused?: boolean;
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
export function RoomMemberTileView(props: IProps): JSX.Element {
|
||||
@@ -36,7 +40,7 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
|
||||
/>
|
||||
);
|
||||
const name = vm.name;
|
||||
const nameJSX = <DisambiguatedProfile member={member} fallbackName={name || ""} />;
|
||||
const nameJSX = <DisambiguatedProfile withTooltip member={member} fallbackName={name || ""} />;
|
||||
|
||||
const presenceState = member.presenceState;
|
||||
let presenceJSX: JSX.Element | undefined;
|
||||
@@ -54,13 +58,17 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<MemberTileView
|
||||
title={vm.title}
|
||||
onClick={vm.onClick}
|
||||
avatarJsx={av}
|
||||
presenceJsx={presenceJSX}
|
||||
nameJsx={nameJSX}
|
||||
userLabel={vm.userLabel}
|
||||
ariaLabel={name}
|
||||
iconJsx={iconJsx}
|
||||
focused={props.focused}
|
||||
tabIndex={props.tabIndex}
|
||||
memberIndex={props.index - (member.isInvite ? 1 : 0)} // Adjust as invites are below the seperator
|
||||
memberCount={props.memberCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@@ -15,20 +15,30 @@ import { InvitedIconView } from "./common/InvitedIconView";
|
||||
|
||||
interface Props {
|
||||
threePidInvite: ThreePIDInvite;
|
||||
memberIndex: number;
|
||||
memberCount: number;
|
||||
focused?: boolean;
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
export function ThreePidInviteTileView(props: Props): JSX.Element {
|
||||
const vm = useThreePidTileViewModel(props);
|
||||
const av = <BaseAvatar name={vm.name} size="32px" aria-hidden="true" />;
|
||||
const iconJsx = <InvitedIconView isThreePid={true} />;
|
||||
const name = vm.name;
|
||||
|
||||
return (
|
||||
<MemberTileView
|
||||
nameJsx={vm.name}
|
||||
nameJsx={name}
|
||||
avatarJsx={av}
|
||||
onClick={vm.onClick}
|
||||
memberIndex={props.memberIndex}
|
||||
memberCount={props.memberCount}
|
||||
ariaLabel={name}
|
||||
userLabel={vm.userLabel}
|
||||
iconJsx={iconJsx}
|
||||
focused={props.focused}
|
||||
tabIndex={props.tabIndex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@@ -5,18 +5,22 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import React, { useEffect, useRef, type JSX } from "react";
|
||||
|
||||
import { RovingAccessibleButton } from "../../../../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton from "../../../../elements/AccessibleButton";
|
||||
|
||||
interface Props {
|
||||
avatarJsx: JSX.Element;
|
||||
nameJsx: JSX.Element | string;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
memberIndex: number;
|
||||
memberCount: number;
|
||||
ariaLabel?: string;
|
||||
presenceJsx?: JSX.Element;
|
||||
userLabel?: React.ReactNode;
|
||||
iconJsx?: JSX.Element;
|
||||
tabIndex?: number;
|
||||
focused?: boolean;
|
||||
}
|
||||
|
||||
export function MemberTileView(props: Props): JSX.Element {
|
||||
@@ -24,22 +28,36 @@ export function MemberTileView(props: Props): JSX.Element {
|
||||
if (props.userLabel) {
|
||||
userLabelJsx = <div className="mx_MemberTileView_userLabel">{props.userLabel}</div>;
|
||||
}
|
||||
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (props.focused) {
|
||||
ref.current?.focus({ preventScroll: true, focusVisible: true });
|
||||
}
|
||||
}, [props.focused]);
|
||||
return (
|
||||
// The wrapping div is required to make the magic mouse listener work, for some reason.
|
||||
<div>
|
||||
<RovingAccessibleButton className="mx_MemberTileView" title={props.title} onClick={props.onClick}>
|
||||
<div className="mx_MemberTileView_left">
|
||||
<AccessibleButton
|
||||
ref={ref}
|
||||
className="mx_MemberTileView"
|
||||
onClick={props.onClick}
|
||||
aria-label={props?.ariaLabel}
|
||||
tabIndex={props.tabIndex}
|
||||
role="option"
|
||||
aria-posinset={props.memberIndex + 1}
|
||||
aria-setsize={props.memberCount}
|
||||
>
|
||||
<div aria-hidden className="mx_MemberTileView_left">
|
||||
<div className="mx_MemberTileView_avatar">
|
||||
{props.avatarJsx} {props.presenceJsx}
|
||||
</div>
|
||||
<div className="mx_MemberTileView_name">{props.nameJsx}</div>
|
||||
</div>
|
||||
<div className="mx_MemberTileView_right">
|
||||
<div aria-hidden className="mx_MemberTileView_right">
|
||||
{userLabelJsx}
|
||||
{props.iconJsx}
|
||||
</div>
|
||||
</RovingAccessibleButton>
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1658,8 +1658,8 @@
|
||||
"filter_placeholder": "Search room members",
|
||||
"invite_button_no_perms_tooltip": "You do not have permission to invite users",
|
||||
"invited_label": "Invited",
|
||||
"no_matches": "No matches",
|
||||
"power_label": "%(userName)s (power %(powerLevelNumber)s)"
|
||||
"list_title": "Member list",
|
||||
"no_matches": "No matches"
|
||||
},
|
||||
"member_list_back_action_label": "Room members",
|
||||
"message_edit_dialog_title": "Message edits",
|
||||
|
@@ -35,7 +35,7 @@ describe("MemberTileView", () => {
|
||||
});
|
||||
|
||||
it("should not display an E2EIcon when the e2E status = normal", () => {
|
||||
const { container } = render(<RoomMemberTileView member={member} />);
|
||||
const { container } = render(<RoomMemberTileView member={member} index={0} memberCount={1} />);
|
||||
const e2eIcon = container.querySelector(".mx_E2EIconView");
|
||||
expect(e2eIcon).toBeNull();
|
||||
expect(container).toMatchSnapshot();
|
||||
@@ -47,7 +47,7 @@ describe("MemberTileView", () => {
|
||||
wasCrossSigningVerified: jest.fn().mockReturnValue(true),
|
||||
} as unknown as UserVerificationStatus);
|
||||
|
||||
const { container } = render(<RoomMemberTileView member={member} />);
|
||||
const { container } = render(<RoomMemberTileView member={member} index={0} memberCount={1} />);
|
||||
await waitFor(async () => {
|
||||
await userEvent.hover(container.querySelector(".mx_E2EIcon")!);
|
||||
expect(screen.getByText("This user has not verified all of their sessions.")).toBeInTheDocument();
|
||||
@@ -68,7 +68,7 @@ describe("MemberTileView", () => {
|
||||
crossSigningVerified: true,
|
||||
} as DeviceVerificationStatus);
|
||||
|
||||
const { container } = render(<RoomMemberTileView member={member} />);
|
||||
const { container } = render(<RoomMemberTileView member={member} index={0} memberCount={1} />);
|
||||
|
||||
await waitFor(async () => {
|
||||
await userEvent.hover(container.querySelector(".mx_E2EIcon")!);
|
||||
@@ -81,15 +81,15 @@ describe("MemberTileView", () => {
|
||||
|
||||
it("renders user labels correctly", async () => {
|
||||
member.powerLevel = 50;
|
||||
const { container: container1 } = render(<RoomMemberTileView member={member} />);
|
||||
const { container: container1 } = render(<RoomMemberTileView member={member} index={0} memberCount={1} />);
|
||||
expect(container1).toHaveTextContent("Moderator");
|
||||
|
||||
member.powerLevel = 100;
|
||||
const { container: container2 } = render(<RoomMemberTileView member={member} />);
|
||||
const { container: container2 } = render(<RoomMemberTileView member={member} index={0} memberCount={1} />);
|
||||
expect(container2).toHaveTextContent("Admin");
|
||||
|
||||
member.isInvite = true;
|
||||
const { container: container3 } = render(<RoomMemberTileView member={member} />);
|
||||
const { container: container3 } = render(<RoomMemberTileView member={member} index={0} memberCount={1} />);
|
||||
expect(container3).toHaveTextContent("Invited");
|
||||
});
|
||||
});
|
||||
@@ -109,7 +109,9 @@ describe("MemberTileView", () => {
|
||||
|
||||
it("renders ThreePidInvite correctly", async () => {
|
||||
const [{ threePidInvite }] = getPending3PidInvites(room);
|
||||
const { container } = render(<ThreePidInviteTileView threePidInvite={threePidInvite!} />);
|
||||
const { container } = render(
|
||||
<ThreePidInviteTileView threePidInvite={threePidInvite!} memberIndex={0} memberCount={1} />,
|
||||
);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
@@ -4,12 +4,15 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
aria-label="@userId:matrix.org (power 0)"
|
||||
aria-label="@userId:matrix.org"
|
||||
aria-posinset="1"
|
||||
aria-setsize="1"
|
||||
class="mx_AccessibleButton mx_MemberTileView"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx_MemberTileView_left"
|
||||
>
|
||||
<div
|
||||
@@ -33,6 +36,7 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile"
|
||||
title="@userId:matrix.org (@userId:matrix.org)"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
@@ -44,10 +48,11 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx_MemberTileView_right"
|
||||
>
|
||||
<div
|
||||
aria-labelledby="«ri»"
|
||||
aria-labelledby="«r6»"
|
||||
class="mx_E2EIconView"
|
||||
>
|
||||
<svg
|
||||
@@ -73,12 +78,15 @@ exports[`MemberTileView RoomMemberTileView should display an warning E2EIcon whe
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
aria-label="@userId:matrix.org (power 0)"
|
||||
aria-label="@userId:matrix.org"
|
||||
aria-posinset="1"
|
||||
aria-setsize="1"
|
||||
class="mx_AccessibleButton mx_MemberTileView"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx_MemberTileView_left"
|
||||
>
|
||||
<div
|
||||
@@ -102,6 +110,7 @@ exports[`MemberTileView RoomMemberTileView should display an warning E2EIcon whe
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile"
|
||||
title="@userId:matrix.org (@userId:matrix.org)"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
@@ -113,10 +122,11 @@ exports[`MemberTileView RoomMemberTileView should display an warning E2EIcon whe
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx_MemberTileView_right"
|
||||
>
|
||||
<div
|
||||
aria-labelledby="«r8»"
|
||||
aria-labelledby="«r0»"
|
||||
class="mx_E2EIconView"
|
||||
>
|
||||
<svg
|
||||
@@ -142,12 +152,15 @@ exports[`MemberTileView RoomMemberTileView should not display an E2EIcon when th
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
aria-label="@userId:matrix.org (power 0)"
|
||||
aria-label="@userId:matrix.org"
|
||||
aria-posinset="1"
|
||||
aria-setsize="1"
|
||||
class="mx_AccessibleButton mx_MemberTileView"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx_MemberTileView_left"
|
||||
>
|
||||
<div
|
||||
@@ -171,6 +184,7 @@ exports[`MemberTileView RoomMemberTileView should not display an E2EIcon when th
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile"
|
||||
title="@userId:matrix.org (@userId:matrix.org)"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
@@ -182,6 +196,7 @@ exports[`MemberTileView RoomMemberTileView should not display an E2EIcon when th
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx_MemberTileView_right"
|
||||
/>
|
||||
</div>
|
||||
@@ -193,11 +208,15 @@ exports[`MemberTileView ThreePidInviteTileView renders ThreePidInvite correctly
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
aria-label="Foobar"
|
||||
aria-posinset="1"
|
||||
aria-setsize="1"
|
||||
class="mx_AccessibleButton mx_MemberTileView"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx_MemberTileView_left"
|
||||
>
|
||||
<div
|
||||
@@ -223,6 +242,7 @@ exports[`MemberTileView ThreePidInviteTileView renders ThreePidInvite correctly
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx_MemberTileView_right"
|
||||
>
|
||||
<div
|
||||
|
@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React, { act } from "react";
|
||||
import { render, type RenderResult, waitFor } from "jest-matrix-react";
|
||||
import { VirtuosoMockContext } from "react-virtuoso";
|
||||
import {
|
||||
Room,
|
||||
type MatrixClient,
|
||||
@@ -121,6 +122,13 @@ export async function renderMemberList(
|
||||
<MemberListView roomId={memberListRoom.roomId} onClose={() => {}} />
|
||||
</SDKContext.Provider>
|
||||
</MatrixClientContext.Provider>,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 56 }}>
|
||||
{children}
|
||||
</VirtuosoMockContext.Provider>
|
||||
),
|
||||
},
|
||||
);
|
||||
await waitFor(async () => {
|
||||
expect(root.container.querySelectorAll(".mx_MemberTileView")).toHaveLength(usersPerLevel * 3);
|
||||
|
377
test/unit-tests/components/views/utils/ListView-test.tsx
Normal file
377
test/unit-tests/components/views/utils/ListView-test.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
/*
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
@@ -13091,6 +13091,11 @@ react-virtualized@^9.22.5:
|
||||
prop-types "^15.7.2"
|
||||
react-lifecycles-compat "^3.0.4"
|
||||
|
||||
react-virtuoso@^4.12.6:
|
||||
version "4.12.6"
|
||||
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.12.6.tgz#20fe374d43cce3c9821e29f4cc4d050596d06d01"
|
||||
integrity sha512-bfvS6aCL1ehXmq39KRiz/vxznGUbtA27I5I24TYCe1DhMf84O3aVNCIwrSjYQjkJGJGzY46ihdN8WkYlemuhMQ==
|
||||
|
||||
"react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", react@^19.0.0:
|
||||
version "19.1.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.1.0.tgz#926864b6c48da7627f004795d6cce50e90793b75"
|
||||
|
Reference in New Issue
Block a user