-
Notifications
You must be signed in to change notification settings - Fork 85
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Co-authored-by: Shauna Keating <[email protected]>
- Loading branch information
1 parent
d4c0b4e
commit d330a12
Showing
6 changed files
with
811 additions
and
0 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
@media not (prefers-reduced-motion) { | ||
html { | ||
scroll-behavior: smooth; | ||
} | ||
} | ||
|
||
:target { | ||
scroll-margin-top: var(--margin-offset); | ||
} |
77 changes: 77 additions & 0 deletions
77
src/components/InPageNavigation/InPageNavigation.stories.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,77 @@ | ||
import React from 'react' | ||
import { InPageNavigation } from './InPageNavigation' | ||
import { CONTENT } from './content' | ||
import { HeadingLevel } from '../../types/headingLevel' | ||
import classNames from 'classnames' | ||
Check warning on line 5 in src/components/InPageNavigation/InPageNavigation.stories.tsx GitHub Actions / Publish next to Github Packages
|
||
|
||
export default { | ||
title: 'Components/In-Page Navigation', | ||
component: InPageNavigation, | ||
argTypes: { | ||
headingLevel: { | ||
control: 'select', | ||
options: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], | ||
}, | ||
rootMargin: { | ||
control: 'text', | ||
}, | ||
threshold: { | ||
control: { type: 'range', min: 0, max: 1, step: 0.01 }, | ||
}, | ||
title: { | ||
control: 'text', | ||
}, | ||
}, | ||
args: { | ||
headingLevel: 'h4', | ||
rootMargin: '0px 0px 0px 0px', | ||
threshold: 1, | ||
title: 'On this page', | ||
}, | ||
parameters: { | ||
docs: { | ||
description: { | ||
component: ` | ||
### USWDS 3.0 In-Page Navigation component | ||
Source: https://designsystem.digital.gov/components/in-page-navigation/ | ||
`, | ||
}, | ||
}, | ||
}, | ||
} | ||
|
||
type StorybookArguments = { | ||
headingLevel: HeadingLevel | ||
rootMargin: string | ||
scrollOffset: string | ||
threshold: number | ||
title: string | ||
} | ||
|
||
export const Default = (argTypes: StorybookArguments): React.ReactElement => ( | ||
<InPageNavigation | ||
content={CONTENT} | ||
headingLevel={argTypes.headingLevel} | ||
mainProps={{ className: 'usa-prose' }} | ||
rootMargin={argTypes.rootMargin} | ||
threshold={argTypes.threshold} | ||
title={argTypes.title} | ||
/> | ||
) | ||
|
||
// Storybook seems to force anchor links to open in a new window, | ||
// so this story is just to demonstrate how the scroll offset works | ||
export const ScrollOffset = ( | ||
argTypes: StorybookArguments | ||
): React.ReactElement => ( | ||
<InPageNavigation | ||
content={CONTENT} | ||
headingLevel={argTypes.headingLevel} | ||
mainProps={{ className: 'usa-prose' }} | ||
rootMargin={argTypes.rootMargin} | ||
scrollOffset="2rem" | ||
threshold={argTypes.threshold} | ||
title={argTypes.title} | ||
/> | ||
) |
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,63 @@ | ||
import React from 'react' | ||
import { screen, render, getByRole } from '@testing-library/react' | ||
import userEvent from '@testing-library/user-event' | ||
import { InPageNavigation } from './InPageNavigation' | ||
import { HeadingLevel } from '../../types/headingLevel' | ||
import { CONTENT } from './content' | ||
|
||
describe('InPageNavigation component', () => { | ||
const props = { | ||
content: CONTENT, | ||
headingLevel: 'h1' as HeadingLevel, | ||
title: 'What do we have <i>here</i>?', | ||
} | ||
|
||
const setup = (plain?: boolean) => { | ||
const utils = plain | ||
? render(<InPageNavigation content={props.content} />) | ||
: render( | ||
<InPageNavigation | ||
content={props.content} | ||
headingLevel={props.headingLevel} | ||
title={props.title} | ||
/> | ||
) | ||
const nav = screen.getByTestId('InPageNavigation') | ||
const user = userEvent.setup() | ||
return { | ||
nav, | ||
user, | ||
...utils, | ||
} | ||
} | ||
|
||
beforeEach(() => { | ||
// IntersectionObserver isn't available in test environment | ||
const mockIntersectionObserver = jest.fn() | ||
mockIntersectionObserver.mockReturnValue({ | ||
observe: () => null, | ||
unobserve: () => null, | ||
disconnect: () => null, | ||
}) | ||
window.IntersectionObserver = mockIntersectionObserver | ||
}) | ||
|
||
it('renders without errors', () => { | ||
const { nav } = setup(true) | ||
expect(nav).toBeInTheDocument() | ||
const heading = getByRole(nav, 'heading', { | ||
level: 4, | ||
name: 'On this page', | ||
}) | ||
expect(heading).toBeInTheDocument() | ||
}) | ||
|
||
it('sets the heading and title', () => { | ||
const { nav } = setup() | ||
const heading = getByRole(nav, 'heading', { | ||
level: Number(props.headingLevel.slice(-1)), | ||
name: props.title, | ||
}) | ||
expect(heading).toBeInTheDocument() | ||
}) | ||
}) |
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,104 @@ | ||
import React, { useEffect, useState } from 'react' | ||
import classnames from 'classnames' | ||
import { HeadingLevel } from '../../types/headingLevel' | ||
import { Link } from '../Link/Link' | ||
import styles from './InPageNavigation.module.scss' | ||
|
||
type InPageNavigationProps = { | ||
className?: string | ||
content: JSX.Element | ||
headingLevel?: HeadingLevel | ||
mainProps?: JSX.IntrinsicElements['main'] | ||
navProps?: JSX.IntrinsicElements['nav'] | ||
rootMargin?: string | ||
scrollOffset?: string | ||
threshold?: number | ||
title?: string | ||
} | ||
|
||
export const InPageNavigation = ({ | ||
className, | ||
content, | ||
headingLevel = 'h4', | ||
mainProps, | ||
navProps, | ||
rootMargin = '0px 0px 0px 0px', | ||
scrollOffset, | ||
threshold = 1, | ||
title = 'On this page', | ||
...divProps | ||
}: InPageNavigationProps & | ||
JSX.IntrinsicElements['div']): React.ReactElement => { | ||
const classes = classnames('usa-in-page-nav', styles.target, className) | ||
const { className: navClassName, ...remainingNavProps } = navProps || {} | ||
const navClasses = classnames('usa-in-page-nav__nav', navClassName) | ||
const { className: mainClassName, ...remainingMainProps } = mainProps || {} | ||
const mainClasses = classnames('main-content', mainClassName) | ||
const Heading = headingLevel | ||
const offsetStyle = { | ||
'--margin-offset': scrollOffset, | ||
} as React.CSSProperties | ||
const [currentSection, setCurrentSection] = useState('') | ||
const sectionHeadings: JSX.Element[] = content.props.children.filter( | ||
(el: JSX.Element) => el.type === 'h2' || el.type === 'h3' | ||
) | ||
const handleIntersection = (entries: IntersectionObserverEntry[]) => { | ||
entries.forEach((entry) => { | ||
if (entry.isIntersecting) { | ||
setCurrentSection(entry.target.id) | ||
} | ||
}) | ||
} | ||
const observerOptions = { | ||
root: null, | ||
rootMargin: rootMargin, | ||
threshold: [threshold], | ||
} | ||
const observer = new IntersectionObserver(handleIntersection, observerOptions) | ||
useEffect(() => { | ||
document.querySelectorAll('h2,h3').forEach((h) => observer.observe(h)) | ||
}) | ||
|
||
return ( | ||
<div className="usa-in-page-nav-container" {...divProps}> | ||
<aside | ||
className={classes} | ||
aria-label={title} | ||
data-testid="InPageNavigation"> | ||
<nav className={navClasses} {...remainingNavProps}> | ||
<Heading className="usa-in-page-nav__heading" tabIndex={0}> | ||
{title} | ||
</Heading> | ||
<ul className="usa-in-page-nav__list"> | ||
{sectionHeadings.map((el: JSX.Element) => { | ||
const heading: JSX.Element = el.props.children | ||
const href: string = el.props.id | ||
const hClass = classnames('usa-in-page-nav__item', { | ||
'usa-in-page-nav__item--sub-item': el.type === 'h3', | ||
}) | ||
const lClass = classnames('usa-in-page-nav__link', { | ||
'usa-current': href === currentSection, | ||
}) | ||
return ( | ||
<li key={`usa-in-page-nav__item_${heading}`} className={hClass}> | ||
<Link href={`#${href}`} className={lClass}> | ||
{heading} | ||
</Link> | ||
</li> | ||
) | ||
})} | ||
</ul> | ||
</nav> | ||
</aside> | ||
<main | ||
id="main-content" | ||
className={mainClasses} | ||
{...remainingMainProps} | ||
style={scrollOffset ? offsetStyle : undefined}> | ||
{content} | ||
</main> | ||
</div> | ||
) | ||
} | ||
|
||
export default InPageNavigation |
Oops, something went wrong.