|
137 | 137 | */ |
138 | 138 | export let listRef = null; |
139 | 139 |
|
| 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 | +
|
140 | 146 | import { afterUpdate, createEventDispatcher, tick } from "svelte"; |
141 | 147 | import Checkmark from "../icons/Checkmark.svelte"; |
142 | 148 | import WarningAltFilled from "../icons/WarningAltFilled.svelte"; |
|
146 | 152 | import ListBoxMenuIcon from "../ListBox/ListBoxMenuIcon.svelte"; |
147 | 153 | import ListBoxMenuItem from "../ListBox/ListBoxMenuItem.svelte"; |
148 | 154 | import ListBoxSelection from "../ListBox/ListBoxSelection.svelte"; |
| 155 | + import { virtualize as virtualizeUtil } from "../utils/virtualize.js"; |
149 | 156 |
|
150 | 157 | const dispatch = createEventDispatcher(); |
151 | 158 |
|
|
154 | 161 | let highlightedIndex = -1; |
155 | 162 | let valueBeforeOpen = ""; |
156 | 163 | let prevInputLength = 0; |
| 164 | + let listScrollTop = 0; |
| 165 | + let prevOpen = false; |
157 | 166 |
|
158 | 167 | /** |
159 | 168 | * @param {Item} item |
|
234 | 243 |
|
235 | 244 | filteredItems = items.filter((item) => filterFn(item, value)); |
236 | 245 | } else { |
| 246 | + // Reset scroll position when menu closes |
| 247 | + if (virtualize) { |
| 248 | + listScrollTop = 0; |
| 249 | + } |
237 | 250 | highlightedIndex = -1; |
238 | 251 | filteredItems = []; |
239 | 252 | if (selectedItem) { |
|
284 | 297 | $: highlightedId = items[highlightedIndex] ? items[highlightedIndex].id : 0; |
285 | 298 | $: filteredItems = items.filter((item) => filterFn(item, value)); |
286 | 299 |
|
| 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 | +
|
287 | 335 | $: if (typeahead) { |
288 | 336 | const showNewSuggestion = |
289 | 337 | value.length > prevInputLength && filteredItems.length > 0; |
|
467 | 515 | /> |
468 | 516 | </div> |
469 | 517 | {#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 = ""; |
485 | 582 |
|
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} |
503 | 601 | </ListBoxMenu> |
504 | 602 | {/if} |
505 | 603 | </ListBox> |
|
0 commit comments