Skip to content

Commit

Permalink
Make SubNav SubMenus accessible using keyboard (#679)
Browse files Browse the repository at this point in the history
* add useIsChildFocused hook

* allow submenus to be opened from keyboard

* remove jsx-a11y rule disable

* move useIsChildFocused hook to SubNav folder

* rename hook and move ref responsibility

* forward refs

* fix incorrect spacing

* add changeset
  • Loading branch information
joshfarrant authored Aug 8, 2024
1 parent 443243b commit 30f717d
Show file tree
Hide file tree
Showing 6 changed files with 217 additions and 47 deletions.
5 changes: 5 additions & 0 deletions .changeset/wild-olives-sin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react-brand': patch
---

Fixed an issue where `SubNav` submenus were not accessible through keyboard navigation
19 changes: 17 additions & 2 deletions packages/react/src/SubNav/SubNav.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
display: none !important;
}

.SubNav__sub-menu-children {
display: contents;
}

/*
* Narrow breakpoint
*/
Expand Down Expand Up @@ -289,17 +293,28 @@
width: var(--brand-SubNav-width-subMenu);
}

.SubNav__link--has-sub-menu:hover .SubNav__sub-menu,
.SubNav__link--has-sub-menu:focus-visible .SubNav__sub-menu {
.SubNav__link--expanded .SubNav__sub-menu {
visibility: visible;
opacity: 1;
transform: scale(1) translateY(0);
box-shadow: var(--brand-SubNav-shadow);
}

.SubNav__sub-menu-toggle {
border: none;
padding: 0;
margin: 0;
background: none;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}

.SubNav__sub-menu .SubNav__link {
display: block;
}

.SubNav__sub-menu .SubNav__link-label {
color: var(--brand-color-text-default);
font-weight: var(--brand-text-weight-100);
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/SubNav/SubNav.module.css.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ declare const styles: {
readonly "SubNav--full-width": string;
readonly "SubNav__action-container": string;
readonly "SubNav__sub-menu-icon": string;
readonly "SubNav__sub-menu-children": string;
readonly "SubNav--open": string;
readonly "fade-in": string;
readonly "SubNav__links-overlay": string;
Expand All @@ -17,6 +18,8 @@ declare const styles: {
readonly "SubNav__overlay-toggle-icon": string;
readonly "SubNav__overlay-toggle-content": string;
readonly "SubNav__link-label": string;
readonly "SubNav__link--expanded": string;
readonly "SubNav__sub-menu-toggle": string;
readonly "SubNav__link--has-sub-menu": string;
readonly "fade-in-down": string;
};
Expand Down
50 changes: 49 additions & 1 deletion packages/react/src/SubNav/SubNav.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import {HTMLAttributes} from 'react'
import React, {render, cleanup, fireEvent} from '@testing-library/react'
import React, {render, cleanup, fireEvent, within} from '@testing-library/react'
import '@testing-library/jest-dom'
import {axe, toHaveNoViolations} from 'jest-axe'

import {SubNav} from './SubNav'
import '../test-utils/mocks/match-media-mock'
import userEvent from '@testing-library/user-event'

expect.extend(toHaveNoViolations)

Expand Down Expand Up @@ -106,4 +107,51 @@ describe('SubNav', () => {

expect(results).toHaveNoViolations()
})

it('shows subitems when the submenu toggle is activated', async () => {
const {getByRole, getAllByTestId} = render(
<SubNav fullWidth>
<SubNav.Link href="#" aria-current="page">
Copilot
<SubNav.SubMenu>
<SubNav.Link href="#">Copilot feature page one</SubNav.Link>
<SubNav.Link href="#">Copilot feature page two</SubNav.Link>
<SubNav.Link href="#">Copilot feature page three</SubNav.Link>
</SubNav.SubMenu>
</SubNav.Link>
<SubNav.Link href="#">Code review</SubNav.Link>
<SubNav.Link href="#">Search</SubNav.Link>
<SubNav.Action href="#">Call to action</SubNav.Action>
</SubNav>,
)

userEvent.tab()
expect(getByRole('link', {name: 'Copilot'})).toHaveFocus()

const toggleSubmenuButton = getByRole('button', {name: 'Open submenu'})
expect(toggleSubmenuButton).toHaveAttribute('aria-expanded', 'false')

userEvent.tab()
expect(toggleSubmenuButton).toHaveFocus()

userEvent.keyboard('{enter}')
expect(toggleSubmenuButton).toHaveFocus()
expect(toggleSubmenuButton).toHaveAttribute('aria-expanded', 'true')

const expanded = getAllByTestId('SubNav-root-link')[0]

userEvent.tab()
expect(within(expanded).getByRole('link', {name: 'Copilot feature page one'})).toHaveFocus()

userEvent.tab()
expect(within(expanded).getByRole('link', {name: 'Copilot feature page two'})).toHaveFocus()

userEvent.tab()
expect(within(expanded).getByRole('link', {name: 'Copilot feature page three'})).toHaveFocus()

userEvent.tab()
expect(getByRole('link', {name: 'Code review'})).toHaveFocus()

expect(toggleSubmenuButton).toHaveAttribute('aria-expanded', 'false')
})
})
134 changes: 90 additions & 44 deletions packages/react/src/SubNav/SubNav.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import React, {
Children,
forwardRef,
isValidElement,
useState,
PropsWithChildren,
memo,
ReactElement,
ReactNode,
useCallback,
useState,
type PropsWithChildren,
type ReactElement,
type ReactNode,
type RefObject,
} from 'react'
import {Button, ButtonSizes, ButtonVariants, Text} from '..'

Expand All @@ -16,6 +18,8 @@ import {useId} from '@reach/auto-id'
import {useKeyboardEscape} from '../hooks/useKeyboardEscape'
import {useFocusTrap} from '../hooks/useFocusTrap'
import {useOnClickOutside} from '../hooks/useOnClickOutside'
import {useProvidedRefOrCreate} from '../hooks/useRef'
import {useContainsFocus} from './useContainsFocus'

import type {BaseProps} from '../component-helpers'

Expand Down Expand Up @@ -176,58 +180,100 @@ type SubNavLinkProps = {
} & PropsWithChildren<React.HTMLProps<HTMLAnchorElement>> &
BaseProps<HTMLAnchorElement>

const SubNavLink = ({
children,
href,
'aria-current': ariaCurrent,
'data-testid': testId,
className,
...props
}: SubNavLinkProps) => {
const hasSubMenu = Children.toArray(children).some(child => {
if (isValidElement(child)) {
return child.type === _SubMenu
}
})
const SubNavLinkWithSubmenu = forwardRef<HTMLDivElement, SubNavLinkProps>(
({children, href, 'aria-current': ariaCurrent, 'data-testid': testId, className, ...props}, forwardedRef) => {
const submenuId = useId()

const [label, SubMenuChildren] = children as ReactNode[]
const [isExpanded, setIsExpanded] = useState(false)
const ref = useProvidedRefOrCreate(forwardedRef as RefObject<HTMLDivElement>)

return (
<>
{hasSubMenu ? (
<div
className={clsx(styles['SubNav__link'], styles['SubNav__link--has-sub-menu'])}
data-testid={testId || testIds.link}
>
<a
href={href}
className={clsx(styles['SubNav__link'], ariaCurrent && styles['SubNav__link--active'], className)}
aria-current={ariaCurrent}
{...props}
>
<Text as="span" size="200" className={styles['SubNav__link-label']}>
{label}
</Text>
</a>
<>{SubMenuChildren}</>
{<ChevronDownIcon className={styles['SubNav__sub-menu-icon']} size={16} />}
</div>
) : (
useContainsFocus(ref, (containsFocus: boolean) => {
if (!containsFocus) {
setIsExpanded(false)
}
})

const [label, SubMenuChildren] = children as ReactNode[]

const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLButtonElement>) => {
if (['Enter', ' '].includes(e.key)) {
setIsExpanded(prev => !prev)
}
}, [])

return (
<div
className={clsx(
styles['SubNav__link'],
styles['SubNav__link--has-sub-menu'],
isExpanded && styles['SubNav__link--expanded'],
)}
data-testid={testId || testIds.link}
ref={ref}
onMouseOver={() => setIsExpanded(true)}
onMouseOut={() => setIsExpanded(false)}
/**
* onFocus and onBlur need to be defined to keep the jsx-a11y/mouse-events-have-key-events
* eslint rule happy. The focus/blur behaviour is handled by useContainsFocus
*/
onFocus={() => null}
onBlur={() => null}
>
<a
href={href}
className={clsx(styles['SubNav__link'], ariaCurrent && styles['SubNav__link--active'], className)}
aria-current={ariaCurrent}
data-testid={testId || testIds.link}
{...props}
>
<Text as="span" size="200" className={styles['SubNav__link-label']}>
{children}
{label}
</Text>
</a>
)}
</>
<button
className={styles['SubNav__sub-menu-toggle']}
onKeyDown={onKeyDown}
aria-expanded={isExpanded ? 'true' : 'false'}
aria-controls={submenuId}
aria-label={`${isExpanded ? 'Close' : 'Open'} submenu`}
>
<ChevronDownIcon className={styles['SubNav__sub-menu-icon']} size={16} />
</button>
<div id={submenuId} className={styles['SubNav__sub-menu-children']}>
{SubMenuChildren}
</div>
</div>
)
},
)

const SubNavLink = forwardRef<HTMLAnchorElement | HTMLDivElement, SubNavLinkProps>((props, ref) => {
const hasSubMenu = Children.toArray(props.children).some(child => {
if (isValidElement(child)) {
return child.type === _SubMenu
}
})

if (hasSubMenu) {
return <SubNavLinkWithSubmenu {...props} ref={ref as RefObject<HTMLDivElement>} />
}

const {children, href, 'aria-current': ariaCurrent, 'data-testid': testId, className, ...rest} = props

return (
<a
href={href}
className={clsx(styles['SubNav__link'], ariaCurrent && styles['SubNav__link--active'], className)}
aria-current={ariaCurrent}
data-testid={testId || testIds.link}
ref={ref as RefObject<HTMLAnchorElement>}
{...rest}
>
<Text as="span" size="200" className={styles['SubNav__link-label']}>
{children}
</Text>
</a>
)
}
})

