refactor(@vben-core/tabs-ui): refactor tabs chrome component

This commit is contained in:
vben
2024-07-14 15:18:02 +08:00
parent d1a19c525f
commit fed422e187
35 changed files with 662 additions and 518 deletions

View File

@@ -0,0 +1,21 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: [
{
builder: 'mkdist',
input: './src',
loaders: ['vue'],
pattern: ['**/*.vue'],
},
{
builder: 'mkdist',
format: 'esm',
input: './src',
loaders: ['js'],
pattern: ['**/*.ts'],
},
],
});

View File

@@ -11,7 +11,7 @@
"license": "MIT",
"type": "module",
"scripts": {
"build": "pnpm vite build",
"build": "pnpm unbuild",
"prepublishOnly": "npm run build"
},
"files": [
@@ -38,9 +38,9 @@
},
"dependencies": {
"@vben-core/design": "workspace:*",
"@vben-core/hooks": "workspace:*",
"@vben-core/iconify": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/toolkit": "workspace:*",
"@vben-core/typings": "workspace:*",
"vue": "^3.4.31"
}

View File

@@ -1,193 +0,0 @@
@import '@vben-core/design/bem';
@include b('chrome-tabs') {
--tabs-background: hsl(var(--background));
--tabs-gap: 7px;
--tabs-divider: hsl(var(--border));
--tabs-hover: hsl(var(--heavy));
--tabs-active-background: hsl(var(--primary) / 100%);
--tabs-active: hsl(var(--primary-foreground));
background-color: var(--tabs-background);
}
@include b('chrome-tab') {
color: hsl(var(--muted-foreground));
@include is('active') {
z-index: 2;
color: var(--tabs-active);
.#{$namespace}-chrome-tab__extra:not(.is-pin) {
background-color: var(--tabs-active-background);
opacity: 1;
}
.#{$namespace}-chrome-tab-background__divider {
display: none;
}
.#{$namespace}-chrome-tab-background__content {
background-color: var(--tabs-active-background);
}
.#{$namespace}-chrome-tab-background__before,
.#{$namespace}-chrome-tab-background__after {
fill: var(--tabs-active-background);
}
}
@include e('content') {
position: absolute;
right: 0;
left: 0;
display: flex;
align-items: center;
height: 100%;
padding-right: 10px;
margin: 0 calc(var(--tabs-gap) * 2);
overflow: hidden;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
@include e('extra') {
position: absolute;
top: 50%;
right: calc(var(--tabs-gap) * 2);
z-index: 1;
width: 14px;
height: 14px;
border-radius: 50%;
opacity: 0;
transition: 0.15s;
transform: translateY(-50%);
// &:hover {
// background-color: hsl(var(--accent));
// }
}
@include e('extra-icon') {
flex-shrink: 0;
width: 100%;
height: 100%;
font-size: 12px;
border-radius: 50%;
transition: all 0.15s ease;
&:hover {
color: hsl(var(--foreground));
transform: scale(1.05);
}
}
@include e('icon') {
display: flex;
align-items: center;
height: 16px;
margin-left: 3%;
overflow: hidden;
img {
height: 100%;
}
}
@include e('label') {
position: relative;
flex: 1;
margin-right: 8px;
margin-left: 5%;
overflow: hidden;
font-size: 14px;
white-space: nowrap;
mask-image: linear-gradient(
90deg,
#000 0%,
#000 calc(100% - 20px),
transparent
);
// &.no-close {
// margin-right: 0;
// }
// &.no-icon {
// margin-left: 0;
// }
}
@include is('hidden-icon') {
margin-left: 0;
}
&:hover {
.#{$namespace}-chrome-tab__extra.is-pin {
opacity: 1;
}
}
&:not(.is-active):hover {
z-index: 1;
.#{$namespace}-chrome-tab__extra {
opacity: 1;
}
.#{$namespace}-chrome-tab-background__divider {
display: none;
}
.#{$namespace}-chrome-tab-background__content {
background-color: var(--tabs-hover);
}
.#{$namespace}-chrome-tab-background__before,
.#{$namespace}-chrome-tab-background__after {
fill: var(--tabs-hover);
}
}
&:first-of-type {
.#{$namespace}-chrome-tab-background__divider::before {
display: none;
}
}
}
@include b('chrome-tab-background') {
padding: 0 calc(var(--tabs-gap) + 0px);
@include e('divider') {
width: calc(100% - 14px);
margin: 0 7px;
&::before {
background-color: var(--tabs-divider);
}
&::after {
left: calc(100% - 1px);
background-color: var(--tabs-divider);
}
}
@include e('content') {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
transition: background 0.15s ease;
}
@include e('before') {
bottom: -1px;
left: -3px;
transition: 0.15s;
}
@include e('after') {
right: -3px;
bottom: -1px;
transition: 0.15s;
}
}

