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 <button>, <a>, and <Link> */ -const ButtonOrLink = ({ href, children, ...props }) => { - if (href) { - if (href.startsWith('http')) { + +const ButtonOrLink = React.forwardRef( + ({ href, children, isDisabled, onClick, ...props }, ref) => { + const handleClick = (e) => { + if (isDisabled) { + e.preventDefault(); + e.stopPropagation(); + return; + } + if (onClick) { + onClick(e); + } + }; + + if (href) { + if (href.startsWith('http')) { + return ( + <a + ref={ref} + href={href} + target="_blank" + rel="noopener noreferrer" + aria-disabled={isDisabled} + {...props} + onClick={handleClick} + > + {children} + </a> + ); + } return ( - <a href={href} target="_blank" rel="noopener noreferrer" {...props}> + <Link + ref={ref} + to={href} + aria-disabled={isDisabled} + {...props} + onClick={handleClick} + > {children} - </a> + </Link> ); } return ( - <Link to={href} {...props}> + <button + ref={ref} + aria-disabled={isDisabled} + {...props} + onClick={handleClick} + > {children} - </Link> + </button> ); } - return <button {...props}>{children}</button>; -}; +); /** * Accepts all the props of an HTML <a> or <button> tag. @@ -34,15 +71,19 @@ ButtonOrLink.propTypes = { * External links should start with 'http' or 'https' and will open in a new window. */ href: PropTypes.string, + isDisabled: PropTypes.bool, /** * Content of the button/link. * Can be either a string or a complex element. */ - children: PropTypes.node.isRequired + children: PropTypes.node.isRequired, + onClick: PropTypes.func }; ButtonOrLink.defaultProps = { - href: null + href: null, + isDisabled: false, + onClick: null }; export default ButtonOrLink; diff --git a/client/common/ButtonOrLink.test.jsx b/client/common/ButtonOrLink.test.jsx index 0e6fb093b4..7b1a6326a4 100644 --- a/client/common/ButtonOrLink.test.jsx +++ b/client/common/ButtonOrLink.test.jsx @@ -13,7 +13,7 @@ describe('ButtonOrLink', () => { render(<ButtonOrLink onClick={clickHandler}>Text</ButtonOrLink>); const button = screen.getByRole('button'); expect(button).toBeInstanceOf(HTMLButtonElement); - expect(button).toContainHTML('<button>Text</button>'); + expect(button).toContainHTML('<button aria-disabled="false">Text</button>'); 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 + * <Menubar> + * <MenubarSubmenu id="file" title="File"> + * ... menu items + * </MenubarSubmenu> + * </Menubar> + */ 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 ( <MenubarContext.Provider value={contextValue}> - <div className={className} ref={nodeRef}> + <ul + className={className} + role="menubar" + ref={nodeRef} + aria-orientation="horizontal" + onFocus={handleFocus} + onKeyDown={(e) => { + const handler = keyHandlers[e.key]; + if (handler) { + handler(e); + } + }} + > <MenuOpenContext.Provider value={menuOpen}> {children} </MenuOpenContext.Provider> - </div> + </ul> </MenubarContext.Provider> ); } @@ -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( + <Menubar> + <MenubarSubmenu id="file" title="File"> + <MenubarItem id="file-new" title="New" onClick={jest.fn()}> + New + </MenubarItem> + <MenubarItem id="file-save" title="Save" onClick={jest.fn()}> + Save + </MenubarItem> + <MenubarItem id="file-open" title="Open" onClick={jest.fn()}> + Open + </MenubarItem> + </MenubarSubmenu> + <MenubarSubmenu id="edit" title="Edit"> + <MenubarItem id="edit-tidy" title="Tidy" onClick={jest.fn()}> + Tidy + </MenubarItem> + <MenubarItem id="edit-find" title="Find" onClick={jest.fn()}> + Find + </MenubarItem> + <MenubarItem id="edit-replace" title="Replace" onClick={jest.fn()}> + Replace + </MenubarItem> + </MenubarSubmenu> + </Menubar> + ); + }; + + 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( + <Menubar> + <MenubarSubmenu id="file" title="File"> + <MenubarItem id="file-new" title="New" onClick={handleClick}> + New + </MenubarItem> + </MenubarSubmenu> + </Menubar> + ); + + 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 + * <MenubarItem id="sketch-run" onClick={() => dispatch(startSketch())}> + * Run + * <span className="nav__keyboard-shortcut">{metaKeyName}+Enter</span> + * </MenubarItem> + * + * // as an option in a listbox + * <MenubarItem + * id={key} + * key={key} + * value={key} + * onClick={handleLangSelection} + * role="option" + * selected={key === language} + * > + * {languageKeyToLabel(key)} + * </MenubarItem> + * ``` + */ 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 ( - <li className={className}> - <ButtonOrLink {...rest} {...handlers} {...ariaSelected} role={role} /> + <li + className={`${className} ${ + isDisabled ? 'nav__dropdown-item--disabled' : '' + }`} + ref={menuItemRef} + onMouseEnter={handleMouseEnter} + > + <ButtonOrLink + {...rest} + {...handlers} + {...ariaSelected} + role={role} + tabIndex={-1} + id={id} + isDisabled={isDisabled} + /> </li> ); } 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 + * <li + * className={classNames('nav__item', isOpen && 'nav__item--open')} + * ref={listItemRef} + * > + * <MenubarTrigger + * ref={buttonRef} + * role={triggerRole} + * hasPopup={hasPopup} + * {...handlers} + * {...props} + * tabIndex={-1} + * /> + * ... menubar list + * </li> + */ + +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 ( <button - {...handlers} {...props} + {...handlers} + ref={ref} role={role} + onMouseEnter={handleMouseEnter} + onKeyDown={handleKeyDown} aria-haspopup={hasPopup} aria-expanded={isOpen} > @@ -44,11 +143,9 @@ function MenubarTrigger({ id, title, role, hasPopup, ...props }) { /> </button> ); -} +}); 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 + * <MenubarList role={listRole}> + * ... <MenubarItem> elements + * </MenubarList> + */ + +function MenubarList({ children, role, ...props }) { + const { id, title } = useContext(SubmenuContext); + return ( - <ul className="nav__dropdown" role={role} {...props}> + <ul + className="nav__dropdown" + role={role} + aria-label={`${title} menu`} + {...props} + > <ParentMenuContext.Provider value={id}> {children} </ParentMenuContext.Provider> @@ -73,7 +194,6 @@ function MenubarList({ id, children, role, ...props }) { } MenubarList.propTypes = { - id: PropTypes.string.isRequired, children: PropTypes.node, role: PropTypes.oneOf(['menu', 'listbox']) }; @@ -87,41 +207,281 @@ MenubarList.defaultProps = { * MenubarSubmenu * -----------------------------------------------------------------------------------------------*/ +/** + * @component + * @param {Object} props + * @param {React.ReactNode} props.children - A list of menu items that will be rendered in the menubar + * @param {string} props.id - The unique id of the submenu + * @param {string} props.title - The title of the submenu + * @param {string} [props.triggerRole='menuitem'] - The ARIA role of the trigger button + * @param {string} [props.listRole='menu'] - The ARIA role of the list element + * @returns {JSX.Element} + */ + +/** + * MenubarSubmenu manages a triggerable submenu within a menubar. It is a compound component + * that manages the state of the submenu and its items. It also provides keyboard navigation + * and screen reader support. Supports menu and listbox roles. Needs to be a direct child of Menubar. + * + * @example + * <Menubar> + * <MenubarSubmenu id="file" title="File"> + * <MenubarItem id="file-new" onClick={handleNew}>New</MenubarItem> + * <MenubarItem id="file-save" onClick={handleSave}>Save</MenubarItem> + * </MenubarSubmenu> + * </Menubar> + */ + function MenubarSubmenu({ + children, id, title, - children, triggerRole: customTriggerRole, listRole: customListRole, ...props }) { - const { isOpen } = useMenuProps(id); + // core state for submenu management + const { isOpen, handlers } = useMenuProps(id); + const [submenuActiveIndex, setSubmenuActiveIndex] = useState(0); + const { setMenuOpen, toggleMenuOpen } = useContext(MenubarContext); + const submenuItems = useRef(new Set()).current; + // refs for the button and list elements + const buttonRef = useRef(null); + const listItemRef = useRef(null); + + // roles and properties for the button and list elements const triggerRole = customTriggerRole || 'menuitem'; const listRole = customListRole || 'menu'; - const hasPopup = listRole === 'listbox' ? 'listbox' : 'menu'; + /** + * navigation functions for the submenu + */ + const prev = useCallback(() => { + const newIndex = + submenuActiveIndex < 0 + ? submenuItems.size - 1 + : (submenuActiveIndex - 1 + submenuItems.size) % submenuItems.size; + setSubmenuActiveIndex(newIndex); + }, [submenuActiveIndex, submenuItems]); + + const next = useCallback(() => { + const newIndex = (submenuActiveIndex + 1) % submenuItems.size; + setSubmenuActiveIndex(newIndex); + }, [submenuActiveIndex, submenuItems]); + + const first = useCallback(() => { + toggleMenuOpen(id); + + if (submenuItems.size > 0) { + setSubmenuActiveIndex(0); + } + }, [submenuItems]); + + const last = useCallback(() => { + toggleMenuOpen(id); + if (submenuItems.size > 0) { + setSubmenuActiveIndex(submenuItems.size - 1); + } + }, [submenuItems]); + + // activate the selected item + const activate = useCallback(() => { + const items = Array.from(submenuItems); + const activeItem = items[submenuActiveIndex]; // get the active item + + if (activeItem) { + // since active item is a <li> element, we need to get the button or link inside it + const activeItemNode = activeItem.firstChild; + + const isDisabled = + activeItemNode.getAttribute('aria-disabled') === 'true'; + + if (isDisabled) { + return; + } + + activeItemNode.click(); + + toggleMenuOpen(id); + + // check if buttonRef is available and focus it + // we check because the button might be unmounted when activating a link or button + if (buttonRef.current) { + buttonRef.current.focus(); + } + } + }, [submenuActiveIndex, submenuItems, buttonRef]); + + const close = useCallback(() => { + setMenuOpen('none'); + + if (buttonRef.current) { + buttonRef.current.focus(); + } + }, [buttonRef]); + + /** + * Register submenu items for keyboard navigation. + * + * @param {React.RefObject} ref - a ref to the DOM node of the menu item + * + */ + const registerSubmenuItem = useCallback( + (ref) => { + const submenuItemNode = ref.current; + + if (submenuItemNode) { + submenuItems.add(submenuItemNode); + } + + return () => { + submenuItems.delete(submenuItemNode); + }; + }, + [submenuItems] + ); + + // key handlers for submenu navigation + const keyHandlers = { + ArrowUp: (e) => { + if (!isOpen) return; + e.preventDefault(); + e.stopPropagation(); + prev(); + }, + ArrowDown: (e) => { + if (!isOpen) return; + e.preventDefault(); + e.stopPropagation(); + next(); + }, + Enter: (e) => { + if (!isOpen) return; + e.preventDefault(); + e.stopPropagation(); + activate(); + }, + ' ': (e) => { + // same as Enter + if (!isOpen) return; + e.preventDefault(); + e.stopPropagation(); + activate(); + }, + Escape: (e) => { + if (!isOpen) return; + e.preventDefault(); + e.stopPropagation(); + close(); + }, + Tab: (e) => { + // close + if (!isOpen) return; + // e.preventDefault(); + e.stopPropagation(); + setMenuOpen('none'); + } + // support direct access keys + }; + + // our custom keydown handler + const handleKeyDown = useCallback( + (e) => { + if (!isOpen) return; + + const handler = keyHandlers[e.key]; + + if (handler) { + handler(e); + } + }, + [isOpen, keyHandlers] + ); + + // reset submenu active index when submenu is closed + useEffect(() => { + if (!isOpen) { + setSubmenuActiveIndex(-1); + } + }, [isOpen]); + + // add keydown event listener to list when submenu is open + useEffect(() => { + const el = listItemRef.current; + if (!el) return () => {}; + + el.addEventListener('keydown', handleKeyDown); + return () => { + el.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, keyHandlers]); + + // focus the active item when submenu is open + useEffect(() => { + if (isOpen && submenuItems.size > 0) { + const items = Array.from(submenuItems); + const activeItem = items[submenuActiveIndex]; + + if (activeItem) { + const activeNode = activeItem.querySelector( + '[role="menuitem"], [role="option"]' + ); + if (activeNode) { + activeNode.focus(); + } + } + } + }, [isOpen, submenuItems, submenuActiveIndex]); + + const submenuContext = useMemo( + () => ({ + id, + title, + submenuItems, + submenuActiveIndex, + setSubmenuActiveIndex, + registerSubmenuItem, + first, + last + }), + [ + id, + title, + submenuItems, + submenuActiveIndex, + setSubmenuActiveIndex, + registerSubmenuItem, + first, + last + ] + ); + return ( - <li className={classNames('nav__item', isOpen && 'nav__item--open')}> - <MenubarTrigger - id={id} - title={title} - role={triggerRole} - hasPopup={hasPopup} - {...props} - /> - <MenubarList id={id} role={listRole}> - {children} - </MenubarList> - </li> + <SubmenuContext.Provider value={submenuContext}> + <li + className={classNames('nav__item', isOpen && 'nav__item--open')} + ref={listItemRef} + > + <MenubarTrigger + ref={buttonRef} + role={triggerRole} + hasPopup={hasPopup} + {...handlers} + {...props} + tabIndex={-1} + /> + <MenubarList role={listRole}>{children}</MenubarList> + </li> + </SubmenuContext.Provider> ); } MenubarSubmenu.propTypes = { id: PropTypes.string.isRequired, - title: PropTypes.node.isRequired, children: PropTypes.node, + title: PropTypes.node.isRequired, triggerRole: PropTypes.string, listRole: PropTypes.string }; diff --git a/client/components/Menubar/contexts.jsx b/client/components/Menubar/contexts.jsx index ab3bb9ffcf..18cad6c36b 100644 --- a/client/components/Menubar/contexts.jsx +++ b/client/components/Menubar/contexts.jsx @@ -9,3 +9,5 @@ export const MenubarContext = createContext({ createMenuItemHandlers: () => ({}), toggleMenuOpen: () => {} }); + +export const SubmenuContext = createContext({}); diff --git a/client/modules/IDE/components/Header/Nav.jsx b/client/modules/IDE/components/Header/Nav.jsx index f40e9137bf..2ed8dd224e 100644 --- a/client/modules/IDE/components/Header/Nav.jsx +++ b/client/modules/IDE/components/Header/Nav.jsx @@ -39,10 +39,16 @@ const Nav = ({ layout }) => { ) : ( <> <header className="nav__header"> - <Menubar> - <LeftLayout layout={layout} /> - <UserMenu /> - </Menubar> + <div className="nav__item-logo"> + <Logo /> + </div> + + <nav className="nav"> + <Menubar> + <LeftLayout layout={layout} /> + <UserMenu /> + </Menubar> + </nav> </header> </> ); @@ -87,19 +93,40 @@ const UserMenu = () => { return null; }; -const DashboardMenu = () => { +const Logo = () => { const { t } = useTranslation(); - const editorLink = useSelector(selectSketchPath); - return ( - <ul className="nav__items-left"> - <li className="nav__item-logo"> + const user = useSelector((state) => state.user); + + if (user?.username) { + return ( + <Link to={`/${user.username}/sketches`}> <LogoIcon role="img" aria-label={t('Common.p5logoARIA')} focusable="false" className="svg__logo" /> - </li> + </Link> + ); + } + + return ( + <a href="https://p5js.org"> + <LogoIcon + role="img" + aria-label={t('Common.p5logoARIA')} + focusable="true" + className="svg__logo" + /> + </a> + ); +}; + +const DashboardMenu = () => { + const { t } = useTranslation(); + const editorLink = useSelector(selectSketchPath); + return ( + <ul className="nav__items-left" role="group"> <li className="nav__item nav__item--no-icon"> <Link to={editorLink} className="nav__back-link"> <CaretLeftIcon @@ -118,7 +145,7 @@ const ProjectMenu = () => { const isUserOwner = useSelector(getIsUserOwner); const project = useSelector((state) => state.project); const user = useSelector((state) => state.user); - const userSketches = `/${user.username}/sketches`; + const isUnsaved = !project?.id; const rootFile = useSelector(selectRootFile); @@ -141,33 +168,17 @@ const ProjectMenu = () => { metaKey === 'Ctrl' ? `${metaKeyName}+Alt+N` : `${metaKeyName}+⌥+N`; return ( - <ul className="nav__items-left" role="menubar"> - <li className="nav__item-logo"> - {user && user.username !== undefined ? ( - <Link to={userSketches}> - <LogoIcon - role="img" - aria-label={t('Common.p5logoARIA')} - focusable="false" - className="svg__logo" - /> - </Link> - ) : ( - <a href="https://p5js.org"> - <LogoIcon - role="img" - aria-label={t('Common.p5logoARIA')} - focusable="false" - className="svg__logo" - /> - </a> - )} - </li> + <ul className="nav__items-left" role="group"> <MenubarSubmenu id="file" title={t('Nav.File.Title')}> - <MenubarItem onClick={newSketch}>{t('Nav.File.New')}</MenubarItem> + <MenubarItem id="file-new" onClick={newSketch}> + {t('Nav.File.New')} + </MenubarItem> <MenubarItem - hideIf={ - !getConfig('LOGIN_ENABLED') || (project?.owner && !isUserOwner) + id="file-save" + isDisabled={ + !user.authenticated || + !getConfig('LOGIN_ENABLED') || + (project?.owner && !isUserOwner) } onClick={() => saveSketch(cmRef.current)} > @@ -175,25 +186,36 @@ const ProjectMenu = () => { <span className="nav__keyboard-shortcut">{metaKeyName}+S</span> </MenubarItem> <MenubarItem - hideIf={isUnsaved || !user.authenticated} + id="file-duplicate" + isDisabled={isUnsaved || !user.authenticated} onClick={() => dispatch(cloneProject())} > {t('Nav.File.Duplicate')} </MenubarItem> - <MenubarItem hideIf={isUnsaved} onClick={shareSketch}> + <MenubarItem + id="file-share" + isDisabled={isUnsaved} + onClick={shareSketch} + > {t('Nav.File.Share')} </MenubarItem> - <MenubarItem hideIf={isUnsaved} onClick={downloadSketch}> + <MenubarItem + id="file-download" + isDisabled={isUnsaved} + onClick={downloadSketch} + > {t('Nav.File.Download')} </MenubarItem> <MenubarItem - hideIf={!user.authenticated} + id="file-open" + isDisabled={!user.authenticated} href={`/${user.username}/sketches`} > {t('Nav.File.Open')} </MenubarItem> <MenubarItem - hideIf={ + id="file-add-to-collection" + isDisabled={ !getConfig('UI_COLLECTIONS_ENABLED') || !user.authenticated || isUnsaved @@ -203,39 +225,46 @@ const ProjectMenu = () => { {t('Nav.File.AddToCollection')} </MenubarItem> <MenubarItem - hideIf={!getConfig('EXAMPLES_ENABLED')} + id="file-examples" + isDisabled={!getConfig('EXAMPLES_ENABLED')} href="/p5/sketches" > {t('Nav.File.Examples')} </MenubarItem> </MenubarSubmenu> <MenubarSubmenu id="edit" title={t('Nav.Edit.Title')}> - <MenubarItem onClick={cmRef.current?.tidyCode}> + <MenubarItem id="edit-tidy" onClick={cmRef.current?.tidyCode}> {t('Nav.Edit.TidyCode')} <span className="nav__keyboard-shortcut">{metaKeyName}+Shift+F</span> </MenubarItem> - <MenubarItem onClick={cmRef.current?.showFind}> + <MenubarItem id="edit-find" onClick={cmRef.current?.showFind}> {t('Nav.Edit.Find')} <span className="nav__keyboard-shortcut">{metaKeyName}+F</span> </MenubarItem> - <MenubarItem onClick={cmRef.current?.showReplace}> + <MenubarItem id="edit-replace" onClick={cmRef.current?.showReplace}> {t('Nav.Edit.Replace')} <span className="nav__keyboard-shortcut">{replaceCommand}</span> </MenubarItem> </MenubarSubmenu> <MenubarSubmenu id="sketch" title={t('Nav.Sketch.Title')}> - <MenubarItem onClick={() => dispatch(newFile(rootFile.id))}> + <MenubarItem + id="sketch-add-file" + onClick={() => dispatch(newFile(rootFile.id))} + > {t('Nav.Sketch.AddFile')} <span className="nav__keyboard-shortcut">{newFileCommand}</span> </MenubarItem> - <MenubarItem onClick={() => dispatch(newFolder(rootFile.id))}> + <MenubarItem + id="sketch-add-folder" + onClick={() => dispatch(newFolder(rootFile.id))} + > {t('Nav.Sketch.AddFolder')} </MenubarItem> - <MenubarItem onClick={() => dispatch(startSketch())}> + <MenubarItem id="sketch-run" onClick={() => dispatch(startSketch())}> {t('Nav.Sketch.Run')} <span className="nav__keyboard-shortcut">{metaKeyName}+Enter</span> </MenubarItem> - <MenubarItem onClick={() => dispatch(stopSketch())}> + <MenubarItem id="sketch-stop" onClick={() => dispatch(stopSketch())}> {t('Nav.Sketch.Stop')} <span className="nav__keyboard-shortcut"> Shift+{metaKeyName}+Enter @@ -243,13 +272,18 @@ const ProjectMenu = () => { </MenubarItem> </MenubarSubmenu> <MenubarSubmenu id="help" title={t('Nav.Help.Title')}> - <MenubarItem onClick={() => dispatch(showKeyboardShortcutModal())}> + <MenubarItem + id="help-shortcuts" + onClick={() => dispatch(showKeyboardShortcutModal())} + > {t('Nav.Help.KeyboardShortcuts')} </MenubarItem> - <MenubarItem href="https://p5js.org/reference/"> + <MenubarItem id="help-reference" href="https://p5js.org/reference/"> {t('Nav.Help.Reference')} </MenubarItem> - <MenubarItem href="/about">{t('Nav.Help.About')}</MenubarItem> + <MenubarItem id="help-about" href="/about"> + {t('Nav.Help.About')} + </MenubarItem> </MenubarSubmenu> {getConfig('TRANSLATIONS_ENABLED') && <LanguageMenu />} </ul> @@ -275,6 +309,7 @@ const LanguageMenu = () => { {sortBy(availableLanguages).map((key) => ( // eslint-disable-next-line react/jsx-no-bind <MenubarItem + id={key} key={key} value={key} onClick={handleLangSelection} @@ -291,7 +326,7 @@ const LanguageMenu = () => { const UnauthenticatedUserMenu = () => { const { t } = useTranslation(); return ( - <ul className="nav__items-right" title="user-menu"> + <ul className="nav__items-right" title="user-menu" role="group"> <li className="nav__item"> <Link to="/login" className="nav__auth-button"> <span className="nav__item-header" title="Login"> @@ -320,7 +355,7 @@ const AuthenticatedUserMenu = () => { const dispatch = useDispatch(); return ( - <ul className="nav__items-right" title="user-menu"> + <ul className="nav__items-right" title="user-menu" role="group"> <MenubarSubmenu id="account" title={ @@ -329,20 +364,23 @@ const AuthenticatedUserMenu = () => { </span> } > - <MenubarItem href={`/${username}/sketches`}> + <MenubarItem id="account-sketches" href={`/${username}/sketches`}> {t('Nav.Auth.MySketches')} </MenubarItem> <MenubarItem + id="account-collections" href={`/${username}/collections`} - hideIf={!getConfig('UI_COLLECTIONS_ENABLED')} + isDisabled={!getConfig('UI_COLLECTIONS_ENABLED')} > {t('Nav.Auth.MyCollections')} </MenubarItem> - <MenubarItem href={`/${username}/assets`}> + <MenubarItem id="account-assets" href={`/${username}/assets`}> {t('Nav.Auth.MyAssets')} </MenubarItem> - <MenubarItem href="/account">{t('Preferences.Settings')}</MenubarItem> - <MenubarItem onClick={() => dispatch(logoutUser())}> + <MenubarItem id="account-settings" href="/account"> + {t('Preferences.Settings')} + </MenubarItem> + <MenubarItem id="account-logout" onClick={() => dispatch(logoutUser())}> {t('Nav.Auth.LogOut')} </MenubarItem> </MenubarSubmenu> diff --git a/client/modules/IDE/components/Header/Nav.unit.test.jsx b/client/modules/IDE/components/Header/Nav.unit.test.jsx index 4ee6b8dae1..a15df02f4f 100644 --- a/client/modules/IDE/components/Header/Nav.unit.test.jsx +++ b/client/modules/IDE/components/Header/Nav.unit.test.jsx @@ -1,3 +1,4 @@ +/* eslint-disable react/prop-types */ import React from 'react'; import { reduxRender } from '../../../../test-utils'; @@ -5,6 +6,51 @@ import Nav from './Nav'; jest.mock('../../../../utils/generateRandomName'); +// mock Menubar +jest.mock( + '../../../../components/Menubar/Menubar', + () => + function Menubar({ children, className = 'nav__menubar' }) { + return ( + <ul className={className} role="menubar"> + {children} + </ul> + ); + } +); + +// mock MenubarSubmenu +jest.mock('../../../../components/Menubar/MenubarSubmenu', () => { + function MenubarSubmenu({ children, title }) { + return ( + <li className="nav__item"> + <span role="menuitem">{title}</span> + <ul role="menu" aria-label={`${title} menu`}> + {children} + </ul> + </li> + ); + } + + MenubarSubmenu.useMenuProps = () => ({ + isOpen: false, + handlers: {} + }); + + return MenubarSubmenu; +}); + +// mock MenubarItem +jest.mock( + '../../../../components/Menubar/MenubarItem', + () => + function MenubarItem({ children, hideIf }) { + if (hideIf) return null; + + return <li>{children}</li>; + } +); + describe('Nav', () => { it('renders editor version for desktop', () => { const { asFragment } = reduxRender(<Nav />, { mobile: false }); diff --git a/client/modules/IDE/components/Header/__snapshots__/Nav.unit.test.jsx.snap b/client/modules/IDE/components/Header/__snapshots__/Nav.unit.test.jsx.snap index 290528582a..faa260aa8b 100644 --- a/client/modules/IDE/components/Header/__snapshots__/Nav.unit.test.jsx.snap +++ b/client/modules/IDE/components/Header/__snapshots__/Nav.unit.test.jsx.snap @@ -6,42 +6,52 @@ exports[`Nav renders dashboard version for desktop 1`] = ` class="nav__header" > <div + class="nav__item-logo" + > + <a + href="https://p5js.org" + > + <test-file-stub + aria-label="p5.js Logo" + classname="svg__logo" + focusable="true" + role="img" + /> + </a> + </div> + <nav class="nav" > <ul - class="nav__items-left" + class="nav__menubar" + role="menubar" > - <li - class="nav__item-logo" - > - <test-file-stub - aria-label="p5.js Logo" - classname="svg__logo" - focusable="false" - role="img" - /> - </li> - <li - class="nav__item nav__item--no-icon" + <ul + class="nav__items-left" + role="group" > - <a - class="nav__back-link" - href="/" + <li + class="nav__item nav__item--no-icon" > - <test-file-stub - aria-hidden="true" - classname="nav__back-icon" - focusable="false" - /> - <span - class="nav__item-header" - > - Back to Editor - </span> - </a> - </li> + <a + class="nav__back-link" + href="/" + > + <test-file-stub + aria-hidden="true" + classname="nav__back-icon" + focusable="false" + /> + <span + class="nav__item-header" + > + Back to Editor + </span> + </a> + </li> + </ul> </ul> - </div> + </nav> </header> </DocumentFragment> `; @@ -267,8 +277,9 @@ exports[`Nav renders dashboard version for mobile 1`] = ` color: #FFF; } -<div +<ul class="c0" + role="menubar" > <div class="c1" @@ -350,135 +361,58 @@ exports[`Nav renders dashboard version for mobile 1`] = ` <b> File </b> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - New - </button> + <li> + New </li> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - Save - </button> + <li> + Save </li> - <li - class="nav__dropdown-item" - > - <a - href="/p5/sketches" - role="menuitem" - > - Examples - </a> + <li> + Examples </li> <b> Edit </b> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - Tidy Code - </button> + <li> + Tidy Code </li> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - Find - </button> + <li> + Find </li> <b> Sketch </b> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - Add File - </button> + <li> + Add File </li> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - Add Folder - </button> + <li> + Add Folder </li> <b> Settings </b> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - Preferences - </button> + <li> + Preferences </li> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - Language - </button> + <li> + Language </li> <b> Help </b> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - Keyboard Shortcuts - </button> + <li> + Keyboard Shortcuts </li> - <li - class="nav__dropdown-item" - > - <a - href="https://p5js.org/reference/" - rel="noopener noreferrer" - role="menuitem" - target="_blank" - > - Reference - </a> + <li> + Reference </li> - <li - class="nav__dropdown-item" - > - <a - href="/about" - role="menuitem" - > - About - </a> + <li> + About </li> </ul> </div> </div> - </div> + </ul> </DocumentFragment> `; @@ -488,261 +422,178 @@ exports[`Nav renders editor version for desktop 1`] = ` class="nav__header" > <div + class="nav__item-logo" + > + <a + href="https://p5js.org" + > + <test-file-stub + aria-label="p5.js Logo" + classname="svg__logo" + focusable="true" + role="img" + /> + </a> + </div> + <nav class="nav" > <ul - class="nav__items-left" + class="nav__menubar" role="menubar" > - <li - class="nav__item-logo" - > - <a - href="https://p5js.org" - > - <test-file-stub - aria-label="p5.js Logo" - classname="svg__logo" - focusable="false" - role="img" - /> - </a> - </li> - <li - class="nav__item" + <ul + class="nav__items-left" + role="group" > - <button - aria-expanded="false" - aria-haspopup="menu" - role="menuitem" + <li + class="nav__item" > <span - class="nav__item-header" + role="menuitem" > File </span> - <test-file-stub - aria-hidden="true" - classname="nav__item-header-triangle" - focusable="false" - /> - </button> - <ul - class="nav__dropdown" - role="menu" - > - <li - class="nav__dropdown-item" + <ul + aria-label="File menu" + role="menu" > - <button - role="menuitem" - > + <li> New - </button> - </li> - </ul> - </li> - <li - class="nav__item" - > - <button - aria-expanded="false" - aria-haspopup="menu" - role="menuitem" + </li> + <li> + Save + <span + class="nav__keyboard-shortcut" + > + Ctrl+S + </span> + </li> + <li> + Duplicate + </li> + <li> + Share + </li> + <li> + Download + </li> + <li> + Open + </li> + <li> + Add to Collection + </li> + <li> + Examples + </li> + </ul> + </li> + <li + class="nav__item" > <span - class="nav__item-header" + role="menuitem" > Edit </span> - <test-file-stub - aria-hidden="true" - classname="nav__item-header-triangle" - focusable="false" - /> - </button> - <ul - class="nav__dropdown" - role="menu" - > - <li - class="nav__dropdown-item" + <ul + aria-label="Edit menu" + role="menu" > - <button - role="menuitem" - > + <li> Tidy Code <span class="nav__keyboard-shortcut" > Ctrl+Shift+F </span> - </button> - </li> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > + </li> + <li> Find <span class="nav__keyboard-shortcut" > Ctrl+F </span> - </button> - </li> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > + </li> + <li> Replace <span class="nav__keyboard-shortcut" > Ctrl+H </span> - </button> - </li> - </ul> - </li> - <li - class="nav__item" - > - <button - aria-expanded="false" - aria-haspopup="menu" - role="menuitem" + </li> + </ul> + </li> + <li + class="nav__item" > <span - class="nav__item-header" + role="menuitem" > Sketch </span> - <test-file-stub - aria-hidden="true" - classname="nav__item-header-triangle" - focusable="false" - /> - </button> - <ul - class="nav__dropdown" - role="menu" - > - <li - class="nav__dropdown-item" + <ul + aria-label="Sketch menu" + role="menu" > - <button - role="menuitem" - > + <li> Add File <span class="nav__keyboard-shortcut" > Ctrl+Alt+N </span> - </button> - </li> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > + </li> + <li> Add Folder - </button> - </li> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > + </li> + <li> Run <span class="nav__keyboard-shortcut" > Ctrl+Enter </span> - </button> - </li> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > + </li> + <li> Stop <span class="nav__keyboard-shortcut" > Shift+Ctrl+Enter </span> - </button> - </li> - </ul> - </li> - <li - class="nav__item" - > - <button - aria-expanded="false" - aria-haspopup="menu" - role="menuitem" + </li> + </ul> + </li> + <li + class="nav__item" > <span - class="nav__item-header" + role="menuitem" > Help </span> - <test-file-stub - aria-hidden="true" - classname="nav__item-header-triangle" - focusable="false" - /> - </button> - <ul - class="nav__dropdown" - role="menu" - > - <li - class="nav__dropdown-item" + <ul + aria-label="Help menu" + role="menu" > - <button - role="menuitem" - > + <li> Keyboard Shortcuts - </button> - </li> - <li - class="nav__dropdown-item" - > - <a - href="https://p5js.org/reference/" - rel="noopener noreferrer" - role="menuitem" - target="_blank" - > + </li> + <li> Reference - </a> - </li> - <li - class="nav__dropdown-item" - > - <a - href="/about" - role="menuitem" - > + </li> + <li> About - </a> - </li> - </ul> - </li> + </li> + </ul> + </li> + </ul> </ul> - </div> + </nav> </header> </DocumentFragment> `; @@ -968,8 +819,9 @@ exports[`Nav renders editor version for mobile 1`] = ` color: #FFF; } -<div +<ul class="c0" + role="menubar" > <div class="c1" @@ -1051,134 +903,57 @@ exports[`Nav renders editor version for mobile 1`] = ` <b> File </b> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - New - </button> + <li> + New </li> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - Save - </button> + <li> + Save </li> - <li - class="nav__dropdown-item" - > - <a - href="/p5/sketches" - role="menuitem" - > - Examples - </a> + <li> + Examples </li> <b> Edit </b> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - Tidy Code - </button> + <li> + Tidy Code </li> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - Find - </button> + <li> + Find </li> <b> Sketch </b> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - Add File - </button> + <li> + Add File </li> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - Add Folder - </button> + <li> + Add Folder </li> <b> Settings </b> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - Preferences - </button> + <li> + Preferences </li> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - Language - </button> + <li> + Language </li> <b> Help </b> - <li - class="nav__dropdown-item" - > - <button - role="menuitem" - > - Keyboard Shortcuts - </button> + <li> + Keyboard Shortcuts </li> - <li - class="nav__dropdown-item" - > - <a - href="https://p5js.org/reference/" - rel="noopener noreferrer" - role="menuitem" - target="_blank" - > - Reference - </a> + <li> + Reference </li> - <li - class="nav__dropdown-item" - > - <a - href="/about" - role="menuitem" - > - About - </a> + <li> + About </li> </ul> </div> </div> - </div> + </ul> </DocumentFragment> `; 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'); } } }