From 8a085858fdee69a1698e89e7b0b45214f57bc4c0 Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Sun, 19 Oct 2025 10:28:43 -0700 Subject: [PATCH 1/3] feat: add `virtualize` utility --- src/utils/virtualize.d.ts | 39 ++++ src/utils/virtualize.js | 68 ++++++ tests/utils/virtualize.test.ts | 365 +++++++++++++++++++++++++++++++++ types/utils/virtualize.d.ts | 39 ++++ 4 files changed, 511 insertions(+) create mode 100644 src/utils/virtualize.d.ts create mode 100644 src/utils/virtualize.js create mode 100644 tests/utils/virtualize.test.ts create mode 100644 types/utils/virtualize.d.ts diff --git a/src/utils/virtualize.d.ts b/src/utils/virtualize.d.ts new file mode 100644 index 0000000000..0b136ab328 --- /dev/null +++ b/src/utils/virtualize.d.ts @@ -0,0 +1,39 @@ +export interface VirtualizeConfig< + T extends Record = Record, +> { + /** Full array of items to virtualize */ + items: T[]; + /** Height of each item in pixels */ + itemHeight: number; + /** Visible container height in pixels */ + containerHeight: number; + /** Current scroll position */ + scrollTop: number; + /** Extra items to render above/below viewport (default: 3) */ + overscan?: number; + /** Cap maximum rendered items */ + maxItems?: number; + /** Minimum items before virtualization activates (default: 100) */ + threshold?: number; +} + +export interface VirtualizeResult< + T extends Record = Record, +> { + /** Items to render in the current viewport */ + visibleItems: T[]; + /** Index of first visible item */ + startIndex: number; + /** Index after last visible item */ + endIndex: number; + /** Y offset for positioning visible items */ + offsetY: number; + /** Total height of all items */ + totalHeight: number; + /** Whether virtualization is active */ + isVirtualized: boolean; +} + +export function virtualize< + T extends Record = Record, +>(config: VirtualizeConfig): VirtualizeResult; diff --git a/src/utils/virtualize.js b/src/utils/virtualize.js new file mode 100644 index 0000000000..7857ac4c86 --- /dev/null +++ b/src/utils/virtualize.js @@ -0,0 +1,68 @@ +/** + * Virtualizes a list to render only visible items for performance. + * + * @template {Record} T The type of items in the array (must be a Record) + * @param {Object} config + * @param {T[]} config.items - Full array of items to virtualize + * @param {number} config.itemHeight - Height of each item in pixels + * @param {number} config.containerHeight - Visible container height in pixels + * @param {number} config.scrollTop - Current scroll position + * @param {number} [config.overscan=3] - Extra items to render above/below viewport + * @param {number} [config.maxItems] - Cap maximum rendered items + * @param {number} [config.threshold=100] - Minimum items before virtualization activates + * + * @returns {{ + * visibleItems: T[], + * startIndex: number, + * endIndex: number, + * offsetY: number, + * totalHeight: number, + * isVirtualized: boolean + * }} + */ +export function virtualize({ + items, + itemHeight, + containerHeight, + scrollTop, + overscan = 3, + maxItems = undefined, + threshold = 100, +}) { + // Auto-disable if below threshold + if (items.length < threshold) { + return { + visibleItems: items, + startIndex: 0, + endIndex: items.length, + offsetY: 0, + totalHeight: items.length * itemHeight, + isVirtualized: false, + }; + } + + const totalHeight = items.length * itemHeight; + + // Calculate visible range + const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); + let endIndex = Math.min( + items.length, + Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan, + ); + + // Apply maxItems cap if specified + if (maxItems && endIndex - startIndex > maxItems) { + endIndex = startIndex + maxItems; + } + + const offsetY = startIndex * itemHeight; + + return { + visibleItems: items.slice(startIndex, endIndex), + startIndex, + endIndex, + offsetY, + totalHeight, + isVirtualized: true, + }; +} diff --git a/tests/utils/virtualize.test.ts b/tests/utils/virtualize.test.ts new file mode 100644 index 0000000000..108cd35efd --- /dev/null +++ b/tests/utils/virtualize.test.ts @@ -0,0 +1,365 @@ +import { virtualize } from "../../src/utils/virtualize.js"; + +describe("virtualize", () => { + test("should return all items when below threshold", () => { + const items = Array.from({ length: 50 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + })); + const result = virtualize({ + items, + itemHeight: 40, + containerHeight: 300, + scrollTop: 0, + threshold: 100, + }); + + expect(result.visibleItems).toEqual(items); + expect(result.startIndex).toBe(0); + expect(result.endIndex).toBe(50); + expect(result.offsetY).toBe(0); + expect(result.totalHeight).toBe(2000); + expect(result.isVirtualized).toBe(false); + }); + + test("should virtualize items when above threshold", () => { + const items = Array.from({ length: 500 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + })); + const result = virtualize({ + items, + itemHeight: 40, + containerHeight: 300, + scrollTop: 0, + threshold: 100, + }); + + expect(result.visibleItems.length).toBeLessThan(500); + expect(result.startIndex).toBe(0); + expect(result.endIndex).toBeGreaterThan(0); + expect(result.endIndex).toBeLessThan(500); + expect(result.offsetY).toBe(0); + expect(result.totalHeight).toBe(20000); + expect(result.isVirtualized).toBe(true); + }); + + test("should calculate visible range based on scroll position", () => { + const items = Array.from({ length: 500 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + })); + const result = virtualize({ + items, + itemHeight: 40, + containerHeight: 300, + scrollTop: 2000, // Scrolled to item 50 + threshold: 100, + }); + + expect(result.startIndex).toBeGreaterThan(0); + expect(result.visibleItems[0].id).toBeGreaterThanOrEqual(50 - 3); // overscan of 3 + expect(result.offsetY).toBe(result.startIndex * 40); + expect(result.isVirtualized).toBe(true); + }); + + test("should apply overscan to render extra items", () => { + const items = Array.from({ length: 500 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + })); + const overscan = 5; + const result = virtualize({ + items, + itemHeight: 40, + containerHeight: 300, + scrollTop: 0, + overscan, + threshold: 100, + }); + + // Should render visible items (300px / 40px = 7.5, so ~8 items) + overscan above and below + expect(result.visibleItems.length).toBeGreaterThan(8); + expect(result.startIndex).toBe(0); // With overscan, startIndex can go negative but is clamped to 0 + expect(result.isVirtualized).toBe(true); + }); + + test("should cap maximum rendered items when maxItems is specified", () => { + const items = Array.from({ length: 500 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + })); + const result = virtualize({ + items, + itemHeight: 40, + containerHeight: 300, + scrollTop: 0, + maxItems: 10, + threshold: 100, + }); + + expect(result.visibleItems.length).toBeLessThanOrEqual(10); + expect(result.endIndex - result.startIndex).toBeLessThanOrEqual(10); + expect(result.isVirtualized).toBe(true); + }); + + test("should handle scroll position at the end of the list", () => { + const items = Array.from({ length: 500 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + })); + const totalHeight = 500 * 40; // 20000px + const scrollTop = totalHeight - 300; // Near the end + const result = virtualize({ + items, + itemHeight: 40, + containerHeight: 300, + scrollTop, + threshold: 100, + }); + + expect(result.endIndex).toBe(500); + expect(result.visibleItems[result.visibleItems.length - 1].id).toBe(499); + expect(result.isVirtualized).toBe(true); + }); + + test("should handle scroll position beyond the list", () => { + const items = Array.from({ length: 500 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + })); + const result = virtualize({ + items, + itemHeight: 40, + containerHeight: 300, + scrollTop: 50000, // Way beyond the list + threshold: 100, + }); + + // When scrollTop is beyond the list, startIndex can be >= items.length + // which results in an empty visibleItems array + expect(result.endIndex).toBe(500); + expect(result.startIndex).toBeGreaterThanOrEqual(0); + expect(result.visibleItems.length).toBe(0); + expect(result.isVirtualized).toBe(true); + }); + + test("should handle empty items array", () => { + const result = virtualize({ + items: [], + itemHeight: 40, + containerHeight: 300, + scrollTop: 0, + threshold: 100, + }); + + expect(result.visibleItems).toEqual([]); + expect(result.startIndex).toBe(0); + expect(result.endIndex).toBe(0); + expect(result.offsetY).toBe(0); + expect(result.totalHeight).toBe(0); + expect(result.isVirtualized).toBe(false); + }); + + test("should handle custom threshold", () => { + const items = Array.from({ length: 150 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + })); + + // With threshold 200, should not virtualize + const result1 = virtualize({ + items, + itemHeight: 40, + containerHeight: 300, + scrollTop: 0, + threshold: 200, + }); + expect(result1.isVirtualized).toBe(false); + expect(result1.visibleItems.length).toBe(150); + + // With threshold 100, should virtualize + const result2 = virtualize({ + items, + itemHeight: 40, + containerHeight: 300, + scrollTop: 0, + threshold: 100, + }); + expect(result2.isVirtualized).toBe(true); + expect(result2.visibleItems.length).toBeLessThan(150); + }); + + test("should calculate correct offsetY for positioning", () => { + const items = Array.from({ length: 500 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + })); + const itemHeight = 40; + const startIndex = 25; + const scrollTop = startIndex * itemHeight; + + const result = virtualize({ + items, + itemHeight, + containerHeight: 300, + scrollTop, + threshold: 100, + }); + + expect(result.offsetY).toBe(result.startIndex * itemHeight); + expect(result.offsetY).toBeGreaterThanOrEqual(0); + }); + + test("should handle different item heights", () => { + const items = Array.from({ length: 500 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + })); + + const result1 = virtualize({ + items, + itemHeight: 20, + containerHeight: 300, + scrollTop: 0, + threshold: 100, + }); + + const result2 = virtualize({ + items, + itemHeight: 60, + containerHeight: 300, + scrollTop: 0, + threshold: 100, + }); + + // With smaller item height, more items fit in viewport + expect(result1.visibleItems.length).toBeGreaterThan( + result2.visibleItems.length, + ); + expect(result1.totalHeight).toBe(10000); + expect(result2.totalHeight).toBe(30000); + }); + + test("should handle different container heights", () => { + const items = Array.from({ length: 500 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + })); + + const result1 = virtualize({ + items, + itemHeight: 40, + containerHeight: 200, + scrollTop: 0, + threshold: 100, + }); + + const result2 = virtualize({ + items, + itemHeight: 40, + containerHeight: 600, + scrollTop: 0, + threshold: 100, + }); + + // Larger container should show more items + expect(result2.visibleItems.length).toBeGreaterThan( + result1.visibleItems.length, + ); + }); + + test("should preserve item references in visibleItems", () => { + const items = Array.from({ length: 500 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + })); + const result = virtualize({ + items, + itemHeight: 40, + containerHeight: 300, + scrollTop: 0, + threshold: 100, + }); + + // visibleItems should be a slice of the original items array + expect(result.visibleItems[0]).toBe(items[result.startIndex]); + expect(result.visibleItems[result.visibleItems.length - 1]).toBe( + items[result.endIndex - 1], + ); + }); + + test("should handle maxItems that is smaller than viewport capacity", () => { + const items = Array.from({ length: 500 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + })); + const result = virtualize({ + items, + itemHeight: 40, + containerHeight: 300, // Can fit ~7-8 items + scrollTop: 0, + maxItems: 5, + threshold: 100, + }); + + expect(result.visibleItems.length).toBe(5); + expect(result.endIndex - result.startIndex).toBe(5); + }); + + test("should handle negative scroll position", () => { + const items = Array.from({ length: 500 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + })); + const result = virtualize({ + items, + itemHeight: 40, + containerHeight: 300, + scrollTop: -100, // Negative scroll + threshold: 100, + }); + + expect(result.startIndex).toBe(0); // Should be clamped to 0 + expect(result.offsetY).toBe(0); + expect(result.isVirtualized).toBe(true); + }); + + test("should handle zero item height", () => { + const items = Array.from({ length: 500 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + })); + const result = virtualize({ + items, + itemHeight: 0, + containerHeight: 300, + scrollTop: 0, + threshold: 100, + }); + + expect(result.totalHeight).toBe(0); + // Division by zero results in NaN for offsetY (0/0 = NaN) + expect(Number.isNaN(result.offsetY) || result.offsetY >= 0).toBe(true); + // With zero height, calculation produces NaN but function still returns + expect(result.isVirtualized).toBe(true); + }); + + test("should handle zero container height", () => { + const items = Array.from({ length: 500 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + })); + const result = virtualize({ + items, + itemHeight: 40, + containerHeight: 0, + scrollTop: 0, + threshold: 100, + }); + + expect(result.isVirtualized).toBe(true); + // With zero container height, should still calculate based on overscan + expect(result.visibleItems.length).toBeGreaterThan(0); + }); +}); diff --git a/types/utils/virtualize.d.ts b/types/utils/virtualize.d.ts new file mode 100644 index 0000000000..0b136ab328 --- /dev/null +++ b/types/utils/virtualize.d.ts @@ -0,0 +1,39 @@ +export interface VirtualizeConfig< + T extends Record = Record, +> { + /** Full array of items to virtualize */ + items: T[]; + /** Height of each item in pixels */ + itemHeight: number; + /** Visible container height in pixels */ + containerHeight: number; + /** Current scroll position */ + scrollTop: number; + /** Extra items to render above/below viewport (default: 3) */ + overscan?: number; + /** Cap maximum rendered items */ + maxItems?: number; + /** Minimum items before virtualization activates (default: 100) */ + threshold?: number; +} + +export interface VirtualizeResult< + T extends Record = Record, +> { + /** Items to render in the current viewport */ + visibleItems: T[]; + /** Index of first visible item */ + startIndex: number; + /** Index after last visible item */ + endIndex: number; + /** Y offset for positioning visible items */ + offsetY: number; + /** Total height of all items */ + totalHeight: number; + /** Whether virtualization is active */ + isVirtualized: boolean; +} + +export function virtualize< + T extends Record = Record, +>(config: VirtualizeConfig): VirtualizeResult; From 2527b5a993963df35710ec78ea66bdaca590a3bd Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Sun, 19 Oct 2025 10:29:00 -0700 Subject: [PATCH 2/3] feat(combo-box): support virtualized items Closes #2395 --- COMPONENT_INDEX.md | 62 ++++---- docs/src/COMPONENT_API.json | 17 ++- src/ComboBox/ComboBox.svelte | 162 ++++++++++++++++---- tests/ComboBox/ComboBox.test.svelte | 2 + tests/ComboBox/ComboBox.test.ts | 228 +++++++++++++++++++++++++++- types/ComboBox/ComboBox.svelte.d.ts | 16 +- 6 files changed, 417 insertions(+), 70 deletions(-) diff --git a/COMPONENT_INDEX.md b/COMPONENT_INDEX.md index 78932fc562..485d4a2cc5 100644 --- a/COMPONENT_INDEX.md +++ b/COMPONENT_INDEX.md @@ -661,36 +661,37 @@ export type ComboBoxItem = { ### Props -| Prop name | Required | Kind | Reactive | Type | Default value | Description | -| :----------------------- | :------- | :-------------------- | :------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| listRef | No | let | Yes | null | HTMLDivElement | null | Obtain a reference to the list HTML element. | -| ref | No | let | Yes | null | HTMLInputElement | null | Obtain a reference to the input HTML element | -| open | No | let | Yes | boolean | false | Set to `true` to open the combobox menu dropdown | -| value | No | let | Yes | string | "" | Specify the selected combobox value | -| selectedId | No | let | Yes | ComboBoxItemId | undefined | Set the selected item by value id. | -| items | No | let | No | ReadonlyArray | [] | Set the combobox items. | -| itemToString | No | let | No | (item: Item) => string | -- | Override the display of a combobox item. | -| direction | No | let | No | "bottom" | "top" | "bottom" | Specify the direction of the combobox dropdown menu. | -| size | No | let | No | "sm" | "xl" | undefined | Set the size of the combobox. | -| disabled | No | let | No | boolean | false | Set to `true` to disable the combobox | -| labelText | No | let | No | string | "" | Specify the title text of the combobox | -| hideLabel | No | let | No | boolean | false | Set to `true` to visually hide the label text | -| placeholder | No | let | No | string | "" | Specify the placeholder text | -| helperText | No | let | No | string | "" | Specify the helper text | -| invalidText | No | let | No | string | "" | Specify the invalid state text | -| invalid | No | let | No | boolean | false | Set to `true` to indicate an invalid state | -| warn | No | let | No | boolean | false | Set to `true` to indicate a warning state | -| warnText | No | let | No | string | "" | Specify the warning state text | -| light | No | let | No | boolean | false | Set to `true` to enable the light variant | -| allowCustomValue | No | let | No | boolean | false | Set to `true` to allow custom values that are not in the items list.
By default, user-entered text is cleared when the combobox loses focus without selecting an item.
When enabled, custom text is preserved. | -| clearFilterOnOpen | No | let | No | boolean | false | Set to `true` to clear the input value when opening the dropdown.
This allows users to see all available items instead of only filtered results.
The original value is restored if the dropdown is closed without making a selection. | -| typeahead | No | let | No | boolean | false | Set to `true` to enable autocomplete with typeahead | -| shouldFilterItem | No | let | No | (item: Item, value: string) => boolean | -- | Determine if an item should be filtered given the current combobox value.
Will be ignored if `typeahead` is enabled. | -| translateWithId | No | let | No | (id: import("../ListBox/ListBoxMenuIcon.svelte").ListBoxMenuIconTranslationId) => string | undefined | Override the chevron icon label based on the open state.
Defaults to "Open menu" when closed and "Close menu" when open. | -| translateWithIdSelection | No | let | No | (id: "clearSelection") => string | undefined | Override the label of the clear button when the input has a selection.
Defaults to "Clear selected item" since a combo box can only have on selection. | -| id | No | let | No | string | "ccs-" + Math.random().toString(36) | Set an id for the list box component | -| name | No | let | No | string | undefined | Specify a name attribute for the input. | -| clear | No | function | No | (options?: { focus?: boolean; }) => void | -- | Clear the combo box programmatically
@example
`svelte
<ComboBox bind:this={comboBox} items={items} />
<button on:click={() => comboBox.clear()}>Clear</button>
<button on:click={() => comboBox.clear({ focus: false })}>Clear (No Focus)</button>
` | +| Prop name | Required | Kind | Reactive | Type | Default value | Description | +| :----------------------- | :------- | :-------------------- | :------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| listRef | No | let | Yes | null | HTMLDivElement | null | Obtain a reference to the list HTML element. | +| ref | No | let | Yes | null | HTMLInputElement | null | Obtain a reference to the input HTML element | +| open | No | let | Yes | boolean | false | Set to `true` to open the combobox menu dropdown | +| value | No | let | Yes | string | "" | Specify the selected combobox value | +| selectedId | No | let | Yes | ComboBoxItemId | undefined | Set the selected item by value id. | +| items | No | let | No | ReadonlyArray | [] | Set the combobox items. | +| itemToString | No | let | No | (item: Item) => string | -- | Override the display of a combobox item. | +| direction | No | let | No | "bottom" | "top" | "bottom" | Specify the direction of the combobox dropdown menu. | +| size | No | let | No | "sm" | "xl" | undefined | Set the size of the combobox. | +| disabled | No | let | No | boolean | false | Set to `true` to disable the combobox | +| labelText | No | let | No | string | "" | Specify the title text of the combobox | +| hideLabel | No | let | No | boolean | false | Set to `true` to visually hide the label text | +| placeholder | No | let | No | string | "" | Specify the placeholder text | +| helperText | No | let | No | string | "" | Specify the helper text | +| invalidText | No | let | No | string | "" | Specify the invalid state text | +| invalid | No | let | No | boolean | false | Set to `true` to indicate an invalid state | +| warn | No | let | No | boolean | false | Set to `true` to indicate a warning state | +| warnText | No | let | No | string | "" | Specify the warning state text | +| light | No | let | No | boolean | false | Set to `true` to enable the light variant | +| allowCustomValue | No | let | No | boolean | false | Set to `true` to allow custom values that are not in the items list.
By default, user-entered text is cleared when the combobox loses focus without selecting an item.
When enabled, custom text is preserved. | +| clearFilterOnOpen | No | let | No | boolean | false | Set to `true` to clear the input value when opening the dropdown.
This allows users to see all available items instead of only filtered results.
The original value is restored if the dropdown is closed without making a selection. | +| typeahead | No | let | No | boolean | false | Set to `true` to enable autocomplete with typeahead | +| shouldFilterItem | No | let | No | (item: Item, value: string) => boolean | -- | Determine if an item should be filtered given the current combobox value.
Will be ignored if `typeahead` is enabled. | +| translateWithId | No | let | No | (id: import("../ListBox/ListBoxMenuIcon.svelte").ListBoxMenuIconTranslationId) => string | undefined | Override the chevron icon label based on the open state.
Defaults to "Open menu" when closed and "Close menu" when open. | +| translateWithIdSelection | No | let | No | (id: "clearSelection") => string | undefined | Override the label of the clear button when the input has a selection.
Defaults to "Clear selected item" since a combo box can only have on selection. | +| id | No | let | No | string | "ccs-" + Math.random().toString(36) | Set an id for the list box component | +| name | No | let | No | string | undefined | Specify a name attribute for the input. | +| virtualize | No | let | No | undefined | boolean | { itemHeight?: number, containerHeight?: number, overscan?: number, threshold?: number, maxItems?: number } | undefined | Enable virtualization for large lists | +| clear | No | function | No | (options?: { focus?: boolean; }) => void | -- | Clear the combo box programmatically
@example
`svelte
<ComboBox bind:this={comboBox} items={items} />
<button on:click={() => comboBox.clear()}>Clear</button>
<button on:click={() => comboBox.clear({ focus: false })}>Clear (No Focus)</button>
` | ### Slots @@ -711,7 +712,6 @@ export type ComboBoxItem = { | focus | forwarded | -- | -- | | blur | forwarded | -- | -- | | paste | forwarded | -- | -- | -| scroll | forwarded | -- | -- | ## `ComposedModal` diff --git a/docs/src/COMPONENT_API.json b/docs/src/COMPONENT_API.json index 75e806f54a..093a2bf422 100644 --- a/docs/src/COMPONENT_API.json +++ b/docs/src/COMPONENT_API.json @@ -2158,6 +2158,18 @@ "constant": false, "reactive": true }, + { + "name": "virtualize", + "kind": "let", + "description": "Enable virtualization for large lists", + "type": "undefined | boolean | { itemHeight?: number, containerHeight?: number, overscan?: number, threshold?: number, maxItems?: number }", + "value": "undefined", + "isFunction": false, + "isFunctionDeclaration": false, + "isRequired": false, + "constant": false, + "reactive": false + }, { "name": "clear", "kind": "function", @@ -2234,11 +2246,6 @@ "type": "forwarded", "name": "paste", "element": "input" - }, - { - "type": "forwarded", - "name": "scroll", - "element": "ListBoxMenu" } ], "typedefs": [ diff --git a/src/ComboBox/ComboBox.svelte b/src/ComboBox/ComboBox.svelte index e65b109f3f..6c6f481b32 100644 --- a/src/ComboBox/ComboBox.svelte +++ b/src/ComboBox/ComboBox.svelte @@ -137,6 +137,12 @@ */ export let listRef = null; + /** + * Enable virtualization for large lists + * @type {undefined | boolean | { itemHeight?: number, containerHeight?: number, overscan?: number, threshold?: number, maxItems?: number }} + */ + export let virtualize = undefined; + import { afterUpdate, createEventDispatcher, tick } from "svelte"; import Checkmark from "../icons/Checkmark.svelte"; import WarningAltFilled from "../icons/WarningAltFilled.svelte"; @@ -146,6 +152,7 @@ import ListBoxMenuIcon from "../ListBox/ListBoxMenuIcon.svelte"; import ListBoxMenuItem from "../ListBox/ListBoxMenuItem.svelte"; import ListBoxSelection from "../ListBox/ListBoxSelection.svelte"; + import { virtualize as virtualizeUtil } from "../utils/virtualize.js"; const dispatch = createEventDispatcher(); @@ -154,6 +161,8 @@ let highlightedIndex = -1; let valueBeforeOpen = ""; let prevInputLength = 0; + let listScrollTop = 0; + let prevOpen = false; /** * @param {Item} item @@ -234,6 +243,10 @@ filteredItems = items.filter((item) => filterFn(item, value)); } else { + // Reset scroll position when menu closes + if (virtualize) { + listScrollTop = 0; + } highlightedIndex = -1; filteredItems = []; if (selectedItem) { @@ -284,6 +297,41 @@ $: highlightedId = items[highlightedIndex] ? items[highlightedIndex].id : 0; $: filteredItems = items.filter((item) => filterFn(item, value)); + $: virtualConfig = virtualize + ? { + itemHeight: 40, // TODO: adjust based on size + containerHeight: 300, + overscan: 3, + threshold: 100, + maxItems: undefined, + ...(typeof virtualize === "object" ? virtualize : {}), + } + : null; + + $: virtualData = virtualConfig + ? virtualizeUtil({ + items: filteredItems, + scrollTop: listScrollTop, + ...virtualConfig, + }) + : null; + + $: itemsToRender = virtualData?.isVirtualized + ? virtualData.visibleItems + : filteredItems; + + // Reset DOM scroll position when menu opens with virtualization + $: if (open && !prevOpen && virtualize && listRef) { + prevOpen = open; + tick().then(() => { + if (listRef) { + listRef.scrollTop = 0; + } + }); + } else { + prevOpen = open; + } + $: if (typeahead) { const showNewSuggestion = value.length > prevInputLength && filteredItems.length > 0; @@ -467,39 +515,89 @@ /> {#if open} - - {#each filteredItems as item, i (item.id)} - { - if (item.disabled) { - e.stopPropagation(); - return; - } - selectedId = item.id; - open = false; - valueBeforeOpen = ""; + { + listScrollTop = e.target.scrollTop; + }} + bind:ref={listRef} + style={virtualConfig + ? `max-height: ${virtualConfig.containerHeight}px; overflow-y: auto;` + : undefined} + > + {#if virtualData?.isVirtualized} +
+
+ {#each itemsToRender as item, i (item.id)} + {@const actualIndex = virtualData.startIndex + i} + { + if (item.disabled) { + e.stopPropagation(); + return; + } + selectedId = item.id; + open = false; + valueBeforeOpen = ""; + + if (filteredItems[actualIndex]) { + value = itemToString(filteredItems[actualIndex]); + } + }} + on:mouseenter={() => { + if (item.disabled) return; + highlightedIndex = actualIndex; + }} + > + + {itemToString(item)} + + {#if selectedItem && selectedItem.id === item.id} + + {/if} + + {/each} +
+
+ {:else} + {#each itemsToRender as item, i (item.id)} + { + if (item.disabled) { + e.stopPropagation(); + return; + } + selectedId = item.id; + open = false; + valueBeforeOpen = ""; - if (filteredItems[i]) { - value = itemToString(filteredItems[i]); - } - }} - on:mouseenter={() => { - if (item.disabled) return; - highlightedIndex = i; - }} - > - - {itemToString(item)} - - {#if selectedItem && selectedItem.id === item.id} - - {/if} - - {/each} + if (filteredItems[i]) { + value = itemToString(filteredItems[i]); + } + }} + on:mouseenter={() => { + if (item.disabled) return; + highlightedIndex = i; + }} + > + + {itemToString(item)} + + {#if selectedItem && selectedItem.id === item.id} + + {/if} +
+ {/each} + {/if}
{/if} diff --git a/tests/ComboBox/ComboBox.test.svelte b/tests/ComboBox/ComboBox.test.svelte index af3fd9e672..874d04e207 100644 --- a/tests/ComboBox/ComboBox.test.svelte +++ b/tests/ComboBox/ComboBox.test.svelte @@ -30,6 +30,7 @@ export let allowCustomValue = false; export let clearFilterOnOpen = false; export let typeahead = false; + export let virtualize: ComponentProps["virtualize"] = undefined; { console.log("select", e.detail); }} diff --git a/tests/ComboBox/ComboBox.test.ts b/tests/ComboBox/ComboBox.test.ts index 825dadfa93..fec42441a3 100644 --- a/tests/ComboBox/ComboBox.test.ts +++ b/tests/ComboBox/ComboBox.test.ts @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/svelte"; +import { render, screen, waitFor } from "@testing-library/svelte"; import type ComboBoxComponent from "carbon-components-svelte/ComboBox/ComboBox.svelte"; import type { ComponentEvents, ComponentProps } from "svelte"; import { user } from "../setup-tests"; @@ -960,4 +960,230 @@ describe("ComboBox", () => { expectTypeOf().toHaveProperty("text"); }); }); + + describe("virtualization", () => { + const createLargeItemList = (count: number) => { + return Array.from({ length: count }, (_, i) => ({ + id: String(i), + text: `Item ${i + 1}`, + price: i * 10, + })); + }; + + it("should enable virtualization for large lists", async () => { + const largeItems = createLargeItemList(500); + render(ComboBox, { + props: { + items: largeItems, + virtualize: true, + }, + }); + + await user.click(getInput()); + + const menu = screen.getAllByRole("listbox")[1]; + expect(menu).toBeVisible(); + + // With virtualization, only visible items should be rendered + // The exact number depends on containerHeight and itemHeight + const options = screen.getAllByRole("option"); + // Should render fewer items than total (virtualization is active) + expect(options.length).toBeLessThan(500); + // But should render some items (at least the visible ones) + expect(options.length).toBeGreaterThan(0); + }); + + it("should reset scroll position when menu reopens", async () => { + const largeItems = createLargeItemList(500); + const { component } = render(ComboBox, { + props: { + items: largeItems, + virtualize: true, + }, + }); + + await user.click(getInput()); + + // The ListBoxMenu itself is the scrollable container + const menu = screen.getAllByRole("listbox")[1]; + expectTypeOf(menu).toEqualTypeOf(); + expect(menu).toBeVisible(); + expect(menu.style.maxHeight).toBeTruthy(); + expect(menu.style.overflowY).toBe("auto"); + + menu.scrollTop = 1000; + await new Promise((resolve) => setTimeout(resolve, 100)); + + const scrollBeforeClose = menu.scrollTop; + expect(scrollBeforeClose).toBeGreaterThan(0); + + component.$set({ open: false }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + component.$set({ open: true }); + + await waitFor(() => { + const menuAfterReopen = screen.getAllByRole("listbox")[1]; + expectTypeOf(menuAfterReopen).toEqualTypeOf(); + expect(menuAfterReopen).toBeInTheDocument(); + return menuAfterReopen; + }); + + await waitFor(() => { + expect(screen.getByText("Item 1")).toBeInTheDocument(); + }); + }); + + it("should work with filtering when virtualized", async () => { + const largeItems = createLargeItemList(500); + render(ComboBox, { + props: { + items: largeItems, + virtualize: true, + shouldFilterItem: ( + item: { id: string; text: string }, + value: string, + ) => item.text.toLowerCase().includes(value.toLowerCase()), + }, + }); + + const input = getInput(); + await user.click(input); + await user.type(input, "Item 1"); + + // Should show filtered results + const options = screen.getAllByRole("option"); + expect(options.length).toBeGreaterThan(0); + // All visible options should match the filter + for (const option of options) { + expect(option.textContent).toMatch(/Item 1/i); + } + }); + + it("should accept virtualization configuration object", async () => { + const largeItems = createLargeItemList(500); + render(ComboBox, { + props: { + items: largeItems, + virtualize: { + itemHeight: 50, + containerHeight: 400, + overscan: 5, + threshold: 50, + maxItems: 20, + }, + }, + }); + + await user.click(getInput()); + + const menu = screen.getAllByRole("listbox")[1]; + expect(menu).toBeVisible(); + + const options = screen.getAllByRole("option"); + // With maxItems: 20, should render at most 20 items + expect(options.length).toBeLessThanOrEqual(20); + }); + + it("should not virtualize lists below threshold", async () => { + const smallItems = createLargeItemList(50); + render(ComboBox, { + props: { + items: smallItems, + virtualize: { + threshold: 100, // Threshold is 100, list has 50 items + }, + }, + }); + + await user.click(getInput()); + + const options = screen.getAllByRole("option"); + // Should render all items when below threshold + expect(options.length).toBe(50); + }); + + it("should handle virtualization with custom item height", async () => { + const largeItems = createLargeItemList(500); + render(ComboBox, { + props: { + items: largeItems, + virtualize: { + itemHeight: 60, + containerHeight: 300, + }, + }, + }); + + await user.click(getInput()); + + const menu = screen.getAllByRole("listbox")[1]; + expect(menu).toBeVisible(); + + const options = screen.getAllByRole("option"); + // Should render items based on custom itemHeight + expect(options.length).toBeGreaterThan(0); + expect(options.length).toBeLessThan(500); + }); + + it("should maintain selection when virtualized", async () => { + const largeItems = createLargeItemList(500); + render(ComboBox, { + props: { + items: largeItems, + virtualize: true, + selectedId: "250", + }, + }); + + const input = getInput(); + expect(input).toHaveValue("Item 251"); + + await user.click(input); + + // Selected item should be visible and marked as active + const selectedOption = screen.getByRole("option", { name: "Item 251" }); + expect(selectedOption).toHaveAttribute("aria-selected", "true"); + }); + + it("should handle keyboard navigation with virtualization", async () => { + const largeItems = createLargeItemList(500); + render(ComboBox, { + props: { + items: largeItems, + virtualize: true, + }, + }); + + const input = getInput(); + await user.click(input); + await user.keyboard("{ArrowDown}"); + await user.keyboard("{ArrowDown}"); + await user.keyboard("{Enter}"); + + // ArrowDown twice selects index 1, which is "Item 2" (items are 0-indexed) + expect(input).toHaveValue("Item 2"); + }); + + it("should apply max-height style when virtualized", async () => { + const largeItems = createLargeItemList(500); + render(ComboBox, { + props: { + items: largeItems, + virtualize: { + containerHeight: 400, + }, + }, + }); + + await user.click(getInput()); + + // The ListBoxMenu itself has the style applied + const menu = screen.getAllByRole("listbox")[1]; + expectTypeOf(menu).toEqualTypeOf(); + expect(menu).toBeInTheDocument(); + expect(menu.style.maxHeight).toBe("400px"); + expect(menu.style.overflowY).toBe("auto"); + }); + }); }); diff --git a/types/ComboBox/ComboBox.svelte.d.ts b/types/ComboBox/ComboBox.svelte.d.ts index 480fddde3f..2e995cd3ba 100644 --- a/types/ComboBox/ComboBox.svelte.d.ts +++ b/types/ComboBox/ComboBox.svelte.d.ts @@ -181,6 +181,21 @@ type $Props = { */ listRef?: null | HTMLDivElement; + /** + * Enable virtualization for large lists + * @default undefined + */ + virtualize?: + | undefined + | boolean + | { + itemHeight?: number; + containerHeight?: number; + overscan?: number; + threshold?: number; + maxItems?: number; + }; + [key: `data-${string}`]: any; }; @@ -200,7 +215,6 @@ export default class ComboBox< focus: WindowEventMap["focus"]; blur: WindowEventMap["blur"]; paste: WindowEventMap["paste"]; - scroll: WindowEventMap["scroll"]; }, { default: { item: Item; index: number }; labelText: Record } > { From 64f2a43a7b49dcc8184bd5ab3d4ac92c430fb354 Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Sun, 19 Oct 2025 10:29:21 -0700 Subject: [PATCH 3/3] docs(combo-box): add virtualization example --- docs/src/pages/components/ComboBox.svx | 24 +++++++++++++++ .../framed/ComboBox/VirtualizeOverscan.svelte | 22 ++++++++++++++ .../ComboBox/VirtualizeThreshold.svelte | 22 ++++++++++++++ .../ComboBox/VirtualizedComboBox.svelte | 29 +++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 docs/src/pages/framed/ComboBox/VirtualizeOverscan.svelte create mode 100644 docs/src/pages/framed/ComboBox/VirtualizeThreshold.svelte create mode 100644 docs/src/pages/framed/ComboBox/VirtualizedComboBox.svelte diff --git a/docs/src/pages/components/ComboBox.svx b/docs/src/pages/components/ComboBox.svx index f0785a67cb..cb84ffbaec 100644 --- a/docs/src/pages/components/ComboBox.svx +++ b/docs/src/pages/components/ComboBox.svx @@ -115,6 +115,30 @@ Set `allowCustomValue` to `true` to let users enter custom text that isn't in th +## Virtualized items (large lists) + +Enable virtualization for large lists to improve performance. Only visible items are rendered in the DOM. + +In the example below, 10,000 items are provided to the combobox but only 11 items (8 visible items + 3 overscan items) are rendered in the DOM. + + + +## Virtualized items (custom overscan) + +Overscanning is the process of rendering extra items above and below the viewport to ensure smooth scrolling. The default overscan value is 3. + +Specify a custom value for `overscan` to control how many extra items are rendered above/below the viewport for smoother scrolling. + + + +## Virtualized items (custom threshold) + +The threshold is the minimum number of items required before virtualization activates. The default threshold value is 100. + +Specify a custom value for `threshold` to control when virtualization activates. Below the threshold, all items are rendered normally without virtualization. + + + ## Top direction Set `direction` to `"top"` to make the dropdown menu appear above the input. diff --git a/docs/src/pages/framed/ComboBox/VirtualizeOverscan.svelte b/docs/src/pages/framed/ComboBox/VirtualizeOverscan.svelte new file mode 100644 index 0000000000..825a2ce344 --- /dev/null +++ b/docs/src/pages/framed/ComboBox/VirtualizeOverscan.svelte @@ -0,0 +1,22 @@ + + + + item.text.toLowerCase().includes(value.toLowerCase())} + bind:selectedId + bind:value +/> diff --git a/docs/src/pages/framed/ComboBox/VirtualizeThreshold.svelte b/docs/src/pages/framed/ComboBox/VirtualizeThreshold.svelte new file mode 100644 index 0000000000..bbdd916039 --- /dev/null +++ b/docs/src/pages/framed/ComboBox/VirtualizeThreshold.svelte @@ -0,0 +1,22 @@ + + + + item.text.toLowerCase().includes(value.toLowerCase())} + bind:selectedId + bind:value +/> diff --git a/docs/src/pages/framed/ComboBox/VirtualizedComboBox.svelte b/docs/src/pages/framed/ComboBox/VirtualizedComboBox.svelte new file mode 100644 index 0000000000..bcdfde4719 --- /dev/null +++ b/docs/src/pages/framed/ComboBox/VirtualizedComboBox.svelte @@ -0,0 +1,29 @@ + + + + + item.text.toLowerCase().includes(value.toLowerCase())} + bind:selectedId + bind:value + /> +
+ Selected: + {selectedItem?.text} +
+