Skip to content

Commit d666c71

Browse files
stefanonardoclaude
andcommitted
feat(VirtualSelectList): add virtualized list for Select/Menu dropdowns
Add VirtualSelectList, a virtualized replacement for PatternFly's SelectList that renders only visible items in a scrollable dropdown. This enables Select menus with thousands of options without DOM performance degradation. Motivation: In OpenShift Console (openshift/console#16252), the Search page Resources dropdown has 1000+ items. Even after algorithmic optimizations, PatternFly's Select refCallback forced reflow on 100+ DOM nodes takes ~566ms. VirtualSelectList reduces INP from 270ms ("needs improvement") to 107ms ("good") by rendering only ~10-20 visible items. Changes: - VirtualGrid: add innerScrollContainerClassName prop so non-table consumers can override the hardcoded pf-v6-c-table__tbody class - VirtualSelectList: new component wrapping VirtualGrid for menu/list semantics (ul[role=listbox]), with keyboard navigation and a11y - Documentation: basic, typeahead, and multi-select examples - Tests: 14 unit tests covering rendering, ARIA, keyboard nav Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 251a50b commit d666c71

9 files changed

Lines changed: 998 additions & 2 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
id: Virtual select list
3+
section: extensions
4+
source: react
5+
sourceLink: https://github.com/patternfly/react-virtualized-extension
6+
propComponents: ['VirtualSelectList']
7+
---
8+
9+
Note: React Virtualized Extension lives in its own package at [`@patternfly/react-virtualized-extension`](https://www.npmjs.com/package/@patternfly/react-virtualized-extension)!
10+
11+
import { VirtualSelectList } from '@patternfly/react-virtualized-extension';
12+
import { Select, SelectOption, MenuToggle, TextInputGroup, TextInputGroupMain, TextInputGroupUtilities, Button, Badge } from '@patternfly/react-core';
13+
import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon';
14+
import { useState, useMemo, useRef } from 'react';
15+
16+
## About
17+
18+
VirtualSelectList is a virtualized replacement for PatternFly's SelectList component. It renders only the visible items in a scrollable dropdown, enabling Select menus with thousands of options without DOM performance degradation.
19+
20+
Use VirtualSelectList as a drop-in replacement for `<SelectList>` inside a composable `<Select>`. The `itemRenderer` callback receives positioning styles that must be applied to each rendered `<SelectOption>`.
21+
22+
## Examples
23+
24+
### Basic
25+
26+
```js file="./VirtualSelectListBasic.tsx"
27+
28+
```
29+
30+
### Typeahead with filtering
31+
32+
```js file="./VirtualSelectListTypeahead.tsx"
33+
34+
```
35+
36+
### Multi-select
37+
38+
```js file="./VirtualSelectListMulti.tsx"
39+
40+
```
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useState, FunctionComponent } from 'react';
2+
import { Select, SelectOption, MenuToggle, MenuToggleElement } from '@patternfly/react-core';
3+
import { VirtualSelectList } from '@patternfly/react-virtualized-extension';
4+
5+
export const VirtualSelectListBasicExample: FunctionComponent = () => {
6+
const [isOpen, setIsOpen] = useState(false);
7+
const [selected, setSelected] = useState<string>();
8+
9+
const items: string[] = [];
10+
for (let i = 0; i < 10000; i++) {
11+
items.push(`Option ${i}`);
12+
}
13+
14+
const onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
15+
setSelected(value as string);
16+
setIsOpen(false);
17+
};
18+
19+
const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
20+
<MenuToggle
21+
ref={toggleRef}
22+
onClick={() => setIsOpen((prev) => !prev)}
23+
isExpanded={isOpen}
24+
style={{ width: '200px' } as React.CSSProperties}
25+
>
26+
{selected || 'Select an option'}
27+
</MenuToggle>
28+
);
29+
30+
return (
31+
<Select
32+
isOpen={isOpen}
33+
selected={selected}
34+
onSelect={onSelect}
35+
onOpenChange={setIsOpen}
36+
toggle={toggle}
37+
isScrollable
38+
>
39+
<VirtualSelectList
40+
rowCount={items.length}
41+
rowHeight={36}
42+
maxHeight={300}
43+
aria-label="Basic virtualized select"
44+
rowRenderer={({ index, style, key }) => (
45+
<SelectOption key={key} style={style} value={items[index]} isSelected={selected === items[index]}>
46+
{items[index]}
47+
</SelectOption>
48+
)}
49+
/>
50+
</Select>
51+
);
52+
};
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { useState, FunctionComponent } from 'react';
2+
import {
3+
Select,
4+
SelectOption,
5+
MenuToggle,
6+
MenuToggleElement,
7+
Badge
8+
} from '@patternfly/react-core';
9+
import { VirtualSelectList } from '@patternfly/react-virtualized-extension';
10+
11+
export const VirtualSelectListMultiExample: FunctionComponent = () => {
12+
const [isOpen, setIsOpen] = useState(false);
13+
const [selectedItems, setSelectedItems] = useState<string[]>([]);
14+
15+
const items: string[] = [];
16+
for (let i = 0; i < 1000; i++) {
17+
items.push(`Option ${i}`);
18+
}
19+
20+
const onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
21+
const val = value as string;
22+
setSelectedItems((prev) =>
23+
prev.includes(val) ? prev.filter((item) => item !== val) : [...prev, val]
24+
);
25+
};
26+
27+
const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
28+
<MenuToggle
29+
ref={toggleRef}
30+
onClick={() => setIsOpen((prev) => !prev)}
31+
isExpanded={isOpen}
32+
style={{ width: '300px' } as React.CSSProperties}
33+
>
34+
{selectedItems.length > 0 ? (
35+
<>
36+
{selectedItems.length} selected
37+
<Badge isRead className="pf-v6-u-ml-sm">
38+
{selectedItems.length}
39+
</Badge>
40+
</>
41+
) : (
42+
'Select options'
43+
)}
44+
</MenuToggle>
45+
);
46+
47+
return (
48+
<Select
49+
isOpen={isOpen}
50+
selected={selectedItems}
51+
onSelect={onSelect}
52+
onOpenChange={setIsOpen}
53+
toggle={toggle}
54+
isScrollable
55+
>
56+
<VirtualSelectList
57+
rowCount={items.length}
58+
rowHeight={36}
59+
maxHeight={300}
60+
isAriaMultiselectable
61+
aria-label="Multi-select virtualized list"
62+
rowRenderer={({ index, style, key }) => (
63+
<SelectOption
64+
key={key}
65+
style={style}
66+
value={items[index]}
67+
hasCheckbox
68+
isSelected={selectedItems.includes(items[index])}
69+
>
70+
{items[index]}
71+
</SelectOption>
72+
)}
73+
/>
74+
</Select>
75+
);
76+
};
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { useState, useMemo, useRef, FunctionComponent } from 'react';
2+
import {
3+
Select,
4+
SelectOption,
5+
MenuToggle,
6+
MenuToggleElement,
7+
TextInputGroup,
8+
TextInputGroupMain,
9+
TextInputGroupUtilities,
10+
Button
11+
} from '@patternfly/react-core';
12+
import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon';
13+
import { VirtualSelectList, VirtualSelectListRef } from '@patternfly/react-virtualized-extension';
14+
15+
const allItems: string[] = [];
16+
for (let i = 0; i < 10000; i++) {
17+
allItems.push(`Option ${i}`);
18+
}
19+
20+
export const VirtualSelectListTypeaheadExample: FunctionComponent = () => {
21+
const [isOpen, setIsOpen] = useState(false);
22+
const [selected, setSelected] = useState<string>();
23+
const [filterValue, setFilterValue] = useState('');
24+
const listRef = useRef<VirtualSelectListRef>(null);
25+
26+
const filteredItems = useMemo(
27+
() =>
28+
filterValue
29+
? allItems.filter((item) => item.toLowerCase().includes(filterValue.toLowerCase()))
30+
: allItems,
31+
[filterValue]
32+
);
33+
34+
const onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
35+
setSelected(value as string);
36+
setFilterValue('');
37+
setIsOpen(false);
38+
};
39+
40+
const onInputChange = (_event: React.FormEvent<HTMLInputElement>, value: string) => {
41+
setFilterValue(value);
42+
if (!isOpen) {
43+
setIsOpen(true);
44+
}
45+
};
46+
47+
const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
48+
<MenuToggle
49+
ref={toggleRef}
50+
variant="typeahead"
51+
onClick={() => setIsOpen((prev) => !prev)}
52+
isExpanded={isOpen}
53+
isFullWidth
54+
>
55+
<TextInputGroup isPlain>
56+
<TextInputGroupMain
57+
value={filterValue || selected || ''}
58+
onClick={() => setIsOpen(true)}
59+
onChange={onInputChange}
60+
autoComplete="off"
61+
placeholder="Search from 10,000 options..."
62+
/>
63+
{(filterValue || selected) && (
64+
<TextInputGroupUtilities>
65+
<Button
66+
variant="plain"
67+
onClick={() => {
68+
setSelected(undefined);
69+
setFilterValue('');
70+
}}
71+
aria-label="Clear input"
72+
>
73+
<TimesIcon />
74+
</Button>
75+
</TextInputGroupUtilities>
76+
)}
77+
</TextInputGroup>
78+
</MenuToggle>
79+
);
80+
81+
return (
82+
<Select
83+
isOpen={isOpen}
84+
selected={selected}
85+
onSelect={onSelect}
86+
onOpenChange={(open) => {
87+
setIsOpen(open);
88+
if (!open) {
89+
setFilterValue('');
90+
}
91+
}}
92+
toggle={toggle}
93+
isScrollable
94+
>
95+
<VirtualSelectList
96+
ref={listRef}
97+
rowCount={filteredItems.length}
98+
rowHeight={36}
99+
maxHeight={300}
100+
aria-label="Typeahead virtualized select"
101+
noRowsRenderer={() => (
102+
<li className="pf-v6-c-menu__list-item pf-m-disabled" role="option" aria-disabled="true">
103+
No results found for &quot;{filterValue}&quot;
104+
</li>
105+
)}
106+
rowRenderer={({ index, style, key }) => (
107+
<SelectOption key={key} style={style} value={filteredItems[index]} isSelected={selected === filteredItems[index]}>
108+
{filteredItems[index]}
109+
</SelectOption>
110+
)}
111+
/>
112+
</Select>
113+
);
114+
};

