diff --git a/components/vc-overflow/Item.tsx b/components/vc-overflow/Item.tsx index e8045ccb8..7090f02bc 100644 --- a/components/vc-overflow/Item.tsx +++ b/components/vc-overflow/Item.tsx @@ -13,7 +13,7 @@ import { Key, VueNode } from '../_util/type'; import PropTypes from '../_util/vue-types'; export default defineComponent({ - name: 'InternalItem', + name: 'Item', props: { prefixCls: String, item: PropTypes.any, diff --git a/components/vc-overflow/Overflow.tsx b/components/vc-overflow/Overflow.tsx new file mode 100644 index 000000000..d47d95535 --- /dev/null +++ b/components/vc-overflow/Overflow.tsx @@ -0,0 +1,393 @@ +import { + computed, + CSSProperties, + defineComponent, + HTMLAttributes, + PropType, + ref, + watch, +} from 'vue'; +import ResizeObserver from '../vc-resize-observer'; +import classNames from '../_util/classNames'; +import { 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: { + 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: String, + /** @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) { + 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[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 ( + 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; + } else if (i === lastIndex) { + // Reach the end + updateDisplayCount(lastIndex); + suffixFixedStart.value = totalWidth - suffixWidth.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, + } = 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: VueNode; + const restContextProps = { + order: displayRest ? mergedDisplayCount.value : Number.MAX_SAFE_INTEGER, + className: `${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)} + + ); + } + + let overflowNode = ( + + {mergedData.value.map(internalRenderItemNode)} + + {/* Rest Count Item */} + {showRest.value ? restNode : null} + + {/* Suffix Node */} + {suffix && ( + + {suffix} + + )} + + ); + + if (isResponsive.value) { + overflowNode = {overflowNode}; + } + + return overflowNode; + }; + }, +}); + +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; +}; diff --git a/components/vc-overflow/RawItem.tsx b/components/vc-overflow/RawItem.tsx new file mode 100644 index 000000000..34af10394 --- /dev/null +++ b/components/vc-overflow/RawItem.tsx @@ -0,0 +1,44 @@ +import { defineComponent } from 'vue'; +import classNames from '../_util/classNames'; +import PropTypes from '../_util/vue-types'; +import { OverflowContextProvider, useInjectOverflowContext } from './context'; +import Item from './Item'; + +export default defineComponent({ + name: 'RawItem', + inheritAttrs: false, + props: { + component: PropTypes.any, + }, + setup(props, { slots, attrs }) { + const context = useInjectOverflowContext(); + + return () => { + // Render directly when context not provided + if (!context.value) { + const { component: Component = 'div', ...restProps } = props; + return ( + + {slots.default?.()} + + ); + } + + const { className: contextClassName, ...restContext } = context.value; + const { class: className, ...restProps } = attrs; + // Do not pass context to sub item to avoid multiple measure + return ( + + + {slots.default?.()} + + + ); + }; + }, +}); diff --git a/components/vc-overflow/context.ts b/components/vc-overflow/context.ts new file mode 100644 index 000000000..0ffe5c0e7 --- /dev/null +++ b/components/vc-overflow/context.ts @@ -0,0 +1,53 @@ +import { + computed, + ComputedRef, + defineComponent, + inject, + InjectionKey, + PropType, + provide, +} from 'vue'; +import { Key } from '../_util/type'; + +export interface OverflowContextProviderValueType { + prefixCls: string; + responsive: boolean; + order: number; + registerSize: (key: Key, width: number | null) => void; + display: boolean; + + invalidate: boolean; + + // Item Usage + item?: any; + itemKey?: Key; + + // Rest Usage + className?: string; +} + +const OverflowContextProviderKey: InjectionKey> = Symbol( + 'OverflowContextProviderKey', +); + +export const OverflowContextProvider = defineComponent({ + name: 'OverflowContextProvider', + inheritAttrs: false, + props: { + value: { type: Object as PropType }, + }, + setup(props, { slots }) { + provide( + OverflowContextProviderKey, + computed(() => props.value), + ); + return () => slots.default?.(); + }, +}); + +export const useInjectOverflowContext = (): ComputedRef => { + return inject( + OverflowContextProviderKey, + computed(() => null), + ); +}; diff --git a/components/vc-overflow/examples/basic.tsx b/components/vc-overflow/examples/basic.tsx new file mode 100644 index 000000000..511f276cd --- /dev/null +++ b/components/vc-overflow/examples/basic.tsx @@ -0,0 +1,98 @@ +import { defineComponent, ref } from 'vue'; +import Overflow from '..'; +import '../assets/index.less'; +import './common.less'; + +interface ItemType { + value: string | number; + label: string; +} + +function createData(count: number): ItemType[] { + const data: ItemType[] = new Array(count).fill(undefined).map((_, index) => ({ + value: index, + label: `Label ${index}`, + })); + + return data; +} + +function renderItem(item: ItemType) { + return ( +
+ {item.label} +
+ ); +} + +function renderRest(items: ItemType[]) { + return ( +
+ +{items.length}... +
+ ); +} +export default defineComponent({ + setup() { + const responsive = ref(true); + const data = ref(createData(1)); + return () => { + return ( +
+ + + +
+ +
+
+ ); + }; + }, +}); diff --git a/components/vc-overflow/index.ts b/components/vc-overflow/index.ts index 0d3724978..dbd789dc0 100644 --- a/components/vc-overflow/index.ts +++ b/components/vc-overflow/index.ts @@ -1,5 +1,5 @@ -// import Overflow, { OverflowProps } from './Overflow'; +import Overflow, { OverflowProps } from './Overflow'; -// export { OverflowProps }; +export { OverflowProps }; -// export default Overflow; +export default Overflow;