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 ( +
} className={isHeader ? styles.stickyRow : undefined}> + {children} +
+ ); +}); /** * 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" />
+
+
+
- -
-
-
-
-
-
- - -
- -
- + +
-
-
- - -
- -
- -
- - -
-
-
- - +
- -
- -
- - - - -
-
-
- - -
- -
- -
- - -
-
-
-
- Last message in Product -
-
-
- - -
- -
- - - - -
-
-
- - -
-
- - + + - -
- - + + + +
+ + +
+ - -
- + + - -
- - +
- + + + +
+ +
+ - - - + + - -
- - + + + +
+ + +
+ - -
- + + - -
- - +
- + + + +
+ +
+ - - - + + - -
-
+
+ + +
+ -
-
- + + - -
- - +
- + + + +
+ +
+ - - - + + - -
- - + + + +
+ + +
+ - -
- + + - -
- - +
- + + + +
+ +
+ - - - + + - -
- - + + + +
+ + +
+ - -
- + + - -
-
+
+ + +
+ -
-
- + + - -
- - + + + +
+ + +
+ - -
- + + - -
- - +
- + + + +
+ +
+ - - - + + - -
- - + + + +
+ + +
+ - -
- + + - -
- - +
- + + + +
+ +
+ - - - + + - -
-
+
+ + +
+ -
-
- + + - -
- - +
- + + + +
+ +
+ - - - + + - -
- - + + + +
+ + +
+ - -
- + + - -
+
+
+ + +
+ +
+ +
+
+ +
+
+
+ + +
+ +
+ +
+ + +
+
+
+ + +
+ +
+
+ + + + +
+
+
+ + +
+ +
+ +
+ + +
+
+
+ + +
+ +
+ +
+ + +
- +
+
+ +
+
+
+ + +
+
-
- + + - -
- - +
- + + + +
+ +
+ - - - + + - -
-
+
+ + +
+
- -
- + + - -
- - -
- -
- + +
- -
- - + + + +
+ + +
+ - -
- + + - -
- - +
- + + + +
+ +
+ - - - + + - -
- - + + + +
+ + +
+ - -
- + + - -
- + +
+
-
- - -
- -
- + + - -
- - -
- +
+ + +
+
-
- + + - -
- - +
- + + + +
+ +
+ - - - + + - -
- - + + + +
+ + +
+ - -
- + + - -
- - -
- -
- -
- - -
-
-
- +
+ -
- - -
- -
- + +
@@ -13680,423 +13729,472 @@ exports[` > renders SmallSectionList 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} /> );