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
Aero 2023-12-21 14:44:12 +08:00 committed by GitHub
parent b8d5d1f0e4
commit 61c4a226b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 194 additions and 4 deletions

View File

@ -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

View File

@ -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;
};