Skip to content

Commit de22a46

Browse files
authored
Merge pull request #36 from zaewc/feat/vue-adapter
Feat: vue 패키지
2 parents 48883a4 + 6ef6ac7 commit de22a46

9 files changed

Lines changed: 494 additions & 0 deletions

File tree

packages/vue/package.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"name": "@scrolloop/vue",
3+
"version": "0.1.0",
4+
"description": "Vue 3 adapter for @scrolloop/core",
5+
"type": "module",
6+
"main": "./dist/index.cjs",
7+
"module": "./dist/index.mjs",
8+
"types": "./dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"types": "./dist/index.d.ts",
12+
"import": "./dist/index.mjs",
13+
"require": "./dist/index.cjs"
14+
}
15+
},
16+
"sideEffects": false,
17+
"files": [
18+
"dist"
19+
],
20+
"scripts": {
21+
"build": "vite build",
22+
"dev": "vite build --watch"
23+
},
24+
"peerDependencies": {
25+
"vue": ">=3.3.0"
26+
},
27+
"dependencies": {
28+
"@scrolloop/core": "workspace:*",
29+
"@scrolloop/shared": "workspace:*"
30+
},
31+
"devDependencies": {
32+
"@vitejs/plugin-vue": "^5.0.0",
33+
"typescript": "^5.0.0",
34+
"vite": "^5.0.0",
35+
"vite-plugin-dts": "^4.0.0",
36+
"vue": "^3.3.0",
37+
"vue-tsc": "^2.0.0"
38+
},
39+
"license": "MIT"
40+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<script setup lang="ts" generic="T">
2+
import { computed, onMounted } from "vue";
3+
import { findMissingPages } from "@scrolloop/shared";
4+
import type { PageResponse, Range } from "@scrolloop/shared";
5+
import VirtualList from "./VirtualList.vue";
6+
import { useInfinitePages } from "../composables/useInfinitePages";
7+
8+
const props = withDefaults(
9+
defineProps<{
10+
fetchPage: (page: number, size: number) => Promise<PageResponse<T>>;
11+
itemSize: number;
12+
pageSize?: number;
13+
initialPage?: number;
14+
prefetchThreshold?: number;
15+
height?: number;
16+
overscan?: number;
17+
class?: string;
18+
}>(),
19+
{
20+
pageSize: 20,
21+
initialPage: 0,
22+
prefetchThreshold: 1,
23+
height: 400,
24+
}
25+
);
26+
27+
const emit = defineEmits<{
28+
pageLoad: [page: number, items: T[]];
29+
error: [error: Error];
30+
}>();
31+
32+
const overscan = computed(
33+
() => props.overscan ?? Math.max(20, props.pageSize * 2)
34+
);
35+
36+
const { pages, loadingPages, total, hasMore, error, loadPage, retry } =
37+
useInfinitePages<T>(() => ({
38+
fetchPage: props.fetchPage,
39+
pageSize: props.pageSize,
40+
initialPage: props.initialPage,
41+
onPageLoad: (page, items) => emit("pageLoad", page, items),
42+
onError: (err) => emit("error", err),
43+
}));
44+
45+
onMounted(() => {
46+
const needed = Math.ceil(props.height / props.itemSize) + overscan.value * 2;
47+
const count = Math.ceil(needed / props.pageSize) + props.prefetchThreshold;
48+
for (let p = 0; p < count; p++) loadPage(p);
49+
});
50+
51+
function handleRangeChange(range: Range) {
52+
const ps = (range.startIndex / props.pageSize) | 0;
53+
const pe =
54+
((range.endIndex / props.pageSize) | 0) +
55+
props.prefetchThreshold +
56+
Math.ceil(overscan.value / props.pageSize);
57+
findMissingPages(ps, pe, pages.value, loadingPages.value);
58+
for (let p = ps; p <= pe; p++) loadPage(p);
59+
}
60+
</script>
61+
62+
<template>
63+
<div :style="{ height: `${height}px` }">
64+
<template v-if="error && total === 0">
65+
<slot name="error" :error="error" :retry="retry">
66+
<div class="scrolloop-state-container">
67+
<div class="scrolloop-error-content">
68+
<p class="scrolloop-error-message">Error: {{ error.message }}</p>
69+
<button class="scrolloop-retry-button" @click="retry">Retry</button>
70+
</div>
71+
</div>
72+
</slot>
73+
</template>
74+
75+
<template v-else-if="total === 0 && loadingPages.size">
76+
<slot name="loading">
77+
<div class="scrolloop-state-container">
78+
<p class="scrolloop-state-text">Loading...</p>
79+
</div>
80+
</slot>
81+
</template>
82+
83+
<template v-else-if="total === 0 && !hasMore">
84+
<slot name="empty">
85+
<div class="scrolloop-state-container">
86+
<p class="scrolloop-state-text">No data.</p>
87+
</div>
88+
</slot>
89+
</template>
90+
91+
<VirtualList
92+
v-else
93+
:count="total"
94+
:item-size="itemSize"
95+
:height="height"
96+
:overscan="overscan"
97+
:class="$props.class"
98+
@range-change="handleRangeChange"
99+
>
100+
<template #default="{ index, style }">
101+
<slot
102+
:item="pages.get(Math.floor(index / props.pageSize))?.[index % props.pageSize]"
103+
:index="index"
104+
:style="style"
105+
/>
106+
</template>
107+
</VirtualList>
108+
</div>
109+
</template>
110+
111+
<style>
112+
.scrolloop-state-container {
113+
height: 100%;
114+
display: flex;
115+
align-items: center;
116+
justify-content: center;
117+
}
118+
119+
.scrolloop-error-content {
120+
text-align: center;
121+
}
122+
123+
.scrolloop-error-message {
124+
margin: 0 0 8px;
125+
}
126+
127+
.scrolloop-retry-button {
128+
padding: 4px 12px;
129+
cursor: pointer;
130+
}
131+
132+
.scrolloop-state-text {
133+
margin: 0;
134+
}
135+
</style>
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<script setup lang="ts">
2+
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
3+
import { calculateVirtualRange } from "@scrolloop/core";
4+
import type { Range } from "@scrolloop/shared";
5+
6+
const props = withDefaults(
7+
defineProps<{
8+
count: number;
9+
itemSize: number;
10+
height?: number;
11+
overscan?: number;
12+
class?: string;
13+
}>(),
14+
{ height: 400, overscan: 4 }
15+
);
16+
17+
const emit = defineEmits<{
18+
rangeChange: [range: Range];
19+
}>();
20+
21+
const containerRef = ref<HTMLDivElement | null>(null);
22+
const scrollTop = ref(0);
23+
const prevScrollTop = ref(0);
24+
25+
const totalHeight = computed(() => props.count * props.itemSize);
26+
27+
const range = computed(() =>
28+
calculateVirtualRange(
29+
scrollTop.value,
30+
props.height,
31+
props.itemSize,
32+
props.count,
33+
props.overscan,
34+
prevScrollTop.value
35+
)
36+
);
37+
38+
const virtualItems = computed(() => {
39+
const items: Array<{ index: number; style: Record<string, string> }> = [];
40+
for (let i = range.value.renderStart; i <= range.value.renderEnd; i++) {
41+
items.push({
42+
index: i,
43+
style: {
44+
position: "absolute",
45+
top: `${i * props.itemSize}px`,
46+
left: "0",
47+
right: "0",
48+
height: `${props.itemSize}px`,
49+
},
50+
});
51+
}
52+
return items;
53+
});
54+
55+
watch(range, (r) => {
56+
emit("rangeChange", { startIndex: r.renderStart, endIndex: r.renderEnd });
57+
});
58+
59+
function handleScroll() {
60+
const el = containerRef.value;
61+
if (!el) return;
62+
prevScrollTop.value = scrollTop.value;
63+
scrollTop.value = el.scrollTop;
64+
}
65+
66+
onMounted(() => {
67+
containerRef.value?.addEventListener("scroll", handleScroll, {
68+
passive: true,
69+
});
70+
});
71+
72+
onUnmounted(() => {
73+
containerRef.value?.removeEventListener("scroll", handleScroll);
74+
});
75+
</script>
76+
77+
<template>
78+
<div
79+
ref="containerRef"
80+
role="list"
81+
:class="$props.class"
82+
:style="{ overflow: 'auto', height: `${height}px` }"
83+
>
84+
<div
85+
:style="{
86+
position: 'relative',
87+
height: `${totalHeight}px`,
88+
width: '100%',
89+
}"
90+
>
91+
<template v-for="item in virtualItems" :key="item.index">
92+
<slot :index="item.index" :style="item.style" />
93+
</template>
94+
</div>
95+
</div>
96+
</template>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { ref, computed, watch, toValue, onUnmounted } from "vue";
2+
import type { MaybeRefOrGetter } from "vue";
3+
import { InfiniteSource } from "@scrolloop/shared";
4+
import type { InfiniteSourceOptions } from "@scrolloop/shared";
5+
6+
export function useInfinitePages<T>(
7+
options: MaybeRefOrGetter<InfiniteSourceOptions<T>>
8+
) {
9+
const resolved = toValue(options);
10+
const source = new InfiniteSource(resolved);
11+
const state = ref(source.getState());
12+
13+
const unsubscribe = source.subscribe((s) => {
14+
state.value = s;
15+
});
16+
17+
watch(
18+
() => {
19+
const { fetchPage, onPageLoad, onError } = toValue(options);
20+
return { fetchPage, onPageLoad, onError };
21+
},
22+
({ fetchPage, onPageLoad, onError }) => {
23+
source.updateCallbacks({ fetchPage, onPageLoad, onError });
24+
}
25+
);
26+
27+
onUnmounted(() => {
28+
unsubscribe();
29+
source.destroy();
30+
});
31+
32+
return {
33+
pages: computed(() => state.value.pages),
34+
loadingPages: computed(() => state.value.loadingPages),
35+
total: computed(() => state.value.total),
36+
hasMore: computed(() => state.value.hasMore),
37+
error: computed(() => state.value.error),
38+
loadPage: (page: number) => source.loadPage(page),
39+
retry: () => source.retry(),
40+
reset: () => source.reset(),
41+
};
42+
}

