Skip to content

Commit 5f56b37

Browse files
committed
feat(combo-box): support virtualized items
1 parent c1505b9 commit 5f56b37

File tree

6 files changed

+417
-70
lines changed

6 files changed

+417
-70
lines changed

COMPONENT_INDEX.md

Lines changed: 31 additions & 31 deletions
Large diffs are not rendered by default.

docs/src/COMPONENT_API.json

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2158,6 +2158,18 @@
21582158
"constant": false,
21592159
"reactive": true
21602160
},
2161+
{
2162+
"name": "virtualize",
2163+
"kind": "let",
2164+
"description": "Enable virtualization for large lists",
2165+
"type": "undefined | boolean | { itemHeight?: number, containerHeight?: number, overscan?: number, threshold?: number, maxItems?: number }",
2166+
"value": "undefined",
2167+
"isFunction": false,
2168+
"isFunctionDeclaration": false,
2169+
"isRequired": false,
2170+
"constant": false,
2171+
"reactive": false
2172+
},
21612173
{
21622174
"name": "clear",
21632175
"kind": "function",
@@ -2234,11 +2246,6 @@
22342246
"type": "forwarded",
22352247
"name": "paste",
22362248
"element": "input"
2237-
},
2238-
{
2239-
"type": "forwarded",
2240-
"name": "scroll",
2241-
"element": "ListBoxMenu"
22422249
}
22432250
],
22442251
"typedefs": [

src/ComboBox/ComboBox.svelte

Lines changed: 130 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@
137137
*/
138138
export let listRef = null;
139139
140+
/**
141+
* Enable virtualization for large lists
142+
* @type {undefined | boolean | { itemHeight?: number, containerHeight?: number, overscan?: number, threshold?: number, maxItems?: number }}
143+
*/
144+
export let virtualize = undefined;
145+
140146
import { afterUpdate, createEventDispatcher, tick } from "svelte";
141147
import Checkmark from "../icons/Checkmark.svelte";
142148
import WarningAltFilled from "../icons/WarningAltFilled.svelte";
@@ -146,6 +152,7 @@
146152
import ListBoxMenuIcon from "../ListBox/ListBoxMenuIcon.svelte";
147153
import ListBoxMenuItem from "../ListBox/ListBoxMenuItem.svelte";
148154
import ListBoxSelection from "../ListBox/ListBoxSelection.svelte";
155+
import { virtualize as virtualizeUtil } from "../utils/virtualize.js";
149156
150157
const dispatch = createEventDispatcher();
151158
@@ -154,6 +161,8 @@
154161
let highlightedIndex = -1;
155162
let valueBeforeOpen = "";
156163
let prevInputLength = 0;
164+
let listScrollTop = 0;
165+
let prevOpen = false;
157166
158167
/**
159168
* @param {Item} item
@@ -234,6 +243,10 @@
234243
235244
filteredItems = items.filter((item) => filterFn(item, value));
236245
} else {
246+
// Reset scroll position when menu closes
247+
if (virtualize) {
248+
listScrollTop = 0;
249+
}
237250
highlightedIndex = -1;
238251
filteredItems = [];
239252
if (selectedItem) {
@@ -284,6 +297,41 @@
284297
$: highlightedId = items[highlightedIndex] ? items[highlightedIndex].id : 0;
285298
$: filteredItems = items.filter((item) => filterFn(item, value));
286299
300+
$: virtualConfig = virtualize
301+
? {
302+
itemHeight: 40, // TODO: adjust based on size
303+
containerHeight: 300,
304+
overscan: 3,
305+
threshold: 100,
306+
maxItems: undefined,
307+
...(typeof virtualize === "object" ? virtualize : {}),
308+
}
309+
: null;
310+
311+
$: virtualData = virtualConfig
312+
? virtualizeUtil({
313+
items: filteredItems,
314+
scrollTop: listScrollTop,
315+
...virtualConfig,
316+
})
317+
: null;
318+
319+
$: itemsToRender = virtualData?.isVirtualized
320+
? virtualData.visibleItems
321+
: filteredItems;
322+
323+
// Reset DOM scroll position when menu opens with virtualization
324+
$: if (open && !prevOpen && virtualize && listRef) {
325+
prevOpen = open;
326+
tick().then(() => {
327+
if (listRef) {
328+
listRef.scrollTop = 0;
329+
}
330+
});
331+
} else {
332+
prevOpen = open;
333+
}
334+
287335
$: if (typeahead) {
288336
const showNewSuggestion =
289337
value.length > prevInputLength && filteredItems.length > 0;
@@ -467,39 +515,89 @@
467515
/>
468516
</div>
469517
{#if open}
470-
<ListBoxMenu aria-label={ariaLabel} {id} on:scroll bind:ref={listRef}>
471-
{#each filteredItems as item, i (item.id)}
472-
<ListBoxMenuItem
473-
id={item.id}
474-
active={selectedId === item.id}
475-
highlighted={highlightedIndex === i}
476-
disabled={item.disabled}
477-
on:click={(e) => {
478-
if (item.disabled) {
479-
e.stopPropagation();
480-
return;
481-
}
482-
selectedId = item.id;
483-
open = false;
484-
valueBeforeOpen = "";
518+
<ListBoxMenu
519+
aria-label={ariaLabel}
520+
{id}
521+
on:scroll={(e) => {
522+
listScrollTop = e.target.scrollTop;
523+
}}
524+
bind:ref={listRef}
525+
style={virtualConfig
526+
? `max-height: ${virtualConfig.containerHeight}px; overflow-y: auto;`
527+
: undefined}
528+
>
529+
{#if virtualData?.isVirtualized}
530+
<div style="height: {virtualData.totalHeight}px; position: relative;">
531+
<div style="transform: translateY({virtualData.offsetY}px);">
532+
{#each itemsToRender as item, i (item.id)}
533+
{@const actualIndex = virtualData.startIndex + i}
534+
<ListBoxMenuItem
535+
id={item.id}
536+
active={selectedId === item.id}
537+
highlighted={highlightedIndex === actualIndex}
538+
disabled={item.disabled}
539+
on:click={(e) => {
540+
if (item.disabled) {
541+
e.stopPropagation();
542+
return;
543+
}
544+
selectedId = item.id;
545+
open = false;
546+
valueBeforeOpen = "";
547+
548+
if (filteredItems[actualIndex]) {
549+
value = itemToString(filteredItems[actualIndex]);
550+
}
551+
}}
552+
on:mouseenter={() => {
553+
if (item.disabled) return;
554+
highlightedIndex = actualIndex;
555+
}}
556+
>
557+
<slot {item} index={actualIndex}>
558+
{itemToString(item)}
559+
</slot>
560+
{#if selectedItem && selectedItem.id === item.id}
561+
<Checkmark class="bx--list-box__menu-item__selected-icon" />
562+
{/if}
563+
</ListBoxMenuItem>
564+
{/each}
565+
</div>
566+
</div>
567+
{:else}
568+
{#each itemsToRender as item, i (item.id)}
569+
<ListBoxMenuItem
570+
id={item.id}
571+
active={selectedId === item.id}
572+
highlighted={highlightedIndex === i}
573+
disabled={item.disabled}
574+
on:click={(e) => {
575+
if (item.disabled) {
576+
e.stopPropagation();
577+
return;
578+
}
579+
selectedId = item.id;
580+
open = false;
581+
valueBeforeOpen = "";
485582
486-
if (filteredItems[i]) {
487-
value = itemToString(filteredItems[i]);
488-
}
489-
}}
490-
on:mouseenter={() => {
491-
if (item.disabled) return;
492-
highlightedIndex = i;
493-
}}
494-
>
495-
<slot {item} index={i}>
496-
{itemToString(item)}
497-
</slot>
498-
{#if selectedItem && selectedItem.id === item.id}
499-
<Checkmark class="bx--list-box__menu-item__selected-icon" />
500-
{/if}
501-
</ListBoxMenuItem>
502-
{/each}
583+
if (filteredItems[i]) {
584+
value = itemToString(filteredItems[i]);
585+
}
586+
}}
587+
on:mouseenter={() => {
588+
if (item.disabled) return;
589+
highlightedIndex = i;
590+
}}
591+
>
592+
<slot {item} index={i}>
593+
{itemToString(item)}
594+
</slot>
595+
{#if selectedItem && selectedItem.id === item.id}
596+
<Checkmark class="bx--list-box__menu-item__selected-icon" />
597+
{/if}
598+
</ListBoxMenuItem>
599+
{/each}
600+
{/if}
503601
</ListBoxMenu>
504602
{/if}
505603
</ListBox>

tests/ComboBox/ComboBox.test.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
export let allowCustomValue = false;
3131
export let clearFilterOnOpen = false;
3232
export let typeahead = false;
33+
export let virtualize: ComponentProps<ComboBox>["virtualize"] = undefined;
3334
</script>
3435

3536
<ComboBox
@@ -53,6 +54,7 @@
5354
{allowCustomValue}
5455
{clearFilterOnOpen}
5556
{typeahead}
57+
{virtualize}
5658
on:select={(e) => {
5759
console.log("select", e.detail);
5860
}}

0 commit comments

Comments
 (0)