Room list: add a grouped virtualized list to shared components (#32566)

* refactor: extract most of the logic from the virtualized list

The VirtualizedList component is renamed FlatVirtualizedList and most of
the logic is extracted. In order to prepare the introduction of the
GroupedVirtualizedList which will share most of the behaviour.

* refactor: use `FlatVirtualizedList` instead of `VirtualizedList`

* feat: add grouped virtualized list to shared components

* feat: add accessiblity helps for virtualized list

* test: use one test suite for the two virtualized lists

* test: update storybook screenshots

* feat: add keyboard navigation on header

* test: make a11y test pass

* chore: delete old screenshot

* doc: a11y docs to list stories

* chore: fix copyright
This commit is contained in:
Florian Duros
2026-03-13 09:53:09 +00:00
committed by GitHub
parent 5f92215ead
commit 40a322ac05
17 changed files with 1304 additions and 234 deletions
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import { Form } from "@vector-im/compound-web";
import React, { type JSX, useCallback } from "react";
import { Flex, type VirtualizedListContext, VirtualizedList } from "@element-hq/web-shared-components";
import { Flex, type VirtualizedListContext, FlatVirtualizedList } from "@element-hq/web-shared-components";
import {
type MemberWithSeparator,
@@ -108,7 +108,7 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
<Form.Root onSubmit={(e) => e.preventDefault()}>
<MemberListHeaderView vm={vm} />
</Form.Root>
<VirtualizedList
<FlatVirtualizedList
items={vm.members}
getItemComponent={getItemComponent}
getItemKey={getItemKey}
@@ -12,7 +12,7 @@ import { isEqual } from "lodash";
import { RoomListItemView, type Room } from "../RoomListItemView";
import { useViewModel } from "../../viewmodel";
import { _t } from "../../utils/i18n";
import { VirtualizedList, type VirtualizedListContext } from "../../utils/VirtualizedList";
import { FlatVirtualizedList, type VirtualizedListContext } from "../../utils/VirtualizedList";
import type { RoomListViewModel } from "../RoomListView";
/**
@@ -174,7 +174,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
);
return (
<VirtualizedList
<FlatVirtualizedList
context={context}
scrollIntoViewOnChange={scrollIntoViewOnChange}
// If fixedItemHeight is not set and initialTopMostItemIndex=undefined, virtuoso crashes
@@ -0,0 +1,113 @@
/*
Copyright 2026 Element Creations.
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 from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FlatVirtualizedList, type FlatVirtualizedListProps } from "./FlatVirtualizedList";
import { type VirtualizedListContext } from "../virtualized-list";
import { items, SimpleItemComponent } from "../story-mock";
import { getContainerAccessibleProps, getItemAccessibleProps } from "../accessbility";
const meta = {
title: "Utils/VirtualizedList/FlatVirtualizedList",
component: FlatVirtualizedList<SimpleItemComponent, undefined>,
parameters: {
docs: {
description: {
component: `
A flat virtualized list that renders large datasets efficiently using
[react-virtuoso](https://virtuoso.dev/), while exposing full keyboard navigation.
## Accessibility with **\`listbox\`** ARIA pattern
This example uses the **\`listbox\`** ARIA pattern, which maps naturally to a
flat list of selectable options.
### Container props — \`getContainerAccessibleProps("listbox")\`
Spread the result of \`getContainerAccessibleProps("listbox")\` directly onto the
\`FlatVirtualizedList\` component to mark the scrollable container as a \`listbox\`:
| Prop | Value | Purpose |
|------|-------|---------|
| \`role\` | \`"listbox"\` | Identifies the container as a listbox widget to assistive technologies. |
\`\`\`tsx
<FlatVirtualizedList
{...getContainerAccessibleProps("listbox")}
aria-label="My list"
{/* other props */}
/>
\`\`\`
### Item props — \`getItemAccessibleProps("listbox", index, listSize)\`
Spread the result of \`getItemAccessibleProps("listbox", index, listSize)\` onto each rendered
item element so that screen readers can announce position and total count even when most DOM
nodes are not mounted (virtualized):
| Prop | Value | Purpose |
|------|-------|---------|
| \`role\` | \`"option"\` | Identifies the element as a selectable option within the listbox. |
| \`aria-posinset\` | \`index + 1\` | 1-based position of this option within the full set. |
| \`aria-setsize\` | \`listSize\` | Total number of options in the list. |
The list uses a [roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex)
pattern: \`context.tabIndexKey\` holds the key of the item that currently owns focus. Set
\`tabIndex={0}\` on the matching item and \`tabIndex={-1}\` on every other to keep the list
to a single tab stop while arrow-key navigation moves focus between items.
\`\`\`tsx
getItemComponent={(index, item, context, onFocus) => {
const selected = context.tabIndexKey === item.id;
return (
<button
type="button"
tabIndex={selected ? 0 : -1}
{...getItemAccessibleProps("listbox", index, items.length)}
onFocus={(e) => onFocus(item, e)}
onClick={() => console.log("Clicked item")}}
>
{item.label}
</button>
);
}}
\`\`\`
`,
},
},
},
args: {
items,
"getItemComponent": (
index: number,
item: SimpleItemComponent,
context: VirtualizedListContext<undefined>,
onFocus: (item: SimpleItemComponent, e: React.FocusEvent) => void,
) => (
<SimpleItemComponent
key={item.id}
item={item}
context={context}
onFocus={onFocus}
{...getItemAccessibleProps("listbox", index, items.length)}
/>
),
"isItemFocusable": () => true,
"getItemKey": (item) => item.id,
"style": { height: "400px" },
"aria-label": "Flat virtualized list",
...getContainerAccessibleProps("listbox"),
},
} satisfies Meta<FlatVirtualizedListProps<SimpleItemComponent, undefined>>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -0,0 +1,58 @@
/*
* 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, { type JSX, useCallback } from "react";
import { Virtuoso } from "react-virtuoso";
import { useVirtualizedList, type VirtualizedListContext, type VirtualizedListProps } from "../virtualized-list";
export interface FlatVirtualizedListProps<Item, Context> extends VirtualizedListProps<Item, Context> {
/**
* Function that renders each list item as a JSX element.
* @param index - The index of the item in the list
* @param item - The data item to render
* @param context - The context object containing the focused key and any additional data
* @param onFocus - A callback that is required to be called when the item component receives focus
* @returns JSX element representing the rendered item
*/
getItemComponent: (
index: number,
item: Item,
context: VirtualizedListContext<Context>,
onFocus: (item: Item, e: React.FocusEvent) => void,
) => JSX.Element;
}
/**
* A generic virtualized list component built on top of react-virtuoso.
* Provides keyboard navigation and virtualized rendering for performance with large lists.
*
* @template Item - The type of data items in the list
* @template Context - The type of additional context data passed to items
*/
export function FlatVirtualizedList<Item, Context>(props: FlatVirtualizedListProps<Item, Context>): React.ReactElement {
const { getItemComponent, ...restProps } = props;
const { onFocusForGetItemComponent, ...virtuosoProps } = useVirtualizedList<Item, Context>(restProps);
const getItemComponentInternal = useCallback(
(index: number, item: Item, context: VirtualizedListContext<Context>): JSX.Element =>
getItemComponent(index, item, context, onFocusForGetItemComponent),
[getItemComponent, onFocusForGetItemComponent],
);
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={getItemComponentInternal}
data={props.items}
{...virtuosoProps}
/>
);
}
@@ -0,0 +1,9 @@
/*
* 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.
*/
export { FlatVirtualizedList } from "./FlatVirtualizedList";
export type { FlatVirtualizedListProps } from "./FlatVirtualizedList";
@@ -0,0 +1,218 @@
/*
* 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 { type Meta, type StoryObj } from "@storybook/react-vite";
import React from "react";
import { GroupedVirtualizedList, type GroupedVirtualizedListProps } from "./GroupedVirtualizedList";
import { type VirtualizedListContext } from "../virtualized-list";
import { GroupHeaderComponent, groups, SimpleItemComponent, type SimpleGroupHeader } from "../story-mock";
import { getContainerAccessibleProps, getGroupHeaderAccessibleProps, getItemAccessibleProps } from "../accessbility";
// Calculate total rows for ARIA props (group headers + items)
const totalRows = groups.reduce((total, group) => total + 1 + group.items.length, 0);
const meta = {
title: "Utils/VirtualizedList/GroupedVirtualizedList",
component: GroupedVirtualizedList<SimpleGroupHeader, SimpleItemComponent, undefined>,
parameters: {
docs: {
description: {
component: `
A grouped virtualized list that renders large datasets organised into labelled sections
efficiently using [react-virtuoso](https://virtuoso.dev/), while exposing full keyboard
navigation for both group headers and child items.
## Accessibility with **\`treegrid\`** ARIA pattern
This example uses the **\`treegrid\`** ARIA pattern. A treegrid models a
two-level hierarchy: group headers sit at **level 1** and their child items sit at
**level 2**. This lets assistive technologies announce both the group structure and the
position of each item within its group.
### Container props — \`getContainerAccessibleProps("treegrid", totalRows)\`
Spread the result of \`getContainerAccessibleProps("treegrid", totalRows)\` directly onto the
\`GroupedVirtualizedList\` component to mark the scrollable container as a \`treegrid\`:
| Prop | Value | Purpose |
|------|-------|---------|
| \`role\` | \`"treegrid"\` | Identifies the container as a treegrid widget to assistive technologies. |
| \`aria-rowcount\` | \`totalRows\` | Total number of rows in the treegrid (group headers + items). Because virtualization only mounts a subset of rows, browsers cannot count them from the DOM — this attribute supplies the true count so screen readers can announce e.g. *"row 12 of 53"*. |
\`totalRows\` must include **every** row that will ever appear: one per group header plus one
per item across all groups.
\`\`\`tsx
const totalRows = groups.reduce((total, group) => total + 1 + group.items.length, 0);
<GroupedVirtualizedList
{...getContainerAccessibleProps("treegrid", totalRows)}
aria-label="My grouped list"
{/* other props */}
/>
\`\`\`
---
### Group header props — \`getGroupHeaderAccessibleProps(index, groupIndex, groupSize)\`
Spread the result of \`getGroupHeaderAccessibleProps\` onto each rendered group header element
to place it at level 1 in the tree hierarchy:
| Prop | Value | Purpose |
|------|-------|---------|
| \`role\` | \`"row"\` | Identifies the element as a row within the treegrid. |
| \`aria-level\` | \`1\` | Places the header at the root level of the tree hierarchy. |
| \`aria-posinset\` | \`groupIndex + 1\` | 1-based position of this group among all groups. |
| \`aria-rowindex\` | \`index + 1\` | 1-based position of this row in the full flat row sequence (headers + items). |
| \`aria-setsize\` | \`groupSize\` | Total number of items inside this group. |
The list also uses a [roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex)
pattern: \`context.tabIndexKey\` holds the key of the element that currently owns focus. Set
\`tabIndex={0}\` on the matching gridcell and \`tabIndex={-1}\` on every other to keep the
list to a single tab stop while arrow-key navigation moves focus between rows.
\`\`\`tsx
getGroupHeaderComponent={(groupIndex, header, context, onFocus) => {
// Flat row index: sum of (1 header + N items) for every preceding group
const index = groups
.slice(0, groupIndex)
.reduce((sum, g) => sum + 1 + g.items.length, 0);
const groupSize = groups[groupIndex].items.length;
const selected = context.tabIndexKey === header.id;
return (
<div
{...getGroupHeaderAccessibleProps(index, groupIndex, groupSize)}
>
{/* Direct child must be a gridcell */}
<button
role="gridcell"
type="button"
tabIndex={selected ? 0 : -1}
onFocus={(e) => onFocus(header, e)}
onClick={() => console.log("Clicked group header")}}
>
{header.label}
</button>
</div>
);
}}
\`\`\`
---
### Item props — \`getItemAccessibleProps("treegrid", index, indexInGroup)\`
Spread the result of \`getItemAccessibleProps("treegrid", index, indexInGroup)\` onto each
rendered item element to place it at level 2 in the tree hierarchy:
| Prop | Value | Purpose |
|------|-------|---------|
| \`role\` | \`"row"\` | Identifies the element as a row within the treegrid. |
| \`aria-level\` | \`2\` | Places the item as a child of its group header at level 1. |
| \`aria-rowindex\` | \`index + 1\` | 1-based position of this row in the full flat row sequence (headers + items). |
| \`aria-posinset\` | \`indexInGroup + 1\` | 1-based position of this item within its own group. |
Both \`index\` (flat row index across the whole treegrid) and \`indexInGroup\` (position
within the item's group) must be computed before passing to the function.
As with group headers, apply the [roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex)
pattern using \`context.tabIndexKey\` to keep the list to a single tab stop.
\`\`\`tsx
getItemComponent={(_, item, context, onFocus, groupIndex) => {
const group = groups[groupIndex];
const indexInGroup = group.items.findIndex((i) => i.id === item.id);
// Flat row index: skip (1 header + N items) per preceding group, then add
// 1 for the current group's header, then the item's position within the group.
const index = groups
.slice(0, groupIndex)
.reduce((sum, g) => sum + 1 + g.items.length, indexInGroup + 1);
const selected = context.tabIndexKey === item.id;
return (
<div
{...getItemAccessibleProps("treegrid", index, indexInGroup)}
>
{/* Direct child must be a gridcell */}
<button
role="gridcell"
type="button"
tabIndex={selected ? 0 : -1}
onFocus={(e) => onFocus(item, e)}
onClick={() => console.log("Clicked item")}}
>
{item.label}
</button>
</div>
);
}}
\`\`\`
`,
},
},
},
args: {
groups,
"getItemComponent": (
_index: number,
item: SimpleItemComponent,
context: VirtualizedListContext<undefined>,
onFocus: (item: SimpleItemComponent, e: React.FocusEvent) => void,
groupIndex: number,
) => {
const group = groups[groupIndex];
const indexInGroup = group.items.findIndex((i) => i.id === item.id);
const index = groups.slice(0, groupIndex).reduce((sum, g) => sum + 1 + g.items.length, indexInGroup + 1);
return (
<SimpleItemComponent
key={item.id}
item={item}
context={context}
onFocus={onFocus}
{...getItemAccessibleProps("treegrid", index, indexInGroup)}
/>
);
},
"getGroupHeaderComponent": (
groupIndex: number,
header: SimpleGroupHeader,
context: VirtualizedListContext<undefined>,
onFocus: (header: SimpleGroupHeader, e: React.FocusEvent) => void,
) => {
const index = groups.slice(0, groupIndex).reduce((sum, g) => sum + 1 + g.items.length, 0);
const groupSize = groups[groupIndex].items.length;
return (
<GroupHeaderComponent
key={header.id}
header={header}
context={context}
onFocus={onFocus}
{...getGroupHeaderAccessibleProps(index, groupIndex, groupSize)}
/>
);
},
"isItemFocusable": () => true,
"isGroupHeaderFocusable": () => true,
"getItemKey": (item) => item.id,
"getHeaderKey": (header) => header.id,
"style": { height: "400px" },
"aria-label": "Grouped virtualized list",
...getContainerAccessibleProps("treegrid", totalRows),
},
} satisfies Meta<GroupedVirtualizedListProps<SimpleGroupHeader, SimpleItemComponent, undefined>>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -0,0 +1,242 @@
/*
* 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, { type JSX, useCallback, useMemo } from "react";
import { GroupedVirtuoso } from "react-virtuoso";
import { useVirtualizedList, type VirtualizedListContext, type VirtualizedListProps } from "../virtualized-list";
/**
* A group of items for the grouped virtualized list.
* The `header` uses a dedicated `Header` type, separate from the `Item` type
* used for the group's child items.
*/
export interface Group<Header, Item> {
/** The data representing this group's header. */
header: Header;
/** The items belonging to this group. */
items: Item[];
}
/**
* Internal discriminated union used to bridge the separate `Item` / `Header`
* types into a single array that the keyboard-navigation hook can operate on.
* Discriminated by property name: `"header" in entry` vs `"item" in entry`.
*/
type NavigationEntry<Header, Item> = { header: Header } | { item: Item };
export interface GroupedVirtualizedListProps<Header, Item, Context> extends Omit<
VirtualizedListProps<Item, Context>,
"items" | "isItemFocusable" | "getItemKey"
> {
/**
* The groups to display in the virtualized list.
* Each group has a header and an array of child items.
*/
groups: Group<Header, Item>[];
/**
* Function to get a unique key for an item.
* @param item - The item to get the key for
* @returns A unique key string
*/
getItemKey: (item: Item) => string;
/**
* Function to get a unique key for a group header.
* @param header - The header to get the key for
* @returns A unique key string
*/
getHeaderKey: (header: Header) => string;
/**
* Function to determine if an item can receive focus during keyboard navigation.
* @param item - The item to check
* @returns true if the item can be focused
*/
isItemFocusable: (item: Item) => boolean;
/**
* Function to determine if a group header can receive focus during keyboard navigation.
* @param header - The header to check
* @returns true if the header can be focused
*/
isGroupHeaderFocusable: (header: Header) => boolean;
/**
* Function that renders the group header as a JSX element.
* @param groupIndex - The index of the group in the list
* @param header - The header data for this group
* @param context - The context object containing the focused key and any additional data
* @param onFocus - A callback that must be called when the group header component receives
* focus. Should be invoked as `onFocus(header, e)`.
* @returns JSX element representing the rendered group header
*/
getGroupHeaderComponent: (
groupIndex: number,
header: Header,
context: VirtualizedListContext<Context>,
onFocus: (header: Header, e: React.FocusEvent) => void,
) => JSX.Element;
/**
* Function that renders each list item as a JSX element.
* @param index - The index of the item in the list (relative to the entire list, not the group)
* @param item - The data item to render
* @param context - The context object containing the focused key and any additional data
* @param onFocus - A callback that is required to be called when the item component receives focus
* @param groupIndex - The index of the group this item belongs to
* @returns JSX element representing the rendered item
*/
getItemComponent: (
index: number,
item: Item,
context: VirtualizedListContext<Context>,
onFocus: (item: Item, e: React.FocusEvent) => void,
groupIndex: number,
) => JSX.Element;
}
/**
* A generic grouped virtualized list component built on top of react-virtuoso's GroupedVirtuoso.
* Provides keyboard navigation (including group headers) and virtualized rendering for
* performance with large lists.
*
* Group headers use a dedicated `Header` type, while child items use `Item`.
* Internally, a unified flat array interleaving headers and items is built using
* `flatMap` so that the keyboard-navigation hook can treat every focusable element
* uniformly.
*
* @template Header - The type of group header data
* @template Item - The type of data items in the list
* @template Context - The type of additional context data passed to items
*/
export function GroupedVirtualizedList<Header, Item, Context>(
props: GroupedVirtualizedListProps<Header, Item, Context>,
): React.ReactElement {
const {
getItemComponent,
groups,
getGroupHeaderComponent,
isItemFocusable,
isGroupHeaderFocusable,
getItemKey,
getHeaderKey,
...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.
const flatEntries = useMemo(
() =>
groups.flatMap<NavigationEntry<Header, Item>>((group) => [
{ header: group.header },
...group.items.map<NavigationEntry<Header, Item>>((item) => ({ item })),
]),
[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]);
// Wrap getItemKey: dispatch to getHeaderKey or getItemKey based on entry type
const wrappedGetEntryKey = useCallback(
(entry: NavigationEntry<Header, Item>): string =>
"header" in entry ? getHeaderKey(entry.header) : getItemKey(entry.item),
[getHeaderKey, getItemKey],
);
// Wrap isItemFocusable: headers use isHeaderFocusable (default: always true), items use isItemFocusable
const wrappedIsEntryFocusable = useCallback(
(entry: NavigationEntry<Header, Item>): boolean =>
"header" in entry ? isGroupHeaderFocusable(entry.header) : isItemFocusable(entry.item),
[isGroupHeaderFocusable, isItemFocusable],
);
const { onFocusForGetItemComponent, ...virtuosoProps } = useVirtualizedList<NavigationEntry<Header, Item>, Context>(
{
...(restProps as Omit<
VirtualizedListProps<NavigationEntry<Header, Item>, Context>,
"items" | "isItemFocusable" | "getItemKey"
>),
items: flatEntries,
isItemFocusable: wrappedIsEntryFocusable,
getItemKey: wrappedGetEntryKey,
mapScrollIndex,
mapRangeIndex,
},
);
// Convert (Item, e) → (NavigationEntry, e) for regular items
const onFocusForItem = useCallback(
(item: Item, e: React.FocusEvent): void => {
onFocusForGetItemComponent({ item }, e);
},
[onFocusForGetItemComponent],
);
// Convert (Header, e) → (NavigationEntry, e) for group headers
const onFocusForHeader = useCallback(
(header: Header, e: React.FocusEvent): void => {
onFocusForGetItemComponent({ header }, e);
},
[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],
);
const getGroupHeaderComponentInternal = useCallback(
(groupIndex: number, context: VirtualizedListContext<Context>): JSX.Element =>
getGroupHeaderComponent(groupIndex, groups[groupIndex].header, context, onFocusForHeader),
[getGroupHeaderComponent, onFocusForHeader, groups],
);
return (
<GroupedVirtuoso
// 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}
{...virtuosoProps}
/>
);
}
@@ -0,0 +1,9 @@
/*
* 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.
*/
export { GroupedVirtualizedList } from "./GroupedVirtualizedList";
export type { GroupedVirtualizedListProps, Group } from "./GroupedVirtualizedList";
@@ -1,59 +0,0 @@
/*
Copyright 2026 New Vector 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 from "react";
import classNames from "classnames";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { VirtualizedList, type IVirtualizedListProps, type VirtualizedListContext } from "./VirtualizedList";
import styles from "./story-mock.module.css";
interface SimpleItem {
id: string;
label: string;
}
const items: SimpleItem[] = Array.from({ length: 50 }, (_, i) => ({
id: `item-${i}`,
label: `Item ${i + 1}`,
}));
const meta = {
title: "Utils/VirtualizedList",
component: VirtualizedList<SimpleItem, undefined>,
args: {
items,
getItemComponent: (
_index: number,
item: SimpleItem,
context: VirtualizedListContext<undefined>,
onFocus: (item: SimpleItem, e: React.FocusEvent) => void,
) => {
const selected = context.tabIndexKey === item.id;
return (
<button
type="button"
key={item.id}
className={classNames(styles.item, { [styles.itemSelected]: selected })}
tabIndex={selected ? 0 : -1}
onFocus={(e) => onFocus(item, e)}
>
{item.label}
</button>
);
},
isItemFocusable: () => true,
getItemKey: (item) => item.id,
style: { height: "400px" },
},
} satisfies Meta<IVirtualizedListProps<SimpleItem, undefined>>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -0,0 +1,158 @@
/*
* 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.
*/
/** The ARIA pattern used to make the virtualized list accessible. */
export type Pattern = "listbox" | "treegrid";
/** ARIA props for a `listbox` container element. */
export type ListboxContainerProps = {
role: "listbox";
};
/** ARIA props for a `treegrid` container element, including the total row count. */
export type TreegridContainerProps = {
/** The ARIA role identifying this element as a treegrid. */
"role": "treegrid";
/** The total number of rows in the treegrid, used by assistive technologies to announce list size. */
"aria-rowcount": number;
};
/**
* Returns the ARIA props to spread onto the virtualized list container element.
*
* @param pattern - `"listbox"` — returns {@link ListboxContainerProps}.
* @returns ARIA props for a `listbox` container.
*/
export function getContainerAccessibleProps(pattern: "listbox"): ListboxContainerProps;
/**
* Returns the ARIA props to spread onto the virtualized list container element.
*
* @param pattern - `"treegrid"` — returns {@link TreegridContainerProps}.
* @param size - Total number of rows in the treegrid, set as `aria-rowcount`.
* @returns ARIA props for a `treegrid` container.
*/
export function getContainerAccessibleProps(pattern: "treegrid", size: number): TreegridContainerProps;
export function getContainerAccessibleProps(
pattern: Pattern,
size?: number,
): ListboxContainerProps | TreegridContainerProps {
switch (pattern) {
case "listbox":
return {
role: "listbox",
};
case "treegrid":
return {
"role": "treegrid",
"aria-rowcount": size!,
};
}
}
/** ARIA props for an item rendered inside a `listbox`. */
export type ListboxItemProps = {
/** Identifies the element as a selectable option within the listbox. */
"role": "option";
/** The 1-based position of this option within the full set, used for virtual lists where not all DOM nodes are mounted. */
"aria-posinset": number;
/** The total number of options in the set. */
"aria-setsize": number;
};
/** ARIA props for an item rendered inside a `treegrid` at depth level 2 (i.e. a child row within a group). */
export type TreegridItemProps = {
/** Identifies the element as a row within the treegrid. */
"role": "row";
/** The depth of this row in the tree hierarchy. Items are always at level 2 (inside a group). */
"aria-level": 2;
/** The 1-based index of this row within the full treegrid row sequence (headers + items). */
"aria-rowindex": number;
/** The 1-based position of this item within its group, used by assistive technologies to announce position. */
"aria-posinset": number;
};
/** ARIA props for a virtualized list item, either in a `listbox` or `treegrid`. */
export type ItemAccessibleProps = ListboxItemProps | TreegridItemProps;
/**
* Returns the ARIA props to spread onto a virtualized list item element.
*
* @param pattern - `"listbox"` — returns {@link ListboxItemProps}.
* @param index - The 0-based index of the item in the full flat list.
* @param listSize - The total number of items across the entire list.
* @returns ARIA props for a `listbox` option.
*/
export function getItemAccessibleProps(pattern: "listbox", index: number, listSize: number): ListboxItemProps;
/**
* Returns the ARIA props to spread onto a virtualized list item element.
*
* @param pattern - `"treegrid"` — returns {@link TreegridItemProps}.
* @param index - The 0-based index of this row in the full flat treegrid row sequence (headers + items).
* @param indexInGroup - The 0-based index of this item within its group, used to compute `aria-posinset`.
* @returns ARIA props for a `treegrid` row at level 2.
*/
export function getItemAccessibleProps(pattern: "treegrid", index: number, indexInGroup: number): TreegridItemProps;
export function getItemAccessibleProps(
pattern: Pattern,
index: number,
listSizeOrIndexInGroup: number,
): ListboxItemProps | TreegridItemProps {
switch (pattern) {
case "listbox":
return {
"role": "option",
"aria-posinset": index + 1,
"aria-setsize": listSizeOrIndexInGroup,
};
case "treegrid":
return {
"role": "row",
"aria-level": 2,
"aria-rowindex": index + 1,
"aria-posinset": listSizeOrIndexInGroup + 1,
};
}
}
/** ARIA props for a group header row rendered inside a `treegrid` at depth level 1. */
export type TreegridGroupHeaderProps = {
/** Identifies the element as a row within the treegrid. */
"role": "row";
/** The depth of this row in the tree hierarchy. Group headers are always at the root level (1). */
"aria-level": 1;
/** The 1-based position of this group among all groups. */
"aria-posinset": number;
/** The 1-based index of this row within the full treegrid row sequence (headers + items). */
"aria-rowindex": number;
/** The total number of groups in the treegrid. */
"aria-setsize": number;
};
/**
* Returns the ARIA props to spread onto a group header row element inside a `treegrid`.
*
* Group headers are rendered at `aria-level="1"` and act as the parent nodes for their
* child item rows (`aria-level="2"`).
*
* @param index - The 0-based index of this row in the full flat treegrid row sequence (headers + items), used to compute `aria-rowindex`.
* @param groupIndex - The 0-based index of this group among all groups, used to compute `aria-posinset`.
* @param groupSize - The total number of items in the group, set as `aria-setsize`.
* @returns ARIA props for a group header `row` at level 1.
*/
export function getGroupHeaderAccessibleProps(
index: number,
groupIndex: number,
groupSize: number,
): TreegridGroupHeaderProps {
return {
"role": "row",
"aria-level": 1,
"aria-posinset": groupIndex + 1,
"aria-rowindex": index + 1,
"aria-setsize": groupSize,
};
}
@@ -5,8 +5,12 @@
* Please see LICENSE files in the repository root for full details.
*/
export { VirtualizedList } from "./VirtualizedList";
export type { IVirtualizedListProps, VirtualizedListContext, ScrollIntoViewOnChange } from "./VirtualizedList";
export { FlatVirtualizedList } from "./FlatVirtualizedList";
export type { FlatVirtualizedListProps } from "./FlatVirtualizedList";
export { GroupedVirtualizedList } from "./GroupedVirtualizedList";
export type { GroupedVirtualizedListProps, Group } from "./GroupedVirtualizedList";
export type { VirtualizedListContext, ScrollIntoViewOnChange } from "./virtualized-list";
export * from "./accessbility";
// Re-export VirtuosoMockContext for testing purposes
// Tests should import this from shared-components to ensure context compatibility
@@ -10,8 +10,28 @@
width: 100%;
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
button {
all: unset;
}
}
.itemSelected {
background-color: #559f24;
}
.group {
width: 100%;
padding: 8px;
background-color: #00adad;
border: 1px solid lightgrey;
font-weight: "bold";
button {
all: unset;
}
}
.groupSelected {
background-color: #559f24;
}
@@ -0,0 +1,100 @@
/*
* 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 } from "react";
import { type JSX } from "react";
import classNames from "classnames";
import { type VirtualizedListContext } from "./virtualized-list";
import type { Group } from "./GroupedVirtualizedList";
import styles from "./story-mock.module.css";
import type { ItemAccessibleProps, TreegridGroupHeaderProps } from "./accessbility";
export interface SimpleItemComponent {
id: string;
label: string;
}
export interface SimpleGroupHeader {
id: string;
label: string;
}
export const items: SimpleItemComponent[] = Array.from({ length: 50 }, (_, i) => ({
id: `item-${i}`,
label: `Item ${i + 1}`,
}));
export const groups: Group<SimpleGroupHeader, SimpleItemComponent>[] = [
{ header: { id: "group-1", label: "Group 1" }, items: items.slice(0, 10) },
{ header: { id: "group-2", label: "Group 2" }, items: items.slice(10, 30) },
{ header: { id: "group-3", label: "Group 3" }, items: items.slice(30, 50) },
];
type SimpleItemComponentProps<Context> = ItemAccessibleProps & {
item: SimpleItemComponent;
context: Context;
onFocus: (item: SimpleItemComponent, e: React.FocusEvent) => void;
};
export const SimpleItemComponent = memo(function SimpleItemComponent({
item,
context,
onFocus,
...rest
}: SimpleItemComponentProps<VirtualizedListContext<undefined>>): JSX.Element {
const selected = context.tabIndexKey === item.id;
const { role } = rest;
const buttonProps = role === "row" ? { role: "gridcell" } : rest;
const button = (
<button
className={classNames(styles.item, { [styles.itemSelected]: selected })}
tabIndex={selected ? 0 : -1}
type="button"
{...buttonProps}
onFocus={(e) => onFocus(item, e)}
>
{item.label}
</button>
);
if (role === "option") return button;
return (
<div {...rest} {...{ "aria-selected": selected }}>
{button}
</div>
);
});
interface GroupHeaderComponentProps extends TreegridGroupHeaderProps {
header: SimpleGroupHeader;
context: VirtualizedListContext<undefined>;
onFocus: (header: SimpleGroupHeader, e: React.FocusEvent) => void;
}
export const GroupHeaderComponent = memo(function GroupHeaderComponent({
header,
context,
onFocus,
...rest
}: GroupHeaderComponentProps): JSX.Element {
const selected = context.tabIndexKey === header.id;
return (
<div
{...rest}
{...{ "aria-selected": selected }}
className={classNames(styles.group, { [styles.groupSelected]: selected })}
>
<button tabIndex={selected ? 0 : -1} type="button" role="gridcell" onFocus={(e) => onFocus(header, e)}>
{header.label}
</button>
</div>
);
});
@@ -10,15 +10,11 @@ import { render, screen, fireEvent, waitFor, act } from "@test-utils";
import { VirtuosoMockContext } from "react-virtuoso";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { VirtualizedList, type IVirtualizedListProps } from "./VirtualizedList";
import { FlatVirtualizedList, type FlatVirtualizedListProps } from "./FlatVirtualizedList";
import { GroupedVirtualizedList, type GroupedVirtualizedListProps } from "./GroupedVirtualizedList";
import type { VirtualizedListContext } from "./virtualized-list";
const expectTabIndex = (element: Element, expected: string): void => {
expect(element.getAttribute("tabindex")).toBe(expected);
};
const expectAttribute = (element: Element, attr: string, expected: string): void => {
expect(element.getAttribute(attr)).toBe(expected);
};
// ─── Test types ──────────────────────────────────────────────────────────────
interface TestItem {
id: string;
@@ -29,7 +25,192 @@ interface TestItem {
const SEPARATOR_ITEM = "SEPARATOR" as const;
type TestItemWithSeparator = TestItem | typeof SEPARATOR_ITEM;
describe("VirtualizedList", () => {
interface TestGroupHeader {
id: string;
name: string;
}
// ─── Shared helpers ──────────────────────────────────────────────────────────
const expectTabIndex = (element: Element, expected: string): void => {
expect(element.getAttribute("tabindex")).toBe(expected);
};
const expectAttribute = (element: Element, attr: string, expected: string): void => {
expect(element.getAttribute(attr)).toBe(expected);
};
const getItemKey = (item: TestItemWithSeparator): string => (typeof item === "string" ? item : item.id);
/** Renders an item element used by the default mock. */
function renderItemElement(
index: number,
item: TestItemWithSeparator,
context: VirtualizedListContext<any>,
): React.JSX.Element {
const itemKey = typeof item === "string" ? item : item.id;
const isFocused = context.tabIndexKey === itemKey;
return (
<div className="mx_item" data-testid={`row-${index}`} tabIndex={isFocused ? 0 : -1} role="gridcell">
{item === SEPARATOR_ITEM ? "---" : (item as TestItem).name}
</div>
);
}
/** Renders a clickable item element used by the scroll-click test mock. */
function renderClickableItemElement(
index: number,
item: TestItemWithSeparator,
context: VirtualizedListContext<any>,
onFocus: (item: TestItemWithSeparator, e: React.FocusEvent) => void,
onClick: () => void,
): React.JSX.Element {
const itemKey = typeof item === "string" ? item : item.id;
const isFocused = context.tabIndexKey === itemKey;
return (
<div
className="mx_item"
data-testid={`row-${index}`}
tabIndex={isFocused ? 0 : -1}
role="button"
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onClick();
}
}}
onFocus={(e) => onFocus(item, e)}
>
{item === SEPARATOR_ITEM ? "---" : (item as TestItem).name}
</div>
);
}
// ─── Variant definitions ─────────────────────────────────────────────────────
interface ListTestVariant {
name: string;
/** Build the JSX element for the given items and props. */
createComponent: (
items: TestItemWithSeparator[],
mockGetItemComponent: any,
mockIsItemFocusable: any,
extraProps?: Record<string, unknown>,
) => React.JSX.Element;
/** Wire up the default `getItemComponent` mock (simple items, no onFocus). */
setupDefaultMock: (mockGetItemComponent: any, getItems: () => TestItemWithSeparator[]) => void;
/** Wire up the `getItemComponent` mock for the click-after-scroll test. */
setupClickTestMock: (mockGetItemComponent: any, mockOnClick: any, getItems: () => TestItemWithSeparator[]) => void;
/** Number of ArrowDown key presses after initial focus to reach the first regular item.
* 0 for flat lists, 1 for grouped lists (to skip past the group header). */
stepsToFirstItem: number;
/** CSS selector matching all elements that participate in keyboard navigation. */
navigableSelector: string;
}
const flatVariant: ListTestVariant = {
name: "FlatVirtualizedList",
stepsToFirstItem: 0,
navigableSelector: ".mx_item",
createComponent(items, mockGetItemComponent, mockIsItemFocusable, extraProps = {}) {
const props: FlatVirtualizedListProps<TestItemWithSeparator, any> = {
items,
"getItemComponent": mockGetItemComponent,
"isItemFocusable": mockIsItemFocusable,
getItemKey,
"role": "grid",
"aria-rowcount": items.length,
"aria-colcount": 1,
...extraProps,
};
return <FlatVirtualizedList {...props} />;
},
setupDefaultMock(mockGetItemComponent, _getItems) {
mockGetItemComponent.mockImplementation(
(index: number, item: TestItemWithSeparator, context: VirtualizedListContext<any>) =>
renderItemElement(index, item, context),
);
},
setupClickTestMock(mockGetItemComponent, mockOnClick, _getItems) {
mockGetItemComponent.mockImplementation(
(
index: number,
item: TestItemWithSeparator,
context: VirtualizedListContext<any>,
onFocus: (item: TestItemWithSeparator, e: React.FocusEvent) => void,
) => renderClickableItemElement(index, item, context, onFocus, () => mockOnClick(item)),
);
},
};
const groupedVariant: ListTestVariant = {
name: "GroupedVirtualizedList",
stepsToFirstItem: 1,
navigableSelector: ".mx_group_header, .mx_item",
createComponent(items, mockGetItemComponent, mockIsItemFocusable, extraProps = {}) {
const header: TestGroupHeader = { id: "test-group-header", name: "Group 0" };
const props: GroupedVirtualizedListProps<TestGroupHeader, TestItemWithSeparator, any> = {
"groups": [{ header, items }],
"getItemComponent": mockGetItemComponent,
"getGroupHeaderComponent": (
_groupIndex: number,
header: TestGroupHeader,
context: VirtualizedListContext<any>,
onFocus: (header: TestGroupHeader, e: React.FocusEvent) => void,
) => (
<div
className="mx_group_header"
data-testid={`group-header-${header.id}`}
tabIndex={context.tabIndexKey === header.id ? 0 : -1}
onFocus={(e) => onFocus(header, e)}
>
{header.name}
</div>
),
"isGroupHeaderFocusable": () => true,
"isItemFocusable": mockIsItemFocusable,
getItemKey,
"getHeaderKey": (header) => header.id,
"role": "grid",
"aria-rowcount": items.length,
"aria-colcount": 1,
...extraProps,
};
return <GroupedVirtualizedList {...props} />;
},
setupDefaultMock(mockGetItemComponent, _getItems) {
mockGetItemComponent.mockImplementation(
(index: number, item: TestItemWithSeparator, context: VirtualizedListContext<any>) =>
renderItemElement(index, item, context),
);
},
setupClickTestMock(mockGetItemComponent, mockOnClick, _getItems) {
mockGetItemComponent.mockImplementation(
(
index: number,
item: TestItemWithSeparator,
context: VirtualizedListContext<any>,
onFocus: (item: TestItemWithSeparator, e: React.FocusEvent) => void,
) => renderClickableItemElement(index, item, context, onFocus, () => mockOnClick(item)),
);
},
};
// ─── Shared test suite ───────────────────────────────────────────────────────
const virtuosoWrapper = ({ children }: PropsWithChildren): React.JSX.Element => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 400, itemHeight: 56 }}>
{children}
</VirtuosoMockContext.Provider>
);
describe.each<ListTestVariant>([flatVariant, groupedVariant])("$name", (variant) => {
const mockGetItemComponent = vi.fn();
const mockIsItemFocusable = vi.fn();
@@ -40,44 +221,30 @@ describe("VirtualizedList", () => {
{ id: "3", name: "Item 3" },
];
const defaultProps: IVirtualizedListProps<TestItemWithSeparator, any> = {
items: defaultItems,
getItemComponent: mockGetItemComponent,
isItemFocusable: mockIsItemFocusable,
getItemKey: (item) => (typeof item === "string" ? item : item.id),
};
/** Tracks whichever items were most recently passed to render / rerender,
* so the grouped variant's mock can look them up by index. */
let currentItems: TestItemWithSeparator[] = defaultItems;
const getListComponent = (
props: Partial<IVirtualizedListProps<TestItemWithSeparator, any>> = {},
items: TestItemWithSeparator[],
extraProps: Record<string, unknown> = {},
): React.JSX.Element => {
const mergedProps = { ...defaultProps, ...props };
return <VirtualizedList {...mergedProps} role="grid" aria-rowcount={props.items?.length} aria-colcount={1} />;
currentItems = items;
return variant.createComponent(items, mockGetItemComponent, mockIsItemFocusable, extraProps);
};
const renderListWithHeight = (
props: Partial<IVirtualizedListProps<TestItemWithSeparator, any>> = {},
overrides: { items?: TestItemWithSeparator[] } & Record<string, unknown> = {},
): ReturnType<typeof render> => {
const mergedProps = { ...defaultProps, ...props };
return render(getListComponent(mergedProps), {
wrapper: ({ children }: PropsWithChildren) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 400, itemHeight: 56 }}>
<>{children}</>
</VirtuosoMockContext.Provider>
),
});
const { items: overrideItems, ...extraProps } = overrides;
const items = overrideItems ?? defaultItems;
return render(getListComponent(items, extraProps), { wrapper: virtuosoWrapper });
};
beforeEach(() => {
vi.clearAllMocks();
mockGetItemComponent.mockImplementation((index: number, item: TestItemWithSeparator, context: any) => {
const itemKey = typeof item === "string" ? item : item.id;
const isFocused = context.tabIndexKey === itemKey;
return (
<div className="mx_item" data-testid={`row-${index}`} tabIndex={isFocused ? 0 : -1} role="gridcell">
{item === SEPARATOR_ITEM ? "---" : (item as TestItem).name}
</div>
);
});
currentItems = defaultItems;
variant.setupDefaultMock(mockGetItemComponent, () => currentItems);
mockIsItemFocusable.mockImplementation((item: TestItemWithSeparator) => item !== SEPARATOR_ITEM);
});
@@ -97,12 +264,21 @@ describe("VirtualizedList", () => {
});
});
/** Press ArrowDown the required number of times to move from the initial
* focus target (e.g. a group header) to the first regular item. */
const navigateToFirstItem = (container: Element): void => {
for (let i = 0; i < variant.stepsToFirstItem; i++) {
fireEvent.keyDown(container, { code: "ArrowDown" });
}
};
describe("Keyboard Navigation", () => {
it("should handle ArrowDown key navigation", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
fireEvent.focus(container);
navigateToFirstItem(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
// ArrowDown should skip the non-focusable item at index 1 and go to index 2
@@ -116,8 +292,9 @@ describe("VirtualizedList", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// First focus and navigate down to second item
// First focus and navigate down past separator
fireEvent.focus(container);
navigateToFirstItem(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
// Then navigate back up
@@ -135,18 +312,19 @@ describe("VirtualizedList", () => {
// First focus and navigate to a later item
fireEvent.focus(container);
navigateToFirstItem(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
fireEvent.keyDown(container, { code: "ArrowDown" });
// Then press Home to go to first item
// Then press Home to go to first navigable element
fireEvent.keyDown(container, { code: "Home" });
// Verify focus moved to first item
const items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0");
// Check that other items are not focused
for (let i = 1; i < items.length; i++) {
expectTabIndex(items[i], "-1");
// Verify focus moved to the very first navigable element
const allNav = container.querySelectorAll(variant.navigableSelector);
expectTabIndex(allNav[0], "0");
// Check that other navigable elements are not focused
for (let i = 1; i < allNav.length; i++) {
expectTabIndex(allNav[i], "-1");
}
});
@@ -154,20 +332,19 @@ describe("VirtualizedList", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// First focus on the list (starts at first item)
// First focus on the list
fireEvent.focus(container);
// Then press End to go to last item
fireEvent.keyDown(container, { code: "End" });
// Verify focus moved to last visible item
const items = container.querySelectorAll(".mx_item");
// Should focus on the last visible item
const lastIndex = items.length - 1;
expectTabIndex(items[lastIndex], "0");
// Check that other items are not focused
// Verify focus moved to last visible navigable element
const allNav = container.querySelectorAll(variant.navigableSelector);
const lastIndex = allNav.length - 1;
expectTabIndex(allNav[lastIndex], "0");
// Check that other navigable elements are not focused
for (let i = 0; i < lastIndex; i++) {
expectTabIndex(items[i], "-1");
expectTabIndex(allNav[i], "-1");
}
});
@@ -175,8 +352,9 @@ describe("VirtualizedList", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// First focus on the list (starts at first item)
// First focus on the list and navigate to first item
fireEvent.focus(container);
navigateToFirstItem(container);
// Then press PageDown to jump down by viewport size
fireEvent.keyDown(container, { code: "PageDown" });
@@ -193,8 +371,9 @@ describe("VirtualizedList", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// First focus and navigate to last item to have something to page up from
// First focus, navigate to first item, then End
fireEvent.focus(container);
navigateToFirstItem(container);
fireEvent.keyDown(container, { code: "End" });
// Then press PageUp to jump up by viewport size
@@ -213,6 +392,7 @@ describe("VirtualizedList", () => {
const container = screen.getByRole("grid");
fireEvent.focus(container);
navigateToFirstItem(container);
// Store initial state - first item should be focused
const initialItems = container.querySelectorAll(".mx_item");
@@ -255,9 +435,9 @@ describe("VirtualizedList", () => {
expectTabIndex(items[2], "0"); // Should have moved to third item (skipping separator)
});
it("should skip non-focusable items when navigating down", async () => {
it("should skip non-focusable items when navigating down", () => {
// Create items where every other item is not focusable
const mixedItems = [
const mixedItems: TestItemWithSeparator[] = [
{ id: "1", name: "Item 1", isFocusable: true },
{ id: "2", name: "Item 2", isFocusable: false },
{ id: "3", name: "Item 3", isFocusable: true },
@@ -274,6 +454,7 @@ describe("VirtualizedList", () => {
const container = screen.getByRole("grid");
fireEvent.focus(container);
navigateToFirstItem(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
// Verify it skipped the non-focusable item at index 1
@@ -285,7 +466,7 @@ describe("VirtualizedList", () => {
});
it("should skip non-focusable items when navigating up", () => {
const mixedItems = [
const mixedItems: TestItemWithSeparator[] = [
{ id: "1", name: "Item 1", isFocusable: true },
SEPARATOR_ITEM,
{ id: "2", name: "Item 2", isFocusable: false },
@@ -305,8 +486,9 @@ describe("VirtualizedList", () => {
fireEvent.keyDown(container, { code: "End" });
fireEvent.keyDown(container, { code: "ArrowUp" });
// Verify it skipped non-focusable items
// and went to the first focusable item
// Verify it skipped non-focusable items and went to the first focusable item.
// For grouped lists the header sits above the first item, so ArrowUp from
// Item 2 (skipping the non-focusable entries) lands on Item 1.
const items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0"); // Item 1 is focused
expectTabIndex(items[3], "-1"); // Item 3 is not focused anymore
@@ -314,19 +496,19 @@ describe("VirtualizedList", () => {
});
describe("Focus Management", () => {
it("should focus first item when list gains focus for the first time", () => {
it("should focus first navigable element when list gains focus for the first time", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
// Initial focus should go to first item
// Initial focus should go to first navigable element
fireEvent.focus(container);
// Verify first item gets focus
const items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0");
// Other items should not be focused
for (let i = 1; i < items.length; i++) {
expectTabIndex(items[i], "-1");
// Verify first navigable element gets focus
const allNav = container.querySelectorAll(variant.navigableSelector);
expectTabIndex(allNav[0], "0");
// Other navigable elements should not be focused
for (let i = 1; i < allNav.length; i++) {
expectTabIndex(allNav[i], "-1");
}
});
@@ -336,11 +518,12 @@ describe("VirtualizedList", () => {
// Focus and navigate to simulate previous usage
fireEvent.focus(container);
navigateToFirstItem(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
// Verify item 2 is focused
// Verify item 2 is focused (ArrowDown skips separator)
let items = container.querySelectorAll(".mx_item");
expectTabIndex(items[2], "0"); // ArrowDown skips to item 2
expectTabIndex(items[2], "0");
// Simulate blur by focusing elsewhere
fireEvent.blur(container);
@@ -368,49 +551,23 @@ describe("VirtualizedList", () => {
it("should not scroll to top when clicking an item after manual scroll", () => {
// Create a larger list to enable meaningful scrolling
const largerItems = Array.from({ length: 50 }, (_, i) => ({
const largerItems: TestItemWithSeparator[] = Array.from({ length: 50 }, (_, i) => ({
id: `item-${i}`,
name: `Item ${i}`,
}));
const mockOnClick = vi.fn();
mockGetItemComponent.mockImplementation(
(
index: number,
item: TestItemWithSeparator,
context: any,
onFocus: (item: TestItemWithSeparator, e: React.FocusEvent) => void,
) => {
const itemKey = typeof item === "string" ? item : item.id;
const isFocused = context.tabIndexKey === itemKey;
return (
<div
className="mx_item"
data-testid={`row-${index}`}
tabIndex={isFocused ? 0 : -1}
role="button"
onClick={() => mockOnClick(item)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
mockOnClick(item);
}
}}
onFocus={(e) => onFocus(item, e)}
>
{item === SEPARATOR_ITEM ? "---" : (item as TestItem).name}
</div>
);
},
);
variant.setupClickTestMock(mockGetItemComponent, mockOnClick, () => currentItems);
const { container } = renderListWithHeight({ items: largerItems });
const listContainer = screen.getByRole("grid");
// Step 1: Focus the list initially (this sets tabIndexKey to first item: "item-0")
// Step 1: Focus the list initially and navigate to the first regular item
fireEvent.focus(listContainer);
navigateToFirstItem(listContainer);
// Verify first item is focused initially and tabIndexKey is set to first item
// Verify first item is focused and tabIndexKey is set to first item
let items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0");
expectAttribute(items[0], "data-testid", "row-0");
@@ -431,11 +588,10 @@ describe("VirtualizedList", () => {
// Find a visible item to click on (should be items from further down the list)
const visibleItems = container.querySelectorAll(".mx_item");
expect(visibleItems.length).toBeGreaterThan(0);
const clickTargetItem = visibleItems[0]; // Click on the first visible item
const clickTargetItem = visibleItems[0];
// Click on the visible item
fireEvent.click(clickTargetItem);
// The click should trigger the onFocus callback, which updates the tabIndexKey
// This simulates the real user interaction where clicking an item focuses it
fireEvent.focus(clickTargetItem);
@@ -454,6 +610,34 @@ describe("VirtualizedList", () => {
});
});
describe("Group header keyboard navigation", () => {
// These tests only exercise meaningful behaviour for the grouped variant;
// for the flat variant they degenerate to basic navigation assertions.
it("should navigate from first navigable element to the first item with ArrowDown", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
fireEvent.focus(container);
navigateToFirstItem(container);
const items = container.querySelectorAll(".mx_item");
expectTabIndex(items[0], "0");
});
it("should navigate back to the first navigable element with ArrowUp from the first item", () => {
renderListWithHeight();
const container = screen.getByRole("grid");
fireEvent.focus(container);
navigateToFirstItem(container);
// Now press ArrowUp to go back before the first item
fireEvent.keyDown(container, { code: "ArrowUp" });
const allNav = container.querySelectorAll(variant.navigableSelector);
expectTabIndex(allNav[0], "0");
});
});
describe("Accessibility", () => {
it("should set correct ARIA attributes", () => {
renderListWithHeight();
@@ -469,17 +653,11 @@ describe("VirtualizedList", () => {
let container = screen.getByRole("grid");
expectAttribute(container, "aria-rowcount", "4");
// Update with fewer items
const fewerItems = [
const fewerItems: TestItemWithSeparator[] = [
{ id: "1", name: "Item 1" },
{ id: "2", name: "Item 2" },
];
rerender(
getListComponent({
...defaultProps,
items: fewerItems,
}),
);
rerender(getListComponent(fewerItems));
container = screen.getByRole("grid");
expectAttribute(container, "aria-rowcount", "2");
@@ -537,7 +715,7 @@ describe("VirtualizedList", () => {
);
return render(
<VirtualizedList
<FlatVirtualizedList
items={largeItems}
getItemComponent={mockGetItemComponent}
isItemFocusable={mockIsItemFocusable}
@@ -5,8 +5,8 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { useRef, type JSX, useCallback, useEffect, useState, useMemo } from "react";
import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type ListRange, type VirtuosoHandle, type VirtuosoProps } from "react-virtuoso";
/**
* Keyboard key codes
@@ -42,7 +42,7 @@ export type VirtualizedListContext<Context> = {
context: Context;
};
export interface IVirtualizedListProps<Item, Context> extends Omit<
export interface VirtualizedListProps<Item, Context> extends Omit<
VirtuosoProps<Item, VirtualizedListContext<Context>>,
"data" | "itemContent" | "context"
> {
@@ -52,21 +52,6 @@ export interface IVirtualizedListProps<Item, Context> extends Omit<
*/
items: Item[];
/**
* Function that renders each list item as a JSX element.
* @param index - The index of the item in the list
* @param item - The data item to render
* @param context - The context object containing the focused key and any additional data
* @param onFocus - A callback that is required to be called when the item component receives focus
* @returns JSX element representing the rendered item
*/
getItemComponent: (
index: number,
item: Item,
context: VirtualizedListContext<Context>,
onFocus: (item: Item, e: React.FocusEvent) => void,
) => JSX.Element;
/**
* Optional additional context data to pass to each rendered item.
* This will be available in the VirtualizedListContext passed to getItemComponent.
@@ -108,6 +93,28 @@ export interface IVirtualizedListProps<Item, Context> extends Omit<
* @param range - The new visible range with startIndex and endIndex
*/
rangeChanged?: (range: ListRange) => void;
/**
* Optional function to map from the items array index to the scroll index
* used by virtuoso's scrollIntoView. This is needed when the items array
* contains entries (such as group headers) that don't have a direct 1:1
* mapping with virtuoso's own item indices.
*
* @param itemsIndex - The index in the items array
* @returns The index to pass to virtuoso's scrollIntoView
*/
mapScrollIndex?: (itemsIndex: number) => number;
/**
* Optional function to map from virtuoso's reported visible-range indices
* back to the items array indices. This is needed when virtuoso reports
* ranges in a different index space than the items array (e.g., in
* GroupedVirtuoso where group headers are not counted in the range).
*
* @param virtuosoIndex - The index reported by virtuoso's rangeChanged
* @returns The corresponding index in the items array
*/
mapRangeIndex?: (virtuosoIndex: number) => number;
}
/**
@@ -117,24 +124,49 @@ export type ScrollIntoViewOnChange<Item, Context> = NonNullable<
VirtuosoProps<Item, VirtualizedListContext<Context>>["scrollIntoViewOnChange"]
>;
export interface UseVirtualizedListResult<Item, Context> extends Omit<
VirtuosoProps<Item, VirtualizedListContext<Context>>,
"data" | "itemContent" | "context" | "onKeyDown" | "onFocus" | "onBlur" | "rangeChanged" | "scrollerRef" | "ref"
> {
ref: React.RefObject<VirtuosoHandle | null>;
scrollerRef: (element: HTMLElement | Window | null) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void;
onFocus: (e: React.FocusEvent) => void;
onBlur: (event: React.FocusEvent<HTMLDivElement>) => void;
rangeChanged: (range: ListRange) => void;
onFocusForGetItemComponent: (item: Item, e: React.FocusEvent) => void;
context: VirtualizedListContext<Context>;
}
/**
* A generic virtualized list component built on top of react-virtuoso.
* Provides keyboard navigation and virtualized rendering for performance with large lists.
* A hook that provides keyboard navigation and focus management for a virtualized list
* built on top of react-virtuoso.
*
* @template Item - The type of data items in the list
* @template Context - The type of additional context data passed to items
* Handles Arrow Up/Down, Home, End, Page Up/Down key navigation, focus tracking via
* a roving `tabIndex`, and automatic scrolling to keep the focused item visible.
*
* Returns props to spread onto a Virtuoso component along with an `onFocusForGetItemComponent`
* callback that each item must call on focus to keep the focus state in sync.
*
* @param props - The virtualized list configuration including items, focusability checks,
* key extraction, and any pass-through Virtuoso props.
* @returns An object of props to wire up to a Virtuoso component, plus `onFocusForGetItemComponent`
* for individual item focus handling.
*/
export function VirtualizedList<Item, Context>(props: IVirtualizedListProps<Item, Context>): React.ReactElement {
export function useVirtualizedList<Item, Context>(
props: VirtualizedListProps<Item, Context>,
): UseVirtualizedListResult<Item, Context> {
// Extract our custom props to avoid conflicts with Virtuoso props
const {
items,
getItemComponent,
isItemFocusable,
getItemKey,
context,
onKeyDown,
totalCount,
rangeChanged,
mapScrollIndex,
mapRangeIndex,
...virtuosoProps
} = props;
/** Reference to the Virtuoso component for programmatic scrolling */
@@ -181,14 +213,15 @@ export function VirtualizedList<Item, Context>(props: IVirtualizedListProps<Item
if (items[clampedIndex]) {
const key = getItemKey(items[clampedIndex]);
setTabIndexKey(key);
const scrollIndex = mapScrollIndex ? mapScrollIndex(clampedIndex) : clampedIndex;
virtuosoHandleRef.current?.scrollIntoView({
index: clampedIndex,
index: scrollIndex,
align: align,
behavior: "auto",
});
}
},
[items, getItemKey],
[items, getItemKey, mapScrollIndex],
);
/**
@@ -300,18 +333,12 @@ export function VirtualizedList<Item, Context>(props: IVirtualizedListProps<Item
[getItemKey],
);
const getItemComponentInternal = useCallback(
(index: number, item: Item, context: VirtualizedListContext<Context>): JSX.Element =>
getItemComponent(index, item, context, onFocusForGetItemComponent),
[getItemComponent, onFocusForGetItemComponent],
);
/**
* Handles focus events on the list.
* Sets the focused state and scrolls to the focused item if it is not currently visible.
*/
const onFocus = useCallback(
(e?: React.FocusEvent): void => {
(e: React.FocusEvent): void => {
if (e?.currentTarget !== virtuosoDomRef.current || typeof tabIndexKey !== "string") {
return;
}
@@ -325,8 +352,8 @@ export function VirtualizedList<Item, Context>(props: IVirtualizedListProps<Item
) {
scrollToIndex(index);
}
e?.stopPropagation();
e?.preventDefault();
e.stopPropagation();
e.preventDefault();
},
[keyToIndexMap, visibleRange, scrollToIndex, tabIndexKey],
);
@@ -351,31 +378,24 @@ export function VirtualizedList<Item, Context>(props: IVirtualizedListProps<Item
// Combine internal range tracking with optional external callback
const handleRangeChanged = useCallback(
(range: ListRange) => {
setVisibleRange(range);
const internalRange = mapRangeIndex
? { startIndex: mapRangeIndex(range.startIndex), endIndex: mapRangeIndex(range.endIndex) }
: range;
setVisibleRange(internalRange);
rangeChanged?.(range);
},
[rangeChanged],
[rangeChanged, mapRangeIndex],
);
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)
ref={virtuosoHandleRef}
scrollerRef={scrollerRef}
onKeyDown={keyDownCallback}
context={listContext}
rangeChanged={handleRangeChanged}
// virtuoso errors internally if you pass undefined.
overscan={props.overscan || 0}
data={props.items}
totalCount={totalCount}
onFocus={onFocus}
onBlur={onBlur}
itemContent={getItemComponentInternal}
{...virtuosoProps}
/>
);
return {
...virtuosoProps,
ref: virtuosoHandleRef,
scrollerRef,
onKeyDown: keyDownCallback,
onFocus,
onBlur,
rangeChanged: handleRangeChanged,
onFocusForGetItemComponent,
context: listContext,
};
}