Skip to content

Commit

Permalink
Feat: Add Mobile styles to FooterNav (#181)
Browse files Browse the repository at this point in the history
- refactor out FooterExtendedNavList
- add more tests
  • Loading branch information
haworku committed May 22, 2020
1 parent cb580f1 commit ff87459
Show file tree
Hide file tree
Showing 8 changed files with 450 additions and 70 deletions.
114 changes: 114 additions & 0 deletions src/components/Footer/Footer/Footer.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,3 +283,117 @@ export const BigFooter = (): React.ReactElement => (
}
/>
)

export const MobileBigFooter = (): React.ReactElement => (
<Footer
big
returnToTop={returnToTop}
primary={
<div className="grid-container">
<div className="grid-row grid-gap">
<div className="tablet:grid-col-8">
<FooterNav
isMobile
big
links={[
[
'Topic',
...Array(2).fill(<a href="#">Secondary link</a>),
<a key="4" href="#">
Secondary link that is a bit longer than most of the others
</a>,
<a key="5" href="#">
Secondary link
</a>,
],
[
'Topic',
<a key="2" href="#">
Secondary link that is pretty long
</a>,
...Array(3).fill(<a href="#">Secondary link</a>),
],
[
'Topic',
...Array(4).fill(
<a className="usa-footer__secondary-link" href="#">
Secondary link
</a>
),
],
[
'Topic',
...Array(4).fill(
<a className="usa-footer__secondary-link" href="#">
Secondary link
</a>
),
],
]}
/>
</div>
<div className="tablet:grid-col-4">
<SignUpForm />
</div>
</div>
</div>
}
secondary={
<div className="grid-row grid-gap">
<Logo
big
image={
<img
className="usa-footer__logo-img"
alt="img alt text"
src={logoImg}
/>
}
heading={<h3 className="usa-footer__logo-heading">Name of Agency</h3>}
/>
<div className="usa-footer__contact-links mobile-lg:grid-col-6">
<SocialLinks
links={[
<a
key="facebook"
className="usa-social-link usa-social-link--facebook"
href="#">
<span>Facebook</span>
</a>,
<a
key="twitter"
className="usa-social-link usa-social-link--twitter"
href="#">
<span>Twitter</span>
</a>,
<a
key="youtube"
className="usa-social-link usa-social-link--youtube"
href="#">
<span>YouTube</span>
</a>,
<a
key="rss"
className="usa-social-link usa-social-link--rss"
href="#">
<span>RSS</span>
</a>,
]}
/>
<h3 className="usa-footer__contact-heading">Agency Contact Center</h3>
<Address
big
items={[
<a key="telephone" href="tel:1-800-555-5555">
(800) CALL-GOVT
</a>,
<a key="email" href="mailto:info@agency.gov">
info@agency.gov
</a>,
]}
/>
</div>
</div>
}
/>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/* eslint-disable jsx-a11y/anchor-is-valid, react/jsx-key */

import React from 'react'
import { fireEvent, render } from '@testing-library/react'

import { FooterExtendedNavList } from './FooterExtendedNavList'

const links = [
[
'Types of Cats',
...Array(2).fill(
<a className="usa-footer__secondary-link" href="#">
Cheetah
</a>
),
],
[
'Musical Gifts',
...Array(3).fill(
<a className="usa-footer__secondary-link" href="#">
Purple Rain
</a>
),
],
]

describe('FooterExtendedNavList component', () => {
it('renders without errors', () => {
const { container } = render(<FooterExtendedNavList nestedLinks={links} />)
expect(container.querySelector('ul')).toBeInTheDocument()
})

it('renders headings', () => {
const { container, getByText } = render(
<FooterExtendedNavList nestedLinks={links} />
)
expect(container.querySelectorAll('h4')).toHaveLength(2)
expect(getByText('Types of Cats')).toBeInTheDocument()
expect(getByText('Musical Gifts')).toBeInTheDocument()
})

it('renders links', () => {
const { container, getAllByText } = render(
<FooterExtendedNavList nestedLinks={links} />
)
expect(container.querySelectorAll('a')).toHaveLength(5)
expect(getAllByText('Purple Rain')).toHaveLength(3)
expect(getAllByText('Cheetah')).toHaveLength(2)
})

it('does not toggle section visiblity onClick in desktop view', () => {
const { getAllByText, getByText } = render(
<FooterExtendedNavList nestedLinks={links} />
)

fireEvent.click(getByText('Types of Cats'))
expect(getAllByText('Purple Rain')).toHaveLength(3)
expect(getAllByText('Cheetah')).toHaveLength(2)
})

describe('isMobile prop', () => {
it('renders mobile styles on all sections on initial load', () => {
const { container } = render(
<FooterExtendedNavList isMobile nestedLinks={links} />
)

const sections = container.querySelectorAll('section')
const elementsWithHiddenClass = container.querySelectorAll('.hidden')
expect(sections.length).toEqual(elementsWithHiddenClass.length)
})

it('renders headings', () => {
const { container, getByText } = render(
<FooterExtendedNavList isMobile nestedLinks={links} />
)
expect(container.querySelectorAll('h4')).toHaveLength(2)
expect(getByText('Types of Cats')).toBeInTheDocument()
expect(getByText('Musical Gifts')).toBeInTheDocument()
})

it('hides secondary links on initial load', () => {
const { getAllByText } = render(
<FooterExtendedNavList isMobile nestedLinks={links} />
)
expect(getAllByText('Cheetah')).not.toBeInTheDocument
expect(getAllByText('Purple Rain')).not.toBeInTheDocument
})

it('toggles section visibility onClick', () => {
const { getByText, getAllByText } = render(
<FooterExtendedNavList isMobile nestedLinks={links} />
)

fireEvent.click(getByText('Types of Cats'))

expect(getAllByText('Cheetah')).toHaveLength(2)
expect(getAllByText('Purple Rain')).not.toBeInTheDocument
})

it('toggles one section expanded at a time onClick', () => {
const { getAllByText, getByText } = render(
<FooterExtendedNavList isMobile nestedLinks={links} />
)

fireEvent.click(getByText('Types of Cats'))
fireEvent.click(getByText('Musical Gifts'))

expect(getAllByText('Purple Rain')).toBeInTheDocument
expect(getAllByText('Cheetah')).not.toBeInTheDocument
})

it('does not render mobile styles when isMobile is undefined in desktop view', () => {
// JSDOM window.innerWidth default is 1024
const { container } = render(
<FooterExtendedNavList nestedLinks={links} />
)

const elementsWithHiddenClass = container.querySelectorAll('.hidden')
expect(elementsWithHiddenClass.length).toEqual(0)
})

describe('when client window width is less than mobile threshold', () => {
beforeEach(() => {
// Mobile width is less than 480
//eslint-disable-next-line @typescript-eslint/ban-ts-ignore
//@ts-ignore
window.innerWidth = 479
})

afterEach(() => {
// Return to JSDOM default
//eslint-disable-next-line @typescript-eslint/ban-ts-ignore
//@ts-ignore
window.innerWidth = 1024
})

it('renders mobile styles if isMobile is undefined', () => {
const { container } = render(
<FooterExtendedNavList nestedLinks={links} />
)

const sections = container.querySelectorAll('section')
const elementsWithHiddenClass = container.querySelectorAll('.hidden')
expect(sections.length).toEqual(elementsWithHiddenClass.length)
})

it('does not render mobile styles when isMobile is false', () => {
const { container } = render(
<FooterExtendedNavList isMobile={false} nestedLinks={links} />
)

const elementsWithHiddenClass = container.querySelectorAll('.hidden')
expect(elementsWithHiddenClass.length).toEqual(0)
})
})
})
})
100 changes: 100 additions & 0 deletions src/components/Footer/FooterExtendedNavList/FooterExtendedNavList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React, { useState, useEffect } from 'react'
import classnames from 'classnames'
import { NavList } from '../../header/NavList/NavList'

export type ExtendedNavLinksType = React.ReactNode[][]

type FooterExtendedNavListProps = {
isMobile?: boolean
/*
Turn on mobile styles via prop. If undefined, a fallback is used based on the client window width.
*/
/*
Multidimensional array of grouped nav links. Sub-arrays are column sections, first element is used as a heading.
*/
nestedLinks: ExtendedNavLinksType
}

export const FooterExtendedNavList = ({
className,
isMobile,
nestedLinks,
}: FooterExtendedNavListProps &
React.HTMLAttributes<HTMLElement>): React.ReactElement => {
const classes = classnames('grid-row grid-gap-4', className)
const isClient = window && typeof window === 'object'

const [isMobileFallback, setIsMobileFallback] = React.useState<boolean>(
isClient && window.innerWidth < 480
)
const [sectionsOpenState, setSectionsOpenState] = useState<boolean[]>(
Array(nestedLinks.length).fill(false)
)

// Use isMobile prop, fallback to calculated state if undefined
const useMobile = isMobile || (isMobile === undefined && isMobileFallback)

useEffect(() => {
if (isMobile) return

function handleResize(): void {
const updatedIsMobileFallback = isClient && window.innerWidth < 480
if (updatedIsMobileFallback !== isMobileFallback) {
setIsMobileFallback(updatedIsMobileFallback)
}
}

window.addEventListener('resize', handleResize)
return (): void => window.removeEventListener('resize', handleResize)
}, [])

const onToggle = (index: number): void => {
setSectionsOpenState((prevIsOpen) => {
const newIsOpen = Array(nestedLinks.length).fill(false)
// eslint-disable-next-line security/detect-object-injection
newIsOpen[index] = !prevIsOpen[index]
return newIsOpen
})
}

return (
<div className={classes}>
{nestedLinks.map((links, i) => (
<div
key={`linkSection-${i}`}
className="mobile-lg:grid-col-6 desktop:grid-col-3">
<Section
onToggle={useMobile ? (): void => onToggle(i) : undefined}
// eslint-disable-next-line security/detect-object-injection
isOpen={useMobile ? sectionsOpenState[i] : true}
links={links}
/>
</div>
))}
</div>
)
}

const Section = ({
isOpen = false,
links,
onToggle,
}: {
isOpen: boolean
links: React.ReactNode[]
onToggle?: () => void
}): React.ReactElement => {
const [primaryLinkOrHeading, ...secondaryLinks] = links
const classes = classnames(
'usa-footer__primary-content usa-footer__primary-content--collapsible',
{ hidden: !isOpen }
)

return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<section className={classes} onClick={onToggle} onKeyPress={onToggle}>
<h4 className="usa-footer__primary-link">{primaryLinkOrHeading}</h4>
<NavList footerSecondary items={secondaryLinks} />
</section>
)
}
Loading

0 comments on commit ff87459

Please sign in to comment.