Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions packages/components/menu/_example/head-menu-ellipsis.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<template>
<div>
<p>缩小浏览器窗口宽度,可以看到多余的菜单项会被收纳到省略号图标的弹出菜单中。</p>
<p>Resize the browser window width to see the overflow menu items being collapsed into an ellipsis popup menu.</p>
<br />
<t-head-menu v-model="activeValue" theme="light" :ellipsis="true">
<template #logo>
<img height="28" src="https://tdesign.gtimg.com/site/baseLogo-light.png" alt="logo" />
</template>
<t-menu-item value="item1"> 菜单一 </t-menu-item>
<t-menu-item value="item2"> 菜单二 </t-menu-item>
<t-menu-item value="item3"> 菜单三 </t-menu-item>
<t-menu-item value="item4"> 菜单四 </t-menu-item>
<t-menu-item value="item5"> 菜单五 </t-menu-item>
<t-menu-item value="item6"> 菜单六 </t-menu-item>
<t-menu-item value="item7"> 菜单七 </t-menu-item>
<t-menu-item value="item8"> 菜单八 </t-menu-item>
<template #operations>
<t-button variant="text" shape="square">
<template #icon><t-icon name="search" /></template>
</t-button>
<t-button variant="text" shape="square">
<template #icon><t-icon name="user" /></template>
</t-button>
</template>
</t-head-menu>

<br />
<br />

<p>使用 ellipsis=false 禁用省略功能</p>
<p>Use ellipsis=false to disable the ellipsis feature</p>
<br />
<t-head-menu v-model="activeValue2" theme="dark" :ellipsis="false">
<template #logo>
<img height="28" src="https://tdesign.gtimg.com/site/baseLogo-dark.png" alt="logo" />
</template>
<t-menu-item value="item1"> 菜单一 </t-menu-item>
<t-menu-item value="item2"> 菜单二 </t-menu-item>
<t-menu-item value="item3"> 菜单三 </t-menu-item>
<t-menu-item value="item4"> 菜单四 </t-menu-item>
<t-menu-item value="item5"> 菜单五 </t-menu-item>
<t-menu-item value="item6"> 菜单六 </t-menu-item>
<template #operations>
<div class="t-demo-menu--dark">
<t-button variant="text" shape="square">
<template #icon><t-icon name="search" /></template>
</t-button>
<t-button variant="text" shape="square">
<template #icon><t-icon name="user" /></template>
</t-button>
</div>
</template>
</t-head-menu>
</div>
</template>

<script setup>
import { ref } from 'vue';

const activeValue = ref('item1');
const activeValue2 = ref('item1');
</script>

<style lang="less" scoped>
.t-menu__operations {
.t-button {
margin-left: 8px;
}
}
.t-demo-menu--dark {
.t-button {
color: #fff;
&:hover {
background-color: #4b4b4b;
border-color: transparent;
--ripple-color: #383838;
}
}
}
</style>
5 changes: 5 additions & 0 deletions packages/components/menu/head-menu-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { TdHeadMenuProps } from '../menu/type';
import { PropType } from 'vue';

