Commit 4f607524 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #170 from blockscout/tabs

routed tabs
parents 8372f4a9 c9c91ec6
......@@ -6,15 +6,15 @@ import type { RouteName } from './routes';
const PATH_PARAM_REGEXP = /\/\[(\w+)\]/g;
export function link(routeName: RouteName, urlParams?: Record<string, string | undefined>, queryParams?: Record<string, string>): string {
export function link(routeName: RouteName, urlParams?: Record<string, Array<string> | string | undefined>, queryParams?: Record<string, string>): string {
const route = ROUTES[routeName];
if (!route) {
return '';
}
const network = findNetwork({
network_type: urlParams?.network_type || '',
network_sub_type: urlParams?.network_sub_type,
network_type: typeof urlParams?.network_type === 'string' ? urlParams?.network_type : '',
network_sub_type: typeof urlParams?.network_sub_type === 'string' ? urlParams?.network_sub_type : undefined,
});
const path = route.pattern.replace(PATH_PARAM_REGEXP, (_, paramName: string) => {
......@@ -22,7 +22,13 @@ export function link(routeName: RouteName, urlParams?: Record<string, string | u
return '';
}
const paramValue = urlParams?.[paramName];
let paramValue = urlParams?.[paramName];
if (Array.isArray(paramValue)) {
// FIXME we don't have yet params as array, but typescript says that we could
// dun't know how to manage it, fix me if you find an issue
paramValue = paramValue.join(',');
}
return paramValue ? `/${ paramValue }` : '';
});
......
......@@ -11,10 +11,6 @@ export default function useLink() {
const networkSubType = router.query.network_sub_type;
return React.useCallback((...args: LinkBuilderParams) => {
if (typeof networkType !== 'string' || typeof networkSubType !== 'string') {
return '';
}
return link(args[0], { network_type: networkType, network_sub_type: networkSubType, ...args[1] }, args[2]);
}, [ networkType, networkSubType ]);
}
......@@ -19,7 +19,7 @@ const AddressTagsPage: NextPage<Props> = ({ pageParams }: Props) => {
return (
<>
<Head><title>{ title }</title></Head>
<PrivateTags tab="address"/>
<PrivateTags tab="private_tags_address"/>
</>
);
};
......
......@@ -19,7 +19,7 @@ const TransactionTagsPage: NextPage<Props> = ({ pageParams }: Props) => {
return (
<>
<Head><title>{ title }</title></Head>
<PrivateTags tab="transaction"/>
<PrivateTags tab="private_tags_tx"/>
</>
);
};
......
......@@ -11,7 +11,7 @@ type Props = {
const TransactionPage: NextPage<Props> = ({ pageParams }: Props) => {
return (
<TransactionNextPage tab="details" pageParams={ pageParams }/>
<TransactionNextPage tab="tx_index" pageParams={ pageParams }/>
);
};
......
......@@ -10,7 +10,7 @@ type Props = {
}
const TransactionPage: NextPage<Props> = ({ pageParams }: Props) => {
return <TransactionNextPage pageParams={ pageParams } tab="internal_txn"/>;
return <TransactionNextPage pageParams={ pageParams } tab="tx_internal"/>;
};
export default TransactionPage;
......
......@@ -10,7 +10,7 @@ type Props = {
}
const TransactionPage: NextPage<Props> = ({ pageParams }: Props) => {
return <TransactionNextPage pageParams={ pageParams } tab="logs"/>;
return <TransactionNextPage pageParams={ pageParams } tab="tx_logs"/>;
};
export default TransactionPage;
......
......@@ -10,7 +10,7 @@ type Props = {
}
const TransactionPage: NextPage<Props> = ({ pageParams }: Props) => {
return <TransactionNextPage pageParams={ pageParams } tab="raw_trace"/>;
return <TransactionNextPage pageParams={ pageParams } tab="tx_raw_trace"/>;
};
export default TransactionPage;
......
......@@ -10,7 +10,7 @@ type Props = {
}
const TransactionPage: NextPage<Props> = ({ pageParams }: Props) => {
return <TransactionNextPage pageParams={ pageParams } tab="state"/>;
return <TransactionNextPage pageParams={ pageParams } tab="tx_state"/>;
};
export default TransactionPage;
......
......@@ -106,15 +106,39 @@ const variantSimple = defineStyle((props) => {
};
});
const variantSubtle = defineStyle((props) => {
const { colorScheme: c } = props;
const activeBg = mode(`${ c }.50`, 'gray.800')(props);
return {
bg: 'transparent',
color: mode(`${ c }.700`, 'gray.400')(props),
_active: {
color: mode(`${ c }.700`, 'gray.50')(props),
bg: mode(`${ c }.50`, 'gray.800')(props),
},
_hover: {
color: `${ c }.400`,
_active: {
bg: props.isActive ? activeBg : 'transparent',
color: mode(`${ c }.700`, 'gray.50')(props),
},
},
};
});
const variants = {
solid: variantSolid,
outline: variantOutline,
simple: variantSimple,
subtle: variantSubtle,
};
const baseStyle = defineStyle({
fontWeight: 600,
borderRadius: 'base',
overflow: 'hidden',
});
const sizes = {
......
......@@ -7,6 +7,7 @@ const global = (props: StyleFunctionProps) => ({
body: {
bg: mode('white', 'black')(props),
...getDefaultTransitionProps(),
'-webkit-tap-highlight-color': 'transparent',
},
form: {
w: '100%',
......
......@@ -5,8 +5,8 @@ import type { AppItemOverview } from 'types/client/apps';
import { TEMPORARY_DEMO_APPS } from 'data/apps';
import AppList from 'ui/apps/AppList';
import FilterInput from 'ui/shared/FilterInput';
import AppModal from 'ui/apps/AppModal';
import FilterInput from 'ui/shared/FilterInput';
const defaultDisplayedApps = [ ...TEMPORARY_DEMO_APPS ]
.sort((a, b) => a.title.localeCompare(b.title));
......
import {
Box,
Tab,
Tabs,
TabList,
TabPanel,
TabPanels,
} from '@chakra-ui/react';
import React, { useCallback, useState } from 'react';
import { Box } from '@chakra-ui/react'; import React from 'react';
import type { RoutedTab } from 'ui/shared/RoutedTabs/types';
import useLink from 'lib/link/useLink';
import PrivateAddressTags from 'ui/privateTags/PrivateAddressTags';
import PrivateTransactionTags from 'ui/privateTags/PrivateTransactionTags';
import Page from 'ui/shared/Page';
import PageHeader from 'ui/shared/PageHeader';
import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
const TABS = [ 'address', 'transaction' ] as const;
type TabName = typeof TABS[number];
const TABS: Array<RoutedTab> = [
{ routeName: 'private_tags_address', title: 'Address', component: <PrivateAddressTags/> },
{ routeName: 'private_tags_tx', title: 'Transaction', component: <PrivateTransactionTags/> },
];
type Props = {
tab: TabName;
tab: RoutedTab['routeName'];
}
const PrivateTags = ({ tab }: Props) => {
const [ , setActiveTab ] = useState<TabName>(tab);
const link = useLink();
const onChangeTab = useCallback((index: number) => {
setActiveTab(TABS[index]);
const newUrl = link(TABS[index] === 'address' ? 'private_tags_address' : 'private_tags_tx');
history.replaceState(history.state, '', newUrl);
}, [ link ]);
return (
<Page>
<Box h="100%">
<PageHeader text="Private tags"/>
<Tabs variant="soft-rounded" colorScheme="blue" isLazy onChange={ onChangeTab } defaultIndex={ TABS.indexOf(tab) }>
<TabList marginBottom={{ base: 6, lg: 8 }}>
<Tab>Address</Tab>
<Tab>Transaction</Tab>
</TabList>
<TabPanels>
<TabPanel padding={ 0 }>
<PrivateAddressTags/>
</TabPanel>
<TabPanel padding={ 0 }>
<PrivateTransactionTags/>
</TabPanel>
</TabPanels>
</Tabs>
<RoutedTabs tabs={ TABS } defaultActiveTab={ tab }/>
</Box>
</Page>
);
......
import {
Tab,
Tabs,
TabList,
TabPanel,
TabPanels,
} from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { RouteName } from 'lib/link/routes';
import useLink from 'lib/link/useLink';
import type { RoutedTab } from 'ui/shared/RoutedTabs/types';
import Page from 'ui/shared/Page';
import PageHeader from 'ui/shared/PageHeader';
import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
import TxDetails from 'ui/tx/TxDetails';
import TxInternals from 'ui/tx/TxInternals';
import TxLogs from 'ui/tx/TxLogs';
import TxRawTrace from 'ui/tx/TxRawTrace';
import TxState from 'ui/tx/TxState';
interface Tab {
type: 'details' | 'internal_txn' | 'logs' | 'raw_trace' | 'state';
name: string;
path?: string;
component?: React.ReactNode;
routeName: RouteName;
}
const TABS: Array<Tab> = [
{ type: 'details', routeName: 'tx_index', name: 'Details', component: <TxDetails/> },
{ type: 'internal_txn', routeName: 'tx_internal', name: 'Internal txn', component: <TxInternals/> },
{ type: 'logs', routeName: 'tx_logs', name: 'Logs', component: <TxLogs/> },
{ type: 'state', routeName: 'tx_state', name: 'State', component: <TxState/> },
{ type: 'raw_trace', routeName: 'tx_raw_trace', name: 'Raw trace', component: <TxRawTrace/> },
const TABS: Array<RoutedTab> = [
{ routeName: 'tx_index', title: 'Details', component: <TxDetails/> },
{ routeName: 'tx_internal', title: 'Internal txn', component: <TxInternals/> },
{ routeName: 'tx_logs', title: 'Logs', component: <TxLogs/> },
{ routeName: 'tx_state', title: 'State', component: <TxState/> },
{ routeName: 'tx_raw_trace', title: 'Raw trace', component: <TxRawTrace/> },
];
export interface Props {
tab: Tab['type'];
tab: RoutedTab['routeName'];
}
const TransactionPageContent = ({ tab }: Props) => {
const [ , setActiveTab ] = React.useState<Tab['type']>(tab);
const router = useRouter();
const link = useLink();
const handleTabChange = React.useCallback((index: number) => {
const nextTab = TABS[index];
setActiveTab(nextTab.type);
const newUrl = link(nextTab.routeName, { id: router.query.id as string });
window.history.replaceState(history.state, '', newUrl);
}, [ setActiveTab, link, router.query.id ]);
const defaultIndex = TABS.map(({ type }) => type).indexOf(tab);
return (
<Page>
<PageHeader text="Transaction details"/>
<Tabs variant="soft-rounded" colorScheme="blue" isLazy onChange={ handleTabChange } defaultIndex={ defaultIndex }>
<TabList marginBottom={{ base: 6, lg: 8 }} flexWrap="wrap">
{ TABS.map((tab) => <Tab key={ tab.type }>{ tab.name }</Tab>) }
</TabList>
<TabPanels>
{ TABS.map((tab) => <TabPanel padding={ 0 } key={ tab.type }>{ tab.component || tab.name }</TabPanel>) }
</TabPanels>
</Tabs>
<RoutedTabs tabs={ TABS } defaultActiveTab={ tab }/>
</Page>
);
};
......
import {
Tab,
Tabs,
TabList,
TabPanel,
TabPanels,
} from '@chakra-ui/react';
import type { StyleProps } from '@chakra-ui/styled-system';
import { useRouter } from 'next/router';
import React from 'react';
import type { RoutedTab } from './types';
import useIsMobile from 'lib/hooks/useIsMobile';
import { link } from 'lib/link/link';
import RoutedTabsMenu from './RoutedTabsMenu';
import useAdaptiveTabs from './useAdaptiveTabs';
const hiddenItemStyles: StyleProps = {
position: 'absolute',
top: '-9999px',
left: '-9999px',
visibility: 'hidden',
};
interface Props {
tabs: Array<RoutedTab>;
defaultActiveTab: RoutedTab['routeName'];
}
const RoutedTabs = ({ tabs, defaultActiveTab }: Props) => {
const defaultIndex = tabs.findIndex(({ routeName }) => routeName === defaultActiveTab);
const isMobile = useIsMobile();
const [ activeTab, setActiveTab ] = React.useState<number>(defaultIndex);
const { tabsCut, tabsList, tabsRefs, listRef } = useAdaptiveTabs(tabs, isMobile);
const router = useRouter();
const handleTabChange = React.useCallback((index: number) => {
const nextTab = tabs[index];
if (nextTab.routeName) {
const newUrl = link(nextTab.routeName, router.query);
router.push(newUrl, undefined, { shallow: true });
}
setActiveTab(index);
}, [ tabs, router ]);
return (
<Tabs variant="soft-rounded" colorScheme="blue" isLazy onChange={ handleTabChange } index={ activeTab }>
<TabList
marginBottom={{ base: 6, lg: 8 }}
flexWrap="nowrap"
whiteSpace="nowrap"
ref={ listRef }
overflowY="hidden"
overflowX={ isMobile ? 'auto' : undefined }
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 */
}}
>
{ tabsList.map((tab, index) => {
if (!tab.routeName) {
return (
<RoutedTabsMenu
key="menu"
tabs={ tabs }
activeTab={ tabs[activeTab] }
tabsCut={ tabsCut }
isActive={ activeTab >= 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] }
/>
);
}
return (
<Tab
key={ tab.routeName }
ref={ tabsRefs[index] }
{ ...(index < tabsCut ? {} : hiddenItemStyles) }
scrollSnapAlign="start"
>
{ tab.title }
</Tab>
);
}) }
</TabList>
<TabPanels>
{ tabsList.map((tab) => <TabPanel padding={ 0 } key={ tab.routeName }>{ tab.component }</TabPanel>) }
</TabPanels>
</Tabs>
);
};
export default React.memo(RoutedTabs);
import { Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
Button,
useDisclosure,
} from '@chakra-ui/react';
import type { StyleProps } from '@chakra-ui/styled-system';
import React from 'react';
import type { MenuButton, RoutedTab } from './types';
import { menuButton } from './utils';
interface Props {
tabs: Array<RoutedTab | MenuButton>;
activeTab: RoutedTab;
tabsCut: number;
isActive: boolean;
styles?: StyleProps;
onItemClick: (index: number) => void;
buttonRef: React.RefObject<HTMLButtonElement>;
}
const RoutedTabsMenu = ({ tabs, tabsCut, isActive, styles, onItemClick, buttonRef, activeTab }: Props) => {
const { isOpen, onClose, onOpen } = useDisclosure();
const handleItemClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
onClose();
const tabIndex = (event.target as HTMLButtonElement).getAttribute('data-index');
if (tabIndex) {
onItemClick(tabsCut + Number(tabIndex));
}
}, [ onClose, onItemClick, tabsCut ]);
return (
<Popover isLazy placement="bottom-end" key="more" isOpen={ isOpen } onClose={ onClose } onOpen={ onOpen } closeDelay={ 0 }>
<PopoverTrigger>
<Button
variant="subtle"
isActive={ isOpen || isActive }
ref={ buttonRef }
{ ...styles }
>
{ menuButton.title }
</Button>
</PopoverTrigger>
<PopoverContent w="auto">
<PopoverBody display="flex" flexDir="column">
{ tabs.slice(tabsCut).map((tab, index) => (
<Button
key={ tab.routeName }
variant="subtle"
onClick={ handleItemClick }
isActive={ activeTab.routeName === tab.routeName }
justifyContent="left"
data-index={ index }
>
{ tab.title }
</Button>
)) }
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default React.memo(RoutedTabsMenu);
import type { RouteName } from 'lib/link/routes';
export interface RoutedTab {
// for simplicity we use routeName as an id
// if we migrate to non-Next.js router that should be revised
// id: string;
routeName: RouteName | null;
title: string;
component: React.ReactNode;
}
export interface MenuButton {
routeName: null;
title: string;
component: null;
}
import _debounce from 'lodash/debounce';
import React from 'react';
import type { RoutedTab } from './types';
import { menuButton } from './utils';
export default function useAdaptiveTabs(tabs: Array<RoutedTab>, 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 calculateCut = React.useCallback(() => {
const listWidth = listRef.current?.getBoundingClientRect().width;
const tabWidths = tabsRefs.map((tab) => tab.current?.getBoundingClientRect().width);
const menuWidth = tabWidths.at(-1);
if (!listWidth || !menuWidth) {
return tabs.length;
}
const { visibleNum } = tabWidths.slice(0, -1).reduce((result, item, index) => {
if (!item) {
return result;
}
if (result.accWidth + item <= listWidth - menuWidth) {
return { visibleNum: result.visibleNum + 1, accWidth: result.accWidth + item };
}
if (result.accWidth + item <= listWidth && index === tabWidths.length - 2) {
return { visibleNum: result.visibleNum + 1, accWidth: result.accWidth + item };
}
return result;
}, { visibleNum: 0, accWidth: 0 });
return visibleNum;
}, [ tabs.length, tabsRefs ]);
const tabsList = React.useMemo(() => {
if (disabled) {
return tabs;
}
return [ ...tabs, menuButton ];
}, [ tabs, disabled ]);
React.useEffect(() => {
!disabled && setTabsRefs(tabsList.map((_, index) => tabsRefs[index] || React.createRef()));
// imitate componentDidMount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
if (tabsRefs.length > 0) {
setTabsCut(calculateCut());
}
}, [ calculateCut, tabsRefs ]);
React.useEffect(() => {
if (tabsRefs.length === 0) {
return;
}
const resizeHandler = _debounce(() => {
setTabsCut(calculateCut());
}, 100);
const resizeObserver = new ResizeObserver(resizeHandler);
resizeObserver.observe(document.body);
return function cleanup() {
resizeObserver.unobserve(document.body);
};
}, [ calculateCut, tabsRefs.length ]);
return React.useMemo(() => {
return {
tabsCut,
tabsList,
tabsRefs,
listRef,
};
}, [ tabsList, tabsCut, tabsRefs, listRef ]);
}
import type { MenuButton } from './types';
import { middot } from 'lib/html-entities';
export const menuButton: MenuButton = {
routeName: null,
title: `${ middot }${ middot }${ middot }`,
component: null,
};
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