mirror of https://github.com/halo-dev/halo
feat: add tabbar component horizontal-scroll-indicator (#4979)
#### What type of PR is this? /area console /kind improvement #### What this PR does / why we need it: 在 Tabbar 组件内容可滚动时,添加内容超出时的水平方向滚动指示器;解决由 #4582 指出的体验问题 #### Which issue(s) this PR fixes: Fixes #4582 #### Special notes for your reviewer: 注意观察各处使用 Tabbar 组件且内容可滚动时的情况(浏览器宽度变化也可生效) #### Does this PR introduce a user-facing change? ```release-note 添加 Tabbar 组件内容超出时的水平方向滚动指示器 ```pull/5101/head^2
parent
b8d5d1f0e4
commit
61c4a226b0
|
@ -1,6 +1,9 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import type { Direction, Type } from "./interface";
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import type { ArrowShow, Direction, Type } from "./interface";
|
||||
import type { ComputedRef } from "vue";
|
||||
import { useElementSize, useThrottleFn } from "@vueuse/core";
|
||||
import { IconArrowLeft, IconArrowRight } from "../../icons/icons";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
@ -30,12 +33,35 @@ const classes = computed(() => {
|
|||
return [`tabbar-${props.type}`, `tabbar-direction-${props.direction}`];
|
||||
});
|
||||
|
||||
const handleChange = (id: number | string) => {
|
||||
const handleChange = (id: number | string, index: number) => {
|
||||
handleClickTabItem(index);
|
||||
emit("update:activeId", id);
|
||||
emit("change", id);
|
||||
};
|
||||
|
||||
const tabbarItemsRef = ref<HTMLElement | undefined>();
|
||||
const tabItemRefs = ref<HTMLElement[] | undefined>();
|
||||
const itemWidthArr = ref<number[]>([]);
|
||||
const indicatorRef = ref<HTMLElement | undefined>();
|
||||
const arrowFlag = ref(false);
|
||||
const { width: tabbarWidth } = useElementSize(tabbarItemsRef);
|
||||
|
||||
const arrowShow: ComputedRef<ArrowShow> = computed(() => {
|
||||
const show: ArrowShow = { left: false, right: false };
|
||||
if (!tabbarItemsRef.value) return show;
|
||||
void arrowFlag.value;
|
||||
void tabbarWidth.value;
|
||||
const { scrollWidth, scrollLeft, clientWidth } = tabbarItemsRef.value;
|
||||
if (scrollWidth > clientWidth) {
|
||||
if (scrollLeft < scrollWidth - clientWidth) {
|
||||
show.right = true;
|
||||
}
|
||||
if (scrollLeft > 20) {
|
||||
show.left = true;
|
||||
}
|
||||
}
|
||||
return show;
|
||||
});
|
||||
|
||||
function handleHorizontalWheel(event: WheelEvent) {
|
||||
if (!tabbarItemsRef.value) {
|
||||
|
@ -52,23 +78,138 @@ function handleHorizontalWheel(event: WheelEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
function saveItemsWidth() {
|
||||
if (!tabbarItemsRef.value || !tabItemRefs.value) return;
|
||||
itemWidthArr.value = [];
|
||||
for (const item of tabItemRefs.value) {
|
||||
itemWidthArr.value.push(item.offsetWidth);
|
||||
}
|
||||
arrowFlag.value = !arrowFlag.value;
|
||||
}
|
||||
|
||||
function handleClickTabItem(index: number) {
|
||||
if (!tabbarItemsRef.value || !indicatorRef.value) return;
|
||||
const { scrollWidth, clientWidth } = tabbarItemsRef.value;
|
||||
if (scrollWidth <= clientWidth) return;
|
||||
if (index === 0) {
|
||||
tabbarItemsRef.value.scrollTo({ left: 0, behavior: "smooth" });
|
||||
return;
|
||||
}
|
||||
if (index === itemWidthArr.value.length - 1) {
|
||||
tabbarItemsRef.value.scrollTo({
|
||||
left: scrollWidth - clientWidth,
|
||||
behavior: "smooth",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickArrow(prev: boolean) {
|
||||
if (!tabbarItemsRef.value || !indicatorRef.value || !tabItemRefs.value)
|
||||
return;
|
||||
const { scrollWidth, scrollLeft, clientWidth } = tabbarItemsRef.value;
|
||||
if (scrollWidth <= clientWidth) return;
|
||||
if (!itemWidthArr.value[0]) {
|
||||
itemWidthArr.value = [];
|
||||
for (const item of tabItemRefs.value) {
|
||||
itemWidthArr.value.push(item.offsetWidth);
|
||||
}
|
||||
}
|
||||
let hiddenNum = 0;
|
||||
let totalWith = 0;
|
||||
let scrollByX = 0;
|
||||
const lastItemWidth = itemWidthArr.value[itemWidthArr.value.length - 1];
|
||||
if (prev) {
|
||||
for (let i = 0; i < itemWidthArr.value.length; i++) {
|
||||
const w = itemWidthArr.value[i];
|
||||
totalWith += w;
|
||||
if (totalWith >= scrollLeft) {
|
||||
hiddenNum = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hiddenNum === 0) {
|
||||
scrollByX = -itemWidthArr.value[0];
|
||||
} else {
|
||||
scrollByX = -(
|
||||
itemWidthArr.value[hiddenNum] -
|
||||
totalWith +
|
||||
scrollLeft +
|
||||
itemWidthArr.value[hiddenNum - 1]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const overWidth = scrollWidth - scrollLeft - clientWidth;
|
||||
for (let i = itemWidthArr.value.length - 1; i >= 0; i--) {
|
||||
const w = itemWidthArr.value[i];
|
||||
totalWith += w;
|
||||
if (totalWith >= overWidth) {
|
||||
hiddenNum = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hiddenNum === itemWidthArr.value.length - 1) {
|
||||
scrollByX =
|
||||
lastItemWidth + itemWidthArr.value[itemWidthArr.value.length - 1];
|
||||
} else {
|
||||
scrollByX =
|
||||
itemWidthArr.value[hiddenNum] -
|
||||
(totalWith - overWidth) +
|
||||
itemWidthArr.value[hiddenNum + 1];
|
||||
}
|
||||
}
|
||||
tabbarItemsRef.value.scrollBy({
|
||||
left: scrollByX,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
const handleScroll = useThrottleFn(
|
||||
() => {
|
||||
arrowFlag.value = !arrowFlag.value;
|
||||
},
|
||||
100,
|
||||
true
|
||||
);
|
||||
|
||||
watch(() => tabItemRefs.value?.length, saveItemsWidth);
|
||||
|
||||
onMounted(() => {
|
||||
tabbarItemsRef.value?.addEventListener("wheel", handleHorizontalWheel);
|
||||
tabbarItemsRef.value?.addEventListener("scroll", handleScroll);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
tabbarItemsRef.value?.removeEventListener("wheel", handleHorizontalWheel);
|
||||
tabbarItemsRef.value?.removeEventListener("scroll", handleScroll);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div :class="classes" class="tabbar-wrapper">
|
||||
<div
|
||||
ref="indicatorRef"
|
||||
:class="['indicator', 'left', arrowShow.left ? 'visible' : 'invisible']"
|
||||
>
|
||||
<div title="向前" class="arrow-left" @click="handleClickArrow(true)">
|
||||
<IconArrowLeft />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="['indicator', 'right', arrowShow.right ? 'visible' : 'invisible']"
|
||||
>
|
||||
<div title="向后" class="arrow-right" @click="handleClickArrow(false)">
|
||||
<IconArrowRight />
|
||||
</div>
|
||||
</div>
|
||||
<div ref="tabbarItemsRef" class="tabbar-items">
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
ref="tabItemRefs"
|
||||
:class="{ 'tabbar-item-active': item[idKey] === activeId }"
|
||||
class="tabbar-item"
|
||||
@click="handleChange(item[idKey])"
|
||||
@click="handleChange(item[idKey], index)"
|
||||
>
|
||||
<div v-if="item.icon" class="tabbar-item-icon">
|
||||
<component :is="item.icon" />
|
||||
|
@ -82,6 +223,51 @@ onUnmounted(() => {
|
|||
</template>
|
||||
<style lang="scss">
|
||||
.tabbar-wrapper {
|
||||
@apply relative;
|
||||
.indicator {
|
||||
@apply absolute
|
||||
top-0
|
||||
z-10
|
||||
w-20
|
||||
h-full
|
||||
flex
|
||||
items-center
|
||||
from-transparent
|
||||
from-10%
|
||||
via-white/80
|
||||
via-30%
|
||||
to-white
|
||||
to-70%
|
||||
pt-1
|
||||
pointer-events-none
|
||||
pb-1.5;
|
||||
|
||||
&.left {
|
||||
@apply left-0
|
||||
justify-start
|
||||
bg-gradient-to-l;
|
||||
}
|
||||
&.right {
|
||||
@apply right-0
|
||||
justify-end
|
||||
bg-gradient-to-r;
|
||||
}
|
||||
.arrow-left,
|
||||
.arrow-right {
|
||||
@apply w-10
|
||||
h-9
|
||||
flex
|
||||
justify-center
|
||||
items-center
|
||||
pointer-events-auto
|
||||
cursor-pointer
|
||||
select-none;
|
||||
svg {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabbar-items {
|
||||
@apply flex
|
||||
items-center
|
||||
|
|
|
@ -1,2 +1,6 @@
|
|||
export type Type = "default" | "pills" | "outline";
|
||||
export type Direction = "row" | "column";
|
||||
export type ArrowShow = {
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue