feature(VirtualList): remove tanstack virtual list solution, add self written one
This commit is contained in:
@@ -66,7 +66,6 @@
|
|||||||
"vitest-browser-svelte": "^2.0.1"
|
"vitest-browser-svelte": "^2.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/svelte-query": "^6.0.14",
|
"@tanstack/svelte-query": "^6.0.14"
|
||||||
"@tanstack/svelte-virtual": "^3.13.17"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fontshareStore } from '$entities/Font/model/store/fontshareStore.svelte';
|
import { fontshareStore } from '$entities/Font/model/store/fontshareStore.svelte';
|
||||||
import type { UnifiedFont } from '$entities/Font/model/types/normalize';
|
|
||||||
import {
|
import {
|
||||||
Content as ItemContent,
|
Content as ItemContent,
|
||||||
Root as ItemRoot,
|
Root as ItemRoot,
|
||||||
@@ -13,34 +12,17 @@ import { VirtualList } from '$shared/ui';
|
|||||||
* Displays a virtualized list of fonts with loading, empty, and error states.
|
* Displays a virtualized list of fonts with loading, empty, and error states.
|
||||||
* Uses unifiedFontStore from context for data, but can accept explicit fonts via props.
|
* Uses unifiedFontStore from context for data, but can accept explicit fonts via props.
|
||||||
*/
|
*/
|
||||||
interface FontListProps {
|
|
||||||
/** Font items to display (defaults to filtered fonts from store) */
|
|
||||||
fonts?: UnifiedFont[];
|
|
||||||
/** Show loading state */
|
|
||||||
loading?: boolean;
|
|
||||||
/** Show empty state when no results */
|
|
||||||
showEmpty?: boolean;
|
|
||||||
/** Custom error message to display */
|
|
||||||
errorMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
fonts,
|
|
||||||
loading,
|
|
||||||
showEmpty = true,
|
|
||||||
errorMessage,
|
|
||||||
}: FontListProps = $props();
|
|
||||||
|
|
||||||
// const fontshareStore = getFontshareContext();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each fontshareStore.fonts as font (font.id)}
|
<VirtualList items={fontshareStore.fonts} itemHeight={30}>
|
||||||
<ItemRoot>
|
{#snippet children({ item: font })}
|
||||||
<ItemContent>
|
<ItemRoot>
|
||||||
<ItemTitle>{font.name}</ItemTitle>
|
<ItemContent>
|
||||||
<span class="text-xs text-muted-foreground">
|
<ItemTitle>{font.name}</ItemTitle>
|
||||||
{font.category} • {font.provider}
|
<span class="text-xs text-muted-foreground">
|
||||||
</span>
|
{font.category} • {font.provider}
|
||||||
</ItemContent>
|
</span>
|
||||||
</ItemRoot>
|
</ItemContent>
|
||||||
{/each}
|
</ItemRoot>
|
||||||
|
{/snippet}
|
||||||
|
</VirtualList>
|
||||||
|
|||||||
@@ -1,15 +1,159 @@
|
|||||||
import {
|
import { untrack } from 'svelte';
|
||||||
createVirtualizer as coreCreateVirtualizer,
|
|
||||||
observeElementRect,
|
export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
|
||||||
} from '@tanstack/svelte-virtual';
|
// Reactive State
|
||||||
import type { VirtualItem as CoreVirtualItem } from '@tanstack/virtual-core';
|
let scrollOffset = $state(0);
|
||||||
import { get } from 'svelte/store';
|
let containerHeight = $state(0);
|
||||||
|
let measuredSizes = $state<Record<number, number>>({});
|
||||||
|
|
||||||
|
// Non-reactive ref for DOM manipulation (avoiding unnecessary state tracking)
|
||||||
|
let elementRef: HTMLElement | null = null;
|
||||||
|
|
||||||
|
// Reactive Options
|
||||||
|
const options = $derived(optionsGetter());
|
||||||
|
|
||||||
|
// Optimized Memoization (The Cache Layer)
|
||||||
|
// Only recalculates when item count or measured sizes change.
|
||||||
|
const offsets = $derived.by(() => {
|
||||||
|
const count = options.count;
|
||||||
|
const result = new Array(count);
|
||||||
|
let accumulated = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
result[i] = accumulated;
|
||||||
|
accumulated += measuredSizes[i] ?? options.estimateSize(i);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalSize = $derived(
|
||||||
|
options.count > 0
|
||||||
|
? offsets[options.count - 1]
|
||||||
|
+ (measuredSizes[options.count - 1] ?? options.estimateSize(options.count - 1))
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Visible Range Calculation
|
||||||
|
// Svelte tracks dependencies automatically here.
|
||||||
|
const items = $derived.by((): VirtualItem[] => {
|
||||||
|
const count = options.count;
|
||||||
|
if (count === 0 || containerHeight === 0) return [];
|
||||||
|
|
||||||
|
const overscan = options.overscan ?? 5;
|
||||||
|
const viewportStart = scrollOffset;
|
||||||
|
const viewportEnd = scrollOffset + containerHeight;
|
||||||
|
|
||||||
|
// Find Start (Linear Scan)
|
||||||
|
let startIdx = 0;
|
||||||
|
while (startIdx < count && offsets[startIdx + 1] < viewportStart) {
|
||||||
|
startIdx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find End
|
||||||
|
let endIdx = startIdx;
|
||||||
|
while (endIdx < count && offsets[endIdx] < viewportEnd) {
|
||||||
|
endIdx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(0, startIdx - overscan);
|
||||||
|
const end = Math.min(count, endIdx + overscan);
|
||||||
|
|
||||||
|
const result: VirtualItem[] = [];
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
const size = measuredSizes[i] ?? options.estimateSize(i);
|
||||||
|
result.push({
|
||||||
|
index: i,
|
||||||
|
start: offsets[i],
|
||||||
|
size,
|
||||||
|
end: offsets[i] + size,
|
||||||
|
key: options.getItemKey?.(i) ?? i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Svelte Actions (The DOM Interface)
|
||||||
|
function container(node: HTMLElement) {
|
||||||
|
elementRef = node;
|
||||||
|
containerHeight = node.offsetHeight;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
scrollOffset = node.scrollTop;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||||
|
if (entry) containerHeight = entry.contentRect.height;
|
||||||
|
});
|
||||||
|
|
||||||
|
node.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
resizeObserver.observe(node);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener('scroll', handleScroll);
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
elementRef = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureElement(node: HTMLElement) {
|
||||||
|
// Use a ResizeObserver on individual items for dynamic height support
|
||||||
|
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||||
|
if (entry) {
|
||||||
|
const index = parseInt(node.dataset.index || '', 10);
|
||||||
|
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
|
||||||
|
|
||||||
|
// Only update if height actually changed to prevent loops
|
||||||
|
if (!isNaN(index) && measuredSizes[index] !== height) {
|
||||||
|
measuredSizes[index] = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(node);
|
||||||
|
return {
|
||||||
|
destroy: () => resizeObserver.disconnect(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Programmatic Scroll
|
||||||
|
function scrollToIndex(index: number, align: 'start' | 'center' | 'end' | 'auto' = 'auto') {
|
||||||
|
if (!elementRef || index < 0 || index >= options.count) return;
|
||||||
|
|
||||||
|
const itemStart = offsets[index];
|
||||||
|
const itemSize = measuredSizes[index] ?? options.estimateSize(index);
|
||||||
|
let target = itemStart;
|
||||||
|
|
||||||
|
if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
|
||||||
|
if (align === 'end') target = itemStart - containerHeight + itemSize;
|
||||||
|
|
||||||
|
elementRef.scrollTo({ top: target, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get items() {
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
get totalSize() {
|
||||||
|
return totalSize;
|
||||||
|
},
|
||||||
|
container,
|
||||||
|
measureElement,
|
||||||
|
scrollToIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface VirtualItem {
|
export interface VirtualItem {
|
||||||
|
/** Index of the item in the data array */
|
||||||
index: number;
|
index: number;
|
||||||
|
/** Offset from the top of the list */
|
||||||
start: number;
|
start: number;
|
||||||
|
/** Height of the item */
|
||||||
size: number;
|
size: number;
|
||||||
|
/** End position (start + size) */
|
||||||
end: number;
|
end: number;
|
||||||
|
/** Unique key for the item (for Svelte's {#each} keying) */
|
||||||
key: string | number;
|
key: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,91 +170,4 @@ export interface VirtualizerOptions {
|
|||||||
scrollMargin?: number;
|
scrollMargin?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a reactive virtualizer using Svelte 5 runes and TanStack's core library.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const virtualizer = createVirtualizer(() => ({
|
|
||||||
* count: items.length,
|
|
||||||
* estimateSize: () => 80,
|
|
||||||
* overscan: 5,
|
|
||||||
* }));
|
|
||||||
*
|
|
||||||
* // In template:
|
|
||||||
* // <div bind:this={virtualizer.scrollElement}>
|
|
||||||
* // {#each virtualizer.items as item}
|
|
||||||
* // <div style="transform: translateY({item.start}px)">
|
|
||||||
* // {items[item.index]}
|
|
||||||
* // </div>
|
|
||||||
* // {/each}
|
|
||||||
* // </div>
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function createVirtualizer(
|
|
||||||
optionsGetter: () => VirtualizerOptions,
|
|
||||||
) {
|
|
||||||
let element = $state<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const internalStore = coreCreateVirtualizer({
|
|
||||||
get count() {
|
|
||||||
return optionsGetter().count;
|
|
||||||
},
|
|
||||||
get estimateSize() {
|
|
||||||
return optionsGetter().estimateSize;
|
|
||||||
},
|
|
||||||
get overscan() {
|
|
||||||
return optionsGetter().overscan ?? 5;
|
|
||||||
},
|
|
||||||
get scrollMargin() {
|
|
||||||
return optionsGetter().scrollMargin;
|
|
||||||
},
|
|
||||||
get getItemKey() {
|
|
||||||
return optionsGetter().getItemKey ?? (i => i);
|
|
||||||
},
|
|
||||||
getScrollElement: () => element,
|
|
||||||
observeElementRect: observeElementRect,
|
|
||||||
});
|
|
||||||
|
|
||||||
const state = $derived(get(internalStore));
|
|
||||||
|
|
||||||
const virtualItems = $derived(
|
|
||||||
state.getVirtualItems().map((item: CoreVirtualItem): VirtualItem => ({
|
|
||||||
index: item.index,
|
|
||||||
start: item.start,
|
|
||||||
size: item.size,
|
|
||||||
end: item.end,
|
|
||||||
key: typeof item.key === 'bigint' ? Number(item.key) : item.key,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
get items() {
|
|
||||||
return virtualItems;
|
|
||||||
},
|
|
||||||
|
|
||||||
get totalSize() {
|
|
||||||
return state.getTotalSize();
|
|
||||||
},
|
|
||||||
|
|
||||||
get scrollOffset() {
|
|
||||||
return state.scrollOffset ?? 0;
|
|
||||||
},
|
|
||||||
|
|
||||||
get scrollElement() {
|
|
||||||
return element;
|
|
||||||
},
|
|
||||||
set scrollElement(el) {
|
|
||||||
element = el;
|
|
||||||
},
|
|
||||||
|
|
||||||
scrollToIndex: (idx: number, opt?: { align?: 'start' | 'center' | 'end' | 'auto' }) =>
|
|
||||||
state.scrollToIndex(idx, opt),
|
|
||||||
|
|
||||||
scrollToOffset: (off: number) => state.scrollToOffset(off),
|
|
||||||
|
|
||||||
measureElement: (el: HTMLElement) => state.measureElement(el),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Virtualizer = ReturnType<typeof createVirtualizer>;
|
export type Virtualizer = ReturnType<typeof createVirtualizer>;
|
||||||
|
|||||||
@@ -55,53 +55,15 @@ interface Props {
|
|||||||
|
|
||||||
let { items, itemHeight = 80, overscan = 5, class: className, children }: Props = $props();
|
let { items, itemHeight = 80, overscan = 5, class: className, children }: Props = $props();
|
||||||
|
|
||||||
let activeIndex = $state(0);
|
const virtualizer = createVirtualizer(() => ({
|
||||||
const itemRefs = new Map<number, HTMLElement>();
|
|
||||||
|
|
||||||
const virtual = createVirtualizer(() => ({
|
|
||||||
count: items.length,
|
count: items.length,
|
||||||
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
||||||
overscan,
|
overscan,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function registerItem(node: HTMLElement, index: number) {
|
|
||||||
itemRefs.set(index, node);
|
|
||||||
return {
|
|
||||||
destroy() {
|
|
||||||
itemRefs.delete(index);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function focusItem(index: number) {
|
|
||||||
activeIndex = index;
|
|
||||||
virtual.scrollToIndex(index, { align: 'auto' });
|
|
||||||
await tick();
|
|
||||||
itemRefs.get(index)?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleKeydown(event: KeyboardEvent) {
|
|
||||||
let nextIndex = activeIndex;
|
|
||||||
if (event.key === 'ArrowDown') nextIndex++;
|
|
||||||
else if (event.key === 'ArrowUp') nextIndex--;
|
|
||||||
else if (event.key === 'Home') nextIndex = 0;
|
|
||||||
else if (event.key === 'End') nextIndex = items.length - 1;
|
|
||||||
else return;
|
|
||||||
|
|
||||||
if (nextIndex >= 0 && nextIndex < items.length) {
|
|
||||||
event.preventDefault();
|
|
||||||
await focusItem(nextIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
|
||||||
Scroll container with single tab stop pattern:
|
|
||||||
- tabindex="0" on container, tabindex="-1" on items
|
|
||||||
- Arrow keys navigate within, Tab moves out
|
|
||||||
-->
|
|
||||||
<div
|
<div
|
||||||
bind:this={virtual.scrollElement}
|
use:virtualizer.container
|
||||||
class={cn(
|
class={cn(
|
||||||
'relative overflow-auto border rounded-md bg-background',
|
'relative overflow-auto border rounded-md bg-background',
|
||||||
'outline-none focus-visible:ring-2 ring-ring ring-offset-2',
|
'outline-none focus-visible:ring-2 ring-ring ring-offset-2',
|
||||||
@@ -110,29 +72,15 @@ async function handleKeydown(event: KeyboardEvent) {
|
|||||||
)}
|
)}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onkeydown={handleKeydown}
|
|
||||||
onfocusin={(e => e.target === virtual.scrollElement && focusItem(activeIndex))}
|
|
||||||
>
|
>
|
||||||
<!-- Total scrollable height placeholder -->
|
{#each virtualizer.items as item (item.key)}
|
||||||
<div
|
<div
|
||||||
class="relative w-full"
|
use:virtualizer.measureElement
|
||||||
style:height="{virtual.totalSize}px"
|
data-index={item.index}
|
||||||
>
|
class="absolute top-0 left-0 w-full translate-y-[var(--offset)] will-change-transform"
|
||||||
{#each virtual.items as row (row.key)}
|
style:--offset="{item.start}px"
|
||||||
<!-- Individual item positioned absolutely via GPU-accelerated transform -->
|
>
|
||||||
<div
|
{@render children({ item: items[item.index], index: item.index })}
|
||||||
use:registerItem={row.index}
|
</div>
|
||||||
data-index={row.index}
|
{/each}
|
||||||
role="option"
|
|
||||||
aria-selected={activeIndex === row.index}
|
|
||||||
tabindex="-1"
|
|
||||||
onmousedown={() => (activeIndex = row.index)}
|
|
||||||
class="absolute top-0 left-0 w-full outline-none focus:bg-accent focus:text-accent-foreground"
|
|
||||||
style:height="{row.size}px"
|
|
||||||
style:transform="translateY({row.start}px)"
|
|
||||||
>
|
|
||||||
{@render children({ item: items[row.index], index: row.index })}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
19
yarn.lock
19
yarn.lock
@@ -1310,24 +1310,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@tanstack/svelte-virtual@npm:^3.13.17":
|
|
||||||
version: 3.13.17
|
|
||||||
resolution: "@tanstack/svelte-virtual@npm:3.13.17"
|
|
||||||
dependencies:
|
|
||||||
"@tanstack/virtual-core": "npm:3.13.17"
|
|
||||||
peerDependencies:
|
|
||||||
svelte: ^3.48.0 || ^4.0.0 || ^5.0.0
|
|
||||||
checksum: 10c0/8139a94d8b913c1a3aef0e7cda4cfd8451c3e46455a5bd5bae1df26ab7583bfde785ab93cacefba4f0f45f2e2cd13f43fa8cf672c45cb31d52b3232ffb37e69e
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"@tanstack/virtual-core@npm:3.13.17":
|
|
||||||
version: 3.13.17
|
|
||||||
resolution: "@tanstack/virtual-core@npm:3.13.17"
|
|
||||||
checksum: 10c0/a021795b88856eff8518137ecb85b72f875399bc234ad10bea440ecb6ab48e5e72a74c9a712649a7765f0c37bc41b88263f5104d18df8256b3d50f6a97b32c48
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"@testing-library/dom@npm:9.x.x || 10.x.x":
|
"@testing-library/dom@npm:9.x.x || 10.x.x":
|
||||||
version: 10.4.1
|
version: 10.4.1
|
||||||
resolution: "@testing-library/dom@npm:10.4.1"
|
resolution: "@testing-library/dom@npm:10.4.1"
|
||||||
@@ -2466,7 +2448,6 @@ __metadata:
|
|||||||
"@sveltejs/vite-plugin-svelte": "npm:^6.2.1"
|
"@sveltejs/vite-plugin-svelte": "npm:^6.2.1"
|
||||||
"@tailwindcss/vite": "npm:^4.1.18"
|
"@tailwindcss/vite": "npm:^4.1.18"
|
||||||
"@tanstack/svelte-query": "npm:^6.0.14"
|
"@tanstack/svelte-query": "npm:^6.0.14"
|
||||||
"@tanstack/svelte-virtual": "npm:^3.13.17"
|
|
||||||
"@testing-library/jest-dom": "npm:^6.9.1"
|
"@testing-library/jest-dom": "npm:^6.9.1"
|
||||||
"@testing-library/svelte": "npm:^5.3.1"
|
"@testing-library/svelte": "npm:^5.3.1"
|
||||||
"@tsconfig/svelte": "npm:^5.0.6"
|
"@tsconfig/svelte": "npm:^5.0.6"
|
||||||
|
|||||||
Reference in New Issue
Block a user