function _SubMenu({children, className, ...props}: PropsWithChildren<BaseProps<HTMLDivElement>>) {
return (
Expand Down
53 changes: 53 additions & 0 deletions packages/react/src/SubNav/useContainsFocus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {useCallback, useEffect, useState, type RefObject} from 'react'

/**
* Determine if a child element of the provided ref is currently focussed.
* @param containerRef The ref to the container element.
* @param onFocusChange The callback to be called when the focus state changes.
* @type T The type of the container element.
* @returns The ref to be applied to the container element.
*/
export const useContainsFocus = <T extends HTMLElement>(
containerRef?: RefObject<T>,
onFocusChange?: (isFocussed: boolean) => void,
) => {
const [isChildFocused, setIsChildFocused] = useState(false)

const updateState = useCallback(
(isFocused: boolean) => {
if (isFocused !== isChildFocused) {
setIsChildFocused(isFocused)
onFocusChange?.(isFocused)
}
},
[isChildFocused, onFocusChange],
)

useEffect(() => {
if (!containerRef) {
return
}

const handleFocusIn = () => {
updateState(true)
}

const handleFocusOut = (event: FocusEvent) => {
if (containerRef.current && !containerRef.current.contains(event.relatedTarget as Node)) {
updateState(false)
}
}

const container = containerRef.current

if (container) {
container.addEventListener('focusin', handleFocusIn, true)
container.addEventListener('focusout', handleFocusOut, true)

return () => {
container.removeEventListener('focusin', handleFocusIn, true)
container.removeEventListener('focusout', handleFocusOut, true)
}
}
}, [updateState, containerRef])
}

0 comments on commit 30f717d

Please sign in to comment.