580 lines
19 KiB
Vue
580 lines
19 KiB
Vue
import { useRafState } from '../hooks/useRaf';
|
|
import TabNode from './TabNode';
|
|
import type {
|
|
TabSizeMap,
|
|
TabPosition,
|
|
RenderTabBar,
|
|
TabsLocale,
|
|
EditableConfig,
|
|
AnimatedConfig,
|
|
OnTabScroll,
|
|
TabBarExtraPosition,
|
|
TabBarExtraContent,
|
|
} from '../interface';
|
|
import useOffsets from '../hooks/useOffsets';
|
|
import OperationNode from './OperationNode';
|
|
import { useInjectTabs } from '../TabContext';
|
|
import useTouchMove from '../hooks/useTouchMove';
|
|
import AddButton from './AddButton';
|
|
import { objectType, functionType } from '../../../_util/type';
|
|
import type { CustomSlotsType, Key } from '../../../_util/type';
|
|
import type { ExtractPropTypes, PropType, CSSProperties } from 'vue';
|
|
import { shallowRef, onBeforeUnmount, defineComponent, watch, watchEffect, computed } from 'vue';
|
|
import PropTypes from '../../../_util/vue-types';
|
|
import useSyncState from '../hooks/useSyncState';
|
|
import useState from '../../../_util/hooks/useState';
|
|
import raf from '../../../_util/raf';
|
|
import classNames from '../../../_util/classNames';
|
|
import ResizeObserver from '../../../vc-resize-observer';
|
|
import { toPx } from '../../../_util/util';
|
|
import useRefs from '../../../_util/hooks/useRefs';
|
|
import pick from 'lodash-es/pick';
|
|
|
|
const DEFAULT_SIZE = { width: 0, height: 0, left: 0, top: 0, right: 0 };
|
|
export const tabNavListProps = () => {
|
|
return {
|
|
id: { type: String },
|
|
tabPosition: { type: String as PropType<TabPosition> },
|
|
activeKey: { type: [String, Number] },
|
|
rtl: { type: Boolean },
|
|
animated: objectType<AnimatedConfig>(),
|
|
editable: objectType<EditableConfig>(),
|
|
moreIcon: PropTypes.any,
|
|
moreTransitionName: { type: String },
|
|
mobile: { type: Boolean },
|
|
tabBarGutter: { type: Number },
|
|
renderTabBar: { type: Function as PropType<RenderTabBar> },
|
|
locale: objectType<TabsLocale>(),
|
|
popupClassName: String,
|
|
getPopupContainer: functionType<
|
|
((triggerNode?: HTMLElement | undefined) => HTMLElement) | undefined
|
|
>(),
|
|
onTabClick: {
|
|
type: Function as PropType<(activeKey: Key, e: MouseEvent | KeyboardEvent) => void>,
|
|
},
|
|
onTabScroll: { type: Function as PropType<OnTabScroll> },
|
|
};
|
|
};
|
|
|
|
export type TabNavListProps = Partial<ExtractPropTypes<ReturnType<typeof tabNavListProps>>>;
|
|
|
|
interface ExtraContentProps {
|
|
position: TabBarExtraPosition;
|
|
prefixCls: string;
|
|
extra?: (info?: { position: 'left' | 'right' }) => TabBarExtraContent;
|
|
}
|
|
|
|
const getTabSize = (tab: HTMLElement, containerRect: { x: number; y: number }) => {
|
|
// tabListRef
|
|
const { offsetWidth, offsetHeight, offsetTop, offsetLeft } = tab;
|
|
const { width, height, x, y } = tab.getBoundingClientRect();
|
|
|
|
// Use getBoundingClientRect to avoid decimal inaccuracy
|
|
if (Math.abs(width - offsetWidth) < 1) {
|
|
return [width, height, x - containerRect.x, y - containerRect.y];
|
|
}
|
|
|
|
return [offsetWidth, offsetHeight, offsetLeft, offsetTop];
|
|
};
|
|
|
|
// const getSize = (refObj: ShallowRef<HTMLElement>) => {
|
|
// const { offsetWidth = 0, offsetHeight = 0 } = refObj.value || {};
|
|
|
|
// // Use getBoundingClientRect to avoid decimal inaccuracy
|
|
// if (refObj.value) {
|
|
// const { width, height } = refObj.value.getBoundingClientRect();
|
|
|
|
// if (Math.abs(width - offsetWidth) < 1) {
|
|
// return [width, height];
|
|
// }
|
|
// }
|
|
|
|
// return [offsetWidth, offsetHeight];
|
|
// };
|
|
|
|
export default defineComponent({
|
|
compatConfig: { MODE: 3 },
|
|
name: 'TabNavList',
|
|
inheritAttrs: false,
|
|
props: tabNavListProps(),
|
|
slots: Object as CustomSlotsType<{
|
|
moreIcon?: any;
|
|
leftExtra?: any;
|
|
rightExtra?: any;
|
|
tabBarExtraContent?: any;
|
|
default?: any;
|
|
}>,
|
|
emits: ['tabClick', 'tabScroll'],
|
|
setup(props, { attrs, slots }) {
|
|
const { tabs, prefixCls } = useInjectTabs();
|
|
const tabsWrapperRef = shallowRef<HTMLDivElement>();
|
|
const tabListRef = shallowRef<HTMLDivElement>();
|
|
const operationsRef = shallowRef<{ $el: HTMLDivElement }>();
|
|
const innerAddButtonRef = shallowRef();
|
|
const [setRef, btnRefs] = useRefs();
|
|
const tabPositionTopOrBottom = computed(
|
|
() => props.tabPosition === 'top' || props.tabPosition === 'bottom',
|
|
);
|
|
|
|
const [transformLeft, setTransformLeft] = useSyncState(0, (next, prev) => {
|
|
if (tabPositionTopOrBottom.value && props.onTabScroll) {
|
|
props.onTabScroll({ direction: next > prev ? 'left' : 'right' });
|
|
}
|
|
});
|
|
const [transformTop, setTransformTop] = useSyncState(0, (next, prev) => {
|
|
if (!tabPositionTopOrBottom.value && props.onTabScroll) {
|
|
props.onTabScroll({ direction: next > prev ? 'top' : 'bottom' });
|
|
}
|
|
});
|
|
|
|
const [wrapperScrollWidth, setWrapperScrollWidth] = useState<number>(0);
|
|
const [wrapperScrollHeight, setWrapperScrollHeight] = useState<number>(0);
|
|
const [wrapperWidth, setWrapperWidth] = useState<number>(null);
|
|
const [wrapperHeight, setWrapperHeight] = useState<number>(null);
|
|
const [addWidth, setAddWidth] = useState<number>(0);
|
|
const [addHeight, setAddHeight] = useState<number>(0);
|
|
|
|
const [tabSizes, setTabSizes] = useRafState<TabSizeMap>(new Map());
|
|
const tabOffsets = useOffsets(tabs, tabSizes);
|
|
// ========================== Util =========================
|
|
const operationsHiddenClassName = computed(() => `${prefixCls.value}-nav-operations-hidden`);
|
|
|
|
const transformMin = shallowRef(0);
|
|
const transformMax = shallowRef(0);
|
|
|
|
watchEffect(() => {
|
|
if (!tabPositionTopOrBottom.value) {
|
|
transformMin.value = Math.min(0, wrapperHeight.value - wrapperScrollHeight.value);
|
|
transformMax.value = 0;
|
|
} else if (props.rtl) {
|
|
transformMin.value = 0;
|
|
transformMax.value = Math.max(0, wrapperScrollWidth.value - wrapperWidth.value);
|
|
} else {
|
|
transformMin.value = Math.min(0, wrapperWidth.value - wrapperScrollWidth.value);
|
|
transformMax.value = 0;
|
|
}
|
|
});
|
|
|
|
const alignInRange = (value: number): number => {
|
|
if (value < transformMin.value) {
|
|
return transformMin.value;
|
|
}
|
|
if (value > transformMax.value) {
|
|
return transformMax.value;
|
|
}
|
|
return value;
|
|
};
|
|
|
|
// ========================= Mobile ========================
|
|
const touchMovingRef = shallowRef<any>();
|
|
const [lockAnimation, setLockAnimation] = useState<number>();
|
|
|
|
const doLockAnimation = () => {
|
|
setLockAnimation(Date.now());
|
|
};
|
|
|
|
const clearTouchMoving = () => {
|
|
clearTimeout(touchMovingRef.value);
|
|
};
|
|
const doMove = (setState: (fn: (val: number) => number) => void, offset: number) => {
|
|
setState((value: number) => {
|
|
const newValue = alignInRange(value + offset);
|
|
|
|
return newValue;
|
|
});
|
|
};
|
|
useTouchMove(tabsWrapperRef, (offsetX, offsetY) => {
|
|
if (tabPositionTopOrBottom.value) {
|
|
// Skip scroll if place is enough
|
|
if (wrapperWidth.value >= wrapperScrollWidth.value) {
|
|
return false;
|
|
}
|
|
|
|
doMove(setTransformLeft, offsetX);
|
|
} else {
|
|
if (wrapperHeight.value >= wrapperScrollHeight.value) {
|
|
return false;
|
|
}
|
|
|
|
doMove(setTransformTop, offsetY);
|
|
}
|
|
|
|
clearTouchMoving();
|
|
doLockAnimation();
|
|
|
|
return true;
|
|
});
|
|
|
|
watch(lockAnimation, () => {
|
|
clearTouchMoving();
|
|
if (lockAnimation.value) {
|
|
touchMovingRef.value = setTimeout(() => {
|
|
setLockAnimation(0);
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
// ========================= Scroll ========================
|
|
const scrollToTab = (key = props.activeKey) => {
|
|
const tabOffset = tabOffsets.value.get(key) || {
|
|
width: 0,
|
|
height: 0,
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
};
|
|
|
|
if (tabPositionTopOrBottom.value) {
|
|
// ============ Align with top & bottom ============
|
|
let newTransform = transformLeft.value;
|
|
|
|
// RTL
|
|
if (props.rtl) {
|
|
if (tabOffset.right < transformLeft.value) {
|
|
newTransform = tabOffset.right;
|
|
} else if (tabOffset.right + tabOffset.width > transformLeft.value + wrapperWidth.value) {
|
|
newTransform = tabOffset.right + tabOffset.width - wrapperWidth.value;
|
|
}
|
|
}
|
|
// LTR
|
|
else if (tabOffset.left < -transformLeft.value) {
|
|
newTransform = -tabOffset.left;
|
|
} else if (tabOffset.left + tabOffset.width > -transformLeft.value + wrapperWidth.value) {
|
|
newTransform = -(tabOffset.left + tabOffset.width - wrapperWidth.value);
|
|
}
|
|
|
|
setTransformTop(0);
|
|
setTransformLeft(alignInRange(newTransform));
|
|
} else {
|
|
// ============ Align with left & right ============
|
|
let newTransform = transformTop.value;
|
|
|
|
if (tabOffset.top < -transformTop.value) {
|
|
newTransform = -tabOffset.top;
|
|
} else if (tabOffset.top + tabOffset.height > -transformTop.value + wrapperHeight.value) {
|
|
newTransform = -(tabOffset.top + tabOffset.height - wrapperHeight.value);
|
|
}
|
|
|
|
setTransformLeft(0);
|
|
setTransformTop(alignInRange(newTransform));
|
|
}
|
|
};
|
|
|
|
const visibleStart = shallowRef(0);
|
|
const visibleEnd = shallowRef(0);
|
|
|
|
watchEffect(() => {
|
|
let unit: 'width' | 'height';
|
|
let position: 'left' | 'top' | 'right';
|
|
let transformSize: number;
|
|
let basicSize: number;
|
|
let tabContentSize: number;
|
|
let addSize: number;
|
|
const tabOffsetsValue = tabOffsets.value;
|
|
if (['top', 'bottom'].includes(props.tabPosition)) {
|
|
unit = 'width';
|
|
basicSize = wrapperWidth.value;
|
|
tabContentSize = wrapperScrollWidth.value;
|
|
addSize = addWidth.value;
|
|
position = props.rtl ? 'right' : 'left';
|
|
transformSize = Math.abs(transformLeft.value);
|
|
} else {
|
|
unit = 'height';
|
|
basicSize = wrapperHeight.value;
|
|
tabContentSize = wrapperScrollWidth.value;
|
|
addSize = addHeight.value;
|
|
position = 'top';
|
|
transformSize = -transformTop.value;
|
|
}
|
|
let mergedBasicSize = basicSize;
|
|
if (tabContentSize + addSize > basicSize && tabContentSize < basicSize) {
|
|
mergedBasicSize = basicSize - addSize;
|
|
}
|
|
|
|
const tabsVal = tabs.value;
|
|
if (!tabsVal.length) {
|
|
return ([visibleStart.value, visibleEnd.value] = [0, 0]);
|
|
}
|
|
|
|
const len = tabsVal.length;
|
|
let endIndex = len;
|
|
for (let i = 0; i < len; i += 1) {
|
|
const offset = tabOffsetsValue.get(tabsVal[i].key) || DEFAULT_SIZE;
|
|
if (offset[position] + offset[unit] > transformSize + mergedBasicSize) {
|
|
endIndex = i - 1;
|
|
break;
|
|
}
|
|
}
|
|
let startIndex = 0;
|
|
for (let i = len - 1; i >= 0; i -= 1) {
|
|
const offset = tabOffsetsValue.get(tabsVal[i].key) || DEFAULT_SIZE;
|
|
if (offset[position] < transformSize) {
|
|
startIndex = i + 1;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return ([visibleStart.value, visibleEnd.value] = [startIndex, endIndex]);
|
|
});
|
|
const updateTabSizes = () => {
|
|
setTabSizes(() => {
|
|
const newSizes: TabSizeMap = new Map();
|
|
const listRect = tabListRef.value?.getBoundingClientRect();
|
|
tabs.value.forEach(({ key }) => {
|
|
const btnRef = btnRefs.value.get(key);
|
|
const btnNode = (btnRef as any)?.$el || btnRef;
|
|
if (btnNode) {
|
|
const [width, height, left, top] = getTabSize(btnNode, listRect);
|
|
newSizes.set(key, { width, height, left, top });
|
|
}
|
|
});
|
|
return newSizes;
|
|
});
|
|
};
|
|
|
|
watch(
|
|
() => tabs.value.map(tab => tab.key).join('%%'),
|
|
() => {
|
|
updateTabSizes();
|
|
},
|
|
{ flush: 'post' },
|
|
);
|
|
const onListHolderResize = () => {
|
|
// Update wrapper records
|
|
const offsetWidth = tabsWrapperRef.value?.offsetWidth || 0;
|
|
const offsetHeight = tabsWrapperRef.value?.offsetHeight || 0;
|
|
const addDom = innerAddButtonRef.value?.$el || {};
|
|
const newAddWidth = addDom.offsetWidth || 0;
|
|
const newAddHeight = addDom.offsetHeight || 0;
|
|
setWrapperWidth(offsetWidth);
|
|
setWrapperHeight(offsetHeight);
|
|
setAddWidth(newAddWidth);
|
|
setAddHeight(newAddHeight);
|
|
|
|
const newWrapperScrollWidth = (tabListRef.value?.offsetWidth || 0) - newAddWidth;
|
|
const newWrapperScrollHeight = (tabListRef.value?.offsetHeight || 0) - newAddHeight;
|
|
|
|
setWrapperScrollWidth(newWrapperScrollWidth);
|
|
setWrapperScrollHeight(newWrapperScrollHeight);
|
|
|
|
// Update buttons records
|
|
updateTabSizes();
|
|
};
|
|
|
|
// ======================== Dropdown =======================
|
|
const hiddenTabs = computed(() => [
|
|
...tabs.value.slice(0, visibleStart.value),
|
|
...tabs.value.slice(visibleEnd.value + 1),
|
|
]);
|
|
|
|
// =================== Link & Operations ===================
|
|
const [inkStyle, setInkStyle] = useState<CSSProperties>();
|
|
|
|
const activeTabOffset = computed(() => tabOffsets.value.get(props.activeKey));
|
|
|
|
// Delay set ink style to avoid remove tab blink
|
|
const inkBarRafRef = shallowRef<number>();
|
|
const cleanInkBarRaf = () => {
|
|
raf.cancel(inkBarRafRef.value);
|
|
};
|
|
|
|
watch([activeTabOffset, tabPositionTopOrBottom, () => props.rtl], () => {
|
|
const newInkStyle: CSSProperties = {};
|
|
|
|
if (activeTabOffset.value) {
|
|
if (tabPositionTopOrBottom.value) {
|
|
if (props.rtl) {
|
|
newInkStyle.right = toPx(activeTabOffset.value.right);
|
|
} else {
|
|
newInkStyle.left = toPx(activeTabOffset.value.left);
|
|
}
|
|
|
|
newInkStyle.width = toPx(activeTabOffset.value.width);
|
|
} else {
|
|
newInkStyle.top = toPx(activeTabOffset.value.top);
|
|
newInkStyle.height = toPx(activeTabOffset.value.height);
|
|
}
|
|
}
|
|
|
|
cleanInkBarRaf();
|
|
inkBarRafRef.value = raf(() => {
|
|
setInkStyle(newInkStyle);
|
|
});
|
|
});
|
|
|
|
watch(
|
|
[() => props.activeKey, activeTabOffset, tabOffsets, tabPositionTopOrBottom],
|
|
() => {
|
|
scrollToTab();
|
|
},
|
|
{ flush: 'post' },
|
|
);
|
|
|
|
watch(
|
|
[() => props.rtl, () => props.tabBarGutter, () => props.activeKey, () => tabs.value],
|
|
() => {
|
|
onListHolderResize();
|
|
},
|
|
{ flush: 'post' },
|
|
);
|
|
|
|
const ExtraContent = ({ position, prefixCls, extra }: ExtraContentProps) => {
|
|
if (!extra) return null;
|
|
const content = extra?.({ position });
|
|
return content ? <div class={`${prefixCls}-extra-content`}>{content}</div> : null;
|
|
};
|
|
|
|
onBeforeUnmount(() => {
|
|
clearTouchMoving();
|
|
cleanInkBarRaf();
|
|
});
|
|
|
|
return () => {
|
|
const {
|
|
id,
|
|
animated,
|
|
activeKey,
|
|
rtl,
|
|
editable,
|
|
locale,
|
|
tabPosition,
|
|
tabBarGutter,
|
|
onTabClick,
|
|
} = props;
|
|
const { class: className, style } = attrs;
|
|
const pre = prefixCls.value;
|
|
// ========================= Render ========================
|
|
const hasDropdown = !!hiddenTabs.value.length;
|
|
const wrapPrefix = `${pre}-nav-wrap`;
|
|
let pingLeft: boolean;
|
|
let pingRight: boolean;
|
|
let pingTop: boolean;
|
|
let pingBottom: boolean;
|
|
|
|
if (tabPositionTopOrBottom.value) {
|
|
if (rtl) {
|
|
pingRight = transformLeft.value > 0;
|
|
pingLeft = transformLeft.value + wrapperWidth.value < wrapperScrollWidth.value;
|
|
} else {
|
|
pingLeft = transformLeft.value < 0;
|
|
pingRight = -transformLeft.value + wrapperWidth.value < wrapperScrollWidth.value;
|
|
}
|
|
} else {
|
|
pingTop = transformTop.value < 0;
|
|
pingBottom = -transformTop.value + wrapperHeight.value < wrapperScrollHeight.value;
|
|
}
|
|
|
|
const tabNodeStyle: CSSProperties = {};
|
|
if (tabPosition === 'top' || tabPosition === 'bottom') {
|
|
tabNodeStyle[rtl ? 'marginRight' : 'marginLeft'] =
|
|
typeof tabBarGutter === 'number' ? `${tabBarGutter}px` : tabBarGutter;
|
|
} else {
|
|
tabNodeStyle.marginTop =
|
|
typeof tabBarGutter === 'number' ? `${tabBarGutter}px` : tabBarGutter;
|
|
}
|
|
|
|
const tabNodes = tabs.value.map((tab, i) => {
|
|
const { key } = tab;
|
|
return (
|
|
<TabNode
|
|
id={id}
|
|
prefixCls={pre}
|
|
key={key}
|
|
tab={tab}
|
|
/* first node should not have margin left */
|
|
style={i === 0 ? undefined : tabNodeStyle}
|
|
closable={tab.closable}
|
|
editable={editable}
|
|
active={key === activeKey}
|
|
removeAriaLabel={locale?.removeAriaLabel}
|
|
ref={setRef(key)}
|
|
onClick={e => {
|
|
onTabClick(key, e);
|
|
}}
|
|
onFocus={() => {
|
|
scrollToTab(key);
|
|
doLockAnimation();
|
|
if (!tabsWrapperRef.value) {
|
|
return;
|
|
}
|
|
// Focus element will make scrollLeft change which we should reset back
|
|
if (!rtl) {
|
|
tabsWrapperRef.value.scrollLeft = 0;
|
|
}
|
|
tabsWrapperRef.value.scrollTop = 0;
|
|
}}
|
|
v-slots={slots}
|
|
></TabNode>
|
|
);
|
|
});
|
|
return (
|
|
<div
|
|
role="tablist"
|
|
class={classNames(`${pre}-nav`, className)}
|
|
style={style as CSSProperties}
|
|
onKeydown={() => {
|
|
// No need animation when use keyboard
|
|
doLockAnimation();
|
|
}}
|
|
>
|
|
<ExtraContent position="left" prefixCls={pre} extra={slots.leftExtra} />
|
|
|
|
<ResizeObserver onResize={onListHolderResize}>
|
|
<div
|
|
class={classNames(wrapPrefix, {
|
|
[`${wrapPrefix}-ping-left`]: pingLeft,
|
|
[`${wrapPrefix}-ping-right`]: pingRight,
|
|
[`${wrapPrefix}-ping-top`]: pingTop,
|
|
[`${wrapPrefix}-ping-bottom`]: pingBottom,
|
|
})}
|
|
ref={tabsWrapperRef}
|
|
>
|
|
<ResizeObserver onResize={onListHolderResize}>
|
|
<div
|
|
ref={tabListRef}
|
|
class={`${pre}-nav-list`}
|
|
style={{
|
|
transform: `translate(${transformLeft.value}px, ${transformTop.value}px)`,
|
|
transition: lockAnimation.value ? 'none' : undefined,
|
|
}}
|
|
>
|
|
{tabNodes}
|
|
<AddButton
|
|
ref={innerAddButtonRef}
|
|
prefixCls={pre}
|
|
locale={locale}
|
|
editable={editable}
|
|
style={{
|
|
...(tabNodes.length === 0 ? undefined : tabNodeStyle),
|
|
visibility: hasDropdown ? 'hidden' : null,
|
|
}}
|
|
/>
|
|
|
|
<div
|
|
class={classNames(`${pre}-ink-bar`, {
|
|
[`${pre}-ink-bar-animated`]: animated.inkBar,
|
|
})}
|
|
style={inkStyle.value}
|
|
/>
|
|
</div>
|
|
</ResizeObserver>
|
|
</div>
|
|
</ResizeObserver>
|
|
<OperationNode
|
|
{...props}
|
|
removeAriaLabel={locale?.removeAriaLabel}
|
|
v-slots={pick(slots, ['moreIcon'])}
|
|
ref={operationsRef}
|
|
prefixCls={pre}
|
|
tabs={hiddenTabs.value}
|
|
class={!hasDropdown && operationsHiddenClassName.value}
|
|
/>
|
|
|
|
<ExtraContent position="right" prefixCls={pre} extra={slots.rightExtra} />
|
|
<ExtraContent position="right" prefixCls={pre} extra={slots.tabBarExtraContent} />
|
|
</div>
|
|
);
|
|
};
|
|
},
|
|
});
|