Room list: scroll to newly creation section (#33210)

* feat(rls): emit tag when section is created

* feat(vm): scroll to newly section tag

* feat(view): scroll to new section
This commit is contained in:
Florian Duros
2026-04-22 12:21:41 +00:00
committed by GitHub
parent 29411f0ded
commit 9df9fb9428
9 changed files with 101 additions and 27 deletions
@@ -6,7 +6,7 @@
*/
import React, { type JSX, useCallback, useMemo } from "react";
import { Virtuoso } from "react-virtuoso";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { useVirtualizedList, type VirtualizedListContext, type VirtualizedListProps } from "../virtualized-list";
@@ -33,6 +33,11 @@ export interface GroupedVirtualizedListProps<Header, Item, Context> extends Omit
VirtualizedListProps<Item, Context>,
"items" | "isItemFocusable" | "getItemKey"
> {
/**
* Optional ref to the underlying Virtuoso handle, for imperative scrolling.
*/
scrollHandleRef?: React.RefCallback<VirtuosoHandle>;
/**
* The groups to display in the virtualized list.
* Each group has a header and an array of child items.
@@ -126,6 +131,7 @@ export function GroupedVirtualizedList<Header, Item, Context>(
isGroupHeaderFocusable,
getItemKey,
getHeaderKey,
scrollHandleRef,
...restProps
} = props;
@@ -171,6 +177,7 @@ export function GroupedVirtualizedList<Header, Item, Context>(
isItemFocusable: wrappedIsEntryFocusable,
getItemKey: wrappedGetEntryKey,
},
scrollHandleRef,
);
// Convert (Item, e) → (NavigationEntry, e) for regular items
@@ -128,7 +128,7 @@ export interface UseVirtualizedListResult<Item, Context> extends Omit<
VirtuosoProps<Item, VirtualizedListContext<Context>>,
"data" | "itemContent" | "context" | "onKeyDown" | "onFocus" | "onBlur" | "rangeChanged" | "scrollerRef" | "ref"
> {
ref: React.RefObject<VirtuosoHandle | null>;
ref: React.RefCallback<VirtuosoHandle>;
scrollerRef: (element: HTMLElement | Window | null) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void;
onFocus: (e: React.FocusEvent) => void;
@@ -155,6 +155,7 @@ export interface UseVirtualizedListResult<Item, Context> extends Omit<
*/
export function useVirtualizedList<Item, Context>(
props: VirtualizedListProps<Item, Context>,
handleRef?: React.RefCallback<VirtuosoHandle>,
): UseVirtualizedListResult<Item, Context> {
// Extract our custom props to avoid conflicts with Virtuoso props
const {
@@ -380,9 +381,17 @@ export function useVirtualizedList<Item, Context>(
[rangeChanged, mapRangeIndex],
);
const setRef = useCallback(
(handle: VirtuosoHandle | null) => {
virtuosoHandleRef.current = handle;
handleRef?.(handle);
},
[handleRef],
);
return {
...virtuosoProps,
ref: virtuosoHandleRef,
ref: setRef,
scrollerRef,
onKeyDown: keyDownCallback,
onFocus,
@@ -13,7 +13,7 @@ import { describe, it, expect } from "vitest";
import * as stories from "./VirtualizedRoomListView.stories";
const { Default } = composeStories(stories);
const { Default, Sections } = composeStories(stories);
const renderWithMockContext = (component: React.ReactElement): ReturnType<typeof render> => {
return render(component, {
@@ -64,4 +64,28 @@ describe("<VirtualizedRoomListView />", () => {
renderWithMockContext(<Default />);
expect(Default.args.updateVisibleRooms).toHaveBeenCalled();
});
describe("scrollToSectionTag", () => {
it("skips scroll when scrollToSectionTag does not match any section", () => {
const roomListState = {
activeRoomIndex: 0,
spaceId: "!space:server",
scrollToSectionTag: "nonexistent",
};
renderWithMockContext(<Sections roomListState={roomListState} />);
expect(screen.getByRole("treegrid", { name: "Room list" })).toBeInTheDocument();
});
it("scrolls to the section when scrollToSectionTag matches", () => {
// sections: favourites(3 rooms), chats(1 room), low-priority(6 rooms)
// flat index for "chats" = 3 rooms + 1 header = 4
const roomListState = {
activeRoomIndex: 0,
spaceId: "!space:server",
scrollToSectionTag: "chats",
};
renderWithMockContext(<Sections roomListState={roomListState} />);
expect(screen.getByRole("treegrid", { name: "Room list" })).toBeInTheDocument();
});
});
});
@@ -5,8 +5,8 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { useCallback, useMemo, useRef, type JSX, type ReactNode } from "react";
import { type ScrollIntoViewLocation } from "react-virtuoso";
import React, { useCallback, useLayoutEffect, useMemo, useRef, type JSX, type ReactNode } from "react";
import { type ScrollIntoViewLocation, type VirtuosoHandle } from "react-virtuoso";
import { isEqual } from "lodash";
import { type Room } from "./RoomListItemAccessibilityWrapper/RoomListItemView";
@@ -38,6 +38,8 @@ export interface RoomListViewState {
spaceId?: string;
/** Active filter keys for context tracking */
filterKeys?: FilterKey[];
/** Tag of a newly created section header to scroll into view */
scrollToSectionTag?: string;
}
/**
@@ -110,8 +112,13 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
const snapshot = useViewModel(vm);
const { roomListState, sections, isFlatList } = snapshot;
const activeRoomIndex = roomListState.activeRoomIndex;
const scrollToSectionTag = roomListState.scrollToSectionTag;
const lastSpaceId = useRef<string | undefined>(undefined);
const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
const virtuosoHandleRef = useRef<VirtuosoHandle | null>(null);
const setVirtuosoHandle = useCallback((handle: VirtuosoHandle | null) => {
virtuosoHandleRef.current = handle;
}, []);
const roomIds = useMemo(() => sections.flatMap((section) => section.roomIds), [sections]);
const roomCount = roomIds.length;
const sectionCount = sections.length;
@@ -328,6 +335,16 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
[activeRoomIndex],
);
// Imperatively scroll to a newly created section header.
// scrollIntoView on virtuoso handle is more reliable in this case vs scrollIntoViewOnChange
useLayoutEffect(() => {
if (scrollToSectionTag === undefined) return;
const sectionIndex = sections.findIndex((s) => s.id === scrollToSectionTag);
if (sectionIndex === -1) return;
const flatIndex = sections.slice(0, sectionIndex).reduce((acc, s) => acc + s.roomIds.length + 1, 0);
virtuosoHandleRef.current?.scrollIntoView({ index: flatIndex, align: "start", behavior: "auto" });
}, [scrollToSectionTag, sections]);
const isItemFocusable = useCallback(() => true, []);
const isGroupHeaderFocusable = useCallback(() => true, []);
const increaseViewportBy = useMemo(
@@ -369,6 +386,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
<GroupedVirtualizedList<string, string, Context>
{...commonProps}
{...getContainerAccessibleProps("treegrid", totalCount)}
scrollHandleRef={setVirtuosoHandle}
groups={groups}
getHeaderKey={getHeaderKey}
getGroupHeaderComponent={getGroupHeaderComponent}