diff --git a/apps/web/src/components/views/rooms/MemberList/MemberListView.tsx b/apps/web/src/components/views/rooms/MemberList/MemberListView.tsx index 3a78d271d90..46a9cd13976 100644 --- a/apps/web/src/components/views/rooms/MemberList/MemberListView.tsx +++ b/apps/web/src/components/views/rooms/MemberList/MemberListView.tsx @@ -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 = (props: IProps) => { e.preventDefault()}> - , + 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 + +\`\`\` + +### 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 ( + + ); +}} +\`\`\` + `, + }, + }, + }, + args: { + items, + "getItemComponent": ( + index: number, + item: SimpleItemComponent, + context: VirtualizedListContext, + onFocus: (item: SimpleItemComponent, e: React.FocusEvent) => void, + ) => ( + + ), + "isItemFocusable": () => true, + "getItemKey": (item) => item.id, + "style": { height: "400px" }, + "aria-label": "Flat virtualized list", + ...getContainerAccessibleProps("listbox"), + }, +} satisfies Meta>; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/shared-components/src/utils/VirtualizedList/FlatVirtualizedList/FlatVirtualizedList.tsx b/packages/shared-components/src/utils/VirtualizedList/FlatVirtualizedList/FlatVirtualizedList.tsx new file mode 100644 index 00000000000..7373092a944 --- /dev/null +++ b/packages/shared-components/src/utils/VirtualizedList/FlatVirtualizedList/FlatVirtualizedList.tsx @@ -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 extends VirtualizedListProps { + /** + * 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, + 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(props: FlatVirtualizedListProps): React.ReactElement { + const { getItemComponent, ...restProps } = props; + const { onFocusForGetItemComponent, ...virtuosoProps } = useVirtualizedList(restProps); + + const getItemComponentInternal = useCallback( + (index: number, item: Item, context: VirtualizedListContext): JSX.Element => + getItemComponent(index, item, context, onFocusForGetItemComponent), + [getItemComponent, onFocusForGetItemComponent], + ); + + return ( + + ); +} diff --git a/packages/shared-components/src/utils/VirtualizedList/FlatVirtualizedList/index.ts b/packages/shared-components/src/utils/VirtualizedList/FlatVirtualizedList/index.ts new file mode 100644 index 00000000000..48f9f259969 --- /dev/null +++ b/packages/shared-components/src/utils/VirtualizedList/FlatVirtualizedList/index.ts @@ -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"; diff --git a/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.stories.tsx b/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.stories.tsx new file mode 100644 index 00000000000..d0f6ac7ecbe --- /dev/null +++ b/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.stories.tsx @@ -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, + 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); + + +\`\`\` + +--- + +### 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 ( +
+ {/* Direct child must be a gridcell */} + +
+ ); +}} +\`\`\` + +--- + +### 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 ( +
+ {/* Direct child must be a gridcell */} + +
+ ); +}} +\`\`\` + `, + }, + }, + }, + args: { + groups, + "getItemComponent": ( + _index: number, + item: SimpleItemComponent, + context: VirtualizedListContext, + 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 ( + + ); + }, + "getGroupHeaderComponent": ( + groupIndex: number, + header: SimpleGroupHeader, + context: VirtualizedListContext, + 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 ( + + ); + }, + "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>; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.tsx b/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.tsx new file mode 100644 index 00000000000..d8d050601cc --- /dev/null +++ b/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.tsx @@ -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 { + /** 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: Header } | { item: Item }; + +export interface GroupedVirtualizedListProps extends Omit< + VirtualizedListProps, + "items" | "isItemFocusable" | "getItemKey" +> { + /** + * The groups to display in the virtualized list. + * Each group has a header and an array of child items. + */ + groups: Group[]; + + /** + * 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, + 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, + 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( + props: GroupedVirtualizedListProps, +): 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>((group) => [ + { header: group.header }, + ...group.items.map>((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): 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): boolean => + "header" in entry ? isGroupHeaderFocusable(entry.header) : isItemFocusable(entry.item), + [isGroupHeaderFocusable, isItemFocusable], + ); + + const { onFocusForGetItemComponent, ...virtuosoProps } = useVirtualizedList, Context>( + { + ...(restProps as Omit< + VirtualizedListProps, 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): JSX.Element => + getItemComponent(index, items[index], context, onFocusForItem, groupIndex), + [items, getItemComponent, onFocusForItem], + ); + + const getGroupHeaderComponentInternal = useCallback( + (groupIndex: number, context: VirtualizedListContext): JSX.Element => + getGroupHeaderComponent(groupIndex, groups[groupIndex].header, context, onFocusForHeader), + [getGroupHeaderComponent, onFocusForHeader, groups], + ); + + return ( + + ); +} diff --git a/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/index.ts b/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/index.ts new file mode 100644 index 00000000000..d905cc055e5 --- /dev/null +++ b/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/index.ts @@ -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"; diff --git a/packages/shared-components/src/utils/VirtualizedList/VirtualizedList.stories.tsx b/packages/shared-components/src/utils/VirtualizedList/VirtualizedList.stories.tsx deleted file mode 100644 index e2b9ef36262..00000000000 --- a/packages/shared-components/src/utils/VirtualizedList/VirtualizedList.stories.tsx +++ /dev/null @@ -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, - args: { - items, - getItemComponent: ( - _index: number, - item: SimpleItem, - context: VirtualizedListContext, - onFocus: (item: SimpleItem, e: React.FocusEvent) => void, - ) => { - const selected = context.tabIndexKey === item.id; - - return ( - - ); - }, - isItemFocusable: () => true, - getItemKey: (item) => item.id, - style: { height: "400px" }, - }, -} satisfies Meta>; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/packages/shared-components/src/utils/VirtualizedList/accessbility.ts b/packages/shared-components/src/utils/VirtualizedList/accessbility.ts new file mode 100644 index 00000000000..b414f16aab9 --- /dev/null +++ b/packages/shared-components/src/utils/VirtualizedList/accessbility.ts @@ -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, + }; +} diff --git a/packages/shared-components/src/utils/VirtualizedList/index.ts b/packages/shared-components/src/utils/VirtualizedList/index.ts index 72476c231a9..8aea34c326c 100644 --- a/packages/shared-components/src/utils/VirtualizedList/index.ts +++ b/packages/shared-components/src/utils/VirtualizedList/index.ts @@ -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 diff --git a/packages/shared-components/src/utils/VirtualizedList/story-mock.module.css b/packages/shared-components/src/utils/VirtualizedList/story-mock.module.css index 87a9346ef4c..44b1e35161e 100644 --- a/packages/shared-components/src/utils/VirtualizedList/story-mock.module.css +++ b/packages/shared-components/src/utils/VirtualizedList/story-mock.module.css @@ -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; +} diff --git a/packages/shared-components/src/utils/VirtualizedList/story-mock.tsx b/packages/shared-components/src/utils/VirtualizedList/story-mock.tsx new file mode 100644 index 00000000000..7f8424eeb8d --- /dev/null +++ b/packages/shared-components/src/utils/VirtualizedList/story-mock.tsx @@ -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[] = [ + { 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 = ItemAccessibleProps & { + item: SimpleItemComponent; + context: Context; + onFocus: (item: SimpleItemComponent, e: React.FocusEvent) => void; +}; + +export const SimpleItemComponent = memo(function SimpleItemComponent({ + item, + context, + onFocus, + ...rest +}: SimpleItemComponentProps>): JSX.Element { + const selected = context.tabIndexKey === item.id; + const { role } = rest; + + const buttonProps = role === "row" ? { role: "gridcell" } : rest; + const button = ( + + ); + + if (role === "option") return button; + + return ( +
+ {button} +
+ ); +}); + +interface GroupHeaderComponentProps extends TreegridGroupHeaderProps { + header: SimpleGroupHeader; + context: VirtualizedListContext; + 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 ( +
+ +
+ ); +}); diff --git a/packages/shared-components/src/utils/VirtualizedList/VirtualizedList.test.tsx b/packages/shared-components/src/utils/VirtualizedList/virtualized-list.test.tsx similarity index 63% rename from packages/shared-components/src/utils/VirtualizedList/VirtualizedList.test.tsx rename to packages/shared-components/src/utils/VirtualizedList/virtualized-list.test.tsx index e6564f3f43b..4a6696aaaca 100644 --- a/packages/shared-components/src/utils/VirtualizedList/VirtualizedList.test.tsx +++ b/packages/shared-components/src/utils/VirtualizedList/virtualized-list.test.tsx @@ -10,7 +10,27 @@ 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"; + +// ─── Test types ────────────────────────────────────────────────────────────── + +interface TestItem { + id: string; + name: string; + isFocusable?: boolean; +} + +const SEPARATOR_ITEM = "SEPARATOR" as const; +type TestItemWithSeparator = TestItem | typeof SEPARATOR_ITEM; + +interface TestGroupHeader { + id: string; + name: string; +} + +// ─── Shared helpers ────────────────────────────────────────────────────────── const expectTabIndex = (element: Element, expected: string): void => { expect(element.getAttribute("tabindex")).toBe(expected); @@ -20,16 +40,177 @@ const expectAttribute = (element: Element, attr: string, expected: string): void expect(element.getAttribute(attr)).toBe(expected); }; -interface TestItem { - id: string; +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, +): React.JSX.Element { + const itemKey = typeof item === "string" ? item : item.id; + const isFocused = context.tabIndexKey === itemKey; + return ( +
+ {item === SEPARATOR_ITEM ? "---" : (item as TestItem).name} +
+ ); +} + +/** Renders a clickable item element used by the scroll-click test mock. */ +function renderClickableItemElement( + index: number, + item: TestItemWithSeparator, + context: VirtualizedListContext, + 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 ( +
{ + if (e.key === "Enter" || e.key === " ") { + onClick(); + } + }} + onFocus={(e) => onFocus(item, e)} + > + {item === SEPARATOR_ITEM ? "---" : (item as TestItem).name} +
+ ); +} + +// ─── Variant definitions ───────────────────────────────────────────────────── + +interface ListTestVariant { name: string; - isFocusable?: boolean; + /** Build the JSX element for the given items and props. */ + createComponent: ( + items: TestItemWithSeparator[], + mockGetItemComponent: any, + mockIsItemFocusable: any, + extraProps?: Record, + ) => 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 SEPARATOR_ITEM = "SEPARATOR" as const; -type TestItemWithSeparator = TestItem | typeof SEPARATOR_ITEM; +const flatVariant: ListTestVariant = { + name: "FlatVirtualizedList", + stepsToFirstItem: 0, + navigableSelector: ".mx_item", + + createComponent(items, mockGetItemComponent, mockIsItemFocusable, extraProps = {}) { + const props: FlatVirtualizedListProps = { + items, + "getItemComponent": mockGetItemComponent, + "isItemFocusable": mockIsItemFocusable, + getItemKey, + "role": "grid", + "aria-rowcount": items.length, + "aria-colcount": 1, + ...extraProps, + }; + return ; + }, + + setupDefaultMock(mockGetItemComponent, _getItems) { + mockGetItemComponent.mockImplementation( + (index: number, item: TestItemWithSeparator, context: VirtualizedListContext) => + renderItemElement(index, item, context), + ); + }, + + setupClickTestMock(mockGetItemComponent, mockOnClick, _getItems) { + mockGetItemComponent.mockImplementation( + ( + index: number, + item: TestItemWithSeparator, + context: VirtualizedListContext, + 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 = { + "groups": [{ header, items }], + "getItemComponent": mockGetItemComponent, + "getGroupHeaderComponent": ( + _groupIndex: number, + header: TestGroupHeader, + context: VirtualizedListContext, + onFocus: (header: TestGroupHeader, e: React.FocusEvent) => void, + ) => ( +
onFocus(header, e)} + > + {header.name} +
+ ), + "isGroupHeaderFocusable": () => true, + "isItemFocusable": mockIsItemFocusable, + getItemKey, + "getHeaderKey": (header) => header.id, + "role": "grid", + "aria-rowcount": items.length, + "aria-colcount": 1, + ...extraProps, + }; + return ; + }, + + setupDefaultMock(mockGetItemComponent, _getItems) { + mockGetItemComponent.mockImplementation( + (index: number, item: TestItemWithSeparator, context: VirtualizedListContext) => + renderItemElement(index, item, context), + ); + }, + + setupClickTestMock(mockGetItemComponent, mockOnClick, _getItems) { + mockGetItemComponent.mockImplementation( + ( + index: number, + item: TestItemWithSeparator, + context: VirtualizedListContext, + onFocus: (item: TestItemWithSeparator, e: React.FocusEvent) => void, + ) => renderClickableItemElement(index, item, context, onFocus, () => mockOnClick(item)), + ); + }, +}; + +// ─── Shared test suite ─────────────────────────────────────────────────────── + +const virtuosoWrapper = ({ children }: PropsWithChildren): React.JSX.Element => ( + + {children} + +); -describe("VirtualizedList", () => { +describe.each([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 = { - 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> = {}, + items: TestItemWithSeparator[], + extraProps: Record = {}, ): React.JSX.Element => { - const mergedProps = { ...defaultProps, ...props }; - return ; + currentItems = items; + return variant.createComponent(items, mockGetItemComponent, mockIsItemFocusable, extraProps); }; const renderListWithHeight = ( - props: Partial> = {}, + overrides: { items?: TestItemWithSeparator[] } & Record = {}, ): ReturnType => { - const mergedProps = { ...defaultProps, ...props }; - return render(getListComponent(mergedProps), { - wrapper: ({ children }: PropsWithChildren) => ( - - <>{children} - - ), - }); + 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 ( -
- {item === SEPARATOR_ITEM ? "---" : (item as TestItem).name} -
- ); - }); + 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 ( -
mockOnClick(item)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - mockOnClick(item); - } - }} - onFocus={(e) => onFocus(item, e)} - > - {item === SEPARATOR_ITEM ? "---" : (item as TestItem).name} -
- ); - }, - ); + 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( - = { context: Context; }; -export interface IVirtualizedListProps extends Omit< +export interface VirtualizedListProps extends Omit< VirtuosoProps>, "data" | "itemContent" | "context" > { @@ -52,21 +52,6 @@ export interface IVirtualizedListProps 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, - 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 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 = NonNullable< VirtuosoProps>["scrollIntoViewOnChange"] >; +export interface UseVirtualizedListResult extends Omit< + VirtuosoProps>, + "data" | "itemContent" | "context" | "onKeyDown" | "onFocus" | "onBlur" | "rangeChanged" | "scrollerRef" | "ref" +> { + ref: React.RefObject; + scrollerRef: (element: HTMLElement | Window | null) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onFocus: (e: React.FocusEvent) => void; + onBlur: (event: React.FocusEvent) => void; + rangeChanged: (range: ListRange) => void; + onFocusForGetItemComponent: (item: Item, e: React.FocusEvent) => void; + context: VirtualizedListContext; +} + /** - * 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. + * + * 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. * - * @template Item - The type of data items in the list - * @template Context - The type of additional context data passed to items + * @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(props: IVirtualizedListProps): React.ReactElement { +export function useVirtualizedList( + props: VirtualizedListProps, +): UseVirtualizedListResult { // 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(props: IVirtualizedListProps(props: IVirtualizedListProps): 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(props: IVirtualizedListProps(props: IVirtualizedListProps { - setVisibleRange(range); + const internalRange = mapRangeIndex + ? { startIndex: mapRangeIndex(range.startIndex), endIndex: mapRangeIndex(range.endIndex) } + : range; + setVisibleRange(internalRange); rangeChanged?.(range); }, - [rangeChanged], + [rangeChanged, mapRangeIndex], ); - return ( - - ); + return { + ...virtuosoProps, + ref: virtuosoHandleRef, + scrollerRef, + onKeyDown: keyDownCallback, + onFocus, + onBlur, + rangeChanged: handleRangeChanged, + onFocusForGetItemComponent, + context: listContext, + }; }