import { clsx } from 'clsx'; import clamp from 'lodash.clamp'; import { Component, createRef } from 'react'; import { Width, Direction } from '../common'; import { DirectionContext } from '../provider/direction'; import Tab from './Tab'; import TabList from './TabList'; import TabPanel from './TabPanel'; import { getSwipeDifference, swipedLeftToRight, swipedRightToLeft, swipeShouldChangeTab, Swipe, } from './utils'; const MIN_INDEX = 0; export interface TabItem { title: string; content: React.ReactNode; disabled: boolean; } export interface TabsProps { tabs: TabItem[]; selected: number; name: string; /** @default true */ changeTabOnSwipe?: boolean; className?: string; /** @default 'block' */ headerWidth?: `${Width}`; onTabSelect: (index: number) => void; } interface TabsState { start: Swipe | null; translateLineX: string | null; isSwiping: boolean; isScrolling: boolean; fullWidthTabs: boolean; } export default class Tabs extends Component { declare props: TabsProps & Required>; static defaultProps = { changeTabOnSwipe: true, headerWidth: Width.BLOCK, } satisfies Partial; containerReference = createRef(); constructor(props: Tabs['props']) { super(props); this.state = { start: null, translateLineX: null, isSwiping: false, isScrolling: false, fullWidthTabs: props.headerWidth === Width.BLOCK, }; } container: HTMLDivElement | null = null; containerWidth = 0; tabRefs: (HTMLLIElement | null)[] = []; get MAX_INDEX() { return this.props.tabs.length - 1; } componentDidMount() { const { selected } = this.props; this.setTabWidth(); this.switchTab(clamp(selected, MIN_INDEX, this.MAX_INDEX)); this.animateLine(clamp(selected, MIN_INDEX, this.MAX_INDEX)); document.body.addEventListener('touchmove', this.disableScroll, { passive: false }); document.body.addEventListener('touchforcechange', this.disableScroll, { passive: false }); window.addEventListener('resize', this.handleResize); } componentDidUpdate(previousProps: TabsProps) { const currentSelected = this.props.selected; const previousSelected = previousProps.selected; const currentSelectedTab = this.props.tabs[currentSelected]; const currentSelectedTabIsDisabled = currentSelectedTab?.disabled; const previousSelectedTab = previousProps.tabs[previousSelected]; const previousSelectedTabIsDisabled = previousSelectedTab?.disabled; const currentTabsLength = this.props.tabs.length; const previousTabsLength = previousProps.tabs.length; const currentDisabledTabsLength = this.props.tabs.filter((tab) => !tab.disabled).length; const previousDisabledTabsLength = previousProps.tabs.filter((tab) => !tab.disabled).length; const currentHeaderWidth = this.props.headerWidth; const previousFullHeaderWidth = previousProps.headerWidth; if ( currentHeaderWidth !== previousFullHeaderWidth || currentTabsLength !== previousTabsLength ) { this.setTabWidth(); } if ( currentSelected !== previousSelected || currentDisabledTabsLength !== previousDisabledTabsLength || currentSelectedTabIsDisabled !== previousSelectedTabIsDisabled ) { this.animateLine(clamp(currentSelected, MIN_INDEX, this.MAX_INDEX)); } } componentWillUnmount() { document.body.removeEventListener('touchmove', this.disableScroll); document.body.removeEventListener('touchforcechange', this.disableScroll); window.removeEventListener('resize', this.handleResize); } handleResize = () => { this.setContainerWidth(this.container); }; setContainerRefAndWidth = (node: HTMLDivElement | null) => { this.container = node; this.setContainerWidth(node); }; setContainerWidth = (node: HTMLDivElement | null) => { if (!node) { return; } const { width } = node.getBoundingClientRect(); this.containerWidth = width; }; isTabDisabled = (index: number) => { const { tabs } = this.props; return tabs[index]?.disabled ?? false; }; getAllTabsWidth = () => { return this.tabRefs .map((reference) => { return reference ? reference.getBoundingClientRect().width : 0; }) .reduce((a, b) => a + b, 0); }; getDistanceToSelectedTab = (selectedTabIndex: number) => { return this.tabRefs .filter((_, idx) => idx < selectedTabIndex) .map((reference) => (reference ? reference.getBoundingClientRect().width : 0)) .reduce((a, b) => a + b, 0); }; setTabWidth = () => { const { fullWidthTabs } = this.state; const { headerWidth, selected } = this.props; const allTabsWidth = this.getAllTabsWidth(); if (!fullWidthTabs && (headerWidth === Width.BLOCK || this.containerWidth < allTabsWidth)) { this.setState({ fullWidthTabs: true, translateLineX: `${selected * 100}%` }); } if (fullWidthTabs && headerWidth === Width.AUTO && this.containerWidth >= allTabsWidth) { this.setState({ fullWidthTabs: false, translateLineX: `${this.getDistanceToSelectedTab(selected)}px`, }); } }; getTabLineWidth = () => { const { fullWidthTabs } = this.state; const { selected, tabs } = this.props; if (fullWidthTabs) { return `${(1 / tabs.length) * 100}%`; } const reference = this.tabRefs[selected] || this.tabRefs[this.tabRefs.length - 1]; const width = reference ? reference.getBoundingClientRect().width : 0; return `${width}px`; }; /* * Gets the next tab that should be selected based on the swipe direction * and the current selected tab (is called recursively to account for disabled tabs). */ getTabToSelect = (selected: number, start: Swipe, end: Swipe): number => { let nextSelected = selected; if (swipedLeftToRight(start, end)) { nextSelected -= 1; if (nextSelected > MIN_INDEX && this.isTabDisabled(nextSelected)) { return this.getTabToSelect(nextSelected, start, end); } } else if (swipedRightToLeft(start, end)) { nextSelected += 1; if (nextSelected < this.MAX_INDEX && this.isTabDisabled(nextSelected)) { return this.getTabToSelect(nextSelected, start, end); } } nextSelected = clamp( nextSelected, Math.max(selected - 1, MIN_INDEX), Math.min(selected + 1, this.MAX_INDEX), ); if (this.isTabDisabled(nextSelected)) { return selected; } return nextSelected; }; swipedOverHalfOfContainer = (difference: number) => difference / this.containerWidth >= 0.5; switchTab = (index: number) => { const { onTabSelect } = this.props; onTabSelect(index); }; animateLine = (index: number) => { this.setState((previousState) => ({ translateLineX: previousState.fullWidthTabs ? `${index * 100}%` : `${this.getDistanceToSelectedTab(index)}px`, })); }; disableScroll = (event: Event) => { const { isSwiping } = this.state; if (isSwiping) { event.preventDefault(); } }; handleTabClick = (index: number) => () => { this.switchTab(index); }; onKeyDown = (index: number) => (event: React.KeyboardEvent) => { if (event && event.key === 'Enter') { this.switchTab(index); } }; handleTouchStart: React.TouchEventHandler = (event) => { const start = { x: event.nativeEvent.touches[0].clientX, y: event.nativeEvent.touches[0].clientY, time: Date.now(), }; this.setState({ start, }); }; handleTouchMove: React.TouchEventHandler = (event) => { const { start } = this.state; if (start == null) { return; } const { selected: currentSelectedFromProps } = this.props; const end: Swipe = { x: event.nativeEvent.changedTouches[0].clientX, y: event.nativeEvent.changedTouches[0].clientY, time: Date.now(), }; const difference = getSwipeDifference(start, end); const yAxisDifference = getSwipeDifference(start, end, 'y'); let { isScrolling, isSwiping } = this.state; if (!isScrolling && !isSwiping) { if (difference > yAxisDifference) { isSwiping = true; } else { isScrolling = true; } } this.setState({ isScrolling, isSwiping }); if (isSwiping) { const nextSelected = this.getTabToSelect(currentSelectedFromProps, start, end); this.animateLine( this.swipedOverHalfOfContainer(difference) ? nextSelected : currentSelectedFromProps, ); } }; handleTouchEnd: React.TouchEventHandler = (event) => { const { start, isSwiping } = this.state; if (start == null) { return; } const { selected } = this.props; const end: Swipe = { x: event.nativeEvent.changedTouches[0].clientX, y: event.nativeEvent.changedTouches[0].clientY, time: Date.now(), }; const difference = getSwipeDifference(start, end); let nextSelected = selected; if (isSwiping) { if (swipeShouldChangeTab(start, end) || this.swipedOverHalfOfContainer(difference)) { nextSelected = this.getTabToSelect(nextSelected, start, end); } if (nextSelected !== selected) { this.switchTab(nextSelected); } else { this.animateLine(nextSelected); } } this.setState({ isSwiping: false, isScrolling: false }); }; render() { const { tabs, changeTabOnSwipe, name, selected, className, headerWidth } = this.props; const { translateLineX, fullWidthTabs } = this.state; const selectedTab = tabs[selected]; return ( {(direction) => { const isRTL = direction === Direction.RTL; return (
{tabs.map(({ title, disabled }, index) => { return ( { this.tabRefs[index] = node; }} id={`${name}-tab-${index}`} panelId={`${name}-panel-${index}`} selected={selected === index} disabled={disabled} focusTab={() => { if (this.containerReference.current?.contains(document.activeElement)) { this.tabRefs[index]?.focus(); } }} onClick={disabled ? undefined : this.handleTabClick(index)} onKeyDown={this.onKeyDown(index)} {...(fullWidthTabs ? { style: { width: `${(1 / tabs.length) * 100}%` } } : {})} > {title} ); })} {translateLineX ? (
  • ) : null}
    {selectedTab && !selectedTab.disabled ? ( {selectedTab.content} ) : null}
  • ); }}
    ); } }