View File

@@ -1,35 +0,0 @@
<script setup lang="ts">
import { useNamespace } from '@vben-core/toolkit';
defineOptions({
name: 'ChromeTabBackground',
});
const { b, e } = useNamespace('chrome-tab-background');
</script>
<template>
<div :class="b()" class="absolute size-full">
<div
:class="e('divider')"
class="absolute left-0 h-full before:absolute before:right-[100%] before:top-[15%] before:h-[60%] before:w-[1px] before:content-[''] after:absolute after:top-[15%] after:h-[60%] after:w-[1px] after:content-['']"
></div>
<div :class="e('content')" class="h-full"></div>
<svg
:class="e('before')"
class="absolute fill-transparent"
height="10"
width="10"
>
<path d="M 0 10 A 10 10 0 0 0 10 0 L 10 10 Z" />
</svg>
<svg
:class="e('after')"
class="absolute fill-transparent"
height="10"
width="10"
>
<path d="M 0 0 A 10 10 0 0 0 10 10 L 0 10 Z" />
</svg>
</div>
</template>

View File

@@ -1,76 +0,0 @@
<script setup lang="ts">
import type { IContextMenuItem } from '@vben-core/shadcn-ui';
import type { TabItem } from '@vben-core/typings';
import { IcRoundClose, MdiPin } from '@vben-core/iconify';
import { VbenContextMenu, VbenIcon } from '@vben-core/shadcn-ui';
import { useNamespace } from '@vben-core/toolkit';
import TabBackground from './tab-background.vue';
interface Props {
affixTab?: boolean;
icon?: string;
menus: (data: any) => IContextMenuItem[];
onlyOne?: boolean;
showIcon?: boolean;
tab: TabItem;
title: string;
}
defineOptions({
name: 'ChromeTab',
});
withDefaults(defineProps<Props>(), {
icon: '',
});
const emit = defineEmits<{ close: []; unpinTab: [] }>();
const { b, e, is } = useNamespace('chrome-tab');
function handleClose() {
emit('close');
}
function handleUnpinTab() {
emit('unpinTab');
}
</script>
<template>
<div
:class="[b()]"
class="absolute flex h-full cursor-pointer select-none items-center"
>
<VbenContextMenu
:handler-data="tab"
:menus="menus"
:modal="false"
item-class="pr-4"
>
<div class="h-full">
<TabBackground />
<div :class="e('content')" :title="title">
<VbenIcon v-if="showIcon" :class="e('icon')" :icon="icon" fallback />
<span :class="[e('label'), is('hidden-icon', !icon)]">
{{ title }}
</span>
</div>
<div
v-show="!affixTab && !onlyOne"
:class="e('extra')"
@click.stop="handleClose"
>
<IcRoundClose :class="e('extra-icon')" />
</div>
<div
v-show="affixTab && !onlyOne"
:class="[e('extra'), is('pin', true)]"
@click.stop="handleUnpinTab"
>
<MdiPin :class="e('extra-icon')" />
</div>
</div>
</VbenContextMenu>
</div>
</template>

View File

