diff --git a/packages/react/src/SubNav/SubNav.module.css b/packages/react/src/SubNav/SubNav.module.css index a9bc1d6c8..adc1e22cf 100644 --- a/packages/react/src/SubNav/SubNav.module.css +++ b/packages/react/src/SubNav/SubNav.module.css @@ -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); diff --git a/packages/react/src/SubNav/SubNav.module.css.d.ts b/packages/react/src/SubNav/SubNav.module.css.d.ts index a13a6c8c2..02ca386e8 100644 --- a/packages/react/src/SubNav/SubNav.module.css.d.ts +++ b/packages/react/src/SubNav/SubNav.module.css.d.ts @@ -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; }; diff --git a/packages/react/src/SubNav/SubNav.test.tsx b/packages/react/src/SubNav/SubNav.test.tsx index c3234bf78..05392611c 100644 --- a/packages/react/src/SubNav/SubNav.test.tsx +++ b/packages/react/src/SubNav/SubNav.test.tsx @@ -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) @@ -106,4 +107,51 @@ describe('SubNav', () => { expect(results).toHaveNoViolations() }) + + it('shows subitems when the submenu toggle is activated', async () => { + const {getByRole, getAllByTestId} = render( + + + Copilot + + Copilot feature page one + Copilot feature page two + Copilot feature page three + + + Code review + Search + Call to action + , + ) + + 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') + }) }) diff --git a/packages/react/src/SubNav/SubNav.tsx b/packages/react/src/SubNav/SubNav.tsx index dbb4b17c8..ec7081037 100644 --- a/packages/react/src/SubNav/SubNav.tsx +++ b/packages/react/src/SubNav/SubNav.tsx @@ -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 '..' @@ -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' @@ -176,7 +177,7 @@ type SubNavLinkProps = { } & PropsWithChildren> & BaseProps -const SubNavLink = ({ +const SubNavLinkWithSubmenu = ({ children, href, 'aria-current': ariaCurrent, @@ -184,48 +185,86 @@ const SubNavLink = ({ 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(onFocusChange) + + const [label, SubMenuChildren] = children as ReactNode[] + + const onKeyDown = useCallback((e: React.KeyboardEvent) => { + 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 +
setIsExpanded(true)} + onMouseOut={() => setIsExpanded(false)} + > + + + {label} + + + +
{SubMenuChildren}
+
+ ) +} + +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 + } + + const {children, href, 'aria-current': ariaCurrent, 'data-testid': testId, className, ...rest} = props return ( - <> - {hasSubMenu ? ( -
- - - {label} - - - <>{SubMenuChildren} - {} -
- ) : ( - - - {children} - - - )} - + + + {children} + + ) }