Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {};
Original file line number Diff line number Diff line change
@@ -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 {

Check warning on line 37 in packages/shared-components/src/utils/VirtualizedList/FlatVirtualizedList/FlatVirtualizedList.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZyPw47qi9jPwfYhsq26&open=AZyPw47qi9jPwfYhsq26&pullRequest=32566
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}
/>
);
}
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading