From cc0ece9837b2d263450544340d8f35c181e82537 Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 31 Jul 2025 16:49:53 +0100 Subject: [PATCH] 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 --- package.json | 1 + .../e2e/lazy-loading/lazy-loading.spec.ts | 4 + .../e2e/share-dialog/share-dialog.spec.ts | 2 +- .../with-four-members-linux.png | Bin 19391 -> 18835 bytes src/components/utils/ListView.tsx | 272 +++++++++++++ .../memberlist/MemberListViewModel.tsx | 12 + .../memberlist/tiles/MemberTileViewModel.tsx | 14 +- .../rooms/MemberList/MemberListHeaderView.tsx | 14 +- .../views/rooms/MemberList/MemberListView.tsx | 142 ++++--- .../MemberList/tiles/RoomMemberTileView.tsx | 12 +- .../tiles/ThreePidInviteTileView.tsx | 12 +- .../tiles/common/MemberTileView.tsx | 34 +- src/i18n/strings/en_EN.json | 4 +- .../rooms/memberlist/MemberTileView-test.tsx | 16 +- .../MemberTileView-test.tsx.snap | 46 ++- .../views/rooms/memberlist/common.tsx | 8 + .../components/views/utils/ListView-test.tsx | 377 ++++++++++++++++++ yarn.lock | 5 + 18 files changed, 849 insertions(+), 126 deletions(-) create mode 100644 src/components/utils/ListView.tsx create mode 100644 test/unit-tests/components/views/utils/ListView-test.tsx diff --git a/package.json b/package.json index bbb0b176a1..363276af27 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright/e2e/lazy-loading/lazy-loading.spec.ts b/playwright/e2e/lazy-loading/lazy-loading.spec.ts index 7c31c288fa..f6f098a079 100644 --- a/playwright/e2e/lazy-loading/lazy-loading.spec.ts +++ b/playwright/e2e/lazy-loading/lazy-loading.spec.ts @@ -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 }); diff --git a/playwright/e2e/share-dialog/share-dialog.spec.ts b/playwright/e2e/share-dialog/share-dialog.spec.ts index 58574a46ff..a77e89fcdc 100644 --- a/playwright/e2e/share-dialog/share-dialog.spec.ts +++ b/playwright/e2e/share-dialog/share-dialog.spec.ts @@ -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" }); diff --git a/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png b/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png index a5db88aae638325eee39eefd8b087d0b27d1e5eb..ab5b0ea76c6dd5e7fe48693310a776a0eb90f663 100644 GIT binary patch literal 18835 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 Q*FfI#boFyt=akR{0K3oHd;kCd literal 19391 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 = { + /** 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 + extends Omit>, "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) => 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(props: IListViewProps): 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(null); + /** Reference to the DOM element containing the virtualized list */ + const virtuosoDomRef = useRef(null); + /** Key of the item that should have tabIndex == 0 */ + const [tabIndexKey, setTabIndexKey] = useState( + props.items[0] ? getItemKey(props.items[0]) : undefined, + ); + /** Range of currently visible items in the viewport */ + const [visibleRange, setVisibleRange] = useState(undefined); + /** Map from item keys to their indices in the items array */ + const [keyToIndexMap, setKeyToIndexMap] = useState>(new Map()); + /** Whether the list is currently scrolling to an item */ + const isScrollingToItem = useRef(false); + /** Whether the list is currently focused */ + const [isFocused, setIsFocused] = useState(false); + + // Update the key-to-index mapping whenever items change + useEffect(() => { + const newKeyToIndexMap = new Map(); + 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 = { + tabIndexKey: tabIndexKey, + focused: isFocused, + context: props.context || ({} as Context), + }; + + return ( + + ); +} diff --git a/src/components/viewmodels/memberlist/MemberListViewModel.tsx b/src/components/viewmodels/memberlist/MemberListViewModel.tsx index a03f703511..55220d29a9 100644 --- a/src/components/viewmodels/memberlist/MemberListViewModel.tsx +++ b/src/components/viewmodels/memberlist/MemberListViewModel.tsx @@ -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, }; diff --git a/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx b/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx index 4f6814caae..0355fe47a3 100644 --- a/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx +++ b/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx @@ -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, diff --git a/src/components/views/rooms/MemberList/MemberListHeaderView.tsx b/src/components/views/rooms/MemberList/MemberListHeaderView.tsx index c1520acd44..5d096c7228 100644 --- a/src/components/views/rooms/MemberList/MemberListHeaderView.tsx +++ b/src/components/views/rooms/MemberList/MemberListHeaderView.tsx @@ -19,10 +19,10 @@ interface TooltipProps { children: React.ReactNode; } -const OptionalTooltip: React.FC = ({ canInvite, children }) => { - if (canInvite) return children; +const InviteTooltip: React.FC = ({ 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 {children}; + return {children}; }; interface Props { @@ -42,7 +42,7 @@ const InviteButton: React.FC = ({ vm }) => { if (shouldShowSearch) { /// When rendered alongside a search box, the invite button is just an icon. return ( - + - + ); }; diff --git a/src/components/views/rooms/MemberList/MemberListView.tsx b/src/components/views/rooms/MemberList/MemberListView.tsx index 1c85b3188e..8afdeaf990 100644 --- a/src/components/views/rooms/MemberList/MemberListView.tsx +++ b/src/components/views/rooms/MemberList/MemberListView.tsx @@ -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 = (props: IProps) => { const vm = useMemberListViewModel(props.roomId); + const { isPresenceEnabled, onClickMember, memberCount } = vm; - const totalRows = vm.members.length; - - const getRowComponent = (item: MemberWithSeparator): JSX.Element => { + const getItemKey = useCallback((item: MemberWithSeparator): string => { if (item === SEPARATOR) { - return
; + return "separator"; } else if (item.member) { - return ; + return `member-${item.member.userId}`; } else { - return ; + return `threePidInvite-${item.threePidInvite.event.getContent().public_key}`; } - }; + }, []); - 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 getItemComponent = useCallback( + (index: number, item: MemberWithSeparator, context: ListContext): JSX.Element => { + const itemKey = getItemKey(item); + const isRovingItem = itemKey === context.tabIndexKey; + const focused = isRovingItem && context.focused; + if (item === SEPARATOR) { + return
; + } else if (item.member) { + return ( + + ); + } else { + return ( + + ); + } + }, + [isPresenceEnabled, getItemKey, memberCount], + ); - 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
; - } - const item = vm.members[index]; - return ( -
- {getRowComponent(item)} -
- ); - }; + 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 ( = (props: IProps) => { header={_t("common|people")} onClose={props.onClose} > - - {({ onKeyDownHandler }) => ( - - e.preventDefault()}> - - - - {({ height, width }) => ( - - )} - - - )} - + + e.preventDefault()}> + + + + ); }; diff --git a/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx b/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx index f5fd5203a5..4837972da3 100644 --- a/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx +++ b/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx @@ -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 = ; + const nameJSX = ; const presenceState = member.presenceState; let presenceJSX: JSX.Element | undefined; @@ -54,13 +58,17 @@ export function RoomMemberTileView(props: IProps): JSX.Element { return ( ); } diff --git a/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx b/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx index 4f6caf06f6..0a93727f5f 100644 --- a/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx +++ b/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx @@ -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 =