perf: optimization of tabbar display (#4169)
* perf: optimization of tabbar display * fix: ci error * chore: typo * chore: typo
This commit is contained in:
@@ -3,10 +3,10 @@ import type { TabDefinition } from '@vben-core/typings';
|
||||
|
||||
import type { TabConfig, TabsProps } from '../../types';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { MdiPin, X } from '@vben-core/icons';
|
||||
import { VbenContextMenu, VbenIcon, VbenScrollbar } from '@vben-core/shadcn-ui';
|
||||
import { VbenContextMenu, VbenIcon } from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props extends TabsProps {}
|
||||
|
||||
@@ -20,17 +20,17 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
contentClass: 'vben-tabs-content',
|
||||
contextMenus: () => [],
|
||||
gap: 7,
|
||||
maxWidth: 150,
|
||||
minWidth: 80,
|
||||
tabs: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ close: [string]; unpin: [TabDefinition] }>();
|
||||
const emit = defineEmits<{
|
||||
close: [string];
|
||||
unpin: [TabDefinition];
|
||||
}>();
|
||||
const active = defineModel<string>('active');
|
||||
|
||||
const contentRef = ref();
|
||||
const tabRef = ref();
|
||||
const tabWidth = ref<number>(props.maxWidth);
|
||||
|
||||
const style = computed(() => {
|
||||
const { gap } = props;
|
||||
@@ -53,148 +53,118 @@ const tabsView = computed((): TabConfig[] => {
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
watch(active, () => {
|
||||
scrollIntoView();
|
||||
});
|
||||
|
||||
function scrollIntoView() {
|
||||
setTimeout(() => {
|
||||
const element = document.querySelector(`.tabs-chrome__item.is-active`);
|
||||
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="style" class="tabs-chrome size-full flex-1 overflow-hidden pt-1">
|
||||
<VbenScrollbar
|
||||
id="tabs-scrollbar"
|
||||
class="tabs-chrome__scrollbar h-full"
|
||||
horizontal
|
||||
scroll-bar-class="z-10 hidden"
|
||||
>
|
||||
<!-- footer -> 4px -->
|
||||
<div
|
||||
ref="contentRef"
|
||||
:class="contentClass"
|
||||
:style="style"
|
||||
class="tabs-chrome !flex h-full w-max pr-6"
|
||||
>
|
||||
<TransitionGroup name="slide-left">
|
||||
<div
|
||||
ref="contentRef"
|
||||
:class="contentClass"
|
||||
class="relative !flex h-full w-max"
|
||||
v-for="(tab, i) in tabsView"
|
||||
:key="tab.key"
|
||||
ref="tabRef"
|
||||
:class="[{ 'is-active': tab.key === active, dragable: !tab.affixTab }]"
|
||||
:data-active-tab="active"
|
||||
:data-index="i"
|
||||
class="tabs-chrome__item draggable group relative -mr-3 flex h-full select-none items-center"
|
||||
data-tab-item="true"
|
||||
@click="active = tab.key"
|
||||
>
|
||||
<TransitionGroup name="slide-left">
|
||||
<div
|
||||
v-for="(tab, i) in tabsView"
|
||||
:key="tab.key"
|
||||
ref="tabRef"
|
||||
:class="[
|
||||
{ 'is-active': tab.key === active, dragable: !tab.affixTab },
|
||||
]"
|
||||
:data-active-tab="active"
|
||||
:data-index="i"
|
||||
:style="{
|
||||
width: `${tabWidth}px`,
|
||||
left: `${(tabWidth - gap * 2) * i}px`,
|
||||
}"
|
||||
class="tabs-chrome__item group absolute flex h-full select-none items-center transition-all"
|
||||
@click="active = tab.key"
|
||||
>
|
||||
<VbenContextMenu
|
||||
:handler-data="tab"
|
||||
:menus="contextMenus"
|
||||
:modal="false"
|
||||
item-class="pr-6"
|
||||
<VbenContextMenu
|
||||
:handler-data="tab"
|
||||
:menus="contextMenus"
|
||||
:modal="false"
|
||||
item-class="pr-6"
|
||||
>
|
||||
<div class="relative size-full px-1">
|
||||
<!-- divider -->
|
||||
<div
|
||||
v-if="i !== 0 && tab.key !== active"
|
||||
class="tabs-chrome__divider bg-foreground/50 absolute left-[var(--gap)] top-1/2 z-0 h-4 w-[1px] translate-y-[-50%] transition-all"
|
||||
></div>
|
||||
<!-- background -->
|
||||
<div
|
||||
class="tabs-chrome__background absolute z-[-1] size-full px-[calc(var(--gap)-1px)] py-0 transition-opacity duration-150"
|
||||
>
|
||||
<div class="size-full">
|
||||
<!-- divider -->
|
||||
<div
|
||||
v-if="i !== 0 && tab.key !== active"
|
||||
class="tabs-chrome__divider bg-foreground/60 absolute left-[var(--gap)] top-1/2 z-0 h-4 w-[1px] translate-y-[-50%] transition-all"
|
||||
></div>
|
||||
<!-- background -->
|
||||
<div
|
||||
class="tabs-chrome__background absolute z-[1] size-full px-[calc(var(--gap)-1px)] py-0 transition-opacity duration-150"
|
||||
>
|
||||
<div
|
||||
class="tabs-chrome__background-content group-[.is-active]:bg-primary/15 dark:group-[.is-active]:bg-accent h-full rounded-tl-[var(--gap)] rounded-tr-[var(--gap)] duration-150"
|
||||
></div>
|
||||
<svg
|
||||
class="tabs-chrome__background-before group-[.is-active]:fill-primary/15 dark:group-[.is-active]:fill-accent absolute bottom-0 left-[-1px] fill-transparent transition-all duration-150"
|
||||
height="7"
|
||||
width="7"
|
||||
>
|
||||
<path d="M 0 7 A 7 7 0 0 0 7 0 L 7 7 Z" />
|
||||
</svg>
|
||||
<svg
|
||||
class="tabs-chrome__background-after group-[.is-active]:fill-primary/15 dark:group-[.is-active]:fill-accent absolute bottom-0 right-[-1px] fill-transparent transition-all duration-150"
|
||||
height="7"
|
||||
width="7"
|
||||
>
|
||||
<path d="M 0 0 A 7 7 0 0 0 7 7 L 0 7 Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="tabs-chrome__background-content group-[.is-active]:bg-heavy dark:group-[.is-active]:bg-accent h-full rounded-tl-[var(--gap)] rounded-tr-[var(--gap)] duration-150"
|
||||
></div>
|
||||
<svg
|
||||
class="tabs-chrome__background-before group-[.is-active]:fill-primary/15 dark:group-[.is-active]:fill-accent absolute bottom-0 left-[-1px] fill-transparent transition-all duration-150"
|
||||
height="7"
|
||||
width="7"
|
||||
>
|
||||
<path d="M 0 7 A 7 7 0 0 0 7 0 L 7 7 Z" />
|
||||
</svg>
|
||||
<svg
|
||||
class="tabs-chrome__background-after group-[.is-active]:fill-primary/15 dark:group-[.is-active]:fill-accent absolute bottom-0 right-[-1px] fill-transparent transition-all duration-150"
|
||||
height="7"
|
||||
width="7"
|
||||
>
|
||||
<path d="M 0 0 A 7 7 0 0 0 7 7 L 0 7 Z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- extra -->
|
||||
<div
|
||||
class="tabs-chrome__extra absolute right-[calc(var(--gap)*1.5)] top-1/2 z-[3] size-4 translate-y-[-50%]"
|
||||
>
|
||||
<!-- close-icon -->
|
||||
<X
|
||||
v-show="
|
||||
!tab.affixTab && tabsView.length > 1 && tab.closable
|
||||
"
|
||||
class="hover:bg-accent stroke-accent-foreground/80 hover:stroke-accent-foreground dark:group-[.is-active]:text-accent-foreground group-[.is-active]:text-primary mt-[2px] size-3 cursor-pointer rounded-full transition-all"
|
||||
@click.stop="() => emit('close', tab.key)"
|
||||
/>
|
||||
<MdiPin
|
||||
v-show="tab.affixTab && tabsView.length > 1 && tab.closable"
|
||||
class="hover:bg-accent hover:stroke-accent-foreground group-[.is-active]:text-primary dark:group-[.is-active]:text-accent-foreground mt-[2px] size-3.5 cursor-pointer rounded-full transition-all"
|
||||
@click.stop="() => emit('unpin', tab)"
|
||||
/>
|
||||
</div>
|
||||
<!-- extra -->
|
||||
<div
|
||||
class="tabs-chrome__extra absolute right-[var(--gap)] top-1/2 z-[3] size-4 translate-y-[-50%]"
|
||||
>
|
||||
<!-- close-icon -->
|
||||
<X
|
||||
v-show="!tab.affixTab && tabsView.length > 1 && tab.closable"
|
||||
class="hover:bg-accent stroke-accent-foreground/80 hover:stroke-accent-foreground text-accent-foreground/80 group-[.is-active]:text-accent-foreground mt-[2px] size-3 cursor-pointer rounded-full transition-all"
|
||||
@click.stop="() => emit('close', tab.key)"
|
||||
/>
|
||||
<MdiPin
|
||||
v-show="tab.affixTab && tabsView.length > 1 && tab.closable"
|
||||
class="hover:text-accent-foreground text-accent-foreground/80 group-[.is-active]:text-accent-foreground mt-[2px] size-3.5 cursor-pointer rounded-full transition-all"
|
||||
@click.stop="() => emit('unpin', tab)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- tab-item-main -->
|
||||
<div
|
||||
class="tabs-chrome__item-main group-[.is-active]:text-primary dark:group-[.is-active]:text-accent-foreground text-accent-foreground absolute left-0 right-0 z-[2] mx-[calc(var(--gap)*2)] my-0 flex h-full items-center overflow-hidden rounded-tl-[5px] rounded-tr-[5px] pr-4 duration-150 group-hover:pr-3"
|
||||
>
|
||||
<VbenIcon
|
||||
v-if="showIcon"
|
||||
:icon="tab.icon"
|
||||
class="ml-[var(--gap)] flex size-4 items-center overflow-hidden"
|
||||
fallback
|
||||
/>
|
||||
<!-- tab-item-main -->
|
||||
<div
|
||||
class="tabs-chrome__item-main group-[.is-active]:text-accent-foreground dark:group-[.is-active]:text-accent-foreground text-accent-foreground z-[2] mx-[calc(var(--gap)*2)] my-0 flex h-full items-center overflow-hidden rounded-tl-[5px] rounded-tr-[5px] pl-2 pr-4 duration-150"
|
||||
>
|
||||
<VbenIcon
|
||||
v-if="showIcon"
|
||||
:icon="tab.icon"
|
||||
class="mr-1 flex size-4 items-center overflow-hidden"
|
||||
fallback
|
||||
/>
|
||||
|
||||
<span
|
||||
class="tabs-chrome__label ml-[var(--gap)] flex-1 overflow-hidden whitespace-nowrap text-sm"
|
||||
>
|
||||
{{ tab.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</VbenContextMenu>
|
||||
<span class="flex-1 overflow-hidden whitespace-nowrap text-sm">
|
||||
{{ tab.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</VbenContextMenu>
|
||||
</div>
|
||||
<!-- footer -->
|
||||
<!-- <div class="bg-background h-1"></div> -->
|
||||
</VbenScrollbar>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tabs-chrome {
|
||||
.dragging {
|
||||
.tabs-chrome__item-main {
|
||||
/* .dragging { */
|
||||
|
||||
/* .tabs-chrome__item-main {
|
||||
@apply pr-0;
|
||||
}
|
||||
} */
|
||||
|
||||
.tabs-chrome__extra {
|
||||
/* .tabs-chrome__extra {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
} */
|
||||
|
||||
/* } */
|
||||
|
||||
&__item:not(.dragging) {
|
||||
@apply cursor-pointer;
|
||||
|
||||
&__item {
|
||||
&:hover:not(.is-active) {
|
||||
& + .tabs-chrome__item {
|
||||
.tabs-chrome__divider {
|
||||
@@ -207,13 +177,10 @@ function scrollIntoView() {
|
||||
}
|
||||
|
||||
.tabs-chrome__background {
|
||||
&-content {
|
||||
@apply bg-accent mx-1 rounded-md pb-2;
|
||||
}
|
||||
@apply pb-[2px];
|
||||
|
||||
&-before,
|
||||
&-after {
|
||||
@apply fill-primary/0;
|
||||
&-content {
|
||||
@apply bg-accent-hover mx-[2px] rounded-md;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,30 +193,7 @@ function scrollIntoView() {
|
||||
@apply opacity-0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-chrome__background {
|
||||
@apply opacity-100;
|
||||
|
||||
/* &-content {
|
||||
@apply bg-accent;
|
||||
}
|
||||
|
||||
&-before,
|
||||
&-after {
|
||||
@apply fill-heavy;
|
||||
} */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__scrollbar,
|
||||
&__label {
|
||||
mask-image: linear-gradient(
|
||||
90deg,
|
||||
#000 0%,
|
||||
#000 calc(100% - 16px),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@@ -3,10 +3,10 @@ import type { TabDefinition } from '@vben-core/typings';
|
||||
|
||||
import type { TabConfig, TabsProps } from '../../types';
|
||||
|
||||
import { computed, watch } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { MdiPin, X } from '@vben-core/icons';
|
||||
import { VbenContextMenu, VbenIcon, VbenScrollbar } from '@vben-core/shadcn-ui';
|
||||
import { VbenContextMenu, VbenIcon } from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props extends TabsProps {}
|
||||
|
||||
@@ -21,7 +21,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
tabs: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ close: [string]; unpin: [TabDefinition] }>();
|
||||
const emit = defineEmits<{
|
||||
close: [string];
|
||||
unpin: [TabDefinition];
|
||||
}>();
|
||||
const active = defineModel<string>('active');
|
||||
|
||||
const typeWithClass = computed(() => {
|
||||
@@ -55,108 +58,71 @@ const tabsView = computed((): TabConfig[] => {
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
watch(active, () => {
|
||||
scrollIntoView();
|
||||
});
|
||||
|
||||
function scrollIntoView() {
|
||||
setTimeout(() => {
|
||||
const element = document.querySelector(`.tabs-chrome__item.is-active`);
|
||||
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="size-full flex-1 overflow-hidden">
|
||||
<VbenScrollbar
|
||||
id="tabs-scrollbar"
|
||||
class="tabs-scrollbar h-full"
|
||||
horizontal
|
||||
scroll-bar-class="z-10 hidden"
|
||||
>
|
||||
<div
|
||||
:class="contentClass"
|
||||
class="relative !flex h-full w-max items-center pr-6"
|
||||
>
|
||||
<TransitionGroup name="slide-left">
|
||||
<div
|
||||
:class="contentClass"
|
||||
class="relative !flex h-full w-max items-center"
|
||||
v-for="(tab, i) in tabsView"
|
||||
:key="tab.key"
|
||||
:class="[
|
||||
{
|
||||
'is-active dark:bg-accent bg-primary/15': tab.key === active,
|
||||
dragable: !tab.affixTab,
|
||||
},
|
||||
typeWithClass.content,
|
||||
]"
|
||||
:data-index="i"
|
||||
class="tab-item [&:not(.is-active)]:hover:bg-accent group relative flex cursor-pointer select-none"
|
||||
data-tab-item="true"
|
||||
@click="active = tab.key"
|
||||
>
|
||||
<TransitionGroup name="slide-left">
|
||||
<div
|
||||
v-for="(tab, i) in tabsView"
|
||||
:key="tab.key"
|
||||
:class="[
|
||||
{
|
||||
'is-active dark:bg-accent bg-primary/15': tab.key === active,
|
||||
dragable: !tab.affixTab,
|
||||
},
|
||||
typeWithClass.content,
|
||||
]"
|
||||
:data-index="i"
|
||||
class="tabs-chrome__item [&:not(.is-active)]:hover:bg-accent group relative flex cursor-pointer select-none transition-all duration-300"
|
||||
@click="active = tab.key"
|
||||
>
|
||||
<VbenContextMenu
|
||||
:handler-data="tab"
|
||||
:menus="contextMenus"
|
||||
:modal="false"
|
||||
item-class="pr-6"
|
||||
<VbenContextMenu
|
||||
:handler-data="tab"
|
||||
:menus="contextMenus"
|
||||
:modal="false"
|
||||
item-class="pr-6"
|
||||
>
|
||||
<div class="relative flex size-full items-center">
|
||||
<!-- extra -->
|
||||
<div
|
||||
class="absolute right-1.5 top-1/2 z-[3] translate-y-[-50%] overflow-hidden"
|
||||
>
|
||||
<div class="relative flex size-full items-center">
|
||||
<!-- extra -->
|
||||
<div
|
||||
class="absolute right-1.5 top-1/2 z-[3] translate-y-[-50%] overflow-hidden"
|
||||
>
|
||||
<!-- close-icon -->
|
||||
<X
|
||||
v-show="
|
||||
!tab.affixTab && tabsView.length > 1 && tab.closable
|
||||
"
|
||||
class="hover:bg-accent stroke-accent-foreground/80 hover:stroke-accent-foreground dark:group-[.is-active]:text-accent-foreground group-[.is-active]:text-primary size-3 cursor-pointer rounded-full transition-all"
|
||||
@click.stop="() => emit('close', tab.key)"
|
||||
/>
|
||||
<MdiPin
|
||||
v-show="tab.affixTab && tabsView.length > 1 && tab.closable"
|
||||
class="hover:bg-accent hover:stroke-accent-foreground group-[.is-active]:text-primary dark:group-[.is-active]:text-accent-foreground mt-[2px] size-3.5 cursor-pointer rounded-full transition-all"
|
||||
@click.stop="() => emit('unpin', tab)"
|
||||
/>
|
||||
</div>
|
||||
<!-- close-icon -->
|
||||
<X
|
||||
v-show="!tab.affixTab && tabsView.length > 1 && tab.closable"
|
||||
class="hover:bg-accent stroke-accent-foreground/80 hover:stroke-accent-foreground dark:group-[.is-active]:text-accent-foreground group-[.is-active]:text-primary size-3 cursor-pointer rounded-full transition-all"
|
||||
@click.stop="() => emit('close', tab.key)"
|
||||
/>
|
||||
<MdiPin
|
||||
v-show="tab.affixTab && tabsView.length > 1 && tab.closable"
|
||||
class="hover:bg-accent hover:stroke-accent-foreground group-[.is-active]:text-primary dark:group-[.is-active]:text-accent-foreground mt-[2px] size-3.5 cursor-pointer rounded-full transition-all"
|
||||
@click.stop="() => emit('unpin', tab)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- tab-item-main -->
|
||||
<div
|
||||
class="text-accent-foreground group-[.is-active]:text-primary dark:group-[.is-active]:text-accent-foreground mx-3 mr-4 flex h-full items-center overflow-hidden rounded-tl-[5px] rounded-tr-[5px] pr-3 transition-all duration-300"
|
||||
>
|
||||
<VbenIcon
|
||||
v-if="showIcon"
|
||||
:icon="tab.icon"
|
||||
class="mr-2 flex size-4 items-center overflow-hidden"
|
||||
fallback
|
||||
/>
|
||||
<!-- tab-item-main -->
|
||||
<div
|
||||
class="text-accent-foreground group-[.is-active]:text-primary dark:group-[.is-active]:text-accent-foreground mx-3 mr-4 flex h-full items-center overflow-hidden rounded-tl-[5px] rounded-tr-[5px] pr-3 transition-all duration-300"
|
||||
>
|
||||
<VbenIcon
|
||||
v-if="showIcon"
|
||||
:icon="tab.icon"
|
||||
class="mr-2 flex size-4 items-center overflow-hidden"
|
||||
fallback
|
||||
/>
|
||||
|
||||
<span
|
||||
class="flex-1 overflow-hidden whitespace-nowrap text-sm"
|
||||
>
|
||||
{{ tab.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</VbenContextMenu>
|
||||
<span class="flex-1 overflow-hidden whitespace-nowrap text-sm">
|
||||
{{ tab.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</VbenContextMenu>
|
||||
</div>
|
||||
</VbenScrollbar>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tabs-scrollbar {
|
||||
mask-image: linear-gradient(
|
||||
90deg,
|
||||
#000 0%,
|
||||
#000 calc(100% - 16px),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,15 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { Sortable } from '@vben-core/composables';
|
||||
import type { TabDefinition } from '@vben-core/typings';
|
||||
import type { TabsEmits, TabsProps } from './types';
|
||||
|
||||
import type { TabsProps } from './types';
|
||||
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { useForwardPropsEmits, useSortable } from '@vben-core/composables';
|
||||
import { useForwardPropsEmits } from '@vben-core/composables';
|
||||
import { ChevronLeft, ChevronRight } from '@vben-core/icons';
|
||||
import { VbenScrollbar } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { Tabs, TabsChrome } from './components';
|
||||
import { useTabsDrag } from './use-tabs-drag';
|
||||
import { useTabsViewScroll } from './use-tabs-view-scroll';
|
||||
|
||||
interface Props extends TabsProps {}
|
||||
@@ -24,136 +21,69 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
styleType: 'chrome',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [string];
|
||||
sortTabs: [number, number];
|
||||
unpin: [TabDefinition];
|
||||
}>();
|
||||
const emit = defineEmits<TabsEmits>();
|
||||
|
||||
const forward = useForwardPropsEmits(props, emit);
|
||||
|
||||
const { initScrollbar, scrollDirection } = useTabsViewScroll();
|
||||
const {
|
||||
handleScrollAt,
|
||||
scrollbarRef,
|
||||
scrollDirection,
|
||||
scrollIsAtLeft,
|
||||
scrollIsAtRight,
|
||||
showScrollButton,
|
||||
} = useTabsViewScroll(props);
|
||||
|
||||
const sortableInstance = ref<null | Sortable>(null);
|
||||
|
||||
// 可能会找到拖拽的子元素,这里需要确保拖拽的dom时tab元素
|
||||
function findParentElement(element: HTMLElement) {
|
||||
const parentCls = 'group';
|
||||
return element.classList.contains(parentCls)
|
||||
? element
|
||||
: element.closest(`.${parentCls}`);
|
||||
}
|
||||
|
||||
async function initTabsSortable() {
|
||||
await nextTick();
|
||||
const { contentClass } = props;
|
||||
|
||||
const el = document.querySelectorAll(`.${contentClass}`)?.[0] as HTMLElement;
|
||||
|
||||
const resetElState = () => {
|
||||
el.style.cursor = 'default';
|
||||
el.classList.remove('dragging');
|
||||
};
|
||||
|
||||
const { initializeSortable } = useSortable(el, {
|
||||
filter: (_evt, target: HTMLElement) => {
|
||||
const parent = findParentElement(target);
|
||||
const dragable = parent?.classList.contains('dragable');
|
||||
return !dragable || !props.dragable;
|
||||
},
|
||||
onEnd(evt) {
|
||||
const { newIndex, oldIndex } = evt;
|
||||
// const fromElement = evt.item;
|
||||
const { srcElement } = (evt as any).originalEvent;
|
||||
|
||||
if (!srcElement) {
|
||||
resetElState();
|
||||
return;
|
||||
}
|
||||
|
||||
const srcParent = findParentElement(srcElement);
|
||||
|
||||
if (!srcParent) {
|
||||
resetElState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!srcParent.classList.contains('dragable')) {
|
||||
resetElState();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
oldIndex !== undefined &&
|
||||
newIndex !== undefined &&
|
||||
!Number.isNaN(oldIndex) &&
|
||||
!Number.isNaN(newIndex) &&
|
||||
oldIndex !== newIndex
|
||||
) {
|
||||
emit('sortTabs', oldIndex, newIndex);
|
||||
}
|
||||
resetElState();
|
||||
},
|
||||
onMove(evt) {
|
||||
const parent = findParentElement(evt.related);
|
||||
return parent?.classList.contains('dragable') && props.dragable;
|
||||
},
|
||||
onStart: () => {
|
||||
el.style.cursor = 'grabbing';
|
||||
el.classList.add('dragging');
|
||||
},
|
||||
});
|
||||
|
||||
sortableInstance.value = await initializeSortable();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await nextTick();
|
||||
initTabsSortable();
|
||||
initScrollbar();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.styleType,
|
||||
() => {
|
||||
sortableInstance.value?.destroy();
|
||||
init();
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
sortableInstance.value?.destroy();
|
||||
});
|
||||
useTabsDrag(props, emit);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'overflow-hidden': styleType !== 'chrome',
|
||||
}"
|
||||
class="flex h-full flex-1"
|
||||
>
|
||||
<div class="flex h-full flex-1 overflow-hidden">
|
||||
<!-- 左侧滚动按钮 -->
|
||||
<span
|
||||
class="hover:bg-muted text-muted-foreground cursor-pointer border-r px-2"
|
||||
v-show="showScrollButton"
|
||||
:class="{
|
||||
'hover:bg-muted text-muted-foreground cursor-pointer': !scrollIsAtLeft,
|
||||
'pointer-events-none opacity-30': scrollIsAtLeft,
|
||||
}"
|
||||
class="border-r px-2"
|
||||
@click="scrollDirection('left')"
|
||||
>
|
||||
<ChevronLeft class="size-4 h-full" />
|
||||
</span>
|
||||
|
||||
<TabsChrome
|
||||
v-if="styleType === 'chrome'"
|
||||
v-bind="{ ...forward, ...$attrs, ...$props }"
|
||||
/>
|
||||
<Tabs v-else v-bind="{ ...forward, ...$attrs, ...$props }" />
|
||||
<div
|
||||
:class="{
|
||||
'pt-[3px]': styleType === 'chrome',
|
||||
}"
|
||||
class="size-full flex-1 overflow-hidden"
|
||||
>
|
||||
<VbenScrollbar
|
||||
ref="scrollbarRef"
|
||||
class="h-full"
|
||||
horizontal
|
||||
scroll-bar-class="z-10 hidden"
|
||||
shadow
|
||||
shadow-left
|
||||
shadow-right
|
||||
@scroll-at="handleScrollAt"
|
||||
>
|
||||
<TabsChrome
|
||||
v-if="styleType === 'chrome'"
|
||||
v-bind="{ ...forward, ...$attrs, ...$props }"
|
||||
/>
|
||||
|
||||
<Tabs v-else v-bind="{ ...forward, ...$attrs, ...$props }" />
|
||||
</VbenScrollbar>
|
||||
</div>
|
||||
|
||||
<!-- 左侧滚动按钮 -->
|
||||
<span
|
||||
v-show="showScrollButton"
|
||||
:class="{
|
||||
'hover:bg-muted text-muted-foreground cursor-pointer': !scrollIsAtRight,
|
||||
'pointer-events-none opacity-30': scrollIsAtRight,
|
||||
}"
|
||||
class="hover:bg-muted text-muted-foreground cursor-pointer border-l px-2"
|
||||
@click="scrollDirection('right')"
|
||||
>
|
||||
|
@@ -1,7 +1,14 @@
|
||||
import type { IContextMenuItem } from '@vben-core/shadcn-ui';
|
||||
import type { TabDefinition, TabsStyleType } from '@vben-core/typings';
|
||||
|
||||
interface TabsProps {
|
||||
export type TabsEmits = {
|
||||
close: [string];
|
||||
sortTabs: [number, number];
|
||||
unpin: [TabDefinition];
|
||||
};
|
||||
|
||||
export interface TabsProps {
|
||||
active?: string;
|
||||
/**
|
||||
* @zh_CN content class
|
||||
* @default tabs-chrome
|
||||
@@ -48,12 +55,10 @@ interface TabsProps {
|
||||
tabs?: TabDefinition[];
|
||||
}
|
||||
|
||||
interface TabConfig extends TabDefinition {
|
||||
export interface TabConfig extends TabDefinition {
|
||||
affixTab: boolean;
|
||||
closable: boolean;
|
||||
icon: string;
|
||||
key: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export type { TabConfig, TabsProps };
|
||||
|
110
packages/@core/ui-kit/tabs-ui/src/use-tabs-drag.ts
Normal file
110
packages/@core/ui-kit/tabs-ui/src/use-tabs-drag.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { EmitType } from '@vben-core/typings';
|
||||
|
||||
import type { TabsProps } from './types';
|
||||
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { type Sortable, useSortable } from '@vben-core/composables';
|
||||
|
||||
// 可能会找到拖拽的子元素,这里需要确保拖拽的dom时tab元素
|
||||
function findParentElement(element: HTMLElement) {
|
||||
const parentCls = 'group';
|
||||
return element.classList.contains(parentCls)
|
||||
? element
|
||||
: element.closest(`.${parentCls}`);
|
||||
}
|
||||
|
||||
export function useTabsDrag(props: TabsProps, emit: EmitType) {
|
||||
const sortableInstance = ref<null | Sortable>(null);
|
||||
|
||||
async function initTabsSortable() {
|
||||
await nextTick();
|
||||
|
||||
const el = document.querySelectorAll(
|
||||
`.${props.contentClass}`,
|
||||
)?.[0] as HTMLElement;
|
||||
|
||||
if (!el) {
|
||||
console.warn('Element not found for sortable initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
const resetElState = async () => {
|
||||
el.style.cursor = 'default';
|
||||
el.classList.remove('dragging');
|
||||
el.querySelector('.draggable')?.classList.remove('dragging');
|
||||
};
|
||||
|
||||
const { initializeSortable } = useSortable(el, {
|
||||
filter: (_evt, target: HTMLElement) => {
|
||||
const parent = findParentElement(target);
|
||||
const dragable = parent?.classList.contains('dragable');
|
||||
return !dragable || !props.dragable;
|
||||
},
|
||||
onEnd(evt) {
|
||||
const { newIndex, oldIndex } = evt;
|
||||
// const fromElement = evt.item;
|
||||
const { srcElement } = (evt as any).originalEvent;
|
||||
|
||||
if (!srcElement) {
|
||||
resetElState();
|
||||
return;
|
||||
}
|
||||
|
||||
const srcParent = findParentElement(srcElement);
|
||||
|
||||
if (!srcParent) {
|
||||
resetElState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!srcParent.classList.contains('dragable')) {
|
||||
resetElState();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
oldIndex !== undefined &&
|
||||
newIndex !== undefined &&
|
||||
!Number.isNaN(oldIndex) &&
|
||||
!Number.isNaN(newIndex) &&
|
||||
oldIndex !== newIndex
|
||||
) {
|
||||
emit('sortTabs', oldIndex, newIndex);
|
||||
}
|
||||
resetElState();
|
||||
},
|
||||
onMove(evt) {
|
||||
const parent = findParentElement(evt.related);
|
||||
return parent?.classList.contains('dragable') && props.dragable;
|
||||
},
|
||||
onStart: () => {
|
||||
el.style.cursor = 'grabbing';
|
||||
el.querySelector('.draggable')?.classList.add('dragging');
|
||||
// el.classList.add('dragging');
|
||||
},
|
||||
});
|
||||
|
||||
sortableInstance.value = await initializeSortable();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await nextTick();
|
||||
initTabsSortable();
|
||||
}
|
||||
|
||||
onMounted(init);
|
||||
|
||||
watch(
|
||||
() => props.styleType,
|
||||
() => {
|
||||
sortableInstance.value?.destroy();
|
||||
init();
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
sortableInstance.value?.destroy();
|
||||
});
|
||||
}
|
@@ -1,15 +1,28 @@
|
||||
import { nextTick, ref } from 'vue';
|
||||
import type { TabsProps } from './types';
|
||||
|
||||
type El = Element | null | undefined;
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
export function useTabsViewScroll(scrollDistance: number = 150) {
|
||||
const scrollbarEl = ref<El>(null);
|
||||
const scrollViewportEl = ref<El>(null);
|
||||
import { VbenScrollbar } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
|
||||
type DomElement = Element | null | undefined;
|
||||
|
||||
export function useTabsViewScroll(props: TabsProps) {
|
||||
let resizeObserver: null | ResizeObserver = null;
|
||||
let mutationObserver: MutationObserver | null = null;
|
||||
let tabItemCount = 0;
|
||||
const scrollbarRef = ref<InstanceType<typeof VbenScrollbar> | null>(null);
|
||||
const scrollViewportEl = ref<DomElement>(null);
|
||||
const showScrollButton = ref(false);
|
||||
const scrollIsAtLeft = ref(true);
|
||||
const scrollIsAtRight = ref(false);
|
||||
|
||||
function getScrollClientWidth() {
|
||||
if (!scrollbarEl.value || !scrollViewportEl.value) return {};
|
||||
const scrollbarEl = scrollbarRef.value?.$el;
|
||||
if (!scrollbarEl || !scrollViewportEl.value) return {};
|
||||
|
||||
const scrollbarWidth = scrollbarEl.value.clientWidth;
|
||||
const scrollbarWidth = scrollbarEl.clientWidth;
|
||||
const scrollViewWidth = scrollViewportEl.value.clientWidth;
|
||||
|
||||
return {
|
||||
@@ -20,7 +33,7 @@ export function useTabsViewScroll(scrollDistance: number = 150) {
|
||||
|
||||
function scrollDirection(
|
||||
direction: 'left' | 'right',
|
||||
distance: number = scrollDistance,
|
||||
distance: number = 150,
|
||||
) {
|
||||
const { scrollbarWidth, scrollViewWidth } = getScrollClientWidth();
|
||||
|
||||
@@ -39,21 +52,142 @@ export function useTabsViewScroll(scrollDistance: number = 150) {
|
||||
|
||||
async function initScrollbar() {
|
||||
await nextTick();
|
||||
const barEl = document.querySelector('#tabs-scrollbar');
|
||||
|
||||
const viewportEl = barEl?.querySelector(
|
||||
const scrollbarEl = scrollbarRef.value?.$el;
|
||||
if (!scrollbarEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportEl = scrollbarEl?.querySelector(
|
||||
'div[data-radix-scroll-area-viewport]',
|
||||
);
|
||||
|
||||
scrollbarEl.value = barEl;
|
||||
scrollViewportEl.value = viewportEl;
|
||||
calcShowScrollbarButton();
|
||||
|
||||
const activeItem = viewportEl?.querySelector('.is-active');
|
||||
activeItem?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
await nextTick();
|
||||
scrollToActiveIntoView();
|
||||
|
||||
// 监听大小变化
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = new ResizeObserver(
|
||||
useDebounceFn((_entries: ResizeObserverEntry[]) => {
|
||||
calcShowScrollbarButton();
|
||||
}, 100),
|
||||
);
|
||||
resizeObserver.observe(viewportEl);
|
||||
|
||||
tabItemCount = props.tabs?.length || 0;
|
||||
mutationObserver?.disconnect();
|
||||
// 使用 MutationObserver 仅监听子节点数量变化
|
||||
mutationObserver = new MutationObserver(() => {
|
||||
const count = viewportEl.querySelectorAll(
|
||||
`div[data-tab-item="true"]`,
|
||||
).length;
|
||||
|
||||
if (count > tabItemCount) {
|
||||
scrollToActiveIntoView();
|
||||
}
|
||||
|
||||
if (count !== tabItemCount) {
|
||||
calcShowScrollbarButton();
|
||||
tabItemCount = count;
|
||||
}
|
||||
});
|
||||
|
||||
// 配置为仅监听子节点的添加和移除
|
||||
mutationObserver.observe(viewportEl, {
|
||||
attributes: false,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function scrollToActiveIntoView() {
|
||||
if (!scrollViewportEl.value) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
const viewportEl = scrollViewportEl.value;
|
||||
const { scrollbarWidth } = getScrollClientWidth();
|
||||
const { scrollWidth } = viewportEl;
|
||||
|
||||
if (scrollbarWidth >= scrollWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const activeItem = viewportEl?.querySelector('.is-active');
|
||||
activeItem?.scrollIntoView({ behavior: 'smooth', inline: 'start' });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算tabs 宽度,用于判断是否显示左右滚动按钮
|
||||
*/
|
||||
async function calcShowScrollbarButton() {
|
||||
if (!scrollViewportEl.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollbarWidth } = getScrollClientWidth();
|
||||
|
||||
showScrollButton.value =
|
||||
scrollViewportEl.value.scrollWidth > scrollbarWidth;
|
||||
}
|
||||
|
||||
const handleScrollAt = useDebounceFn(({ left, right }) => {
|
||||
scrollIsAtLeft.value = left;
|
||||
scrollIsAtRight.value = right;
|
||||
}, 100);
|
||||
|
||||
watch(
|
||||
() => props.active,
|
||||
async () => {
|
||||
// 200为了等待 tab 切换动画完成
|
||||
// setTimeout(() => {
|
||||
scrollToActiveIntoView();
|
||||
// }, 300);
|
||||
},
|
||||
{
|
||||
flush: 'post',
|
||||
},
|
||||
);
|
||||
|
||||
// watch(
|
||||
// () => props.tabs?.length,
|
||||
// async () => {
|
||||
// await nextTick();
|
||||
// calcShowScrollbarButton();
|
||||
// },
|
||||
// {
|
||||
// flush: 'post',
|
||||
// },
|
||||
// );
|
||||
|
||||
watch(
|
||||
() => props.styleType,
|
||||
() => {
|
||||
initScrollbar();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(initScrollbar);
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
mutationObserver = null;
|
||||
});
|
||||
|
||||
return {
|
||||
handleScrollAt,
|
||||
initScrollbar,
|
||||
scrollbarRef,
|
||||
scrollDirection,
|
||||
scrollIsAtLeft,
|
||||
scrollIsAtRight,
|
||||
showScrollButton,
|
||||
};
|
||||
}
|
||||
|
Reference in New Issue
Block a user