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 22 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.
Jump to
Jump to file
Failed to load files.
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);
}
74 changes: 74 additions & 0 deletions src/components/InPageNavigation/InPageNavigation.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React from 'react'
import { InPageNavigation } from './InPageNavigation'
import { CONTENT } from './content'
import { HeadingLevel } from '../../types/headingLevel'

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}
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}
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()
})
})
101 changes: 101 additions & 0 deletions src/components/InPageNavigation/InPageNavigation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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'

export type ContentType = [heading: HeadingLevel, text: string, href: string]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this doesn't appear to be used

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah yep, this was from my previous approach. Removed!


type InPageNavigationProps = {
className?: string
content: JSX.Element
headingLevel?: HeadingLevel
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',
navProps,
rootMargin = '0px 0px 0px 0px',
scrollOffset = '0',
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 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={classnames('main-content', 'usa-prose')}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the classnames here are specific to the USWDS example. I could definitely see a case where a consumer wouldn't want usa-prose applied to their app's page's main content for example. Similarly, they might want to customize things about main like the id, etc. I've got another comment with further thoughts about making this more flexible for consumers.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that comment: #2551 (comment)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in latest commit

style={offsetStyle}>
{content}
</main>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: I have some mixed thoughts on whether we should include the main tag here, or expect consumers to pass it in.

The USWDS guidance is very clear that this component should interact with well structured markup, so enforcing the use of main either way is important:

Display the in-page navigation to the side of the main content

The text of the links displayed within the in-page navigation aside should match the heading text of the target sections. Our component scans the page for h2 and h3 elements within the main element, automatically creates the in-page navigation block, and dynamically inserts the text to match the section headings.

On one hand, passing it in makes using the component more complex. On the other hand, it is more flexible, especially since it's pretty common for React web apps to build main into their page components.

At the very least, main needs to be customizable, so you'd have to at least expose mainProps in the same way I made the suggestion about navProps. Then we can just spread {...mainProps} on it, and (if needed) apply the offsetStyle

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in latest commit

</div>
)
}

export default InPageNavigation