diff --git a/client/common/ButtonOrLink.jsx b/client/common/ButtonOrLink.jsx index 924f108024..f759aa8ffb 100644 --- a/client/common/ButtonOrLink.jsx +++ b/client/common/ButtonOrLink.jsx @@ -5,23 +5,60 @@ import PropTypes from 'prop-types'; /** * Helper for switching between ); } - return ; -}; +); /** * Accepts all the props of an HTML or '); + expect(button).toContainHTML(''); fireEvent.click(button); expect(clickHandler).toHaveBeenCalled(); }); diff --git a/client/common/usePrevious.js b/client/common/usePrevious.js new file mode 100644 index 0000000000..ed46581cb0 --- /dev/null +++ b/client/common/usePrevious.js @@ -0,0 +1,11 @@ +import { useEffect, useRef } from 'react'; + +export default function usePrevious(value) { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +} diff --git a/client/components/Menubar/Menubar.jsx b/client/components/Menubar/Menubar.jsx index b806246515..8d7ff38da0 100644 --- a/client/components/Menubar/Menubar.jsx +++ b/client/components/Menubar/Menubar.jsx @@ -1,19 +1,135 @@ import PropTypes from 'prop-types'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { + useCallback, + useMemo, + useRef, + useState, + useEffect +} from 'react'; import useModalClose from '../../common/useModalClose'; import { MenuOpenContext, MenubarContext } from './contexts'; +import usePrevious from '../../common/usePrevious'; + +/** + * @component + * @param {object} props + * @param {React.ReactNode} props.children - Menu items that will be rendered in the menubar + * @param {string} [props.className='nav__menubar'] - CSS class name to apply to the menubar + * @returns {JSX.Element} + */ + +/** + * Menubar manages a collection of menu items and their submenus. It provides keyboard navigation, + * focus and state management, and other accessibility features for the menu items and submenus. + * + * @example + * + * + * ... menu items + * + * + */ function Menubar({ children, className }) { + // core state for menu management const [menuOpen, setMenuOpen] = useState('none'); + const [activeIndex, setActiveIndex] = useState(0); + const prevIndex = usePrevious(activeIndex); + const [hasFocus, setHasFocus] = useState(false); + // refs for menu items and their ids + const menuItems = useRef(new Set()).current; + const menuItemToId = useRef(new Map()).current; + + // ref for hiding submenus const timerRef = useRef(null); - const handleClose = useCallback(() => { + // get the id of a menu item by its index + const getMenuId = useCallback( + (index) => { + const items = Array.from(menuItems); + const itemNode = items[index]; + return menuItemToId.get(itemNode); + }, + [menuItems, menuItemToId, activeIndex] + ); + + /** + * navigation functions + */ + const prev = useCallback(() => { + const newIndex = (activeIndex - 1 + menuItems.size) % menuItems.size; + setActiveIndex(newIndex); + + if (menuOpen !== 'none') { + const newMenuId = getMenuId(newIndex); + setMenuOpen(newMenuId); + } + }, [activeIndex, menuItems, menuOpen, getMenuId]); + + const next = useCallback(() => { + const newIndex = (activeIndex + 1) % menuItems.size; + setActiveIndex(newIndex); + + if (menuOpen !== 'none') { + const newMenuId = getMenuId(newIndex); + setMenuOpen(newMenuId); + } + }, [activeIndex, menuItems, menuOpen, getMenuId]); + + const first = useCallback(() => { + setActiveIndex(0); + }, []); + + const last = useCallback(() => { + setActiveIndex(menuItems.size - 1); + }, []); + + // closes the menu and returns focus to the active menu item + // is called on Escape key press + const close = useCallback(() => { + if (menuOpen === 'none') return; + + const items = Array.from(menuItems); + const activeNode = items[activeIndex]; setMenuOpen('none'); - }, [setMenuOpen]); + activeNode.focus(); + }, [activeIndex, menuItems, menuOpen]); - const nodeRef = useModalClose(handleClose); + // toggle the open state of a submenu + const toggleMenuOpen = useCallback((id) => { + setMenuOpen((prevState) => (prevState === id ? 'none' : id)); + }); + + /** + * Register top level menu items. Stores both the DOM node and the id of the submenu. + * Access to the DOM node is needed for focus management and tabindex control, + * while the id is needed to toggle the submenu open and closed. + * + * @param {React.RefObject} ref - a ref to the DOM node of the menu item + * @param {string} submenuId - the id of the submenu that the menu item opens + * + */ + const registerTopLevelItem = useCallback( + (ref, submenuId) => { + const menuItemNode = ref.current; + if (menuItemNode) { + menuItems.add(menuItemNode); + menuItemToId.set(menuItemNode, submenuId); // store the id of the submenu + } + + return () => { + menuItems.delete(menuItemNode); + menuItemToId.delete(menuItemNode); + }; + }, + [menuItems, menuItemToId] + ); + + /** + * focus and blur management + */ const clearHideTimeout = useCallback(() => { if (timerRef.current) { clearTimeout(timerRef.current); @@ -21,17 +137,89 @@ function Menubar({ children, className }) { } }, [timerRef]); - const handleBlur = useCallback(() => { - timerRef.current = setTimeout(() => setMenuOpen('none'), 10); - }, [timerRef, setMenuOpen]); + const handleClose = useCallback(() => { + clearHideTimeout(); + setMenuOpen('none'); + }, [setMenuOpen]); + + const nodeRef = useModalClose(handleClose); + + const handleFocus = useCallback(() => { + setHasFocus(true); + }, []); - const toggleMenuOpen = useCallback( - (menu) => { - setMenuOpen((prevState) => (prevState === menu ? 'none' : menu)); + const handleBlur = useCallback( + (e) => { + const isInMenu = nodeRef.current?.contains(document.activeElement); + + if (!isInMenu) { + timerRef.current = setTimeout(() => { + if (nodeRef.current) { + setMenuOpen('none'); + setHasFocus(false); + } + }, 10); + } }, - [setMenuOpen] + [nodeRef] ); + // keyboard navigation + const keyHandlers = { + ArrowLeft: (e) => { + e.preventDefault(); + e.stopPropagation(); + prev(); + }, + ArrowRight: (e) => { + e.preventDefault(); + e.stopPropagation(); + next(); + }, + Escape: (e) => { + e.preventDefault(); + e.stopPropagation(); + close(); + }, + Tab: (e) => { + e.stopPropagation(); + // close + }, + Home: (e) => { + e.preventDefault(); + e.stopPropagation(); + first(); + }, + End: (e) => { + e.preventDefault(); + e.stopPropagation(); + last(); + } + // to do: support direct access keys + }; + + // focus the active menu item and set its tabindex + useEffect(() => { + if (activeIndex !== prevIndex) { + const items = Array.from(menuItems); + const activeNode = items[activeIndex]; + const prevNode = items[prevIndex]; + + // roving tabindex + prevNode?.setAttribute('tabindex', '-1'); + activeNode?.setAttribute('tabindex', '0'); + + if (hasFocus) { + activeNode?.focus(); + } + } + }, [activeIndex, prevIndex, menuItems]); + + useEffect(() => { + clearHideTimeout(); + }, [clearHideTimeout]); + + // context value for dropdowns and menu items const contextValue = useMemo( () => ({ createMenuHandlers: (menu) => ({ @@ -40,6 +228,15 @@ function Menubar({ children, className }) { }, onClick: () => { toggleMenuOpen(menu); + const items = Array.from(menuItems); + const index = items.findIndex( + (item) => menuItemToId.get(item) === menu + ); + const item = items[index]; + if (index !== -1) { + setActiveIndex(index); + item.focus(); + } }, onBlur: handleBlur, onFocus: clearHideTimeout @@ -49,6 +246,16 @@ function Menubar({ children, className }) { if (e.button === 2) { return; } + + const isDisabled = + e.currentTarget.getAttribute('aria-disabled') === 'true'; + + if (isDisabled) { + e.preventDefault(); + e.stopPropagation(); + return; + } + setMenuOpen('none'); }, onBlur: handleBlur, @@ -57,18 +264,48 @@ function Menubar({ children, className }) { setMenuOpen(menu); } }), - toggleMenuOpen + menuItems, + activeIndex, + setActiveIndex, + registerTopLevelItem, + setMenuOpen, + toggleMenuOpen, + hasFocus, + setHasFocus }), - [setMenuOpen, toggleMenuOpen, clearHideTimeout, handleBlur] + [ + menuItems, + activeIndex, + setActiveIndex, + registerTopLevelItem, + menuOpen, + toggleMenuOpen, + hasFocus, + setHasFocus, + clearHideTimeout, + handleBlur + ] ); return ( -
+
    { + const handler = keyHandlers[e.key]; + if (handler) { + handler(e); + } + }} + > {children} -
