import type { DId, DSize } from '../../utils/types';
import type { DDropdownItem } from '../dropdown';
import { isUndefined, nth } from 'lodash';
import React, { useEffect, useImperativeHandle, useRef, useState } from 'react';
import { useEventCallback, useId, useIsomorphicLayoutEffect, useResize } from '@react-devui/hooks';
import { CloseOutlined, EllipsisOutlined, PlusOutlined } from '@react-devui/icons';
import { checkScrollEnd, getClassName } from '@react-devui/utils';
import { useDValue } from '../../hooks';
import { registerComponentMate } from '../../utils';
import { DDropdown } from '../dropdown';
import { useComponentConfig, usePrefixConfig, useTranslation } from '../root';
export interface DTabsRef {
updateIndicator: () => void;
}
export interface DTabItem<ID extends DId> {
id: ID;
title: React.ReactNode;
panel: React.ReactNode;
dropdownItem?: React.ReactNode;
disabled?: boolean;
closable?: boolean;
}
export interface DTabsProps<ID extends DId, T extends DTabItem<ID>> extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
dList: T[];
dActive?: ID;
dPlacement?: 'top' | 'right' | 'bottom' | 'left';
dCenter?: boolean;
dType?: 'wrap' | 'slider';
dSize?: DSize;
onActiveChange?: (id: T['id'], item: T) => void;
onAddClick?: () => void;
onClose?: (id: T['id'], item: T) => void;
}
const { COMPONENT_NAME } = registerComponentMate({ COMPONENT_NAME: 'DTabs' as const });
function Tabs<ID extends DId, T extends DTabItem<ID>>(props: DTabsProps<ID, T>, ref: React.ForwardedRef<DTabsRef>): JSX.Element | null {
const {
dList,
dActive,
dPlacement = 'top',
dCenter = false,
dType,
dSize,
onActiveChange,
onAddClick,
onClose,
...restProps
} = useComponentConfig(COMPONENT_NAME, props);
const dPrefix = usePrefixConfig();
const tabsRef = useRef<HTMLDivElement>(null);
const tablistWrapperRef = useRef<HTMLDivElement>(null);
const tablistRef = useRef<HTMLDivElement>(null);
const indicatorRef = useRef<HTMLDivElement>(null);
const [t] = useTranslation();
const uniqueId = useId();
const getTabId = (id: ID) => `${dPrefix}tabs-tab-${id}-${uniqueId}`;
const getPanelId = (id: ID) => `${dPrefix}tabs-panel-${id}-${uniqueId}`;
const [listOverflow, setListOverflow] = useState(false);
const [dropdownList, setDropdownList] = useState<T[]>([]);
const [scrollEnd, setScrollEnd] = useState(false);
const iconSize = dSize === 'smaller' ? 16 : dSize === 'larger' ? 20 : 18;
const isHorizontal = dPlacement === 'top' || dPlacement === 'bottom';
const [activeId, changeActiveId] = useDValue<ID | undefined, ID>(
() => {
for (const tab of dList) {
if (!tab.disabled) {
return tab.id;
}
}
},
dActive,
(id) => {
if (onActiveChange) {
const tab = dList.find((t) => t.id === id);
if (tab) {
onActiveChange(id, tab);
}
}
}
);
const refreshTabs = () => {
const tablistWrapperEl = tablistWrapperRef.current;
if (tablistWrapperEl) {
const isOverflow = isHorizontal
? tablistWrapperEl.scrollWidth > tablistWrapperEl.clientWidth
: tablistWrapperEl.scrollHeight > tablistWrapperEl.clientHeight;
setListOverflow(isOverflow);
setScrollEnd(checkScrollEnd(tablistWrapperEl)[isHorizontal ? 'x' : 'y']);
if (isOverflow) {
const tablistWrapperRect = tablistWrapperEl.getBoundingClientRect();
const dropdownList: T[] = [];
dList.forEach((tab) => {
const el = document.getElementById(getTabId(tab.id));
if (el) {
const rect = el.getBoundingClientRect();
if (isHorizontal) {
if (rect.right + 52 + (onAddClick ? 52 : 0) > tablistWrapperRect.right || rect.left < tablistWrapperRect.left) {
dropdownList.push(tab);
}
} else {
if (rect.bottom + 36 + (onAddClick ? 36 : 0) > tablistWrapperRect.bottom || rect.top < tablistWrapperRect.top) {
dropdownList.push(tab);
}
}
}
});
setDropdownList(dropdownList);
}
}
};
useIsomorphicLayoutEffect(() => {
refreshTabs();
}, []);
useResize(tablistWrapperRef, refreshTabs);
useResize(tablistRef, refreshTabs);
const updateIndicator = useEventCallback(() => {
if (tablistRef.current && indicatorRef.current) {
const tablistRect = tablistRef.current.getBoundingClientRect();
for (const tab of dList) {
if (tab.id === activeId) {
const el = document.getElementById(getTabId(tab.id));
if (el) {
const rect = el.getBoundingClientRect();
if (isHorizontal) {
indicatorRef.current.style.cssText = `left:${rect.left - tablistRect.left}px;width:${rect.width}px;opacity:1;`;
} else {
indicatorRef.current.style.cssText = `top:${rect.top - tablistRect.top}px;opacity:1;`;
}
}
}
}
}
});
useEffect(() => {
updateIndicator();
});
useImperativeHandle(
ref,
() => ({
updateIndicator,
}),
[updateIndicator]
);
const headerNode = (
<div
ref={tablistWrapperRef}
className={`${dPrefix}tabs__tablist-wrapper`}
onScroll={() => {
refreshTabs();
}}
>
<div
ref={tablistRef}
className={`${dPrefix}tabs__tablist`}
role="tablist"
aria-orientation={isHorizontal ? 'horizontal' : 'vertical'}
>
{dList.map((item, index) => {
const { id: itemId, title: itemTitle, disabled: itemDisabled, closable: itemClosable } = item;
const getTab = (next: boolean, _index = index): T | undefined => {
for (let focusIndex = next ? _index + 1 : _index - 1, n = 0; n < dList.length; next ? focusIndex++ : focusIndex--, n++) {
const t = nth(dList, focusIndex % dList.length);
if (t && !t.disabled) {
return t;
}
}
};
const focusTab = (t?: T) => {
if (t) {
changeActiveId(t.id);
const el = document.getElementById(getTabId(t.id));
if (el) {
el.focus();
}
}
};
const closeTab = () => {
if (activeId === itemId) {
let hasTab = false;
for (let focusIndex = index + 1; focusIndex < dList.length; focusIndex++) {
const t = nth(dList, focusIndex);
if (t && !t.disabled) {
hasTab = true;
focusTab(t);
break;
}
}
if (!hasTab) {
for (let focusIndex = index - 1; focusIndex >= 0; focusIndex--) {
const t = nth(dList, focusIndex);
if (t && !t.disabled) {
focusTab(t);
break;
}
}
}
}
onClose?.(itemId, item);
};
const active = itemId === activeId;
return (
<div
key={itemId}
id={getTabId(itemId)}
className={getClassName(`${dPrefix}tabs__tab`, {
'is-active': active,
'is-disabled': itemDisabled,
[`${dPrefix}tabs__tab--first`]: index === 0,
[`${dPrefix}tabs__tab--last`]: index === dList.length - 1,
})}
tabIndex={active && !itemDisabled ? 0 : -1}
role="tab"
aria-controls={getPanelId(itemId)}
aria-selected={active}
aria-disabled={itemDisabled}
onClick={() => {
changeActiveId(itemId);
}}
onKeyDown={(e) => {
switch (e.code) {
case 'Delete':
e.preventDefault();
if (itemClosable) {
closeTab();
}
break;
case 'ArrowLeft':
e.preventDefault();
if (dPlacement === 'top' || dPlacement === 'bottom') {
focusTab(getTab(false));
}
break;
case 'ArrowRight':
e.preventDefault();
if (dPlacement === 'top' || dPlacement === 'bottom') {
focusTab(getTab(true));
}
break;
case 'ArrowUp':
e.preventDefault();
if (dPlacement === 'left' || dPlacement === 'right') {
focusTab(getTab(false));
}
break;
case 'ArrowDown':
e.preventDefault();
if (dPlacement === 'left' || dPlacement === 'right') {
focusTab(getTab(true));
}
break;
case 'Home':
e.preventDefault();
for (const t of dList) {
if (!t.disabled) {
focusTab(t);
break;
}
}
break;
case 'End':
e.preventDefault();
for (let index = dList.length - 1; index >= 0; index--) {
if (!dList[index].disabled) {
focusTab(dList[index]);
break;
}
}
break;
default:
break;
}
}}
>
{itemTitle}
{!itemDisabled && itemClosable && (
<div
className={`${dPrefix}tabs__close`}
role="button"
aria-label={t('Close')}
onClick={(e) => {
e.stopPropagation();
closeTab();
}}
>
<CloseOutlined dSize={14} />
</div>
)}
</div>
);
})}
{(listOverflow || onAddClick) && (
<div className={`${dPrefix}tabs__button-container`}>
{listOverflow && (
<DDropdown
dList={dropdownList.map<DDropdownItem<ID>>((tab) => {
const { id: itemId, title: itemTitle, dropdownItem: itemDropdownItem, disabled: itemDisabled } = tab;
return {
id: itemId,
label: (
<span
className={getClassName(`${dPrefix}tabs__dropdown-item`, {
'is-active': itemId === activeId,
})}
>
{isUndefined(itemDropdownItem) ? itemTitle : itemDropdownItem}
</span>
),
type: 'item',
disabled: itemDisabled,
};
})}
dPlacement={dPlacement === 'left' ? 'bottom-left' : 'bottom-right'}
onItemClick={(id: ID) => {
changeActiveId(id);
return false;
}}
>
<div
className={getClassName(`${dPrefix}tabs__button`, `${dPrefix}tabs__button--more`, {
'is-end': scrollEnd,
})}
style={{
right: isHorizontal && onAddClick ? 52 : undefined,
bottom: !isHorizontal && onAddClick ? 36 : undefined,
}}
aria-label={t('More')}
>
<EllipsisOutlined dSize={iconSize} />
</div>
</DDropdown>
)}
{onAddClick && (
<div
className={getClassName(`${dPrefix}tabs__button`, `${dPrefix}tabs__button--add`)}
role="button"
aria-label={t('Add')}
onClick={() => {
onAddClick?.();
}}
>
<PlusOutlined dSize={iconSize} />
</div>
)}
</div>
)}
<div ref={indicatorRef} className={`${dPrefix}tabs__${dType ? dType + '-' : ''}indicator`}></div>
</div>
</div>
);
const panelNode = dList.map((item) => {
const { id: itemId, panel: itemPanel } = item;
return (
<div
key={itemId}
id={getPanelId(itemId)}
className={`${dPrefix}tabs__tabpanel`}
tabIndex={0}
hidden={itemId !== activeId}
role="tabpanel"
aria-labelledby={getTabId(itemId)}
>
{itemPanel}
</div>
);
});
return (
<div
{...restProps}
ref={tabsRef}
className={getClassName(restProps.className, `${dPrefix}tabs`, `${dPrefix}tabs--${dPlacement}`, {
[`${dPrefix}tabs--${dType}`]: dType,
[`${dPrefix}tabs--${dSize}`]: dSize,
[`${dPrefix}tabs--center`]: dCenter,
})}
>
{dPlacement === 'top' || dPlacement === 'left' ? (
<>
{headerNode}
{panelNode}
</>
) : (
<>
{panelNode}
{headerNode}
</>
)}
</div>
);
}
export const DTabs: <ID extends DId, T extends DTabItem<ID>>(
props: DTabsProps<ID, T> & React.RefAttributes<DTabsRef>
) => ReturnType<typeof Tabs> = React.forwardRef(Tabs) as any;