@@ -1,114 +0,0 @@
<script setup lang="ts">
import type { TabItem } from '@vben-core/typings';
import type { TabsProps } from '../../types';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useNamespace } from '@vben-core/toolkit';
import Tab from './tab.vue';
interface Props extends TabsProps {}
defineOptions({
name: 'ChromeTabs',
});
const props = withDefaults(defineProps<Props>(), {
maxWidth: 150,
menus: () => [],
minWidth: 40,
tabs: () => [],
});
const emit = defineEmits<{ close: [string]; unpinTab: [TabItem] }>();
const gap = 7;
const active = defineModel<string>('active');
const { b, e, is } = useNamespace('chrome-tabs');
const contentRef = ref();
const tabWidth = ref<number>(0);
const layout = () => {
const { maxWidth, minWidth, tabs } = props;
if (!contentRef.value) {
return Math.max(maxWidth, minWidth);
}
const contentWidth = contentRef.value.clientWidth - gap * 3;
let width = contentWidth / tabs.length;
width += gap * 2;
if (width > maxWidth) {
width = maxWidth;
}
if (width < minWidth) {
width = minWidth;
}
tabWidth.value = width;
};
const tabsView = computed(() => {
return props.tabs.map((tab) => {
return {
...tab,
affixTab: !!tab.meta?.affixTab,
icon: tab.meta.icon as string,
key: tab.fullPath || tab.path,
title: (tab.meta?.title || tab.name) as string,
};
});
});
watch(
() => props.tabs,
() => {
nextTick(() => {
layout();
});
},
);
onMounted(() => {
layout();
});
function handleClose(key: string) {
emit('close', key);
}
function handleUnpinTab(tab: TabItem) {
emit('unpinTab', tab);
}
</script>
<template>
<div :class="b()" class="relative size-full pt-1">
<div ref="contentRef" class="relative h-8 overflow-hidden">
<TransitionGroup name="slide-down">
<Tab
v-for="(tab, i) in tabsView"
:key="tab.key"
:affix-tab="tab.affixTab"
:class="[e('tab'), is('active', tab.key === active)]"
:icon="tab.icon"
:menus="menus"
:only-one="tabsView.length <= 1"
:show-icon="showIcon"
:style="{
width: `${tabWidth}px`,
left: `${(tabWidth - gap * 2) * i}px`,
}"
:tab="tab"
:title="tab.title"
@click="active = tab.key"
@close="() => handleClose(tab.key)"
@unpin-tab="() => handleUnpinTab(tab)"
/>
</TransitionGroup>
</div>
</div>
</template>
<style lang="scss">
@import './chrome-tabs.scss';
</style>

View File

@@ -1 +1 @@
export { default as ChromeTabs } from './chrome-tabs/tabs.vue';
export { default as TabsChrome } from './tabs-chrome/tabs.vue';

View File

