diff --git a/packages/shared-components/src/core/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.module.css b/packages/shared-components/src/core/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.module.css
new file mode 100644
index 0000000000..76e35c9988
--- /dev/null
+++ b/packages/shared-components/src/core/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.module.css
@@ -0,0 +1,56 @@
+/*
+Copyright 2026 Element Creations 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.
+*/
+
+/*
+Wrapper that establishes the positioning context for the pinned overlay header.
+It must fill its parent so the virtualized scroller below it keeps its height.
+*/
+.stickyRoot {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ /* Keep the pinned overlay and list content clipped to the panel bounds. */
+ overflow: hidden;
+}
+
+/*
+The pinned "current section" overlay header. It lives OUTSIDE the virtualized item stream so it never
+unmounts, and backstops the cover/reveal done by the sticky header rows (.stickyRow): once a tall
+section's real header row scrolls far enough that the virtualizer unmounts it, this overlay keeps that
+section's header visible at the top. A mounted real header row (z-index 2) sits exactly over it, so
+the hand-off is invisible. It is interactive (click toggles the section, hover highlights), so wheel
+events over it are forwarded to the scroller in JS to keep scrolling working.
+
+Only the relative order of this local 3-layer stack matters — room rows (normal flow) < this overlay
+< real sticky header rows (.stickyRow) — so the values are just 1 and 2, not tied to any global scale.
+*/
+.stickyHeader {
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline: 0;
+ z-index: 1;
+ pointer-events: auto;
+}
+
+/*
+Section-header rows pin natively, which is what produces the cover/reveal: `top: 0` sticks each
+header to the scroller's top; z-index above the overlay (1) so a mounted real header covers the
+overlay backstop, and so a stuck header covers the room rows scrolling beneath it. Consecutive stuck
+headers stack in DOM order, so the incoming header covers the current one on scroll-down and the
+top one slides off to reveal the previous on scroll-up. (Room rows stay in normal flow, not sticky.)
+
+The opaque background is essential: every header scrolled past the top stays stuck at top:0, so they
+overlap. Without an opaque fill they show through each other (overlapping text); with it, the current
+(top-most, last-in-DOM) header covers the rest, and sliding it down on scroll-up reveals the previous.
+*/
+.stickyRow {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ background: var(--cpd-color-bg-canvas-default);
+}
diff --git a/packages/shared-components/src/core/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.tsx b/packages/shared-components/src/core/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.tsx
index eb183586e2..b7b69c2444 100644
--- a/packages/shared-components/src/core/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.tsx
+++ b/packages/shared-components/src/core/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.tsx
@@ -5,10 +5,41 @@
* Please see LICENSE files in the repository root for full details.
*/
-import React, { type JSX, useCallback, useMemo } from "react";
-import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
+import React, {
+ type JSX,
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { type Components, type ListItem, Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { useVirtualizedList, type VirtualizedListContext, type VirtualizedListProps } from "../virtualized-list";
+import styles from "./GroupedVirtualizedList.module.css";
+
+/**
+ * Virtuoso row wrapper that makes section-header rows `position: sticky`, so a header pins to the
+ * top and the next one slides up to cover it (and reveals the previous on the way back) — native,
+ * compositor-driven cover/reveal. Room rows render in normal flow. Because Virtuoso unmounts rows
+ * scrolled out of its render window, a tall section's header eventually unmounts and stops sticking;
+ * the pinned overlay (rendered outside the list) backstops that gap.
+ */
+const StickyRowItem: Components["Item"] = React.forwardRef(function StickyRowItem(
+ // `item` and `context` are Virtuoso-injected props, not DOM attributes — pull them out so they
+ // aren't spread onto the div (`context` would otherwise render as `context="[object Object]"`).
+ { item, context, children, ...props },
+ ref,
+) {
+ const isHeader = item != null && typeof item === "object" && "header" in item;
+ return (
+
+ );
+});
/**
* A group of items for the grouped virtualized list.
@@ -31,7 +62,9 @@ type NavigationEntry = { header: Header } | { item: Item };
export interface GroupedVirtualizedListProps extends Omit<
VirtualizedListProps,
- "items" | "isItemFocusable" | "getItemKey"
+ // `itemsRendered`, `onScroll` and `scrollPaddingTop` are owned internally to drive the pinned
+ // header (see renderStickyHeader) and keep keyboard focus clear of it.
+ "items" | "isItemFocusable" | "getItemKey" | "itemsRendered" | "onScroll" | "scrollPaddingTop"
> {
/**
* Optional ref to the underlying Virtuoso handle, for imperative scrolling.
@@ -104,6 +137,25 @@ export interface GroupedVirtualizedListProps extends Omit
onFocus: (item: Item, e: React.FocusEvent) => void,
groupIndex: number,
) => JSX.Element;
+
+ /**
+ * Optional renderer for a "pinned" header that stays fixed at the top of the scroll
+ * viewport, reflecting the group the user is currently scrolled within.
+ *
+ * List rows — including real group headers — are virtualized and unmount once scrolled out
+ * of the render window, so a CSS `position: sticky` header would disappear partway through a
+ * tall group. This header is rendered OUTSIDE the virtualized stream, so it never unmounts.
+ *
+ * The real header rows remain the focusable, accessible elements driving keyboard navigation
+ * and screen-reader output; this overlay must therefore be purely presentational and is
+ * hidden from assistive technology by the caller.
+ *
+ * @param groupIndex - The index of the group currently pinned at the top
+ * @param header - The header data for that group
+ * @param context - The list context, including any additional context data
+ * @returns The presentational pinned header, or `null`/`undefined` to render nothing
+ */
+ renderStickyHeader?: (groupIndex: number, header: Header, context: VirtualizedListContext) => ReactNode;
}
/**
@@ -132,16 +184,23 @@ export function GroupedVirtualizedList(
getItemKey,
getHeaderKey,
scrollHandleRef,
+ renderStickyHeader,
...restProps
} = props;
+ // Measured height of the pinned overlay header. Drives both the push animation and the keyboard
+ // scroll padding (so focused items land below the overlay rather than behind it). 0 = no overlay.
+ const [headerHeight, setHeaderHeight] = useState(0);
+
// Build a flat array interleaving group headers with items.
// Each entry is either { header } or { item }.
const flatEntries = useMemo(
() =>
groups.flatMap>((group) => [
{ header: group.header },
- ...group.items.map>((item) => ({ item })),
+ ...group.items.map>((item) => ({
+ item,
+ })),
]),
[groups],
);
@@ -153,6 +212,17 @@ export function GroupedVirtualizedList(
[groups],
);
+ // Per-item top padding for keyboard scrolling: reserve the overlay height for room items (so
+ // they land below the pinned header), but 0 for header items — a focused header should land at
+ // the exact top, where the overlay yields to the real (focusable) header.
+ const getScrollPaddingTop = useCallback(
+ (index: number): number => {
+ const entry = flatEntries[index];
+ return entry && "header" in entry ? 0 : headerHeight;
+ },
+ [flatEntries, headerHeight],
+ );
+
// Wrap getItemKey: dispatch to getHeaderKey or getItemKey based on entry type
const wrappedGetEntryKey = useCallback(
(entry: NavigationEntry): string =>
@@ -167,7 +237,11 @@ export function GroupedVirtualizedList(
[isGroupHeaderFocusable, isItemFocusable],
);
- const { onFocusForGetItemComponent, ...virtuosoProps } = useVirtualizedList, Context>(
+ const {
+ onFocusForGetItemComponent,
+ scrollerRef: hookScrollerRef,
+ ...virtuosoProps
+ } = useVirtualizedList, Context>(
{
...(restProps as Omit<
VirtualizedListProps, Context>,
@@ -176,6 +250,8 @@ export function GroupedVirtualizedList(
items: flatEntries,
isItemFocusable: wrappedIsEntryFocusable,
getItemKey: wrappedGetEntryKey,
+ // Keep keyboard-focused rooms clear of the pinned overlay header (headers get 0; see above).
+ scrollPaddingTop: getScrollPaddingTop,
},
scrollHandleRef,
);
@@ -226,15 +302,119 @@ export function GroupedVirtualizedList(
],
);
+ // --- Pinned ("sticky") header tracking -------------------------------------------------
+ // Work out which section is at the top of the viewport so the overlay can mirror it: find the
+ // top-most rendered item by comparing each item's measured `offset` (from `itemsRendered`)
+ // against the live `scrollTop`, then take that item's group.
+ //
+ // We can't use Virtuoso's reported range for this. It also counts the rows rendered off-screen
+ // above the viewport (`increaseViewportBy`), so its start index sits ~a screenful too high.
+ // Right after you scroll into a new section that start index is still back in the previous
+ // section — so the overlay would keep showing the previous section's header until you'd
+ // scrolled well into the new one.
+ const renderedItemsRef = useRef<{ index: number; offset: number }[]>([]);
+ const overlayRef = useRef(null);
+ // The scroller element, held as a ref for imperative reads (`scrollTop`, while computing the
+ // pinned header) and writes (wheel forwarding). A ref rather than state because mutating a
+ // *state* value's `scrollTop` trips react-compiler's immutability check, and scroll updates are
+ // driven by Virtuoso's `onScroll` prop — so no effect needs to re-subscribe when it mounts.
+ const scrollerElRef = useRef(null);
+ const [currentGroupIndex, setCurrentGroupIndex] = useState(0);
+
+ // Recompute which section the overlay backstop should mirror. Runs on every scroll frame, but
+ // it's cheap — it only reads scrollTop + the cached item offsets and updates a single state value.
+ const updateSticky = useCallback((): void => {
+ const scroller = scrollerElRef.current;
+ if (!scroller || flatIndexToGroupIndex.length === 0) return;
+ const scrollTop = scroller.scrollTop;
+ const rendered = renderedItemsRef.current;
+
+ // Find the top-most rendered item: the greatest measured offset that's still at or above the
+ // fold (+1px rounding tolerance). Its group is the section currently at the top.
+ let topIndex = rendered.length ? rendered[0].index : 0;
+ let bestOffset = rendered.length ? rendered[0].offset : 0;
+ for (const item of rendered) {
+ if (item.offset <= scrollTop + 1 && item.offset > bestOffset) {
+ bestOffset = item.offset;
+ topIndex = item.index;
+ }
+ }
+
+ const groupIndex = flatIndexToGroupIndex[topIndex] ?? 0;
+ setCurrentGroupIndex((prev) => (prev === groupIndex ? prev : groupIndex));
+ }, [flatIndexToGroupIndex]);
+
+ // Capture the latest item offsets, then refresh the pinned header.
+ const handleItemsRendered = useCallback(
+ (items: ListItem>[]): void => {
+ renderedItemsRef.current = items.map((item) => ({ index: item.index, offset: item.offset }));
+ updateSticky();
+ },
+ [updateSticky],
+ );
+
+ // Compose the hook's scroller ref so we can also capture the element for imperative use
+ // (reading scrollTop while computing the pinned header, and forwarding wheel events).
+ const handleScrollerRef = useCallback(
+ (element: HTMLElement | Window | null): void => {
+ hookScrollerRef?.(element);
+ scrollerElRef.current = element instanceof HTMLElement ? element : null;
+ },
+ [hookScrollerRef],
+ );
+
+ // The overlay is interactive (so it can be clicked/hovered), which means it would otherwise
+ // swallow wheel scrolling over the header strip. Forward those wheel deltas to the scroller.
+ const handleOverlayWheel = useCallback((e: React.WheelEvent): void => {
+ const el = scrollerElRef.current;
+ if (el) el.scrollTop += e.deltaY * (e.deltaMode === 1 ? 16 : 1);
+ }, []);
+
+ // Measure the pinned header's height (used by the keyboard scroll padding, so a focused room
+ // lands clear of it) whenever it could have changed. setState bails out when unchanged.
+ useLayoutEffect(() => {
+ if (overlayRef.current) setHeaderHeight(overlayRef.current.offsetHeight);
+ }, [currentGroupIndex, groups]);
+
+ // Groups can change (sections added/removed/reordered); re-evaluate against the new layout.
+ useEffect(() => {
+ updateSticky();
+ }, [groups, updateSticky]);
+
+ const stickyGroupIndex = Math.min(currentGroupIndex, groups.length - 1);
+ const stickyHeader =
+ renderStickyHeader && stickyGroupIndex >= 0
+ ? renderStickyHeader(stickyGroupIndex, groups[stickyGroupIndex].header, virtuosoProps.context)
+ : null;
+
return (
-
+
+ {stickyHeader != null && (
+
+ {stickyHeader}
+
+ )}
+ ,
+ VirtualizedListContext
+ >
+ }
+ scrollerRef={handleScrollerRef}
+ itemsRendered={handleItemsRendered}
+ // Keep the overlay backstop's section current as you scroll. The cover/reveal
+ // animation is native `position: sticky` (.stickyRow), so this only tracks which
+ // section the backstop should mirror.
+ onScroll={updateSticky}
+ />
+
);
}
diff --git a/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx b/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx
index 6a586f3978..11b7f66926 100644
--- a/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx
+++ b/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx
@@ -6,7 +6,7 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { type ListRange, type VirtuosoHandle, type VirtuosoProps } from "react-virtuoso";
+import { type CalculateViewLocation, type ListRange, type VirtuosoHandle, type VirtuosoProps } from "react-virtuoso";
/**
* Keyboard key codes
@@ -122,6 +122,17 @@ export interface VirtualizedListProps extends Omit<
* @returns The corresponding index in the items array
*/
mapRangeIndex?: (virtuosoIndex: number) => number;
+
+ /**
+ * Optional space, in pixels, reserved at the top of the scroll viewport — e.g. for a pinned
+ * sticky header that overlays the top of the list. When set, keyboard navigation scrolls
+ * focused items to just below this offset rather than flush to the top, so the focused item
+ * (and its focus ring / hover affordances) is never hidden behind the pinned header.
+ *
+ * Pass a function to vary the reserved space per item index — e.g. return 0 for an item that is
+ * itself the pinned header (so it lands flush at the top) and the header height for the rest.
+ */
+ scrollPaddingTop?: number | ((index: number) => number);
}
/**
@@ -145,6 +156,28 @@ export interface UseVirtualizedListResult extends Omit<
context: VirtualizedListContext;
}
+/**
+ * Builds a Virtuoso `calculateViewLocation` that keeps `paddingTop` pixels clear at the top of the
+ * viewport (e.g. for a pinned sticky header). It honours the requested alignment and only insets
+ * the cases that would otherwise place the item against the top edge — so a focused item lands just
+ * below the pinned header instead of underneath it. `offset` is negative because Virtuoso adds it to
+ * the computed `scrollTop`, and a smaller scrollTop pushes the item further down the viewport.
+ */
+function reserveTopViewLocation(paddingTop: number): CalculateViewLocation {
+ return ({ itemTop, itemBottom, viewportTop, viewportBottom, locationParams: { align, behavior, ...rest } }) => {
+ if (align === "start" || (align === undefined && itemTop < viewportTop + paddingTop)) {
+ return { ...rest, behavior, align: "start", offset: -paddingTop };
+ }
+ if (align === "end" || (align === undefined && itemBottom > viewportBottom)) {
+ return { ...rest, behavior, align: "end" };
+ }
+ if (align === "center") {
+ return { ...rest, behavior, align: "center" };
+ }
+ return null;
+ };
+}
+
/**
* A hook that provides keyboard navigation and focus management for a virtualized list
* built on top of react-virtuoso.
@@ -177,6 +210,7 @@ export function useVirtualizedList(
mapScrollIndex,
mapRangeIndex,
scrollerRef: externalScrollerRef,
+ scrollPaddingTop,
...virtuosoProps
} = props;
/** Reference to the Virtuoso component for programmatic scrolling */
@@ -217,14 +251,19 @@ export function useVirtualizedList(
const key = getItemKey(items[clampedIndex]);
setTabIndexKey(key);
const scrollIndex = mapScrollIndex ? mapScrollIndex(clampedIndex) : clampedIndex;
+ // Reserve space for a pinned header so the focused item isn't hidden behind it.
+ // The reserved amount can vary per item (e.g. 0 for the header that itself pins).
+ const paddingTop =
+ typeof scrollPaddingTop === "function" ? scrollPaddingTop(clampedIndex) : (scrollPaddingTop ?? 0);
virtuosoHandleRef.current?.scrollIntoView({
index: scrollIndex,
align: align,
behavior: "auto",
+ ...(paddingTop > 0 ? { calculateViewLocation: reserveTopViewLocation(paddingTop) } : {}),
});
}
},
- [items, getItemKey, mapScrollIndex],
+ [items, getItemKey, mapScrollIndex, scrollPaddingTop],
);
/**
diff --git a/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap b/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap
index 0cc7f1aecb..23fad55e27 100644
--- a/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap
+++ b/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap
@@ -8210,4986 +8210,5035 @@ exports[` > renders LargeSectionList story 1`] = `
data-dnd-overlay="true"
/>
diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css
index 82fd44ae66..348e986ae5 100644
--- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css
+++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css
@@ -58,6 +58,12 @@
}
}
+ /* A focused header can be scrolled flush to the scroller's top edge (where it pins), so the
+ default outline would be clipped by overflow:auto. Inset it so the focus ring stays visible. */
+ &:focus-visible {
+ outline-offset: -2px;
+ }
+
&.unread {
font: var(--cpd-font-body-sm-semibold);
color: var(--cpd-color-text-primary);
@@ -88,12 +94,6 @@
.firstHeader {
padding-top: 0;
-
- /* The first item sits flush with the scroller's top edge, so the default outline
- would be clipped by overflow:auto. Inset it so it stays inside the button. */
- &:focus-visible {
- outline-offset: -2px;
- }
}
.lastHeader {
@@ -135,3 +135,32 @@
.dragSource .container {
opacity: 0.6;
}
+
+/*
+Opaque backing for the pinned "current section" overlay header. The list rows scroll underneath it,
+so it must obscure them. The header button stays full width (so its pill aligns with the real header
+rows), while the opaque fill is painted by the pseudo-element below.
+*/
+.stickyBackground {
+ position: relative;
+ width: 100%;
+}
+
+/*
+The opaque fill. It stops short of the inline-end edge by the same gutter the room/header pills
+already leave clear (--cpd-space-3x), so the scroller's scrollbar — which floats in that gutter —
+stays visible instead of being covered by the pinned overlay. It's a separate inset layer (rather
+than a plain `background` on .stickyBackground) precisely so it can stop short of that gutter.
+
+An absolutely-positioned element paints ABOVE its statically-positioned siblings by default, so
+`z-index: -1` is needed to drop this fill behind the header's text/chevron/menu. It still covers the
+scrolling rows beneath because the overlay wrapper (.stickyHeader) owns the stacking context.
+*/
+.stickyBackground::before {
+ content: "";
+ position: absolute;
+ inset-block: 0;
+ inset-inline: 0 var(--cpd-space-3x);
+ z-index: -1;
+ background: var(--cpd-color-bg-canvas-default);
+}
diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListStickySectionHeaderView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListStickySectionHeaderView.tsx
new file mode 100644
index 0000000000..9e67b43a83
--- /dev/null
+++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListStickySectionHeaderView.tsx
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2026 Element Creations 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, { memo, type JSX } from "react";
+import classNames from "classnames";
+
+import { useViewModel } from "../../../core/viewmodel";
+import styles from "./RoomListSectionHeaderView.module.css";
+import { type RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderView";
+import { RoomListSectionHeaderContent } from "./RoomListSectionHeaderContent";
+
+/**
+ * Props for {@link RoomListStickySectionHeaderView}.
+ */
+export interface RoomListStickySectionHeaderViewProps {
+ /** The view model for the section currently pinned at the top of the list. */
+ vm: RoomListSectionHeaderViewModel;
+ /** Whether this is the first section, so it can sit flush with the top edge like the real header. */
+ isFirst: boolean;
+}
+
+/**
+ * A clone of {@link RoomListSectionHeaderView} used as the pinned "current section" overlay at the
+ * top of the virtualized room list.
+ *
+ * It is mouse-interactive — clicking toggles the section and hovering highlights it — but it is
+ * hidden from assistive technology (`aria-hidden`) and removed from the tab order (`tabIndex={-1}`).
+ * The real header rows inside the list remain the focusable, keyboard-navigable, screen-reader
+ * elements, so the overlay is a mouse convenience that never duplicates anything for AT. It reuses
+ * {@link RoomListSectionHeaderContent} so the chevron, title, notification decoration and section
+ * menu stay identical to the real header; it only omits the real header's drag-and-drop wiring.
+ */
+export const RoomListStickySectionHeaderView = memo(function RoomListStickySectionHeaderView({
+ vm,
+ isFirst,
+}: Readonly): JSX.Element {
+ const { isExpanded, isUnread } = useViewModel(vm);
+
+ return (
+
+ {/* `aria-expanded` is reused only to drive the chevron rotation via the shared CSS. */}
+
+
+
+
+ );
+});
diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/index.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/index.ts
index 5668037aaa..eef4eac089 100644
--- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/index.ts
+++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/index.ts
@@ -13,3 +13,5 @@ export type {
RoomListSectionHeaderViewSnapshot,
RoomListSectionHeaderActions,
} from "./RoomListSectionHeaderView";
+export { RoomListStickySectionHeaderView } from "./RoomListStickySectionHeaderView";
+export type { RoomListStickySectionHeaderViewProps } from "./RoomListStickySectionHeaderView";
diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx
index a490e3d139..5641b652e0 100644
--- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx
+++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx
@@ -20,7 +20,7 @@ import {
} from "../../core/VirtualizedList";
import type { RoomListViewSnapshot, RoomListViewModel } from "../RoomListView";
import { GroupedVirtualizedList, type GroupedVirtualizedListProps } from "../../core/VirtualizedList";
-import { RoomListSectionHeaderView } from "./RoomListSectionHeaderView";
+import { RoomListSectionHeaderView, RoomListStickySectionHeaderView } from "./RoomListSectionHeaderView";
import { RoomListSectionHeaderDragOverlayView } from "./RoomListSectionHeaderDragOverlayView";
import { RoomListItemWrapper } from "./RoomListItemWrapper";
import { RoomListItemDragOverlayView } from "./RoomListItemDragOverlayView";
@@ -390,6 +390,19 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
[],
);
+ /**
+ * Render the pinned "current section" overlay header for the grouped list.
+ * Presentational only — the real header rows in the list stay the accessible, focusable
+ * controls. See {@link RoomListStickySectionHeaderView}.
+ */
+ const renderStickyHeader = useCallback(
+ (groupIndex: number, headerId: string, context: VirtualizedListContext): ReactNode => {
+ const sectionHeaderVM = context.context.vm.getSectionHeaderViewModel(headerId);
+ return ;
+ },
+ [],
+ );
+
/**
* Get the key for a room item
* Since we're using virtualization, items are always room ID strings
@@ -431,7 +444,10 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
*/
const scrollIntoViewOnChange = useCallback(
(params: {
- context: VirtualizedListContext<{ spaceId: string; filterKeys: FilterKey[] | undefined }>;
+ context: VirtualizedListContext<{
+ spaceId: string;
+ filterKeys: FilterKey[] | undefined;
+ }>;
}): ScrollIntoViewLocation | null | undefined | false => {
const { spaceId, filterKeys } = params.context.context;
const shouldScrollIndexIntoView =
@@ -458,7 +474,11 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
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" });
+ virtuosoHandleRef.current?.scrollIntoView({
+ index: flatIndex,
+ align: "start",
+ behavior: "auto",
+ });
}, [scrollToSectionTag, sections]);
// Give the view model an imperative handle to scroll an item index into view (e.g. when the
@@ -567,6 +587,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
getGroupHeaderComponent={getGroupHeaderComponent}
getItemComponent={getItemComponentForGroupedList}
isGroupHeaderFocusable={isGroupHeaderFocusable}
+ renderStickyHeader={renderStickyHeader}
/>
);