packages/module/src/components/Virtualized/VirtualGrid.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,11 @@ export interface VirtualGridProps {
239239

240240
/** Inner Scroll Container element to render */
241241
innerScrollContainerComponent?: string | ComponentType<any>;
242+
243+
/** Optional custom CSS class name for the inner scroll container.
244+
* Defaults to 'pf-v6-c-table__tbody' for backward compatibility with table-based usage.
245+
*/
246+
innerScrollContainerClassName?: string;
242247
}
243248

244249
interface InstanceProps {
@@ -920,7 +925,8 @@ export class VirtualGrid extends Component<VirtualGridProps, VirtualGridState> {
920925
tabIndex,
921926
width,
922927
scrollContainerComponent,
923-
innerScrollContainerComponent
928+
innerScrollContainerComponent,
929+
innerScrollContainerClassName
924930
} = this.props;
925931
const { instanceProps, needToResetStyleCache } = this.state;
926932

@@ -1008,7 +1014,7 @@ export class VirtualGrid extends Component<VirtualGridProps, VirtualGridState> {
10081014
let innerScrollContainer = null;
10091015
if (childrenToDisplay.length > 0) {
10101016
const innerScrollContainerProps = {
1011-
className: 'ReactVirtualized__VirtualGrid__innerScrollContainer pf-v6-c-table__tbody',
1017+
className: css('ReactVirtualized__VirtualGrid__innerScrollContainer', innerScrollContainerClassName ?? 'pf-v6-c-table__tbody'),
10121018
key: 'ReactVirtualized__VirtualGrid__innerScrollContainer',
10131019
role: containerRole,
10141020
style: {

0 commit comments

Comments
 (0)