Skip to content

NavList.Item: Go one level deeper for NextJSLikeLink #2079

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

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
59 changes: 58 additions & 1 deletion src/NavList/NavList.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const Simple: Story = () => (
</PageLayout>
)

export const SubItems: Story = () => (
export const WithSubItems: Story = () => (
<PageLayout>
<PageLayout.Pane position="start">
<NavList>
Expand All @@ -47,4 +47,61 @@ export const SubItems: Story = () => (
</PageLayout>
)

type ReactRouterLikeLinkProps = {to: string; children: React.ReactNode}
const ReactRouterLikeLink = React.forwardRef<HTMLAnchorElement, ReactRouterLikeLinkProps>(({to, ...props}, ref) => {
// eslint-disable-next-line jsx-a11y/anchor-has-content
return <a ref={ref} href={to} {...props} />
})

export const WithReactRouterLink = () => (
<PageLayout>
<PageLayout.Pane position="start">
<NavList>
<NavList.Item as={ReactRouterLikeLink} to="#" aria-current="page">
Item 1
</NavList.Item>
<NavList.Item as={ReactRouterLikeLink} to="#">
Item 2
</NavList.Item>
<NavList.Item as={ReactRouterLikeLink} to="#">
Item 3
</NavList.Item>
</NavList>
</PageLayout.Pane>
<PageLayout.Content></PageLayout.Content>
</PageLayout>
)

type NextJSLinkProps = {href: string; children: React.ReactNode}

const NextJSLikeLink = React.forwardRef<HTMLAnchorElement, NextJSLinkProps>(
({href, children}, ref): React.ReactElement => {
const child = React.Children.only(children)
const childProps = {
ref,
href
}
return <>{React.isValidElement(child) ? React.cloneElement(child, childProps) : null}</>
}
)

export const WithNextJSLink = () => (
<PageLayout>
<PageLayout.Pane position="start">
<NavList>
<NextJSLikeLink href="#">
<NavList.Item aria-current="page">Item 1</NavList.Item>
</NextJSLikeLink>
<NextJSLikeLink href="#">
<NavList.Item>Item 2</NavList.Item>
</NextJSLikeLink>
<NextJSLikeLink href="#">
<NavList.Item>Item 3</NavList.Item>
</NextJSLikeLink>
</NavList>
</PageLayout.Pane>
<PageLayout.Content></PageLayout.Content>
</PageLayout>
)

export default meta
79 changes: 79 additions & 0 deletions src/NavList/NavList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@ import React from 'react'
import {ThemeProvider, SSRProvider} from '..'
import {NavList} from './NavList'

type ReactRouterLikeLinkProps = {to: string; children: React.ReactNode}

const ReactRouterLikeLink = React.forwardRef<HTMLAnchorElement, ReactRouterLikeLinkProps>(({to, ...props}, ref) => {
// eslint-disable-next-line jsx-a11y/anchor-has-content
return <a ref={ref} href={to} {...props} />
})

type NextJSLinkProps = {href: string; children: React.ReactNode}

const NextJSLikeLink = React.forwardRef<HTMLAnchorElement, NextJSLinkProps>(
({href, children}, ref): React.ReactElement => {
const child = React.Children.only(children)
const childProps = {
ref,
href
}
return <>{React.isValidElement(child) ? React.cloneElement(child, childProps) : null}</>
}
)

describe('NavList', () => {
it('renders a simple list', () => {
const {container} = render(
Expand Down Expand Up @@ -60,6 +80,36 @@ describe('NavList.Item', () => {
expect(homeLink).toHaveAttribute('aria-current', 'page')
expect(aboutLink).not.toHaveAttribute('aria-current')
})

it('is compatiable with React-Router-like link components', () => {
const {getByRole} = render(
<NavList>
<NavList.Item as={ReactRouterLikeLink} to={'/'} aria-current="page">
React Router link
</NavList.Item>
</NavList>
)

const link = getByRole('link', {name: 'React Router link'})

expect(link).toHaveAttribute('aria-current', 'page')
expect(link).toHaveAttribute('href', '/')
})

it('is compatible with NextJS-like link components', () => {
const {getByRole} = render(
<NavList>
<NextJSLikeLink href="/">
<NavList.Item aria-current="page">NextJS link</NavList.Item>
</NextJSLikeLink>
</NavList>
)

const link = getByRole('link', {name: 'NextJS link'})

expect(link).toHaveAttribute('href', '/')
expect(link).toHaveAttribute('aria-current', 'page')
})
})

describe('NavList.Item with NavList.SubNav', () => {
Expand Down Expand Up @@ -227,4 +277,33 @@ describe('NavList.Item with NavList.SubNav', () => {

expect(consoleSpy).toHaveBeenCalled()
})

it('is compatiable with React-Router-like link components', () => {
function NavLink({href, children}: {href: string; children: React.ReactNode}) {
// In a real app, you'd check if the href matches the url of the current page. For testing purposes, we'll use the text of the link to determine if it's current
const isCurrent = children === 'Current'
return (
<NavList.Item as={ReactRouterLikeLink} to={href} aria-current={isCurrent ? 'page' : false}>
{children}
</NavList.Item>
)
}

const {queryByRole} = render(
<NavList>
<NavLink href="/">Item 1</NavLink>
<NavList.Item>
Item 2
<NavList.SubNav>
<NavLink href="/sub-item-1">Current</NavLink>
<NavLink href="/sub-item-2">Sub item 2</NavLink>
</NavList.SubNav>
</NavList.Item>
<NavLink href="/">Item 3</NavLink>
</NavList>
)

const currentLink = queryByRole('link', {name: 'Current'})
expect(currentLink).toBeInTheDocument()
})
})
59 changes: 51 additions & 8 deletions src/NavList/NavList.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {ChevronDownIcon} from '@primer/octicons-react'
import {ForwardRefComponent as PolymorphicForwardRefComponent} from '@radix-ui/react-polymorphic'
import {useSSRSafeId} from '@react-aria/ssr'
import React, {isValidElement} from 'react'
import {ActionList} from '../ActionList'
import Box from '../Box'
import StyledOcticon from '../StyledOcticon'
import {useProvidedRefOrCreate} from '../hooks'

// ----------------------------------------------------------------------------
// NavList
Expand All @@ -12,10 +14,34 @@ export type NavListProps = {
children: React.ReactNode
} & React.ComponentProps<'nav'>

const useWarningForMissingAriaCurrent = containerRef => {
React.useEffect(
function checkForAriaCurrentOnMount() {
const ariaCurrentEl = containerRef.current?.querySelector('[aria-current]')

if (!ariaCurrentEl)
throw new Error(`
aria-current not found on NavList.Item

To create an accessible navigation, it is required to add aria-current to the current anchor element.

See https://primer.style/react/NavList#accessibilty for more info

If you are extending NavList.Item to create a navigation element, make sure you are passing aria-current at the site of usage.
See https://primer.style/react/NavList#extend for more info.
`)
},
[containerRef]
)
}

// TODO: sx prop
const Root = React.forwardRef<HTMLElement, NavListProps>(({children, ...props}, ref) => {
const ensureRef = useProvidedRefOrCreate(ref)
useWarningForMissingAriaCurrent(ensureRef)

return (
<nav ref={ref} {...props}>
<nav ref={ensureRef} {...props}>
<ActionList>{children}</ActionList>
</nav>
)
Expand All @@ -33,9 +59,8 @@ export type NavListItemProps = {
}

// TODO: sx prop
// TODO: as prop
const Item = React.forwardRef<HTMLAnchorElement, NavListItemProps>(
({href, 'aria-current': ariaCurrent, children}, ref) => {
({'aria-current': ariaCurrent, children, ...props}, ref) => {
const {depth} = React.useContext(SubNavContext)

// Get SubNav from children
Expand All @@ -49,9 +74,27 @@ const Item = React.forwardRef<HTMLAnchorElement, NavListItemProps>(
// Render ItemWithSubNav if SubNav is present
if (subNav && isValidElement(subNav) && depth < 1) {
// Search SubNav children for current Item
const currentItem = React.Children.toArray(subNav.props.children).find(
child => isValidElement(child) && child.props['aria-current']
)
const currentItem = React.Children.toArray(subNav.props.children).find(child => {
if (!isValidElement(child)) return false

// if direct child is SubNav.Item
if (child.type === Item) return child.props['aria-current']
//
// if direct child isn't SubNav.Item
// it's either a custom NavItem or a NextJSLikeLink

// custom NavItem requires aria-current on the direct child
if (child.props['aria-current']) return true

// for NextJSLikeLink, go one level deeper
if (typeof child.props.children === 'object') {
const wrappedItem = child.props.children
return wrappedItem.props['aria-current']
}

// we don't recognise this API usage
return false
})

return (
<ItemWithSubNav subNav={subNav} subNavContainsCurrentItem={Boolean(currentItem)}>
Expand All @@ -63,20 +106,20 @@ const Item = React.forwardRef<HTMLAnchorElement, NavListItemProps>(
return (
<ActionList.LinkItem
ref={ref}
href={href}
aria-current={ariaCurrent}
active={Boolean(ariaCurrent) && ariaCurrent !== 'false'}
sx={{
paddingLeft: depth > 0 ? 5 : null, // Indent sub-items
fontSize: depth > 0 ? 0 : null, // Reduce font size of sub-items
fontWeight: depth > 0 ? 'normal' : null // Sub-items don't get bolded
}}
{...props}
>
{children}
</ActionList.LinkItem>
)
}
)
) as PolymorphicForwardRefComponent<'a', NavListItemProps>

Item.displayName = 'NavList.Item'

Expand Down