Skip to content

Commit

Permalink
Merge pull request #1488 from PrefectHQ/virtual-scroller-scroll-item-…
Browse files Browse the repository at this point in the history
…into-view

Add the ability to scroll an item into view in the virtual scroller
  • Loading branch information
pleek91 authored Oct 28, 2024
2 parents b09157d + a14c504 commit 6a396e8
Show file tree
Hide file tree
Showing 5 changed files with 186 additions and 22 deletions.
45 changes: 32 additions & 13 deletions demo/sections/components/VirtualScroller.vue
Original file line number Diff line number Diff line change
@@ -1,37 +1,56 @@
<template>
<ComponentPage title="Virtual Scroller" :demos="[{ title: 'Virtual Scroller' }]">
<template #virtual-scroller>
<p-number-input v-model="itemCount" />

<div class="virtual-scroller_demo">
<p-virtual-scroller :items="items">
<template #default="{ item }">
<div>
{{ item }}
</div>
</template>
</p-virtual-scroller>
</div>
<p-content>
<p-number-input v-model="itemCount" />

<div class="virtual-scroller_demo">
<p-virtual-scroller :items name="demo-scroller" :item-estimate-height="24">
<template #default="{ item, id }">
<div :id>
{{ item }}
</div>
</template>
</p-virtual-scroller>
</div>

<div class="flex gap-2">
<p-number-input v-model="itemToScrollTo" />
<p-button @click="scrollToItem">
Scroll to item
</p-button>
<p-checkbox v-model="smooth" label="smooth" />
</div>
</p-content>
</template>
</ComponentPage>
</template>

<script lang="ts" setup>
import { getVirtualScroller } from '@/components/VirtualScroller'
import { computed, ref } from 'vue'
import ComponentPage from '@/demo/components/ComponentPage.vue'
const itemCount = ref(100)
const itemCount = ref(1000)
const items = computed(() => new Array(itemCount.value).fill(null).map((item, index) => ({
id: index,
label: `item #${index}`,
})))
const itemToScrollTo = ref<number>(800)
const smooth = ref(true)
function scrollToItem(): void {
const scroller = getVirtualScroller('demo-scroller')
scroller.scrollItemIntoView(itemToScrollTo.value, { behavior: smooth.value ? 'smooth' : 'auto' })
}
</script>

<style>
.virtual-scroller_demo { @apply
border
overflow-auto
max-h-52
mt-2
}
</style>
101 changes: 93 additions & 8 deletions src/components/VirtualScroller/PVirtualScroller.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<template>
<component :is="element" class="p-virtual-scroller">
<component :is="element" ref="scrollerRef" class="p-virtual-scroller">
<template v-for="(chunk, chunkIndex) in chunks" :key="chunkIndex">
<VirtualScrollerChunk :height="itemEstimateHeight * chunk.length" v-bind="{ observerOptions }">
<VirtualScrollerChunk :id="getChunkId(chunkIndex)" :height="getChunkHeight(chunkIndex)" :force-visible="getChunkForceVisible(chunkIndex)" :observer-options>
<template v-for="(item, itemChunkIndex) in chunk" :key="item[itemKey]">
<slot :item="item" :index="getItemIndex(chunkIndex, itemChunkIndex)" />
<slot :id="getItemId(item)" :item :index="getItemIndex(chunkIndex, itemChunkIndex)" />
</template>
</VirtualScrollerChunk>
</template>
Expand All @@ -14,8 +14,9 @@

