-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Implement the member list with virtuoso #29869
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
35 commits
Select commit
Hold shift + click to select a range
c764fc8
implement basic scrolling and keyboard navigation
langleyd 5166501
Merge branch 'develop' of github.com:vector-im/element-web into langl…
langleyd e661903
Update focus style and improve keyboard navigation
langleyd b83a865
lint
langleyd e567d80
Use avatar tootltip for the title rather than the whole button
langleyd cc6b3f0
lint
langleyd 7571ea3
Add tooltip for invite buttons active state
langleyd 919b5ee
Fix location of scrollToIndex and add useCallback
langleyd ce68db5
Improve voiceover experience
langleyd fe67aec
Fix jest tests
langleyd 0a85d5f
Add aria index/counts and remove repeating "Open" string in label
langleyd 23af58d
update snapshot
langleyd 9288b88
Add the rest of the keyboard navigation and handle the case when the …
langleyd f5243ed
lint and update snapshot
langleyd 8379213
lint
langleyd 0f02d6a
Only focus first/lastFocsed cell if focus.currentTarget is the overal…
langleyd 31f24d1
Merge branch 'develop' of github.com:vector-im/element-web into langl…
langleyd aa745e0
Put back overscan and fix formatting
langleyd 9c7bd34
Extract ListView out of MemberList
langleyd ae87097
lint and fix e2e test
langleyd 376f6ab
Update screenshot
langleyd dc45381
Merge branch 'develop' into langleyd/memberlist_to_virtuoso
langleyd c0ca9fa
Fix default overscan value and add ListView tests
langleyd 1d17d35
Merge branch 'langleyd/memberlist_to_virtuoso' of github.com:vector-i…
langleyd b6cbf2d
Just leave the avatar as it was
langleyd 094eb4c
We removed the tooltip that showed power level. Removing string.
langleyd 76623a9
Use key rather than index to track focus.
langleyd e66f540
Remove overscan, fix typos, fix scrollToItem logic
langleyd a09197d
Use listbox role for member list and correct position/count values to…
langleyd 4fecf14
Fix inadvertant scrolling of the timeline when using pageUp/pageDown
langleyd 2db428f
Always set the roving tab index regardless of whether we are actually…
langleyd 561920c
Add aria-hidden to items within the option to avoid the SR calling it…
langleyd 4e169cf
Make sure there is a roving tab set if the last one has been removed …
langleyd 1c04dad
Update snapshot
langleyd 876bb7d
Merge branch 'develop' into langleyd/memberlist_to_virtuoso
langleyd File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Binary file modified
BIN
-556 Bytes
(97%)
playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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} | ||
/> | ||
); | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.