Skip to content

Commit

Permalink
allow submenus to be opened from keyboard
Browse files Browse the repository at this point in the history
  • Loading branch information
joshfarrant committed Aug 6, 2024
1 parent 65585da commit c539e70
Show file tree
Hide file tree
Showing 4 changed files with 143 additions and 43 deletions.
15 changes: 13 additions & 2 deletions packages/react/src/SubNav/SubNav.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -289,17 +289,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
2 changes: 2 additions & 0 deletions packages/react/src/SubNav/SubNav.module.css.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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')
})
})
119 changes: 79 additions & 40 deletions packages/react/src/SubNav/SubNav.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import React, {
Children,
isValidElement,
useState,
PropsWithChildren,
memo,
ReactElement,
ReactNode,
useCallback,
useState,
type PropsWithChildren,
type ReactElement,
type ReactNode,
} from 'react'
import {Button, ButtonSizes, ButtonVariants, Text} from '..'

Expand All @@ -16,6 +16,7 @@ import {useId} from '@reach/auto-id'
import {useKeyboardEscape} from '../hooks/useKeyboardEscape'
import {useFocusTrap} from '../hooks/useFocusTrap'
import {useOnClickOutside} from '../hooks/useOnClickOutside'
import {useIsChildFocused} from '../hooks/useIsChildFocused'

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

Expand Down Expand Up @@ -176,56 +177,94 @@ type SubNavLinkProps = {
} & PropsWithChildren<React.HTMLProps<HTMLAnchorElement>> &
BaseProps<HTMLAnchorElement>

const SubNavLink = ({
const SubNavLinkWithSubmenu = ({
children,
href,
'aria-current': ariaCurrent,
'data-testid': testId,
className,
...props
}: SubNavLinkProps) => {
const hasSubMenu = Children.toArray(children).some(child => {
const [isExpanded, setIsExpanded] = useState(false)
const onFocusChange = useCallback((isFocused: boolean) => {
if (!isFocused) {
setIsExpanded(false)
}
}, [])
const submenuId = useId()
const ref = useIsChildFocused<HTMLDivElement>(onFocusChange)

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

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

return (
// Disabling as the focus and blur events are handled by the useIsChildFocused hook
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
<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)}
>
<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>
<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}>{SubMenuChildren}</div>
</div>
)
}

const SubNavLink = (props: SubNavLinkProps) => {
const hasSubMenu = Children.toArray(props.children).some(child => {
if (isValidElement(child)) {
return child.type === _SubMenu
}
})

const [label, SubMenuChildren] = children as ReactNode[]
if (hasSubMenu) {
return <SubNavLinkWithSubmenu {...props} />
}

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

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

Expand Down

0 comments on commit c539e70

Please sign in to comment.