Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/svelte/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "@scrolloop/svelte",
"version": "0.1.0",
"description": "Svelte 5 adapter for @scrolloop/core",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"sideEffects": false,
"files": [
"dist",
"src"
],
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
},
"peerDependencies": {
"svelte": ">=5.0.0"
},
"dependencies": {
"@scrolloop/core": "workspace:*",
"@scrolloop/shared": "workspace:*"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0",
"typescript": "^5.0.0",
"vite": "^5.0.0",
"vite-plugin-dts": "^4.0.0"
},
"license": "MIT"
}
102 changes: 102 additions & 0 deletions packages/svelte/src/InfiniteList.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<script lang="ts" generics="T">
import { onMount } from "svelte";
import type { Snippet } from "svelte";
import { createInfinitePages } from "./stores/createInfinitePages";
import VirtualList from "./VirtualList.svelte";
import type { InfiniteSourceOptions } from "@scrolloop/shared";

interface Props {
fetchPage: InfiniteSourceOptions<T>["fetchPage"];
pageSize?: number;
initialPage?: number;
itemSize: number;
height?: number;
overscan?: number;
children: Snippet<[index: number, item: T | undefined, style: Record<string, string>]>;
error?: Snippet<[err: Error, retry: () => void]>;
loading?: Snippet;
empty?: Snippet;
}

let {
fetchPage,
pageSize = 20,
initialPage = 0,
itemSize,
height = 400,
overscan,
children,
error: errorSnippet,
loading: loadingSnippet,
empty: emptySnippet,
}: Props = $props();

const source = createInfinitePages<T>({ fetchPage, pageSize, initialPage });

const allItems = $derived($source.allItems);
Comment thread
zaewc marked this conversation as resolved.
Outdated
const loadingPages = $derived($source.loadingPages);
const errorState = $derived($source.error);
const hasMore = $derived($source.hasMore);

const effectiveOverscan = $derived(overscan ?? Math.max(20, pageSize * 2));

onMount(() => {
const needed = Math.ceil(height / itemSize) + effectiveOverscan * 2;
const pagesToLoad = Math.ceil(needed / pageSize);
for (let p = 0; p < pagesToLoad; p++) {
source.loadPage(p);
}
});

function handleRangeChange(range: { startIndex: number; endIndex: number }) {
const ps = (range.startIndex / pageSize) | 0;
const pe = ((range.endIndex / pageSize) | 0) + 1;
for (let p = ps; p <= pe; p++) {
source.loadPage(p);
}
}
Comment thread
zaewc marked this conversation as resolved.
</script>