packages/vue/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { default as VirtualList } from "./components/VirtualList.vue";
2+
export { default as InfiniteList } from "./components/InfiniteList.vue";
3+
export { useInfinitePages } from "./composables/useInfinitePages";
4+
export type { VirtualListProps, InfiniteListProps, ItemStyle } from "./types";

packages/vue/src/types.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { PageResponse, Range } from "@scrolloop/shared";
2+
3+
export type { PageResponse, Range };
4+
5+
export interface VirtualListProps {
6+
count: number;
7+
itemSize: number;
8+
height?: number;
9+
overscan?: number;
10+
class?: string;
11+
}
12+
13+
export interface InfiniteListProps<T> {
14+
fetchPage: (page: number, size: number) => Promise<PageResponse<T>>;
15+
itemSize: number;
16+
pageSize?: number;
17+
initialPage?: number;
18+
prefetchThreshold?: number;
19+
height?: number;
20+
overscan?: number;
21+
class?: string;
22+
}
23+
24+
export type ItemStyle = Record<string, string>;

packages/vue/tsconfig.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": "./dist",
5+
"declaration": true,
6+
"declarationMap": true,
7+
"jsx": "preserve"
8+
},
9+
"include": ["src/**/*", "vite.config.ts"],
10+
"exclude": ["node_modules", "dist"]
11+
}

packages/vue/vite.config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { defineConfig } from "vite";
2+
import vue from "@vitejs/plugin-vue";
3+
import dts from "vite-plugin-dts";
4+
5+
export default defineConfig({
6+
plugins: [vue(), dts({ include: ["src/**/*"] })],
7+
build: {
8+
lib: {
9+
entry: "src/index.ts",
10+
formats: ["es", "cjs"],
11+
fileName: (format) => `index.${format === "es" ? "mjs" : "cjs"}`,
12+
},
13+
rollupOptions: {
14+
external: ["vue", "@scrolloop/core", "@scrolloop/shared"],
15+
},
16+
},
17+
});

0 commit comments

Comments
 (0)