bbc/psammead

View on GitHub
packages/components/psammead-navigation/src/index.jsx

Summary

Maintainability
A
2 hrs
Test Coverage
A
100%
import React from 'react';
import styled from '@emotion/styled';
import { shape, string, node, bool, oneOf } from 'prop-types';
import VisuallyHiddenText from '@bbc/psammead-visually-hidden-text';
import { C_WHITE, C_EBON } from '@bbc/psammead-styles/colours';
import {
  GEL_SPACING_HLF,
  GEL_SPACING,
  GEL_SPACING_SEXT,
} from '@bbc/gel-foundations/spacings';
import {
  GEL_GROUP_2_SCREEN_WIDTH_MAX,
  GEL_GROUP_3_SCREEN_WIDTH_MIN,
  GEL_GROUP_5_SCREEN_WIDTH_MIN,
} from '@bbc/gel-foundations/breakpoints';
import { getPica } from '@bbc/gel-foundations/typography';
import { scriptPropType } from '@bbc/gel-foundations/prop-types';
import { getSansRegular } from '@bbc/psammead-styles/font-styles';
import { NAV_BAR_TOP_BOTTOM_SPACING } from './DropdownNavigation';

const SPACING_AROUND_NAV_ITEMS = `${NAV_BAR_TOP_BOTTOM_SPACING}rem`; // 12px
const CURRENT_ITEM_HOVER_BORDER = '0.3125rem'; // 5px

const NavWrapper = styled.div`
  position: relative;
  max-width: ${GEL_GROUP_5_SCREEN_WIDTH_MIN};
  margin: 0 auto;
`;

const StyledUnorderedList = styled.ul`
  list-style-type: none;
  padding: 0;
  margin: 0;
  position: relative;

  @media (min-width: ${GEL_GROUP_3_SCREEN_WIDTH_MIN}) {
    overflow: hidden;
  }
`;

const ListItemBorder = `
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
`;

const StyledLink = styled.a`
  ${({ script }) => script && getPica(script)};
  ${({ service }) => getSansRegular(service)};
  ${({ brandForegroundColour }) => `color: ${brandForegroundColour};`}
  cursor: pointer;
  text-decoration: none;
  display: inline-block;
  padding: ${SPACING_AROUND_NAV_ITEMS};

  @media (max-width: ${GEL_GROUP_2_SCREEN_WIDTH_MAX}) {
    padding: ${SPACING_AROUND_NAV_ITEMS} ${GEL_SPACING};
  }

  &:hover::after {
    ${ListItemBorder}
    ${({ brandHighlightColour }) =>
      `border-bottom: ${GEL_SPACING_HLF} solid ${brandHighlightColour};`}
    ${({ currentLink, brandHighlightColour }) =>
      currentLink &&
      `
        border-bottom: ${CURRENT_ITEM_HOVER_BORDER} solid ${brandHighlightColour};
      `}
  }

  &:focus::after {
    ${ListItemBorder}
    ${({ brandHighlightColour }) =>
      `border-bottom: ${GEL_SPACING_HLF} solid ${brandHighlightColour};`}
    top: 0;
    ${({ brandHighlightColour }) =>
      `border: ${GEL_SPACING_HLF} solid ${brandHighlightColour};`}
  }
`;

const StyledListItem = styled.li`
  display: inline-block;
  position: relative;
  z-index: 2;

  @media (max-width: ${GEL_GROUP_2_SCREEN_WIDTH_MAX}) {
    &:last-child {
      ${({ dir }) => `
        margin-${dir === 'ltr' ? 'right' : 'left'}: ${GEL_SPACING_SEXT}; 
      `}
    }
  }

  @media (min-width: ${GEL_GROUP_3_SCREEN_WIDTH_MIN}) {
    /* Trick to display a border between the list items when it breaks into multiple lines, which takes the full width */
    &::after {
      content: '';
      position: absolute;
      bottom: -1px;
      width: ${GEL_GROUP_5_SCREEN_WIDTH_MIN};
      ${({ brandBorderColour }) =>
        `border-bottom: 0.0625rem solid ${brandBorderColour};`}
      z-index: -1;
    }
  }
`;

const StyledSpan = styled.span`
  &::after {
    ${ListItemBorder}
    ${({ brandHighlightColour }) =>
      `border-bottom: ${GEL_SPACING_HLF} solid ${brandHighlightColour};`}
  }
`;

const CurrentLink = ({
  linkId,
  children: link,
  script,
  currentPageText,
  brandHighlightColour,
}) => (
  <>
    <StyledSpan
      // eslint-disable-next-line jsx-a11y/aria-role
      role="text"
      script={script}
      brandHighlightColour={brandHighlightColour}
      // This is a temporary fix for the a11y nested span's bug experienced in TalkBack, refer to the following issue: https://github.com/bbc/simorgh/issues/9652
      id={`NavigationLinks-${linkId}`}
    >
      <VisuallyHiddenText>{`${currentPageText}, `}</VisuallyHiddenText>
      {link}
    </StyledSpan>
  </>
);

