Remove sticky headers in shared components grouped list (#32908)

* fix: use a flat list instead of grouped virtuoso for the grouped list

* test: update virtualized list

* test: update snapshots
This commit is contained in:
Florian Duros
2026-03-25 14:33:41 +00:00
committed by GitHub
parent d4fb08b392
commit 3764224fc2
3 changed files with 99 additions and 160 deletions
@@ -8283,16 +8283,18 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
tabindex="0"
>
<div
style="position: sticky; top: 0px; width: 100%; z-index: 1; margin-top: 0px;"
data-viewport-type="element"
style="height: 100%; position: absolute; top: 0px; width: 100%;"
>
<div
data-testid="virtuoso-top-item-list"
data-testid="virtuoso-item-list"
style="box-sizing: border-box; margin-top: 0px; padding-bottom: 3432px; padding-top: 0px;"
>
<div
data-index="0"
data-item-index="0"
data-known-size="52"
style="overflow-anchor: none; position: sticky; z-index: 1;"
style="overflow-anchor: none;"
>
<div
aria-expanded="true"
@@ -8334,20 +8336,9 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</button>
</div>
</div>
</div>
</div>
<div
data-viewport-type="element"
style="height: 100%; position: absolute; top: 0px; width: 100%;"
>
<div
data-testid="virtuoso-item-list"
style="box-sizing: border-box; margin-top: 0px; padding-bottom: 3432px; padding-top: 52px;"
>
<div
data-index="1"
data-item-group-index="0"
data-item-index="0"
data-item-index="1"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -8501,8 +8492,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="2"
data-item-group-index="0"
data-item-index="1"
data-item-index="2"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -8629,8 +8619,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="3"
data-item-group-index="0"
data-item-index="2"
data-item-index="3"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -8763,8 +8752,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="4"
data-item-group-index="0"
data-item-index="3"
data-item-index="4"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -8891,8 +8879,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="5"
data-item-group-index="0"
data-item-index="4"
data-item-index="5"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -9025,8 +9012,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="6"
data-item-group-index="0"
data-item-index="5"
data-item-index="6"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -9176,8 +9162,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="7"
data-item-group-index="0"
data-item-index="6"
data-item-index="7"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -9310,8 +9295,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="8"
data-item-group-index="0"
data-item-index="7"
data-item-index="8"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -9438,8 +9422,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="9"
data-item-group-index="0"
data-item-index="8"
data-item-index="9"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -9572,8 +9555,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="10"
data-item-group-index="0"
data-item-index="9"
data-item-index="10"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -9700,8 +9682,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="11"
data-item-group-index="0"
data-item-index="10"
data-item-index="11"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -9857,8 +9838,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="12"
data-item-group-index="0"
data-item-index="11"
data-item-index="12"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -9985,8 +9965,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="13"
data-item-group-index="0"
data-item-index="12"
data-item-index="13"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -10119,8 +10098,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="14"
data-item-group-index="0"
data-item-index="13"
data-item-index="14"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -10247,8 +10225,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="15"
data-item-group-index="0"
data-item-index="14"
data-item-index="15"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -10381,8 +10358,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="16"
data-item-group-index="0"
data-item-index="15"
data-item-index="16"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -10532,8 +10508,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="17"
data-item-group-index="0"
data-item-index="16"
data-item-index="17"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -10666,8 +10641,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="18"
data-item-group-index="0"
data-item-index="17"
data-item-index="18"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -10794,8 +10768,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="19"
data-item-group-index="0"
data-item-index="18"
data-item-index="19"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -10928,8 +10901,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="20"
data-item-group-index="0"
data-item-index="19"
data-item-index="20"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -11056,8 +11028,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="21"
data-item-group-index="0"
data-item-index="20"
data-item-index="21"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -11213,8 +11184,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="22"
data-item-group-index="0"
data-item-index="21"
data-item-index="22"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -11341,8 +11311,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="23"
data-item-group-index="0"
data-item-index="22"
data-item-index="23"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -11475,9 +11444,9 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="24"
data-item-index="1"
data-item-index="24"
data-known-size="52"
style="overflow-anchor: none; position: sticky; z-index: 1;"
style="overflow-anchor: none;"
>
<div
aria-expanded="true"
@@ -11521,8 +11490,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="25"
data-item-group-index="1"
data-item-index="23"
data-item-index="25"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -11649,8 +11617,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="26"
data-item-group-index="1"
data-item-index="24"
data-item-index="26"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -11783,8 +11750,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="27"
data-item-group-index="1"
data-item-index="25"
data-item-index="27"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -11934,8 +11900,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="28"
data-item-group-index="1"
data-item-index="26"
data-item-index="28"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -12068,8 +12033,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="29"
data-item-group-index="1"
data-item-index="27"
data-item-index="29"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -12196,8 +12160,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="30"
data-item-group-index="1"
data-item-index="28"
data-item-index="30"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -12330,8 +12293,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="31"
data-item-group-index="1"
data-item-index="29"
data-item-index="31"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -12458,8 +12420,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="32"
data-item-group-index="1"
data-item-index="30"
data-item-index="32"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -12615,8 +12576,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="33"
data-item-group-index="1"
data-item-index="31"
data-item-index="33"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -12743,8 +12703,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="34"
data-item-group-index="1"
data-item-index="32"
data-item-index="34"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -12877,8 +12836,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="35"
data-item-group-index="1"
data-item-index="33"
data-item-index="35"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -13005,8 +12963,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
</div>
<div
data-index="36"
data-item-group-index="1"
data-item-index="34"
data-item-index="36"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -13626,16 +13583,18 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
tabindex="0"
>
<div
style="position: sticky; top: 0px; width: 100%; z-index: 1; margin-top: 0px;"
data-viewport-type="element"
style="height: 100%; position: absolute; top: 0px; width: 100%;"
>
<div
data-testid="virtuoso-top-item-list"
data-testid="virtuoso-item-list"
style="box-sizing: border-box; margin-top: 0px; padding-bottom: 0px; padding-top: 0px;"
>
<div
data-index="0"
data-item-index="0"
data-known-size="52"
style="overflow-anchor: none; position: sticky; z-index: 1;"
style="overflow-anchor: none;"
>
<div
aria-expanded="true"
@@ -13677,20 +13636,9 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
</button>
</div>
</div>
</div>
</div>
<div
data-viewport-type="element"
style="height: 100%; position: absolute; top: 0px; width: 100%;"
>
<div
data-testid="virtuoso-item-list"
style="box-sizing: border-box; margin-top: 0px; padding-bottom: 0px; padding-top: 52px;"
>
<div
data-index="1"
data-item-group-index="0"
data-item-index="0"
data-item-index="1"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -13844,8 +13792,7 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
</div>
<div
data-index="2"
data-item-group-index="0"
data-item-index="1"
data-item-index="2"
data-known-size="52"
style="overflow-anchor: none;"
>
@@ -13972,9 +13919,9 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
</div>
<div
data-index="3"
data-item-index="1"
data-item-index="3"
data-known-size="52"
style="overflow-anchor: none; position: sticky; z-index: 1;"
style="overflow-anchor: none;"
>
<div
aria-expanded="true"
@@ -6,7 +6,7 @@
*/
import React, { type JSX, useCallback, useMemo } from "react";
import { GroupedVirtuoso } from "react-virtuoso";
import { Virtuoso } from "react-virtuoso";
import { useVirtualizedList, type VirtualizedListContext, type VirtualizedListProps } from "../virtualized-list";
@@ -102,7 +102,7 @@ export interface GroupedVirtualizedListProps<Header, Item, Context> extends Omit
}
/**
* A generic grouped virtualized list component built on top of react-virtuoso's GroupedVirtuoso.
* A generic grouped virtualized list component built on top of react-virtuoso's Virtuoso.
* Provides keyboard navigation (including group headers) and virtualized rendering for
* performance with large lists.
*
@@ -129,10 +129,8 @@ export function GroupedVirtualizedList<Header, Item, Context>(
...restProps
} = props;
const groupCounts = useMemo(() => groups.map((group) => group.items.length), [groups]);
const items = useMemo(() => groups.flatMap((group) => group.items), [groups]);
// Build a flat navigation array interleaving group headers with items.
// 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) => [
@@ -142,34 +140,12 @@ export function GroupedVirtualizedList<Header, Item, Context>(
[groups],
);
// Build both index-mapping functions in a single pass over the flat entries.
// mapScrollIndex: flat index → GroupedVirtuoso item index (headers map to their
// first item so scrollIntoView makes the sticky header visible).
// mapRangeIndex: GroupedVirtuoso item index → flat index (translates visible-range
// indices back so the hook's PageUp/PageDown and focus-restore logic works).
const { mapScrollIndex, mapRangeIndex } = useMemo(() => {
// Map each flat index to the corresponding virtuoso item index.
// Headers map to the first item of their group so scrollIntoView shows the sticky header.
const flatIndexToVirtuosoIndex: number[] = [];
// Map the Item index (from virtuoso) to their position in the flat list
const virtuosoIndexToFlatIndex: number[] = [];
let virtuosoIndex = 0;
for (let i = 0; i < flatEntries.length; i++) {
flatIndexToVirtuosoIndex.push(virtuosoIndex);
if ("item" in flatEntries[i]) {
virtuosoIndexToFlatIndex.push(i);
virtuosoIndex++;
}
}
return {
mapScrollIndex: (flatIndex: number): number => flatIndexToVirtuosoIndex[flatIndex] ?? 0,
mapRangeIndex: (virtuosoIndex: number): number => virtuosoIndexToFlatIndex[virtuosoIndex] ?? 0,
};
}, [flatEntries]);
// Pre-compute a lookup from flat index to group index.
// Each group contributes 1 header + N items, all mapped to the same group index.
const flatIndexToGroupIndex = useMemo(
() => groups.flatMap((group, groupIdx) => new Array(1 + group.items.length).fill(groupIdx)),
[groups],
);
// Wrap getItemKey: dispatch to getHeaderKey or getItemKey based on entry type
const wrappedGetEntryKey = useCallback(
@@ -178,7 +154,7 @@ export function GroupedVirtualizedList<Header, Item, Context>(
[getHeaderKey, getItemKey],
);
// Wrap isItemFocusable: headers use isHeaderFocusable (default: always true), items use isItemFocusable
// Wrap isItemFocusable: headers use isGroupHeaderFocusable, items use isItemFocusable
const wrappedIsEntryFocusable = useCallback(
(entry: NavigationEntry<Header, Item>): boolean =>
"header" in entry ? isGroupHeaderFocusable(entry.header) : isItemFocusable(entry.item),
@@ -194,8 +170,6 @@ export function GroupedVirtualizedList<Header, Item, Context>(
items: flatEntries,
isItemFocusable: wrappedIsEntryFocusable,
getItemKey: wrappedGetEntryKey,
mapScrollIndex,
mapRangeIndex,
},
);
@@ -215,27 +189,44 @@ export function GroupedVirtualizedList<Header, Item, Context>(
[onFocusForGetItemComponent],
);
const getItemComponentInternal = useCallback(
(index: number, groupIndex: number, _item: unknown, context: VirtualizedListContext<Context>): JSX.Element =>
getItemComponent(index, items[index], context, onFocusForItem, groupIndex),
[items, getItemComponent, onFocusForItem],
);
// Unified item renderer that dispatches to group header or item component
// based on the entry type at the given flat index.
const itemContent = useCallback(
(
flatIndex: number,
_entry: NavigationEntry<Header, Item>,
context: VirtualizedListContext<Context>,
): JSX.Element => {
const entry = flatEntries[flatIndex];
const groupIndex = flatIndexToGroupIndex[flatIndex];
const getGroupHeaderComponentInternal = useCallback(
(groupIndex: number, context: VirtualizedListContext<Context>): JSX.Element =>
getGroupHeaderComponent(groupIndex, groups[groupIndex].header, context, onFocusForHeader),
[getGroupHeaderComponent, onFocusForHeader, groups],
if ("header" in entry) {
return getGroupHeaderComponent(groupIndex, entry.header, context, onFocusForHeader);
}
// Item index in the flattened (non-header) items array:
// flatIndex minus the number of headers before it (groupIndex + 1).
const itemIndex = flatIndex - (groupIndex + 1);
return getItemComponent(itemIndex, entry.item, context, onFocusForItem, groupIndex);
},
[
flatEntries,
flatIndexToGroupIndex,
getGroupHeaderComponent,
getItemComponent,
onFocusForItem,
onFocusForHeader,
],
);
return (
<GroupedVirtuoso
<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)
groupCounts={groupCounts}
itemContent={getItemComponentInternal}
groupContent={getGroupHeaderComponentInternal}
itemContent={itemContent}
data={flatEntries}
{...virtuosoProps}
/>
);
@@ -379,12 +379,13 @@ describe.each<ListTestVariant>([flatVariant, groupedVariant])("$name", (variant)
// Then press PageUp to jump up by viewport size
fireEvent.keyDown(container, { code: "PageUp" });
// Verify focus moved up
const items = container.querySelectorAll(".mx_item");
// PageUp should move back to the first item since we only have 4 items
expectTabIndex(items[0], "0");
const lastIndex = items.length - 1;
expectTabIndex(items[lastIndex], "-1");
// Verify focus moved up use the variant's navigable selector so
// group headers (which are also navigable) are included.
const allNav = container.querySelectorAll(variant.navigableSelector);
// PageUp should move back to the first navigable element since we only have a few items
expectTabIndex(allNav[0], "0");
const lastIndex = allNav.length - 1;
expectTabIndex(allNav[lastIndex], "-1");
});
it("should not handle keyboard navigation when modifier keys are pressed", () => {