fix adaptive tabs and footer layout (#1302)
* fix tab list resize issue * footer layout adjustments for md screens * fix ts
@@ -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);
|
||||
@@ -1,15 +1,15 @@
|
||||
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');
|
||||
|
||||
@@ -17,21 +17,14 @@ const TabCounter = ({ count, parentClassName }: Props) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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 ]);
|
||||
@@ -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>;
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import _debounce from 'lodash/debounce';
|
||||
import React from 'react';
|
||||
|
||||
import type { MenuButton, RoutedTab } from './types';
|
||||
@@ -28,7 +27,7 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab | MenuButton>, dis
|
||||
|
||||
if (result.visibleNum < index) {
|
||||
// means that we haven't increased visibleNum on the previous iteration, so there is no space left
|
||||
// we skip now till the rest of the loop
|
||||
// we skip now till the end of the loop
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -62,22 +61,6 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab | MenuButton>, dis
|
||||
}
|
||||
}, [ calculateCut, disabled, tabsRefs ]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tabsRefs.length === 0 || disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeHandler = _debounce(() => {
|
||||
setTabsCut(calculateCut());
|
||||
}, 100);
|
||||
const resizeObserver = new ResizeObserver(resizeHandler);
|
||||
|
||||
resizeObserver.observe(document.body);
|
||||
return function cleanup() {
|
||||
resizeObserver.unobserve(document.body);
|
||||
};
|
||||
}, [ calculateCut, disabled, tabsRefs.length ]);
|
||||
|
||||
return React.useMemo(() => {
|
||||
return {
|
||||
tabsCut,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
activeTabIndex: number;
|
||||
tabsRefs: Array<React.RefObject<HTMLButtonElement>>;
|
||||
listRef: React.RefObject<HTMLDivElement>;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export default function useScrollToActiveTab({ activeTabIndex, tabsRefs, listRef, isMobile }: Props) {
|
||||
React.useEffect(() => {
|
||||
if (activeTabIndex < tabsRefs.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 changed
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ activeTabIndex, isMobile ]);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
@@ -107,7 +107,8 @@ const Footer = () => {
|
||||
return (
|
||||
<Flex
|
||||
direction={{ base: 'column', lg: 'row' }}
|
||||
p={{ base: 4, lg: 9 }}
|
||||
px={{ base: 4, lg: 12 }}
|
||||
py={{ base: 4, lg: 9 }}
|
||||
borderTop="1px solid"
|
||||
borderColor="divider"
|
||||
as="footer"
|
||||
@@ -149,8 +150,20 @@ const Footer = () => {
|
||||
{ config.UI.footer.links && <Text fontWeight={ 500 } mb={ 3 }>Blockscout</Text> }
|
||||
<Grid
|
||||
gap={ 1 }
|
||||
gridTemplateColumns={ config.UI.footer.links ? '160px' : { base: 'repeat(auto-fill, 160px)', lg: 'repeat(4, 160px)' } }
|
||||
gridTemplateRows={{ base: 'auto', lg: config.UI.footer.links ? 'auto' : 'repeat(2, auto)' }}
|
||||
gridTemplateColumns={
|
||||
config.UI.footer.links ?
|
||||
'160px' :
|
||||
{
|
||||
base: 'repeat(auto-fill, 160px)',
|
||||
lg: 'repeat(2, 160px)',
|
||||
xl: 'repeat(4, 160px)',
|
||||
}
|
||||
}
|
||||
gridTemplateRows={{
|
||||
base: 'auto',
|
||||
lg: config.UI.footer.links ? 'auto' : 'repeat(4, auto)',
|
||||
xl: config.UI.footer.links ? 'auto' : 'repeat(2, auto)',
|
||||
}}
|
||||
gridAutoFlow={{ base: 'row', lg: config.UI.footer.links ? 'row' : 'column' }}
|
||||
mt={{ base: 0, lg: config.UI.footer.links ? 0 : '100px' }}
|
||||
>
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 27 KiB |