From 66b62c0de0d3aebece8b0e2e1c4a6f1c60047b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20Longoni?= Date: Wed, 14 Sep 2022 13:14:06 -0300 Subject: [PATCH] New Menu (#186) * add basic menu * fix storybook * add menu content * add menu and links styles * mobile improvements * mobile menu * navbar improvements * Fix conflict with styled-components * add icons and remove old menu * delete menu * toogle styles * show logo on mobile and fix ui issues * mobile improvements * add dark bg color * fix routes and constants part 1 * const error fix * change links to routes fromo const * remove const folder - to be refactored in a new PR * menu logic * fix husky error * menu logic * fix menu color on mobile * Fix navbar * icons tets * Convert file to .tsx * Add icon type * Fix icon type * Fix network redirect * fix style home link on mobile * feedback point 5 - mobile links * Display icons on menu dropdown * Fix problems with typing * Move menuContainer * Fix clickoutside * Fix when clickCloseMenuMobile, unify screenSizes mediaqueries * Remove escaped log * Fix close mobile menu click when click on Logo * Fix hide mobile mene when clicking on an internal Link * add icons to the menu * change logo animation as we have on CoWSwap * add external link icon * fix logo animation * fix cow icon size * remove svg styles * removed internal link attributes * feddback 2. Removed logo animation on mobile. * header improvement * remove globally touch highlight color effect * feedback 5: fix scrollbar on mobile * feedback 4: fix menu on medium and desktop * menu: App Data -> AppData * delete commented code * fix logo on mobile avoid opening the menu * feedback 3: landscape mode on mobile * menu item height * code improvements * fix storybook * fix dropdown stories * add MainMenu storybook * Moving MenuIcon inside MenuTree component, addin mobile behavior to the storybook * fix menu alignment on mobile * change dropdown component storybook * change component name Co-authored-by: Henry Palacios Co-authored-by: Mati Dastugue --- src/apps/explorer/const.ts | 18 ++ src/apps/explorer/layout/Header.tsx | 75 ++--- src/apps/explorer/styled.ts | 1 + src/assets/img/CowProtocol-logo.svg | 7 + src/assets/img/carret-down.svg | 3 + src/assets/img/code.svg | 4 + src/assets/img/discord.svg | 3 + src/assets/img/doc.svg | 4 + src/assets/img/info.svg | 3 + src/assets/img/pie.svg | 3 + .../common/LinkWithPrefixNetwork/index.tsx | 10 +- .../MenuDropdown/InternalExternalLink.tsx | 51 ++++ .../MenuDropdown/MainMenuTree.stories.tsx | 109 +++++++ .../MenuDropdown/MenuDropdownItem.stories.tsx | 94 ++++++ .../common/MenuDropdown/MenuTree.tsx | 52 ++++ .../MenuDropdown/MobileMenuIcon/index.tsx | 81 +++++ src/components/common/MenuDropdown/index.tsx | 70 +++++ .../common/MenuDropdown/mainMenu.ts | 65 ++++ src/components/common/MenuDropdown/styled.ts | 286 ++++++++++++++++++ src/components/common/MenuDropdown/types.ts | 40 +++ .../layout/GenericLayout/Header/index.tsx | 14 +- .../layout/GenericLayout/variablesCss.ts | 1 + src/theme/styles/colours.ts | 3 + src/utils/mediaQueries.ts | 16 +- src/utils/toggleBodyClass.ts | 15 + 25 files changed, 970 insertions(+), 58 deletions(-) create mode 100644 src/assets/img/CowProtocol-logo.svg create mode 100644 src/assets/img/carret-down.svg create mode 100644 src/assets/img/code.svg create mode 100644 src/assets/img/discord.svg create mode 100644 src/assets/img/doc.svg create mode 100644 src/assets/img/info.svg create mode 100644 src/assets/img/pie.svg create mode 100644 src/components/common/MenuDropdown/InternalExternalLink.tsx create mode 100644 src/components/common/MenuDropdown/MainMenuTree.stories.tsx create mode 100644 src/components/common/MenuDropdown/MenuDropdownItem.stories.tsx create mode 100644 src/components/common/MenuDropdown/MenuTree.tsx create mode 100644 src/components/common/MenuDropdown/MobileMenuIcon/index.tsx create mode 100644 src/components/common/MenuDropdown/index.tsx create mode 100644 src/components/common/MenuDropdown/mainMenu.ts create mode 100644 src/components/common/MenuDropdown/styled.ts create mode 100644 src/components/common/MenuDropdown/types.ts create mode 100644 src/utils/toggleBodyClass.ts diff --git a/src/apps/explorer/const.ts b/src/apps/explorer/const.ts index 6b5838eff..9865e17dd 100644 --- a/src/apps/explorer/const.ts +++ b/src/apps/explorer/const.ts @@ -28,3 +28,21 @@ export const NETWORK_ID_SEARCH_LIST = [Network.MAINNET, Network.GNOSIS_CHAIN, Ne export const HEIGHT_HEADER_FOOTER = 257 export const TOKEN_SYMBOL_UNKNOWN = 'UNKNOWN' + +// Routes and Links +export enum Routes { + HOME = '/', + APPDATA = '/appdata', +} + +const GITHUB_REPOSITORY = 'cowprotocol/explorer' +export const CODE_LINK = 'https://github.com/' + GITHUB_REPOSITORY +export const RAW_CODE_LINK = 'https://raw.githubusercontent.com/' + GITHUB_REPOSITORY +export const DOCS_LINK = 'https://docs.cow.fi' +export const PROTOCOL_LINK = 'https://cow.fi' +export const CONTRACTS_CODE_LINK = 'https://github.com/cowprotocol/contracts' +export const DISCORD_LINK = 'https://discord.gg/cowprotocol' +export const DUNE_DASHBOARD_LINK = 'https://dune.com/gnosis.protocol/Gnosis-Protocol-V2' +export const TWITTER_LINK = 'https://twitter.com/CoWSwap' +export const COWWIKI_LINK = 'https://en.wikipedia.org/wiki/Coincidence_of_wants' +export const GNOSIS_FORUM_ROADTODECENT_LINK = 'https://forum.gnosis.io/t/gpv2-road-to-decentralization/1245' diff --git a/src/apps/explorer/layout/Header.tsx b/src/apps/explorer/layout/Header.tsx index 38f003086..ac05ea0e4 100644 --- a/src/apps/explorer/layout/Header.tsx +++ b/src/apps/explorer/layout/Header.tsx @@ -1,22 +1,26 @@ -import React, { useState, createRef } from 'react' +import React, { useState, useCallback, useEffect } from 'react' -import { MenuBarToggle, Navigation } from 'components/layout/GenericLayout/Navigation' import { Header as GenericHeader } from 'components/layout/GenericLayout/Header' import { NetworkSelector } from 'components/NetworkSelector' import { PREFIX_BY_NETWORK_ID, useNetworkId } from 'state/network' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faEllipsisH, faTimes } from '@fortawesome/free-solid-svg-icons' import { FlexWrap } from 'apps/explorer/pages/styled' -import { ExternalLink } from 'components/analytics/ExternalLink' -import { useHistory } from 'react-router' -import useOnClickOutside from 'hooks/useOnClickOutside' -import { APP_NAME } from 'const' +import { useMediaBreakpoint } from 'hooks/useMediaBreakPoint' +import { MenuTree } from 'components/common/MenuDropdown/MenuTree' +import { addBodyClass, removeBodyClass } from 'utils/toggleBodyClass' export const Header: React.FC = () => { - const history = useHistory() - const [isBarActive, setBarActive] = useState(false) - const flexWrapDivRef = createRef() - useOnClickOutside(flexWrapDivRef, () => isBarActive && setBarActive(false)) + const isMobile = useMediaBreakpoint(['xs', 'sm']) + const [isMobileMenuOpen, setMobileMenuOpen] = useState(false) + + // Toggle the 'noScroll' class on body, whenever the mobile menu or orders panel is open. + // This removes the inner scrollbar on the page body, to prevent showing double scrollbars. + useEffect(() => { + isMobileMenuOpen ? addBodyClass('noScroll') : removeBodyClass('noScroll') + }, [isMobileMenuOpen, isMobile]) + + const handleMobileMenuOnClick = useCallback(() => { + isMobile && setMobileMenuOpen((isMobileMenuOpen) => !isMobileMenuOpen) + }, [isMobile]) const networkId = useNetworkId() if (!networkId) { @@ -25,44 +29,19 @@ export const Header: React.FC = () => { const prefixNetwork = PREFIX_BY_NETWORK_ID.get(networkId) - const handleNavigate = (e: React.MouseEvent): void => { - e.preventDefault() - setBarActive(false) - history.push(`/${prefixNetwork || ''}`) - } - return ( - + - - setBarActive(!isBarActive)}> - - - -
  • - handleNavigate(e)}>Home -
  • -
  • - - {APP_NAME} - -
  • -
  • - - Documentation - -
  • -
  • - - Community - -
  • -
  • - - Analytics - -
  • -
    + +
    ) diff --git a/src/apps/explorer/styled.ts b/src/apps/explorer/styled.ts index e6e968cd6..fc57e542c 100644 --- a/src/apps/explorer/styled.ts +++ b/src/apps/explorer/styled.ts @@ -19,6 +19,7 @@ export const ScrollBarStyle = css` export const GlobalStyle = createGlobalStyle` html { height: 100%; + -webkit-tap-highlight-color: transparent; } html, body, diff --git a/src/assets/img/CowProtocol-logo.svg b/src/assets/img/CowProtocol-logo.svg new file mode 100644 index 000000000..661aeeaf1 --- /dev/null +++ b/src/assets/img/CowProtocol-logo.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/assets/img/carret-down.svg b/src/assets/img/carret-down.svg new file mode 100644 index 000000000..1831affe5 --- /dev/null +++ b/src/assets/img/carret-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/img/code.svg b/src/assets/img/code.svg new file mode 100644 index 000000000..2ced6ce64 --- /dev/null +++ b/src/assets/img/code.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/img/discord.svg b/src/assets/img/discord.svg new file mode 100644 index 000000000..734c76598 --- /dev/null +++ b/src/assets/img/discord.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/img/doc.svg b/src/assets/img/doc.svg new file mode 100644 index 000000000..673e82bb9 --- /dev/null +++ b/src/assets/img/doc.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/img/info.svg b/src/assets/img/info.svg new file mode 100644 index 000000000..3bc8b57bf --- /dev/null +++ b/src/assets/img/info.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/img/pie.svg b/src/assets/img/pie.svg new file mode 100644 index 000000000..b6c9dcf6d --- /dev/null +++ b/src/assets/img/pie.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/common/LinkWithPrefixNetwork/index.tsx b/src/components/common/LinkWithPrefixNetwork/index.tsx index 413a320e3..44011e696 100644 --- a/src/components/common/LinkWithPrefixNetwork/index.tsx +++ b/src/components/common/LinkWithPrefixNetwork/index.tsx @@ -3,13 +3,17 @@ import { Link, LinkProps } from 'react-router-dom' import { usePathPrefix } from 'state/network' -export function LinkWithPrefixNetwork(props: LinkProps): JSX.Element { - const { to, children, ...otherParams } = props +interface LinkWithPrefixProps extends LinkProps { + onClickOptional?: React.MouseEventHandler +} + +export function LinkWithPrefixNetwork(props: LinkWithPrefixProps): JSX.Element { + const { to, children, onClickOptional, ...otherParams } = props const prefix = usePathPrefix() const _to = prefix ? `/${prefix}${to}` : to return ( - + onClickOptional && onClickOptional(event)} {...otherParams}> {children} ) diff --git a/src/components/common/MenuDropdown/InternalExternalLink.tsx b/src/components/common/MenuDropdown/InternalExternalLink.tsx new file mode 100644 index 000000000..e2294759a --- /dev/null +++ b/src/components/common/MenuDropdown/InternalExternalLink.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import SVG from 'react-inlinesvg' +import { StyledIcon } from 'components/common/MenuDropdown/styled' +import { ExternalLink } from 'components/analytics/ExternalLink' +import { faExternalLink } from '@fortawesome/free-solid-svg-icons' +import { LinkWithPrefixNetwork } from 'components/common/LinkWithPrefixNetwork' +import { MenuImageProps, MenuItemKind, MenuLink } from './types' + +function MenuImage(props: MenuImageProps): JSX.Element | null { + const { title, iconSVG, icon } = props + + if (iconSVG) { + return + } else if (icon) { + return {`${title} + } else { + return null + } +} + +interface InternalExternalLinkProps { + link: MenuLink + handleMobileMenuOnClick?: () => void +} + +export default function InternalExternalMenuLink({ + link, + handleMobileMenuOnClick, +}: InternalExternalLinkProps): JSX.Element { + const { kind, title, url, iconSVG, icon } = link + const menuImage = + const menuImageExternal = + const isExternal = kind === MenuItemKind.EXTERNAL_LINK + + if (isExternal) { + return ( + + {menuImage} + {title} + {menuImageExternal} + + ) + } else { + return ( + + {menuImage} + {title} + + ) + } +} diff --git a/src/components/common/MenuDropdown/MainMenuTree.stories.tsx b/src/components/common/MenuDropdown/MainMenuTree.stories.tsx new file mode 100644 index 000000000..69652bdf5 --- /dev/null +++ b/src/components/common/MenuDropdown/MainMenuTree.stories.tsx @@ -0,0 +1,109 @@ +import React, { Dispatch, SetStateAction, useState } from 'react' +import { Story, Meta } from '@storybook/react/types-6-0' + +import { GlobalStyles, ThemeToggler, Router } from 'storybook/decorators' + +import { useMediaBreakpoint } from 'hooks/useMediaBreakPoint' +import { MenuTree, MenuTreeProps } from 'components/common/MenuDropdown/MenuTree' +import { MenuItemKind, MenuTreeItem } from './types' + +import { DOCS_LINK, DISCORD_LINK, PROTOCOL_LINK, DUNE_DASHBOARD_LINK, Routes } from 'apps/explorer/const' +import IMAGE_COW from 'assets/img/CowProtocol-logo.svg' +import IMAGE_DISCORD from 'assets/img/discord.svg' +import IMAGE_DOC from 'assets/img/doc.svg' +import IMAGE_ANALYTICS from 'assets/img/pie.svg' +import IMAGE_APPDATA from 'assets/img/code.svg' + +export default { + title: 'Common/Menu', + component: MenuTree, + decorators: [Router, GlobalStyles, ThemeToggler], +} as Meta + +const DropdownMenu: MenuTreeItem[] = [ + { + title: 'Home', + url: Routes.HOME, + }, + { + kind: MenuItemKind.DROP_DOWN, + title: 'More', + items: [ + { + sectionTitle: 'OVERVIEW', + links: [ + { + title: 'CoW Protocol', + url: PROTOCOL_LINK, + kind: MenuItemKind.EXTERNAL_LINK, + iconSVG: IMAGE_COW, + }, + { + title: 'Documentation', + url: DOCS_LINK, + kind: MenuItemKind.EXTERNAL_LINK, + iconSVG: IMAGE_DOC, + }, + { + title: 'Analytics', + url: DUNE_DASHBOARD_LINK, + kind: MenuItemKind.EXTERNAL_LINK, + iconSVG: IMAGE_ANALYTICS, + }, + ], + }, + { + sectionTitle: 'COMMUNITY', + links: [ + { + title: 'Discord', + url: DISCORD_LINK, + iconSVG: IMAGE_DISCORD, // If icon is a inline component + kind: MenuItemKind.EXTERNAL_LINK, + }, + ], + }, + { + sectionTitle: 'OTHER', + links: [ + { + title: 'AppData', + url: Routes.APPDATA, + iconSVG: IMAGE_APPDATA, + }, + ], + }, + ], + }, +] + +const useMobileMenuOpen = (): { + isMobileMenuOpen: boolean + handleMobileMenuOnClick: Dispatch> +} => { + const [isMobileMenuOpen, handleMobileMenuOnClick] = useState(false) + return { isMobileMenuOpen, handleMobileMenuOnClick } +} + +const Template: Story = (args) => { + const context = useMobileMenuOpen() + const isMobile = useMediaBreakpoint(['xs', 'sm']) + + return ( + context.handleMobileMenuOnClick((prevState) => !prevState)} + /> + ) +} + +const defaultProps: Omit = { + menuList: DropdownMenu, +} + +export const MainMenu = Template.bind({}) +MainMenu.args = { + ...defaultProps, +} diff --git a/src/components/common/MenuDropdown/MenuDropdownItem.stories.tsx b/src/components/common/MenuDropdown/MenuDropdownItem.stories.tsx new file mode 100644 index 000000000..b77d44dfa --- /dev/null +++ b/src/components/common/MenuDropdown/MenuDropdownItem.stories.tsx @@ -0,0 +1,94 @@ +import React, { Dispatch, SetStateAction, useState } from 'react' +import { Story, Meta } from '@storybook/react/types-6-0' + +import { GlobalStyles, ThemeToggler, Router } from 'storybook/decorators' + +import MenuDropdown, { DropdownProps } from '.' +import { DropDownItem, MenuItemKind } from './types' + +import { DOCS_LINK, DISCORD_LINK, PROTOCOL_LINK, Routes } from 'apps/explorer/const' +import IMAGE_COW from 'assets/img/CowProtocol-logo.svg' +import IMAGE_DISCORD from 'assets/img/discord.svg' +import IMAGE_DOC from 'assets/img/doc.svg' +import IMAGE_APPDATA from 'assets/img/code.svg' + +export default { + title: 'Common/MenuDropdownItem', + component: MenuDropdown, + decorators: [Router, GlobalStyles, ThemeToggler], +} as Meta + +const DropdownMenu: DropDownItem = { + kind: MenuItemKind.DROP_DOWN, + title: 'Dropdown menu', + items: [ + { + sectionTitle: 'Section 1', + links: [ + { + title: 'Option 1', + url: PROTOCOL_LINK, + kind: MenuItemKind.EXTERNAL_LINK, + iconSVG: IMAGE_COW, + }, + { + title: 'Option 2', + url: DOCS_LINK, + kind: MenuItemKind.EXTERNAL_LINK, + iconSVG: IMAGE_DOC, + }, + ], + }, + { + sectionTitle: 'Section 2', + links: [ + { + title: 'Option 3', + url: DISCORD_LINK, + iconSVG: IMAGE_DISCORD, + kind: MenuItemKind.EXTERNAL_LINK, + }, + ], + }, + { + sectionTitle: 'Section 3', + links: [ + { + title: 'Option 4', + url: Routes.APPDATA, + iconSVG: IMAGE_APPDATA, + }, + ], + }, + ], +} + +const useMobileMenuOpen = (): { + isMobileMenuOpen: boolean + handleMobileMenuOnClick: Dispatch> +} => { + const [isMobileMenuOpen, handleMobileMenuOnClick] = useState(false) + return { isMobileMenuOpen, handleMobileMenuOnClick } +} + +const Template: Story = () => { + const context = useMobileMenuOpen() + return ( + context.handleMobileMenuOnClick((prevState) => !prevState), + }} + /> + ) +} + +const defaultProps: Omit = { + context: { isMobileMenuOpen: false, handleMobileMenuOnClick: () => console.log }, +} + +export const singleDropdown = Template.bind({}) +singleDropdown.args = { + ...defaultProps, +} diff --git a/src/components/common/MenuDropdown/MenuTree.tsx b/src/components/common/MenuDropdown/MenuTree.tsx new file mode 100644 index 000000000..9393bf1e5 --- /dev/null +++ b/src/components/common/MenuDropdown/MenuTree.tsx @@ -0,0 +1,52 @@ +import React from 'react' + +import InternalExternalMenuLink from 'components/common/MenuDropdown/InternalExternalLink' +import { MAIN_MENU } from 'components/common/MenuDropdown/mainMenu' +import { Wrapper, MenuContainer } from 'components/common/MenuDropdown/styled' +import { MenuItemKind, MenuTreeItem } from 'components/common/MenuDropdown/types' +import DropDown from '.' +import MobileMenuIcon from 'components/common/MenuDropdown/MobileMenuIcon' + +interface MenuItemWithDropDownProps { + menuItem: MenuTreeItem + context: MenuTreeProps +} + +function MenuItemWithDropDown(props: MenuItemWithDropDownProps): JSX.Element | null { + const { menuItem, context } = props + + switch (menuItem.kind) { + case MenuItemKind.DROP_DOWN: + return + + case undefined: // INTERNAL + case MenuItemKind.EXTERNAL_LINK: // EXTERNAL + // Render Internal/External links + return + default: + return null + } +} + +export interface MenuTreeProps { + isMobileMenuOpen: boolean + handleMobileMenuOnClick: () => void + menuList?: MenuTreeItem[] + isMobile?: boolean +} + +export function MenuTree(props: MenuTreeProps): JSX.Element { + const { isMobileMenuOpen, handleMobileMenuOnClick, isMobile, menuList = MAIN_MENU } = props + return ( + <> + + + {menuList.map((menuItem, index) => ( + + ))} + + {isMobile && } + + + ) +} diff --git a/src/components/common/MenuDropdown/MobileMenuIcon/index.tsx b/src/components/common/MenuDropdown/MobileMenuIcon/index.tsx new file mode 100644 index 000000000..77899d53b --- /dev/null +++ b/src/components/common/MenuDropdown/MobileMenuIcon/index.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import styled, { css, FlattenSimpleInterpolation } from 'styled-components' +import { media } from 'theme/styles/media' + +const Wrapper = styled.div<{ isMobileMenuOpen: boolean }>` + z-index: 102; + display: flex; + cursor: pointer; + margin: 0 0.6rem 0 1.6rem; + position: relative; + width: 3.4rem; + height: 1.8rem; + ${media.mobile} { + width: 2.8rem; + } + + span { + background-color: ${({ theme }): string => theme.textSecondary1}; + border-radius: 0.3rem; + height: 0.2rem; + position: absolute; + transition: all 0.15s cubic-bezier(0.8, 0.5, 0.2, 1.4); + width: 100%; + margin: auto; + } + + span:nth-child(1) { + left: 0; + top: 0; + ${({ isMobileMenuOpen }): FlattenSimpleInterpolation | null => + isMobileMenuOpen + ? css` + transform: rotate(45deg); + top: 50%; + bottom: 50%; + ` + : null} + } + + span:nth-child(2) { + left: 0; + opacity: 1; + top: 50%; + bottom: 50%; + ${({ isMobileMenuOpen }): FlattenSimpleInterpolation | null => + isMobileMenuOpen + ? css` + opacity: 0; + ` + : null} + } + + span:nth-child(3) { + bottom: 0; + left: 0; + width: 75%; + ${({ isMobileMenuOpen }): FlattenSimpleInterpolation | null => + isMobileMenuOpen + ? css` + transform: rotate(-45deg); + top: 50%; + bottom: 50%; + width: 100%; + ` + : null} + } +` +interface IconProps { + isMobileMenuOpen: boolean + onClick?: () => void +} + +export default function MobileMenuIcon(params: IconProps): JSX.Element { + return ( + + + + + + ) +} diff --git a/src/components/common/MenuDropdown/index.tsx b/src/components/common/MenuDropdown/index.tsx new file mode 100644 index 000000000..4f5342b83 --- /dev/null +++ b/src/components/common/MenuDropdown/index.tsx @@ -0,0 +1,70 @@ +import React, { useState, createRef } from 'react' +import { MenuFlyout, Content, MenuSection, MenuTitle } from 'components/common/MenuDropdown/styled' +import IMAGE_CARRET_DOWN from 'assets/img/carret-down.svg' +import SVG from 'react-inlinesvg' +import { useMediaBreakpoint } from 'hooks/useMediaBreakPoint' +import useOnClickOutside from 'hooks/useOnClickOutside' +import { DropDownItem, MenuItemKind } from './types' +import InternalExternalMenuLink from './InternalExternalLink' +import { MenuTreeProps } from './MenuTree' + +interface MenuProps { + title: string + children: React.ReactNode + isMobileMenuOpen?: boolean + showDropdown?: boolean + url?: string +} + +export function MenuItemsPanel({ title, children }: MenuProps): JSX.Element { + const isLargeAndUp = useMediaBreakpoint(['lg', 'xl']) + const node = createRef() + const [showMenu, setShowMenu] = useState(false) + + const handleOnClick = (): void => { + setShowMenu((showMenu) => !showMenu) + } + + useOnClickOutside(node, () => isLargeAndUp && setShowMenu(false)) // only trigger on large screens + + return ( + + + {showMenu && {children}} + + ) +} + +export interface DropdownProps { + menuItem: DropDownItem + context: Omit +} + +export const DropDown = ({ menuItem, context }: DropdownProps): JSX.Element => { + const { isMobileMenuOpen, handleMobileMenuOnClick } = context + + return ( + + {menuItem.items.map((item, index) => { + const { sectionTitle, links } = item + return ( + + {sectionTitle && {sectionTitle}} + {links.map((link, linkIndex) => ( + + ))} + + ) + })} + + ) +} + +export default DropDown diff --git a/src/components/common/MenuDropdown/mainMenu.ts b/src/components/common/MenuDropdown/mainMenu.ts new file mode 100644 index 000000000..0d154fd93 --- /dev/null +++ b/src/components/common/MenuDropdown/mainMenu.ts @@ -0,0 +1,65 @@ +import { DOCS_LINK, DISCORD_LINK, PROTOCOL_LINK, DUNE_DASHBOARD_LINK, Routes } from 'apps/explorer/const' +import IMAGE_COW from 'assets/img/CowProtocol-logo.svg' +import IMAGE_DISCORD from 'assets/img/discord.svg' +import IMAGE_DOC from 'assets/img/doc.svg' +import IMAGE_ANALYTICS from 'assets/img/pie.svg' +import IMAGE_APPDATA from 'assets/img/code.svg' + +import { MenuItemKind, MenuTreeItem } from './types' + +export const MAIN_MENU: MenuTreeItem[] = [ + { + title: 'Home', + url: Routes.HOME, + }, + { + kind: MenuItemKind.DROP_DOWN, + title: 'More', + items: [ + { + sectionTitle: 'OVERVIEW', + links: [ + { + title: 'CoW Protocol', + url: PROTOCOL_LINK, + kind: MenuItemKind.EXTERNAL_LINK, + iconSVG: IMAGE_COW, + }, + { + title: 'Documentation', + url: DOCS_LINK, + kind: MenuItemKind.EXTERNAL_LINK, + iconSVG: IMAGE_DOC, + }, + { + title: 'Analytics', + url: DUNE_DASHBOARD_LINK, + kind: MenuItemKind.EXTERNAL_LINK, + iconSVG: IMAGE_ANALYTICS, + }, + ], + }, + { + sectionTitle: 'COMMUNITY', + links: [ + { + title: 'Discord', + url: DISCORD_LINK, + iconSVG: IMAGE_DISCORD, // If icon is a inline component + kind: MenuItemKind.EXTERNAL_LINK, + }, + ], + }, + { + sectionTitle: 'OTHER', + links: [ + { + title: 'AppData', + url: Routes.APPDATA, + iconSVG: IMAGE_APPDATA, + }, + ], + }, + ], + }, +] diff --git a/src/components/common/MenuDropdown/styled.ts b/src/components/common/MenuDropdown/styled.ts new file mode 100644 index 000000000..437818808 --- /dev/null +++ b/src/components/common/MenuDropdown/styled.ts @@ -0,0 +1,286 @@ +import styled, { css, FlattenSimpleInterpolation } from 'styled-components' +import { media } from 'theme/styles/media' +import Icon from 'components/Icon' + +export const Wrapper = styled.div<{ isMobileMenuOpen: boolean }>` + width: 100%; + display: flex; + justify-content: flex-end; + .mobile-menu { + background: ${({ theme }): string => theme.bg4}; + min-height: 100vh; + display: flex; + flex-direction: column; + flex-wrap: wrap; + justify-content: flex-start; + align-items: flex-start; + ${media.mediumUp} { + min-height: 0; + flex-direction: inherit; + background: transparent; + } + + ${media.mobile} { + gap: 0; + flex-wrap: nowrap; + } + } + ${media.mobile} { + grid-template-columns: unset; + ${({ isMobileMenuOpen }): FlattenSimpleInterpolation | false => + isMobileMenuOpen && + css` + top: 0; + z-index: 4; + right: 0; + &::before { + content: ''; + width: 100%; + display: flex; + height: 7rem; + background: var(--color-menu-mobile); + position: fixed; + top: 0; + left: 0; + z-index: 101; + } + `} + } +` + +export const MenuContainer = styled.nav` + display: flex; + position: relative; + justify-content: flex-end; + gap: 2rem; + + ${media.mobile}, ${media.mediumOnly} { + width: 100%; + height: 100%; + position: fixed; + flex-direction: column; + flex-wrap: nowrap; + justify-content: flex-start; + align-items: flex-start; + top: 0; + left: 0; + bottom: 0; + z-index: 3; + outline: 0; + padding: 8rem 0.8rem; + overflow: hidden auto; + display: none; + } + + a { + font-size: 1.6rem; + font-weight: 600; + appearance: none; + outline: 0; + border-radius: 1.6rem; + padding: 1.1rem 1.2rem; + cursor: pointer; + background: transparent; + transition: background 0.15s ease-in-out 0s, color 0.15s ease-in-out 0s; + color: ${({ theme }): string => theme.textSecondary2}; + :hover { + background: ${({ theme }): string => theme.bg2}; + text-decoration: none; + color: ${({ theme }): string => theme.textSecondary1}; + } + ${media.mobile} { + width: 100%; + border-bottom: 0.1rem solid ${({ theme }): string => theme.bg3}; + border-radius: 0; + padding: 2.8rem 1rem; + font-size: 1.8rem; + :hover { + background: none; + } + } + } +` + +export const MenuFlyout = styled.ol` + display: flex; + padding: 0; + margin: 0; + position: relative; + justify-content: flex-end; + + ${media.mobile} { + display: flex; + flex-direction: column; + width: 100%; + } + + > button { + font-size: 1.6rem; + position: relative; + border-radius: 1.6rem; + display: flex; + align-items: center; + font-weight: 600; + appearance: none; + outline: 0; + padding: 0.8rem 1.2rem; + border: 0; + cursor: pointer; + background: transparent; + transition: background 0.15s ease-in-out 0s, color 0.15s ease-in-out 0s; + color: ${({ theme }): string => theme.textSecondary2}; + + ${media.mobile} { + width: 100%; + border-bottom: 0.1rem solid ${({ theme }): string => theme.bg3}; + border-radius: 0; + padding: 2.8rem 1rem; + font-size: 1.8rem; + } + + &.expanded { + border: none; + } + + &:hover { + background: ${({ theme }): string => theme.bg2}; + color: ${({ theme }): string => theme.textSecondary1}; + ${media.mobile} { + background: none; + } + &::after { + content: ''; + display: block; + position: absolute; + height: 1.8rem; + width: 100%; + ${media.desktopLarge} { + content: none; + } + } + } + + > svg { + margin: 0 0 0 0.3rem; + width: 1.6rem; + height: 0.6rem; + object-fit: contain; + ${media.mobile} { + margin: 0 0 0 auto; + height: 1rem; + } + } + + > svg.expanded { + transition: transform 0.3s ease-in-out; + transform: rotate(180deg); + } + + svg > path { + fill: ${({ theme }): string => theme.textSecondary2}; + } + :hover > svg > path { + fill: ${({ theme }): string => theme.textSecondary1}; + } + } +` + +export const Content = styled.div` + display: flex; + position: absolute; + top: 100%; + right: 0; + border-radius: 1.6rem; + background: ${({ theme }): string => theme.bg4}; + box-shadow: 0 1.2rem 1.8rem ${({ theme }): string => theme.bg3}; + padding: 3.2rem; + gap: 6.2rem; + margin: 1.2rem 0 0; + + ${media.mobile} { + box-shadow: none; + background: transparent; + padding: 0; + position: relative; + top: initial; + left: initial; + border-radius: 0; + display: flex; + flex-flow: column wrap; + margin: 1.2rem; + gap: 3.6rem; + } + + > div { + display: flex; + flex-flow: column wrap; + } +` + +export const MenuTitle = styled.b` + font-size: 1.2rem; + text-transform: uppercase; + font-weight: 600; + opacity: 0.75; + letter-spacing: 0.2rem; + display: flex; + margin: 0 0 0.6rem; + color: ${({ theme }): string => theme.textSecondary2}; + ${media.mobile} { + display: none; + } +` + +export const MenuSection = styled.div` + display: flex; + flex-flow: column wrap; + align-items: flex-start; + align-content: flex-start; + justify-content: flex-start; + justify-items: flex-start; + margin: 0; + gap: 2.4rem; + ${media.mobile} { + gap: 3.6rem; + opacity: 0.7; + } + + a, + button { + display: flex; + background: transparent; + appearance: none; + outline: 0; + border: 0; + cursor: pointer; + font-size: 1.5rem; + white-space: nowrap; + font-weight: 500; + margin: 0; + padding: 0; + color: ${({ theme }): string => theme.textSecondary1}; + gap: 1.2rem; + align-items: center; + + &:hover { + text-decoration: underline; + font-weight: 500; + background: transparent; + } + + &.ACTIVE { + font-weight: bold; + } + } + + a > svg > path { + fill: white; + } +` + +export const StyledIcon = styled(Icon)` + background: transparent; + padding: 0; + margin: 0; + opacity: 0.3; +` diff --git a/src/components/common/MenuDropdown/types.ts b/src/components/common/MenuDropdown/types.ts new file mode 100644 index 000000000..548d21e1d --- /dev/null +++ b/src/components/common/MenuDropdown/types.ts @@ -0,0 +1,40 @@ +export interface BasicMenuLink { + title: string + url: string + icon?: string // If icon uses a regular tag + iconSVG?: string // If icon is a inline component +} + +export interface MenuInternalLink extends BasicMenuLink { + kind?: undefined +} +export interface MenuExternalLink extends BasicMenuLink { + kind: MenuItemKind.EXTERNAL_LINK +} + +export type MenuLink = MenuInternalLink | MenuExternalLink + +export interface DropDownSubItem { + sectionTitle?: string + links: MenuLink[] +} + +export interface DropDownItem { + kind: MenuItemKind.DROP_DOWN + title: string + items: DropDownSubItem[] +} + +export interface MenuImageProps { + title: string + iconSVG?: string + icon?: string +} + +export enum MenuItemKind { + DROP_DOWN = 'DROP_DOWN', + EXTERNAL_LINK = 'EXTERNAL_LINK', + DARK_MODE_BUTTON = 'DARK_MODE_BUTTON', +} + +export type MenuTreeItem = MenuInternalLink | MenuExternalLink | DropDownItem diff --git a/src/components/layout/GenericLayout/Header/index.tsx b/src/components/layout/GenericLayout/Header/index.tsx index eec1a1745..b1fc43ec2 100644 --- a/src/components/layout/GenericLayout/Header/index.tsx +++ b/src/components/layout/GenericLayout/Header/index.tsx @@ -16,6 +16,7 @@ const HeaderStyled = styled.header` box-sizing: border-box; padding: 0 1.6rem; max-width: 140rem; + z-index: 5; ${media.mediumDown} { max-width: 94rem; @@ -34,10 +35,16 @@ const Logo = styled(Link)` justify-content: center; width: 12rem; height: 3.9rem; + z-index: 6; + transition: transform 0.3s ease 0s; &:hover { text-decoration: none; - opacity: 0.9; + transform: rotate(-5deg); + transition: transform 0.3s ease 0s; + ${media.mobile} { + transform: none; + } } > img { @@ -67,11 +74,12 @@ const Logo = styled(Link)` type Props = PropsWithChildren<{ linkTo?: string logoAlt?: string + onClickOptional?: React.MouseEventHandler }> -export const Header: React.FC = ({ children, linkTo, logoAlt }) => ( +export const Header: React.FC = ({ children, linkTo, logoAlt, onClickOptional }) => ( - + onClickOptional && onClickOptional(event)}> {logoAlt {children} diff --git a/src/components/layout/GenericLayout/variablesCss.ts b/src/components/layout/GenericLayout/variablesCss.ts index c7151c0de..866176b79 100644 --- a/src/components/layout/GenericLayout/variablesCss.ts +++ b/src/components/layout/GenericLayout/variablesCss.ts @@ -33,6 +33,7 @@ const DarkColors = ` // --color-primary: #1E1F2B; --color-primary: #181923; + --color-menu-mobile: #0e0f14; /* bg4 */ --color-transparent: transparent; --color-long: var(--color-green); --color-short: var(--color-red); diff --git a/src/theme/styles/colours.ts b/src/theme/styles/colours.ts index 29e93347a..5020edce2 100644 --- a/src/theme/styles/colours.ts +++ b/src/theme/styles/colours.ts @@ -14,6 +14,7 @@ export interface Colors { bg1: Color bg2: Color bg3: Color + bg4: Color shade: Color boxShadow: Color @@ -98,6 +99,7 @@ export const LIGHT_COLOURS = { bg1: '#F7F8FA', bg2: '#F7F8FA', bg3: '#232432', + bg4: '#0e0f14', shade: '#2E2F3B', boxShadow: 'rgba(0, 0, 0, 0.16)', @@ -135,6 +137,7 @@ export const DARK_COLOURS = { bg1: '#16171F', bg2: '#2C2D3F', bg3: '#232432', + bg4: '#0e0f14', bgDisabled: '#ffffff80', shade: '#2E2F3B', boxShadow: 'rgba(0, 0, 0, 0.16)', diff --git a/src/utils/mediaQueries.ts b/src/utils/mediaQueries.ts index 6fafb5273..742586a0b 100644 --- a/src/utils/mediaQueries.ts +++ b/src/utils/mediaQueries.ts @@ -1,25 +1,33 @@ import { Command } from 'types' +import { media } from 'theme/styles/media' export type Breakpoints = 'xl' | 'lg' | 'md' | 'sm' | 'xs' +const extrapolatedScreenSize = { + sm: media.xSmallScreen, + md: media.smallScreenUp, + lg: media.desktopScreen, + xl: media.desktopScreenLarge, +} + export const MEDIA_QUERY_MATCHES: Array<{ name: Breakpoints; query: string }> = [ // must be in descending order for .find to match from largest to smallest // as sm will also match for xl and lg, for example { name: 'xl', - query: '(min-width:1200px)', + query: `(min-width:${extrapolatedScreenSize.xl})`, }, { name: 'lg', - query: '(min-width:992px)', + query: `(min-width:${extrapolatedScreenSize.lg})`, }, { name: 'md', - query: '(min-width:768px)', + query: `(min-width:${extrapolatedScreenSize.md})`, }, { name: 'sm', - query: '(min-width:576px)', + query: `(min-width:${extrapolatedScreenSize.sm})`, }, // anything smaller -- xs ] diff --git a/src/utils/toggleBodyClass.ts b/src/utils/toggleBodyClass.ts new file mode 100644 index 000000000..ee22cb77e --- /dev/null +++ b/src/utils/toggleBodyClass.ts @@ -0,0 +1,15 @@ +export const toggleBodyClass = (className: string): void => { + if (!document.body.classList.contains(className)) { + document.body.classList.add(className) + } else { + document.body.classList.remove(className) + } +} + +export const addBodyClass = (className: string): void => { + !document.body.classList.contains(className) && document.body.classList.add(className) +} + +export const removeBodyClass = (className: string): void => { + document.body.classList.contains(className) && document.body.classList.remove(className) +}