diff --git a/.env b/.env index ce7e2b852..c605a97a2 100644 --- a/.env +++ b/.env @@ -14,3 +14,5 @@ GOOGLE_FORM = 'https://docs.google.com/forms/d/e/1FAIpQLSfGcd3FDsM3kQIOVKjzdPn4f SHOW_CONFIGURABLE_COLOR_MAP = 'TRUE' +# Enables the refactor page header component that uses the USWDS design system +ENABLE_USWDS_PAGE_HEADER = 'TRUE' \ No newline at end of file diff --git a/.stylelintrc.json b/.stylelintrc.json index 4397871d1..e75e13932 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,11 +1,8 @@ { - "processors": [ - "stylelint-processor-styled-components" - ], "extends": [ - "stylelint-config-recommended", - "stylelint-config-styled-components" + "stylelint-config-recommended" ], + "customSyntax": "postcss-styled-syntax", "rules": { "font-family-no-missing-generic-family-keyword": null, "no-descending-specificity": [ diff --git a/.vscode/settings.json.sample b/.vscode/settings.json.sample index 6d5720fa4..ffbfb4709 100644 --- a/.vscode/settings.json.sample +++ b/.vscode/settings.json.sample @@ -9,6 +9,9 @@ "javascriptreact", "typescript", "typescriptreact" - ] + ], + "[scss]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } } \ No newline at end of file diff --git a/app/scripts/components/common/google-form.tsx b/app/scripts/components/common/google-form.tsx index 923c4ff80..109f52783 100644 --- a/app/scripts/components/common/google-form.tsx +++ b/app/scripts/components/common/google-form.tsx @@ -1,61 +1,16 @@ import React from 'react'; import styled from 'styled-components'; -import { Button } from '@devseed-ui/button'; import { Modal } from '@devseed-ui/modal'; -import { media, themeVal } from '@devseed-ui/theme-provider'; - -import { useFeedbackModal } from './layout-root'; - -import GlobalMenuLinkCSS from '$styles/menu-link'; const StyledGoogleForm = styled.iframe` width: 100%; `; -interface BtnMediaProps { - active?: boolean; -} - -// Global menu link style -const ButtonAsNavLink = styled(Button)` - ${media.mediumUp` - background-color: ${themeVal('color.primary-700')}; - - &:hover { - background-color: ${themeVal('color.primary-800')}; - } - - /* Print & when prop is passed */ - ${({ active }) => active && '&,'} - &:active, - &.active { - background-color: ${themeVal('color.primary-900')}; - } - - &:focus-visible { - background-color: ${themeVal('color.primary-200a')}; - } - `} - - ${media.mediumDown` - ${GlobalMenuLinkCSS} - `} -`; - -const GoogleForm: React.FC<{ title: string, src: string }> = (props) => { - const { title, src } = props; - const { isRevealed, show, hide } = useFeedbackModal(); +const GoogleForm: React.FC<{ src: string, isRevealed: boolean, hide: () => void }> = (props) => { + const { src, isRevealed, hide } = props; return ( <> - - {title} - + NASA + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/app/scripts/components/common/nav-wrapper.js b/app/scripts/components/common/nav-wrapper.js index 9bb162c0f..906624635 100644 --- a/app/scripts/components/common/nav-wrapper.js +++ b/app/scripts/components/common/nav-wrapper.js @@ -2,15 +2,17 @@ import React from 'react'; import styled, { css } from 'styled-components'; import { themeVal } from '@devseed-ui/theme-provider'; import { NavLink } from 'react-router-dom'; +import { default as PageHeaderLegacy } from './page-header-legacy'; import PageHeader from './page-header'; import { useSlidingStickyHeaderProps } from './layout-root/useSlidingStickyHeaderProps'; - +import NasaLogoColor from './nasa-logo-color'; import { HEADER_WRAPPER_ID, HEADER_TRANSITION_DURATION } from '$utils/use-sliding-sticky-header'; +import { checkEnvFlag } from '$utils/utils'; -const NavWrapper = styled.div` +const NavWrapperContainer = styled.div` position: sticky; top: 0; width: 100%; @@ -26,21 +28,34 @@ const NavWrapper = styled.div` top: -${headerHeight}px; `} `; +// Hiding configurable map for now until Instances are ready to adapt it +const isUSWDSEnabled = checkEnvFlag(process.env.ENABLE_USWDS_PAGE_HEADER); +const appTitle = isUSWDSEnabled + ? 'Earthdata VEDA Dashboard' + : process.env.APP_TITLE; -function PageNavWrapper(props) { +function NavWrapper(props) { const { isHeaderHidden, headerHeight } = useSlidingStickyHeaderProps(); - return ( - } + linkProperties={{ LinkElement: NavLink, pathAttributeKeyName: 'to' }} + title={appTitle} + /> + ) : ( + - - + ); } -export default PageNavWrapper; +export default NavWrapper; diff --git a/app/scripts/components/common/page-header-legacy/index.tsx b/app/scripts/components/common/page-header-legacy/index.tsx new file mode 100644 index 000000000..dd286585d --- /dev/null +++ b/app/scripts/components/common/page-header-legacy/index.tsx @@ -0,0 +1,360 @@ +import React, { + useCallback, + useEffect, + useRef, + useState, + ReactElement +} from 'react'; +import styled, { css } from 'styled-components'; +import { + glsp, + listReset, + media, + themeVal, + visuallyHidden +} from '@devseed-ui/theme-provider'; +import { reveal } from '@devseed-ui/animation'; +import { Heading, Overline } from '@devseed-ui/typography'; +import { Button } from '@devseed-ui/button'; +import { CollecticonHamburgerMenu } from '@devseed-ui/collecticons'; + +import UnscrollableBody from '../unscrollable-body'; +import { NavItem } from '../page-header/types'; +import NavMenuItem from './nav-menu-item'; +import { LinkProperties } from '$types/veda'; + +import { variableGlsp } from '$styles/variable-utils'; +import { PAGE_BODY_ID } from '$components/common/layout-root'; +import { useMediaQuery } from '$utils/use-media-query'; +import { HEADER_ID } from '$utils/use-sliding-sticky-header'; + +const PageHeaderSelf = styled.header` + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: space-between; + gap: ${variableGlsp()}; + padding: ${variableGlsp(0.75, 1)}; + background: ${themeVal('color.primary')}; + animation: ${reveal} 0.32s ease 0s 1; + + &, + &:visited { + color: ${themeVal('color.surface')}; + } +`; + +const GlobalNav = styled.nav<{ revealed: boolean }>` + position: fixed; + inset: 0 0 0 auto; + z-index: 900; + display: flex; + flex-flow: column nowrap; + width: 20rem; + margin-right: -20rem; + transition: margin 0.24s ease 0s; + + ${({ revealed }) => + revealed && + css` + & { + margin-right: 0; + } + `} + + ${media.largeUp` + position: static; + flex: 1; + margin: 0; + } + + &:before { + content: ''; + } + `} + + /* Show page nav backdrop on small screens */ + + &::after { + content: ''; + position: absolute; + inset: 0 0 0 auto; + z-index: -1; + background: transparent; + width: 0; + transition: background 0.64s ease 0s; + + ${({ revealed }) => + revealed && + css` + ${media.mediumDown` + background: ${themeVal('color.base-400a')}; + width: 200vw; + `} + `} + } +`; + +const GlobalNavInner = styled.div` + display: flex; + flex-direction: column; + flex: 1; + background-color: ${themeVal('color.primary')}; + + ${media.mediumDown` + box-shadow: ${themeVal('boxShadow.elevationD')}; + `} +`; + +const GlobalNavHeader = styled.div` + padding: ${variableGlsp(1)}; + box-shadow: inset 0 -1px 0 0 ${themeVal('color.surface-200a')}; + ${media.largeUp` + display: none; + `} +`; + +const GlobalNavTitle = styled(Heading).attrs({ + as: 'span', + size: 'small' +})` + /* styled-component */ +`; + +export const GlobalNavActions = styled.div` + align-self: start; + ${media.largeUp` + display: none; + `} +`; + +export const GlobalNavToggle = styled(Button)` + z-index: 2000; +`; + +const GlobalNavBody = styled.div` + display: flex; + flex: 1; + + .shadow-top { + background: linear-gradient( + to top, + ${themeVal('color.primary-600')}00 0%, + ${themeVal('color.primary-600')} 100% + ); + } + + .shadow-bottom { + background: linear-gradient( + to bottom, + ${themeVal('color.primary-600')}00 0%, + ${themeVal('color.primary-600')} 100% + ); + } +`; + +const GlobalNavBodyInner = styled.div` + display: flex; + flex-direction: column; + flex: 1; + gap: ${variableGlsp()}; + padding: ${variableGlsp(1, 0)}; + + ${media.largeUp` + flex-direction: row; + justify-content: space-between; + padding: 0; + `} +`; + +const NavBlock = styled.div` + display: flex; + flex-flow: column nowrap; + gap: ${glsp(0.25)}; + + ${media.largeUp` + flex-direction: row; + align-items: center; + gap: ${glsp(1.5)}; + `} +`; + +const SROnly = styled.a` + height: 1px; + left: -10000px; + overflow: hidden; + position: absolute; + top: auto; + width: 1px; + color: ${themeVal('color.link')}; + &:focus { + top: 0; + left: 0; + background-color: ${themeVal('color.surface')}; + padding: ${glsp(0.25)}; + height: auto; + width: auto; + } +`; + +const SectionsNavBlock = styled(NavBlock)` + /* styled-component */ +`; + +const GlobalNavBlockTitle = styled(Overline).attrs({ + as: 'span' +})` + ${visuallyHidden} + display: block; + padding: ${variableGlsp(1, 1, 0.25, 1)}; + color: currentColor; + opacity: 0.64; + + ${media.largeUp` + padding: 0; + `} +`; + +const GlobalMenu = styled.ul` + ${listReset()} + display: flex; + flex-flow: column nowrap; + gap: ${glsp(0.5)}; + + ${media.largeUp` + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: ${glsp(1.5)}; + `} +`; + +interface PageHeaderProps { + mainNavItems: NavItem[]; + subNavItems: NavItem[]; + logo: ReactElement; + linkProperties: LinkProperties; +} + +function PageHeader(props: PageHeaderProps) { + const { mainNavItems, subNavItems, logo, linkProperties } = props; + const { isMediumDown } = useMediaQuery(); + const [globalNavRevealed, setGlobalNavRevealed] = useState(false); + + const globalNavBodyRef = useRef(null); + // Click listener for the whole global nav body so we can close it when clicking + // the overlay on medium down media query. + const onGlobalNavClick = useCallback((e) => { + if (!globalNavBodyRef.current?.contains(e.target)) { + setGlobalNavRevealed(false); + } + }, []); + + useEffect(() => { + // Close global nav when media query changes. + // NOTE: isMediumDown is returning document.body's width, not the whole window width + // which conflicts with how mediaquery decides the width. + // JSX element susing isMediumDown is also protected with css logic because of this. + // ex. Look at GlobalNavActions + if (!isMediumDown) setGlobalNavRevealed(false); + }, [isMediumDown]); + + const closeNavOnClick = useCallback(() => { + setGlobalNavRevealed(false); + }, []); + + function skipNav(e) { + // a tag won't appear for keyboard focus without href + // so we are preventing the default behaviour of a link here + e.preventDefault(); + // Then find a next focusable element in pagebody,focus it. + const pageBody = document.getElementById(PAGE_BODY_ID); + if (pageBody) { + pageBody.focus(); + } + } + + return ( + <> + + Skip to main content + + + {globalNavRevealed && isMediumDown && } + {logo} + {isMediumDown && ( + + setGlobalNavRevealed((v) => !v)} + active={globalNavRevealed} + > + + + + )} + + + {isMediumDown && ( + <> + + + + + )} + + + + Global + + {mainNavItems.map((item) => { + return ( + + ); + })} + + + + Meta + + {subNavItems.map((item) => { + return ( + + ); + })} + + + + + + + + + ); +} + +export default PageHeader; diff --git a/app/scripts/components/common/page-header/logo.tsx b/app/scripts/components/common/page-header-legacy/logo.tsx similarity index 100% rename from app/scripts/components/common/page-header/logo.tsx rename to app/scripts/components/common/page-header-legacy/logo.tsx diff --git a/app/scripts/components/common/page-header-legacy/nav-menu-item.tsx b/app/scripts/components/common/page-header-legacy/nav-menu-item.tsx new file mode 100644 index 000000000..d4ea7d812 --- /dev/null +++ b/app/scripts/components/common/page-header-legacy/nav-menu-item.tsx @@ -0,0 +1,248 @@ +import React, { ComponentType } from 'react'; +import styled from 'styled-components'; +import { glsp, media, rgba, themeVal } from '@devseed-ui/theme-provider'; +import { Button } from '@devseed-ui/button'; +import { CollecticonChevronDownSmall } from '@devseed-ui/collecticons'; +import { DropMenu, DropMenuItem } from '@devseed-ui/dropdown'; + +import DropdownScrollable from '../dropdown-scrollable'; +import GoogleForm from '../google-form'; +import { + AlignmentEnum, + InternalNavLink, + ExternalNavLink, + NavLinkItem, + DropdownNavLink, + NavItem, + NavItemType +} from '../page-header/types'; +import { USWDSButton } from '../uswds/button'; +import { useFeedbackModal } from '../layout-root'; +import GlobalMenuLinkCSS from '$styles/menu-link'; +import { useMediaQuery } from '$utils/use-media-query'; +import { LinkProperties } from '$types/veda'; + +const rgbaFixed = rgba as any; + +export const GlobalNavActions = styled.div` + align-self: start; + ${media.largeUp` + display: none; + `} +`; + +const GlobalMenuItem = styled.span` + ${GlobalMenuLinkCSS} + cursor: default; + &:hover { + opacity: 1; + } +`; + +export const GlobalNavToggle = styled(Button)` + z-index: 2000; +`; + +const GlobalMenuLink = styled.a` + ${GlobalMenuLinkCSS} +`; + +const GlobalMenuButton = styled(Button)` + ${GlobalMenuLinkCSS} +`; + +const DropMenuNavItem = styled(DropMenuItem)` + &.active { + background-color: ${rgbaFixed(themeVal('color.link'), 0.08)}; + } + ${media.largeDown` + padding-left ${glsp(2)}; + &:hover { + color: inherit; + opacity: 0.64; + } + `} +`; + +const LOG = true; + +function LinkDropMenuNavItem({ + child, + onClick, + linkProperties +}: { + child: NavLinkItem; + onClick?: () => void; + linkProperties: LinkProperties; +}) { + const { title, type, ...rest } = child; + const linkProps = { + as: linkProperties.LinkElement as ComponentType, + [linkProperties.pathAttributeKeyName]: (rest as InternalNavLink).to + }; + + if (type === NavItemType.INTERNAL_LINK) { + return ( +
  • + + {title} + +
  • + ); + // In case a user inputs a wrong type + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (type === NavItemType.EXTERNAL_LINK) { + return ( +
  • + + {title} + +
  • + ); + } else { + LOG && + /* eslint-disable-next-line no-console */ + console.error( + `Invalid child Nav Item type, type "${type}" is not of `, + NavItemType + ); + return null; + } +} + +export default function NavMenuItem({ + item, + alignment, + onClick, + linkProperties +}: { + item: NavItem; + alignment?: AlignmentEnum; + onClick?: () => void; + linkProperties: LinkProperties; +}) { + const { isMediumDown } = useMediaQuery(); + const { isRevealed, show, hide } = useFeedbackModal(); + const { title, type, ...rest } = item; + + if (type === NavItemType.INTERNAL_LINK) { + const linkProps = { + as: linkProperties.LinkElement as ComponentType, + [linkProperties.pathAttributeKeyName]: (rest as InternalNavLink).to + }; + return ( +
  • + + {title} + +
  • + ); + } else if (item.type === NavItemType.EXTERNAL_LINK) { + return ( +
  • + + {title} + +
  • + ); + } else if (item.type === NavItemType.ACTION) { + return ( +
  • + + {item.title} + + {process.env.GOOGLE_FORM && ( + + )} +
  • + ); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (type === NavItemType.DROPDOWN) { + const { title } = item as DropdownNavLink; + // Mobile view + if (isMediumDown) { + return ( + <> +
  • + {title} +
  • + {item.children.map((child) => { + return ( + + ); + })} + + ); + } else { + return ( +
  • + ( + + {title} + + )} + > + + {(item as DropdownNavLink).children.map((child) => { + return ( + + ); + })} + + +
  • + ); + } + } else { + LOG && + /* eslint-disable-next-line no-console */ + console.error( + `Invalid type for Nav Items, type "${type}" is not of `, + NavItemType + ); + return null; + } +} diff --git a/app/scripts/components/common/page-header/default-config.ts b/app/scripts/components/common/page-header/default-config.ts index 889f884b8..00040cd2a 100644 --- a/app/scripts/components/common/page-header/default-config.ts +++ b/app/scripts/components/common/page-header/default-config.ts @@ -1,5 +1,11 @@ import { getString, getNavItemsFromVedaConfig } from 'veda'; -import { InternalNavLink, ExternalNavLink, ModalNavLink, DropdownNavLink, NavItemType } from '$components/common/page-header/types'; +import { + InternalNavLink, + ExternalNavLink, + DropdownNavLink, + NavItemType, + ActionNavItem +} from '$components/common/page-header/types'; import { STORIES_PATH, @@ -8,44 +14,71 @@ import { ABOUT_PATH } from '$utils/routes'; -let defaultMainNavItems:(ExternalNavLink | InternalNavLink | DropdownNavLink | ModalNavLink)[] = [{ - title: 'Data Catalog', - to: DATASETS_PATH, - type: NavItemType.INTERNAL_LINK -}, { - title: 'Exploration', - to: EXPLORATION_PATH, - type: NavItemType.INTERNAL_LINK -}, { - title: getString('stories').other, - to: STORIES_PATH, - type: NavItemType.INTERNAL_LINK -}]; +let defaultMainNavItems: ( + | ExternalNavLink + | InternalNavLink + | DropdownNavLink + | ActionNavItem +)[] = [ + { + id: 'data-catalog', + title: 'Data Catalog', + to: DATASETS_PATH, + type: NavItemType.INTERNAL_LINK + }, + { + id: 'exploration', + title: 'Exploration', + to: EXPLORATION_PATH, + type: NavItemType.INTERNAL_LINK + }, + { + id: 'stories', + title: getString('stories').other, + to: STORIES_PATH, + type: NavItemType.INTERNAL_LINK + } +]; -if (!!process.env.HUB_URL && !!process.env.HUB_NAME) defaultMainNavItems = [...defaultMainNavItems, { - title: process.env.HUB_NAME, - href: process.env.HUB_URL, - type: NavItemType.EXTERNAL_LINK -} as ExternalNavLink]; +if (!!process.env.HUB_URL && !!process.env.HUB_NAME) + defaultMainNavItems = [ + ...defaultMainNavItems, + { + title: process.env.HUB_NAME, + href: process.env.HUB_URL, + type: NavItemType.EXTERNAL_LINK + } as ExternalNavLink + ]; -let defaultSubNavItems:(ExternalNavLink | InternalNavLink | DropdownNavLink | ModalNavLink)[] = [{ - title: 'About', - to: ABOUT_PATH, - type: NavItemType.INTERNAL_LINK -}]; +let defaultSubNavItems: ( + | ExternalNavLink + | InternalNavLink + | DropdownNavLink + | ActionNavItem +)[] = [ + { + id: 'about', + title: 'About', + to: ABOUT_PATH, + type: NavItemType.INTERNAL_LINK + } +]; -if (process.env.GOOGLE_FORM) { - defaultSubNavItems = [...defaultSubNavItems, { - title: 'Contact us', - src: process.env.GOOGLE_FORM, - type: NavItemType.MODAL - }]; +if (process.env.GOOGLE_FORM !== undefined) { + defaultSubNavItems = [ + ...defaultSubNavItems, + { + id: 'contact-us', + title: 'Contact us', + actionId: 'open-google-form', + type: NavItemType.ACTION + } + ]; } -const mainNavItems = getNavItemsFromVedaConfig()?.mainNavItems?? defaultMainNavItems; -const subNavItems = getNavItemsFromVedaConfig()?.subNavItems?? defaultSubNavItems; +const mainNavItems = + getNavItemsFromVedaConfig()?.mainNavItems ?? defaultMainNavItems; +const subNavItems = + getNavItemsFromVedaConfig()?.subNavItems ?? defaultSubNavItems; -export { - mainNavItems, - subNavItems -}; \ No newline at end of file +export { mainNavItems, subNavItems }; diff --git a/app/scripts/components/common/page-header/index.tsx b/app/scripts/components/common/page-header/index.tsx index 1b603f88a..40340dfd3 100644 --- a/app/scripts/components/common/page-header/index.tsx +++ b/app/scripts/components/common/page-header/index.tsx @@ -1,340 +1,97 @@ -import React, { useCallback, useEffect, useRef, useState, ReactElement } from 'react'; -import styled, { css } from 'styled-components'; -import { - glsp, - listReset, - media, - themeVal, - visuallyHidden -} from '@devseed-ui/theme-provider'; -import { reveal } from '@devseed-ui/animation'; -import { Heading, Overline } from '@devseed-ui/typography'; -import { Button } from '@devseed-ui/button'; -import { - CollecticonHamburgerMenu -} from '@devseed-ui/collecticons'; +import React, { useCallback, useState, useMemo } from 'react'; -import UnscrollableBody from '../unscrollable-body'; -import NavMenuItem from './nav-menu-item'; import { NavItem } from './types'; - -import { variableGlsp } from '$styles/variable-utils'; -import { PAGE_BODY_ID } from '$components/common/layout-root'; -import { useMediaQuery } from '$utils/use-media-query'; -import { HEADER_ID } from '$utils/use-sliding-sticky-header'; +import LogoContainer from './logo-container'; +import useMobileMenuFix from './use-mobile-menu-fix'; +import { createDynamicNavMenuList } from './nav/create-dynamic-nav-menu-list'; +import { + USWDSHeader, + USWDSHeaderTitle, + USWDSNavMenuButton, + USWDSExtendedNav +} from '$components/common/uswds'; import { LinkProperties } from '$types/veda'; - - -const PageHeaderSelf = styled.header` - display: flex; - flex-flow: row nowrap; - align-items: center; - justify-content: space-between; - gap: ${variableGlsp()}; - padding: ${variableGlsp(0.75, 1)}; - background: ${themeVal('color.primary')}; - animation: ${reveal} 0.32s ease 0s 1; - - &, - &:visited { - color: ${themeVal('color.surface')}; - } -`; - - -const GlobalNav = styled.nav<{ revealed: boolean }>` - position: fixed; - inset: 0 0 0 auto; - z-index: 900; - display: flex; - flex-flow: column nowrap; - width: 20rem; - margin-right: -20rem; - transition: margin 0.24s ease 0s; - - ${({ revealed }) => - revealed && - css` - & { - margin-right: 0; - } - `} - - ${media.largeUp` - position: static; - flex: 1; - margin: 0; - } - - &:before { - content: ''; - } - `} - - /* Show page nav backdrop on small screens */ - - &::after { - content: ''; - position: absolute; - inset: 0 0 0 auto; - z-index: -1; - background: transparent; - width: 0; - transition: background 0.64s ease 0s; - - ${({ revealed }) => - revealed && - css` - ${media.mediumDown` - background: ${themeVal('color.base-400a')}; - width: 200vw; - `} - `} - } -`; - -const GlobalNavInner = styled.div` - display: flex; - flex-direction: column; - flex: 1; - background-color: ${themeVal('color.primary')}; - - ${media.mediumDown` - box-shadow: ${themeVal('boxShadow.elevationD')}; - `} -`; - -const GlobalNavHeader = styled.div` - padding: ${variableGlsp(1)}; - box-shadow: inset 0 -1px 0 0 ${themeVal('color.surface-200a')}; - ${media.largeUp` - display: none; - `} -`; - -const GlobalNavTitle = styled(Heading).attrs({ - as: 'span', - size: 'small' -})` - /* styled-component */ -`; - -export const GlobalNavActions = styled.div` - align-self: start; - ${media.largeUp` - display: none; - `} -`; - -export const GlobalNavToggle = styled(Button)` - z-index: 2000; -`; - -const GlobalNavBody = styled.div` - display: flex; - flex: 1; - - .shadow-top { - background: linear-gradient( - to top, - ${themeVal('color.primary-600')}00 0%, - ${themeVal('color.primary-600')} 100% - ); - } - - .shadow-bottom { - background: linear-gradient( - to bottom, - ${themeVal('color.primary-600')}00 0%, - ${themeVal('color.primary-600')} 100% - ); - } -`; - -const GlobalNavBodyInner = styled.div` - display: flex; - flex-direction: column; - flex: 1; - gap: ${variableGlsp()}; - padding: ${variableGlsp(1, 0)}; - - ${media.largeUp` - flex-direction: row; - justify-content: space-between; - padding: 0; - `} -`; - -const NavBlock = styled.div` - display: flex; - flex-flow: column nowrap; - gap: ${glsp(0.25)}; - - ${media.largeUp` - flex-direction: row; - align-items: center; - gap: ${glsp(1.5)}; - `} -`; - -const SROnly = styled.a` - height: 1px; - left: -10000px; - overflow: hidden; - position: absolute; - top: auto; - width: 1px; - color: ${themeVal('color.link')}; - &:focus { - top: 0; - left: 0; - background-color: ${themeVal('color.surface')}; - padding: ${glsp(0.25)}; - height: auto; - width: auto; - } -`; - -const SectionsNavBlock = styled(NavBlock)` - /* styled-component */ -`; - -const GlobalNavBlockTitle = styled(Overline).attrs({ - as: 'span' -})` - ${visuallyHidden} - display: block; - padding: ${variableGlsp(1, 1, 0.25, 1)}; - color: currentColor; - opacity: 0.64; - - ${media.largeUp` - padding: 0; - `} -`; - -const GlobalMenu = styled.ul` - ${listReset()} - display: flex; - flex-flow: column nowrap; - gap: ${glsp(0.5)}; - - ${media.largeUp` - flex-direction: row; - justify-content: flex-start; - align-items: center; - gap: ${glsp(1.5)}; - `} -`; +import './styles.scss'; interface PageHeaderProps { mainNavItems: NavItem[]; subNavItems: NavItem[]; - logo: ReactElement; + logoSvg?: SVGElement | JSX.Element; linkProperties: LinkProperties; + title: string; + version?: string; + accessibilityHomeShortCutText?: string; } -function PageHeader(props: PageHeaderProps) { - const { mainNavItems, subNavItems, logo, linkProperties } = props; - const { isMediumDown } = useMediaQuery(); - const [globalNavRevealed, setGlobalNavRevealed] = useState(false); +export default function PageHeader({ + mainNavItems, + subNavItems, + logoSvg: Logo, + linkProperties, + title, + version, + accessibilityHomeShortCutText +}: PageHeaderProps) { + const [expanded, setExpanded] = useState(false); + useMobileMenuFix(expanded, setExpanded); + + const [isOpen, setIsOpen] = useState( + mainNavItems.map(() => false) + ); - const globalNavBodyRef = useRef(null); - // Click listener for the whole global nav body so we can close it when clicking - // the overlay on medium down media query. - const onGlobalNavClick = useCallback((e) => { - if (!globalNavBodyRef.current?.contains(e.target)) { - setGlobalNavRevealed(false); - } + const toggleExpansion: () => void = useCallback(() => { + setExpanded((prvExpanded) => { + return !prvExpanded; + }); }, []); - useEffect(() => { - // Close global nav when media query changes. - // NOTE: isMediumDown is returning document.body's width, not the whole window width - // which conflicts with how mediaquery decides the width. - // JSX element susing isMediumDown is also protected with css logic because of this. - // ex. Look at GlobalNavActions - if (!isMediumDown) setGlobalNavRevealed(false); - }, [isMediumDown]); + const primaryItems = useMemo( + () => + createDynamicNavMenuList(mainNavItems, linkProperties, isOpen, setIsOpen), + [mainNavItems, linkProperties, isOpen] + ); - const closeNavOnClick = useCallback(() => { - setGlobalNavRevealed(false); - }, []); + const secondaryItems = useMemo( + () => createDynamicNavMenuList(subNavItems, linkProperties), + [subNavItems, linkProperties] + ); - function skipNav(e) { - // a tag won't appear for keyboard focus without href - // so we are preventing the default behaviour of a link here + const skipNav = (e) => { e.preventDefault(); - // Then find a next focusable element in pagebody,focus it. - const pageBody = document.getElementById(PAGE_BODY_ID); + const pageBody = document.getElementById('pagebody'); if (pageBody) { - pageBody.focus(); + pageBody.focus(); } - } + }; return ( <> - Skip to main content - - {globalNavRevealed && isMediumDown && } - {logo} - {isMediumDown && ( - - setGlobalNavRevealed((v) => !v)} - active={globalNavRevealed} - > - - - - )} - - - {isMediumDown && ( - <> - - - - - )} - - - - Global - - {mainNavItems.map((item) => { - return ; - })} - - - - Meta - - {subNavItems.map((item) => { - return ; - })} - - - - - - - + {accessibilityHomeShortCutText || 'Skip to main content'} + + +
    + + + + +
    + +
    ); } - -export default PageHeader; diff --git a/app/scripts/components/common/page-header/logo-container.tsx b/app/scripts/components/common/page-header/logo-container.tsx deleted file mode 100644 index d0cac7c6d..000000000 --- a/app/scripts/components/common/page-header/logo-container.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { ComponentType } from 'react'; -import { Tip } from '../tip'; -import { Brand, PageTitleSecLink } from './logo'; -import { LinkProperties } from '$types/veda'; - -/** - * LogoContainer that is meant to integrate in the default page header without the dependencies of the veda virtual modules - * and expects the Logo SVG to be passed in as a prop - this will support the instance for refactor - */ - -export default function LogoContainer ({ linkProperties, Logo, title, subTitle, version }: { - linkProperties: LinkProperties, - Logo: JSX.Element, - title: string, - subTitle: string, - version: string -}) { - const LinkElement: ComponentType = linkProperties.LinkElement as ComponentType; - - return ( - - - {Logo} - {title} {subTitle} - - - , [linkProperties.pathAttributeKeyName]: '/development'}}>Beta - - - ); -} \ No newline at end of file diff --git a/app/scripts/components/common/page-header/logo-container/index.tsx b/app/scripts/components/common/page-header/logo-container/index.tsx new file mode 100644 index 000000000..9aabf892a --- /dev/null +++ b/app/scripts/components/common/page-header/logo-container/index.tsx @@ -0,0 +1,49 @@ +import React, { ComponentType } from 'react'; +import { Tip } from '../../tip'; +import { LinkProperties } from '$types/veda'; +import './styles.scss'; + +/** + * LogoContainer that is meant to integrate in the default + * page header without the dependencies of the veda virtual modules + * and expects the Logo SVG to be passed in as a prop - this will + * support the instance for refactor + */ + +export default function LogoContainer({ + linkProperties, + LogoSvg, + title, + version +}: { + linkProperties: LinkProperties; + LogoSvg?: SVGElement | JSX.Element; + title: string; + version?: string; +}) { + const LinkElement: ComponentType = + linkProperties.LinkElement as ComponentType; + + return ( +
    + + {LogoSvg} + {title} + + +
    , + [linkProperties.pathAttributeKeyName]: '/development' + }} + > + {version || 'BETA'} +
    +
    +
    + ); +} diff --git a/app/scripts/components/common/page-header/logo-container/styles.scss b/app/scripts/components/common/page-header/logo-container/styles.scss new file mode 100644 index 000000000..326450eec --- /dev/null +++ b/app/scripts/components/common/page-header/logo-container/styles.scss @@ -0,0 +1,45 @@ +@use '$styles/veda-ui-theme-vars.scss' as themeVars; + +#logo-container { + display: flex; + font-family: themeVars.$veda-uswds-basefont-sans; + gap: themeVars.$veda-uswds-spacing-105; + + #logo-container-link { + display: flex; + align-items: center; + gap: themeVars.$veda-uswds-spacing-105; + font-weight: themeVars.$veda-uswds-fontweight-bold; + font-size: themeVars.$veda-uswds-fontsize-lg; + } + + #nasa-logo-pos { + opacity: 1; + transform: translate(0, -100%); + /* TODO: Fix the svg to not require any styles! + * - set opacity to 1 in svg and fix translation + */ + } + + @media (width <= themeVars.$veda-uswds-spacing-desktop) { + #nasa-logo-pos { + display: none; + } + } + + svg { + height: 2.5rem; + width: auto; + } + + #logo-container-beta-tag { + color: white; + align-self: center; + font-size: themeVars.$veda-uswds-fontsize-3xs; + font-weight: themeVars.$veda-uswds-fontweight-regular; + padding-left: themeVars.$veda-uswds-spacing-5; + padding-right: themeVars.$veda-uswds-spacing-5; + border-radius: 2px; + background-color: themeVars.$veda-uswds-color-secondary; + } +} diff --git a/app/scripts/components/common/page-header/nav-menu-item.tsx b/app/scripts/components/common/page-header/nav-menu-item.tsx deleted file mode 100644 index c56249d63..000000000 --- a/app/scripts/components/common/page-header/nav-menu-item.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import React, { ComponentType } from 'react'; -import styled from 'styled-components'; -import { - glsp, - media, - rgba, - themeVal -} from '@devseed-ui/theme-provider'; -import { Button } from '@devseed-ui/button'; -import { CollecticonChevronDownSmall } from '@devseed-ui/collecticons'; -import { DropMenu, DropMenuItem } from '@devseed-ui/dropdown'; - -import DropdownScrollable from '../dropdown-scrollable'; -import GoogleForm from '../google-form'; -import { AlignmentEnum, InternalNavLink, ExternalNavLink, NavLinkItem, DropdownNavLink, ModalNavLink, NavItem, NavItemType } from './types'; -import GlobalMenuLinkCSS from '$styles/menu-link'; -import { useMediaQuery } from '$utils/use-media-query'; -import { LinkProperties } from '$types/veda'; - - -const rgbaFixed = rgba as any; - -export const GlobalNavActions = styled.div` - align-self: start; - ${media.largeUp` - display: none; - `} -`; - -const GlobalMenuItem = styled.span` - ${GlobalMenuLinkCSS} - cursor: default; - &:hover { - opacity: 1; - } -`; - -export const GlobalNavToggle = styled(Button)` - z-index: 2000; -`; - -const GlobalMenuLink = styled.a` - ${GlobalMenuLinkCSS} -`; - -const GlobalMenuButton = styled(Button)` - ${GlobalMenuLinkCSS} -`; - -const DropMenuNavItem = styled(DropMenuItem)` - &.active { - background-color: ${rgbaFixed(themeVal('color.link'), 0.08)}; - } - ${media.largeDown` - padding-left ${glsp(2)}; - &:hover { - color: inherit; - opacity: 0.64; - } - `} -`; - -const LOG = true; - -function LinkDropMenuNavItem({ child, onClick, linkProperties }: { child: NavLinkItem, onClick?:() => void, linkProperties: LinkProperties }) { - const { title, type, ...rest } = child; - const linkProps = { - as: linkProperties.LinkElement as ComponentType, - [linkProperties.pathAttributeKeyName]: (rest as InternalNavLink).to, - }; - - if (type === NavItemType.INTERNAL_LINK) { - return ( -
  • - - {title} - -
  • - ); - // In case a user inputs a wrong type - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - } else if (type === NavItemType.EXTERNAL_LINK) { - return ( -
  • - - {title} - -
  • - ); - } else { - LOG && - /* eslint-disable-next-line no-console */ - console.error(`Invalid child Nav Item type, type "${type}" is not of `, NavItemType); - return null; - } -} - - -export default function NavMenuItem({ item, alignment, onClick, linkProperties }: {item: NavItem, alignment?: AlignmentEnum, onClick?: () => void, linkProperties: LinkProperties }) { - const { isMediumDown } = useMediaQuery(); - const { title, type, ...rest } = item; - - if (type === NavItemType.INTERNAL_LINK) { - const linkProps = { - as: linkProperties.LinkElement as ComponentType, - [linkProperties.pathAttributeKeyName]: (rest as InternalNavLink).to, - }; - return ( -
  • - - {title} - -
  • - - ); - } else if (item.type === NavItemType.EXTERNAL_LINK) { - return ( -
  • - - {title} - -
  • - - ); - } else if (type === NavItemType.MODAL) { - return (
  • ); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - } else if (type === NavItemType.DROPDOWN) { - const { title } = item as DropdownNavLink; - // Mobile view - if (isMediumDown) { - return ( - <> -
  • {title}
  • - {item.children.map((child) => { - return ; - })} - - ); - } else { - return (
  • - ( - // @ts-expect-error achromic text exists - - {title} - - )} - > - - {(item as DropdownNavLink).children.map((child) => { - return ; - })} - - -
  • ); - } - } else { - LOG && - /* eslint-disable-next-line no-console */ - console.error(`Invalid type for Nav Items, type "${type}" is not of `, NavItemType); - return null; - } -} \ No newline at end of file diff --git a/app/scripts/components/common/page-header/nav/create-dynamic-nav-menu-list.tsx b/app/scripts/components/common/page-header/nav/create-dynamic-nav-menu-list.tsx new file mode 100644 index 000000000..764853687 --- /dev/null +++ b/app/scripts/components/common/page-header/nav/create-dynamic-nav-menu-list.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { NavItem, NavItemType } from '../types'; +import { NavDropDownButton } from './nav-dropdown-button'; +import { NavItemExternalLink, NavItemInternalLink } from './nav-item-links'; +import { NavItemCTA } from './nav-item-cta'; +import { LinkProperties } from '$types/veda'; +import { SetState } from '$types/aliases'; + +export const createDynamicNavMenuList = ( + navItems: NavItem[], + linkProperties: LinkProperties, + isOpen?: boolean[], + setIsOpen?: SetState +): JSX.Element[] => { + return navItems.map((item, index) => { + switch (item.type) { + case NavItemType.DROPDOWN: + if (isOpen === undefined || setIsOpen === undefined) return <>; + return ( + + ); + + case NavItemType.INTERNAL_LINK: + return ( + linkProperties && ( + + ) + ); + + case NavItemType.EXTERNAL_LINK: + return ; + + case NavItemType.ACTION: + return ; + + default: + return <>; + } + }); +}; diff --git a/app/scripts/components/common/page-header/nav/nav-dropdown-button.tsx b/app/scripts/components/common/page-header/nav/nav-dropdown-button.tsx new file mode 100644 index 000000000..f76d5f9b9 --- /dev/null +++ b/app/scripts/components/common/page-header/nav/nav-dropdown-button.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { USWDSNavDropDownButton } from '../../uswds/header/nav-drop-down-button'; +import { USWDSMenu } from '../../uswds/header/menu'; +import { DropdownNavLink } from '../types'; +import { createDynamicNavMenuList } from './create-dynamic-nav-menu-list'; +import { SetState } from '$types/aliases'; +import { LinkProperties } from '$types/veda'; + +interface NavDropDownButtonProps { + item: DropdownNavLink; + isOpen: boolean[]; + setIsOpen: SetState; + index: number; + linkProperties: LinkProperties; +} + +export const NavDropDownButton = ({ + item, + isOpen, + setIsOpen, + index, + linkProperties +}: NavDropDownButtonProps) => { + const onToggle = (index: number, setIsOpen: SetState): void => { + setIsOpen((prevIsOpen) => { + const newIsOpen = prevIsOpen.map( + (prev, i) => + i === index + ? !prev // toggle the current dropdown + : false // close all other dropdowns + ); + return newIsOpen; + }); + }; + + const submenuItems = createDynamicNavMenuList(item.children, linkProperties); + + return ( + + onToggle(index, setIsOpen)} + menuId={item.title} + isOpen={isOpen[index]} + label={item.title} + /> + + + ); +}; diff --git a/app/scripts/components/common/page-header/nav/nav-item-cta.tsx b/app/scripts/components/common/page-header/nav/nav-item-cta.tsx new file mode 100644 index 000000000..04efa7798 --- /dev/null +++ b/app/scripts/components/common/page-header/nav/nav-item-cta.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import GoogleForm from '../../google-form'; +import { useFeedbackModal } from '../../layout-root'; +import { ActionNavItem } from '../types'; + +interface NavItemCTAProps { + item: ActionNavItem; +} + +export const NavItemCTA: React.FC = ({ + item +}): JSX.Element => { + const { isRevealed, show, hide } = useFeedbackModal(); + return ( + + {item.actionId === 'open-google-form' && ( + <> + + + + )} + {/* @TODO: Other possible cases would go here to perform some type of action */} + + ); +}; diff --git a/app/scripts/components/common/page-header/nav/nav-item-links.tsx b/app/scripts/components/common/page-header/nav/nav-item-links.tsx new file mode 100644 index 000000000..76537c885 --- /dev/null +++ b/app/scripts/components/common/page-header/nav/nav-item-links.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { ExternalNavLink, InternalNavLink } from '../types'; +import { LinkProperties } from '$types/veda'; + +interface NavItemExternalLinkProps { + item: ExternalNavLink; +} + +interface NavItemInternalLinkProps { + item: InternalNavLink; + linkProperties: LinkProperties; +} + +export const NavItemExternalLink: React.FC = ({ + item +}): JSX.Element => { + return ( + + {item.title} + + ); +}; + +export const NavItemInternalLink: React.FC = ({ + item, + linkProperties +}): JSX.Element | null => { + if (linkProperties.LinkElement) { + const path = { + [linkProperties.pathAttributeKeyName]: (item as InternalNavLink).to + }; + const LinkElement = linkProperties.LinkElement; + return ( + + {item.title} + + ); + } + // If the link provided is invalid, do not render the element + return null; +}; diff --git a/app/scripts/components/common/page-header/page-header.test.tsx b/app/scripts/components/common/page-header/page-header.test.tsx new file mode 100644 index 000000000..65a7e7c99 --- /dev/null +++ b/app/scripts/components/common/page-header/page-header.test.tsx @@ -0,0 +1,72 @@ +import React, { ComponentType } from 'react'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom/extend-expect'; + +import { navItems } from '../../../../../mock/veda.config.js'; +import NasaLogoColor from '../nasa-logo-color'; +import { NavItem } from './types'; +import PageHeader from './index'; + +// @NOTE: Possible Test cases +// config & create dynamic nav menu list fn - different scenerios, happy vs unhappy path + +const mockMainNavItems: NavItem[] = navItems.mainNavItems; +const mockSubNavItems: NavItem[] = navItems.subNavItems; + +const mockLinkProperties = { + pathAttributeKeyName: 'to', + LinkElement: 'a' as unknown as ComponentType +}; +const testTitle= 'Test Title'; + +describe('PageHeader', () => { + beforeEach(() => { + render( + } + title={testTitle} + linkProperties={mockLinkProperties} + /> + ); + }); + + test('renders the PageHeader component title', () => { + expect(screen.getByTestId('header')).toHaveTextContent( + testTitle + ); + }); + + test('renders the PageHeader nav items', () => { + const navElement = screen.getByRole('navigation'); + expect(navElement).toBeInTheDocument(); + + const primaryNav = within(navElement).getAllByRole('list')[0]; + const secondaryNav = within(navElement).getAllByRole('list')[1]; + + expect(primaryNav.childElementCount).toEqual(mockMainNavItems.length); + expect(secondaryNav.childElementCount).toEqual(mockSubNavItems.length); + expect(within(primaryNav).getByText('Test')).toBeInTheDocument(); + expect(within(primaryNav).getByText('Data Catalog')).toBeInTheDocument(); + expect(within(primaryNav).getByText('Exploration')).toBeInTheDocument(); + expect(within(primaryNav).getByText('Stories')).toBeInTheDocument(); + + expect(within(secondaryNav).getByText('About')).toBeInTheDocument(); + }); + + test('the nav items are clickable and open the drop down', async () => { + const user = userEvent.setup(); + const navElement = screen.getByRole('navigation'); + expect(navElement).toBeInTheDocument(); + + const primaryNav = within(navElement).getAllByRole('list')[0]; + const navItem = screen.getByText('Test'); + expect( + within(primaryNav).getByText('dropdown menu item 1') + ).not.toBeVisible(); + await user.click(navItem); + expect(within(primaryNav).getByText('dropdown menu item 1')).toBeVisible(); + }); +}); diff --git a/app/scripts/components/common/page-header/styles.scss b/app/scripts/components/common/page-header/styles.scss new file mode 100644 index 000000000..f7a211807 --- /dev/null +++ b/app/scripts/components/common/page-header/styles.scss @@ -0,0 +1,25 @@ +@use '$styles/veda-ui-theme-vars.scss' as themeVars; + +.usa-logo { + max-width: none !important; +} + +.usa-nav__secondary-item { + font-weight: themeVars.$veda-uswds-fontweight-regular; +} + +@media (width >= themeVars.$veda-uswds-spacing-desktop) { + .usa-nav__secondary-links { + margin-bottom: 1.5rem; + } + + .usa-nav__secondary-item .usa-nav__link { + padding: 0; + color: themeVars.$veda-uswds-color-base-dark; + } + + .usa-nav__primary button[aria-expanded="false"] span:after { + background-color: themeVars.$veda-uswds-color-base-dark; + } +} + diff --git a/app/scripts/components/common/page-header/types.ts b/app/scripts/components/common/page-header/types.ts index 0c9ada42d..d3ec29774 100644 --- a/app/scripts/components/common/page-header/types.ts +++ b/app/scripts/components/common/page-header/types.ts @@ -1,36 +1,40 @@ export type AlignmentEnum = 'left' | 'right'; export enum NavItemType { - INTERNAL_LINK= 'internalLink', - EXTERNAL_LINK= 'externalLink', - DROPDOWN= 'dropdown', - MODAL= 'modal' + INTERNAL_LINK = 'internalLink', + EXTERNAL_LINK = 'externalLink', + DROPDOWN = 'dropdown', + ACTION = 'action', // styled as the link but performs some type of action instead of re-routing } -export interface InternalNavLink { +export type ActionId = 'open-google-form' | undefined; // @NOTE: ActionIds are nav items that perform some type of action but without it being a button + +interface BaseNavItems { + id: string; title: string; +} + +export interface InternalNavLink extends BaseNavItems { to: string; type: NavItemType.INTERNAL_LINK; } -export interface ExternalNavLink { - title: string; +export interface ExternalNavLink extends BaseNavItems { href: string; type: NavItemType.EXTERNAL_LINK; } -export type NavLinkItem = (ExternalNavLink | InternalNavLink); +export type NavLinkItem = ExternalNavLink | InternalNavLink; -export interface ModalNavLink { - title: string; - type: NavItemType.MODAL; - src: string; +export interface ActionNavItem extends BaseNavItems { + actionId: ActionId; + src?: string; + type: NavItemType.ACTION; } -export interface DropdownNavLink { - title: string; +export interface DropdownNavLink extends BaseNavItems { type: NavItemType.DROPDOWN; children: NavLinkItem[]; } -export type NavItem = (NavLinkItem | ModalNavLink | DropdownNavLink); \ No newline at end of file +export type NavItem = NavLinkItem | DropdownNavLink | ActionNavItem; diff --git a/app/scripts/components/common/page-header/use-mobile-menu-fix.ts b/app/scripts/components/common/page-header/use-mobile-menu-fix.ts new file mode 100644 index 000000000..d155d7d07 --- /dev/null +++ b/app/scripts/components/common/page-header/use-mobile-menu-fix.ts @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; + +/** + * Would be better to have this fixed on the react-uswds side! + * + * Subscribe to https://github.com/trussworks/react-uswds/issues/2586 + * for updates + * */ + +const USWDS_DESKTOP_BREAKPOINT = 1024; +// no magic numbers! Use value from theme? + +function useMobileMenuFix(expanded, setExpanded) { + const handleClickOutside = (event: MouseEvent) => { + // When the mobile nav is active, clicking outside of the nav should close it + if ( + event.target instanceof Element && + event.target.closest('.usa-overlay') + ) { + setExpanded(false); + } + }; + + const handleResize = () => { + if (window.innerWidth > USWDS_DESKTOP_BREAKPOINT && expanded) { + setExpanded(false); + } + }; + + useEffect(() => { + document.addEventListener('click', handleClickOutside); + window.addEventListener('resize', handleResize); + + return () => { + document.removeEventListener('click', handleClickOutside); + window.removeEventListener('resize', handleResize); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [expanded]); // We want to register the event listeners only once, when the component mounts, and when the expanded state changes +} + +export default useMobileMenuFix; diff --git a/app/scripts/components/common/uswds/header/extended-nav.tsx b/app/scripts/components/common/uswds/header/extended-nav.tsx new file mode 100644 index 000000000..dfd9f4efa --- /dev/null +++ b/app/scripts/components/common/uswds/header/extended-nav.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { ExtendedNav } from "@trussworks/react-uswds"; + +export function USWDSExtendedNav (props) { + return ; +} \ No newline at end of file diff --git a/app/scripts/components/common/uswds/header/index.tsx b/app/scripts/components/common/uswds/header/index.tsx new file mode 100644 index 000000000..f2e46199e --- /dev/null +++ b/app/scripts/components/common/uswds/header/index.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import { Header, Title } from "@trussworks/react-uswds"; + +export function USWDSHeader (props) { + return
    ; +} + +export function USWDSHeaderTitle (props) { + return ; +} \ No newline at end of file diff --git a/app/scripts/components/common/uswds/header/menu.tsx b/app/scripts/components/common/uswds/header/menu.tsx new file mode 100644 index 000000000..3b1e93f20 --- /dev/null +++ b/app/scripts/components/common/uswds/header/menu.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { Menu} from "@trussworks/react-uswds"; + +export function USWDSMenu (props) { + return <Menu {...props} />; +} \ No newline at end of file diff --git a/app/scripts/components/common/uswds/header/nav-drop-down-button.tsx b/app/scripts/components/common/uswds/header/nav-drop-down-button.tsx new file mode 100644 index 000000000..cdf6e1e64 --- /dev/null +++ b/app/scripts/components/common/uswds/header/nav-drop-down-button.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { NavDropDownButton} from "@trussworks/react-uswds"; + +export function USWDSNavDropDownButton (props) { + return <NavDropDownButton {...props} />; +} \ No newline at end of file diff --git a/app/scripts/components/common/uswds/header/nav-menu-button.tsx b/app/scripts/components/common/uswds/header/nav-menu-button.tsx new file mode 100644 index 000000000..87bdbf56e --- /dev/null +++ b/app/scripts/components/common/uswds/header/nav-menu-button.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { NavMenuButton} from "@trussworks/react-uswds"; + +export function USWDSNavMenuButton (props) { + return <NavMenuButton {...props} />; +} \ No newline at end of file diff --git a/app/scripts/components/common/uswds/index.tsx b/app/scripts/components/common/uswds/index.tsx index de6d276ee..157e364d7 100644 --- a/app/scripts/components/common/uswds/index.tsx +++ b/app/scripts/components/common/uswds/index.tsx @@ -2,4 +2,7 @@ export { USWDSAlert } from './alert'; export { USWDSButtonGroup, USWDSButton } from './button'; export { USWDSLink } from './link'; export { USWDSBanner, USWDSBannerContent } from './banner'; +export { USWDSHeader, USWDSHeaderTitle } from './header'; +export { USWDSNavMenuButton } from './header/nav-menu-button'; +export { USWDSExtendedNav } from './header/extended-nav'; export { USWDSTextInput, USWDSTextInputMask } from './input'; diff --git a/app/scripts/components/home/index.tsx b/app/scripts/components/home/index.tsx index 891aeadcf..a79767758 100644 --- a/app/scripts/components/home/index.tsx +++ b/app/scripts/components/home/index.tsx @@ -68,7 +68,9 @@ const ConnectionsBlock = styled.div` const ConnectionsBlockTitle = styled(Heading).attrs({ as: 'h2', size: 'medium' -})``; +})` + /* no style, only attrs */ +`; const ConnectionsList = styled.ul` ${listReset()}; diff --git a/app/scripts/index.ts b/app/scripts/index.ts index 8c2c2b29c..5bbdc7f8c 100644 --- a/app/scripts/index.ts +++ b/app/scripts/index.ts @@ -17,7 +17,6 @@ import PageHero from '$components/common/page-hero'; import StoriesHubContent from '$components/stories/hub/hub-content'; import { useFiltersWithQS } from '$components/common/catalog/controls/hooks/use-filters-with-query'; import PageHeader from '$components/common/page-header'; -import LogoContainer from '$components/common/page-header/logo-container'; import type { NavItem, InternalNavLink, @@ -67,7 +66,6 @@ export { ReactQueryProvider, EnvConfigProvider, StoriesHubContent, - LogoContainer, ExplorationAndAnalysis, DatasetSelectorModal, diff --git a/app/scripts/styles/_uswds-theme.scss b/app/scripts/styles/_uswds-theme.scss index 39e5f6eb6..c9a410dd8 100644 --- a/app/scripts/styles/_uswds-theme.scss +++ b/app/scripts/styles/_uswds-theme.scss @@ -1,19 +1,22 @@ +// @QUESTION: Move to under uswds dir? @use 'uswds-core' as * with ( - $utilities-use-important: true, - $theme-show-notifications: false, - // To add more custom fonts, see 'Adding fonts to USWDS': - // https://designsystem.digital.gov/design-tokens/typesetting/font-family/ - $theme-typeface-tokens: ( - baseFontFamily: ( - "display-name": var(--base-font-family), - "cap-height": 364px, - ), + $utilities-use-important: true, + $theme-show-notifications: false, + $theme-font-weight-semibold: '600', + // To add more custom fonts, see 'Adding fonts to USWDS': + // https://designsystem.digital.gov/design-tokens/typesetting/font-family/ + $theme-typeface-tokens: + ( + baseFontFamily: ( + 'display-name': var(--base-font-family), + 'cap-height': 364px + ) ), - $theme-type-scale-md: 8, - $theme-utility-breakpoints: ( - "mobile-lg": false, - "desktop": false - ), - $theme-font-type-sans: baseFontFamily, - $theme-font-type-serif: baseFontFamily, -); \ No newline at end of file + $theme-type-scale-md: 8, + $theme-utility-breakpoints: ( + 'mobile-lg': false, + 'desktop': false + ), + $theme-font-type-serif: baseFontFamily, + $theme-font-type-sans: baseFontFamily +); diff --git a/app/scripts/styles/_veda-ui-theme-vars.scss b/app/scripts/styles/_veda-ui-theme-vars.scss new file mode 100644 index 000000000..e086a4a7e --- /dev/null +++ b/app/scripts/styles/_veda-ui-theme-vars.scss @@ -0,0 +1,33 @@ +@forward 'uswds-theme'; +@use 'uswds-core/src/styles/functions/utilities' as utils; +@use 'uswds-core/src/styles/functions/units' as spacing; + +/*********** VEDAUI THEME PALETTE ***********/ +// These map to the veda defined styles between https://www.figma.com/design/5mclPTReHcRIzKbJm8YA6a/VEDA---USWDS?node-id=139-14&node-type=canvas&t=7Qa02mMKUgBy5Qho-0 +// and uswds found at https://designsystem.digital.gov/design-tokens/ + +// TYPOGRAPHY +$veda-uswds-basefont-sans: utils.family('sans'); + +// FONT-SIZE +$veda-uswds-fontsize-3xs: utils.size('body', '3xs'); +$veda-uswds-fontsize-lg: utils.size('body', 'lg'); + +// FONT-WEIGHT +$veda-uswds-fontweight-light: utils.font-weight('light'); +$veda-uswds-fontweight-regular: utils.font-weight('normal'); +$veda-uswds-fontweight-bold: utils.font-weight('bold'); +$veda-uswds-fontweight-semibold: utils.font-weight('semibold'); + +// COLORS +$veda-uswds-color-primary-darker: utils.color('primary-darker'); +$veda-uswds-color-secondary: utils.color('secondary'); +$veda-uswds-color-base-dark: utils.color('base-dark'); +$veda-uswds-color-base-darkest: utils.color('base-darkest'); +$veda-uswds-color-base-light: utils.color('base-light'); +$veda-uswds-color-base-ink: utils.color('ink'); + +// SPACING +$veda-uswds-spacing-5: spacing.units(0.5); +$veda-uswds-spacing-105: spacing.units(1.5); +$veda-uswds-spacing-desktop: spacing.units('desktop'); diff --git a/app/scripts/styles/styles.scss b/app/scripts/styles/styles.scss index 16c5d7607..646f6c989 100644 --- a/app/scripts/styles/styles.scss +++ b/app/scripts/styles/styles.scss @@ -1,13 +1,12 @@ -@forward 'uswds-theme'; - -@use "uswds-utilities"; -@use "usa-layout-grid"; -@use "usa-banner"; -@use "usa-button"; -@use "usa-card"; -@use "usa-alert"; -@use "usa-button-group"; -@use "usa-icon"; -@use "usa-modal"; - +@forward 'veda-ui-theme-vars'; +@use 'uswds-utilities'; +@use 'usa-layout-grid'; +@use 'usa-banner'; +@use 'usa-button'; +@use 'usa-card'; +@use 'usa-alert'; +@use 'usa-button-group'; +@use 'usa-icon'; +@use 'usa-modal'; +@use 'usa-header'; diff --git a/app/scripts/styles/theme.ts b/app/scripts/styles/theme.ts index 767553398..fa0fe80e8 100644 --- a/app/scripts/styles/theme.ts +++ b/app/scripts/styles/theme.ts @@ -31,7 +31,7 @@ export const VEDA_OVERRIDE_THEME = { }, type: { base: { - family: '"Open Sans",sans-serif', + family: '"Public Sans",sans-serif', leadSize: '1.25rem', extrabold: '800', // Increments to the type.base.size for each media breakpoint. diff --git a/app/scripts/utils/utils.ts b/app/scripts/utils/utils.ts index 034f83821..088e54764 100644 --- a/app/scripts/utils/utils.ts +++ b/app/scripts/utils/utils.ts @@ -87,12 +87,11 @@ export function composeVisuallyDisabled( `; } - /** * Checks if the given environment variable is set to 'true', ignoring case. * @param value - The value of the environment variable. * @returns A boolean indicating whether the flag is true. */ -export function checkEnvFlag(value?: string) { +export function checkEnvFlag(value?: string): boolean { return (value ?? '').toLowerCase() === 'true'; } diff --git a/jest-transformer-mdx.js b/jest-transformer-mdx.js new file mode 100644 index 000000000..52c87de72 --- /dev/null +++ b/jest-transformer-mdx.js @@ -0,0 +1,18 @@ +/* eslint-disable no-unused-vars */ +// const mdx = require('@mdx-js/react'); + +/** + * Currently, Jest tests for components importing MDX content will fail. + * Properly setting up MDX rendering in Jest is complex. For now, + * we will render the MDX content as null to prevent Jest errors. + */ + +const transformer = { + process: function (sourceText, sourcePath, options) { + return { + code: 'module.exports = () => null' + }; + } +}; + +module.exports = transformer; diff --git a/jest.config.js b/jest.config.js index dfdca18ce..f1ff4090f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -140,6 +140,7 @@ module.exports = { // The paths to modules that run some code to configure or set up the testing environment before each test // setupFiles: [], + setupFiles: [`<rootDir>/jest.setup.js`], // A list of paths to modules that run some code to configure or set up the testing framework before each test // setupFilesAfterEnv: [], @@ -188,7 +189,8 @@ module.exports = { // A map from regular expressions to paths to transformers transform: { '^.+\\.(js|jsx)$': 'babel-jest', - '^.+\\.(ts|tsx)?$': 'ts-jest' + '^.+\\.(ts|tsx)?$': 'ts-jest', + '^.+\\.mdx$': '<rootDir>/jest-transformer-mdx.js' }, // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 000000000..d402fb974 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,11 @@ +jest.mock('@mdx-js/react', () => ({ + MDXProvider: () => null +})); +jest.mock('veda', () => ({ + getString: (variable) => ({ + one: variable, + other: variable + }), + getNavItemsFromVedaConfig: () => [] +})); +export default undefined; diff --git a/mock/veda.config.js b/mock/veda.config.js index 97fde679c..5aadadc95 100644 --- a/mock/veda.config.js +++ b/mock/veda.config.js @@ -6,28 +6,46 @@ function checkEnvFlag(value) { let mainNavItems = [ { + id: 'test', title: 'Test', type: 'dropdown', children: [ { - title: 'test dropdown', + id: 'dropdown-menu-item-1', + title: 'dropdown menu item 1', to: '/stories', type: 'internalLink' } ] }, { + id: 'another-test', + title: 'Another Test', + type: 'dropdown', + children: [ + { + id: 'dropdown-menu-item-2', + title: 'dropdown menu item 2', + to: '/stories', + type: 'internalLink' + } + ] + }, + { + id: 'data-catalog', title: 'Data Catalog', to: '/data-catalog', type: 'internalLink' }, { + id: 'exploration', title: 'Exploration', to: '/exploration', type: 'internalLink' }, { - title: 'stories', + id: 'stories', + title: 'Stories', to: '/stories', type: 'internalLink' } @@ -37,6 +55,7 @@ if (!!config.HUB_URL && !!config.HUB_NAME) mainNavItems = [ ...mainNavItems, { + id: 'hub', title: process.env.HUB_NAME, href: process.env.HUB_URL, type: 'externalLink' @@ -45,6 +64,7 @@ if (!!config.HUB_URL && !!config.HUB_NAME) let subNavItems = [ { + id: 'about', title: 'About', to: '/about', type: 'internalLink' @@ -55,9 +75,10 @@ if (config.GOOGLE_FORM) { subNavItems = [ ...subNavItems, { + id: 'contact-us', title: 'Contact us', - src: config.GOOGLE_FORM, - type: 'modal' + actionId: 'open-google-form', + type: 'action' } ]; } diff --git a/package.json b/package.json index 441d0953f..4783b332a 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@release-it/conventional-changelog": "^9.0.3", "@testing-library/jest-dom": "^5.16.2", "@testing-library/react": "^12.1.2", + "@testing-library/user-event": "^14.5.2", "@types/d3": "^7.4.0", "@types/mapbox-gl": "^2.7.5", "@types/node": "^22.5.0", @@ -99,6 +100,7 @@ "parcel-transformer-mdx": "link:./parcel-transformer-mdx", "parcel-transformer-mdx-frontmatter": "link:./parcel-transformer-mdx-frontmatter", "portscanner": "^2.2.0", + "postcss-styled-syntax": "^0.7.0", "posthtml-expressions": "^1.9.0", "prettier": "^2.4.1", "process": "^0.11.10", @@ -106,10 +108,9 @@ "remark-gfm": "^3.0.1", "stream-browserify": "^3.0.0", "string_decoder": "^1.3.0", - "stylelint": "^13.12", - "stylelint-config-recommended": "^3.0.0", - "stylelint-config-styled-components": "^0.1.1", - "stylelint-processor-styled-components": "^1.10.0", + "stylelint": "^16.10.0", + "stylelint-config-recommended": "^14.0.1", + "stylelint-config-standard-scss": "^13.1.0", "ts-jest": "^28.0.7", "typescript": "^4.5.5" }, diff --git a/parcel-resolver-veda/index.d.ts b/parcel-resolver-veda/index.d.ts index eee428ff4..a47e84c72 100644 --- a/parcel-resolver-veda/index.d.ts +++ b/parcel-resolver-veda/index.d.ts @@ -280,23 +280,21 @@ declare module 'veda' { } interface InternalNavLink { + id: string; title: string; to: string; type: 'internalLink'; } interface ExternalNavLink { + id: string; title: string; href: string; type: 'externalLink'; } type NavLinkItem = ExternalNavLink | InternalNavLink; - export interface ModalNavLink { - title: string; - type: 'modal'; - src: string; - } export interface DropdownNavLink { + id: string; title: string; type: 'dropdown'; children: NavLinkItem[]; @@ -352,10 +350,10 @@ declare module 'veda' { export const getNavItemsFromVedaConfig: () => | { mainNavItems: - | (NavLinkItem | ModalNavLink | DropdownNavLink)[] + | (NavLinkItem | DropdownNavLink)[] | undefined; subNavItems: - | (NavLinkItem | ModalNavLink | DropdownNavLink)[] + | (NavLinkItem | DropdownNavLink)[] | undefined; } | undefined; diff --git a/yarn.lock b/yarn.lock index 214dee875..0f1903eaa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16922,4 +16922,4 @@ zwitch@^1.0.0: zwitch@^2.0.0: version "2.0.2" resolved "http://verdaccio.ds.io:4873/zwitch/-/zwitch-2.0.2.tgz#91f8d0e901ffa3d66599756dde7f57b17c95dce1" - integrity sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA== + integrity sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA== \ No newline at end of file