Reworked the table scroll
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import type { ScrollAreaRootProps } from "reka-ui"
|
||||
import { onBeforeUnmount, onMounted, ref, toRef, type HTMLAttributes } from "vue"
|
||||
import { reactiveOmit, useScroll } from "@vueuse/core"
|
||||
import {
|
||||
ScrollAreaCorner,
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaViewport,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import ScrollBar from "./ScrollBar.vue"
|
||||
|
||||
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes["class"], keepBottom?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const scrollAreaRootRef = ref<{ viewport: HTMLElement } | null>(null)
|
||||
const { arrivedState } = useScroll(() => scrollAreaRootRef.value?.viewport)
|
||||
const bottom = toRef(arrivedState, 'bottom')
|
||||
const isFollowingBottom = ref(props.keepBottom ?? false)
|
||||
let isAutoScrolling = false
|
||||
let mutationObserver: MutationObserver | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let followFrame: number | null = null
|
||||
let previousScrollTop = 0
|
||||
|
||||
const getViewport = () => scrollAreaRootRef.value?.viewport
|
||||
const isNearBottom = () => {
|
||||
const viewport = getViewport()
|
||||
|
||||
return viewport ? viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= 4 : true
|
||||
}
|
||||
const handleScroll = () => {
|
||||
const viewport = getViewport()
|
||||
if (!viewport) {
|
||||
return
|
||||
}
|
||||
|
||||
const scrollTop = viewport.scrollTop
|
||||
if (scrollTop < previousScrollTop) {
|
||||
isAutoScrolling = false
|
||||
isFollowingBottom.value = false
|
||||
}
|
||||
previousScrollTop = scrollTop
|
||||
|
||||
if (isAutoScrolling) {
|
||||
if (isNearBottom()) {
|
||||
isAutoScrolling = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
isFollowingBottom.value = isNearBottom()
|
||||
}
|
||||
const handleUserScroll = () => {
|
||||
isAutoScrolling = false
|
||||
isFollowingBottom.value = false
|
||||
const viewport = getViewport()
|
||||
viewport?.scrollTo({ top: viewport.scrollTop, behavior: 'auto' })
|
||||
}
|
||||
const followBottomAfterUpdate = () => {
|
||||
followFrame = null
|
||||
if (isFollowingBottom.value) {
|
||||
scrollToBottom('smooth')
|
||||
}
|
||||
}
|
||||
const scheduleFollowBottom = () => {
|
||||
if (followFrame === null) {
|
||||
followFrame = requestAnimationFrame(followBottomAfterUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
const isBottom = () => bottom.value
|
||||
const scrollToBottom = (behavior: string = 'auto') => {
|
||||
const viewport = getViewport()
|
||||
if (viewport) {
|
||||
isFollowingBottom.value = true
|
||||
isAutoScrolling = behavior === 'smooth'
|
||||
previousScrollTop = viewport.scrollTop
|
||||
viewport.scrollTo({
|
||||
top: viewport.scrollHeight,
|
||||
behavior: behavior === 'smooth' ? 'smooth' : 'auto',
|
||||
})
|
||||
}
|
||||
}
|
||||
const followBottom = (force = false) => {
|
||||
if (!force && !isFollowingBottom.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isFollowingBottom.value = true
|
||||
scrollToBottom('smooth')
|
||||
}
|
||||
const scrollToTop = (behavior: ScrollBehavior = 'smooth') => {
|
||||
const viewport = getViewport()
|
||||
if (viewport) {
|
||||
isAutoScrolling = false
|
||||
isFollowingBottom.value = false
|
||||
viewport.scrollTo({ top: 0, behavior })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const viewport = getViewport()
|
||||
if (!viewport) {
|
||||
return
|
||||
}
|
||||
|
||||
viewport.addEventListener('scroll', handleScroll, { passive: true })
|
||||
viewport.addEventListener('wheel', handleUserScroll, { passive: true })
|
||||
viewport.addEventListener('touchstart', handleUserScroll, { passive: true })
|
||||
mutationObserver = new MutationObserver(() => {
|
||||
if (props.keepBottom) {
|
||||
scheduleFollowBottom()
|
||||
}
|
||||
})
|
||||
mutationObserver.observe(viewport, { childList: true, subtree: true })
|
||||
resizeObserver = new ResizeObserver(scheduleFollowBottom)
|
||||
resizeObserver.observe(viewport)
|
||||
|
||||
if (props.keepBottom) {
|
||||
scrollToBottom()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const viewport = getViewport()
|
||||
viewport?.removeEventListener('scroll', handleScroll)
|
||||
viewport?.removeEventListener('wheel', handleUserScroll)
|
||||
viewport?.removeEventListener('touchstart', handleUserScroll)
|
||||
mutationObserver?.disconnect()
|
||||
resizeObserver?.disconnect()
|
||||
if (followFrame !== null) {
|
||||
cancelAnimationFrame(followFrame)
|
||||
}
|
||||
})
|
||||
|
||||
interface Expose {
|
||||
isBottom: () => boolean
|
||||
isFollowingBottom: typeof isFollowingBottom
|
||||
followBottom: (force?: boolean) => void
|
||||
scrollToBottom: (behavior?: string) => void
|
||||
scrollToTop: (behavior?: ScrollBehavior) => void
|
||||
}
|
||||
|
||||
defineExpose<Expose>({
|
||||
isBottom,
|
||||
isFollowingBottom,
|
||||
followBottom,
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaRoot
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('relative overflow-hidden', props.class)"
|
||||
ref="scrollAreaRootRef"
|
||||
>
|
||||
<ScrollAreaViewport class="h-full w-full overflow-y-auto rounded-[inherit]">
|
||||
<slot />
|
||||
</ScrollAreaViewport>
|
||||
<slot name="overlay" />
|
||||
<ScrollBar />
|
||||
<ScrollAreaCorner />
|
||||
</ScrollAreaRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { ScrollAreaScrollbarProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ScrollAreaScrollbar, ScrollAreaThumb } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes["class"] }>(), {
|
||||
orientation: "vertical",
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn('flex touch-none p-px transition-colors select-none',
|
||||
orientation === 'vertical'
|
||||
&& 'h-full w-2.5 border-l border-l-transparent',
|
||||
orientation === 'horizontal'
|
||||
&& 'h-2.5 flex-col border-t border-t-transparent',
|
||||
props.class)"
|
||||
>
|
||||
<ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
class="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaScrollbar>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as ScrollArea } from "./ScrollArea.vue"
|
||||
export { default as ScrollBar } from "./ScrollBar.vue"
|
||||
Reference in New Issue
Block a user