src/components/Tabs/Tabs.tsx (105 lines of code) (raw):
import React, { useState, useRef, useEffect, useCallback } from 'react';
import clsx from 'clsx';
import smoothScroll from '../../utils/smoothScroll';
import useNextId from '../../hooks/useNextId';
type TabItemProps = {
active: boolean;
index: number;
tabIndex: number;
onClick: (index: number, event: React.MouseEvent) => void;
};
const TabItem: React.FC<TabItemProps> = (props) => {
const { active, index, children, onClick, ...others } = props;
function handleClick(e: React.MouseEvent) {
onClick(index, e);
}
return (
<div className="Tabs-navItem">
<button
className={clsx('Tabs-navLink', { active })}
type="button"
role="tab"
aria-selected={active}
onClick={handleClick}
{...others}
>
<span>{children}</span>
</button>
</div>
);
};
type TabsPaneProps = {
active: boolean;
id?: string;
};
const TabsPane: React.FC<TabsPaneProps> = (props) => {
const { active, children, ...others } = props;
return (
<div className={clsx('Tabs-pane', { active })} {...others} role="tabpanel">
{children}
</div>
);
};
export type TabsProps = {
className?: string;
index?: number;
scrollable?: boolean;
hideNavIfOnlyOne?: boolean;
onChange?: (index: number, event: React.MouseEvent) => void;
};
export const Tabs: React.FC<TabsProps> = (props) => {
const { className, index: oIndex = 0, scrollable, hideNavIfOnlyOne, children, onChange } = props;
const [pointerStyles, setPointerStyles] = useState({});
const [index, setIndex] = useState(oIndex || 0);
const indexRef = useRef(index);
const navRef = useRef<HTMLDivElement>(null);
const headers: Array<React.ReactNode> = [];
const contents: Array<React.ReactNode> = [];
const tabPaneId = useNextId('tabs-');
function handleIndexChange(idx: number, e: React.MouseEvent) {
setIndex(idx);
if (onChange) {
onChange(idx, e);
}
}
React.Children.forEach(children, (item: any, idx) => {
if (!item) return;
const active = index === idx;
const id = `${tabPaneId}-${idx}`;
headers.push(
<TabItem
active={active}
index={idx}
key={id}
onClick={handleIndexChange}
aria-controls={id}
tabIndex={active ? -1 : 0}
>
{item.props.label}
</TabItem>,
);
if (item.props.children) {
contents.push(
<TabsPane active={active} key={id} id={id}>
{item.props.children}
</TabsPane>,
);
}
});
useEffect(() => {
setIndex(oIndex);
}, [oIndex]);
const movePointer = useCallback(() => {
const nav = navRef.current;
if (!nav) return;
const currentNav = nav.children[indexRef.current];
if (!currentNav) return;
const text = currentNav.querySelector('span');
if (!text) return;
const { offsetWidth: navWidth, offsetLeft: navOffsetLeft } = currentNav as HTMLElement;
const { width: textWidth } = text.getBoundingClientRect();
const pointerWidth = Math.max(textWidth - 16, 26);
// 中心位的偏移量
const offsetLeftOfCenter = navOffsetLeft + navWidth / 2;
setPointerStyles({
transform: `translateX(${offsetLeftOfCenter - pointerWidth / 2}px)`,
width: `${pointerWidth}px`,
});
if (scrollable) {
smoothScroll({
el: nav,
to: offsetLeftOfCenter - nav.offsetWidth / 2,
x: true,
});
}
}, [scrollable]);
useEffect(() => {
const nav = navRef.current;
let ro: ResizeObserver;
if (nav && 'ResizeObserver' in window) {
ro = new ResizeObserver(movePointer);
ro.observe(nav);
}
return () => {
if (ro && nav) {
ro.unobserve(nav);
}
};
}, [movePointer]);
useEffect(() => {
indexRef.current = index;
movePointer();
}, [index, movePointer]);
const needNav = headers.length > (hideNavIfOnlyOne ? 1 : 0);
return (
<div className={clsx('Tabs', { 'Tabs--scrollable': scrollable }, className)}>
{needNav && (
<div className="Tabs-nav" role="tablist" ref={navRef}>
{headers}
<span className="Tabs-navPointer" style={pointerStyles} />
</div>
)}
<div className="Tabs-content">{contents}</div>
</div>
);
};