Skip to content

Commit

Permalink
feat: in-page navigation (#2551)
Browse files Browse the repository at this point in the history
Co-authored-by: Shauna Keating <59394696+shkeating@users.noreply.github.com>
  • Loading branch information
werdnanoslen and shkeating committed Oct 31, 2023
1 parent d4c0b4e commit d330a12
Show file tree
Hide file tree
Showing 6 changed files with 811 additions and 0 deletions.
9 changes: 9 additions & 0 deletions src/components/InPageNavigation/InPageNavigation.module.scss
@@ -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 src/components/InPageNavigation/InPageNavigation.stories.tsx
@@ -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

View workflow job for this annotation

GitHub Actions / Publish next to Github Packages

'classNames' is defined but never used

Check warning on line 5 in src/components/InPageNavigation/InPageNavigation.stories.tsx

View workflow job for this annotation

GitHub Actions / run-checks

'classNames' is defined but never used

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}
/>
)
63 changes: 63 additions & 0 deletions src/components/InPageNavigation/InPageNavigation.test.tsx
@@ -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()
})
})
104 changes: 104 additions & 0 deletions src/components/InPageNavigation/InPageNavigation.tsx
@@ -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

0 comments on commit d330a12

Please sign in to comment.