Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/famous-trees-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@strapi/design-system': major
'@strapi/ui-primitives': major
---

Add virtualization as an option to combobox list
45 changes: 45 additions & 0 deletions docs/stories/03-inputs/Combobox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,51 @@ export const WithField = {
name: 'With Field',
};

export const Virtualization = {
args: {
label: 'Fruits',
error: 'Error',
hint: 'Description line lorem ipsum',
},
render: ({ error, hint, label, ...comboboxProps }) => {
const [value, setValue] = React.useState<string | undefined>('');

return (
<Field.Root id="with_field" error={error} hint={hint}>
<Field.Label>{label}</Field.Label>
<Combobox value={value} onChange={setValue} onClear={() => setValue('')} {...comboboxProps}>
{[...Array(1000)].map((_, i) => (
<ComboboxOption key={i} value={`option-${i}`}>
Option {i}
</ComboboxOption>
))}
</Combobox>
<Field.Error />
<Field.Hint />
</Field.Root>
);
},
parameters: {
docs: {
source: {
code: outdent`
<Field.Root id="with_field" error={error} hint={hint}>
<Field.Label>{label}</Field.Label>
<Combobox {...props}>
{options.map(({ name, value }) => (
<ComboboxOption key={value} value={value}>{name}</ComboboxOption>
))}
</Combobox>
<Field.Error />
<Field.Hint />
</Field.Root>
`,
},
},
},
name: 'Virtualization',
};

export const ComboboxProps = {
/**
* add !dev tag so this story does not appear in the sidebar
Expand Down
46 changes: 46 additions & 0 deletions packages/design-system/src/components/Combobox/Combobox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,52 @@ describe('Combobox', () => {
});
});

describe('virtualization', () => {
it('should enable virtualization when there are more than 100 items', async () => {
// Create an array of 150 items
const manyOptions = Array.from({ length: 150 }, (_, i) => ({
value: `item-${i}`,
children: `Item ${i}`,
}));

const { getByRole, getByTestId, user } = render({
options: manyOptions,
});

await user.click(getByRole('combobox'));

// VirtualizedList should be present when >100 items and not filtering
expect(getByTestId('virtualized-list')).toBeInTheDocument();
});

it('should disable virtualization when filtering, even with many items', async () => {
// Create an array of 150 items
const manyOptions = Array.from({ length: 150 }, (_, i) => ({
value: `item-${i}`,
children: `Item ${i}`,
}));

const { getByRole, queryByTestId, user } = render({
options: manyOptions,
});

await user.click(getByRole('combobox'));
await user.type(getByRole('combobox'), 'item-1');

// When filtering, VirtualizedList should not be used
expect(queryByTestId('virtualized-list')).not.toBeInTheDocument();
});

it('should not use virtualization with fewer than 100 items', async () => {
const { getByRole, queryByTestId, user } = render(); // Uses defaultOptions which has 4 items

await user.click(getByRole('combobox'));

// VirtualizedList should not be present with small lists
expect(queryByTestId('virtualized-list')).not.toBeInTheDocument();
});
});

describe('clear props', () => {
it('should only show the clear button if the user has started typing an onClear is passed', async () => {
const { getByRole, queryByRole, user } = render({
Expand Down
30 changes: 29 additions & 1 deletion packages/design-system/src/components/Combobox/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { Field, useField } from '../Field';
import { IconButton } from '../IconButton';
import { Loader } from '../Loader';

import { VirtualizedList } from './VirtualizedList';

/* -------------------------------------------------------------------------------------------------
* ComboboxInput
* -----------------------------------------------------------------------------------------------*/
Expand Down Expand Up @@ -64,6 +66,12 @@ interface ComboboxProps
*/
size?: 'S' | 'M';
startIcon?: React.ReactNode;
/**
* Enable virtualization for large lists
* @default false
*/
// Virtualization is automatic based on the number of options; manual
// control props were removed to simplify the API.
}

