Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: in-page navigation #2551

Merged
merged 28 commits into from
Oct 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e496788
initial setup
werdnanoslen Aug 7, 2023
c3f63d2
better test setup
werdnanoslen Aug 7, 2023
63cb987
initial approach
werdnanoslen Aug 9, 2023
2083469
Merge branch 'main' into an-nav-2518
werdnanoslen Aug 17, 2023
69d77b0
finished component code
werdnanoslen Aug 23, 2023
0e09051
Merge branch 'main' into an-nav-2518
werdnanoslen Aug 23, 2023
05bc5f3
a11y fix
werdnanoslen Aug 23, 2023
a631909
added story controls
werdnanoslen Aug 23, 2023
ade5417
added some tests
werdnanoslen Aug 24, 2023
ddabf58
test cleanup
werdnanoslen Aug 26, 2023
d5449bd
Merge branch 'main' into an-nav-2518
werdnanoslen Aug 26, 2023
78aaf40
Merge branch 'main' into an-nav-2518
werdnanoslen Sep 5, 2023
8be2d6e
miscellaneous improvements
werdnanoslen Sep 5, 2023
069913c
added offset story
werdnanoslen Sep 5, 2023
4b939e9
prettier
werdnanoslen Sep 5, 2023
220f0d0
Merge branch 'main' into an-nav-2518
werdnanoslen Sep 29, 2023
73401b8
Merge branch 'main' into an-nav-2518
shkeating Oct 3, 2023
787f100
Merge branch 'main' into an-nav-2518
shkeating Oct 11, 2023
0aa1e7a
added links to demonstrate keyboard focus change
werdnanoslen Oct 11, 2023
ce17d25
added tabindexes to example content
werdnanoslen Oct 11, 2023
8d45f29
removed demo links
werdnanoslen Oct 11, 2023
d0659de
Merge branch 'main' into an-nav-2518
shkeating Oct 13, 2023
91799f8
Merge branch 'main' into an-nav-2518
shkeating Oct 23, 2023
908ca78
better customizability
werdnanoslen Oct 25, 2023
520b258
Merge branch 'main' into an-nav-2518
werdnanoslen Oct 25, 2023
ed166dd
prettier
werdnanoslen Oct 25, 2023
703e2fe
Merge branch 'main' into an-nav-2518
shkeating Oct 31, 2023
333c8f3
Merge branch 'main' into an-nav-2518
werdnanoslen Oct 31, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 src/components/InPageNavigation/InPageNavigation.stories.tsx
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

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: {
werdnanoslen marked this conversation as resolved.
Show resolved Hide resolved
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
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()
})
})
104 changes: 104 additions & 0 deletions src/components/InPageNavigation/InPageNavigation.tsx
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
brandonlenz marked this conversation as resolved.
Show resolved Hide resolved
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
Loading
Loading