206 lines
5.0 KiB
Vue
206 lines
5.0 KiB
Vue
<script setup>
|
|
import { ref, onMounted, computed } from 'vue';
|
|
|
|
defineOptions({ name: 'ScrollArea' });
|
|
|
|
const props = defineProps({
|
|
type: {
|
|
type: String,
|
|
default: 'hover',
|
|
validator: (value) => ['always', 'scroll', 'hover', 'auto'].includes(value),
|
|
},
|
|
orientation: {
|
|
type: String,
|
|
default: 'vertical',
|
|
validator: (value) => ['vertical', 'horizontal', 'both'].includes(value),
|
|
},
|
|
scrollHideDelay: {
|
|
type: Number,
|
|
default: 600,
|
|
},
|
|
});
|
|
|
|
const viewport = ref(null);
|
|
const scrollbar = ref(null);
|
|
const thumb = ref(null);
|
|
|
|
let isDragging = false;
|
|
let startPosition = { x: 0, y: 0 };
|
|
let startScroll = { left: 0, top: 0 };
|
|
let hideScrollbarTimeout;
|
|
|
|
const showScrollbar = ref(props.type === 'always');
|
|
|
|
const isVertical = computed(() => props.orientation !== 'horizontal');
|
|
const isHorizontal = computed(() => props.orientation !== 'vertical');
|
|
|
|
const handleMouseEnter = () => {
|
|
if (props.type === 'hover') {
|
|
showScrollbar.value = true;
|
|
}
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
if (props.type === 'hover' && !isDragging) {
|
|
showScrollbar.value = false;
|
|
}
|
|
};
|
|
|
|
const handleScroll = () => {
|
|
if (props.type === 'scroll') {
|
|
showScrollbar.value = true;
|
|
clearTimeout(hideScrollbarTimeout);
|
|
hideScrollbarTimeout = setTimeout(() => {
|
|
if (!isDragging) showScrollbar.value = false;
|
|
}, props.scrollHideDelay);
|
|
}
|
|
updateThumbPosition();
|
|
};
|
|
|
|
const updateThumbPosition = () => {
|
|
if (!viewport.value || !thumb.value) return;
|
|
|
|
const {
|
|
scrollTop,
|
|
scrollLeft,
|
|
scrollHeight,
|
|
scrollWidth,
|
|
clientHeight,
|
|
clientWidth,
|
|
} = viewport.value;
|
|
|
|
if (isVertical.value) {
|
|
const thumbHeight = (clientHeight / scrollHeight) * clientHeight;
|
|
const thumbPosition = (scrollTop / scrollHeight) * clientHeight;
|
|
thumb.value.style.height = `${thumbHeight}px`;
|
|
thumb.value.style.transform = `translateY(${thumbPosition}px)`;
|
|
thumb.value.style.width = '6px';
|
|
}
|
|
|
|
if (isHorizontal.value) {
|
|
const thumbWidth = (clientWidth / scrollWidth) * clientWidth;
|
|
const thumbPosition = (scrollLeft / scrollWidth) * clientWidth;
|
|
thumb.value.style.width = `${thumbWidth}px`;
|
|
thumb.value.style.transform = `translateX(${thumbPosition}px)`;
|
|
thumb.value.style.height = '6px';
|
|
}
|
|
};
|
|
|
|
const handleThumbMousedown = (e) => {
|
|
e.preventDefault();
|
|
isDragging = true;
|
|
startPosition = { x: e.clientX, y: e.clientY };
|
|
startScroll = {
|
|
left: viewport.value.scrollLeft,
|
|
top: viewport.value.scrollTop,
|
|
};
|
|
document.addEventListener('mousemove', handleThumbMousemove);
|
|
document.addEventListener('mouseup', handleThumbMouseup);
|
|
};
|
|
|
|
const handleThumbMousemove = (e) => {
|
|
if (!isDragging) return;
|
|
|
|
const { scrollHeight, scrollWidth, clientHeight, clientWidth } = viewport.value;
|
|
|
|
if (isVertical.value) {
|
|
const delta = e.clientY - startPosition.y;
|
|
const scrollFactor = scrollHeight / clientHeight;
|
|
viewport.value.scrollTop = startScroll.top + delta * scrollFactor;
|
|
}
|
|
|
|
if (isHorizontal.value) {
|
|
const delta = e.clientX - startPosition.x;
|
|
const scrollFactor = scrollWidth / clientWidth;
|
|
viewport.value.scrollLeft = startScroll.left + delta * scrollFactor;
|
|
}
|
|
};
|
|
|
|
const handleThumbMouseup = () => {
|
|
isDragging = false;
|
|
document.removeEventListener('mousemove', handleThumbMousemove);
|
|
document.removeEventListener('mouseup', handleThumbMouseup);
|
|
if (props.type === 'hover') {
|
|
handleMouseLeave();
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
updateThumbPosition();
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
clearTimeout(hideScrollbarTimeout);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="relative"
|
|
@mouseenter="handleMouseEnter"
|
|
@mouseleave="handleMouseLeave"
|
|
>
|
|
<div
|
|
ref="viewport"
|
|
class="scrollarea-viewport h-full w-full overflow-auto"
|
|
:class="{
|
|
'overflow-y-auto overflow-x-hidden': orientation === 'vertical',
|
|
'overflow-x-auto overflow-y-hidden': orientation === 'horizontal',
|
|
'overflow-auto': orientation === 'both',
|
|
}"
|
|
@scroll="handleScroll"
|
|
>
|
|
<div class="scrollarea-content">
|
|
<slot />
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-show="showScrollbar"
|
|
ref="scrollbar"
|
|
class="scrollarea-scrollbar touch-none select-none transition-colors"
|
|
:class="{
|
|
'right-0.5 top-0.5 bottom-0.5 w-2': orientation === 'vertical',
|
|
'bottom-0.5 left-0.5 right-0.5 h-2': orientation === 'horizontal',
|
|
}"
|
|
>
|
|
<div
|
|
ref="thumb"
|
|
class="scrollarea-thumb relative rounded-full bg-border hover:bg-foreground/50"
|
|
@mousedown="handleThumbMousedown"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.scrollarea-viewport {
|
|
scrollbar-width: none;
|
|
-ms-overflow-style: none;
|
|
}
|
|
|
|
.scrollarea-viewport::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.scrollarea-scrollbar {
|
|
position: absolute;
|
|
background-color: transparent;
|
|
z-index: 10;
|
|
}
|
|
|
|
.scrollarea-thumb {
|
|
position: absolute;
|
|
background-color: rgb(var(--border));
|
|
cursor: pointer;
|
|
border-radius: 9999px;
|
|
}
|
|
|
|
.scrollarea-thumb:hover {
|
|
background-color: rgb(var(--foreground), 0.5);
|
|
}
|
|
|
|
.scrollarea-viewport {
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
</style> |