diff --git a/.changeset/dry-feet-attack.md b/.changeset/dry-feet-attack.md new file mode 100644 index 00000000000..8d41178f0a2 --- /dev/null +++ b/.changeset/dry-feet-attack.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +Draft `NavList.Item` now accepts an `as` prop, allowing it to be rendered as a Next.js or React Router link diff --git a/docs/content/NavList.mdx b/docs/content/NavList.mdx index 1fb38fd7765..9293821a83f 100644 --- a/docs/content/NavList.mdx +++ b/docs/content/NavList.mdx @@ -174,8 +174,6 @@ If a `NavList.Item` contains a `NavList.SubNav`, the `NavList.Item` will render ### With React Router -Not implemented yet - ```jsx import {Link, useMatch, useResolvedPath} from 'react-router-dom' import {NavList} from '@primer/react' @@ -203,8 +201,6 @@ function App() { ### With Next.js -Not implemented yet - ```jsx import {useRouter} from 'next/router' import Link from 'next/link' diff --git a/src/NavList/NavList.stories.tsx b/src/NavList/NavList.stories.tsx index ca2e667f1f5..62d6125d5c5 100644 --- a/src/NavList/NavList.stories.tsx +++ b/src/NavList/NavList.stories.tsx @@ -26,7 +26,7 @@ export const Simple: Story = () => ( ) -export const SubItems: Story = () => ( +export const WithSubItems: Story = () => ( @@ -47,4 +47,61 @@ export const SubItems: Story = () => ( ) +type ReactRouterLikeLinkProps = {to: string; children: React.ReactNode} +const ReactRouterLikeLink = React.forwardRef(({to, ...props}, ref) => { + // eslint-disable-next-line jsx-a11y/anchor-has-content + return +}) + +export const WithReactRouterLink = () => ( + + + + + Item 1 + + + Item 2 + + + Item 3 + + + + + +) + +type NextJSLinkProps = {href: string; children: React.ReactNode} + +const NextJSLikeLink = React.forwardRef( + ({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 = () => ( + + + + + Item 1 + + + Item 2 + + + Item 3 + + + + + +) + export default meta diff --git a/src/NavList/NavList.test.tsx b/src/NavList/NavList.test.tsx index 8acb971d40c..6cda27bfb41 100644 --- a/src/NavList/NavList.test.tsx +++ b/src/NavList/NavList.test.tsx @@ -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(({to, ...props}, ref) => { + // eslint-disable-next-line jsx-a11y/anchor-has-content + return +}) + +type NextJSLinkProps = {href: string; children: React.ReactNode} + +const NextJSLikeLink = React.forwardRef( + ({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( @@ -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( + + + React Router link + + + ) + + 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( + + + NextJS link + + + ) + + const link = getByRole('link', {name: 'NextJS link'}) + + expect(link).toHaveAttribute('href', '/') + expect(link).toHaveAttribute('aria-current', 'page') + }) }) describe('NavList.Item with NavList.SubNav', () => { @@ -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 ( + + {children} + + ) + } + + const {queryByRole} = render( + + Item 1 + + Item 2 + + Current + Sub item 2 + + + Item 3 + + ) + + const currentLink = queryByRole('link', {name: 'Current'}) + expect(currentLink).toBeVisible() + }) }) diff --git a/src/NavList/NavList.tsx b/src/NavList/NavList.tsx index 2dbed46085d..c86dc471677 100644 --- a/src/NavList/NavList.tsx +++ b/src/NavList/NavList.tsx @@ -1,4 +1,5 @@ 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 styled from 'styled-components' @@ -36,9 +37,8 @@ export type NavListItemProps = { 'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false' | boolean } & SxProp -// TODO: as prop const Item = React.forwardRef( - ({href, 'aria-current': ariaCurrent, children, sx: sxProp = {}}, ref) => { + ({'aria-current': ariaCurrent, children, sx: sxProp = {}, ...props}, ref) => { const {depth} = React.useContext(SubNavContext) // Get SubNav from children @@ -51,13 +51,8 @@ const Item = React.forwardRef( // 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'] - ) - return ( - + {childrenWithoutSubNav} ) @@ -66,7 +61,6 @@ const Item = React.forwardRef( return ( ( @@ -77,12 +71,13 @@ const Item = React.forwardRef( }, sxProp )} + {...props} > {children} ) } -) +) as PolymorphicForwardRefComponent<'a', NavListItemProps> Item.displayName = 'NavList.Item' @@ -92,24 +87,36 @@ Item.displayName = 'NavList.Item' type ItemWithSubNavProps = { children: React.ReactNode subNav: React.ReactNode - subNavContainsCurrentItem: boolean } & SxProp -const ItemWithSubNavContext = React.createContext<{buttonId: string; subNavId: string}>({ +const ItemWithSubNavContext = React.createContext<{buttonId: string; subNavId: string; isOpen: boolean}>({ buttonId: '', - subNavId: '' + subNavId: '', + isOpen: false }) // TODO: ref prop // TODO: Animate open/close transition -function ItemWithSubNav({children, subNav, subNavContainsCurrentItem, sx: sxProp = {}}: ItemWithSubNavProps) { +function ItemWithSubNav({children, subNav, sx: sxProp = {}}: ItemWithSubNavProps) { const buttonId = useSSRSafeId() const subNavId = useSSRSafeId() - // SubNav starts open if current item is in it - const [isOpen, setIsOpen] = React.useState(subNavContainsCurrentItem) + const [isOpen, setIsOpen] = React.useState(false) + const subNavRef = React.useRef(null) + const [containsCurrentItem, setContainsCurrentItem] = React.useState(false) + + React.useLayoutEffect(() => { + if (subNavRef.current) { + // Check if SubNav contains current item + const currentItem = subNavRef.current.querySelector('[aria-current]') + if (currentItem && currentItem.getAttribute('aria-current') !== 'false') { + setContainsCurrentItem(true) + setIsOpen(true) + } + } + }, [subNav]) return ( - + setIsOpen(open => !open)} sx={merge( { - fontWeight: subNavContainsCurrentItem ? 'bold' : null // Parent item is bold if any of it's sub-items are current + fontWeight: containsCurrentItem ? 'bold' : null // Parent item is bold if any of it's sub-items are current }, sxProp )} @@ -138,7 +145,7 @@ function ItemWithSubNav({children, subNav, subNavContainsCurrentItem, sx: sxProp - {isOpen ? subNav : null} +
{subNav}
) @@ -156,7 +163,7 @@ const SubNavContext = React.createContext<{depth: number}>({depth: 0}) // TODO: ref prop // NOTE: SubNav must be a direct child of an Item const SubNav = ({children, sx: sxProp = {}}: NavListSubNavProps) => { - const {buttonId, subNavId} = React.useContext(ItemWithSubNavContext) + const {buttonId, subNavId, isOpen} = React.useContext(ItemWithSubNavContext) const {depth} = React.useContext(SubNavContext) if (!buttonId || !subNavId) { @@ -179,7 +186,8 @@ const SubNav = ({children, sx: sxProp = {}}: NavListSubNavProps) => { sx={merge( { padding: 0, - margin: 0 + margin: 0, + display: isOpen ? 'block' : 'none' }, sxProp )} diff --git a/src/NavList/__snapshots__/NavList.test.tsx.snap b/src/NavList/__snapshots__/NavList.test.tsx.snap index 1caedbe2f5a..87cfd0798f2 100644 --- a/src/NavList/__snapshots__/NavList.test.tsx.snap +++ b/src/NavList/__snapshots__/NavList.test.tsx.snap @@ -842,6 +842,7 @@ exports[`NavList.Item with NavList.SubNav does not have active styles if SubNav .c9 { padding: 0; margin: 0; + display: block; } .c7 { @@ -987,6 +988,12 @@ exports[`NavList.Item with NavList.SubNav does not have active styles if SubNav --divider-color: transparent; } +.c18:hover:not([aria-disabled]) + .c2, +.c18:focus:not([aria-disabled]) + .c18, +.c18[data-focus-visible-added] + li { + --divider-color: transparent; +} + .c3 { position: relative; display: -webkit-box; @@ -1059,9 +1066,9 @@ exports[`NavList.Item with NavList.SubNav does not have active styles if SubNav --divider-color: transparent; } -.c18:hover:not([aria-disabled]) + .c2, -.c18:focus:not([aria-disabled]) + .c18, -.c18[data-focus-visible-added] + li { +.c19:hover:not([aria-disabled]) + .c2, +.c19:focus:not([aria-disabled]) + .c19, +.c19[data-focus-visible-added] + li { --divider-color: transparent; } @@ -1228,34 +1235,36 @@ exports[`NavList.Item with NavList.SubNav does not have active styles if SubNav -
+ @@ -1289,6 +1298,12 @@ exports[`NavList.Item with NavList.SubNav has active styles if SubNav contains t list-style: none; } +.c9 { + padding: 0; + margin: 0; + display: none; +} + .c5 { display: -webkit-box; display: -webkit-flex; @@ -1323,9 +1338,70 @@ exports[`NavList.Item with NavList.SubNav has active styles if SubNav contains t padding-bottom: 8px; } -.c9:hover:not([aria-disabled]) + .c2, -.c9:focus:not([aria-disabled]) + .c9, -.c9[data-focus-visible-added] + li { +.c10 { + position: relative; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding-left: 0; + padding-right: 0; + font-size: 14px; + padding-top: 0; + padding-bottom: 0; + line-height: 20px; + min-height: 5px; + margin-left: 8px; + margin-right: 8px; + border-radius: 6px; + -webkit-transition: background 33.333ms linear; + transition: background 33.333ms linear; + color: #24292f; + cursor: pointer; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: unset; + border: unset; + width: calc(100% - 16px); + font-family: unset; + text-align: unset; + margin-top: unset; + margin-bottom: unset; + font-weight: 600; + background-color: rgba(208,215,222,0.24); +} + +.c10[aria-disabled] { + cursor: not-allowed; +} + +.c10 [data-component="ActionList.Item--DividerContainer"] { + position: relative; +} + +.c10 [data-component="ActionList.Item--DividerContainer"]::before { + content: " "; + display: block; + position: absolute; + width: 100%; + top: -7px; + border: 0 solid; + border-top-width: 0; + border-color: var(--divider-color,transparent); +} + +.c10:not(:first-of-type) { + --divider-color: rgba(208,215,222,0.48); +} + +[data-component="ActionList.Divider"] + .c10 { + --divider-color: transparent !important; +} + +.c10:hover:not([aria-disabled]), +.c10:focus:not([aria-disabled]), +.c10[data-focus-visible-added]:not([aria-disabled]) { --divider-color: transparent; } @@ -1335,10 +1411,15 @@ exports[`NavList.Item with NavList.SubNav has active styles if SubNav contains t --divider-color: transparent; } -.c11:hover:not([aria-disabled]) + .c2, -.c11:focus:not([aria-disabled]) + .c11, -.c11[data-focus-visible-added] + li { - --divider-color: transparent; +.c10::after { + position: absolute; + top: calc(50% - 12px); + left: -8px; + width: 4px; + height: 24px; + content: ""; + background-color: #0969da; + border-radius: 6px; } .c12:hover:not([aria-disabled]) + .c2, @@ -1371,6 +1452,24 @@ exports[`NavList.Item with NavList.SubNav has active styles if SubNav contains t --divider-color: transparent; } +.c17:hover:not([aria-disabled]) + .c2, +.c17:focus:not([aria-disabled]) + .c17, +.c17[data-focus-visible-added] + li { + --divider-color: transparent; +} + +.c18:hover:not([aria-disabled]) + .c2, +.c18:focus:not([aria-disabled]) + .c18, +.c18[data-focus-visible-added] + li { + --divider-color: transparent; +} + +.c19:hover:not([aria-disabled]) + .c2, +.c19:focus:not([aria-disabled]) + .c19, +.c19[data-focus-visible-added] + li { + --divider-color: transparent; +} + .c3 { position: relative; display: -webkit-box; @@ -1455,6 +1554,86 @@ exports[`NavList.Item with NavList.SubNav has active styles if SubNav contains t border-radius: 6px; } +.c11 { + color: #0969da; + -webkit-text-decoration: none; + text-decoration: none; + padding-left: 32px; + padding-right: 8px; + padding-top: 6px; + padding-bottom: 6px; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + border-radius: 6px; + color: inherit; + font-size: 12px; + font-weight: 400; +} + +.c11:hover { + -webkit-text-decoration: underline; + text-decoration: underline; +} + +.c11:is(button) { + display: inline-block; + padding: 0; + font-size: inherit; + white-space: nowrap; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background-color: transparent; + border: 0; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +.c11:hover { + color: inherit; + -webkit-text-decoration: none; + text-decoration: none; +} + +@media (hover:hover) and (pointer:fine) { + .c10:hover:not([aria-disabled]) { + background-color: rgba(208,215,222,0.32); + color: #24292f; + } + + .c10:focus:not([data-focus-visible-added]) { + background-color: rgba(208,215,222,0.24); + color: #24292f; + outline: none; + } + + .c10[data-focus-visible-added] { + outline: none; + border: 2 solid; + box-shadow: 0 0 0 2px #0969da; + } + + .c10:active:not([aria-disabled]) { + background-color: rgba(208,215,222,0.48); + color: #24292f; + } +} + +@media (forced-colors:active) { + .c10:focus { + outline: solid 1px transparent !important; + } +} + @media (hover:hover) and (pointer:fine) { .c3:hover:not([aria-disabled]) { background-color: rgba(208,215,222,0.32); @@ -1538,6 +1717,36 @@ exports[`NavList.Item with NavList.SubNav has active styles if SubNav contains t +
+ +