CurrentLink.propTypes = {
  linkId: string.isRequired,
  children: string.isRequired,
  script: shape(scriptPropType).isRequired,
  currentPageText: string,
  brandHighlightColour: string.isRequired,
};

CurrentLink.defaultProps = {
  currentPageText: null,
};

export const NavigationUl = ({ children, ...props }) => (
  <StyledUnorderedList role="list" {...props}>
    {children}
  </StyledUnorderedList>
);

NavigationUl.propTypes = {
  children: node.isRequired,
};

export const NavigationLi = ({
  children: link,
  url,
  script,
  currentPageText,
  active,
  service,
  dir,
  brandForegroundColour,
  brandHighlightColour,
  brandBorderColour,
  ...props
}) => {
  return (
    <StyledListItem
      dir={dir}
      role="listitem"
      brandForegroundColour={brandForegroundColour}
      brandHighlightColour={brandHighlightColour}
      brandBorderColour={brandBorderColour}
    >
      {active && currentPageText ? (
        <StyledLink
          href={url}
          script={script}
          service={service}
          currentLink
          brandForegroundColour={brandForegroundColour}
          brandHighlightColour={brandHighlightColour}
          // This is a temporary fix for the a11y nested span's bug experienced in TalkBack, refer to the following issue: https://github.com/bbc/simorgh/issues/9652
          aria-labelledby={`NavigationLinks-${link}`}
          {...props}
        >
          <CurrentLink
            linkId={link}
            script={script}
            currentPageText={currentPageText}
            brandHighlightColour={brandHighlightColour}
          >
            {link}
          </CurrentLink>
        </StyledLink>
      ) : (
        <StyledLink
          href={url}
          script={script}
          service={service}
          brandForegroundColour={brandForegroundColour}
          brandHighlightColour={brandHighlightColour}
          {...props}
        >
          {link}
        </StyledLink>
      )}
    </StyledListItem>
  );
};

NavigationLi.propTypes = {
  children: node.isRequired,
  url: string.isRequired,
  script: shape(scriptPropType).isRequired,
  active: bool,
  currentPageText: string,
  service: string.isRequired,
  dir: oneOf(['ltr', 'rtl']),
  brandForegroundColour: string.isRequired,
  brandHighlightColour: string.isRequired,
  brandBorderColour: string.isRequired,
};

NavigationLi.defaultProps = {
  active: false,
  currentPageText: null,
  dir: 'ltr',
};

// ampOpenClass is the class added to the Navigation, and is toggled on tap.
// It indicates whether the menu is open or not. This overrides the background
// color of the Navigation
const StyledNav = styled.nav`
  position: relative;
  ${({ isOpen, brandBackgroundColour }) =>
    `background-color: ${isOpen ? C_EBON : brandBackgroundColour};`}
  ${({ ampOpenClass }) =>
    ampOpenClass &&
    `
      &.${ampOpenClass} {
        @media (max-width: ${GEL_GROUP_2_SCREEN_WIDTH_MAX}) {
          background-color: ${C_EBON};
        }
      }
    `}
  border-top: 0.0625rem solid ${C_WHITE};

  &::after {
    content: '';
    position: absolute;
    bottom: 0;
    right: 0;
    left: 0;
    border-bottom: 0.0625rem solid transparent;
  }

  ${StyledListItem} {
    ${({ dir }) => `
      &::after {
        ${dir === 'ltr' ? 'left' : 'right'}: 0;
      }
    `}
  }
`;

const Navigation = ({
  children,
  dir,
  isOpen,
  ampOpenClass,
  brandBackgroundColour,
  brandForegroundColour,
  brandBorderColour,
  brandHighlightColour,
  ...props
}) => {
  return (
    <StyledNav
      role="navigation"
      dir={dir}
      isOpen={isOpen}
      ampOpenClass={ampOpenClass}
      brandBackgroundColour={brandBackgroundColour}
      brandForegroundColour={brandForegroundColour}
      brandBorderColour={brandBorderColour}
      brandHighlightColour={brandHighlightColour}
      {...props}
    >
      <NavWrapper>{children}</NavWrapper>
    </StyledNav>
  );
};

Navigation.propTypes = {
  children: node.isRequired,
  dir: oneOf(['ltr', 'rtl']),
  isOpen: bool,
  ampOpenClass: string,
  brandBackgroundColour: string.isRequired,
  brandForegroundColour: string.isRequired,
  brandBorderColour: string.isRequired,
  brandHighlightColour: string.isRequired,
};

Navigation.defaultProps = {
  dir: 'ltr',
  isOpen: false,
  ampOpenClass: null,
};

export default Navigation;