Skip to content

Commit

Permalink
fix adaptive tabs and footer layout (#1302)
Browse files Browse the repository at this point in the history
* fix tab list resize issue

* footer layout adjustments for md screens

* fix ts
  • Loading branch information
tom2drum authored Oct 31, 2023
1 parent 5f937f7 commit f1374b4
Show file tree
Hide file tree
Showing 19 changed files with 221 additions and 155 deletions.
130 changes: 130 additions & 0 deletions ui/shared/Tabs/AdaptiveTabsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { StyleProps, ThemingProps } from '@chakra-ui/react';
import { Box, Tab, TabList, useColorModeValue } from '@chakra-ui/react';
import React from 'react';

import { useScrollDirection } from 'lib/contexts/scrollDirection';
import useIsMobile from 'lib/hooks/useIsMobile';
import useIsSticky from 'lib/hooks/useIsSticky';

import TabCounter from './TabCounter';
import TabsMenu from './TabsMenu';
import type { Props as TabsProps } from './TabsWithScroll';
import useAdaptiveTabs from './useAdaptiveTabs';
import useScrollToActiveTab from './useScrollToActiveTab';
import { menuButton } from './utils';

const hiddenItemStyles: StyleProps = {
position: 'absolute',
top: '-9999px',
left: '-9999px',
visibility: 'hidden',
};

interface Props extends TabsProps {
activeTabIndex: number;
onItemClick: (index: number) => void;
themeProps: ThemingProps<'Tabs'>;
}

const AdaptiveTabsList = (props: Props) => {

const scrollDirection = useScrollDirection();
const listBgColor = useColorModeValue('white', 'black');
const isMobile = useIsMobile();

const tabsList = React.useMemo(() => {
return [ ...props.tabs, menuButton ];
}, [ props.tabs ]);

const { tabsCut, tabsRefs, listRef, rightSlotRef } = useAdaptiveTabs(tabsList, isMobile);
const isSticky = useIsSticky(listRef, 5, props.stickyEnabled);
useScrollToActiveTab({ activeTabIndex: props.activeTabIndex, listRef, tabsRefs, isMobile });

return (
<TabList
marginBottom={{ base: 6, lg: 8 }}
mx={{ base: '-16px', lg: 'unset' }}
px={{ base: '16px', lg: 'unset' }}
flexWrap="nowrap"
whiteSpace="nowrap"
ref={ listRef }
overflowX={{ base: 'auto', lg: 'initial' }}
overscrollBehaviorX="contain"
css={{
'scroll-snap-type': 'x mandatory',
// hide scrollbar
'&::-webkit-scrollbar': { /* Chromiums */
display: 'none',
},
'-ms-overflow-style': 'none', /* IE and Edge */
'scrollbar-width': 'none', /* Firefox */
}}
bgColor={ listBgColor }
transitionProperty="top,box-shadow,background-color,color"
transitionDuration="normal"
transitionTimingFunction="ease"
{
...(props.stickyEnabled ? {
position: 'sticky',
boxShadow: { base: isSticky ? 'md' : 'none', lg: 'none' },
top: { base: scrollDirection === 'down' ? `0px` : `106px`, lg: 0 },
zIndex: { base: 'sticky2', lg: 'docked' },
} : { })
}
{
...(typeof props.tabListProps === 'function' ?
props.tabListProps({ isSticky, activeTabIndex: props.activeTabIndex }) :
props.tabListProps)
}
>
{ tabsList.map((tab, index) => {
if (!tab.id) {
return (
<TabsMenu
key="menu"
tabs={ props.tabs }
activeTab={ props.tabs[props.activeTabIndex] }
tabsCut={ tabsCut }
isActive={ props.activeTabIndex >= tabsCut }
styles={ tabsCut < props.tabs.length ?
// initially our cut is 0 and we don't want to show the menu button too
// but we want to keep it in the tabs row so it won't collapse
// that's why we only change opacity but not the position itself
{ opacity: tabsCut === 0 ? 0 : 1 } :
hiddenItemStyles
}
onItemClick={ props.onItemClick }
buttonRef={ tabsRefs[index] }
size={ props.themeProps.size || 'md' }
/>
);
}

return (
<Tab
key={ tab.id }
ref={ tabsRefs[index] }
{ ...(index < tabsCut ? {} : hiddenItemStyles) }
scrollSnapAlign="start"
flexShrink={ 0 }
sx={{
'&:hover span': {
color: 'inherit',
},
}}
>
{ typeof tab.title === 'function' ? tab.title() : tab.title }
<TabCounter count={ tab.count }/>
</Tab>
);
}) }
{
props.rightSlot && tabsCut > 0 ?
<Box ref={ rightSlotRef } ml="auto" { ...props.rightSlotProps }> { props.rightSlot } </Box> :
null
}
</TabList>
);
};

export default React.memo(AdaptiveTabsList);
21 changes: 7 additions & 14 deletions ui/shared/Tabs/TabCounter.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,30 @@
import type { SystemStyleObject } from '@chakra-ui/react';
import { Text, useColorModeValue } from '@chakra-ui/react';
import { chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';

import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps';

const COUNTER_OVERLOAD = 50;

type Props = {
count?: number | null;
parentClassName: string;
}

const TabCounter = ({ count, parentClassName }: Props) => {
const TabCounter = ({ count }: Props) => {

const zeroCountColor = useColorModeValue('blackAlpha.400', 'whiteAlpha.400');

if (count === undefined || count === null) {
return null;
}

const sx: SystemStyleObject = {
[`.${ parentClassName }:hover &`]: { color: 'inherit' },
};

return (
<Text
<chakra.span
color={ count > 0 ? 'text_secondary' : zeroCountColor }
ml={ 1 }
sx={ sx }
transitionProperty="color"
transitionDuration="normal"
transitionTimingFunction="ease"
{ ...getDefaultTransitionProps() }
>
{ count > COUNTER_OVERLOAD ? `${ COUNTER_OVERLOAD }+` : count }
</Text>
</chakra.span>
);
};

Expand Down
10 changes: 6 additions & 4 deletions ui/shared/Tabs/TabsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ import type { MenuButton, TabItem } from './types';
import TabCounter from './TabCounter';
import { menuButton } from './utils';

const BUTTON_CLASSNAME = 'button-item';

interface Props {
tabs: Array<TabItem | MenuButton>;
activeTab?: TabItem;
Expand Down Expand Up @@ -62,10 +60,14 @@ const TabsMenu = ({ tabs, tabsCut, isActive, styles, onItemClick, buttonRef, act
isActive={ activeTab ? activeTab.id === tab.id : false }
justifyContent="left"
data-index={ index }
className={ BUTTON_CLASSNAME }
sx={{
'&:hover span': {
color: 'inherit',
},
}}
>
{ typeof tab.title === 'function' ? tab.title() : tab.title }
<TabCounter count={ tab.count } parentClassName={ BUTTON_CLASSNAME }/>
<TabCounter count={ tab.count }/>
</Button>
)) }
</PopoverBody>
Expand Down
146 changes: 30 additions & 116 deletions ui/shared/Tabs/TabsWithScroll.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,20 @@
import type { LazyMode } from '@chakra-ui/lazy-utils';
import type { ChakraProps, ThemingProps } from '@chakra-ui/react';
import {
Tab,
Tabs,
TabList,
TabPanel,
TabPanels,
Box,
useColorModeValue,
chakra,
} from '@chakra-ui/react';
import type { StyleProps } from '@chakra-ui/styled-system';
import _debounce from 'lodash/debounce';
import React, { useEffect, useRef, useState } from 'react';

import type { TabItem } from './types';

import { useScrollDirection } from 'lib/contexts/scrollDirection';
import useIsMobile from 'lib/hooks/useIsMobile';
import useIsSticky from 'lib/hooks/useIsSticky';

import TabCounter from './TabCounter';
import TabsMenu from './TabsMenu';
import useAdaptiveTabs from './useAdaptiveTabs';
import AdaptiveTabsList from './AdaptiveTabsList';
import { menuButton } from './utils';

const TAB_CLASSNAME = 'tab-item';

const hiddenItemStyles: StyleProps = {
position: 'absolute',
top: '-9999px',
left: '-9999px',
visibility: 'hidden',
};

interface Props extends ThemingProps<'Tabs'> {
export interface Props extends ThemingProps<'Tabs'> {
tabs: Array<TabItem>;
lazyBehavior?: LazyMode;
tabListProps?: ChakraProps | (({ isSticky, activeTabIndex }: { isSticky: boolean; activeTabIndex: number }) => ChakraProps);
Expand All @@ -57,19 +38,15 @@ const TabsWithScroll = ({
className,
...themeProps
}: Props) => {
const scrollDirection = useScrollDirection();
const [ activeTabIndex, setActiveTabIndex ] = useState<number>(defaultTabIndex || 0);
const isMobile = useIsMobile();
const [ screenWidth, setScreenWidth ] = React.useState(0);

const tabsRef = useRef<HTMLDivElement>(null);

const tabsList = React.useMemo(() => {
return [ ...tabs, menuButton ];
}, [ tabs ]);

const { tabsCut, tabsRefs, listRef, rightSlotRef } = useAdaptiveTabs(tabsList, isMobile);
const isSticky = useIsSticky(listRef, 5, stickyEnabled);
const listBgColor = useColorModeValue('white', 'black');

const handleTabChange = React.useCallback((index: number) => {
onTabChange ? onTabChange(index) : setActiveTabIndex(index);
}, [ onTabChange ]);
Expand All @@ -80,23 +57,17 @@ const TabsWithScroll = ({
}
}, [ defaultTabIndex ]);

useEffect(() => {
if (activeTabIndex < tabs.length && isMobile) {
window.setTimeout(() => {
const activeTabRef = tabsRefs[activeTabIndex];
if (activeTabRef.current && listRef.current) {
const activeTabRect = activeTabRef.current.getBoundingClientRect();
listRef.current.scrollTo({
left: activeTabRect.left + listRef.current.scrollLeft - 16,
behavior: 'smooth',
});
}
// have to wait until DOM is updated and all styles to tabs is applied
}, 300);
}
// run only when tab index or device type is updated
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ activeTabIndex, isMobile ]);
React.useEffect(() => {
const resizeHandler = _debounce(() => {
setScreenWidth(window.innerWidth);
}, 100);
const resizeObserver = new ResizeObserver(resizeHandler);

resizeObserver.observe(document.body);
return function cleanup() {
resizeObserver.unobserve(document.body);
};
}, []);

if (tabs.length === 1) {
return <div>{ tabs[0].component }</div>;
Expand All @@ -115,77 +86,20 @@ const TabsWithScroll = ({
ref={ tabsRef }
lazyBehavior={ lazyBehavior }
>
<TabList
marginBottom={{ base: 6, lg: 8 }}
mx={{ base: '-16px', lg: 'unset' }}
px={{ base: '16px', lg: 'unset' }}
flexWrap="nowrap"
whiteSpace="nowrap"
ref={ listRef }
overflowX={{ base: 'auto', lg: 'initial' }}
overscrollBehaviorX="contain"
css={{
'scroll-snap-type': 'x mandatory',
// hide scrollbar
'&::-webkit-scrollbar': { /* Chromiums */
display: 'none',
},
'-ms-overflow-style': 'none', /* IE and Edge */
'scrollbar-width': 'none', /* Firefox */
}}
bgColor={ listBgColor }
transitionProperty="top,box-shadow,background-color,color"
transitionDuration="normal"
transitionTimingFunction="ease"
{
...(stickyEnabled ? {
position: 'sticky',
boxShadow: { base: isSticky ? 'md' : 'none', lg: 'none' },
top: { base: scrollDirection === 'down' ? `0px` : `106px`, lg: 0 },
zIndex: { base: 'sticky2', lg: 'docked' },
} : { })
}
{ ...(typeof tabListProps === 'function' ? tabListProps({ isSticky, activeTabIndex }) : tabListProps) }
>
{ tabsList.map((tab, index) => {
if (!tab.id) {
return (
<TabsMenu
key="menu"
tabs={ tabs }
activeTab={ tabs[activeTabIndex] }
tabsCut={ tabsCut }
isActive={ activeTabIndex >= tabsCut }
styles={ tabsCut < tabs.length ?
// initially our cut is 0 and we don't want to show the menu button too
// but we want to keep it in the tabs row so it won't collapse
// that's why we only change opacity but not the position itself
{ opacity: tabsCut === 0 ? 0 : 1 } :
hiddenItemStyles
}
onItemClick={ handleTabChange }
buttonRef={ tabsRefs[index] }
size={ themeProps.size || 'md' }
/>
);
}

return (
<Tab
key={ tab.id }
ref={ tabsRefs[index] }
{ ...(index < tabsCut ? {} : hiddenItemStyles) }
scrollSnapAlign="start"
flexShrink={ 0 }
className={ TAB_CLASSNAME }
>
{ typeof tab.title === 'function' ? tab.title() : tab.title }
<TabCounter count={ tab.count } parentClassName={ TAB_CLASSNAME }/>
</Tab>
);
}) }
{ rightSlot && tabsCut > 0 ? <Box ref={ rightSlotRef } ml="auto" { ...rightSlotProps }> { rightSlot } </Box> : null }
</TabList>
<AdaptiveTabsList
// the easiest and most readable way to achieve correct tab's cut recalculation when screen is resized
// is to do full re-render of the tabs list
// so we use screenWidth as a key for the TabsList component
key={ screenWidth }
tabs={ tabs }
tabListProps={ tabListProps }
rightSlot={ rightSlot }
rightSlotProps={ rightSlotProps }
stickyEnabled={ stickyEnabled }
activeTabIndex={ activeTabIndex }
onItemClick={ handleTabChange }
themeProps={ themeProps }
/>
<TabPanels>
{ tabsList.map((tab) => <TabPanel padding={ 0 } key={ tab.id }>{ tab.component }</TabPanel>) }
</TabPanels>
Expand Down
Loading

0 comments on commit f1374b4

Please sign in to comment.