Commit f129e6d4 authored by tom's avatar tom

fix adaptive tabs

parent cb7398ef
import { Tabs as ChakraTabs } from '@chakra-ui/react';
import { Tabs as ChakraTabs, chakra } from '@chakra-ui/react';
import * as React from 'react';
export interface TabsProps extends ChakraTabs.RootProps {}
export const TabsRoot = React.forwardRef<HTMLDivElement, TabsProps>(
function TabsRoot(props, ref) {
return <ChakraTabs.Root ref={ ref } { ...props }/>;
const { lazyMount = true, unmountOnExit = true, ...rest } = props;
return <ChakraTabs.Root ref={ ref } { ...rest } lazyMount={ lazyMount } unmountOnExit={ unmountOnExit }/>;
},
);
export const TabsList = ChakraTabs.List;
export const TabsTrigger = ChakraTabs.Trigger;
export const TabsTrigger = React.forwardRef<HTMLButtonElement, ChakraTabs.TriggerProps>(
function TabsTrigger(props, ref) {
return <ChakraTabs.Trigger ref={ ref } className="group" { ...props }/>;
},
);
export const TabsContent = ChakraTabs.Content;
export interface TabsCounterProps {
count?: number | null;
}
export const TabsCounter = ({ count }: TabsCounterProps) => {
const COUNTER_OVERLOAD = 50;
if (count === undefined || count === null) {
return null;
}
return (
<chakra.span
color={ count > 0 ? 'text.secondary' : { _light: 'blackAlpha.400', _dark: 'whiteAlpha.400' } }
ml={ 1 }
_groupHover={{
color: 'inherit',
}}
>
{ count > COUNTER_OVERLOAD ? `${ COUNTER_OVERLOAD }+` : count }
</chakra.span>
);
};
import React from 'react';
import type { TabsProps } from 'toolkit/chakra/tabs';
import { TabsContent, TabsRoot } from 'toolkit/chakra/tabs';
import useViewportSize from 'toolkit/hooks/useViewportSize';
import AdaptiveTabsList, { type BaseProps as AdaptiveTabsListProps } from './AdaptiveTabsList';
import { getTabValue } from './utils';
export interface Props extends TabsProps, AdaptiveTabsListProps { }
const AdaptiveTabs = (props: Props) => {
const {
tabs,
onValueChange,
defaultValue,
isLoading,
listProps,
rightSlot,
rightSlotProps,
leftSlot,
leftSlotProps,
stickyEnabled,
size,
variant,
...rest
} = props;
const [ activeTab, setActiveTab ] = React.useState<string>(defaultValue || getTabValue(tabs[0]));
const handleTabChange = React.useCallback(({ value }: { value: string }) => {
if (isLoading) {
return;
}
onValueChange ? onValueChange({ value }) : setActiveTab(value);
}, [ isLoading, onValueChange ]);
const viewportSize = useViewportSize();
React.useEffect(() => {
if (defaultValue) {
setActiveTab(defaultValue);
}
}, [ defaultValue ]);
if (tabs.length === 1) {
return <div>{ tabs[0].component }</div>;
}
return (
<TabsRoot
position="relative"
value={ activeTab }
onValueChange={ handleTabChange }
size={ size }
variant={ variant }
{ ...rest }
>
<AdaptiveTabsList
// the easiest and most readable way to achieve correct tab's cut recalculation when
// - screen is resized or
// - tabs list is changed when API data is loaded
// is to do full re-render of the tabs list
// so we use screenWidth + tabIds as a key for the TabsList component
key={ isLoading + '_' + viewportSize.width + '_' + tabs.map((tab) => tab.id).join(':') }
tabs={ tabs }
listProps={ listProps }
leftSlot={ leftSlot }
leftSlotProps={ leftSlotProps }
rightSlot={ rightSlot }
rightSlotProps={ rightSlotProps }
stickyEnabled={ stickyEnabled }
activeTab={ activeTab }
isLoading={ isLoading }
/>
{ tabs.map((tab) => {
const value = getTabValue(tab);
return (
<TabsContent padding={ 0 } key={ value } value={ value }>
{ tab.component }
</TabsContent>
);
}) }
</TabsRoot>
);
};
export default React.memo(AdaptiveTabs);
import type { HTMLChakraProps } from '@chakra-ui/react';
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { TabItemRegular } from './types';
import { useScrollDirection } from 'lib/contexts/scrollDirection';
import useIsMobile from 'lib/hooks/useIsMobile';
import useIsSticky from 'lib/hooks/useIsSticky';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { TabsCounter, TabsList, TabsTrigger } from 'toolkit/chakra/tabs';
import AdaptiveTabsMenu from './AdaptiveTabsMenu';
import useAdaptiveTabs from './useAdaptiveTabs';
import useScrollToActiveTab from './useScrollToActiveTab';
import { menuButton, getTabValue } from './utils';
export interface BaseProps {
tabs: Array<TabItemRegular>;
listProps?: HTMLChakraProps<'div'> | (({ isSticky, activeTab }: { isSticky: boolean; activeTab: string }) => HTMLChakraProps<'div'>);
rightSlot?: React.ReactNode;
rightSlotProps?: HTMLChakraProps<'div'>;
leftSlot?: React.ReactNode;
leftSlotProps?: HTMLChakraProps<'div'>;
stickyEnabled?: boolean;
isLoading?: boolean;
}
interface Props extends BaseProps {
activeTab: string;
}
const HIDDEN_ITEM_STYLES: HTMLChakraProps<'button'> = {
position: 'absolute',
top: '-9999px',
left: '-9999px',
visibility: 'hidden',
};
const AdaptiveTabsList = (props: Props) => {
const {
tabs,
activeTab,
listProps,
rightSlot,
rightSlotProps,
leftSlot,
leftSlotProps,
stickyEnabled,
isLoading,
} = props;
const scrollDirection = useScrollDirection();
const isMobile = useIsMobile();
const tabsList = React.useMemo(() => {
return [ ...tabs, menuButton ];
}, [ tabs ]);
const { tabsCut, tabsRefs, listRef, rightSlotRef, leftSlotRef } = useAdaptiveTabs(tabsList, isMobile);
const isSticky = useIsSticky(listRef, 5, stickyEnabled);
const activeTabIndex = tabsList.findIndex((tab) => getTabValue(tab) === activeTab) ?? 0;
useScrollToActiveTab({ activeTabIndex, listRef, tabsRefs, isMobile, isLoading });
return (
<TabsList
ref={ listRef }
flexWrap="nowrap"
alignItems="center"
whiteSpace="nowrap"
bgColor={{ _light: 'white', _dark: 'black' }}
// initially our cut is 0 and we don't want to show the list
// but we want to keep all items in the tabs row so it won't collapse
// that's why we only change opacity but not the position itself
opacity={ tabsCut ? 1 : 0 }
marginBottom={ 6 }
mx={{ base: '-12px', lg: 'unset' }}
px={{ base: '12px', lg: 'unset' }}
overflowX={{ base: 'auto', lg: 'initial' }}
overscrollBehaviorX="contain"
css={{
'scroll-snap-type': 'x mandatory',
'scroll-padding-inline': '12px', // mobile page padding
// hide scrollbar
'&::-webkit-scrollbar': { /* Chromiums */
display: 'none',
},
'-ms-overflow-style': 'none', /* IE and Edge */
'scrollbar-width': 'none', /* Firefox */
}}
{
...(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 listProps === 'function' ? listProps({ isSticky, activeTab }) : listProps)
}
>
{ leftSlot && <Box ref={ leftSlotRef } { ...leftSlotProps }> { leftSlot } </Box> }
{ tabsList.slice(0, isLoading ? 5 : Infinity).map((tab, index) => {
const value = getTabValue(tab);
const ref = tabsRefs[index];
if (tab.id === 'menu') {
if (isLoading) {
return null;
}
return (
<AdaptiveTabsMenu
key="menu"
ref={ ref }
tabs={ tabs }
tabsCut={ tabsCut }
isActive={ activeTabIndex > 0 && activeTabIndex >= tabsCut }
{ ...(tabsCut >= tabs.length ? HIDDEN_ITEM_STYLES : {}) }
/>
);
}
return (
<TabsTrigger
key={ value }
value={ value }
ref={ ref }
scrollSnapAlign="start"
flexShrink={ 0 }
{ ...(!tabsCut || index < tabsCut ? {} : HIDDEN_ITEM_STYLES as never) }
>
<Skeleton loading={ isLoading }>
{ typeof tab.title === 'function' ? tab.title() : tab.title }
<TabsCounter count={ tab.count }/>
</Skeleton>
</TabsTrigger>
);
}) }
{
rightSlot ?
<Box ref={ rightSlotRef } ml="auto" { ...rightSlotProps }> { rightSlot } </Box> :
null
}
</TabsList>
);
};
export default React.memo(AdaptiveTabsList);
import React from 'react';
import type { TabItem } from './types';
import type { ButtonProps } from 'toolkit/chakra/button';
import { Button } from 'toolkit/chakra/button';
import { PopoverBody, PopoverContent, PopoverRoot, PopoverTrigger } from 'toolkit/chakra/popover';
import { TabsCounter, TabsTrigger } from 'toolkit/chakra/tabs';
import { getTabValue, menuButton } from './utils';
interface Props extends ButtonProps {
tabs: Array<TabItem>;
tabsCut: number;
isActive: boolean;
}
const AdaptiveTabsMenu = ({ tabs, tabsCut, isActive, ...props }: Props, ref: React.Ref<HTMLButtonElement>) => {
return (
<PopoverRoot positioning={{ placement: 'bottom-end' }}>
<PopoverTrigger>
<Button
variant="plain"
color="tabs.solid.fg"
_hover={{
color: 'link.primary.hover',
}}
_expanded={{
color: 'tabs.solid.fg.selected',
bg: 'tabs.solid.bg.selected',
}}
ref={ ref }
expanded={ isActive }
{ ...props }
>
{ menuButton.title }
</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverBody display="flex" flexDir="column">
{ tabs.slice(tabsCut).map((tab) => {
const value = getTabValue(tab);
return (
<TabsTrigger
key={ value }
value={ value }
w="fit-content"
>
{ typeof tab.title === 'function' ? tab.title() : tab.title }
<TabsCounter count={ tab.count }/>
</TabsTrigger>
);
}) }
</PopoverBody>
</PopoverContent>
</PopoverRoot>
);
};
export default React.memo(React.forwardRef(AdaptiveTabsMenu));
import type React from 'react';
export interface TabItemRegular {
// NOTE, in case of array of ids, when switching tabs, the first id will be used
// switching between other ids should be handled in the underlying component
id: string | Array<string>;
title: string | (() => React.ReactNode);
count?: number | null;
component: React.ReactNode;
subTabs?: Array<string>;
}
export interface TabItemMenu {
id: 'menu';
title: string;
count?: never;
component: null;
}
export type TabItem = TabItemRegular | TabItemMenu;
export type SubTabItem = Omit<TabItem, 'subTabs'>;
import React from 'react';
import type { TabItem } from './types';
export default function useAdaptiveTabs(tabs: Array<TabItem>, disabled?: boolean) {
// to avoid flickering we set initial value to 0
// so there will be no displayed tabs initially
const [ tabsCut, setTabsCut ] = React.useState(disabled ? tabs.length : 0);
const [ tabsRefs, setTabsRefs ] = React.useState<Array<React.RefObject<HTMLButtonElement>>>([]);
const listRef = React.useRef<HTMLDivElement>(null);
const rightSlotRef = React.useRef<HTMLDivElement>(null);
const leftSlotRef = React.useRef<HTMLDivElement>(null);
const calculateCut = React.useCallback(() => {
const listWidth = listRef.current?.getBoundingClientRect().width;
const rightSlotWidth = rightSlotRef.current?.getBoundingClientRect().width || 0;
const leftSlotWidth = leftSlotRef.current?.getBoundingClientRect().width || 0;
const tabWidths = tabsRefs.map((tab) => tab.current?.getBoundingClientRect().width);
const menuWidth = tabWidths[tabWidths.length - 1];
if (!listWidth || !menuWidth) {
return tabs.length;
}
const { visibleNum } = tabWidths.slice(0, -1).reduce((result, item, index, array) => {
if (!item) {
return result;
}
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 end of the loop
return result;
}
if (index === array.length - 1) {
// last element
if (result.accWidth + item < listWidth - rightSlotWidth - leftSlotWidth) {
return { visibleNum: result.visibleNum + 1, accWidth: result.accWidth + item };
}
} else {
if (result.accWidth + item + menuWidth < listWidth - rightSlotWidth - leftSlotWidth) {
return { visibleNum: result.visibleNum + 1, accWidth: result.accWidth + item };
}
}
return result;
}, { visibleNum: 0, accWidth: 0 });
return visibleNum;
}, [ tabs.length, tabsRefs ]);
React.useEffect(() => {
setTabsRefs(tabs.map((_, index) => tabsRefs[index] || React.createRef()));
setTabsCut(disabled ? tabs.length : 0);
// update refs only when disabled prop changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ disabled ]);
React.useEffect(() => {
if (tabsRefs.length > 0 && !disabled) {
setTabsCut(calculateCut());
}
}, [ calculateCut, disabled, tabsRefs ]);
return React.useMemo(() => {
return {
tabsCut,
tabsRefs,
listRef,
rightSlotRef,
leftSlotRef,
};
}, [ tabsCut, tabsRefs ]);
}
import React from 'react';
interface Props {
activeTabIndex: number;
tabsRefs: Array<React.RefObject<HTMLButtonElement>>;
listRef: React.RefObject<HTMLDivElement>;
isMobile?: boolean;
isLoading?: boolean;
}
export default function useScrollToActiveTab({ activeTabIndex, tabsRefs, listRef, isMobile, isLoading }: Props) {
React.useEffect(() => {
if (isLoading) {
return;
}
if (activeTabIndex < tabsRefs.length && isMobile) {
window.setTimeout(() => {
const activeTabRef = tabsRefs[activeTabIndex];
if (activeTabRef.current && listRef.current) {
const containerWidth = listRef.current.getBoundingClientRect().width;
const activeTabWidth = activeTabRef.current.getBoundingClientRect().width;
const left = tabsRefs.slice(0, activeTabIndex)
.map((tab) => tab.current?.getBoundingClientRect())
.filter(Boolean)
.map((rect) => rect.width)
.reduce((result, item) => result + item, 0);
const isWithinFirstPage = containerWidth > left + activeTabWidth;
if (isWithinFirstPage) {
return;
}
listRef.current.scrollTo({
left,
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, isLoading ]);
}
import type { TabItem, TabItemMenu } from './types';
import { middot } from 'lib/html-entities';
export const menuButton: TabItemMenu = {
id: 'menu',
title: `${ middot }${ middot }${ middot }`,
component: null,
};
export const getTabValue = (tab: TabItem): string => tab.id.toString();
import { debounce } from 'es-toolkit';
import { useEffect, useState } from 'react';
export default function useViewportSize(debounceTime = 100) {
const [ viewportSize, setViewportSize ] = useState({ width: 0, height: 0 });
useEffect(() => {
setViewportSize({ width: window.innerWidth, height: window.innerHeight });
const resizeHandler = debounce(() => {
setViewportSize({ width: window.innerWidth, height: window.innerHeight });
}, debounceTime);
const resizeObserver = new ResizeObserver(resizeHandler);
resizeObserver.observe(document.body);
return function cleanup() {
resizeObserver.unobserve(document.body);
};
}, [ debounceTime ]);
return viewportSize;
}
......@@ -25,6 +25,7 @@ import PageTitle from 'ui/shared/Page/PageTitle';
import AlertsShowcase from 'ui/showcases/Alerts';
import BadgesShowcase from 'ui/showcases/Badges';
import ButtonShowcase from 'ui/showcases/Button';
import TabsShowcase from 'ui/showcases/Tabs';
const TEXT = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.';
......@@ -48,15 +49,17 @@ const ChakraShowcases = () => {
</Switch>
<TabsRoot defaultValue="alerts">
<TabsList>
<TabsList flexWrap="wrap">
<TabsTrigger value="alerts">Alerts</TabsTrigger>
<TabsTrigger value="badges">Badges</TabsTrigger>
<TabsTrigger value="buttons">Buttons</TabsTrigger>
<TabsTrigger value="tabs">Tabs</TabsTrigger>
<TabsTrigger value="unsorted">Unsorted</TabsTrigger>
</TabsList>
<AlertsShowcase/>
<BadgesShowcase/>
<ButtonShowcase/>
<TabsShowcase/>
<TabsContent value="unsorted">
<VStack align="flex-start" gap={ 6 }>
......@@ -190,28 +193,6 @@ const ChakraShowcases = () => {
</HStack>
</section>
<section>
<Heading textStyle="heading.md" mb={ 2 }>Tabs</Heading>
<HStack gap={ 4 }>
<TabsRoot defaultValue="tab1" variant="solid">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
<TabsContent value="tab2">Content 2</TabsContent>
</TabsRoot>
<TabsRoot defaultValue="tab1" variant="secondary" size="sm">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
<TabsContent value="tab2">Content 2</TabsContent>
</TabsRoot>
</HStack>
</section>
<section>
<Heading textStyle="heading.md" mb={ 2 }>Toasts</Heading>
<HStack gap={ 4 } whiteSpace="nowrap">
......
import type { StyleProps, ThemingProps } from '@chakra-ui/react';
import { Box, Tab, TabList, useColorModeValue } from '@chakra-ui/react';
import { Box } 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 Skeleton from 'ui/shared/chakra/Skeleton';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { TabsList, TabsTrigger } from 'toolkit/chakra/tabs';
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';
import { getTabValue, menuButton } from './utils';
const hiddenItemStyles: StyleProps = {
position: 'absolute',
......@@ -22,7 +23,7 @@ const hiddenItemStyles: StyleProps = {
};
interface Props extends TabsProps {
activeTabIndex: number;
activeTab: string;
onItemClick: (index: number) => void;
themeProps: ThemingProps<'Tabs'>;
isLoading?: boolean;
......@@ -31,19 +32,20 @@ interface Props extends TabsProps {
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, leftSlotRef } = useAdaptiveTabs(tabsList, isMobile);
// TODO @tom2drum remove isMobile || true
const { tabsCut, tabsRefs, listRef, rightSlotRef, leftSlotRef } = useAdaptiveTabs(tabsList, isMobile || true);
const isSticky = useIsSticky(listRef, 5, props.stickyEnabled);
useScrollToActiveTab({ activeTabIndex: props.activeTabIndex, listRef, tabsRefs, isMobile, isLoading: props.isLoading });
const activeTabIndex = tabsList.findIndex((tab) => getTabValue(tab) === props.activeTab) ?? 0;
useScrollToActiveTab({ activeTabIndex, listRef, tabsRefs, isMobile, isLoading: props.isLoading });
return (
<TabList
<TabsList
marginBottom={ 6 }
mx={{ base: '-12px', lg: 'unset' }}
px={{ base: '12px', lg: 'unset' }}
......@@ -63,7 +65,7 @@ const AdaptiveTabsList = (props: Props) => {
'-ms-overflow-style': 'none', /* IE and Edge */
'scrollbar-width': 'none', /* Firefox */
}}
bgColor={ listBgColor }
bgColor={{ _light: 'white', _dark: 'black' }}
transitionProperty="top,box-shadow,background-color,color"
transitionDuration="normal"
transitionTimingFunction="ease"
......@@ -77,13 +79,15 @@ const AdaptiveTabsList = (props: Props) => {
}
{
...(typeof props.tabListProps === 'function' ?
props.tabListProps({ isSticky, activeTabIndex: props.activeTabIndex }) :
props.tabListProps({ isSticky, activeTab: props.activeTab }) :
props.tabListProps)
}
>
{ props.leftSlot && <Box ref={ leftSlotRef } { ...props.leftSlotProps }> { props.leftSlot } </Box> }
{ tabsList.slice(0, props.isLoading ? 5 : Infinity).map((tab, index) => {
if (!tab.id) {
const value = getTabValue(tab);
if (tab.id === 'menu') {
if (props.isLoading) {
return null;
}
......@@ -92,9 +96,9 @@ const AdaptiveTabsList = (props: Props) => {
<TabsMenu
key="menu"
tabs={ props.tabs }
activeTab={ props.tabs[props.activeTabIndex] }
activeTab={ props.tabs[activeTabIndex] }
tabsCut={ tabsCut }
isActive={ props.activeTabIndex >= tabsCut }
isActive={ 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
......@@ -110,8 +114,9 @@ const AdaptiveTabsList = (props: Props) => {
}
return (
<Tab
key={ tab.id.toString() }
<TabsTrigger
key={ value }
value={ value }
ref={ tabsRefs[index] }
{ ...(index < tabsCut ? {} : hiddenItemStyles) }
scrollSnapAlign="start"
......@@ -121,13 +126,13 @@ const AdaptiveTabsList = (props: Props) => {
color: 'inherit',
},
}}
{ ...(index === props.activeTabIndex ? { 'data-selected': true } : {}) }
{ ...(value === props.activeTab ? { 'data-selected': true } : {}) }
>
<Skeleton isLoaded={ !props.isLoading }>
<Skeleton loading={ props.isLoading }>
{ typeof tab.title === 'function' ? tab.title() : tab.title }
<TabCounter count={ tab.count }/>
</Skeleton>
</Tab>
</TabsTrigger>
);
}) }
{
......@@ -135,7 +140,7 @@ const AdaptiveTabsList = (props: Props) => {
<Box ref={ rightSlotRef } ml="auto" { ...props.rightSlotProps }> { props.rightSlot } </Box> :
null
}
</TabList>
</TabsList>
);
};
......
import { chakra, useColorModeValue } from '@chakra-ui/react';
import { chakra } from '@chakra-ui/react';
import React from 'react';
import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps';
const COUNTER_OVERLOAD = 50;
type Props = {
count?: number | null;
};
// TODO @tom2drum remove this
const TabCounter = ({ count }: Props) => {
const zeroCountColor = useColorModeValue('blackAlpha.400', 'whiteAlpha.400');
if (count === undefined || count === null) {
return null;
}
return (
<chakra.span
color={ count > 0 ? 'text_secondary' : zeroCountColor }
color={ count > 0 ? 'text.secondary' : { _light: 'blackAlpha.400', _dark: 'whiteAlpha.400' } }
ml={ 1 }
{ ...getDefaultTransitionProps() }
>
{ count > COUNTER_OVERLOAD ? `${ COUNTER_OVERLOAD }+` : count }
</chakra.span>
......
import type {
ButtonProps } from '@chakra-ui/react';
import {
PopoverTrigger,
PopoverContent,
PopoverBody,
Button,
useDisclosure,
} from '@chakra-ui/react';
import type { StyleProps } from '@chakra-ui/styled-system';
import React from 'react';
import type { MenuButton, TabItem } from './types';
import Popover from 'ui/shared/chakra/Popover';
import type { ButtonProps } from 'toolkit/chakra/button';
import { Button } from 'toolkit/chakra/button';
import { PopoverBody, PopoverContent, PopoverRoot, PopoverTrigger } from 'toolkit/chakra/popover';
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import TabCounter from './TabCounter';
import { menuButton } from './utils';
......@@ -29,24 +23,24 @@ interface Props {
}
const TabsMenu = ({ tabs, tabsCut, isActive, styles, onItemClick, buttonRef, activeTab, size }: Props) => {
const { isOpen, onClose, onOpen } = useDisclosure();
// const { isOpen, onClose, onOpen } = useDisclosure();
const handleItemClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
onClose();
// onClose();
const tabIndex = event.currentTarget.getAttribute('data-index');
if (tabIndex) {
onItemClick(tabsCut + Number(tabIndex));
}
}, [ onClose, onItemClick, tabsCut ]);
}, [ onItemClick, tabsCut ]);
return (
<Popover isLazy placement="bottom-end" key="more" isOpen={ isOpen } onClose={ onClose } onOpen={ onOpen } closeDelay={ 0 }>
<PopoverRoot positioning={{ placement: 'bottom-end' }}>
<PopoverTrigger>
<Button
as="div"
role="button"
variant="ghost"
isActive={ isOpen || isActive }
// isActive={ isOpen || isActive }
ref={ buttonRef }
size={ size }
{ ...styles }
......@@ -61,10 +55,10 @@ const TabsMenu = ({ tabs, tabsCut, isActive, styles, onItemClick, buttonRef, act
key={ tab.id?.toString() }
variant="ghost"
onClick={ handleItemClick }
isActive={ activeTab ? activeTab.id === tab.id : false }
active={ activeTab ? activeTab.id === tab.id : false }
justifyContent="left"
data-index={ index }
sx={{
css={{
'&:hover span': {
color: 'inherit',
},
......@@ -76,7 +70,7 @@ const TabsMenu = ({ tabs, tabsCut, isActive, styles, onItemClick, buttonRef, act
)) }
</PopoverBody>
</PopoverContent>
</Popover>
</PopoverRoot>
);
};
......
import type { LazyMode } from '@chakra-ui/lazy-utils';
import type { ChakraProps, ThemingProps } from '@chakra-ui/react';
import {
Tabs,
TabPanel,
TabPanels,
chakra,
} from '@chakra-ui/react';
import { chakra } from '@chakra-ui/react';
import { debounce } from 'es-toolkit';
import React, { useEffect, useRef, useState } from 'react';
import type { TabItem } from './types';
import isBrowser from 'lib/isBrowser';
import type { TabsProps } from 'toolkit/chakra/tabs';
import { TabsContent, TabsRoot } from 'toolkit/chakra/tabs';
import AdaptiveTabsList from './AdaptiveTabsList';
import { menuButton } from './utils';
import { getTabValue, menuButton } from './utils';
export interface Props extends ThemingProps<'Tabs'> {
export interface Props extends TabsProps {
tabs: Array<TabItem>;
lazyBehavior?: LazyMode;
tabListProps?: ChakraProps | (({ isSticky, activeTabIndex }: { isSticky: boolean; activeTabIndex: number }) => ChakraProps);
......@@ -25,12 +20,13 @@ export interface Props extends ThemingProps<'Tabs'> {
leftSlot?: React.ReactNode;
leftSlotProps?: ChakraProps;
stickyEnabled?: boolean;
onTabChange?: (index: number) => void;
defaultTabIndex?: number;
onTabChange?: (value: string) => void;
defaultTab?: string;
isLoading?: boolean;
className?: string;
}
// TODO @tom2drum remove this component
const TabsWithScroll = ({
tabs,
lazyBehavior,
......@@ -41,12 +37,12 @@ const TabsWithScroll = ({
leftSlotProps,
stickyEnabled,
onTabChange,
defaultTabIndex,
defaultTab,
isLoading,
className,
...themeProps
}: Props) => {
const [ activeTabIndex, setActiveTabIndex ] = useState<number>(defaultTabIndex || 0);
const [ activeTab, setActiveTab ] = useState<string>(defaultTab || getTabValue(tabs[0]));
const [ screenWidth, setScreenWidth ] = React.useState(isBrowser() ? window.innerWidth : 0);
const tabsRef = useRef<HTMLDivElement>(null);
......@@ -55,18 +51,18 @@ const TabsWithScroll = ({
return [ ...tabs, menuButton ];
}, [ tabs ]);
const handleTabChange = React.useCallback((index: number) => {
const handleTabChange = React.useCallback(({ value }: { value: string }) => {
if (isLoading) {
return;
}
onTabChange ? onTabChange(index) : setActiveTabIndex(index);
onTabChange ? onTabChange(value) : setActiveTab(value);
}, [ isLoading, onTabChange ]);
useEffect(() => {
if (defaultTabIndex !== undefined) {
setActiveTabIndex(defaultTabIndex);
if (defaultTab !== undefined) {
setActiveTab(defaultTab);
}
}, [ defaultTabIndex ]);
}, [ defaultTab ]);
React.useEffect(() => {
const resizeHandler = debounce(() => {
......@@ -85,17 +81,18 @@ const TabsWithScroll = ({
}
return (
<Tabs
<TabsRoot
className={ className }
variant={ themeProps.variant || 'soft-rounded' }
colorScheme={ themeProps.colorScheme || 'blue' }
isLazy
onChange={ handleTabChange }
index={ activeTabIndex }
variant={ themeProps.variant }
// colorScheme={ themeProps.colorScheme || 'blue' }
lazyMount
unmountOnExit
onValueChange={ handleTabChange }
value={ activeTab }
position="relative"
size={ themeProps.size || 'md' }
size={ themeProps.size }
ref={ tabsRef }
lazyBehavior={ lazyBehavior }
>
<AdaptiveTabsList
// the easiest and most readable way to achieve correct tab's cut recalculation when
......@@ -111,19 +108,17 @@ const TabsWithScroll = ({
rightSlot={ rightSlot }
rightSlotProps={ rightSlotProps }
stickyEnabled={ stickyEnabled }
activeTabIndex={ activeTabIndex }
activeTab={ activeTab }
onItemClick={ handleTabChange }
themeProps={ themeProps }
isLoading={ isLoading }
/>
<TabPanels>
{ tabsList.map((tab) => (
<TabPanel padding={ 0 } key={ tab.id?.toString() || (typeof tab.title === 'string' ? tab.title : undefined) }>
<TabsContent padding={ 0 } key={ getTabValue(tab) } value={ getTabValue(tab) }>
{ tab.component }
</TabPanel>
</TabsContent>
)) }
</TabPanels>
</Tabs>
</TabsRoot>
);
};
......
......@@ -14,7 +14,7 @@ export type RoutedTab = TabItem & { subTabs?: Array<string> };
export type RoutedSubTab = Omit<TabItem, 'subTabs'>;
export interface MenuButton {
id: null;
id: 'menu';
title: string;
count?: never;
component: null;
......
import type { MenuButton } from './types';
import type { MenuButton, TabItem } from './types';
import { middot } from 'lib/html-entities';
export const menuButton: MenuButton = {
id: null,
id: 'menu',
title: `${ middot }${ middot }${ middot }`,
component: null,
};
export const getTabValue = (tab: MenuButton | TabItem): string => tab.id.toString();
import { Box } from '@chakra-ui/react';
import React from 'react';
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'toolkit/chakra/tabs';
import AdaptiveTabs from 'toolkit/components/AdaptiveTabs/AdaptiveTabs';
import { Section, Container, SectionHeader, SamplesStack, Sample, SectionSubHeader } from './parts';
const TabsShowcase = () => {
const tabs = [
{ id: 'tab1', title: 'Swaps', component: <div>Swaps content</div>, count: 10 },
{ id: 'tab2', title: 'Bridges', component: <div>Bridges content</div>, count: 3 },
{ id: 'tab3', title: 'Liquidity staking', component: <div>Liquidity staking content</div>, count: 300 },
{ id: 'tab4', title: 'Lending', component: <div>Lending content</div>, count: 400 },
{ id: 'tab5', title: 'Yield farming', component: <div>Yield farming content</div> },
];
return (
<Container value="tabs">
<Section>
<SectionHeader>Variants</SectionHeader>
<SamplesStack>
<Sample label="variant: solid">
<TabsRoot defaultValue="tab1" variant="solid">
<TabsList>
<TabsTrigger value="tab1">First tab</TabsTrigger>
<TabsTrigger value="tab2">Second tab</TabsTrigger>
</TabsList>
<TabsContent value="tab1">First tab content</TabsContent>
<TabsContent value="tab2">Second tab content</TabsContent>
</TabsRoot>
</Sample>
<Sample label="variant: secondary">
<TabsRoot defaultValue="tab1" variant="secondary" size="sm">
<TabsList>
<TabsTrigger value="tab1">First tab</TabsTrigger>
<TabsTrigger value="tab2">Second tab</TabsTrigger>
</TabsList>
<TabsContent value="tab1">First tab content</TabsContent>
<TabsContent value="tab2">Second tab content</TabsContent>
</TabsRoot>
</Sample>
</SamplesStack>
</Section>
<Section>
<SectionHeader>Examples</SectionHeader>
<SectionSubHeader>Adaptive tabs</SectionSubHeader>
<SamplesStack>
<Sample>
<AdaptiveTabs
tabs={ tabs }
defaultValue={ tabs[0].id }
outline="1px dashed lightpink"
listProps={{ maxW: { base: '100vw', lg: '40vw' } }}
leftSlot={ <Box display={{ base: 'none', lg: 'block' }}>Left element</Box> }
leftSlotProps={{ pr: { base: 0, lg: 4 }, color: 'text.secondary' }}
rightSlot={ <Box display={{ base: 'none', lg: 'block' }}>Right element</Box> }
rightSlotProps={{ pl: { base: 0, lg: 4 }, color: 'text.secondary' }}
/>
</Sample>
</SamplesStack>
</Section>
</Container>
);
};
export default React.memo(TabsShowcase);
......@@ -20,9 +20,9 @@ export const SamplesStack = ({ children }: { children: React.ReactNode }) => (
{ children }
</Grid>
);
export const Sample = ({ children, label, ...props }: { children: React.ReactNode; label: string } & StackProps) => (
export const Sample = ({ children, label, ...props }: { children: React.ReactNode; label?: string } & StackProps) => (
<>
<Code w="fit-content">{ label }</Code>
<HStack gap={ 3 } whiteSpace="pre-wrap" flexWrap="wrap" { ...props }>{ children }</HStack>
{ label && <Code w="fit-content">{ label }</Code> }
<HStack gap={ 3 } whiteSpace="pre-wrap" flexWrap="wrap" columnSpan={ label ? '1' : '2' } { ...props }>{ children }</HStack>
</>
);
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