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-string-replace": "^1.1.1",
|
||||||
"react-transition-group": "^4.4.1",
|
"react-transition-group": "^4.4.1",
|
||||||
"react-virtualized": "^9.22.5",
|
"react-virtualized": "^9.22.5",
|
||||||
|
"react-virtuoso": "^4.12.6",
|
||||||
"rfc4648": "^1.4.0",
|
"rfc4648": "^1.4.0",
|
||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
"sanitize-html": "2.17.0",
|
"sanitize-html": "2.17.0",
|
||||||
|
@@ -30,6 +30,10 @@ test.describe("Lazy Loading", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async ({ page, homeserver, user, bot, app }) => {
|
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++) {
|
for (let i = 1; i <= 10; i++) {
|
||||||
const displayName = `Charly #${i}`;
|
const displayName = `Charly #${i}`;
|
||||||
const bot = new Bot(page, homeserver, { displayName, startClient: false, autoAcceptInvites: false });
|
const bot = new Bot(page, homeserver, { displayName, startClient: false, autoAcceptInvites: false });
|
||||||
|
@@ -35,7 +35,7 @@ test.describe("Share dialog", () => {
|
|||||||
|
|
||||||
const rightPanel = await app.toggleRoomInfoPanel();
|
const rightPanel = await app.toggleRoomInfoPanel();
|
||||||
await rightPanel.getByRole("menuitem", { name: "People" }).click();
|
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();
|
await rightPanel.getByRole("button", { name: "Share profile" }).click();
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog", { name: "Share User" });
|
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 ThreePIDInvite } from "../../../models/rooms/ThreePIDInvite";
|
||||||
import { type XOR } from "../../../@types/common";
|
import { type XOR } from "../../../@types/common";
|
||||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||||
|
import { Action } from "../../../dispatcher/actions";
|
||||||
|
import dis from "../../../dispatcher/dispatcher";
|
||||||
|
|
||||||
type Member = XOR<{ member: RoomMember }, { threePidInvite: ThreePIDInvite }>;
|
type Member = XOR<{ member: RoomMember }, { threePidInvite: ThreePIDInvite }>;
|
||||||
|
|
||||||
@@ -111,6 +113,7 @@ export interface MemberListViewState {
|
|||||||
shouldShowSearch: boolean;
|
shouldShowSearch: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
canInvite: boolean;
|
canInvite: boolean;
|
||||||
|
onClickMember: (member: RoomMember | ThreePIDInvite) => void;
|
||||||
onInviteButtonClick: (ev: ButtonEvent) => void;
|
onInviteButtonClick: (ev: ButtonEvent) => void;
|
||||||
}
|
}
|
||||||
export function useMemberListViewModel(roomId: string): MemberListViewState {
|
export function useMemberListViewModel(roomId: string): MemberListViewState {
|
||||||
@@ -133,6 +136,14 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
|
|||||||
*/
|
*/
|
||||||
const [memberCount, setMemberCount] = useState(0);
|
const [memberCount, setMemberCount] = useState(0);
|
||||||
|
|
||||||
|
const onClickMember = (member: RoomMember | ThreePIDInvite): void => {
|
||||||
|
dis.dispatch({
|
||||||
|
action: Action.ViewUser,
|
||||||
|
member: member,
|
||||||
|
push: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const loadMembers = useMemo(
|
const loadMembers = useMemo(
|
||||||
() =>
|
() =>
|
||||||
throttle(
|
throttle(
|
||||||
@@ -267,6 +278,7 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
|
|||||||
isPresenceEnabled,
|
isPresenceEnabled,
|
||||||
isLoading,
|
isLoading,
|
||||||
onInviteButtonClick,
|
onInviteButtonClick,
|
||||||
|
onClickMember,
|
||||||
shouldShowSearch: totalMemberCount >= 20,
|
shouldShowSearch: totalMemberCount >= 20,
|
||||||
canInvite,
|
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.
|
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 { RoomStateEvent, type MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
|
||||||
import { type UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
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 { getUserDeviceIds } from "../../../../utils/crypto/deviceInfo";
|
||||||
import { type RoomMember } from "../../../../models/rooms/RoomMember";
|
import { type RoomMember } from "../../../../models/rooms/RoomMember";
|
||||||
import { _t, _td, type TranslationKey } from "../../../../languageHandler";
|
import { _t, _td, type TranslationKey } from "../../../../languageHandler";
|
||||||
import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier";
|
|
||||||
import { E2EStatus } from "../../../../utils/ShieldUtils";
|
import { E2EStatus } from "../../../../utils/ShieldUtils";
|
||||||
|
|
||||||
interface MemberTileViewModelProps {
|
interface MemberTileViewModelProps {
|
||||||
@@ -28,7 +27,6 @@ export interface MemberTileViewState extends MemberTileViewModelProps {
|
|||||||
e2eStatus?: E2EStatus;
|
e2eStatus?: E2EStatus;
|
||||||
name: string;
|
name: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
title?: string;
|
|
||||||
userLabel?: 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;
|
let userLabel;
|
||||||
const powerStatus = powerStatusMap.get(powerLevel);
|
const powerStatus = powerStatusMap.get(powerLevel);
|
||||||
if (powerStatus) {
|
if (powerStatus) {
|
||||||
@@ -149,7 +138,6 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
|
||||||
member,
|
member,
|
||||||
name,
|
name,
|
||||||
onClick,
|
onClick,
|
||||||
|
@@ -19,10 +19,10 @@ interface TooltipProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OptionalTooltip: React.FC<TooltipProps> = ({ canInvite, children }) => {
|
const InviteTooltip: React.FC<TooltipProps> = ({ canInvite, children }) => {
|
||||||
if (canInvite) return 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.
|
// 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 {
|
interface Props {
|
||||||
@@ -42,7 +42,7 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
|
|||||||
if (shouldShowSearch) {
|
if (shouldShowSearch) {
|
||||||
/// When rendered alongside a search box, the invite button is just an icon.
|
/// When rendered alongside a search box, the invite button is just an icon.
|
||||||
return (
|
return (
|
||||||
<OptionalTooltip canInvite={vm.canInvite}>
|
<InviteTooltip canInvite={vm.canInvite}>
|
||||||
<Button
|
<Button
|
||||||
className="mx_MemberListHeaderView_invite_small"
|
className="mx_MemberListHeaderView_invite_small"
|
||||||
kind="secondary"
|
kind="secondary"
|
||||||
@@ -54,13 +54,13 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
|
|||||||
aria-label={_t("action|invite")}
|
aria-label={_t("action|invite")}
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
</OptionalTooltip>
|
</InviteTooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Without a search box, invite button is a full size button.
|
// Without a search box, invite button is a full size button.
|
||||||
return (
|
return (
|
||||||
<OptionalTooltip canInvite={vm.canInvite}>
|
<InviteTooltip canInvite={vm.canInvite}>
|
||||||
<Button
|
<Button
|
||||||
kind="secondary"
|
kind="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -72,7 +72,7 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
|
|||||||
>
|
>
|
||||||
{_t("action|invite")}
|
{_t("action|invite")}
|
||||||
</Button>
|
</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 { Form } from "@vector-im/compound-web";
|
||||||
import React, { type JSX } from "react";
|
import React, { type JSX, useCallback } from "react";
|
||||||
import { List, type ListRowProps } from "react-virtualized/dist/commonjs/List";
|
|
||||||
import { AutoSizer } from "react-virtualized";
|
|
||||||
|
|
||||||
import { Flex } from "../../../../shared-components/utils/Flex";
|
import { Flex } from "../../../../shared-components/utils/Flex";
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +19,7 @@ import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView";
|
|||||||
import { MemberListHeaderView } from "./MemberListHeaderView";
|
import { MemberListHeaderView } from "./MemberListHeaderView";
|
||||||
import BaseCard from "../../right_panel/BaseCard";
|
import BaseCard from "../../right_panel/BaseCard";
|
||||||
import { _t } from "../../../../languageHandler";
|
import { _t } from "../../../../languageHandler";
|
||||||
import { RovingTabIndexProvider } from "../../../../accessibility/RovingTabIndex";
|
import { type ListContext, ListView } from "../../../utils/ListView";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -30,53 +28,67 @@ interface IProps {
|
|||||||
|
|
||||||
const MemberListView: React.FC<IProps> = (props: IProps) => {
|
const MemberListView: React.FC<IProps> = (props: IProps) => {
|
||||||
const vm = useMemberListViewModel(props.roomId);
|
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) {
|
if (item === SEPARATOR) {
|
||||||
return <hr className="mx_MemberListView_separator" />;
|
return <hr className="mx_MemberListView_separator" />;
|
||||||
} else if (item.member) {
|
} 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 (
|
return (
|
||||||
<div key={key} style={style}>
|
<RoomMemberTileView
|
||||||
{getRowComponent(item)}
|
member={item.member}
|
||||||
</div>
|
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 (
|
return (
|
||||||
<BaseCard
|
<BaseCard
|
||||||
@@ -87,34 +99,20 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
|
|||||||
header={_t("common|people")}
|
header={_t("common|people")}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
>
|
>
|
||||||
<RovingTabIndexProvider handleUpDown scrollIntoView>
|
<Flex align="stretch" direction="column" className="mx_MemberListView_container">
|
||||||
{({ onKeyDownHandler }) => (
|
|
||||||
<Flex
|
|
||||||
align="stretch"
|
|
||||||
direction="column"
|
|
||||||
className="mx_MemberListView_container"
|
|
||||||
onKeyDown={onKeyDownHandler}
|
|
||||||
>
|
|
||||||
<Form.Root onSubmit={(e) => e.preventDefault()}>
|
<Form.Root onSubmit={(e) => e.preventDefault()}>
|
||||||
<MemberListHeaderView vm={vm} />
|
<MemberListHeaderView vm={vm} />
|
||||||
</Form.Root>
|
</Form.Root>
|
||||||
<AutoSizer>
|
<ListView
|
||||||
{({ height, width }) => (
|
items={vm.members}
|
||||||
<List
|
onSelectItem={handleSelectItem}
|
||||||
rowRenderer={rowRenderer}
|
getItemComponent={getItemComponent}
|
||||||
rowHeight={getRowHeight}
|
getItemKey={getItemKey}
|
||||||
// The +1 refers to the additional empty div that we render at the end of the list.
|
isItemFocusable={isItemFocusable}
|
||||||
rowCount={totalRows + 1}
|
role="listbox"
|
||||||
// Subtract the height of MemberlistHeaderView so that the parent div does not overflow.
|
aria-label={_t("member_list|list_title")}
|
||||||
height={height - 113}
|
|
||||||
width={width}
|
|
||||||
overscanRowCount={15}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</AutoSizer>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
|
||||||
</RovingTabIndexProvider>
|
|
||||||
</BaseCard>
|
</BaseCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -19,7 +19,11 @@ import { InvitedIconView } from "./common/InvitedIconView";
|
|||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
member: RoomMember;
|
member: RoomMember;
|
||||||
|
index: number;
|
||||||
|
memberCount: number;
|
||||||
showPresence?: boolean;
|
showPresence?: boolean;
|
||||||
|
focused?: boolean;
|
||||||
|
tabIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoomMemberTileView(props: IProps): JSX.Element {
|
export function RoomMemberTileView(props: IProps): JSX.Element {
|
||||||
@@ -36,7 +40,7 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
const name = vm.name;
|
const name = vm.name;
|
||||||
const nameJSX = <DisambiguatedProfile member={member} fallbackName={name || ""} />;
|
const nameJSX = <DisambiguatedProfile withTooltip member={member} fallbackName={name || ""} />;
|
||||||
|
|
||||||
const presenceState = member.presenceState;
|
const presenceState = member.presenceState;
|
||||||
let presenceJSX: JSX.Element | undefined;
|
let presenceJSX: JSX.Element | undefined;
|
||||||
@@ -54,13 +58,17 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MemberTileView
|
<MemberTileView
|
||||||
title={vm.title}
|
|
||||||
onClick={vm.onClick}
|
onClick={vm.onClick}
|
||||||
avatarJsx={av}
|
avatarJsx={av}
|
||||||
presenceJsx={presenceJSX}
|
presenceJsx={presenceJSX}
|
||||||
nameJsx={nameJSX}
|
nameJsx={nameJSX}
|
||||||
userLabel={vm.userLabel}
|
userLabel={vm.userLabel}
|
||||||
|
ariaLabel={name}
|
||||||
iconJsx={iconJsx}
|
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 {
|
interface Props {
|
||||||
threePidInvite: ThreePIDInvite;
|
threePidInvite: ThreePIDInvite;
|
||||||
|
memberIndex: number;
|
||||||
|
memberCount: number;
|
||||||
|
focused?: boolean;
|
||||||
|
tabIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThreePidInviteTileView(props: Props): JSX.Element {
|
export function ThreePidInviteTileView(props: Props): JSX.Element {
|
||||||
const vm = useThreePidTileViewModel(props);
|
const vm = useThreePidTileViewModel(props);
|
||||||
const av = <BaseAvatar name={vm.name} size="32px" aria-hidden="true" />;
|
const av = <BaseAvatar name={vm.name} size="32px" aria-hidden="true" />;
|
||||||
const iconJsx = <InvitedIconView isThreePid={true} />;
|
const iconJsx = <InvitedIconView isThreePid={true} />;
|
||||||
|
const name = vm.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MemberTileView
|
<MemberTileView
|
||||||
nameJsx={vm.name}
|
nameJsx={name}
|
||||||
avatarJsx={av}
|
avatarJsx={av}
|
||||||
onClick={vm.onClick}
|
onClick={vm.onClick}
|
||||||
|
memberIndex={props.memberIndex}
|
||||||
|
memberCount={props.memberCount}
|
||||||
|
ariaLabel={name}
|
||||||
userLabel={vm.userLabel}
|
userLabel={vm.userLabel}
|
||||||
iconJsx={iconJsx}
|
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.
|
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 {
|
interface Props {
|
||||||
avatarJsx: JSX.Element;
|
avatarJsx: JSX.Element;
|
||||||
nameJsx: JSX.Element | string;
|
nameJsx: JSX.Element | string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
title?: string;
|
memberIndex: number;
|
||||||
|
memberCount: number;
|
||||||
|
ariaLabel?: string;
|
||||||
presenceJsx?: JSX.Element;
|
presenceJsx?: JSX.Element;
|
||||||
userLabel?: React.ReactNode;
|
userLabel?: React.ReactNode;
|
||||||
iconJsx?: JSX.Element;
|
iconJsx?: JSX.Element;
|
||||||
|
tabIndex?: number;
|
||||||
|
focused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MemberTileView(props: Props): JSX.Element {
|
export function MemberTileView(props: Props): JSX.Element {
|
||||||
@@ -24,22 +28,36 @@ export function MemberTileView(props: Props): JSX.Element {
|
|||||||
if (props.userLabel) {
|
if (props.userLabel) {
|
||||||
userLabelJsx = <div className="mx_MemberTileView_userLabel">{props.userLabel}</div>;
|
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 (
|
return (
|
||||||
// The wrapping div is required to make the magic mouse listener work, for some reason.
|
// The wrapping div is required to make the magic mouse listener work, for some reason.
|
||||||
<div>
|
<div>
|
||||||
<RovingAccessibleButton className="mx_MemberTileView" title={props.title} onClick={props.onClick}>
|
<AccessibleButton
|
||||||
<div className="mx_MemberTileView_left">
|
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">
|
<div className="mx_MemberTileView_avatar">
|
||||||
{props.avatarJsx} {props.presenceJsx}
|
{props.avatarJsx} {props.presenceJsx}
|
||||||
</div>
|
</div>
|
||||||
<div className="mx_MemberTileView_name">{props.nameJsx}</div>
|
<div className="mx_MemberTileView_name">{props.nameJsx}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx_MemberTileView_right">
|
<div aria-hidden className="mx_MemberTileView_right">
|
||||||
{userLabelJsx}
|
{userLabelJsx}
|
||||||
{props.iconJsx}
|
{props.iconJsx}
|
||||||
</div>
|
</div>
|
||||||
</RovingAccessibleButton>
|
</AccessibleButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1658,8 +1658,8 @@
|
|||||||
"filter_placeholder": "Search room members",
|
"filter_placeholder": "Search room members",
|
||||||
"invite_button_no_perms_tooltip": "You do not have permission to invite users",
|
"invite_button_no_perms_tooltip": "You do not have permission to invite users",
|
||||||
"invited_label": "Invited",
|
"invited_label": "Invited",
|
||||||
"no_matches": "No matches",
|
"list_title": "Member list",
|
||||||
"power_label": "%(userName)s (power %(powerLevelNumber)s)"
|
"no_matches": "No matches"
|
||||||
},
|
},
|
||||||
"member_list_back_action_label": "Room members",
|
"member_list_back_action_label": "Room members",
|
||||||
"message_edit_dialog_title": "Message edits",
|
"message_edit_dialog_title": "Message edits",
|
||||||
|
@@ -35,7 +35,7 @@ describe("MemberTileView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should not display an E2EIcon when the e2E status = normal", () => {
|
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");
|
const e2eIcon = container.querySelector(".mx_E2EIconView");
|
||||||
expect(e2eIcon).toBeNull();
|
expect(e2eIcon).toBeNull();
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
@@ -47,7 +47,7 @@ describe("MemberTileView", () => {
|
|||||||
wasCrossSigningVerified: jest.fn().mockReturnValue(true),
|
wasCrossSigningVerified: jest.fn().mockReturnValue(true),
|
||||||
} as unknown as UserVerificationStatus);
|
} as unknown as UserVerificationStatus);
|
||||||
|
|
||||||
const { container } = render(<RoomMemberTileView member={member} />);
|
const { container } = render(<RoomMemberTileView member={member} index={0} memberCount={1} />);
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
await userEvent.hover(container.querySelector(".mx_E2EIcon")!);
|
await userEvent.hover(container.querySelector(".mx_E2EIcon")!);
|
||||||
expect(screen.getByText("This user has not verified all of their sessions.")).toBeInTheDocument();
|
expect(screen.getByText("This user has not verified all of their sessions.")).toBeInTheDocument();
|
||||||
@@ -68,7 +68,7 @@ describe("MemberTileView", () => {
|
|||||||
crossSigningVerified: true,
|
crossSigningVerified: true,
|
||||||
} as DeviceVerificationStatus);
|
} as DeviceVerificationStatus);
|
||||||
|
|
||||||
const { container } = render(<RoomMemberTileView member={member} />);
|
const { container } = render(<RoomMemberTileView member={member} index={0} memberCount={1} />);
|
||||||
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
await userEvent.hover(container.querySelector(".mx_E2EIcon")!);
|
await userEvent.hover(container.querySelector(".mx_E2EIcon")!);
|
||||||
@@ -81,15 +81,15 @@ describe("MemberTileView", () => {
|
|||||||
|
|
||||||
it("renders user labels correctly", async () => {
|
it("renders user labels correctly", async () => {
|
||||||
member.powerLevel = 50;
|
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");
|
expect(container1).toHaveTextContent("Moderator");
|
||||||
|
|
||||||
member.powerLevel = 100;
|
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");
|
expect(container2).toHaveTextContent("Admin");
|
||||||
|
|
||||||
member.isInvite = true;
|
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");
|
expect(container3).toHaveTextContent("Invited");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -109,7 +109,9 @@ describe("MemberTileView", () => {
|
|||||||
|
|
||||||
it("renders ThreePidInvite correctly", async () => {
|
it("renders ThreePidInvite correctly", async () => {
|
||||||
const [{ threePidInvite }] = getPending3PidInvites(room);
|
const [{ threePidInvite }] = getPending3PidInvites(room);
|
||||||
const { container } = render(<ThreePidInviteTileView threePidInvite={threePidInvite!} />);
|
const { container } = render(
|
||||||
|
<ThreePidInviteTileView threePidInvite={threePidInvite!} memberIndex={0} memberCount={1} />,
|
||||||
|
);
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -4,12 +4,15 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh
|
|||||||
<div>
|
<div>
|
||||||
<div>
|
<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"
|
class="mx_AccessibleButton mx_MemberTileView"
|
||||||
role="button"
|
role="option"
|
||||||
tabindex="-1"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
class="mx_MemberTileView_left"
|
class="mx_MemberTileView_left"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -33,6 +36,7 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx_DisambiguatedProfile"
|
class="mx_DisambiguatedProfile"
|
||||||
|
title="@userId:matrix.org (@userId:matrix.org)"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class=""
|
class=""
|
||||||
@@ -44,10 +48,11 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
class="mx_MemberTileView_right"
|
class="mx_MemberTileView_right"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
aria-labelledby="«ri»"
|
aria-labelledby="«r6»"
|
||||||
class="mx_E2EIconView"
|
class="mx_E2EIconView"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -73,12 +78,15 @@ exports[`MemberTileView RoomMemberTileView should display an warning E2EIcon whe
|
|||||||
<div>
|
<div>
|
||||||
<div>
|
<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"
|
class="mx_AccessibleButton mx_MemberTileView"
|
||||||
role="button"
|
role="option"
|
||||||
tabindex="-1"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
class="mx_MemberTileView_left"
|
class="mx_MemberTileView_left"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -102,6 +110,7 @@ exports[`MemberTileView RoomMemberTileView should display an warning E2EIcon whe
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx_DisambiguatedProfile"
|
class="mx_DisambiguatedProfile"
|
||||||
|
title="@userId:matrix.org (@userId:matrix.org)"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class=""
|
class=""
|
||||||
@@ -113,10 +122,11 @@ exports[`MemberTileView RoomMemberTileView should display an warning E2EIcon whe
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
class="mx_MemberTileView_right"
|
class="mx_MemberTileView_right"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
aria-labelledby="«r8»"
|
aria-labelledby="«r0»"
|
||||||
class="mx_E2EIconView"
|
class="mx_E2EIconView"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -142,12 +152,15 @@ exports[`MemberTileView RoomMemberTileView should not display an E2EIcon when th
|
|||||||
<div>
|
<div>
|
||||||
<div>
|
<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"
|
class="mx_AccessibleButton mx_MemberTileView"
|
||||||
role="button"
|
role="option"
|
||||||
tabindex="-1"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
class="mx_MemberTileView_left"
|
class="mx_MemberTileView_left"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -171,6 +184,7 @@ exports[`MemberTileView RoomMemberTileView should not display an E2EIcon when th
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx_DisambiguatedProfile"
|
class="mx_DisambiguatedProfile"
|
||||||
|
title="@userId:matrix.org (@userId:matrix.org)"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class=""
|
class=""
|
||||||
@@ -182,6 +196,7 @@ exports[`MemberTileView RoomMemberTileView should not display an E2EIcon when th
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
class="mx_MemberTileView_right"
|
class="mx_MemberTileView_right"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,11 +208,15 @@ exports[`MemberTileView ThreePidInviteTileView renders ThreePidInvite correctly
|
|||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
|
aria-label="Foobar"
|
||||||
|
aria-posinset="1"
|
||||||
|
aria-setsize="1"
|
||||||
class="mx_AccessibleButton mx_MemberTileView"
|
class="mx_AccessibleButton mx_MemberTileView"
|
||||||
role="button"
|
role="option"
|
||||||
tabindex="-1"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
class="mx_MemberTileView_left"
|
class="mx_MemberTileView_left"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -223,6 +242,7 @@ exports[`MemberTileView ThreePidInviteTileView renders ThreePidInvite correctly
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
class="mx_MemberTileView_right"
|
class="mx_MemberTileView_right"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
|
|
||||||
import React, { act } from "react";
|
import React, { act } from "react";
|
||||||
import { render, type RenderResult, waitFor } from "jest-matrix-react";
|
import { render, type RenderResult, waitFor } from "jest-matrix-react";
|
||||||
|
import { VirtuosoMockContext } from "react-virtuoso";
|
||||||
import {
|
import {
|
||||||
Room,
|
Room,
|
||||||
type MatrixClient,
|
type MatrixClient,
|
||||||
@@ -121,6 +122,13 @@ export async function renderMemberList(
|
|||||||
<MemberListView roomId={memberListRoom.roomId} onClose={() => {}} />
|
<MemberListView roomId={memberListRoom.roomId} onClose={() => {}} />
|
||||||
</SDKContext.Provider>
|
</SDKContext.Provider>
|
||||||
</MatrixClientContext.Provider>,
|
</MatrixClientContext.Provider>,
|
||||||
|
{
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 56 }}>
|
||||||
|
{children}
|
||||||
|
</VirtuosoMockContext.Provider>
|
||||||
|
),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
expect(root.container.querySelectorAll(".mx_MemberTileView")).toHaveLength(usersPerLevel * 3);
|
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"
|
prop-types "^15.7.2"
|
||||||
react-lifecycles-compat "^3.0.4"
|
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:
|
"react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", react@^19.0.0:
|
||||||
version "19.1.0"
|
version "19.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/react/-/react-19.1.0.tgz#926864b6c48da7627f004795d6cce50e90793b75"
|
resolved "https://registry.yarnpkg.com/react/-/react-19.1.0.tgz#926864b6c48da7627f004795d6cce50e90793b75"
|
||||||
|
Reference in New Issue
Block a user