@@ -0,0 +1,264 @@
<script setup lang="ts">
import type { TabItem } from '@vben-core/typings';
import type { TabsProps } from '../../types';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { MdiPin } from '@vben-core/iconify';
import { VbenContextMenu, VbenIcon } from '@vben-core/shadcn-ui';
interface Props extends TabsProps {}
defineOptions({
name: 'TabsChrome',
// eslint-disable-next-line perfectionist/sort-objects
inheritAttrs: false,
});
const props = withDefaults(defineProps<Props>(), {
contentClass: 'vben-tabs-content',
contextMenus: () => [],
gap: 7,
maxWidth: 150,
minWidth: 40,
tabs: () => [],
});
const emit = defineEmits<{ close: [string]; unpin: [TabItem] }>();
const active = defineModel<string>('active');
const contentRef = ref();
const tabRef = ref();
const tabWidth = ref<number>(0);
const style = computed(() => {
const { gap } = props;
return {
'--gap': `${gap}px`,
};
});
const layout = () => {
const { gap, maxWidth, minWidth, tabs } = props;
if (!contentRef.value) {
return Math.max(maxWidth, minWidth);
}
const contentWidth = contentRef.value.clientWidth - gap * 3;
let width = contentWidth / tabs.length;
width += gap * 2;
if (width > maxWidth) {
width = maxWidth;
}
if (width < minWidth) {
width = minWidth;
}
tabWidth.value = width;
};
const tabsView = computed(() => {
return props.tabs.map((tab) => {
return {
...tab,
affixTab: !!tab.meta?.affixTab,
closable: tab.meta?.tabClosable ?? true,
icon: tab.meta.icon as string,
key: tab.fullPath || tab.path,
title: (tab.meta?.title || tab.name) as string,
};
});
});
watch(
() => props.tabs,
() => {
nextTick(() => {
layout();
});
},
);
onMounted(() => {
layout();
});
function handleClose(key: string) {
emit('close', key);
}
function handleUnpinTab(tab: TabItem) {
emit('unpin', tab);
}
</script>
<template>
<div :style="style" class="tabs-chrome bg-accent size-full pt-1">
<!-- footer -> 4px -->
<div
ref="contentRef"
:class="contentClass"
class="relative h-full overflow-hidden"
>
<TransitionGroup name="slide-down">
<div
v-for="(tab, i) in tabsView"
:key="tab.key"
ref="tabRef"
:class="[
{ 'is-active': tab.key === active, dragable: !tab.affixTab },
]"
: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"
>
<div class="size-full">
<!-- divider -->
<div
v-if="i !== 0"
class="tabs-chrome__divider bg-accent absolute left-[var(--gap)] top-1/2 z-0 h-5 w-[1px] translate-y-[-50%]"
></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 h-full rounded-tl-[var(--gap)] rounded-tr-[var(--gap)] duration-150"
></div>
<svg
class="tabs-chrome__background-before absolute bottom-[-1px] 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 absolute bottom-[-1px] 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)*2)] top-1/2 z-[3] size-4 translate-y-[-50%] opacity-0 transition-opacity group-hover:opacity-100"
>
<!-- close-icon -->
<svg
v-show="!tab.affixTab && tabsView.length > 1 && tab.closable"
class="hover:bg-accent hover:stroke-accent-foreground size-full cursor-pointer rounded-full transition-all"
height="12"
stroke="#595959"
width="12"
@click.stop="handleClose(tab.key)"
>
<path d="M 4 4 L 12 12 M 12 4 L 4 12" />
</svg>
<MdiPin
v-show="tab.affixTab && tabsView.length > 1 && tab.closable"
class="hover:bg-accent hover:stroke-accent-foreground mt-[2px] size-3.5 cursor-pointer rounded-full transition-all"
@click.stop="handleUnpinTab(tab)"
/>
</div>
<!-- tab-item-main -->
<div
class="tabs-chrome__item-main 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] 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
/>
<span
class="tabs-chrome__label ml-[var(--gap)] flex-1 overflow-hidden whitespace-nowrap"
>
{{ tab.title }}
</span>
</div>
</div>
</VbenContextMenu>
</div>
</TransitionGroup>
</div>
<!-- footer -->
<div class="bg-background h-1"></div>
</div>
</template>
<style scoped>
.tabs-chrome {
.dragging {
.tabs-chrome__item-main {
@apply pr-0;
}
.tabs-chrome__extra {
@apply hidden;
}
}
&__item {
&:hover {
& + .tabs-chrome__item {
.tabs-chrome__divider {
@apply opacity-0;
}
}
.tabs-chrome__divider {
@apply opacity-0;
}
.tabs-chrome__background {
&-content {
@apply bg-accent;
}
&-before,
&-after {
@apply fill-accent;
}
}
}
&.is-active {
@apply z-[2];
.tabs-chrome__background {
@apply opacity-100;
&-content {
@apply bg-background;
}
&-before,
&-after {
@apply fill-background;
}
}
}
}
&__label {
mask-image: linear-gradient(
90deg,
#000 0%,
#000 calc(100% - 16px),
transparent
);
}
}
</style>

View File

@@ -10,7 +10,7 @@ defineProps<DropdownMenuProps>();
<template>
<VbenDropdownMenu :menus="menus" :modal="false">
<div
class="flex-center hover:bg-accent hover:text-foreground text-muted-foreground border-border h-full cursor-pointer border-l px-1.5 text-lg font-semibold"
class="flex-center hover:bg-muted bg-accent hover:text-foreground text-muted-foreground border-border h-full cursor-pointer border-l px-1.5 text-lg font-semibold"
>
<IcRoundKeyboardArrowDown class="size-5" />
</div>

