Commit 0b3fc463 authored by tom's avatar tom

basic implementation of responsive tabs

parent ee99031c
...@@ -4,10 +4,19 @@ import { ...@@ -4,10 +4,19 @@ import {
TabList, TabList,
TabPanel, TabPanel,
TabPanels, TabPanels,
Button,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import type { StyleProps } from '@chakra-ui/styled-system';
import _debounce from 'lodash/debounce';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { middot } from 'lib/html-entities';
import { link } from 'lib/link/link'; import { link } from 'lib/link/link';
import type { RouteName } from 'lib/link/routes'; import type { RouteName } from 'lib/link/routes';
...@@ -15,36 +24,166 @@ export interface RoutedTab { ...@@ -15,36 +24,166 @@ export interface RoutedTab {
// for simplicity we use routeName as an id // for simplicity we use routeName as an id
// if we migrate to non-Next.js router that should be revised // if we migrate to non-Next.js router that should be revised
// id: string; // id: string;
routeName: RouteName; routeName: RouteName | null;
title: string; title: string;
component: React.ReactNode; component: React.ReactNode;
} }
const menuButton: RoutedTab = {
routeName: null,
title: `${ middot }${ middot }${ middot }`,
component: null,
};
const hiddenItemStyles: StyleProps = {
position: 'absolute',
top: '-9999px',
left: '-9999px',
visibility: 'hidden',
};
interface Props { interface Props {
tabs: Array<RoutedTab>; tabs: Array<RoutedTab>;
defaultActiveTab: RoutedTab['routeName']; defaultActiveTab: RoutedTab['routeName'];
} }
const RoutedTabs = ({ tabs, defaultActiveTab }: Props) => { const RoutedTabs = ({ tabs, defaultActiveTab }: Props) => {
const [ , setActiveTab ] = React.useState<RoutedTab['routeName']>(defaultActiveTab); const defaultIndex = tabs.findIndex(({ routeName }) => routeName === defaultActiveTab);
const [ tabsNum, setTabsNum ] = React.useState(tabs.length);
const [ activeTab, setActiveTab ] = React.useState<number>(defaultIndex);
const { isOpen: isMenuOpen, onToggle: onMenuToggle } = useDisclosure();
const [ tabsRefs, setTabsRefs ] = React.useState<Array<React.RefObject<HTMLButtonElement>>>([]);
const menuRef = React.useRef<HTMLDivElement>(null);
const router = useRouter(); const router = useRouter();
const displayedTabs = (() => {
return [ ...tabs, menuButton ];
})();
React.useEffect(() => {
setTabsRefs(displayedTabs.map((_, index) => tabsRefs[index] || React.createRef()));
// imitate componentDidMount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const calculateCut = React.useCallback(() => {
const listWidth = menuRef.current?.getBoundingClientRect().width;
const tabWidths = tabsRefs.map((tab) => tab.current?.getBoundingClientRect().width);
const menuWidth = tabWidths.at(-1);
if (!listWidth || !menuWidth) {
return tabs.length;
}
const { visibleTabNum } = tabWidths.slice(0, -1).reduce((result, item, index) => {
if (!item) {
return result;
}
if (result.accWidth + item <= listWidth - menuWidth) {
return { visibleTabNum: result.visibleTabNum + 1, accWidth: result.accWidth + item };
}
if (result.accWidth + item <= listWidth && index === tabWidths.length - 2) {
return { visibleTabNum: result.visibleTabNum + 1, accWidth: result.accWidth + item };
}
return result;
}, { visibleTabNum: 0, accWidth: 0 });
return visibleTabNum;
}, [ tabs.length, tabsRefs ]);
React.useEffect(() => {
if (tabsRefs.length > 0) {
setTabsNum(calculateCut());
}
}, [ calculateCut, tabsRefs ]);
React.useEffect(() => {
const resizeHandler = _debounce(() => {
setTabsNum(calculateCut());
}, 100);
const resizeObserver = new ResizeObserver(resizeHandler);
resizeObserver.observe(document.body);
return function cleanup() {
resizeObserver.unobserve(document.body);
};
}, [ calculateCut ]);
const handleTabChange = React.useCallback((index: number) => { const handleTabChange = React.useCallback((index: number) => {
const nextTab = tabs[index]; const nextTab = displayedTabs[index];
setActiveTab(nextTab.routeName);
const newUrl = link(nextTab.routeName, router.query); if (nextTab.routeName) {
router.push(newUrl, undefined, { shallow: true }); const newUrl = link(nextTab.routeName, router.query);
}, [ tabs, router ]); router.push(newUrl, undefined, { shallow: true });
}
setActiveTab(index);
}, [ displayedTabs, router ]);
const defaultIndex = tabs.map(({ routeName }) => routeName).indexOf(defaultActiveTab); const handleItemInMenuClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
const tabIndex = (event.target as HTMLButtonElement).getAttribute('data-index');
if (tabIndex) {
handleTabChange(tabsNum + Number(tabIndex));
}
}, [ handleTabChange, tabsNum ]);
return ( return (
<Tabs variant="soft-rounded" colorScheme="blue" isLazy onChange={ handleTabChange } defaultIndex={ defaultIndex }> <Tabs variant="soft-rounded" colorScheme="blue" isLazy onChange={ handleTabChange } index={ activeTab }>
<TabList marginBottom={{ base: 6, lg: 8 }} flexWrap="wrap"> <TabList marginBottom={{ base: 6, lg: 8 }} flexWrap="nowrap" whiteSpace="nowrap" ref={ menuRef }>
{ tabs.map((tab) => <Tab key={ tab.routeName }>{ tab.title }</Tab>) } { displayedTabs.map((tab, index) => {
if (!tab.routeName) {
return (
<Popover isLazy placement="bottom-end" key="more">
<PopoverTrigger>
<Button
variant="subtle"
onClick={ onMenuToggle }
isActive={ isMenuOpen || activeTab >= tabsNum }
ref={ tabsRefs[index] }
{ ...(tabsNum < tabs.length ? {} : hiddenItemStyles) }
>
{ menuButton.title }
</Button>
</PopoverTrigger>
<PopoverContent w="auto">
<PopoverBody display="flex" flexDir="column">
{ displayedTabs.slice(tabsNum, -1).map((tab, index) => (
<Button
key={ tab.routeName }
variant="subtle"
onClick={ handleItemInMenuClick }
isActive={ displayedTabs[activeTab].routeName === tab.routeName }
justifyContent="left"
data-index={ index }
>
{ tab.title }
</Button>
)) }
</PopoverBody>
</PopoverContent>
</Popover>
);
}
return (
<Tab
key={ tab.routeName }
ref={ tabsRefs[index] }
{ ...(index < tabsNum ? {} : hiddenItemStyles) }
>
{ tab.title }
</Tab>
);
}) }
</TabList> </TabList>
<TabPanels> <TabPanels>
{ tabs.map((tab) => <TabPanel padding={ 0 } key={ tab.routeName }>{ tab.component }</TabPanel>) } { displayedTabs.map((tab) => <TabPanel padding={ 0 } key={ tab.routeName }>{ tab.component }</TabPanel>) }
</TabPanels> </TabPanels>
</Tabs> </Tabs>
); );
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment