feat: mobile navbar on Console (#2547)

* feat: mobile navbar on Console

* feat: mobile navbar on Console - adjust unit test

* feat: mobile navbar on Console - adjust unit test

* feat: mobile navbar on Console - adjust themes

* feat: mobile navbar on Console - add some unit tests

* feat: mobile navbar on Console - refactor solution

* feat: mobile navbar on Console - adjust int tests

* feat: mobile navbar on Console - adjust styling

* feat: mobile navbar on Console - move close button into the drawer

* feat: mobile navbar on Console - adjust int tests

* chore: close drawe after navigation

* chore: mobile navbar on Console - adjust unit tests

* chore: mobile navbar on Console - adjust unit tests

Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
This commit is contained in:
macqbat 2023-01-17 10:59:12 +01:00 committed by GitHub
parent ff53e5e841
commit 45a4dd7009
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 595 additions and 99 deletions

View File

@ -142,19 +142,48 @@ describe('Navbar', { tags: '@smoke' }, () => {
const hashes = ['#/markets/all', '#/markets/market-0', '#/portfolio']; const hashes = ['#/markets/all', '#/markets/market-0', '#/portfolio'];
let i = 0; let i = 0;
cy.getByTestId('navbar').within(() => { cy.getByTestId('navbar').within(() => {
cy.get('a[data-testid]', { log: true }) cy.get('[data-testid="navbar-links"] a[data-testid]', { log: true })
.should('have.length', 3) .should('have.length', 3)
.each((item) => { .each((item) => {
cy.wrap(item).click(); cy.wrap(item).click();
cy.wrap(item).get('span.absolute.h-1.w-full').should('exist'); cy.wrap(item).get('span.absolute.md\\:h-1.w-full').should('exist');
cy.location('hash').should('equal', hashes[i]); cy.location('hash').should('equal', hashes[i]);
cy.wrap(item).should('have.data', 'testid', links[i++]); cy.wrap(item).should('have.data', 'testid', links[i++]);
}); });
}); });
}); });
it('should look nicer on mobile', () => { it('wallet drawer should be correctly rendered', () => {
cy.viewport(560, 890); cy.viewport(560, 890);
cy.getByTestId('theme-switcher').scrollIntoView().click(); mockConnectWallet();
cy.connectVegaWallet(true);
cy.getByTestId('connect-vega-wallet-mobile').click();
cy.getByTestId('wallets-drawer').should('be.visible');
cy.getByTestId('wallets-drawer').within((el) => {
cy.wrap(el).get('button').contains('Disconnect').click();
});
cy.getByTestId('wallets-drawer').should('not.be.visible');
});
it('menu drawer should be correctly rendered', () => {
cy.viewport(560, 890);
cy.getByTestId('button-menu-drawer').click();
cy.getByTestId('menu-drawer').should('be.visible');
cy.getByTestId('menu-drawer').within((el) => {
cy.wrap(el).getByTestId('Markets').click();
cy.location('hash').should('equal', '#/markets/all');
});
cy.getByTestId('button-menu-drawer').click();
cy.getByTestId('menu-drawer').within((el) => {
cy.wrap(el).getByTestId('Trading').click();
cy.location('hash').should('equal', '#/markets/market-0');
});
cy.getByTestId('button-menu-drawer').click();
cy.getByTestId('menu-drawer').within((el) => {
cy.wrap(el).getByTestId('Portfolio').click();
cy.location('hash').should('equal', '#/portfolio');
cy.wrap(el).getByTestId('theme-switcher').should('be.visible');
});
cy.getByTestId('menu-drawer').should('not.be.visible');
}); });
}); });

View File

@ -227,9 +227,7 @@ describe('home', { tags: '@regression' }, () => {
describe('redirect should take last visited market into consideration', () => { describe('redirect should take last visited market into consideration', () => {
beforeEach(() => { beforeEach(() => {
cy.window().then((window) => { cy.clearLocalStorage();
window.localStorage.removeItem('marketId');
});
}); });
it('marketId comes from existing market', () => { it('marketId comes from existing market', () => {
cy.window().then((window) => { cy.window().then((window) => {
@ -237,7 +235,7 @@ describe('home', { tags: '@regression' }, () => {
cy.visit('/'); cy.visit('/');
cy.wait('@Market'); cy.wait('@Market');
cy.location('hash').should('equal', '#/markets/market-1'); cy.location('hash').should('equal', '#/markets/market-1');
cy.get('[role="dialog"]').should('not.exist'); cy.getByTestId('dialog-content').should('not.exist');
}); });
}); });
@ -250,7 +248,7 @@ describe('home', { tags: '@regression' }, () => {
cy.visit('/'); cy.visit('/');
cy.wait('@Market'); cy.wait('@Market');
cy.location('hash').should('equal', '#/markets/market-not-existing'); cy.location('hash').should('equal', '#/markets/market-not-existing');
cy.get('[role="dialog"]').should('not.exist'); cy.getByTestId('dialog-content').should('not.exist');
}); });
}); });
}); });

