Skip to content
Draft
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
62 changes: 31 additions & 31 deletions COMPONENT_INDEX.md

Large diffs are not rendered by default.

17 changes: 12 additions & 5 deletions docs/src/COMPONENT_API.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -2234,11 +2246,6 @@
"type": "forwarded",
"name": "paste",
"element": "input"
},
{
"type": "forwarded",
"name": "scroll",
"element": "ListBoxMenu"
}
],
"typedefs": [
Expand Down
24 changes: 24 additions & 0 deletions docs/src/pages/components/ComboBox.svx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,30 @@ Set `allowCustomValue` to `true` to let users enter custom text that isn't in th

<FileSource src="/framed/ComboBox/AllowCustomValue" />

## 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.

<FileSource src="/framed/ComboBox/VirtualizedComboBox" />

## 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.

<FileSource src="/framed/ComboBox/VirtualizeOverscan" />

## 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.

<FileSource src="/framed/ComboBox/VirtualizeThreshold" />

## Top direction

Set `direction` to `"top"` to make the dropdown menu appear above the input.
Expand Down
22 changes: 22 additions & 0 deletions docs/src/pages/framed/ComboBox/VirtualizeOverscan.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script>
import { ComboBox } from "carbon-components-svelte";

const items = Array.from({ length: 10_000 }, (_, i) => ({
id: i,
text: "Item " + (i + 1),
}));

let value = "";
let selectedId = undefined;
</script>

<ComboBox
virtualize={{ overscan: 100 }}
labelText="High overscan (overscan: 100)"
placeholder="Filter..."
{items}
shouldFilterItem={(item, value) =>
item.text.toLowerCase().includes(value.toLowerCase())}
bind:selectedId
bind:value
/>
22 changes: 22 additions & 0 deletions docs/src/pages/framed/ComboBox/VirtualizeThreshold.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script>
import { ComboBox } from "carbon-components-svelte";

const items = Array.from({ length: 100 }, (_, i) => ({
id: i,
text: "Item " + (i + 1),
}));

let value = "";
let selectedId = undefined;
</script>

<ComboBox
virtualize={{ threshold: 200 }}
labelText="Custom threshold (threshold: 200)"
placeholder="Filter..."
{items}
shouldFilterItem={(item, value) =>
item.text.toLowerCase().includes(value.toLowerCase())}
bind:selectedId
bind:value
/>
29 changes: 29 additions & 0 deletions docs/src/pages/framed/ComboBox/VirtualizedComboBox.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script>
import { ComboBox, Stack } from "carbon-components-svelte";

const items = Array.from({ length: 10_000 }, (_, i) => ({
id: i,
text: "Item " + (i + 1),
}));

let value = "";
let selectedId = undefined;
$: selectedItem = items.find((item) => item.id === selectedId);
</script>

<Stack gap={5}>
<ComboBox
virtualize={true}
labelText="Virtualized ComboBox (10,000 items)"
placeholder="Filter..."
{items}
shouldFilterItem={(item, value) =>
item.text.toLowerCase().includes(value.toLowerCase())}
bind:selectedId
bind:value
/>
<div>
<strong>Selected:</strong>
{selectedItem?.text}
</div>
</Stack>
162 changes: 130 additions & 32 deletions src/ComboBox/ComboBox.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();

Expand All @@ -154,6 +161,8 @@
let highlightedIndex = -1;
let valueBeforeOpen = "";
let prevInputLength = 0;
let listScrollTop = 0;
let prevOpen = false;

/**
* @param {Item} item
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -467,39 +515,89 @@
/>
</div>
{#if open}
<ListBoxMenu aria-label={ariaLabel} {id} on:scroll bind:ref={listRef}>
{#each filteredItems as item, i (item.id)}
<ListBoxMenuItem
id={item.id}
active={selectedId === item.id}
highlighted={highlightedIndex === i}
disabled={item.disabled}
on:click={(e) => {
if (item.disabled) {
e.stopPropagation();
return;
}
selectedId = item.id;
open = false;
valueBeforeOpen = "";
<ListBoxMenu
aria-label={ariaLabel}
{id}
on:scroll={(e) => {
listScrollTop = e.target.scrollTop;
}}
bind:ref={listRef}
style={virtualConfig
? `max-height: ${virtualConfig.containerHeight}px; overflow-y: auto;`
: undefined}
>
{#if virtualData?.isVirtualized}
<div style="height: {virtualData.totalHeight}px; position: relative;">
<div style="transform: translateY({virtualData.offsetY}px);">
{#each itemsToRender as item, i (item.id)}
{@const actualIndex = virtualData.startIndex + i}
<ListBoxMenuItem
id={item.id}
active={selectedId === item.id}
highlighted={highlightedIndex === actualIndex}
disabled={item.disabled}
on:click={(e) => {
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;
}}
>
<slot {item} index={actualIndex}>
{itemToString(item)}
</slot>
{#if selectedItem && selectedItem.id === item.id}
<Checkmark class="bx--list-box__menu-item__selected-icon" />
{/if}
</ListBoxMenuItem>
{/each}
</div>
</div>
{:else}
{#each itemsToRender as item, i (item.id)}
<ListBoxMenuItem
id={item.id}
active={selectedId === item.id}
highlighted={highlightedIndex === i}
disabled={item.disabled}
on:click={(e) => {
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;
}}
>
<slot {item} index={i}>
{itemToString(item)}
</slot>
{#if selectedItem && selectedItem.id === item.id}
<Checkmark class="bx--list-box__menu-item__selected-icon" />
{/if}
</ListBoxMenuItem>
{/each}
if (filteredItems[i]) {
value = itemToString(filteredItems[i]);
}
}}
on:mouseenter={() => {
if (item.disabled) return;
highlightedIndex = i;
}}
>
<slot {item} index={i}>
{itemToString(item)}
</slot>
{#if selectedItem && selectedItem.id === item.id}
<Checkmark class="bx--list-box__menu-item__selected-icon" />
{/if}
</ListBoxMenuItem>
{/each}
{/if}
</ListBoxMenu>
{/if}
</ListBox>
Expand Down
39 changes: 39 additions & 0 deletions src/utils/virtualize.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export interface VirtualizeConfig<
T extends Record<string, unknown> = Record<string, unknown>,
> {
/** 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<string, unknown> = Record<string, unknown>,
> {
/** 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<string, unknown> = Record<string, unknown>,
>(config: VirtualizeConfig<T>): VirtualizeResult<T>;
Loading