Compare commits

...

10 Commits

Author SHA1 Message Date
David Langley
9e65d4254d Move to virtua 2025-05-09 09:25:14 +01:00
David Langley
ce68db5c20 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
2025-05-08 18:46:36 +01:00
David Langley
919b5ee452 Fix location of scrollToIndex and add useCallback 2025-05-08 15:57:57 +01:00
David Langley
7571ea3dd2 Add tooltip for invite buttons active state
As we have for other icon based buttons in the right panel/app
2025-05-08 15:57:07 +01:00
David Langley
cc6b3f0c39 lint 2025-05-08 00:23:25 +01:00
David Langley
e567d8039c 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.
2025-05-08 00:18:32 +01:00
David Langley
b83a86532b lint 2025-05-08 00:12:25 +01:00
David Langley
e66190304f Update focus style and improve keyboard navigation 2025-05-08 00:01:10 +01:00
David Langley
51665017a3 Merge branch 'develop' of github.com:vector-im/element-web into langleyd/memberlist_to_virtuoso 2025-05-07 17:31:59 +01:00
langleyd
c764fc8f2e implement basic scrolling and keyboard navigation 2025-05-02 10:16:19 +01:00
10 changed files with 148 additions and 88 deletions

View File

@@ -149,6 +149,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.16.0",
@@ -156,6 +157,7 @@
"temporal-polyfill": "^0.3.0",
"ua-parser-js": "^1.0.2",
"uuid": "^11.0.0",
"virtua": "^0.41.0",
"what-input": "^5.2.10"
},
"devDependencies": {

View File

@@ -42,3 +42,7 @@ Please see LICENSE files in the repository root for full details.
width: 32px;
}
}
.mx_MemberTileView_hover {
background-color: var(--cpd-color-bg-action-secondary-hovered);
}

View File