{#if errorState && !allItems.length}
{#if errorSnippet}
{@render errorSnippet(errorState, source.retry)}
{:else}
<div style:height="{height}px" style:display="flex" style:align-items="center" style:justify-content="center">
<div style:text-align="center">
<p>Error.</p>
<p style:color="#666" style:font-size="0.9em">{errorState.message}</p>
<button onclick={source.retry} style:margin-top="8px" style:padding="4px 12px" style:cursor="pointer">
Retry
</button>
</div>
</div>
Comment thread
zaewc marked this conversation as resolved.
Outdated
{/if}
{:else if !allItems.length && loadingPages.size > 0}
{#if loadingSnippet}
<div style:height="{height}px">{@render loadingSnippet()}</div>
{:else}
<div style:height="{height}px" style:display="flex" style:align-items="center" style:justify-content="center">
<p>Loading...</p>
</div>
{/if}
{:else if !allItems.length && !hasMore}
{#if emptySnippet}
<div style:height="{height}px">{@render emptySnippet()}</div>
{:else}
<div style:height="{height}px" style:display="flex" style:align-items="center" style:justify-content="center">
<p>No data.</p>
</div>
{/if}
{:else}
<VirtualList
count={allItems.length}
{itemSize}
{height}
overscan={effectiveOverscan}
onRangeChange={handleRangeChange}
>
{#snippet children(index, style)}
{@render children(index, allItems[index], style)}
{/snippet}
</VirtualList>
{/if}
84 changes: 84 additions & 0 deletions packages/svelte/src/VirtualList.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<script lang="ts" generics="T">
import { onMount, onDestroy } from "svelte";
import { calculateVirtualRange } from "@scrolloop/core";
import type { Snippet } from "svelte";

interface Props {
count: number;
itemSize: number;
height?: number;
overscan?: number;
onRangeChange?: (range: { startIndex: number; endIndex: number }) => void;
children: Snippet<[index: number, style: Record<string, string>]>;
}

let {
count,
itemSize,
height = 400,
overscan = 4,
onRangeChange,
children,
}: Props = $props();

let containerEl: HTMLDivElement | undefined = $state();
let scrollTop = $state(0);
let prevScrollTop = $state(0);

const totalHeight = $derived(count * itemSize);

const range = $derived(
calculateVirtualRange(scrollTop, height, itemSize, count, overscan, prevScrollTop)
);

const virtualItems = $derived.by(() => {
const items: Array<{ index: number; style: Record<string, string> }> = [];
for (let i = range.renderStart; i <= range.renderEnd; i++) {
items.push({
index: i,
style: {
position: "absolute",
top: `${i * itemSize}px`,
left: "0",
right: "0",
height: `${itemSize}px`,
},
});
}
return items;
});

$effect(() => {
onRangeChange?.({ startIndex: range.renderStart, endIndex: range.renderEnd });
});

function handleScroll() {
if (!containerEl) return;
prevScrollTop = scrollTop;
scrollTop = containerEl.scrollTop;
}

onMount(() => {
containerEl?.addEventListener("scroll", handleScroll, { passive: true });
});

onDestroy(() => {
containerEl?.removeEventListener("scroll", handleScroll);
});
Comment thread
zaewc marked this conversation as resolved.
Outdated
Comment thread
zaewc marked this conversation as resolved.
</script>

<div
bind:this={containerEl}
role="list"
style:overflow="auto"
style:height="{height}px"
style:position="relative"
>
<div style:position="relative" style:height="{totalHeight}px" style:width="100%">
{#each virtualItems as item (item.index)}
<div role="listitem">
{@render children(item.index, item.style)}
</div>
{/each}
</div>
</div>
3 changes: 3 additions & 0 deletions packages/svelte/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as VirtualList } from "./VirtualList.svelte";
export { default as InfiniteList } from "./InfiniteList.svelte";
export { createInfinitePages } from "./stores/createInfinitePages";
25 changes: 25 additions & 0 deletions packages/svelte/src/stores/createInfinitePages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { readable } from "svelte/store";
import { InfiniteSource } from "@scrolloop/shared";
import type {
InfiniteSourceOptions,
InfiniteSourceState,
} from "@scrolloop/shared";

export function createInfinitePages<T>(options: InfiniteSourceOptions<T>) {
const source = new InfiniteSource(options);

const store = readable<InfiniteSourceState<T>>(source.getState(), (set) => {
const unsubscribe = source.subscribe(set);
return () => {
unsubscribe();
source.destroy();
};
});

return {
subscribe: store.subscribe,
loadPage: (page: number) => source.loadPage(page),
retry: () => source.retry(),
reset: () => source.reset(),
};
}
32 changes: 32 additions & 0 deletions packages/svelte/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Snippet } from "svelte";

export interface ItemStyle {
position: "absolute";
top: string;
height: string;
width: string;
}

export interface VirtualListProps<T = unknown> {
items: T[];
itemHeight: number | ((index: number) => number);
containerHeight: number;
overscan?: number;
children: Snippet<[index: number, item: T, style: ItemStyle]>;
}

export interface InfiniteListProps<T> {
fetchPage: (
page: number,
size: number
) => Promise<{ items: T[]; total: number }>;
pageSize?: number;
initialPage?: number;
itemHeight: number | ((index: number) => number);
containerHeight: number;
overscan?: number;
children: Snippet<[index: number, item: T | undefined, style: ItemStyle]>;
error?: Snippet<[error: Error, retry: () => void]>;
loading?: Snippet;
empty?: Snippet;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

types.ts 파일에 정의된 VirtualListPropsInfiniteListProps 인터페이스는 실제 컴포넌트(VirtualList.svelte, InfiniteList.svelte)에서 사용되는 Props와 일치하지 않으며, 현재 프로젝트 내에서 사용되지 않는 것으로 보입니다. 예를 들어, itemSize 대신 itemHeight를 사용하고, fetchPage의 반환 타입이 다릅니다. 이로 인해 혼란이 발생할 수 있습니다. 이 파일의 타입을 실제 컴포넌트와 동기화하고 사용하거나, 불필요하다면 제거하는 것을 고려해 보세요.

10 changes: 10 additions & 0 deletions packages/svelte/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*.ts", "vite.config.ts"],
"exclude": ["node_modules", "dist"]
}
22 changes: 22 additions & 0 deletions packages/svelte/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import dts from "vite-plugin-dts";

export default defineConfig({
plugins: [svelte(), dts({ include: ["src/**/*.ts"] })],
build: {
lib: {
entry: "src/index.ts",
formats: ["es", "cjs"],
fileName: (format) => `index.${format === "es" ? "mjs" : "cjs"}`,
},
rollupOptions: {
external: [
"svelte",
"svelte/store",
"@scrolloop/core",
"@scrolloop/shared",
],
},
},
});
Loading
Loading