-
Notifications
You must be signed in to change notification settings - Fork 3
Feat: svelte 패키지 #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat: svelte 패키지 #38
Changes from 6 commits
f7aa7f5
23b3d15
3520a4a
74798f7
b0beef5
a156391
0456448
6d1d997
c4438b0
01b99fc
104c1f9
6ce7b3e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
| } |
| 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); | ||
| 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); | ||
| } | ||
| } | ||
|
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> | ||
|
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} | ||
| 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); | ||
| }); | ||
|
zaewc marked this conversation as resolved.
Outdated
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> | ||
| 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"; |
| 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(), | ||
| }; | ||
| } |
| 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; | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이 |
||
| 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"] | ||
| } |
| 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", | ||
| ], | ||
| }, | ||
| }, | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.