import type { CSSProperties, HTMLAttributes, PropType } from 'vue'; import { computed, defineComponent, ref, watch } from 'vue'; import ResizeObserver from '../vc-resize-observer'; import classNames from '../_util/classNames'; import type { Key, VueNode } from '../_util/type'; import PropTypes from '../_util/vue-types'; import { OverflowContextProvider } from './context'; import Item from './Item'; import RawItem from './RawItem'; const RESPONSIVE = 'responsive' as const; const INVALIDATE = 'invalidate' as const; function defaultRenderRest(omittedItems: ItemType[]) { return `+ ${omittedItems.length} ...`; } export interface OverflowProps extends HTMLAttributes { prefixCls?: string; data?: ItemType[]; itemKey?: Key; /** Used for `responsive`. It will limit render node to avoid perf issue */ itemWidth?: number; renderItem?: (item: ItemType) => VueNode; /** @private Do not use in your production. Render raw node that need wrap Item by developer self */ renderRawItem?: (item: ItemType, index: number) => VueNode; maxCount?: number | typeof RESPONSIVE | typeof INVALIDATE; renderRest?: VueNode | ((omittedItems: ItemType[]) => VueNode); /** @private Do not use in your production. Render raw node that need wrap Item by developer self */ renderRawRest?: (omittedItems: ItemType[]) => VueNode; suffix?: VueNode; component?: any; itemComponent?: any; /** @private This API may be refactor since not well design */ onVisibleChange?: (visibleCount: number) => void; /** When set to `full`, ssr will render full items by default and remove at client side */ ssr?: 'full'; } const Overflow = defineComponent({ name: 'Overflow', inheritAttrs: false, props: { id: String, prefixCls: String, data: Array, itemKey: [String, Number, Function] as PropType Key)>, /** Used for `responsive`. It will limit render node to avoid perf issue */ itemWidth: { type: Number, default: 10 }, renderItem: Function as PropType<(item: any) => VueNode>, /** @private Do not use in your production. Render raw node that need wrap Item by developer self */ renderRawItem: Function as PropType<(item: any, index: number) => VueNode>, maxCount: [Number, String] as PropType, renderRest: Function as PropType<(items: any[]) => VueNode>, /** @private Do not use in your production. Render raw node that need wrap Item by developer self */ renderRawRest: Function as PropType<(items: any[]) => VueNode>, suffix: PropTypes.any, component: String, itemComponent: PropTypes.any, /** @private This API may be refactor since not well design */ onVisibleChange: Function as PropType<(visibleCount: number) => void>, /** When set to `full`, ssr will render full items by default and remove at client side */ ssr: String as PropType<'full'>, }, emits: ['visibleChange'], setup(props, { attrs, emit }) { const fullySSR = computed(() => props.ssr === 'full'); const containerWidth = ref(null); const mergedContainerWidth = computed(() => containerWidth.value || 0); const itemWidths = ref>(new Map()); const prevRestWidth = ref(0); const restWidth = ref(0); const suffixWidth = ref(0); const suffixFixedStart = ref(null); const displayCount = ref(null); const mergedDisplayCount = computed(() => { if (displayCount.value === null && fullySSR.value) { return Number.MAX_SAFE_INTEGER; } return displayCount.value || 0; }); const restReady = ref(false); const itemPrefixCls = computed(() => `${props.prefixCls}-item`); // Always use the max width to avoid blink const mergedRestWidth = computed(() => Math.max(prevRestWidth.value, restWidth.value)); // ================================= Data ================================= const isResponsive = computed(() => !!(props.data.length && props.maxCount === RESPONSIVE)); const invalidate = computed(() => props.maxCount === INVALIDATE); /** * When is `responsive`, we will always render rest node to get the real width of it for calculation */ const showRest = computed( () => isResponsive.value || (typeof props.maxCount === 'number' && props.data.length > props.maxCount), ); const mergedData = computed(() => { let items = props.data; if (isResponsive.value) { if (containerWidth.value === null && fullySSR.value) { items = props.data; } else { items = props.data.slice( 0, Math.min(props.data.length, mergedContainerWidth.value / props.itemWidth), ); } } else if (typeof props.maxCount === 'number') { items = props.data.slice(0, props.maxCount); } return items; }); const omittedItems = computed(() => { if (isResponsive.value) { return props.data.slice(mergedDisplayCount.value + 1); } return props.data.slice(mergedData.value.length); }); // ================================= Item ================================= const getKey = (item: any, index: number) => { if (typeof props.itemKey === 'function') { return props.itemKey(item); } return (props.itemKey && (item as any)?.[props.itemKey]) ?? index; }; const mergedRenderItem = computed(() => props.renderItem || ((item: any) => item)); const updateDisplayCount = (count: number, notReady?: boolean) => { displayCount.value = count; if (!notReady) { restReady.value = count < props.data.length - 1; emit('visibleChange', count); } }; // ================================= Size ================================= const onOverflowResize = (_: object, element: HTMLElement) => { containerWidth.value = element.clientWidth; }; const registerSize = (key: Key, width: number | null) => { const clone = new Map(itemWidths.value); if (width === null) { clone.delete(key); } else { clone.set(key, width); } itemWidths.value = clone; }; const registerOverflowSize = (_: Key, width: number | null) => { prevRestWidth.value = restWidth.value; restWidth.value = width!; }; const registerSuffixSize = (_: Key, width: number | null) => { suffixWidth.value = width!; }; // ================================ Effect ================================ const getItemWidth = (index: number) => { return itemWidths.value.get(getKey(mergedData.value[index], index)); }; watch( [mergedContainerWidth, itemWidths, restWidth, suffixWidth, () => props.itemKey, mergedData], () => { if (mergedContainerWidth.value && mergedRestWidth.value && mergedData.value) { let totalWidth = suffixWidth.value; const len = mergedData.value.length; const lastIndex = len - 1; // When data count change to 0, reset this since not loop will reach if (!len) { updateDisplayCount(0); suffixFixedStart.value = null; return; } for (let i = 0; i < len; i += 1) { const currentItemWidth = getItemWidth(i); // Break since data not ready if (currentItemWidth === undefined) { updateDisplayCount(i - 1, true); break; } // Find best match totalWidth += currentItemWidth; if ( // Only one means `totalWidth` is the final width (lastIndex === 0 && totalWidth <= mergedContainerWidth.value) || // Last two width will be the final width (i === lastIndex - 1 && totalWidth + getItemWidth(lastIndex)! <= mergedContainerWidth.value) ) { // Additional check if match the end updateDisplayCount(lastIndex); suffixFixedStart.value = null; break; } else if (totalWidth + mergedRestWidth.value > mergedContainerWidth.value) { // Can not hold all the content to show rest updateDisplayCount(i - 1); suffixFixedStart.value = totalWidth - currentItemWidth - suffixWidth.value + restWidth.value; break; } } if (props.suffix && getItemWidth(0) + suffixWidth.value > mergedContainerWidth.value) { suffixFixedStart.value = null; } } }, ); return () => { // ================================ Render ================================ const displayRest = restReady.value && !!omittedItems.value.length; const { itemComponent, renderRawItem, renderRawRest, renderRest, prefixCls = 'rc-overflow', suffix, component: Component = 'div' as any, id, } = props; const { class: className, style, ...restAttrs } = attrs; let suffixStyle: CSSProperties = {}; if (suffixFixedStart.value !== null && isResponsive.value) { suffixStyle = { position: 'absolute', left: `${suffixFixedStart.value}px`, top: 0, }; } const itemSharedProps = { prefixCls: itemPrefixCls.value, responsive: isResponsive.value, component: itemComponent, invalidate: invalidate.value, }; // >>>>> Choice render fun by `renderRawItem` const internalRenderItemNode = renderRawItem ? (item: any, index: number) => { const key = getKey(item, index); return ( {renderRawItem(item, index)} ); } : (item: any, index: number) => { const key = getKey(item, index); return ( ); }; // >>>>> Rest node let restNode = () => null; const restContextProps = { order: displayRest ? mergedDisplayCount.value : Number.MAX_SAFE_INTEGER, className: `${itemPrefixCls.value} ${itemPrefixCls.value}-rest`, registerSize: registerOverflowSize, display: displayRest, }; if (!renderRawRest) { const mergedRenderRest = renderRest || defaultRenderRest; restNode = () => ( typeof mergedRenderRest === 'function' ? mergedRenderRest(omittedItems.value) : mergedRenderRest, }} > ); } else if (renderRawRest) { restNode = () => ( {renderRawRest(omittedItems.value)} ); } const overflowNode = () => ( {mergedData.value.map(internalRenderItemNode)} {/* Rest Count Item */} {showRest.value ? restNode() : null} {/* Suffix Node */} {suffix && ( suffix }} > )} ); // 使用 disabled 避免结构不一致 导致子组件 rerender return ( ); }; }, }); Overflow.Item = RawItem; Overflow.RESPONSIVE = RESPONSIVE; Overflow.INVALIDATE = INVALIDATE; export default Overflow as typeof Overflow & { readonly Item: typeof RawItem; readonly RESPONSIVE: typeof RESPONSIVE; readonly INVALIDATE: typeof INVALIDATE; };