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>
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||||
import type { Direction, Type } from "./interface";
|
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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
@ -30,12 +33,35 @@ const classes = computed(() => {
|
||||||
return [`tabbar-${props.type}`, `tabbar-direction-${props.direction}`];
|
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("update:activeId", id);
|
||||||
emit("change", id);
|
emit("change", id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabbarItemsRef = ref<HTMLElement | undefined>();
|
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) {
|
function handleHorizontalWheel(event: WheelEvent) {
|
||||||
if (!tabbarItemsRef.value) {
|
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(() => {
|
onMounted(() => {
|
||||||
tabbarItemsRef.value?.addEventListener("wheel", handleHorizontalWheel);
|
tabbarItemsRef.value?.addEventListener("wheel", handleHorizontalWheel);
|
||||||
|
tabbarItemsRef.value?.addEventListener("scroll", handleScroll);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
tabbarItemsRef.value?.removeEventListener("wheel", handleHorizontalWheel);
|
tabbarItemsRef.value?.removeEventListener("wheel", handleHorizontalWheel);
|
||||||
|
tabbarItemsRef.value?.removeEventListener("scroll", handleScroll);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div :class="classes" class="tabbar-wrapper">
|
<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 ref="tabbarItemsRef" class="tabbar-items">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in items"
|
v-for="(item, index) in items"
|
||||||
:key="index"
|
:key="index"
|
||||||
|
ref="tabItemRefs"
|
||||||
:class="{ 'tabbar-item-active': item[idKey] === activeId }"
|
:class="{ 'tabbar-item-active': item[idKey] === activeId }"
|
||||||
class="tabbar-item"
|
class="tabbar-item"
|
||||||
@click="handleChange(item[idKey])"
|
@click="handleChange(item[idKey], index)"
|
||||||
>
|
>
|
||||||
<div v-if="item.icon" class="tabbar-item-icon">
|
<div v-if="item.icon" class="tabbar-item-icon">
|
||||||
<component :is="item.icon" />
|
<component :is="item.icon" />
|
||||||
|
@ -82,6 +223,51 @@ onUnmounted(() => {
|
||||||
</template>
|
</template>
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.tabbar-wrapper {
|
.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 {
|
.tabbar-items {
|
||||||
@apply flex
|
@apply flex
|
||||||
items-center
|
items-center
|
||||||
|
|
|
@ -1,2 +1,6 @@
|
||||||
export type Type = "default" | "pills" | "outline";
|
export type Type = "default" | "pills" | "outline";
|
||||||
export type Direction = "row" | "column";
|
export type Direction = "row" | "column";
|
||||||
|
export type ArrowShow = {
|
||||||
|
left: boolean;
|
||||||
|
right: boolean;
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in New Issue