+
); } @@ -80,7 +317,7 @@ Menubar.propTypes = { Menubar.defaultProps = { children: null, - className: 'nav' + className: 'nav__menubar' }; export default Menubar; diff --git a/client/components/Menubar/Menubar.test.jsx b/client/components/Menubar/Menubar.test.jsx new file mode 100644 index 0000000000..0f78d2f547 --- /dev/null +++ b/client/components/Menubar/Menubar.test.jsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { render, screen, fireEvent } from '../../test-utils'; +import Menubar from './Menubar'; +import MenubarSubmenu from './MenubarSubmenu'; +import MenubarItem from './MenubarItem'; + +describe('Menubar', () => { + const renderMenubar = () => { + render( + + + + New + + + Save + + + Open + + + + + Tidy + + + Find + + + Replace + + + + ); + }; + + it('should render a menubar with submenu triggers', () => { + renderMenubar(); + + const fileMenuTrigger = screen.getByRole('menuitem', { name: 'File' }); + const editMenuTrigger = screen.getByRole('menuitem', { name: 'Edit' }); + + expect(fileMenuTrigger).toBeInTheDocument(); + expect(editMenuTrigger).toBeInTheDocument(); + expect(fileMenuTrigger).toHaveAttribute('aria-expanded', 'false'); + }); + + it('should open a submenu when clicked', () => { + renderMenubar(); + + const fileMenuTrigger = screen.getByRole('menuitem', { name: 'File' }); + const editMenuTrigger = screen.getByRole('menuitem', { name: 'Edit' }); + + fireEvent.click(fileMenuTrigger); + expect(fileMenuTrigger).toHaveAttribute('aria-expanded', 'true'); + expect(editMenuTrigger).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(document.body); + expect(fileMenuTrigger).toHaveAttribute('aria-expanded', 'false'); + }); + + it('should support top-level keyboard navigation', () => { + renderMenubar(); + + const fileMenuTrigger = screen.getByRole('menuitem', { name: 'File' }); + const editMenuTrigger = screen.getByRole('menuitem', { name: 'Edit' }); + fireEvent.focus(fileMenuTrigger); + + fireEvent.keyDown(fileMenuTrigger, { key: 'ArrowRight' }); + expect(editMenuTrigger).toHaveFocus(); + + fireEvent.keyDown(editMenuTrigger, { key: 'ArrowLeft' }); + expect(fileMenuTrigger).toHaveFocus(); + + const newMenuItem = screen.getByRole('menuitem', { name: 'New' }); + + fireEvent.keyDown(fileMenuTrigger, { key: 'ArrowDown' }); + expect(newMenuItem).toHaveFocus(); + + fireEvent.keyDown(newMenuItem, { key: 'Escape' }); + expect(fileMenuTrigger).toHaveAttribute('aria-expanded', 'false'); + }); + + it('should support submenu keyboard navigation', () => { + renderMenubar(); + + const fileMenuTrigger = screen.getByRole('menuitem', { name: 'File' }); + const newMenuItem = screen.getByRole('menuitem', { name: 'New' }); + const openMenuItem = screen.getByRole('menuitem', { name: 'Open' }); + + const editMenuTrigger = screen.getByRole('menuitem', { name: 'Edit' }); + + fireEvent.click(fileMenuTrigger); + expect(fileMenuTrigger).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.keyDown(fileMenuTrigger, { key: 'ArrowDown' }); + expect(newMenuItem).toHaveFocus(); + + fireEvent.keyDown(newMenuItem, { key: 'ArrowUp' }); + expect(newMenuItem).not.toHaveFocus(); + expect(openMenuItem).toHaveFocus(); + + fireEvent.keyDown(openMenuItem, { key: 'ArrowRight' }); + expect(fileMenuTrigger).toHaveAttribute('aria-expanded', 'false'); + expect(openMenuItem).not.toHaveFocus(); + expect(editMenuTrigger).toHaveFocus(); + expect(editMenuTrigger).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.keyDown(editMenuTrigger, { key: 'ArrowLeft' }); + expect(editMenuTrigger).toHaveAttribute('aria-expanded', 'false'); + expect(editMenuTrigger).not.toHaveFocus(); + expect(fileMenuTrigger).toHaveFocus(); + expect(fileMenuTrigger).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.keyDown(newMenuItem, { key: 'Escape' }); + expect(fileMenuTrigger).toHaveAttribute('aria-expanded', 'false'); + }); + + it('should activate a menu item when clicked', () => { + const handleClick = jest.fn(); + + render( + + + + New + + + + ); + + const fileMenuTrigger = screen.getByRole('menuitem', { name: 'File' }); + const newMenuItem = screen.getByRole('menuitem', { name: 'New' }); + fireEvent.click(fileMenuTrigger); + fireEvent.mouseUp(newMenuItem); + fireEvent.click(newMenuItem); + + expect(handleClick).toHaveBeenCalled(); + expect(fileMenuTrigger).toHaveAttribute('aria-expanded', 'false'); + }); + + it('should have proper ARIA attributes', () => { + renderMenubar(); + + const menubar = screen.getByRole('menubar'); + expect(menubar).toHaveAttribute('aria-orientation', 'horizontal'); + + const fileMenu = screen.getByRole('menuitem', { name: 'File' }); + expect(fileMenu).toHaveAttribute('aria-haspopup', 'menu'); + expect(fileMenu).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(fileMenu); + expect(fileMenu).toHaveAttribute('aria-expanded', 'true'); + }); +}); diff --git a/client/components/Menubar/MenubarItem.jsx b/client/components/Menubar/MenubarItem.jsx index 8d595bb5cd..3b0aea6be9 100644 --- a/client/components/Menubar/MenubarItem.jsx +++ b/client/components/Menubar/MenubarItem.jsx @@ -1,46 +1,119 @@ import PropTypes from 'prop-types'; -import React, { useContext, useMemo } from 'react'; +import React, { useEffect, useContext, useRef } from 'react'; +import { MenubarContext, SubmenuContext, ParentMenuContext } from './contexts'; import ButtonOrLink from '../../common/ButtonOrLink'; -import { MenubarContext, ParentMenuContext } from './contexts'; + +/** + * @component + * @param {object} props + * @param {string} [props.className='nav__dropdown-item'] - CSS class name to apply to the list item + * @param {string} props.id - The id of the list item + * @param {string} [props.role='menuitem'] - The role of the list item + * @param {boolean} [props.isDisabled=false] - Whether to hide the item + * @param {boolean} [props.selected=false] - Whether the item is selected + * @returns {JSX.Element} + */ + +/** + * MenubarItem wraps a button or link in an accessible list item that + * integrates with keyboard navigation and other submenu behaviors. + * + * @example + * ```jsx + * // basic MenubarItem with click handler and keyboard shortcut + * dispatch(startSketch())}> + * Run + * {metaKeyName}+Enter + * + * + * // as an option in a listbox + * + * {languageKeyToLabel(key)} + * + * ``` + */ function MenubarItem({ - hideIf, className, + id, role: customRole, + isDisabled, selected, ...rest }) { + // core context and state management + const { createMenuItemHandlers, hasFocus } = useContext(MenubarContext); + const { + setSubmenuActiveIndex, + submenuItems, + registerSubmenuItem + } = useContext(SubmenuContext); const parent = useContext(ParentMenuContext); - const { createMenuItemHandlers } = useContext(MenubarContext); - - const handlers = useMemo(() => createMenuItemHandlers(parent), [ - createMenuItemHandlers, - parent - ]); + // ref for the list item + const menuItemRef = useRef(null); - if (hideIf) { - return null; - } + // handlers from parent menu + const handlers = createMenuItemHandlers(parent); + // role and aria-selected const role = customRole || 'menuitem'; const ariaSelected = role === 'option' ? { 'aria-selected': selected } : {}; + // focus submenu item on mouse enter + const handleMouseEnter = () => { + if (hasFocus) { + const items = Array.from(submenuItems); + const index = items.findIndex((item) => item === menuItemRef.current); + if (index !== -1) { + setSubmenuActiveIndex(index); + } + } + }; + + // register with parent submenu for keyboard navigation + useEffect(() => { + const unregister = registerSubmenuItem(menuItemRef); + return unregister; + }, [submenuItems, registerSubmenuItem]); + return ( -
  • - +
  • +
  • ); } MenubarItem.propTypes = { ...ButtonOrLink.propTypes, + id: PropTypes.string, onClick: PropTypes.func, value: PropTypes.string, /** * Provides a way to deal with optional items. */ - hideIf: PropTypes.bool, + isDisabled: PropTypes.bool, className: PropTypes.string, role: PropTypes.oneOf(['menuitem', 'option']), selected: PropTypes.bool @@ -49,9 +122,10 @@ MenubarItem.propTypes = { MenubarItem.defaultProps = { onClick: null, value: null, - hideIf: false, + isDisabled: false, className: 'nav__dropdown-item', role: 'menuitem', + id: undefined, selected: false }; diff --git a/client/components/Menubar/MenubarSubmenu.jsx b/client/components/Menubar/MenubarSubmenu.jsx index 13b0e33177..c751641514 100644 --- a/client/components/Menubar/MenubarSubmenu.jsx +++ b/client/components/Menubar/MenubarSubmenu.jsx @@ -2,9 +2,21 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; -import React, { useContext, useMemo } from 'react'; +import React, { + useState, + useEffect, + useContext, + useCallback, + useRef, + useMemo +} from 'react'; +import { + MenuOpenContext, + MenubarContext, + SubmenuContext, + ParentMenuContext +} from './contexts'; import TriangleIcon from '../../images/down-filled-triangle.svg'; -import { MenuOpenContext, MenubarContext, ParentMenuContext } from './contexts'; export function useMenuProps(id) { const activeMenu = useContext(MenuOpenContext); @@ -25,14 +37,101 @@ export function useMenuProps(id) { * MenubarTrigger * -----------------------------------------------------------------------------------------------*/ -function MenubarTrigger({ id, title, role, hasPopup, ...props }) { +/** + * @component + * @param {Object} props + * @param {string} [props.role='menuitem'] - The ARIA role of the trigger button + * @param {string} [props.hasPopup='menu'] - The ARIA property that indicates the presence of a popup + * @returns {JSX.Element} + */ + +/** + * MenubarTrigger renders a button that toggles a submenu. It handles keyboard navigations and supports + * screen readers. It needs to be within a submenu context. + * + * @example + *
  • + * + * ... menubar list + *
  • + */ + +const MenubarTrigger = React.forwardRef(({ role, hasPopup, ...props }, ref) => { + const { + setActiveIndex, + menuItems, + registerTopLevelItem, + hasFocus + } = useContext(MenubarContext); + const { id, title, first, last } = useContext(SubmenuContext); const { isOpen, handlers } = useMenuProps(id); + // update active index when mouse enters the trigger and the menu has focus + const handleMouseEnter = () => { + if (hasFocus) { + const items = Array.from(menuItems); + const index = items.findIndex((item) => item === ref.current); + + if (index !== -1) { + setActiveIndex(index); + } + } + }; + + // keyboard handlers + const handleKeyDown = (e) => { + switch (e.key) { + case 'ArrowDown': + if (!isOpen) { + e.preventDefault(); + e.stopPropagation(); + first(); + } + break; + case 'ArrowUp': + if (!isOpen) { + e.preventDefault(); + e.stopPropagation(); + last(); + } + break; + case 'Enter': + case ' ': + if (!isOpen) { + e.preventDefault(); + e.stopPropagation(); + first(); + } + break; + default: + break; + } + }; + + // register trigger with parent menubar + useEffect(() => { + const unregister = registerTopLevelItem(ref, id); + return unregister; + }, [menuItems, registerTopLevelItem]); + return ( ); -} +}); MenubarTrigger.propTypes = { - id: PropTypes.string.isRequired, - title: PropTypes.node.isRequired, role: PropTypes.string, hasPopup: PropTypes.oneOf(['menu', 'listbox', 'true']) }; @@ -62,9 +159,33 @@ MenubarTrigger.defaultProps = { * MenubarList * -----------------------------------------------------------------------------------------------*/ -function MenubarList({ id, children, role, ...props }) { +/** + * @component + * @param {Object} props + * @param {React.ReactNode} props.children - MenubarItems that should be rendered in the list + * @param {string} [props.role='menu'] - The ARIA role of the list element + * @returns {JSX.Element} + */ + +/** + * MenubarList renders the container for menu items in a submenu. It provides context and handles ARIA roles. + * + * @example + * + * ... elements + * + */ + +function MenubarList({ children, role, ...props }) { + const { id, title } = useContext(SubmenuContext); + return ( -
    - + `; diff --git a/client/styles/components/_nav.scss b/client/styles/components/_nav.scss index 58691ff251..5d597596ac 100644 --- a/client/styles/components/_nav.scss +++ b/client/styles/components/_nav.scss @@ -24,6 +24,13 @@ // padding-left: #{math.div(20, $base-font-size)}rem; } +.nav__menubar { + display: flex; + flex-direction: row; + width:100%; + justify-content: space-between; +} + .nav__items-left, .nav__items-right { list-style: none; @@ -37,15 +44,6 @@ @include icon(); } -// .nav__items-left, -// .nav__items-right { -// & button, & a { -// @include themify() { -// color: getThemifyVariable('primary-text-color'); -// } -// } -// } - .nav__item { position: relative; display: flex; @@ -58,6 +56,60 @@ } } +// base focus styles +.nav__item button:focus { + @include themify() { + background-color: getThemifyVariable('nav-hover-color'); + } + + .nav__item-header { + @include themify() { + color: getThemifyVariable('button-hover-color'); + } + } + + .nav__item-header-triangle polygon, + .nav__item-header-triangle path { + @include themify() { + fill: getThemifyVariable('button-hover-color'); + } + } +} + + +.nav__dropdown-item { + & button:focus, + & a:focus { + @include themify() { + color: getThemifyVariable('button-hover-color'); + background-color: getThemifyVariable('nav-hover-color'); + } + } + & button:focus .nav__keyboard-shortcut, + & a:focus .nav__keyboard-shortcut { + @include themify() { + color: getThemifyVariable('button-hover-color'); + } + } + + &.nav__dropdown-item--disabled { + & button, + & a, + & button:hover, + & a:hover { + @include themify() { + color: getThemifyVariable('button-nav-inactive-color'); + } + + & .nav__keyboard-shortcut { + @include themify() { + color: getThemifyVariable('button-nav-inactive-color'); + } + } + } + } +} + .nav__item--no-icon { padding-left: #{math.div(15, $base-font-size)}rem; } @@ -70,9 +122,13 @@ } .nav__item:hover { + @include themify() { + background-color: getThemifyVariable('nav-hover-color'); + } + .nav__item-header { @include themify() { - color: getThemifyVariable('nav-hover-color'); + color: getThemifyVariable('button-hover-color'); } } @@ -85,7 +141,7 @@ .nav__item-header-triangle polygon, .nav__item-header-triangle path { @include themify() { - fill: getThemifyVariable('nav-hover-color'); + fill: getThemifyVariable('button-hover-color'); } } }