1
0
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:
David Langley
2025-07-31 16:49:53 +01:00
committed by GitHub
parent ab6ef2fa85
commit cc0ece9837
18 changed files with 849 additions and 126 deletions

View File

@@ -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",

View File

@@ -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 });

View File

@@ -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

View 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}
/>
);
}

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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}
/>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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",

View File

@@ -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();
});
});

View File

@@ -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

View File

@@ -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);

View 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");
});
});
});

View File

@@ -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"