Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add tabbar component horizontal-scroll-indicator #4979

Merged
merged 12 commits into from
Dec 21, 2023
Merged
205 changes: 201 additions & 4 deletions console/packages/components/src/components/tabs/Tabbar.vue
Original file line number Diff line number Diff line change
@@ -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 } from "@vueuse/core";
import { IconArrowLeft, IconArrowRight } from "../../icons/icons";

const props = withDefaults(
defineProps<{
Expand Down Expand Up @@ -30,12 +33,34 @@ const classes = computed(() => {
return [`tabbar-${props.type}`, `tabbar-direction-${props.direction}`];
});

const handleChange = (id: number | string) => {
const handleChange = (id: number | string, index: number) => {
handleClickArrow(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;
const { scrollWidth, scrollLeft } = tabbarItemsRef.value;
if (scrollWidth > tabbarWidth.value) {
if (scrollLeft < scrollWidth - tabbarWidth.value) {
show.right = true;
}
if (scrollLeft > 20) {
show.left = true;
}
}
return show;
});

function handleHorizontalWheel(event: WheelEvent) {
if (!tabbarItemsRef.value) {
Expand All @@ -52,23 +77,151 @@ function handleHorizontalWheel(event: WheelEvent) {
}
}

// 保存每项 tab 宽度
AeroWang marked this conversation as resolved.
Show resolved Hide resolved
function calculateItemWidth(
n: Record<string, string>[] | undefined,
o: Record<string, string>[] | undefined
) {
if (!tabbarItemsRef.value) return;
if (
tabItemRefs.value &&
tabItemRefs.value.length === n?.length &&
n?.length !== o?.length
) {
for (const item of tabItemRefs.value) {
itemWidthArr.value.push(item.offsetWidth);
}
arrowFlag.value = !arrowFlag.value;
}
}

AeroWang marked this conversation as resolved.
Show resolved Hide resolved
// 以单标签距离滚动
AeroWang marked this conversation as resolved.
Show resolved Hide resolved
function handleClickArrow(
index: number | undefined,
prev: boolean | undefined = undefined
) {
AeroWang marked this conversation as resolved.
Show resolved Hide resolved
if (!tabbarItemsRef.value || !indicatorRef.value) return;
const { scrollWidth, scrollLeft, 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;
}
let hiddenNum = 0;
let totalWith = 0;
let overWidth = 0;
AeroWang marked this conversation as resolved.
Show resolved Hide resolved
let scrollByX = 0;
const lastItemWidth = itemWidthArr.value[itemWidthArr.value.length - 1];
if (prev) {
overWidth = scrollLeft;
for (let i = 0; i < itemWidthArr.value.length; i++) {
const w = itemWidthArr.value[i];
totalWith += w;
if (totalWith >= overWidth) {
hiddenNum = i;
break;
}
}
if (hiddenNum === 0) {
scrollByX = -itemWidthArr.value[0];
} else {
scrollByX = -(
itemWidthArr.value[hiddenNum] -
totalWith +
overWidth +
itemWidthArr.value[hiddenNum - 1]
);
}
} else if (prev !== undefined && !prev) {
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 ||
hiddenNum === itemWidthArr.value.length - 2
) {
scrollByX =
lastItemWidth + itemWidthArr.value[itemWidthArr.value.length - 2];
AeroWang marked this conversation as resolved.
Show resolved Hide resolved
} else {
scrollByX =
itemWidthArr.value[hiddenNum] -
(totalWith - overWidth) +
itemWidthArr.value[hiddenNum + 1];
}
}
tabbarItemsRef.value.scrollBy({
left: scrollByX,
behavior: "smooth",
});
}

// NOTE: throttle ?
AeroWang marked this conversation as resolved.
Show resolved Hide resolved
const handleScroll = () => {
arrowFlag.value = !arrowFlag.value;
};
AeroWang marked this conversation as resolved.
Show resolved Hide resolved

watch(() => props.items, calculateItemWidth, {
flush: "post",
deep: true,
});

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(undefined, true)"
>
<IconArrowLeft />
</div>
</div>
<div
:class="['indicator', 'right', arrowShow.right ? 'visible' : 'invisible']"
>
<div
title="向后"
class="arrow-right"
@click="handleClickArrow(undefined, 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" />
Expand All @@ -82,6 +235,50 @@ onUnmounted(() => {
</template>
<style lang="scss">
.tabbar-wrapper {
@apply relative;
.indicator {
@apply absolute
top-0
z-10
w-20
AeroWang marked this conversation as resolved.
Show resolved Hide resolved
h-full
flex
items-center
from-transparent
from-10%
via-white/80
via-30%
to-white
to-70%
pt-1
pb-1.5;
AeroWang marked this conversation as resolved.
Show resolved Hide resolved

&.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
Expand Down
4 changes: 4 additions & 0 deletions console/packages/components/src/components/tabs/interface.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
export type Type = "default" | "pills" | "outline";
export type Direction = "row" | "column";
export type ArrowShow = {
left: boolean;
right: boolean;
};
Loading