137 lines
3.7 KiB
Vue
137 lines
3.7 KiB
Vue
<script setup lang="ts">
|
|
import { ChevronLeft, ChevronRight } from '@lucide/vue';
|
|
import { ref, onMounted, nextTick } from 'vue';
|
|
|
|
const track = ref<HTMLElement | null>(null);
|
|
const canScrollLeft = ref(false);
|
|
const canScrollRight = ref(false);
|
|
|
|
// Drag state
|
|
const isDragging = ref(false);
|
|
const dragStartX = ref(0);
|
|
const dragScrollStart = ref(0);
|
|
const hasDragged = ref(false);
|
|
let dragRafId = 0;
|
|
|
|
const updateScrollButtons = () => {
|
|
const el = track.value;
|
|
|
|
if (!el) {
|
|
return;
|
|
}
|
|
|
|
canScrollLeft.value = el.scrollLeft > 4;
|
|
canScrollRight.value = el.scrollLeft + el.clientWidth < el.scrollWidth - 4;
|
|
};
|
|
|
|
const scrollTo = (direction: 'left' | 'right') => {
|
|
const el = track.value;
|
|
|
|
if (!el) {
|
|
return;
|
|
}
|
|
|
|
const amount = el.clientWidth * 0.6;
|
|
el.style.scrollBehavior = 'smooth';
|
|
el.scrollBy({ left: direction === 'left' ? -amount : amount });
|
|
|
|
requestAnimationFrame(() => {
|
|
el.style.scrollBehavior = '';
|
|
});
|
|
};
|
|
|
|
const onDragStart = (e: MouseEvent | TouchEvent) => {
|
|
const el = track.value;
|
|
|
|
if (!el) {
|
|
return;
|
|
}
|
|
|
|
isDragging.value = true;
|
|
hasDragged.value = false;
|
|
dragStartX.value = 'touches' in e ? e.touches[0].pageX : e.pageX;
|
|
dragScrollStart.value = el.scrollLeft;
|
|
};
|
|
|
|
const onDragMove = (e: MouseEvent | TouchEvent) => {
|
|
if (!isDragging.value) {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
|
|
const el = track.value;
|
|
|
|
if (!el) {
|
|
return;
|
|
}
|
|
|
|
const currentX = 'touches' in e ? e.touches[0].pageX : e.pageX;
|
|
const delta = currentX - dragStartX.value;
|
|
|
|
if (Math.abs(delta) > 3) {
|
|
hasDragged.value = true;
|
|
}
|
|
|
|
cancelAnimationFrame(dragRafId);
|
|
dragRafId = requestAnimationFrame(() => {
|
|
el.scrollLeft = dragScrollStart.value - delta;
|
|
});
|
|
};
|
|
|
|
const onDragEnd = () => {
|
|
isDragging.value = false;
|
|
cancelAnimationFrame(dragRafId);
|
|
nextTick(() => updateScrollButtons());
|
|
};
|
|
|
|
const preventClick = (e: Event) => {
|
|
if (hasDragged.value) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
nextTick(() => updateScrollButtons());
|
|
});
|
|
|
|
defineExpose({ updateScrollButtons });
|
|
</script>
|
|
|
|
<template>
|
|
<div class="relative flex items-center min-w-0">
|
|
<button v-show="canScrollLeft" @click="scrollTo('left')"
|
|
class="absolute left-0 z-10 flex items-center justify-center w-7 h-7 rounded-full bg-white/90 border border-slate-200 shadow-sm hover:bg-amber-50 hover:border-amber-300 transition-all cursor-pointer -ml-1">
|
|
<ChevronLeft class="w-4 h-4 text-slate-500" />
|
|
</button>
|
|
|
|
<div ref="track" @scroll="updateScrollButtons"
|
|
@mousedown="onDragStart" @mousemove="onDragMove" @mouseup="onDragEnd" @mouseleave="onDragEnd"
|
|
@touchstart.passive="onDragStart" @touchmove="onDragMove" @touchend="onDragEnd"
|
|
@click.capture="preventClick"
|
|
class="flex gap-2 overflow-x-auto scrollbar-hide px-1 py-1 select-none touch-pan-y will-change-scroll"
|
|
:class="[
|
|
{ 'mx-8': canScrollLeft || canScrollRight },
|
|
isDragging ? 'cursor-grabbing' : 'cursor-grab'
|
|
]">
|
|
<slot />
|
|
</div>
|
|
|
|
<button v-show="canScrollRight" @click="scrollTo('right')"
|
|
class="absolute right-0 z-10 flex items-center justify-center w-7 h-7 rounded-full bg-white/90 border border-slate-200 shadow-sm hover:bg-amber-50 hover:border-amber-300 transition-all cursor-pointer -mr-1">
|
|
<ChevronRight class="w-4 h-4 text-slate-500" />
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.scrollbar-hide::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
.scrollbar-hide {
|
|
-ms-overflow-style: none;
|
|
scrollbar-width: none;
|
|
}
|
|
</style>
|