Sticky Header for Room List Sections (#33968)
* Add a pinned sticky group header to GroupedVirtualizedList Section-header rows pin natively with `position: sticky`, applied via a `components.Item` wrapper that detects header entries. Consecutive stuck headers stack in DOM order, giving compositor-driven cover/reveal: the incoming header covers the current one on scroll-down and slides off to reveal the previous on scroll-up. Because the flat virtualizer unmounts off-screen rows, a sticky header row vanishes once a tall section scrolls past the overscan window. An always-mounted overlay outside the item stream backstops this, keeping the current section's header pinned; a mounted real header row sits over it for an invisible hand-off. The current section is tracked from the rendered items' offsets and live scrollTop on Virtuoso's onScroll. `scrollPaddingTop` on the useVirtualizedList hook lands keyboard-focused items below the pinned header via Virtuoso's calculateViewLocation. Signed-off-by: David Langley <langley.dave@gmail.com> * Show a sticky section header in the room list VirtualizedRoomListView renders the current section's header into the pinned overlay via RoomListStickySectionHeaderView, which reuses RoomListSectionHeaderContent so the chevron, title, notification badge and section menu stay identical to the real header. The overlay is mouse-interactive but `aria-hidden` and out of the tab order; the real header rows keep all keyboard, screen-reader and drag-and-drop duties. Headers use a uniform 44px height so a covering header fully covers the one behind it, and an opaque background so stacked stuck headers don't show through each other. Snapshot regenerated. Signed-off-by: David Langley <langley.dave@gmail.com> * Stop enforcing 44px height * Clarify sticky-header z-index layering Only the relative order of the room-list sticky stack matters (room rows < overlay backstop < real sticky header rows), so use 1/2 instead of 10/11 and spell out the ordering in comments. Explain why the overlay's opaque fill needs z-index: -1 (absolutely-positioned, so it would otherwise paint over the header content). No behavioural change. --------- Signed-off-by: David Langley <langley.dave@gmail.com>
This commit is contained in:
+56
@@ -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);
|
||||
}
|
||||
+194
-14
@@ -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 (
|
||||
<div {...props} ref={ref as React.Ref<HTMLDivElement>} className={isHeader ? styles.stickyRow : undefined}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A group of items for the grouped virtualized list.
|
||||
@@ -31,7 +62,9 @@ type NavigationEntry<Header, Item> = { header: Header } | { item: Item };
|
||||
|
||||
export interface GroupedVirtualizedListProps<Header, Item, Context> extends Omit<
|
||||
VirtualizedListProps<Item, Context>,
|
||||
"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<Header, Item, Context> 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<Context>) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,16 +184,23 @@ export function GroupedVirtualizedList<Header, Item, Context>(
|
||||
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<NavigationEntry<Header, Item>>((group) => [
|
||||
{ header: group.header },
|
||||
...group.items.map<NavigationEntry<Header, Item>>((item) => ({ item })),
|
||||
...group.items.map<NavigationEntry<Header, Item>>((item) => ({
|
||||
item,
|
||||
})),
|
||||
]),
|
||||
[groups],
|
||||
);
|
||||
@@ -153,6 +212,17 @@ export function GroupedVirtualizedList<Header, Item, Context>(
|
||||
[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<Header, Item>): string =>
|
||||
@@ -167,7 +237,11 @@ export function GroupedVirtualizedList<Header, Item, Context>(
|
||||
[isGroupHeaderFocusable, isItemFocusable],
|
||||
);
|
||||
|
||||
const { onFocusForGetItemComponent, ...virtuosoProps } = useVirtualizedList<NavigationEntry<Header, Item>, Context>(
|
||||
const {
|
||||
onFocusForGetItemComponent,
|
||||
scrollerRef: hookScrollerRef,
|
||||
...virtuosoProps
|
||||
} = useVirtualizedList<NavigationEntry<Header, Item>, Context>(
|
||||
{
|
||||
...(restProps as Omit<
|
||||
VirtualizedListProps<NavigationEntry<Header, Item>, Context>,
|
||||
@@ -176,6 +250,8 @@ export function GroupedVirtualizedList<Header, Item, Context>(
|
||||
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<Header, Item, Context>(
|
||||
],
|
||||
);
|
||||
|
||||
// --- 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<HTMLDivElement>(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<HTMLElement | null>(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<NavigationEntry<Header, Item>>[]): 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 (
|
||||
<Virtuoso
|
||||
// note that either the container of direct children must be focusable to be axe
|
||||
// compliant, so we leave tabIndex as the default so the container can be focused
|
||||
// (virtuoso wraps the children inside another couple of elements so setting it
|
||||
// on those doesn't seem to work, unfortunately)
|
||||
itemContent={itemContent}
|
||||
data={flatEntries}
|
||||
{...virtuosoProps}
|
||||
/>
|
||||
<div className={styles.stickyRoot}>
|
||||
{stickyHeader != null && (
|
||||
<div className={styles.stickyHeader} ref={overlayRef} onWheel={handleOverlayWheel}>
|
||||
{stickyHeader}
|
||||
</div>
|
||||
)}
|
||||
<Virtuoso
|
||||
// note that either the container of direct children must be focusable to be axe
|
||||
// compliant, so we leave tabIndex as the default so the container can be focused
|
||||
// (virtuoso wraps the children inside another couple of elements so setting it
|
||||
// on those doesn't seem to work, unfortunately)
|
||||
itemContent={itemContent}
|
||||
data={flatEntries}
|
||||
{...virtuosoProps}
|
||||
components={
|
||||
{ Item: StickyRowItem } as Components<
|
||||
NavigationEntry<Header, Item>,
|
||||
VirtualizedListContext<Context>
|
||||
>
|
||||
}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Item, Context> 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<Item, Context> extends Omit<
|
||||
context: VirtualizedListContext<Context>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Item, Context>(
|
||||
mapScrollIndex,
|
||||
mapRangeIndex,
|
||||
scrollerRef: externalScrollerRef,
|
||||
scrollPaddingTop,
|
||||
...virtuosoProps
|
||||
} = props;
|
||||
/** Reference to the Virtuoso component for programmatic scrolling */
|
||||
@@ -217,14 +251,19 @@ export function useVirtualizedList<Item, Context>(
|
||||
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],
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
+4696
-4598
File diff suppressed because it is too large
Load Diff
+35
-6
@@ -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);
|
||||
}
|
||||
|
||||
+60
@@ -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<RoomListStickySectionHeaderViewProps>): JSX.Element {
|
||||
const { isExpanded, isUnread } = useViewModel(vm);
|
||||
|
||||
return (
|
||||
<div className={styles.stickyBackground} aria-hidden={true}>
|
||||
{/* `aria-expanded` is reused only to drive the chevron rotation via the shared CSS. */}
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(styles.header, {
|
||||
[styles.firstHeader]: isFirst,
|
||||
[styles.unread]: isUnread,
|
||||
})}
|
||||
aria-expanded={isExpanded}
|
||||
onClick={vm.onClick}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RoomListSectionHeaderContent vm={vm} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
+2
@@ -13,3 +13,5 @@ export type {
|
||||
RoomListSectionHeaderViewSnapshot,
|
||||
RoomListSectionHeaderActions,
|
||||
} from "./RoomListSectionHeaderView";
|
||||
export { RoomListStickySectionHeaderView } from "./RoomListStickySectionHeaderView";
|
||||
export type { RoomListStickySectionHeaderViewProps } from "./RoomListStickySectionHeaderView";
|
||||
|
||||
+24
-3
@@ -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<Context>): ReactNode => {
|
||||
const sectionHeaderVM = context.context.vm.getSectionHeaderViewModel(headerId);
|
||||
return <RoomListStickySectionHeaderView key={headerId} vm={sectionHeaderVM} isFirst={groupIndex === 0} />;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 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}
|
||||
/>
|
||||
</DragDropProvider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user