View File

@ -2,17 +2,19 @@ import * as Schema from '@vegaprotocol/types';
describe('markets table', { tags: '@smoke' }, () => { describe('markets table', { tags: '@smoke' }, () => {
beforeEach(() => { beforeEach(() => {
cy.mockTradingPage( cy.clearLocalStorage().then(() => {
Schema.MarketState.STATE_ACTIVE, cy.mockTradingPage(
Schema.MarketTradingMode.TRADING_MODE_MONITORING_AUCTION, Schema.MarketState.STATE_ACTIVE,
Schema.AuctionTrigger.AUCTION_TRIGGER_LIQUIDITY Schema.MarketTradingMode.TRADING_MODE_MONITORING_AUCTION,
); Schema.AuctionTrigger.AUCTION_TRIGGER_LIQUIDITY
cy.mockSubscription(); );
cy.visit('/'); cy.mockSubscription();
cy.wait('@Market'); cy.visit('/');
cy.wait('@Markets'); cy.wait('@Market');
cy.wait('@MarketsData'); cy.wait('@Markets');
cy.wait('@MarketsCandles'); cy.wait('@MarketsData');
cy.wait('@MarketsCandles');
});
}); });
it('renders markets correctly', () => { it('renders markets correctly', () => {

View File

@ -22,6 +22,7 @@ export const Home = () => {
replace: true, replace: true,
}); });
} else if (data) { } else if (data) {
update({ shouldDisplayWelcomeDialog: true });
const marketDataId = data[0]?.id; const marketDataId = data[0]?.id;
if (marketDataId) { if (marketDataId) {
navigate(Links[Routes.MARKET](marketDataId), { navigate(Links[Routes.MARKET](marketDataId), {
@ -30,7 +31,6 @@ export const Home = () => {
} else { } else {
navigate(Links[Routes.MARKET]()); navigate(Links[Routes.MARKET]());
} }
update({ shouldDisplayWelcomeDialog: true });
} }
}, [marketId, data, navigate, update]); }, [marketId, data, navigate, update]);

View File

@ -0,0 +1,18 @@
export const WalletIcon = ({ className }: { className?: string }) => {
return (
<svg
width="26"
height="18"
viewBox="0 0 26 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
data-testid="wallet-icon"
>
<path
d="M4.77437 17.7499H4.74987C3.6504 17.7368 2.77439 16.8489 2.77439 15.7772V12.8495V12.6343L2.5615 12.6023C1.59672 12.4575 0.849609 11.6116 0.849609 10.6266V7.40064C0.849609 6.39018 1.59509 5.56985 2.56147 5.4249L2.77439 5.39297V5.17767V2.24998C2.77439 1.14102 3.66537 0.25 4.77437 0.25H23.7501C24.8591 0.25 25.7501 1.14098 25.7501 2.24998V15.7499C25.7501 16.8588 24.8591 17.7499 23.7501 17.7499H4.77437ZM4.44917 12.5992H4.19917L4.77441 16.075V16.325H4.77466H23.7502C24.0778 16.325 24.3254 16.0777 24.3254 15.7497V2.24984C24.3254 1.9222 24.0782 1.6746 23.7502 1.6746H4.77441C4.44677 1.6746 4.19917 1.92182 4.19917 2.24984V5.12306V5.37306H4.44917H7.0244C8.51139 5.37306 9.67508 6.56094 9.67508 8.02374V9.94852C9.67508 11.4355 8.4872 12.5992 7.0244 12.5992H4.44917ZM2.84963 6.8253C2.52199 6.8253 2.27439 7.07253 2.27439 7.40054V10.6264C2.27439 10.9541 2.52161 11.2017 2.84962 11.2017L7.02419 11.2019C7.73619 11.2019 8.25009 10.6515 8.25009 9.97598V8.0512C8.25009 7.3392 7.69976 6.8253 7.0242 6.8253H2.84963Z"
stroke="none"
/>
</svg>
);
};

View File

@ -1,3 +1,4 @@
import { useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { NavLink, Link } from 'react-router-dom'; import { NavLink, Link } from 'react-router-dom';
import { import {
@ -9,7 +10,7 @@ import {
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
import { useGlobalStore } from '../../stores/global'; import { useGlobalStore } from '../../stores/global';
import { VegaWalletConnectButton } from '../vega-wallet-connect-button'; import { VegaWalletConnectButton } from '../vega-wallet-connect-button';
import { NewTab, ThemeSwitcher } from '@vegaprotocol/ui-toolkit'; import { Drawer, NewTab, ThemeSwitcher } from '@vegaprotocol/ui-toolkit';
import { Vega } from '../icons/vega'; import { Vega } from '../icons/vega';
import type { HTMLAttributeAnchorTarget } from 'react'; import type { HTMLAttributeAnchorTarget } from 'react';
import { Links, Routes } from '../../pages/client-router'; import { Links, Routes } from '../../pages/client-router';
@ -24,7 +25,17 @@ interface NavbarProps {
navbarTheme?: NavbarTheme; navbarTheme?: NavbarTheme;
} }
export const Navbar = ({ navbarTheme = 'inherit' }: NavbarProps) => { const LinkList = ({
navbarTheme,
className = 'flex',
dataTestId = 'navbar-links',
onNavigate,
}: {
navbarTheme: NavbarTheme;
className?: string;
dataTestId?: string;
onNavigate?: () => void;
}) => {
const tokenLink = useLinks(DApp.Token); const tokenLink = useLinks(DApp.Token);
const { marketId } = useGlobalStore((store) => ({ const { marketId } = useGlobalStore((store) => ({
marketId: store.marketId, marketId: store.marketId,
@ -33,47 +44,130 @@ export const Navbar = ({ navbarTheme = 'inherit' }: NavbarProps) => {
? Links[Routes.MARKET](marketId) ? Links[Routes.MARKET](marketId)
: Links[Routes.MARKET](); : Links[Routes.MARKET]();
return ( return (
<Nav <div className={className} data-testid={dataTestId}>
navbarTheme={navbarTheme}
title={t('Console')}
titleContent={<NetworkSwitcher />}
icon={
<Link to="/">
<Vega className="w-13" />
</Link>
}
>
<AppNavLink <AppNavLink
name={t('Markets')} name={t('Markets')}
path={Links[Routes.MARKETS]()} path={Links[Routes.MARKETS]()}
navbarTheme={navbarTheme} navbarTheme={navbarTheme}
onClick={onNavigate}
end end
/> />
<AppNavLink <AppNavLink
name={t('Trading')} name={t('Trading')}
path={tradingPath} path={tradingPath}
navbarTheme={navbarTheme} navbarTheme={navbarTheme}
onClick={onNavigate}
end end
/> />
<AppNavLink <AppNavLink
name={t('Portfolio')} name={t('Portfolio')}
path={Links[Routes.PORTFOLIO]()} path={Links[Routes.PORTFOLIO]()}
navbarTheme={navbarTheme} navbarTheme={navbarTheme}
onClick={onNavigate}
/> />
<a <a
href={tokenLink(TOKEN_GOVERNANCE)} href={tokenLink(TOKEN_GOVERNANCE)}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className={getActiveNavLinkClassNames(false, navbarTheme)} className={classNames(
'w-full md:w-auto',
getActiveNavLinkClassNames(false, navbarTheme)
)}
> >
<span className="flex items-center gap-2"> <span className="flex items-center justify-between w-full gap-2 pr-3 md:pr-0">
{t('Governance')} {t('Governance')}
<NewTab /> <NewTab />
</span> </span>
</a> </a>
<div className="flex items-center gap-2 ml-auto"> </div>
);
};
const MobileMenuBar = ({ navbarTheme }: { navbarTheme: NavbarTheme }) => {
const [drawerOpen, setDrawerOpen] = useState(false);
const [container, setContainer] = useState<HTMLElement | null>(null);
const menuButton = (
<button
className={classNames(
'flex flex-col justify-around gap-3 p-2 relative z-30 h-[34px]',
{
'z-50': drawerOpen,
}
)}
onClick={() => setDrawerOpen(!drawerOpen)}
data-testid="button-menu-drawer"
>
<div
className={classNames('w-[26px] h-[2px] transition-all', {
'translate-y-0 rotate-0 bg-white': !drawerOpen,
'bg-black': !drawerOpen && navbarTheme === 'yellow',
'translate-y-[7.5px] rotate-45 bg-black dark:bg-white': drawerOpen,
})}
/>
<div
className={classNames('w-[26px] h-[2px] transition-all', {
'translate-y-0 rotate-0 bg-white': !drawerOpen,
'bg-black': !drawerOpen && navbarTheme === 'yellow',
'-translate-y-[7.5px] -rotate-45 bg-black dark:bg-white': drawerOpen,
})}
/>
</button>
);
return (
<div className="flex overflow-hidden md:hidden" ref={setContainer}>
<Drawer
dataTestId="menu-drawer"
open={drawerOpen}
onChange={setDrawerOpen}
container={container}
trigger={menuButton}
>
<div className="border-l border-default px-4 py-2 gap-4 flex flex-col w-full h-full bg-white dark:bg-black dark:text-white justify-start">
<div className="w-full h-1"></div>
<div className="px-2 pt-10 w-full flex flex-col items-stretch">
<NetworkSwitcher />
<div className="w-full pt-8 h-1 border-b border-default"></div>
</div>
<LinkList
className="flex flex-col"
navbarTheme={navbarTheme}
dataTestId="mobile-navbar-links"
onNavigate={() => setDrawerOpen(false)}
/>
<div className="flex flex-col px-2 justify-between">
<div className="w-full h-1 border-t border-default py-5"></div>
<ThemeSwitcher withMobile />
</div>
</div>
</Drawer>
</div>
);
};
export const Navbar = ({ navbarTheme = 'inherit' }: NavbarProps) => {
const titleContent = (
<div className="hidden md:block">
<NetworkSwitcher />
</div>
);
return (
<Nav
navbarTheme={navbarTheme}
title={t('Console')}
titleContent={titleContent}
icon={
<Link to="/">
<Vega className="w-13" />
</Link>
}
>
<LinkList className="hidden md:flex" navbarTheme={navbarTheme} />
<div className="flex items-center gap-2 ml-auto overflow-hidden">
<VegaWalletConnectButton /> <VegaWalletConnectButton />
<ThemeSwitcher /> <ThemeSwitcher className="hidden md:block" />
<MobileMenuBar navbarTheme={navbarTheme} />
</div> </div>
</Nav> </Nav>
); );
@ -86,6 +180,7 @@ interface AppNavLinkProps {
testId?: string; testId?: string;
target?: HTMLAttributeAnchorTarget; target?: HTMLAttributeAnchorTarget;
end?: boolean; end?: boolean;
onClick?: () => void;
} }
const AppNavLink = ({ const AppNavLink = ({
@ -95,16 +190,21 @@ const AppNavLink = ({
target, target,
testId = name, testId = name,
end, end,
onClick,
}: AppNavLinkProps) => { }: AppNavLinkProps) => {
const borderClasses = classNames('absolute h-1 w-full bottom-[-1px] left-0', { const borderClasses = classNames(
'bg-black dark:bg-vega-yellow': navbarTheme !== 'yellow', 'absolute h-[2px] md:h-1 w-full bottom-[-1px] left-0',
'bg-black': navbarTheme === 'yellow', {
}); 'bg-black dark:bg-vega-yellow': navbarTheme !== 'yellow',
'bg-black dark:bg-vega-yellow md:dark:bg-black': navbarTheme === 'yellow',
}
);
return ( return (
<NavLink <NavLink
data-testid={testId} data-testid={testId}
to={{ pathname: path }} to={{ pathname: path }}
className={getNavLinkClassNames(navbarTheme)} className={getNavLinkClassNames(navbarTheme)}
onClick={onClick}
target={target} target={target}
end={end} end={end}
> >

View File

@ -7,9 +7,7 @@ import { truncateByChars } from '@vegaprotocol/react-helpers';
const mockUpdateDialogOpen = jest.fn(); const mockUpdateDialogOpen = jest.fn();
jest.mock('@vegaprotocol/wallet', () => ({ jest.mock('@vegaprotocol/wallet', () => ({
...jest.requireActual('@vegaprotocol/wallet'), ...jest.requireActual('@vegaprotocol/wallet'),
useVegaWalletDialogStore: () => ({ useVegaWalletDialogStore: () => mockUpdateDialogOpen,
openVegaWalletDialog: mockUpdateDialogOpen,
}),
})); }));
beforeEach(() => { beforeEach(() => {
@ -27,7 +25,7 @@ const generateJsx = (context: VegaWalletContextShape) => {
it('Not connected', () => { it('Not connected', () => {
render(generateJsx({ pubKey: null } as VegaWalletContextShape)); render(generateJsx({ pubKey: null } as VegaWalletContextShape));
const button = screen.getByRole('button'); const button = screen.getByTestId('connect-vega-wallet');
expect(button).toHaveTextContent('Connect Vega wallet'); expect(button).toHaveTextContent('Connect Vega wallet');
fireEvent.click(button); fireEvent.click(button);
expect(mockUpdateDialogOpen).toHaveBeenCalled(); expect(mockUpdateDialogOpen).toHaveBeenCalled();
@ -42,7 +40,7 @@ it('Connected', () => {
} as VegaWalletContextShape) } as VegaWalletContextShape)
); );
const button = screen.getByRole('button'); const button = screen.getByTestId('manage-vega-wallet');
expect(button).toHaveTextContent(truncateByChars(pubKey.publicKey)); expect(button).toHaveTextContent(truncateByChars(pubKey.publicKey));
fireEvent.click(button); fireEvent.click(button);
expect(mockUpdateDialogOpen).not.toHaveBeenCalled(); expect(mockUpdateDialogOpen).not.toHaveBeenCalled();

View File

@ -1,3 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import CopyToClipboard from 'react-copy-to-clipboard';
import classNames from 'classnames';
import { t, truncateByChars } from '@vegaprotocol/react-helpers'; import { t, truncateByChars } from '@vegaprotocol/react-helpers';
import { import {
Button, Button,
@ -9,17 +12,125 @@ import {
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuTrigger, DropdownMenuTrigger,
Icon, Icon,
Drawer,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import type { PubKey } from '@vegaprotocol/wallet'; import type { PubKey } from '@vegaprotocol/wallet';
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet'; import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { useEffect, useMemo, useState } from 'react'; import { Networks, useEnvironment } from '@vegaprotocol/environment';
import CopyToClipboard from 'react-copy-to-clipboard'; import { WalletIcon } from '../icons/wallet';
const MobileWalletButton = ({
isConnected,
activeKey,
}: {
isConnected?: boolean;
activeKey?: PubKey;
}) => {
const { pubKeys, selectPubKey, disconnect } = useVegaWallet();
const openVegaWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
const { VEGA_ENV } = useEnvironment();
const isYellow = VEGA_ENV === Networks.TESTNET;
const [drawerOpen, setDrawerOpen] = useState(false);
const mobileDisconnect = useCallback(() => {
setDrawerOpen(false);
disconnect();
}, [disconnect]);
const openDrawer = useCallback(() => {
if (!isConnected) {
openVegaWalletDialog();
setDrawerOpen(false);
} else {
setDrawerOpen(!drawerOpen);
}
}, [drawerOpen, isConnected, openVegaWalletDialog]);
const iconClass = drawerOpen
? 'hidden'
: isYellow
? 'fill-black'
: 'fill-white';
const [container, setContainer] = useState<HTMLElement | null>(null);
const walletButton = (
<button
className="my-2 transition-all flex flex-col justify-around gap-3 p-2 relative h-[34px]"
onClick={openDrawer}
data-testid="connect-vega-wallet-mobile"
>
<WalletIcon className={iconClass} />
</button>
);
const onSelectItem = useCallback(
(pubkey: string) => {
setDrawerOpen(false);
selectPubKey(pubkey);
},
[selectPubKey]
);
return (
<div className="md:hidden overflow-hidden flex" ref={setContainer}>
<Drawer
dataTestId="wallets-drawer"
open={drawerOpen}
onChange={setDrawerOpen}
container={container}
trigger={walletButton}
>
<div className="border-l border-default p-2 gap-4 flex flex-col w-full h-full bg-white dark:bg-black dark:text-white justify-between">
<div className="flex h-5 justify-end">
<button
className="transition-all flex flex-col justify-around gap-3 p-2 relative h-[34px]"
onClick={() => setDrawerOpen(false)}
data-testid="connect-vega-wallet-mobile-close"
>
<>
<div
className={classNames(
'w-[26px] h-[2px] bg-black dark:bg-white transition-all translate-y-[7.5px] rotate-45',
{
hidden: !drawerOpen,
}
)}
/>
<div
className={classNames(
'w-[26px] h-[2px] bg-black dark:bg-white transition-all -translate-y-[7.5px] -rotate-45',
{
hidden: !drawerOpen,
}
)}
/>
</>
</button>
</div>
<div className="grow my-4" role="list">
{(pubKeys || []).map((pk) => (
<KeypairListItem
key={pk.publicKey}
pk={pk}
isActive={activeKey?.publicKey === pk.publicKey}
onSelectItem={onSelectItem}
/>
))}
</div>
<div className="m-4">
<Button onClick={mobileDisconnect} fill>
{t('Disconnect')}
</Button>
</div>
</div>
</Drawer>
</div>
);
};
export const VegaWalletConnectButton = () => { export const VegaWalletConnectButton = () => {
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const { openVegaWalletDialog } = useVegaWalletDialogStore((store) => ({ const openVegaWalletDialog = useVegaWalletDialogStore(
openVegaWalletDialog: store.openVegaWalletDialog, (store) => store.openVegaWalletDialog
})); );
const { pubKey, pubKeys, selectPubKey, disconnect } = useVegaWallet(); const { pubKey, pubKeys, selectPubKey, disconnect } = useVegaWallet();
const isConnected = pubKey !== null; const isConnected = pubKey !== null;
@ -29,44 +140,55 @@ export const VegaWalletConnectButton = () => {
if (isConnected && pubKeys) { if (isConnected && pubKeys) {
return ( return (
<DropdownMenu open={dropdownOpen}> <>
<DropdownMenuTrigger <div className="hidden md:block">
data-testid="manage-vega-wallet" <DropdownMenu open={dropdownOpen}>
onClick={() => setDropdownOpen((curr) => !curr)} <DropdownMenuTrigger
> data-testid="manage-vega-wallet"
{activeKey && <span className="uppercase">{activeKey.name}</span>} onClick={() => setDropdownOpen((curr) => !curr)}
{': '}
{truncateByChars(pubKey)}
</DropdownMenuTrigger>
<DropdownMenuContent onInteractOutside={() => setDropdownOpen(false)}>
<div className="min-w-[340px]" data-testid="keypair-list">
<DropdownMenuRadioGroup
value={pubKey}
onValueChange={(value) => {
selectPubKey(value);
}}
> >
{pubKeys.map((pk) => ( {activeKey && <span className="uppercase">{activeKey.name}</span>}
<KeypairItem key={pk.publicKey} pk={pk} /> {': '}
))} {truncateByChars(pubKey)}
</DropdownMenuRadioGroup> </DropdownMenuTrigger>
<DropdownMenuItem data-testid="disconnect" onClick={disconnect}> <DropdownMenuContent
{t('Disconnect')} onInteractOutside={() => setDropdownOpen(false)}
</DropdownMenuItem> >
</div> <div className="min-w-[340px]" data-testid="keypair-list">
</DropdownMenuContent> <DropdownMenuRadioGroup
</DropdownMenu> value={pubKey}
onValueChange={(value) => {
selectPubKey(value);
}}
>
{pubKeys.map((pk) => (
<KeypairItem key={pk.publicKey} pk={pk} />
))}
</DropdownMenuRadioGroup>
<DropdownMenuItem data-testid="disconnect" onClick={disconnect}>
{t('Disconnect')}
</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
<MobileWalletButton isConnected activeKey={activeKey} />
</>
); );
} }
return ( return (
<Button <>
data-testid="connect-vega-wallet" <Button
onClick={openVegaWalletDialog} data-testid="connect-vega-wallet"
size="sm" onClick={openVegaWalletDialog}
> size="sm"
<span className="whitespace-nowrap">{t('Connect Vega wallet')}</span> className="hidden md:block"
</Button> >
<span className="whitespace-nowrap">{t('Connect Vega wallet')}</span>
</Button>
<MobileWalletButton />
</>
); );
}; };
@ -115,3 +237,58 @@ const KeypairItem = ({ pk }: { pk: PubKey }) => {
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
); );
}; };
const KeypairListItem = ({
pk,
isActive,
onSelectItem,
}: {
pk: PubKey;
isActive: boolean;
onSelectItem: (pk: string) => void;
}) => {
const [copied, setCopied] = useState(false);
useEffect(() => {
// eslint-disable-next-line
let timeout: any;
if (copied) {
timeout = setTimeout(() => {
setCopied(false);
}, 800);
}
return () => {
clearTimeout(timeout);
};
}, [copied]);
return (
<div
className="flex flex-col w-full ml-4 mr-2 mb-4"
data-testid={`key-${pk.publicKey}-mobile`}
>
<span className="mr-2">
<button onClick={() => onSelectItem(pk.publicKey)}>
<span className="uppercase">{pk.name}</span>
</button>
{isActive && <Icon name="tick" className="ml-2" />}
</span>
<span className="text-neutral-500 dark:text-neutral-400">
{truncateByChars(pk.publicKey)}{' '}
<CopyToClipboard text={pk.publicKey} onCopy={() => setCopied(true)}>
<button
data-testid="copy-vega-public-key"
onClick={(e) => e.stopPropagation()}
>
<span className="sr-only">{t('Copy')}</span>
<Icon name="duplicate" className="mr-2" />
</button>
</CopyToClipboard>
{copied && (
<span className="text-xs text-neutral-500">{t('Copied')}</span>
)}
</span>
</div>
);
};

View File

@ -5,7 +5,7 @@ declare global {
namespace Cypress { namespace Cypress {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Chainable<Subject> { interface Chainable<Subject> {
connectVegaWallet(): void; connectVegaWallet(isMobile?: boolean): void;
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Chainable<Subject> { interface Chainable<Subject> {
@ -25,10 +25,12 @@ export const mockConnectWallet = () => {
}; };
export function addVegaWalletConnect() { export function addVegaWalletConnect() {
Cypress.Commands.add('connectVegaWallet', () => { Cypress.Commands.add('connectVegaWallet', (isMobile) => {
mockConnectWallet(); mockConnectWallet();
cy.highlight(`Connecting Vega Wallet`); cy.highlight(`Connecting Vega Wallet`);
cy.get('[data-testid=connect-vega-wallet]').click(); cy.get(
`[data-testid=connect-vega-wallet${isMobile ? '-mobile' : ''}]`
).click();
cy.get('[data-testid=connectors-list]') cy.get('[data-testid=connectors-list]')
.find('[data-testid="connector-jsonRpc"]') .find('[data-testid="connector-jsonRpc"]')
.click(); .click();

View File

@ -1,4 +1,4 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useRef } from 'react';
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
import { import {
Link, Link,
@ -93,11 +93,20 @@ export const NetworkSwitcher = () => {
}, },
[setOpen, setAdvancedView] [setOpen, setAdvancedView]
); );
const menuRef = useRef<HTMLButtonElement | null>(null);
return ( return (
<DropdownMenu open={isOpen} onOpenChange={handleOpen}> <DropdownMenu open={isOpen} onOpenChange={handleOpen}>
<DropdownMenuTrigger>{envTriggerMapping[VEGA_ENV]}</DropdownMenuTrigger> <DropdownMenuTrigger
<DropdownMenuContent align="start"> ref={menuRef}
className="w-full flex justify-between items-center"
>
{envTriggerMapping[VEGA_ENV]}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
style={{ minWidth: `${menuRef.current?.offsetWidth || 290}px` }}
>
{!isAdvancedView && ( {!isAdvancedView && (
<> <>
{standardNetworkKeys.map((key) => ( {standardNetworkKeys.map((key) => (
@ -121,6 +130,7 @@ export const NetworkSwitcher = () => {
e.stopPropagation(); e.stopPropagation();
setAdvancedView(true); setAdvancedView(true);
}} }}
className="w-full flex flex-col items-stretch"
> >
{t('Advanced')} {t('Advanced')}
</DropdownMenuItem> </DropdownMenuItem>
@ -137,7 +147,10 @@ export const NetworkSwitcher = () => {
isAvailable={!!VEGA_NETWORKS[key]} isAvailable={!!VEGA_NETWORKS[key]}
/> />
</div> </div>
<span data-testid="network-item-description"> <span
className="hidden md:inline"
data-testid="network-item-description"
>
{envDescriptionMapping[key]} {envDescriptionMapping[key]}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>

View File

@ -16,6 +16,7 @@ interface DialogProps {
icon?: ReactNode; icon?: ReactNode;
intent?: Intent; intent?: Intent;
size?: 'small' | 'medium'; size?: 'small' | 'medium';
dataTestId?: string;
} }
export function Dialog({ export function Dialog({
@ -27,6 +28,7 @@ export function Dialog({
icon, icon,
intent, intent,
size = 'small', size = 'small',
dataTestId = 'dialog-content',
}: DialogProps) { }: DialogProps) {
const contentClasses = classNames( const contentClasses = classNames(
'fixed top-0 left-0 z-20 flex justify-center items-start overflow-auto', 'fixed top-0 left-0 z-20 flex justify-center items-start overflow-auto',
@ -64,6 +66,7 @@ export function Dialog({
<DialogPrimitives.Content <DialogPrimitives.Content
className={contentClasses} className={contentClasses}
onCloseAutoFocus={onCloseAutoFocus} onCloseAutoFocus={onCloseAutoFocus}
data-testid={dataTestId}
> >
<div className={wrapperClasses}> <div className={wrapperClasses}>
{onChange && ( {onChange && (

View File

@ -0,0 +1,38 @@
import type { ComponentStory, ComponentMeta } from '@storybook/react';
import { Drawer } from './drawer';
import React, { useState } from 'react';
import { Button } from '../button';
export default {
title: 'Drawer',
component: Drawer,
} as ComponentMeta<typeof Drawer>;
const Template: ComponentStory<typeof Drawer> = (args) => {
const [open, setOpen] = useState(args.open);
const [container, setContainer] = useState<HTMLElement | null>(null);
const openButton = <Button onClick={() => setOpen(true)}>Open drawer</Button>;
return (
<div ref={setContainer}>
<Drawer
{...args}
open={open}
onChange={setOpen}
container={container}
trigger={openButton}
>
{args.children}
</Drawer>
</div>
);
};
export const Default = Template.bind({});
Default.args = {
open: false,
children: (
<p className="h-full bg-black dark:bg-white text-white dark:text-black">
Some content
</p>
),
};

View File

@ -0,0 +1,74 @@
import type { ReactNode } from 'react';
import classNames from 'classnames';
import * as DialogPrimitives from '@radix-ui/react-dialog';
import { Icon } from '../icon';
interface DrawerProps {
children: ReactNode;
open: boolean;
onChange?: (isOpen: boolean) => void;
onCloseAutoFocus?: (e: Event) => void;
container?: HTMLElement | null;
dataTestId?: string;
className?: string;
showClose?: boolean;
trigger?: ReactNode;
}
export function Drawer({
children,
open,
onChange,
onCloseAutoFocus,
dataTestId = 'drawer-content',
container,
className = '',
showClose,
trigger,
}: DrawerProps) {
const contentClasses = classNames(
'h-full max-w-[500px] w-[90vw] z-10 top-0 right-0 fixed transition-transform',
className,
{
'translate-x-[100%]': !open,
'translate-x-0 z-40': open,
}
);
const overlayClass = classNames('inset-0 bg-black/50 z-20', {
hidden: !open,
fixed: open,
});
return (
<DialogPrimitives.Root
open={open}
onOpenChange={(x) => onChange?.(x)}
modal={false}
>
<DialogPrimitives.Trigger asChild>{trigger}</DialogPrimitives.Trigger>
<DialogPrimitives.Portal forceMount container={container}>
<DialogPrimitives.Overlay
className={overlayClass}
data-testid="dialog-overlay"
/>
<DialogPrimitives.Content
className={contentClasses}
onCloseAutoFocus={onCloseAutoFocus}
data-testid={dataTestId}
forceMount
>
{showClose && onChange && (
<DialogPrimitives.Close
className="absolute p-2 top-0 right-0 md:top-2 md:right-2"
data-testid="drawer-close"
>
<Icon name="cross" />
</DialogPrimitives.Close>
)}
{children}
</DialogPrimitives.Content>
</DialogPrimitives.Portal>
</DialogPrimitives.Root>
);
}

View File

@ -0,0 +1 @@
export * from './drawer';

View File

@ -8,6 +8,7 @@ export * from './callout';
export * from './checkbox'; export * from './checkbox';
export * from './copy-with-tooltip'; export * from './copy-with-tooltip';
export * from './dialog'; export * from './dialog';
export * from './drawer';
export * from './dropdown-menu'; export * from './dropdown-menu';
export * from './form-group'; export * from './form-group';
export * from './icon'; export * from './icon';

View File

@ -15,13 +15,15 @@ export const getActiveNavLinkClassNames = (
navbarTheme: string, navbarTheme: string,
fullWidth = false fullWidth = false
): string | undefined => { ): string | undefined => {
return classNames('mx-2 py-3 self-end relative', { return classNames('mx-2 my-4 md:my-0 md:py-3 self-start relative', {
'cursor-default': isActive, 'cursor-default': isActive,
'text-black dark:text-white': isActive && navbarTheme !== 'yellow', 'text-black dark:text-white': isActive && navbarTheme !== 'yellow',
'text-neutral-500 dark:text-neutral-400 hover:text-black dark:hover:text-neutral-300': 'text-neutral-500 dark:text-neutral-400 hover:text-black dark:hover:text-neutral-300':
!isActive && navbarTheme !== 'yellow', !isActive && navbarTheme !== 'yellow',
'text-black': isActive && navbarTheme === 'yellow', 'text-black dark:text-white md:dark:text-black':
'text-black/60 hover:text-black': !isActive && navbarTheme === 'yellow', isActive && navbarTheme === 'yellow',
'text-black/60 dark:text-neutral-400 md:dark:text-black/60 hover:text-black':
!isActive && navbarTheme === 'yellow',
'flex-1': fullWidth, 'flex-1': fullWidth,
}); });
}; };

View File

@ -1,9 +1,16 @@
import { useThemeSwitcher } from '@vegaprotocol/react-helpers'; import { useThemeSwitcher, t } from '@vegaprotocol/react-helpers';
import { SunIcon, MoonIcon } from './icons'; import { SunIcon, MoonIcon } from './icons';
import { Toggle } from '../toggle';
export const ThemeSwitcher = ({ className }: { className?: string }) => { export const ThemeSwitcher = ({
className,
withMobile,
}: {
className?: string;
withMobile?: boolean;
}) => {
const { theme, setTheme } = useThemeSwitcher(); const { theme, setTheme } = useThemeSwitcher();
return ( const button = (
<button <button
type="button" type="button"
onClick={() => setTheme()} onClick={() => setTheme()}
@ -14,4 +21,31 @@ export const ThemeSwitcher = ({ className }: { className?: string }) => {
{theme === 'light' && <MoonIcon />} {theme === 'light' && <MoonIcon />}
</button> </button>
); );
const toggles = [
{
value: 'dark',
label: t('Dark mode'),
},
{
value: 'light',
label: t('Light mode'),
},
];
return withMobile ? (
<>
<div className="flex grow gap-6 md:hidden whitespace-nowrap justify-between">
{button}{' '}
<Toggle
name="theme-switch"
checkedValue={theme}
toggles={toggles}
onChange={() => setTheme()}
size="sm"
/>
</div>
<div className="hidden md:block">{button}</div>
</>
) : (
button
);
}; };

View File

@ -14,6 +14,7 @@ export interface ToggleProps {
onChange?: (e: ChangeEvent<HTMLInputElement>) => void; onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
checkedValue?: string | undefined | null; checkedValue?: string | undefined | null;
type?: 'primary' | 'buy' | 'sell'; type?: 'primary' | 'buy' | 'sell';
size?: 'sm' | 'md' | 'lg';
} }
export const Toggle = ({ export const Toggle = ({
@ -23,6 +24,7 @@ export const Toggle = ({
onChange, onChange,
checkedValue, checkedValue,
type = 'primary', type = 'primary',
size = 'lg',
...props ...props
}: ToggleProps) => { }: ToggleProps) => {
const fieldsetClasses = const fieldsetClasses =
@ -35,7 +37,6 @@ export const Toggle = ({
const buttonClasses = classnames( const buttonClasses = classnames(
'relative inline-block w-full text-center', 'relative inline-block w-full text-center',
'peer-checked:rounded-full', 'peer-checked:rounded-full',
'px-10 py-2',
{ {
'peer-checked:bg-neutral-400 dark:peer-checked:bg-white dark:peer-checked:text-black': 'peer-checked:bg-neutral-400 dark:peer-checked:bg-white dark:peer-checked:text-black':
type === 'primary', type === 'primary',
@ -45,7 +46,12 @@ export const Toggle = ({
type === 'sell', type === 'sell',
}, },
'peer-checked:text-white dark:peer-checked:text-black', 'peer-checked:text-white dark:peer-checked:text-black',
'cursor-pointer peer-checked:cursor-auto select-none' 'cursor-pointer peer-checked:cursor-auto select-none',
{
'px-10 py-2': size === 'lg',
'px-8 py-2': size === 'md',
'px-6 py-2': size === 'sm',
}
); );
return ( return (