feature/comparison-slider #19

Merged
ilia merged 129 commits from feature/comparison-slider into main 2026-02-02 09:23:46 +00:00
3 changed files with 115 additions and 5 deletions
Showing only changes of commit 3add50a190 - Show all commits

View File

@@ -10,9 +10,10 @@ import { appliedFontsManager } from '../../model';
interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleItemsChange'> { interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleItemsChange'> {
onVisibleItemsChange?: (items: T[]) => void; onVisibleItemsChange?: (items: T[]) => void;
onNearBottom?: (lastVisibleIndex: number) => void;
} }
let { items, children, onVisibleItemsChange, ...rest }: Props = $props(); let { items, children, onVisibleItemsChange, onNearBottom, ...rest }: Props = $props();
function handleInternalVisibleChange(visibleItems: T[]) { function handleInternalVisibleChange(visibleItems: T[]) {
// Auto-register fonts with the manager // Auto-register fonts with the manager
@@ -22,12 +23,18 @@ function handleInternalVisibleChange(visibleItems: T[]) {
// Forward the call to any external listener // Forward the call to any external listener
onVisibleItemsChange?.(visibleItems); onVisibleItemsChange?.(visibleItems);
} }
function handleNearBottom(lastVisibleIndex: number) {
// Forward the call to any external listener
onNearBottom?.(lastVisibleIndex);
}
</script> </script>
<VirtualList <VirtualList
{items} {items}
{...rest} {...rest}
onVisibleItemsChange={handleInternalVisibleChange} onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom}
> >
{#snippet children(scope)} {#snippet children(scope)}
{@render children(scope)} {@render children(scope)}

View File

@@ -1,6 +1,7 @@
<!-- <!--
Component: SuggestedFonts Component: SuggestedFonts
Renders a list of suggested fonts in a virtualized list to improve performance. Renders a list of suggested fonts in a virtualized list to improve performance.
Includes pagination with auto-loading when scrolling near the bottom.
--> -->
<script lang="ts"> <script lang="ts">
import { import {
@@ -8,9 +9,58 @@ import {
FontVirtualList, FontVirtualList,
unifiedFontStore, unifiedFontStore,
} from '$entities/Font'; } from '$entities/Font';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
/**
* Load more fonts by moving to the next page
*/
function loadMore() {
if (!unifiedFontStore.pagination.hasMore || unifiedFontStore.isFetching) {
return;
}
unifiedFontStore.nextPage();
}
/**
* Handle scroll near bottom - auto-load next page
*
* Triggered when the user scrolls within 5 items of the end of the list.
* Only fetches if there are more pages available and not already fetching.
*/
function handleNearBottom(lastVisibleIndex: number) {
const { hasMore, total } = unifiedFontStore.pagination;
const itemsRemaining = total - lastVisibleIndex;
// Only trigger if within 5 items of the end, more data exists, and not already fetching
if (itemsRemaining <= 5 && hasMore && !unifiedFontStore.isFetching) {
loadMore();
}
}
/**
* Calculate display range for pagination info
*/
const displayRange = $derived.by(() => {
const { offset, limit, total } = unifiedFontStore.pagination;
const loadedCount = Math.min(offset + limit, total);
return `Showing ${loadedCount} of ${total} fonts`;
});
</script> </script>
<FontVirtualList items={unifiedFontStore.fonts}> {#if unifiedFontStore.pagination.total > 0 && !unifiedFontStore.isLoading}
<div class="text-sm text-muted-foreground px-2 py-2">
{displayRange}
{#if unifiedFontStore.isFetching}
<span class="ml-2 text-xs text-muted-foreground/70">(Loading...)</span>
{/if}
</div>
{/if}
<FontVirtualList
items={unifiedFontStore.fonts}
total={unifiedFontStore.pagination.total}
onNearBottom={handleNearBottom}
>
{#snippet children({ item: font, isVisible, proximity })} {#snippet children({ item: font, isVisible, proximity })}
<FontListItem {font} {isVisible} {proximity} /> <FontListItem {font} {isVisible} {proximity} />
{/snippet} {/snippet}

View File

@@ -19,6 +19,20 @@ interface Props {
* @template T - The type of items in the list * @template T - The type of items in the list
*/ */
items: T[]; items: T[];
/**
* Total number of items (including not-yet-loaded items for pagination).
* If not provided, defaults to items.length.
*
* Use this when implementing pagination to ensure the scrollbar
* reflects the total count of items, not just the loaded ones.
*
* @example
* ```ts
* // Pagination scenario: 1920 total fonts, but only 50 loaded
* <VirtualList items={loadedFonts} total={1920}>
* ```
*/
total?: number;
/** /**
* Height for each item, either as a fixed number * Height for each item, either as a fixed number
* or a function that returns height per index. * or a function that returns height per index.
@@ -40,6 +54,24 @@ interface Props {
* @param items - Loaded items * @param items - Loaded items
*/ */
onVisibleItemsChange?: (items: T[]) => void; onVisibleItemsChange?: (items: T[]) => void;
/**
* An optional callback that will be called when user scrolls near the end of the list.
* Useful for triggering auto-pagination.
*
* The callback receives the index of the last visible item. You can use this
* to determine if you should load more data.
*
* @example
* ```ts
* onNearBottom={(lastVisibleIndex) => {
* const itemsRemaining = total - lastVisibleIndex;
* if (itemsRemaining < 5 && hasMore && !isFetching) {
* loadMore();
* }
* }}
* ```
*/
onNearBottom?: (lastVisibleIndex: number) => void;
/** /**
* Snippet for rendering individual list items. * Snippet for rendering individual list items.
* *
@@ -55,10 +87,19 @@ interface Props {
children: Snippet<[{ item: T; index: number; isVisible: boolean; proximity: number }]>; children: Snippet<[{ item: T; index: number; isVisible: boolean; proximity: number }]>;
} }
let { items, itemHeight = 80, overscan = 5, class: className, onVisibleItemsChange, children }: Props = $props(); let {
items,
total = items.length,
itemHeight = 80,
overscan = 5,
class: className,
onVisibleItemsChange,
onNearBottom,
children,
}: Props = $props();
const virtualizer = createVirtualizer(() => ({ const virtualizer = createVirtualizer(() => ({
count: items.length, count: total,
data: items, data: items,
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight, estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
overscan, overscan,
@@ -67,6 +108,16 @@ const virtualizer = createVirtualizer(() => ({
$effect(() => { $effect(() => {
const visibleItems = virtualizer.items.map(item => items[item.index]); const visibleItems = virtualizer.items.map(item => items[item.index]);
onVisibleItemsChange?.(visibleItems); onVisibleItemsChange?.(visibleItems);
// Trigger onNearBottom when user scrolls near the end (within 5 items)
if (virtualizer.items.length > 0 && onNearBottom) {
const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1];
const itemsRemaining = total - lastVisibleItem.index;
if (itemsRemaining <= 5) {
onNearBottom(lastVisibleItem.index);
}
}
}); });
</script> </script>
@@ -96,12 +147,14 @@ $effect(() => {
class="absolute top-0 left-0 w-full" class="absolute top-0 left-0 w-full"
style:transform="translateY({item.start}px)" style:transform="translateY({item.start}px)"
> >
{@render children({ {#if item.index < items.length}
{@render children({
item: items[item.index], item: items[item.index],
index: item.index, index: item.index,
isVisible: item.isVisible, isVisible: item.isVisible,
proximity: item.proximity, proximity: item.proximity,
})} })}
{/if}
</div> </div>
{/each} {/each}
</div> </div>