fix @tanstack/virtual not fully support smooth scroll behavior on dynamic height containers

This commit is contained in:
pa
2026-01-25 20:43:04 +09:00
parent 722cc615cb
commit dbc98cdc4e
4 changed files with 65 additions and 188 deletions

View File

@@ -5,12 +5,15 @@
import { Button } from '@/components/ui/button';
const props = defineProps({
// scroll DOM ref
target: { type: [String, Object], default: null },
// @tanstack/virtual instance
virtualizer: { type: [Object], default: null },
bottom: { type: Number, default: 20 },
right: { type: Number, default: 20 },
visibilityHeight: { type: Number, default: 200 },
visibilityHeight: { type: Number, default: 400 },
behavior: {
type: String,
@@ -21,28 +24,26 @@
tooltip: { type: Boolean, default: true },
tooltipText: { type: String, default: 'Back to top' },
teleport: { type: Boolean, default: true }
teleport: { type: Boolean, default: true },
teleportTo: { type: [Boolean, String, Object], default: null }
});
const visible = ref(false);
let containerEl = null;
function resolveTarget() {
if (!props.target) return null;
if (typeof props.target === 'string') {
return document.querySelector(props.target);
function resolveElement(target) {
if (!target) return null;
if (typeof target === 'string') return document.querySelector(target);
if (typeof target === 'object') {
if ('value' in target) return target.value;
if ('$el' in target) return target.$el;
}
return target;
}
if (typeof props.target === 'object') {
if ('value' in props.target) {
return props.target.value;
}
if ('$el' in props.target) {
return props.target.$el;
}
}
return props.target;
function getVirtualizer() {
if (!props.virtualizer) return null;
return 'value' in props.virtualizer ? props.virtualizer.value : props.virtualizer;
}
function getScrollTop() {
@@ -58,15 +59,21 @@
function scrollToTop() {
const behavior = props.behavior === 'auto' ? 'auto' : 'smooth';
if (!containerEl || typeof containerEl.scrollTo !== 'function') {
window.scrollTo({ top: 0, behavior });
const v = getVirtualizer();
if (v?.scrollToIndex) {
v.scrollToIndex(0, { align: 'start', behavior: 'auto' });
return;
}
containerEl.scrollTo({ top: 0, behavior });
const target = containerEl || resolveElement(props.target);
if (target && typeof target.scrollTo === 'function') {
target.scrollTo({ top: 0, behavior });
return;
}
window.scrollTo({ top: 0, behavior });
}
function bind() {
containerEl = resolveTarget();
containerEl = resolveElement(props.target);
const target = containerEl && typeof containerEl.addEventListener === 'function' ? containerEl : window;
target.addEventListener('scroll', handleScroll, { passive: true });
@@ -94,13 +101,25 @@
unbind();
});
const teleportTarget = computed(() => {
if (props.teleportTo !== null && props.teleportTo !== undefined) {
if (props.teleportTo === true) return 'body';
if (props.teleportTo === false) return null;
return resolveElement(props.teleportTo);
}
return props.teleport ? 'body' : null;
});
const isBodyTeleport = computed(() => teleportTarget.value === 'body' || teleportTarget.value === document.body);
const wrapperStyle = computed(
() => `position:fixed; right:${props.right}px; bottom:${props.bottom}px; z-index:50;`
() =>
`position:${isBodyTeleport.value ? 'fixed' : 'absolute'}; right:${props.right}px; bottom:${props.bottom}px; z-index:50;`
);
</script>
<template>
<Teleport v-if="teleport" to="body">
<Teleport v-if="teleportTarget" :to="teleportTarget">
<Transition name="back-to-top">
<div v-if="visible" :style="wrapperStyle">
<TooltipProvider v-if="tooltip">
@@ -115,7 +134,7 @@
<ArrowUp class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<TooltipContent side="top">
{{ tooltipText }}
</TooltipContent>
</Tooltip>
@@ -136,7 +155,26 @@
<Transition v-else name="back-to-top">
<div v-if="visible" :style="wrapperStyle">
<TooltipProvider v-if="tooltip">
<Tooltip>
<TooltipTrigger as-child>
<Button
size="icon"
variant="secondary"
class="rounded-full shadow"
aria-label="Back to top"
@click="scrollToTop">
<ArrowUp class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{{ tooltipText }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
v-else
size="icon"
variant="secondary"
class="rounded-full shadow"

View File

@@ -1,161 +0,0 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { ArrowUp } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
const props = defineProps({
// scroll DOM ref
target: { type: [String, Object], default: null },
// @tanstack/virtual instance
virtualizer: { type: [Object], default: null },
bottom: { type: Number, default: 4 },
right: { type: Number, default: 10 },
visibilityHeight: { type: Number, default: 500 },
behavior: {
type: String,
default: 'smooth',
validator: (value) => value === 'auto' || value === 'smooth'
},
teleportTo: { type: [String, Object], default: null }
});
const visible = ref(false);
let containerEl = null;
function resolveElement(target) {
if (!target) return null;
if (typeof target === 'string') return document.querySelector(target);
if (typeof target === 'object') {
if ('value' in target) return target.value;
if ('$el' in target) return target.$el;
}
return target;
}
function getVirtualizer() {
if (!props.virtualizer) return null;
return 'value' in props.virtualizer ? props.virtualizer.value : props.virtualizer;
}
function getScrollTop() {
if (!containerEl || typeof containerEl.scrollTop !== 'number') {
return window.scrollY || document.documentElement.scrollTop || 0;
}
return containerEl.scrollTop || 0;
}
function handleScroll() {
visible.value = getScrollTop() >= props.visibilityHeight;
}
function scrollToTop() {
const behavior = props.behavior === 'auto' ? 'auto' : 'smooth';
const v = getVirtualizer();
if (v?.scrollToIndex) {
v.scrollToIndex(0, { align: 'start', behavior });
return;
}
const target = containerEl || resolveElement(props.target);
if (target && typeof target.scrollTo === 'function') {
target.scrollTo({ top: 0, behavior });
return;
}
window.scrollTo({ top: 0, behavior });
}
function bind() {
containerEl = resolveElement(props.target);
const target = containerEl && typeof containerEl.addEventListener === 'function' ? containerEl : window;
target.addEventListener('scroll', handleScroll, { passive: true });
handleScroll();
}
function unbind() {
const target = containerEl || window;
target.removeEventListener('scroll', handleScroll);
}
onMounted(() => {
bind();
});
watch(
() => props.target,
() => {
unbind();
bind();
}
);
onBeforeUnmount(() => {
unbind();
});
const wrapperStyle = computed(
() => `position:absolute; right:${props.right}px; bottom:${props.bottom}px; z-index:10;`
);
const teleportTarget = computed(() => resolveElement(props.teleportTo));
</script>
<template>
<Teleport v-if="teleportTarget" :to="teleportTarget">
<Transition name="back-to-top">
<div v-if="visible" :style="wrapperStyle">
<Button
size="icon"
variant="secondary"
class="rounded-full shadow"
aria-label="Back to top"
@click="scrollToTop">
<ArrowUp />
</Button>
</div>
</Transition>
</Teleport>
<Transition v-else name="back-to-top">
<div v-if="visible" :style="wrapperStyle">
<Button
size="icon"
variant="secondary"
class="rounded-full shadow"
aria-label="Back to top"
@click="scrollToTop">
<ArrowUp />
</Button>
</div>
</Transition>
</template>
<style scoped>
.back-to-top-enter-active,
.back-to-top-leave-active {
transition:
opacity 160ms ease,
transform 160ms ease;
}
.back-to-top-enter-from,
.back-to-top-leave-to {
opacity: 0;
transform: translateY(6px);
}
.back-to-top-enter-to,
.back-to-top-leave-from {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.back-to-top-enter-active,
.back-to-top-leave-active {
transition: none;
}
}
</style>

View File

@@ -84,7 +84,7 @@
</div>
</div>
</div>
<BackToTopVirtual :virtualizer="virtualizer" :target="scrollViewportRef" :teleport-to="scrollRootRef" />
<BackToTop :virtualizer="virtualizer" :target="scrollViewportRef" :tooltip="false" />
</div>
</template>
@@ -107,7 +107,7 @@
import { isRealInstance, userImage, userStatusClass } from '../../../shared/utils';
import { getFriendsLocations } from '../../../shared/utils/location.js';
import BackToTopVirtual from '../../../components/BackToTopVirtual.vue';
import BackToTop from '../../../components/BackToTop.vue';
import FriendItem from './FriendItem.vue';
import Location from '../../../components/Location.vue';
import configRepository from '../../../service/config';

View File

@@ -52,7 +52,7 @@
</div>
</div>
</div>
<BackToTopVirtual :virtualizer="virtualizer" :target="scrollViewportRef" :teleport-to="scrollRootRef" />
<BackToTop :virtualizer="virtualizer" :target="scrollViewportRef" :tooltip="false" />
</div>
</template>
@@ -65,7 +65,7 @@
import { useAppearanceSettingsStore, useGroupStore } from '../../../stores';
import { convertFileUrlToImageUrl } from '../../../shared/utils';
import BackToTopVirtual from '../../../components/BackToTopVirtual.vue';
import BackToTop from '../../../components/BackToTop.vue';
import Location from '../../../components/Location.vue';
const { isAgeGatedInstancesVisible } = storeToRefs(useAppearanceSettingsStore());