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