export default {
/** 是否省略多余的子项(仅在横向模式生效) */
ellipsis: {
type: Boolean,
default: true,
},
/** 展开的子菜单集合 */
expanded: {
type: Array as PropType<TdHeadMenuProps['expanded']>,
Expand Down
162 changes: 123 additions & 39 deletions packages/components/menu/head-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import {
reactive,
watch,
onMounted,
onBeforeUnmount,
watchEffect,
toRefs,
h,
VNode,
Component,
getCurrentInstance,
nextTick,
} from 'vue';
import { EllipsisIcon } from 'tdesign-icons-vue-next';
import { isArray, isFunction } from 'lodash-es';
Expand Down Expand Up @@ -114,13 +116,6 @@ export default defineComponent({
},
);

onMounted(() => {
activeValues.value = vMenu.select(activeValue.value);
if (expandValues.value?.length > 0) {
handleSubmenuExpand(expandValues.value[0]); // 顶部导航只能同时展开一个子菜单
}
});

const handleClickSubMenuItem = (value: MenuValue) => {
const activeMenuItem = submenu.find((v) => v.value === value);
activeMenuItem.onClick?.({ value });
Expand Down Expand Up @@ -154,13 +149,22 @@ export default defineComponent({
const logoRef = ref<HTMLDivElement>();
const operationRef = ref<HTMLDivElement>();

// Store the index at which menu items should be sliced for ellipsis
const sliceIndex = ref(-1);
// ResizeObserver instance
let resizeObserver: ResizeObserver | null = null;
// Width reserved for the ellipsis menu item
const ELLIPSIS_WIDTH = 56;

const getComputedCss = (el: Element, cssProperty: keyof CSSStyleDeclaration) =>
getComputedStyle(el)[cssProperty] ?? '';

const getComputedCssValue = (el: Element, cssProperty: keyof CSSStyleDeclaration) =>
Number.parseInt(String(getComputedCss(el, cssProperty)), 10);

const calcMenuWidth = () => {
if (!menuRef.value || !innerRef.value) return 0;

const menuPaddingLeft = getComputedCssValue(menuRef.value, 'paddingLeft');
const menuPaddingRight = getComputedCssValue(menuRef.value, 'paddingRight');
let totalWidth = innerRef.value.clientWidth;
Expand All @@ -179,42 +183,84 @@ export default defineComponent({
return totalWidth - menuPaddingLeft - menuPaddingRight;
};

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const formatContent = () => {
let slot = ctx.slots.default?.() || ctx.slots.content?.() || [];
// Calculate the slice index based on available width
const calcEllipsisSliceIndex = () => {
if (!props.ellipsis || !menuRef.value || !innerRef.value) {
sliceIndex.value = -1;
return;
}

if (menuRef.value && innerRef.value) {
const validNodes = Array.from(menuRef.value.childNodes ?? []).filter(
(item) => item.nodeName !== '#text' || item.nodeValue,
) as HTMLElement[];
const menuItems = Array.from(menuRef.value.children ?? []).filter(
(item) => !item.classList.contains(`${classPrefix.value}-menu__ellipsis`),
) as HTMLElement[];

const menuWidth = calcMenuWidth();
const menuItemMinWidth = 104;
if (menuItems.length === 0) {
sliceIndex.value = -1;
return;
}

let remainWidth = menuWidth;
let sliceIndex = validNodes.length;
// Store widths for calculations
const widths = menuItems.map((item) => item.offsetWidth);

for (let index = 0; index < validNodes.length; index++) {
const element = validNodes[index];
remainWidth -= element.offsetWidth || 0;
if (remainWidth < menuItemMinWidth) {
sliceIndex = index;
break;
}
}
const availableWidth = calcMenuWidth();

let usedWidth = 0;
let newSliceIndex = menuItems.length;

const defaultSlot = slot.slice(0, sliceIndex);
const subMore = slot.slice(sliceIndex);
for (let i = 0; i < menuItems.length; i++) {
usedWidth += widths[i];
// Check if adding the next item would overflow
// We need to reserve space for ellipsis if there are more items after
const needsEllipsis = i < menuItems.length - 1;
const reservedWidth = needsEllipsis ? ELLIPSIS_WIDTH : 0;

if (subMore.length) {
slot = defaultSlot.concat(
<Submenu expandType="popup" title={() => <EllipsisIcon />}>
{subMore}
</Submenu>,
);
if (usedWidth + reservedWidth > availableWidth) {
// This item doesn't fit, slice here
newSliceIndex = i;
break;
}
}
return slot;

// If all items fit, no ellipsis needed
sliceIndex.value = newSliceIndex < menuItems.length ? newSliceIndex : -1;
};

// Setup ResizeObserver to detect size changes
const setupResizeObserver = () => {
if (!props.ellipsis || typeof ResizeObserver === 'undefined') return;

resizeObserver = new ResizeObserver(() => {
nextTick(() => {
calcEllipsisSliceIndex();
});
});

if (innerRef.value) {
resizeObserver.observe(innerRef.value);
}
};

// Get content with ellipsis handling
const getContent = () => {
const slot = ctx.slots.default?.() || ctx.slots.content?.() || [];

if (!props.ellipsis || sliceIndex.value === -1 || sliceIndex.value >= slot.length) {
return slot;
}

const visibleItems = slot.slice(0, sliceIndex.value);
const hiddenItems = slot.slice(sliceIndex.value);

if (hiddenItems.length === 0) {
return slot;
}

return [
...visibleItems,
<Submenu expandType="popup" title={() => <EllipsisIcon />} class={`${classPrefix.value}-menu__ellipsis`}>
{hiddenItems}
</Submenu>,
];
};

const initVMenu = (slots: VNode[], parentValue?: string) => {
Expand All @@ -234,13 +280,51 @@ export default defineComponent({
};
initVMenu(ctx.slots.default?.() || ctx.slots.content?.() || []);

onMounted(() => {
activeValues.value = vMenu.select(activeValue.value);
if (expandValues.value?.length > 0) {
handleSubmenuExpand(expandValues.value[0]); // 顶部导航只能同时展开一个子菜单
}

// Initial calculation after mount
if (props.ellipsis) {
nextTick(() => {
calcEllipsisSliceIndex();
setupResizeObserver();
});
}
});

onBeforeUnmount(() => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
});

// Watch for ellipsis prop changes
watch(
() => props.ellipsis,
(newVal) => {
if (newVal) {
nextTick(() => {
calcEllipsisSliceIndex();
setupResizeObserver();
});
} else {
sliceIndex.value = -1;
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
}
},
);

return () => {
const logo = props.logo?.(h) || ctx.slots.logo?.();
const operations = props.operations?.(h) || ctx.slots.operations?.() || ctx.slots.options?.();

// TODO: 判断逻辑不够完善 影响封装组件的子菜单样式渲染 暂时先不执行 待调整实现方案
// const content = formatContent();
const content = ctx.slots.default?.() || ctx.slots.content?.() || [];
const content = getContent();

return (
<div class={menuClass.value}>
Expand Down
5 changes: 5 additions & 0 deletions packages/components/menu/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ export interface TdMenuProps {
}

export interface TdHeadMenuProps {
/**
* 是否省略多余的子项(仅在横向模式生效)
* @default true
*/
ellipsis?: boolean;
/**
* 展开的子菜单集合
*/
Expand Down
Loading