<script lang="ts" setup generic="T extends Record<string, any>">
import { useIntersectionObserver } from '@prefecthq/vue-compositions'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import VirtualScrollerChunk from '@/components/VirtualScroller/PVirtualScrollerChunk.vue'
import { registerVirtualScroller, scrollIntoView, unregisterVirtualScroller } from '@/components/VirtualScroller/utilities'
const props = withDefaults(defineProps<{
items: T[],
Expand All @@ -24,11 +25,13 @@
chunkSize?: number,
observerOptions?: IntersectionObserverInit,
element?: string,
name?: string,
}>(), {
itemEstimateHeight: 50,
itemKey: 'id',
chunkSize: 50,
element: 'div',
name: undefined,
observerOptions: () => ({
rootMargin: '200px',
}),
Expand All @@ -38,8 +41,39 @@
(event: 'bottom'): void,
}>()
defineExpose({
scrollItemIntoView,
})
watch(() => props.name, (newName, oldName) => {
if (oldName) {
unregisterVirtualScroller(oldName)
}
if (newName) {
registerVirtualScroller(newName, {
makeItemVisible,
scrollItemIntoView,
})
}
}, { immediate: true })
watch(() => props.items, () => check(bottom))
onMounted(() => {
observe(bottom)
})
onUnmounted(() => {
if (props.name) {
unregisterVirtualScroller(props.name)
}
})
const scrollerRef = ref<HTMLElement>()
const bottom = ref<HTMLDivElement>()
const { observe, check } = useIntersectionObserver(intersect, props.observerOptions)
const chunksForcedToBeVisible = reactive(new Set<number>())
const chunks = computed(() => {
const chunks = []
Expand All @@ -52,6 +86,47 @@
return chunks
})
function getItemChunkIndex(itemKey: unknown): number {
return chunks.value.findIndex(chunk => chunk.some(item => item[props.itemKey] === itemKey))
}
function makeItemVisible(itemKey: unknown): () => void {
const chunkIndex = getItemChunkIndex(itemKey)
chunksForcedToBeVisible.add(chunkIndex)
return () => chunksForcedToBeVisible.delete(chunkIndex)
}
function scrollItemIntoView(itemKey: unknown, options?: ScrollIntoViewOptions): void {
const hide = makeItemVisible(itemKey)
const chunkIndex = getItemChunkIndex(itemKey)
nextTick(async () => {
if (!scrollerRef.value) {
return
}
const chunk = scrollerRef.value.querySelector(`#${getChunkId(chunkIndex)}`)
if (!chunk) {
return
}
await scrollIntoView(chunk, { ...options, block: 'nearest' })
const item = scrollerRef.value.querySelector(`#${props.name}-${itemKey}`)
if (!item) {
return
}
await scrollIntoView(item, options)
hide()
})
}
function intersect(entries: IntersectionObserverEntry[]): void {
entries.forEach(entry => {
if (entry.isIntersecting) {
Expand All @@ -64,11 +139,21 @@
return props.chunkSize * chunkIndex + itemChunkIndex
}
watch(() => props.items, () => check(bottom))
function getChunkHeight(index: number): number {
return props.itemEstimateHeight * chunks.value[index].length
}
onMounted(() => {
observe(bottom)
})
function getChunkForceVisible(index: number): boolean {
return chunksForcedToBeVisible.has(index)
}
function getItemId(item: T): string {
return `${props.name}-${item[props.itemKey]}`
}
function getChunkId(index: number): string {
return `${props.name}-chunk-${index}`
}
</script>

<style>
Expand Down
3 changes: 2 additions & 1 deletion src/components/VirtualScroller/PVirtualScrollerChunk.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div ref="el" class="p-virtual-scroller-chunk" :style="styles">
<template v-if="visible">
<template v-if="forceVisible || visible">
<slot />
</template>
</div>
Expand All @@ -13,6 +13,7 @@
const props = defineProps<{
height: number,
observerOptions: IntersectionObserverInit,
forceVisible?: boolean,
}>()
const styles = computed(() => ({
Expand Down
2 changes: 2 additions & 0 deletions src/components/VirtualScroller/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { App } from 'vue'
import PVirtualScroller from '@/components/VirtualScroller/PVirtualScroller.vue'

export * from '@/components/VirtualScroller/utilities'

const install = (app: App): void => {
app.component('PVirtualScroller', PVirtualScroller)
}
Expand Down
57 changes: 57 additions & 0 deletions src/components/VirtualScroller/utilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
const scrollers = new Map<string, UseVirtualScroller>()

type UseVirtualScroller = {
makeItemVisible: (itemKey: unknown) => void,
scrollItemIntoView: (itemKey: unknown, options?: ScrollIntoViewOptions) => void,
}

export function registerVirtualScroller(name: string, scroller: UseVirtualScroller): void {
scrollers.set(name, scroller)
}

export function unregisterVirtualScroller(name: string): void {
scrollers.delete(name)
}

export function getVirtualScroller(name: string): UseVirtualScroller {

const makeItemVisible: UseVirtualScroller['makeItemVisible'] = (itemKey) => {
const scroller = scrollers.get(name)

return scroller?.makeItemVisible(itemKey)
}

const scrollItemIntoView: UseVirtualScroller['scrollItemIntoView'] = (itemKey, options) => {
const scroller = scrollers.get(name)

return scroller?.scrollItemIntoView(itemKey, options)
}

return {
makeItemVisible,
scrollItemIntoView,
}
}

export function scrollIntoView(target: Element, options?: ScrollIntoViewOptions): Promise<void> {
const { promise, resolve } = Promise.withResolvers<void>()

const observer = new IntersectionObserver((entries) => {
const [entry] = entries

if (entry.isIntersecting) {
observer.unobserve(target)
observer.disconnect()

setTimeout(() => {
resolve()
}, 100)
}
})

observer.observe(target)

target.scrollIntoView(options)

return promise
}

0 comments on commit 6a396e8

Please sign in to comment.