feature/fetch-fonts #14
@@ -1,177 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Generic virtualized list component optimized for smooth scrolling with
|
||||
* large datasets. Uses TanStack Virtual to render only visible items.
|
||||
*
|
||||
* Key optimizations:
|
||||
* - Renders only visible items (50-100 max regardless of total count)
|
||||
* - Maintains 60FPS scrolling with 10,000+ items
|
||||
* - Minimal memory usage
|
||||
* - Smooth scrolling without jank
|
||||
*
|
||||
* Accessibility:
|
||||
* - ARIA roles for virtual list
|
||||
* - Keyboard navigation support
|
||||
* - Focus management
|
||||
* - Screen reader support
|
||||
*/
|
||||
|
||||
import { createVirtualizerStore } from '$shared/store/createVirtualizerStore';
|
||||
|
||||
/**
|
||||
* Props for VirtualList
|
||||
*/
|
||||
interface VirtualListProps<T> {
|
||||
/** Items to display in the virtual list */
|
||||
items: T[];
|
||||
/** Fixed height for each item (in pixels) */
|
||||
itemHeight?: number | ((index: number) => number);
|
||||
/** Number of items to render beyond viewport */
|
||||
overscan?: number;
|
||||
/** Height of the list container (Tailwind class, e.g., "h-96", "h-[500px]") */
|
||||
height?: string;
|
||||
/** Scroll offset threshold for triggering update (in pixels) */
|
||||
scrollMargin?: number;
|
||||
/** CSS class name for the scroll container */
|
||||
class?: string;
|
||||
/** Function to get stable key for each item */
|
||||
getItemKey?: (item: T, index: number) => string | number;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
itemHeight: rawItemHeight = 80,
|
||||
overscan = 5,
|
||||
scrollMargin,
|
||||
height = 'h-96',
|
||||
class: className = '',
|
||||
getItemKey: rawGetItemKey,
|
||||
}: VirtualListProps<any> = $props();
|
||||
|
||||
// Reactive state for items
|
||||
const currentItems = $derived(items);
|
||||
|
||||
// Create virtualizer store
|
||||
const virtualizer = createVirtualizerStore({
|
||||
get count() {
|
||||
return currentItems.length;
|
||||
},
|
||||
estimateSize: typeof rawItemHeight === 'function'
|
||||
? (index: number) => (rawItemHeight as (index: number) => number)(index)
|
||||
: () => rawItemHeight,
|
||||
get overscan() {
|
||||
return overscan;
|
||||
},
|
||||
get scrollMargin() {
|
||||
return scrollMargin;
|
||||
},
|
||||
getItemKey: rawGetItemKey
|
||||
? (index: number) => {
|
||||
const item = currentItems[index];
|
||||
if (!item) return index;
|
||||
return rawGetItemKey(item, index);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Reactive virtual items and total size using store subscription
|
||||
let virtualItems: Array<{
|
||||
index: number;
|
||||
start: number;
|
||||
size: number;
|
||||
key: string | number;
|
||||
}> = $state([]);
|
||||
let totalSize = $state(0);
|
||||
|
||||
// Subscribe to store updates
|
||||
$effect(() => {
|
||||
const unsubscribe1 = virtualizer.virtualItems.subscribe(
|
||||
(items: Array<{ index: number; start: number; size: number; key: string | number }>) => {
|
||||
virtualItems = items;
|
||||
},
|
||||
);
|
||||
const unsubscribe2 = virtualizer.totalSize.subscribe((size: number) => {
|
||||
totalSize = size;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe1();
|
||||
unsubscribe2();
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation
|
||||
*/
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
const items = document.querySelectorAll('[data-index]');
|
||||
if (!items.length) return;
|
||||
|
||||
const currentIndex = Array.from(items).findIndex(el => el === document.activeElement);
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
const nextIndex = Math.min(currentIndex + 1, items.length - 1);
|
||||
(items[nextIndex] as HTMLElement).focus();
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
const prevIndex = Math.max(currentIndex - 1, 0);
|
||||
(items[prevIndex] as HTMLElement).focus();
|
||||
break;
|
||||
}
|
||||
case 'PageDown': {
|
||||
event.preventDefault();
|
||||
const nextIndex = Math.min(currentIndex + 10, items.length - 1);
|
||||
(items[nextIndex] as HTMLElement).focus();
|
||||
break;
|
||||
}
|
||||
case 'PageUp': {
|
||||
event.preventDefault();
|
||||
const prevIndex = Math.max(currentIndex - 10, 0);
|
||||
(items[prevIndex] as HTMLElement).focus();
|
||||
break;
|
||||
}
|
||||
case 'Home': {
|
||||
event.preventDefault();
|
||||
(items[0] as HTMLElement).focus();
|
||||
break;
|
||||
}
|
||||
case 'End': {
|
||||
event.preventDefault();
|
||||
(items[items.length - 1] as HTMLElement).focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Scroll container with ARIA role for accessibility -->
|
||||
<div
|
||||
bind:this={virtualizer.scrollElement}
|
||||
class="overflow-auto {height} {className}"
|
||||
role="listbox"
|
||||
aria-label="Virtual list"
|
||||
tabindex="0"
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<!-- Virtual items container -->
|
||||
<div style="height: {totalSize}px; position: relative;">
|
||||
{#each virtualItems as item (item.key)}
|
||||
<div
|
||||
data-index={item.index}
|
||||
style="position: absolute;
|
||||
top: {item.start}px;
|
||||
height: {item.size}px;
|
||||
width: 100%;"
|
||||
role="option"
|
||||
aria-selected="false"
|
||||
tabindex="0"
|
||||
>
|
||||
<slot item={currentItems[item.index]} index={item.index} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
132
src/shared/ui/VirtualList/VirtualList.svelte
Normal file
132
src/shared/ui/VirtualList/VirtualList.svelte
Normal file
@@ -0,0 +1,132 @@
|
||||
<!--
|
||||
Component: VirtualList
|
||||
|
||||
High-performance virtualized list for large datasets with:
|
||||
- Virtual scrolling (only renders visible items + overscan)
|
||||
- Keyboard navigation (ArrowUp/Down, Home, End)
|
||||
- Fixed or dynamic item heights
|
||||
- ARIA listbox/option pattern with single tab stop
|
||||
-->
|
||||
<script lang="ts" generics="T">
|
||||
import { createVirtualizer } from '$shared/lib/utils';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
type Snippet,
|
||||
tick,
|
||||
} from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Array of items to render in the virtual list.
|
||||
*
|
||||
* @template T - The type of items in the list
|
||||
*/
|
||||
items: T[];
|
||||
/**
|
||||
* Height for each item, either as a fixed number
|
||||
* or a function that returns height per index.
|
||||
* @default 80
|
||||
*/
|
||||
itemHeight?: number | ((index: number) => number);
|
||||
/**
|
||||
* Optional CSS class string for styling the container
|
||||
* (follows shadcn convention for className prop)
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* Snippet for rendering individual list items.
|
||||
*
|
||||
* The snippet receives an object containing:
|
||||
* - `item`: The item from the items array (type T)
|
||||
* - `index`: The current item's index in the array
|
||||
*
|
||||
* This pattern provides type safety and flexibility for
|
||||
* rendering different item types without prop drilling.
|
||||
*
|
||||
* @template T - The type of items in the list
|
||||
*/
|
||||
children: Snippet<[{ item: T; index: number }]>;
|
||||
}
|
||||
|
||||
let { items, itemHeight = 80, class: className, children }: Props = $props();
|
||||
|
||||
let activeIndex = $state(0);
|
||||
const itemRefs = new Map<number, HTMLElement>();
|
||||
|
||||
const virtual = createVirtualizer(() => ({
|
||||
count: items.length,
|
||||
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
||||
}));
|
||||
|
||||
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>
|
||||
|
||||
<!--
|
||||
Scroll container with single tab stop pattern:
|
||||
- tabindex="0" on container, tabindex="-1" on items
|
||||
- Arrow keys navigate within, Tab moves out
|
||||
-->
|
||||
<div
|
||||
bind:this={virtual.scrollElement}
|
||||
class={cn(
|
||||
'relative overflow-auto border rounded-md bg-background',
|
||||
'outline-none focus-visible:ring-2 ring-ring ring-offset-2',
|
||||
'h-full w-full',
|
||||
className,
|
||||
)}
|
||||
role="listbox"
|
||||
tabindex="0"
|
||||
onkeydown={handleKeydown}
|
||||
onfocusin={(e => e.target === virtual.scrollElement && focusItem(activeIndex))}
|
||||
>
|
||||
<!-- Total scrollable height placeholder -->
|
||||
<div
|
||||
class="relative w-full"
|
||||
style:height="{virtual.totalSize}px"
|
||||
>
|
||||
{#each virtual.items as row (row.key)}
|
||||
<!-- Individual item positioned absolutely via GPU-accelerated transform -->
|
||||
<div
|
||||
use:registerItem={row.index}
|
||||
data-index={row.index}
|
||||
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>
|
||||
@@ -1,277 +0,0 @@
|
||||
# Virtualization - Store Pattern Implementation
|
||||
|
||||
This folder contains the virtualization layer for smooth 60FPS scrolling with large font collections.
|
||||
|
||||
**Updated:** Now uses Svelte 5 rune-based store pattern instead of React-inspired hooks.
|
||||
|
||||
## Files
|
||||
|
||||
### Store
|
||||
|
||||
- **createVirtualizerStore.ts**: Svelte 5 rune-based store for virtualized lists
|
||||
|
||||
### Component
|
||||
|
||||
- **VirtualList.svelte** (moved to `shared/ui/`): Generic virtualized list component
|
||||
|
||||
## Why Store Pattern?
|
||||
|
||||
The store pattern is more idiomatic for Svelte than React-inspired hooks:
|
||||
|
||||
1. **More Svelte-native**: Stores are core to Svelte, hooks are React-specific
|
||||
2. **Better reactivity**: Stores auto-derive values using `$derived`, hooks need manual updates
|
||||
3. **Consistent with project patterns**: Matches `createFilterStore` and `createControlStore`
|
||||
4. **More extensible**: Easy to add store methods and computed values
|
||||
5. **Type-safe**: Full TypeScript generics support
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage with Store
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { createVirtualizerStore } from '$shared/store';
|
||||
|
||||
const fonts = $state<UnifiedFont[]>(fontData);
|
||||
|
||||
const virtualizer = createVirtualizerStore({
|
||||
count: fonts.length,
|
||||
estimateSize: () => 80,
|
||||
overscan: 5,
|
||||
getItemKey: index => fonts[index].id,
|
||||
});
|
||||
|
||||
const virtualItems = $derived(() => virtualizer.virtualItems);
|
||||
const totalSize = $derived(() => virtualizer.totalSize);
|
||||
</script>
|
||||
|
||||
<div bind:this={virtualizer.scrollElement} class="h-96 overflow-auto">
|
||||
<div style="height: {totalSize}px; position: relative;">
|
||||
{#each virtualItems as item (item.key)}
|
||||
<div
|
||||
style="position: absolute; top: {item.start}px; height: {item.size}px; width: 100%;"
|
||||
role="option"
|
||||
aria-selected="false"
|
||||
>
|
||||
<FontListItem font={fonts[item.index]} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Using VirtualList Component
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { UnifiedFont } from '$entities/Font';
|
||||
import { FontListItem } from '$entities/Font/ui';
|
||||
import { VirtualList } from '$shared/ui';
|
||||
|
||||
const fonts = $state<UnifiedFont[]>(fontData);
|
||||
</script>
|
||||
|
||||
<VirtualList
|
||||
items={fonts}
|
||||
itemHeight={80}
|
||||
height="h-96"
|
||||
let:item
|
||||
let:index
|
||||
>
|
||||
<FontListItem {item} {index} />
|
||||
</VirtualList>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### createVirtualizerStore
|
||||
|
||||
```typescript
|
||||
function createVirtualizerStore(options: VirtualizerOptions): VirtualizerStore;
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
- `count`: Number of items (required)
|
||||
- `estimateSize`: Function returning estimated height for each item (required)
|
||||
- `overscan`: Number of items to render beyond viewport (default: 5)
|
||||
- `getItemKey`: Function to get stable key for each item
|
||||
- `scrollMargin`: Scroll offset threshold (in pixels)
|
||||
|
||||
**Returns:**
|
||||
|
||||
- `virtualItems`: Array of visible virtual items (reactive getter)
|
||||
- `totalSize`: Total height of all items (reactive getter)
|
||||
- `scrollOffset`: Current scroll offset (reactive getter)
|
||||
- `scrollToIndex`: Scroll to specific item index
|
||||
- `scrollToOffset`: Scroll to specific pixel offset
|
||||
- `measureElement`: Manually measure item element
|
||||
- `scrollElement`: Reference to scroll element (bindable)
|
||||
|
||||
### VirtualList Component Props
|
||||
|
||||
```typescript
|
||||
interface VirtualListProps<T> {
|
||||
items: T[]; // Items to virtualize
|
||||
itemHeight?: number | ((index: number) => number; // Item height (default: 80)
|
||||
overscan?: number; // Overscan items (default: 5)
|
||||
height?: string; // Container height class (default: "h-96")
|
||||
scrollMargin?: number; // Scroll margin
|
||||
class?: string; // CSS class name
|
||||
getItemKey?: (item: T, index: number) => string | number; // Key function
|
||||
}
|
||||
```
|
||||
|
||||
**Slots:**
|
||||
|
||||
- `let:item`: Current item
|
||||
- `let:index`: Current item index
|
||||
|
||||
## Key Features
|
||||
|
||||
- Renders only visible items (50-100 max) regardless of total count
|
||||
- Maintains 60FPS scrolling with 10,000+ items
|
||||
- Minimal memory usage
|
||||
- Smooth scrolling without jank
|
||||
- ARIA roles for accessibility
|
||||
- Keyboard navigation support
|
||||
- Customizable overscan for smoother scrolling
|
||||
- Stable keys for efficient re-rendering
|
||||
- Responsive height using Tailwind CSS classes
|
||||
- No pixel-based styling in `<style>` tags
|
||||
|
||||
## Styling
|
||||
|
||||
The component uses Tailwind CSS for all styling:
|
||||
|
||||
- **Height**: Use Tailwind classes like `h-96`, `h-[500px]`, `h-[calc(100vh-200px)]`
|
||||
- **Responsive**: Use responsive classes like `h-96 md:h-[700px]`
|
||||
- **Custom**: Pass custom CSS classes via `class` prop
|
||||
|
||||
No pixel values in `<style>` tags - all styling is done through Tailwind utility classes or inline styles for dynamic positioning (which is required for virtualization).
|
||||
|
||||
## Performance
|
||||
|
||||
The virtualization ensures:
|
||||
|
||||
- **Minimal DOM nodes**: Only visible items are rendered
|
||||
- **Smooth scrolling**: Overscan reduces blank space during fast scrolling
|
||||
- **Efficient updates**: TanStack Virtual optimizes item updates
|
||||
- **Memory efficient**: Constant memory usage regardless of dataset size
|
||||
|
||||
## Testing
|
||||
|
||||
Run unit tests:
|
||||
|
||||
```bash
|
||||
yarn test:unit src/shared/store/createVirtualizerStore.test.ts
|
||||
```
|
||||
|
||||
Run E2E tests (with component):
|
||||
|
||||
```bash
|
||||
yarn test:e2e
|
||||
```
|
||||
|
||||
## Migration from Hook Pattern
|
||||
|
||||
**Old (hook):**
|
||||
|
||||
```svelte
|
||||
const { virtualItems, totalSize } = useVirtualList({
|
||||
items: fonts,
|
||||
scrollElement,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
```
|
||||
|
||||
**New (store):**
|
||||
|
||||
```svelte
|
||||
const virtualizer = createVirtualizerStore({
|
||||
count: fonts.length,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
const virtualItems = $derived(() => virtualizer.virtualItems);
|
||||
const totalSize = $derived(() => virtualizer.totalSize);
|
||||
```
|
||||
|
||||
Key differences:
|
||||
|
||||
1. Use `count` instead of `items` array
|
||||
2. Store created once, reactive values accessed via getters
|
||||
3. Bind `scrollElement` property instead of passing in options
|
||||
4. Use `$derived` for reactive values in Svelte 5
|
||||
|
||||
## Breaking Changes from Phase 2
|
||||
|
||||
1. **Component renamed**: `FontVirtualList` → `VirtualList`
|
||||
2. **Component moved**: `shared/virtual/` → `shared/ui/`
|
||||
3. **Hook removed**: `useVirtualList` replaced with `createVirtualizerStore`
|
||||
4. **Props changed**: `items` prop (was `{fonts}` shorthand)
|
||||
5. **Styling**: Removed pixel values from `<style>` tags, use Tailwind classes
|
||||
|
||||
## Examples
|
||||
|
||||
### Dynamic Item Height
|
||||
|
||||
```svelte
|
||||
<VirtualList
|
||||
{fonts}
|
||||
itemHeight={(index => fonts[index].isFeatured ? 120 : 80)}
|
||||
height="h-96"
|
||||
let:item
|
||||
>
|
||||
<FontListItem font={item} />
|
||||
</VirtualList>
|
||||
```
|
||||
|
||||
### Custom Item Keys
|
||||
|
||||
```svelte
|
||||
<VirtualList
|
||||
{fonts}
|
||||
itemHeight={80}
|
||||
getItemKey={(font => font.id)}
|
||||
let:item
|
||||
>
|
||||
<FontListItem {item} />
|
||||
</VirtualList>
|
||||
```
|
||||
|
||||
### Responsive Height
|
||||
|
||||
```svelte
|
||||
<VirtualList
|
||||
{fonts}
|
||||
itemHeight={80}
|
||||
height="h-[500px] md:h-[700px] lg:h-[800px]"
|
||||
let:item
|
||||
>
|
||||
<FontListItem {item} />
|
||||
</VirtualList>
|
||||
```
|
||||
|
||||
### Scroll Control
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
const virtualizer = createVirtualizerStore({
|
||||
count: fonts.length,
|
||||
estimateSize: () => 80,
|
||||
});
|
||||
|
||||
function scrollToTop() {
|
||||
virtualizer.scrollToIndex(0);
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
virtualizer.scrollToIndex(fonts.length - 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<button on:click={scrollToTop}>Top</button>
|
||||
<button on:click={scrollToBottom}>Bottom</button>
|
||||
</div>
|
||||
```
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* SHARED VIRTUALIZATION LAYER - MIGRATION GUIDE
|
||||
* ============================================================================
|
||||
*
|
||||
* The virtualization API has been refactored to use Svelte 5 store pattern.
|
||||
*
|
||||
* Migration:
|
||||
* - Component moved: src/shared/virtual/FontVirtualList.svelte → src/shared/ui/VirtualList.svelte
|
||||
* - Hook removed: src/shared/virtual/useVirtualList.ts → src/shared/store/createVirtualizerStore.ts
|
||||
* - Pattern changed: Hook pattern → Store pattern (more Svelte-idiomatic)
|
||||
*
|
||||
* New Imports:
|
||||
* ```ts
|
||||
* import { VirtualList } from '$shared/ui';
|
||||
* import { createVirtualizerStore } from '$shared/store';
|
||||
* ```
|
||||
*
|
||||
* Old Imports (deprecated):
|
||||
* ```ts
|
||||
* import { useVirtualList, FontVirtualList } from '$shared/virtual';
|
||||
* ```
|
||||
*
|
||||
* See src/shared/virtual/README.md for detailed usage examples and API documentation.
|
||||
*/
|
||||
|
||||
// This file serves as migration guide - no exports needed
|
||||
export {};
|
||||
Reference in New Issue
Block a user