@@ -38,6 +38,8 @@ import { isValid3pidInvite } from "../../../RoomInvite";
import { type ThreePIDInvite } from "../../../models/rooms/ThreePIDInvite";
import { type XOR } from "../../../@types/common";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { Action } from "../../../dispatcher/actions";
import dis from "../../../dispatcher/dispatcher";
type Member = XOR<{ member: RoomMember }, { threePidInvite: ThreePIDInvite }>;
@@ -111,6 +113,7 @@ export interface MemberListViewState {
shouldShowSearch: boolean;
isLoading: boolean;
canInvite: boolean;
onClickMember: (member: RoomMember | ThreePIDInvite) => void;
onInviteButtonClick: (ev: ButtonEvent) => void;
}
export function useMemberListViewModel(roomId: string): MemberListViewState {
@@ -133,6 +136,14 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
*/
const [memberCount, setMemberCount] = useState(0);
const onClickMember = (member: RoomMember | ThreePIDInvite): void => {
dis.dispatch({
action: Action.ViewUser,
member: member,
push: true,
});
};
const loadMembers = useMemo(
() =>
throttle(
@@ -267,6 +278,7 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
isPresenceEnabled,
isLoading,
onInviteButtonClick,
onClickMember,
shouldShowSearch: totalMemberCount >= 20,
canInvite,
};

View File

@@ -19,10 +19,10 @@ interface TooltipProps {
children: React.ReactNode;
}
const OptionalTooltip: React.FC<TooltipProps> = ({ canInvite, children }) => {
if (canInvite) return children;
const InviteTooltip: React.FC<TooltipProps> = ({ canInvite, children }) => {
const description: string = canInvite ? _t("action|invite") : _t("member_list|invite_button_no_perms_tooltip");
// If the user isn't allowed to invite others to this room, wrap with a relevant tooltip.
return <Tooltip description={_t("member_list|invite_button_no_perms_tooltip")}>{children}</Tooltip>;
return <Tooltip description={description}>{children}</Tooltip>;
};
interface Props {
@@ -42,7 +42,7 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
if (shouldShowSearch) {
/// When rendered alongside a search box, the invite button is just an icon.
return (
<OptionalTooltip canInvite={vm.canInvite}>
<InviteTooltip canInvite={vm.canInvite}>
<Button
className="mx_MemberListHeaderView_invite_small"
kind="primary"
@@ -54,13 +54,13 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
aria-label={_t("action|invite")}
type="button"
/>
</OptionalTooltip>
</InviteTooltip>
);
}
// Without a search box, invite button is a full size button.
return (
<OptionalTooltip canInvite={vm.canInvite}>
<InviteTooltip canInvite={vm.canInvite}>
<Button
kind="secondary"
size="sm"
@@ -72,7 +72,7 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
>
{_t("action|invite")}
</Button>
</OptionalTooltip>
</InviteTooltip>
);
};

View File

@@ -6,9 +6,9 @@ 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, { useCallback, useRef, type JSX } from "react";
import { Virtualizer, VirtualizerHandle } from "virtua";
// import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
import { Flex } from "../../../utils/Flex";
import {
@@ -21,7 +21,6 @@ import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView";
import { MemberListHeaderView } from "./MemberListHeaderView";
import BaseCard from "../../right_panel/BaseCard";
import { _t } from "../../../../languageHandler";
import { RovingTabIndexProvider } from "../../../../accessibility/RovingTabIndex";
interface IProps {
roomId: string;
@@ -30,53 +29,65 @@ interface IProps {
const MemberListView: React.FC<IProps> = (props: IProps) => {
const vm = useMemberListViewModel(props.roomId);
const totalRows = vm.members.length;
const ref = useRef<VirtualizerHandle | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const [focusedIndex, setFocusedIndex] = React.useState(-1);
const getRowComponent = (item: MemberWithSeparator): JSX.Element => {
const getRowComponent = (item: MemberWithSeparator, focused: boolean): JSX.Element => {
if (item === SEPARATOR) {
return <hr className="mx_MemberListView_separator" />;
} else if (item.member) {
return <RoomMemberTileView member={item.member} showPresence={vm.isPresenceEnabled} />;
return <RoomMemberTileView member={item.member} showPresence={vm.isPresenceEnabled} focused={focused} />;
} else {
return <ThreePidInviteTileView threePidInvite={item.threePidInvite} />;
return <ThreePidInviteTileView threePidInvite={item.threePidInvite} focused={focused} />;
}
};
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 scrollToIndex = useCallback(
(index: number): void => {
ref?.current?.scrollToIndex(index, {
align: "nearest",
});
setFocusedIndex(index);
},
[ref],
);
const keyDownCallback = useCallback(
(e: any) => {
if (e.code === "ArrowUp") {
const nextItemIsSeparator = focusedIndex > 1 && vm.members[focusedIndex - 1] === SEPARATOR;
const nextMemberOffset = nextItemIsSeparator ? 2 : 1;
scrollToIndex(Math.max(0, focusedIndex - nextMemberOffset));
e.preventDefault();
} else if (e.code === "ArrowDown") {
const nextItemIsSeparator = focusedIndex < totalRows - 1 && vm.members[focusedIndex + 1] === SEPARATOR;
const nextMemberOffset = nextItemIsSeparator ? 2 : 1;
scrollToIndex(Math.min(totalRows - 1, focusedIndex + nextMemberOffset));
e.preventDefault();
} else if ((e.code === "Enter" || e.code === "Space") && focusedIndex >= 0) {
const item = vm.members[focusedIndex];
if (item !== SEPARATOR) {
const member = item.member ?? item.threePidInvite;
vm.onClickMember(member);
e.stopPropagation();
e.preventDefault();
}
}
},
[scrollToIndex, focusedIndex, setFocusedIndex, vm, totalRows],
);
const onFocus = (e: React.FocusEvent): void => {
const nextIndex = focusedIndex == -1 ? 0 : focusedIndex;
scrollToIndex(nextIndex);
e.preventDefault();
};
const rowRenderer = ({ key, index, style }: ListRowProps): JSX.Element => {
if (index === totalRows) {
// We've rendered all the members,
// now we render an empty div to add some space to the end of the list.
return <div key={key} style={style} />;
}
const item = vm.members[index];
return (
<div key={key} style={style}>
{getRowComponent(item)}
</div>
);
};
function footer(): React.ReactNode {
return <div style={{ height: "32px" }} />;
}
return (
<BaseCard
@@ -87,34 +98,29 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
header={_t("common|people")}
onClose={props.onClose}
>
<RovingTabIndexProvider handleUpDown scrollIntoView>
{({ onKeyDownHandler }) => (
<Flex
align="stretch"
direction="column"
className="mx_MemberListView_container"
onKeyDown={onKeyDownHandler}
>
<Form.Root>
<MemberListHeaderView vm={vm} />
</Form.Root>
<AutoSizer>
{({ height, width }) => (
<List
rowRenderer={rowRenderer}
rowHeight={getRowHeight}
// The +1 refers to the additional empty div that we render at the end of the list.
rowCount={totalRows + 1}
// Subtract the height of MemberlistHeaderView so that the parent div does not overflow.
height={height - 113}
width={width}
overscanRowCount={15}
/>
)}
</AutoSizer>
</Flex>
)}
</RovingTabIndexProvider>
<Flex align="stretch" direction="column" className="mx_MemberListView_container">
<Form.Root>
<MemberListHeaderView vm={vm} />
</Form.Root>
<div
style={{
overflowY: "auto",
// opt out browser's scroll anchoring on header/footer because it will conflict to scroll anchoring of virtualizer
overflowAnchor: "none",
}}
aria-label={_t("room_list|list_title")}
role="grid"
ref={scrollRef}
onFocus={onFocus}
onKeyDown={keyDownCallback}
tabIndex={0}
>
<Virtualizer ref={ref} scrollRef={scrollRef}>
{vm.members.map((member, index) => getRowComponent(member, index === focusedIndex))}
</Virtualizer>
{footer()}
</div>
</Flex>
</BaseCard>
);
};

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import React, { useEffect, type JSX } from "react";
import DisambiguatedProfile from "../../../messages/DisambiguatedProfile";
import { type RoomMember } from "../../../../../models/rooms/RoomMember";
@@ -20,6 +20,7 @@ import { InvitedIconView } from "./common/InvitedIconView";
interface IProps {
member: RoomMember;
showPresence?: boolean;
focused?: boolean;
}
export function RoomMemberTileView(props: IProps): JSX.Element {
@@ -30,13 +31,13 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
size="32px"
name={member.name}
idName={member.userId}
title={member.displayUserId}
title={vm.title}
url={member.avatarThumbnailUrl}
altText={_t("common|user_avatar")}
/>
);
const name = vm.name;
const nameJSX = <DisambiguatedProfile member={member} fallbackName={name || ""} />;
const nameJSX = <DisambiguatedProfile withTooltip member={member} fallbackName={name || ""} />;
const presenceState = member.presenceState;
let presenceJSX: JSX.Element | undefined;
@@ -54,13 +55,14 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
return (
<MemberTileView
title={vm.title}
onClick={vm.onClick}
avatarJsx={av}
presenceJsx={presenceJSX}
nameJsx={nameJSX}
userLabel={vm.userLabel}
ariaLabel={_t("member_list|open_profile", { memberName: name })}
iconJsx={iconJsx}
focused={props.focused}
/>
);
}

View File

@@ -12,23 +12,28 @@ import { type ThreePIDInvite } from "../../../../../models/rooms/ThreePIDInvite"
import BaseAvatar from "../../../avatars/BaseAvatar";
import { MemberTileView } from "./common/MemberTileView";
import { InvitedIconView } from "./common/InvitedIconView";
import { _t } from "../../../../../languageHandler";
interface Props {
threePidInvite: ThreePIDInvite;
focused?: boolean;
}
export function ThreePidInviteTileView(props: Props): JSX.Element {
const vm = useThreePidTileViewModel(props);
const av = <BaseAvatar name={vm.name} size="32px" aria-hidden="true" />;
const iconJsx = <InvitedIconView isThreePid={true} />;
const name = vm.name;
return (
<MemberTileView
nameJsx={vm.name}
nameJsx={name}
avatarJsx={av}
onClick={vm.onClick}
ariaLabel={_t("member_list|open_profile", { memberName: name })}
userLabel={vm.userLabel}
iconJsx={iconJsx}
focused={props.focused}
/>
);
}

View File

@@ -5,18 +5,20 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import classNames from "classnames";
import React, { useEffect, useRef, type JSX } from "react";
import { RovingAccessibleButton } from "../../../../../../accessibility/RovingTabIndex";
import AccessibleButton from "../../../../elements/AccessibleButton";
interface Props {
avatarJsx: JSX.Element;
nameJsx: JSX.Element | string;
onClick: () => void;
title?: string;
ariaLabel: string;
presenceJsx?: JSX.Element;
userLabel?: React.ReactNode;
iconJsx?: JSX.Element;
focused?: boolean;
}
export function MemberTileView(props: Props): JSX.Element {
@@ -24,11 +26,25 @@ export function MemberTileView(props: Props): JSX.Element {
if (props.userLabel) {
userLabelJsx = <div className="mx_MemberTileView_userLabel">{props.userLabel}</div>;
}
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (props.focused) {
ref.current?.focus();
}
}, [props.focused]);
return (
// The wrapping div is required to make the magic mouse listener work, for some reason.
<div>
<RovingAccessibleButton className="mx_MemberTileView" title={props.title} onClick={props.onClick}>
<AccessibleButton
ref={ref}
className={classNames("mx_MemberTileView", {
mx_MemberTileView_hover: props.focused,
})}
onClick={props.onClick}
aria-label={props.ariaLabel}
tabIndex={props.focused ? 0 : -1}
role="gridcell"
>
<div className="mx_MemberTileView_left">
<div className="mx_MemberTileView_avatar">
{props.avatarJsx} {props.presenceJsx}
@@ -39,7 +55,7 @@ export function MemberTileView(props: Props): JSX.Element {
{userLabelJsx}
{props.iconJsx}
</div>
</RovingAccessibleButton>
</AccessibleButton>
</div>
);
}

View File

@@ -1645,7 +1645,9 @@
"invite_button_no_perms_tooltip": "You do not have permission to invite users",
"invited_label": "Invited",
"no_matches": "No matches",
"power_label": "%(userName)s (power %(powerLevelNumber)s)"
"power_label": "%(userName)s (power %(powerLevelNumber)s)",
"list_title": "Member list",
"open_profile": "Open profile %(memberName)s"
},
"member_list_back_action_label": "Room members",
"message_edit_dialog_title": "Message edits",

View File

@@ -3722,13 +3722,14 @@
"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm":
version "0.0.0"
uid ""
"@vector-im/matrix-wysiwyg@2.38.3":
version "2.38.3"
resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.38.3.tgz#cc54d8b3e9472bcd8e622126ba364ee31952cd8a"
integrity sha512-fqo8P55Vc/t0vxpFar9RDJN5gKEjJmzrLo+O4piDbFda6VrRoqrWAtiu0Au0g6B4hRDPKIuFupk8v9Ja7q8Hvg==
dependencies:
"@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm"
"@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm"
"@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1":
version "1.14.1"
@@ -11060,6 +11061,11 @@ react-virtualized@^9.22.5:
prop-types "^15.7.2"
react-lifecycles-compat "^3.0.4"
react-virtuoso@^4.12.6:
version "4.12.6"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.12.6.tgz#20fe374d43cce3c9821e29f4cc4d050596d06d01"
integrity sha512-bfvS6aCL1ehXmq39KRiz/vxznGUbtA27I5I24TYCe1DhMf84O3aVNCIwrSjYQjkJGJGzY46ihdN8WkYlemuhMQ==
react@^19.0.0:
version "19.1.0"
resolved "https://registry.yarnpkg.com/react/-/react-19.1.0.tgz#926864b6c48da7627f004795d6cce50e90793b75"
@@ -13014,6 +13020,11 @@ vaul@^1.0.0:
dependencies:
"@radix-ui/react-dialog" "^1.1.1"
virtua@^0.41.0:
version "0.41.0"
resolved "https://registry.yarnpkg.com/virtua/-/virtua-0.41.0.tgz#1e3c847baceb14c57e14be36272903f80a95f006"
integrity sha512-P8XJhPAz3mNrxAjPc+NRvD8a8+JtInKgHXuvtucThXW93U1ztUKJ2TDCAMaCPRY0CQCR465mYcOf26yQkCHWEw==
vt-pbf@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/vt-pbf/-/vt-pbf-3.1.3.tgz#68fd150756465e2edae1cc5c048e063916dcfaac"