-
Notifications
You must be signed in to change notification settings - Fork 81
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Feat: Add Mobile styles to FooterNav (#181)
- refactor out FooterExtendedNavList - add more tests
- Loading branch information
Showing
8 changed files
with
450 additions
and
70 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
157 changes: 157 additions & 0 deletions
157
src/components/Footer/FooterExtendedNavList/FooterExtendedNavList.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
100
src/components/Footer/FooterExtendedNavList/FooterExtendedNavList.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
Oops, something went wrong.