type ComboboxInputElement = HTMLInputElement;
Expand Down Expand Up @@ -98,6 +106,7 @@ const Combobox = React.forwardRef<ComboboxInputElement, ComboboxProps>(
onChange,
onClear,
onCreateOption,
// virtualization props removed; virtualization is automatic
onFilterValueChange,
onInputChange,
onTextValueChange,
Expand Down Expand Up @@ -206,6 +215,21 @@ const Combobox = React.forwardRef<ComboboxInputElement, ComboboxProps>(
const name = field.name ?? nameProp;
const required = field.required || requiredProp;

// Compute children count early so we can decide about virtualization
const childArray = React.Children.toArray(children).filter(Boolean);
const childrenCount = childArray.length;

// If the user is actively filtering/typing, disable virtualization so
// the list can resize to the filtered results and show the NoValueFound node.
const isFiltering = Boolean(
(internalTextValue && internalTextValue !== '') || (internalFilterValue && internalFilterValue !== ''),
);

// Auto-enable virtualization when there are more than 100 items and the
// user is not currently filtering.
const AUTO_VIRTUALIZE_THRESHOLD = 100;
const shouldVirtualizeOptions = !isFiltering && childrenCount > AUTO_VIRTUALIZE_THRESHOLD;

let ariaDescription: string | undefined;
if (error) {
ariaDescription = `${id}-error`;
Expand Down Expand Up @@ -271,7 +295,11 @@ const Combobox = React.forwardRef<ComboboxInputElement, ComboboxProps>(
<Content sideOffset={4}>
<ComboboxPrimitive.Viewport ref={viewportRef}>
<ScrollAreaCombobox>
{children}
{shouldVirtualizeOptions ? (
<VirtualizedList itemCount={childrenCount}>{children}</VirtualizedList>
) : (
children
)}
{creatable !== true && !loading ? (
<ComboboxPrimitive.NoValueFound asChild>
<OptionBox $hasHover={false}>
Expand Down
118 changes: 118 additions & 0 deletions packages/design-system/src/components/Combobox/VirtualizedList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { ReactNode, FC, useRef, useState, useEffect, startTransition, useMemo, Children, useCallback } from 'react';

import { useVirtualizer } from '@tanstack/react-virtual';

import { Box } from '../../primitives';

interface VirtualizedListProps {
children?: ReactNode;
estimatedItemSize?: number;
overscan?: number;
// Optional: lazy rendering support
itemCount?: number;
renderItem?: (index: number) => ReactNode;
}

/**
* VirtualizedList - Wraps Combobox children in a virtualizer for performance
* This component should be used inside ScrollArea to virtualize the list
*
* Two modes:
* 1. Children mode (default): Pass children directly
* 2. Lazy mode: Pass itemCount + renderItem for maximum performance
*/
export const VirtualizedList: FC<VirtualizedListProps> = ({
children,
estimatedItemSize = 40,
overscan = 10,
itemCount,
renderItem,
}) => {
const parentRef = useRef<HTMLDivElement>(null);
const [isReady, setIsReady] = useState(false);
const isMountedRef = useRef(true);

useEffect(() => {
isMountedRef.current = true;

if (typeof startTransition === 'function') {
startTransition(() => {
if (isMountedRef.current) {
setIsReady(true);
}
});
}

return () => {
isMountedRef.current = false;
};
}, []);

// Convert children to array only once and cache it (for children mode)
const childArray = useMemo(() => {
if (renderItem && itemCount !== undefined) {
// Lazy mode: no children array needed
return [];
}
return Children.toArray(children);
}, [children, renderItem, itemCount]);

const count = itemCount ?? childArray.length;

const virtualizer = useVirtualizer({
count,
// parentRef is the inner container; the scroll element is its closest scrollable ancestor
getScrollElement: () => parentRef.current ?? null,
estimateSize: useCallback(() => estimatedItemSize, [estimatedItemSize]),
overscan,
// Optimize scroll performance
scrollMargin: 0,
// Don't measure elements dynamically - use fixed size
measureElement: undefined,
// Use lanes for better performance with large lists
lanes: 1,
});

// Get virtual items - this updates as you scroll
const virtualItems = isReady && isMountedRef.current ? virtualizer.getVirtualItems() : [];

// Show minimal content until ready to prevent blocking
if (!isReady) {
// Small placeholder while React.startTransition finishes to avoid huge blank areas
return <Box ref={parentRef} height="40px" width="100%" position="relative" />;
}

return (
<Box
ref={parentRef}
height={`${virtualizer.getTotalSize() > 0 ? virtualizer.getTotalSize() : 0}px`}
width="100%"
position="relative"
data-testid="virtualized-list"
style={{
willChange: 'transform',
}}
>
{virtualItems.map((virtualItem) => {
// Lazy mode: render on-demand
const child = renderItem ? renderItem(virtualItem.index) : childArray[virtualItem.index];

return (
<Box
key={virtualItem.key}
data-index={virtualItem.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translate3d(0, ${virtualItem.start}px, 0)`,
}}
>
{child}
</Box>
);
})}
</Box>
);
};
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from 'react';

import { Clock } from '@strapi/icons';
import styled from 'styled-components';
import { styled } from 'styled-components';

import { useControllableState } from '../../hooks/useControllableState';
import { useDateFormatter } from '../../hooks/useDateFormatter';
Expand Down Expand Up @@ -74,9 +74,11 @@ export const TimePicker = React.forwardRef<ComboboxInputElement, TimePickerProps
return separator;
}, [formatter]);

// Always generate the full set of time options. The Combobox will
// automatically enable virtualization when the number of children
// exceeds the threshold (AUTO_VIRTUALIZE_THRESHOLD).
const timeOptions = React.useMemo(() => {
const stepCount = 60 / step;

return [...Array(24).keys()].flatMap((hour) =>
[...Array(stepCount).keys()].map((minuteStep) => formatter.format(new Date(0, 0, 0, hour, minuteStep * step))),
);
Expand Down Expand Up @@ -137,6 +139,8 @@ export const TimePicker = React.forwardRef<ComboboxInputElement, TimePickerProps
const escapedSeparator = escapeForRegex(separator);
const pattern = `\\d{2}${escapedSeparator}\\d{2}`;

// (no lazy render function required anymore)

return (
<TimePickerCombobox
{...restProps}
Expand Down
1 change: 1 addition & 0 deletions packages/primitives/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@radix-ui/react-use-layout-effect": "1.0.1",
"@radix-ui/react-use-previous": "1.0.1",
"@radix-ui/react-visually-hidden": "1.0.3",
"@tanstack/react-virtual": "^3.10.8",
"aria-hidden": "1.2.4",
"react-remove-scroll": "2.5.10"
},
Expand Down
Loading
Loading