View File

@@ -10,7 +10,7 @@ function toggleScreen() {
<template>
<div
class="flex-center hover:bg-accent hover:text-foreground text-muted-foreground border-border h-full cursor-pointer border-l px-2 text-lg font-semibold"
class="flex-center hover:bg-muted bg-accent hover:text-foreground text-muted-foreground border-border h-full cursor-pointer border-l px-2 text-lg font-semibold"
@click="toggleScreen"
>
<IcTwotoneFitScreen v-if="screen" />

View File

@@ -1,3 +1,3 @@
export * from './components/widgets';
export { default as TabsView } from './tabs-view.vue';
export * from './widgets';
export type { IContextMenuItem } from '@vben-core/shadcn-ui';

View File

@@ -1,9 +1,12 @@
<script setup lang="ts">
import type { TabItem } from '@vben-core/typings';
import { nextTick, onMounted } from 'vue';
import { useSortable } from '@vben-core/hooks';
import { useForwardPropsEmits } from '@vben-core/shadcn-ui';
import { ChromeTabs } from './components';
import { TabsChrome } from './components';
import { TabsProps } from './types';
interface Props extends TabsProps {}
@@ -12,13 +15,86 @@ defineOptions({
name: 'TabsView',
});
const props = withDefaults(defineProps<Props>(), {});
const props = withDefaults(defineProps<Props>(), {
contentClass: 'vben-tabs-content',
dragable: true,
});
const emit = defineEmits<{ close: [string]; unPushPin: [TabItem] }>();
const emit = defineEmits<{
close: [string];
sortTabs: [number, number];
unpin: [TabItem];
}>();
const forward = useForwardPropsEmits(props, emit);
// 可能会找到拖拽的子元素这里需要确保拖拽的dom时tab元素
const 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 { 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) {
return;
}
const srcParent = findParentElement(srcElement);
if (!srcParent) {
return;
}
if (!srcParent.classList.contains('dragable')) {
return;
}
if (
oldIndex !== undefined &&
newIndex !== undefined &&
!Number.isNaN(oldIndex) &&
!Number.isNaN(newIndex) &&
oldIndex !== newIndex
) {
emit('sortTabs', oldIndex, newIndex);
}
el.classList.remove('dragging');
el.style.cursor = 'default';
},
onMove(evt) {
const parent = findParentElement(evt.related);
return parent?.classList.contains('dragable');
},
onStart: () => {
el.style.cursor = 'grabbing';
el.classList.add('dragging');
},
});
await initializeSortable();
}
onMounted(initTabsSortable);
</script>
<template>
<ChromeTabs v-bind="forward" />
<TabsChrome v-bind="forward" />
</template>

View File

@@ -2,10 +2,45 @@ import type { IContextMenuItem } from '@vben-core/shadcn-ui';
import type { TabItem } from '@vben-core/typings';
interface TabsProps {
/**
* @zh_CN content class
* @default tabs-chrome
*/
contentClass?: string;
/**
* @zh_CN 右键菜单
*/
contextMenus?: (data: any) => IContextMenuItem[];
/**
* @zh_CN 是否可以拖拽
*/
dragable?: boolean;
/**
* @zh_CN 间隙
* @default 7
* 仅限 tabs-chrome
*/
gap?: number;
/**
* @zh_CN tab 最大宽度
* 仅限 tabs-chrome
*/
maxWidth?: number;
menus?: (data: any) => IContextMenuItem[];
/**
* @zh_CN tab最小宽度
* 仅限 tabs-chrome
*/
minWidth?: number;
/**
* @zh_CN 是否显示图标
*/
showIcon?: boolean;
/**
* @zh_CN 选项卡数据
*/
tabs?: TabItem[];
}

View File

@@ -1,3 +0,0 @@
import { defineConfig } from '@vben/vite-config';
export default defineConfig();