diff --git a/client/components/Nav/NavBar.jsx b/client/components/Nav/NavBar.jsx index c8ae7c6377..5826613c81 100644 --- a/client/components/Nav/NavBar.jsx +++ b/client/components/Nav/NavBar.jsx @@ -1,13 +1,52 @@ import PropTypes from 'prop-types'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react'; import useModalClose from '../../common/useModalClose'; import { MenuOpenContext, NavBarContext } from './contexts'; +import usePrevious from '../../modules/IDE/hooks/usePrevious'; +import useKeyDownHandlers from '../../common/useKeyDownHandlers'; function NavBar({ children, className }) { const [dropdownOpen, setDropdownOpen] = useState('none'); - + const [currentIndex, setCurrentIndex] = useState(0); + const prevIndex = usePrevious(currentIndex) ?? null; + const menuItems = useRef(new Set()).current; const timerRef = useRef(null); + const first = () => { + setCurrentIndex(0); + }; + const last = () => { + setCurrentIndex(menuItems.size - 1); + }; + const next = () => { + const index = currentIndex === menuItems.size - 1 ? 0 : currentIndex + 1; + setCurrentIndex(index); + }; + const prev = () => { + const index = currentIndex === 0 ? menuItems.size - 1 : currentIndex - 1; + setCurrentIndex(index); + }; + + // match focused item to typed character; if no match, focus is not moved + + useEffect(() => { + if (currentIndex !== prevIndex) { + const items = Array.from(menuItems); + const currentNode = items[currentIndex]?.firstChild; + const prevNode = items[prevIndex]?.firstChild; + + prevNode?.setAttribute('tabindex', -1); + currentNode?.setAttribute('tabindex', 0); + currentNode?.focus(); + } + }, [currentIndex, prevIndex, menuItems]); + const handleClose = useCallback(() => { setDropdownOpen('none'); }, [setDropdownOpen]); @@ -34,6 +73,30 @@ function NavBar({ children, className }) { [setDropdownOpen] ); + useKeyDownHandlers({ + ArrowLeft: (e) => { + e.preventDefault(); + e.stopPropagation(); + prev(); + }, + ArrowRight: (e) => { + e.preventDefault(); + e.stopPropagation(); + next(); + }, + Home: (e) => { + e.preventDefault(); + e.stopPropagation(); + first(); + }, + End: (e) => { + e.preventDefault(); + e.stopPropagation(); + last(); + } + // keydown event listener for letter keys + }); + const contextValue = useMemo( () => ({ createDropdownHandlers: (dropdown) => ({ @@ -61,9 +124,16 @@ function NavBar({ children, className }) { setDropdownOpen(dropdown); } }), - toggleDropdownOpen + toggleDropdownOpen, + menuItems }), - [setDropdownOpen, toggleDropdownOpen, clearHideTimeout, handleBlur] + [ + setDropdownOpen, + toggleDropdownOpen, + clearHideTimeout, + handleBlur, + menuItems + ] ); return ( @@ -71,7 +141,9 @@ function NavBar({ children, className }) {
- {children} +
    + {children} +
diff --git a/client/components/Nav/NavDropdownMenu.jsx b/client/components/Nav/NavDropdownMenu.jsx index d2c5744c46..c120401f6a 100644 --- a/client/components/Nav/NavDropdownMenu.jsx +++ b/client/components/Nav/NavDropdownMenu.jsx @@ -1,8 +1,36 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; -import React, { useContext, useMemo } from 'react'; +import React, { + useContext, + useRef, + useEffect, + useMemo, + useState, + useReducer, + useCallback +} from 'react'; import TriangleIcon from '../../images/down-filled-triangle.svg'; -import { MenuOpenContext, NavBarContext, ParentMenuContext } from './contexts'; +import { + MenuOpenContext, + NavBarContext, + ParentMenuContext, + SubmenuContext +} from './contexts'; +import useKeyDownHandlers from '../../common/useKeyDownHandlers'; + +const INIT_STATE = { + currentIndex: null, + prevIndex: null +}; + +function submenuReducer(state, { type, payload }) { + switch (type) { + case 'setIndex': + return { ...state, currentIndex: payload, prevIndex: state.currentIndex }; + default: + return state; + } +} export function useMenuProps(id) { const activeMenu = useContext(MenuOpenContext); @@ -19,30 +47,164 @@ export function useMenuProps(id) { return { isOpen, handlers }; } -function NavDropdownMenu({ id, title, children }) { +function NavTrigger({ id, title, ...props }) { + const submenuContext = useContext(SubmenuContext); const { isOpen, handlers } = useMenuProps(id); + const { isFirstChild, first } = submenuContext; + + useKeyDownHandlers({ + Space: (e) => { + e.preventDefault(); + e.stopPropagation(); + first(); + }, + ArrowDown: (e) => { + // open the menu and focus on the first item + } + // handle match to char keys + }); + + const triggerProps = { + ...handlers, + ...props, + role: 'menuitem', + 'aria-haspopup': 'menu', + 'aria-expanded': isOpen, + tabIndex: isFirstChild ? 0 : -1 + }; + + return ( + + ); +} + +NavTrigger.propTypes = { + id: PropTypes.string.isRequired, + title: PropTypes.node.isRequired +}; + +function NavList({ children, id }) { + const submenuContext = useContext(SubmenuContext); + + const { submenuItems, currentIndex, dispatch } = submenuContext; + + const prev = () => { + const index = currentIndex === 0 ? submenuItems.size - 1 : currentIndex - 1; + dispatch({ type: 'setIndex', payload: index }); + }; + + const next = () => { + const index = currentIndex === submenuItems.size - 1 ? 0 : currentIndex + 1; + dispatch({ type: 'setIndex', payload: index }); + }; + + useKeyDownHandlers({ + ArrowUp: (e) => { + e.preventDefault(); + e.stopPropagation(); + prev(); + }, + ArrowDown: (e) => { + e.preventDefault(); + e.stopPropagation(); + next(); + } + // keydown event listener for letter keys + }); + + const listProps = { + role: 'menu' + }; + + return ( + + ); +} + +NavList.propTypes = { + id: PropTypes.string.isRequired, + children: PropTypes.node +}; + +NavList.defaultProps = { + children: null +}; + +function NavDropdownMenu({ id, title, children }) { + const { isOpen } = useMenuProps(id); + const [isFirstChild, setIsFirstChild] = useState(false); + const menuItemRef = useRef(); + const { menuItems } = useContext(NavBarContext); + const submenuItems = useRef(new Set()).current; + const [state, dispatch] = useReducer(submenuReducer, INIT_STATE); + const { currentIndex, prevIndex } = state; + + const first = useCallback(() => { + dispatch({ type: 'setIndex', payload: 0 }); + }, []); + + const last = useCallback( + () => dispatch({ type: 'setIndex', payload: submenuItems.size - 1 }), + [submenuItems.size] + ); + + useEffect(() => { + const menuItemNode = menuItemRef.current; + if (menuItemNode) { + if (!menuItems.size) { + setIsFirstChild(true); + } + menuItems.add(menuItemNode); + } + + return () => { + menuItems.delete(menuItemNode); + }; + }, [menuItems]); + + useEffect(() => { + const items = Array.from(submenuItems); + + if (currentIndex !== prevIndex) { + const currentNode = items[currentIndex]?.firstChild; + currentNode?.focus(); + } + }, [submenuItems, currentIndex, prevIndex]); + + const value = useMemo( + () => ({ + isFirstChild, + submenuItems, + currentIndex, + dispatch, + first, + last + }), + [isFirstChild, submenuItems, currentIndex, first, last] + ); + return ( -
  • - - -
  • + + {children} + + ); } diff --git a/client/components/Nav/NavMenuItem.jsx b/client/components/Nav/NavMenuItem.jsx index 09436e43ee..812633fae7 100644 --- a/client/components/Nav/NavMenuItem.jsx +++ b/client/components/Nav/NavMenuItem.jsx @@ -1,25 +1,51 @@ import PropTypes from 'prop-types'; -import React, { useContext, useMemo } from 'react'; +import React, { useContext, useMemo, useState, useRef, useEffect } from 'react'; import ButtonOrLink from '../../common/ButtonOrLink'; -import { NavBarContext, ParentMenuContext } from './contexts'; +import { NavBarContext, ParentMenuContext, SubmenuContext } from './contexts'; function NavMenuItem({ hideIf, className, ...rest }) { + const [isFirstChild, setIsFirstChild] = useState(false); + const menuItemRef = useRef(null); + const menubarContext = useContext(NavBarContext); + const submenuContext = useContext(SubmenuContext); + const { submenuItems } = submenuContext; const parent = useContext(ParentMenuContext); - const { createMenuItemHandlers } = useContext(NavBarContext); + const { createMenuItemHandlers } = menubarContext; const handlers = useMemo(() => createMenuItemHandlers(parent), [ createMenuItemHandlers, parent ]); + useEffect(() => { + const menuItemNode = menuItemRef.current; + if (menuItemNode) { + if (!submenuItems?.size) { + setIsFirstChild(true); + } + submenuItems?.add(menuItemNode); + } + + return () => { + submenuItems?.delete(menuItemNode); + }; + }, [submenuItems]); + if (hideIf) { return null; } + const buttonProps = { + ...rest, + ...handlers, + role: 'menuitem', + tabIndex: !submenuContext && isFirstChild ? 0 : -1 + }; + return ( -
  • - +
  • +
  • ); } diff --git a/client/components/Nav/contexts.jsx b/client/components/Nav/contexts.jsx index 896d7283f4..202011b5cc 100644 --- a/client/components/Nav/contexts.jsx +++ b/client/components/Nav/contexts.jsx @@ -2,6 +2,8 @@ import { createContext } from 'react'; export const ParentMenuContext = createContext('none'); +export const SubmenuContext = createContext('none'); + export const MenuOpenContext = createContext('none'); export const NavBarContext = createContext({ diff --git a/client/modules/IDE/components/Header/Nav.jsx b/client/modules/IDE/components/Header/Nav.jsx index 3492c4388a..4ad4a6c86d 100644 --- a/client/modules/IDE/components/Header/Nav.jsx +++ b/client/modules/IDE/components/Header/Nav.jsx @@ -37,10 +37,12 @@ const Nav = ({ layout }) => { return isMobile ? ( ) : ( - - + <> + + + - + ); }; @@ -137,7 +139,7 @@ const ProjectMenu = () => { metaKey === 'Ctrl' ? `${metaKeyName}+Alt+N` : `${metaKeyName}+⌥+N`; return ( - + {getConfig('TRANSLATIONS_ENABLED') && } + ); }; @@ -276,9 +279,8 @@ const UnauthenticatedUserMenu = () => { const { t } = useTranslation(); return (
      - {getConfig('TRANSLATIONS_ENABLED') && }
    • - + {t('Nav.Login')} @@ -286,7 +288,7 @@ const UnauthenticatedUserMenu = () => {
    • {t('Nav.LoginOr')}
    • - + {t('Nav.SignUp')} 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 af56a1a418..1f1f887f91 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 @@ -8,36 +8,41 @@ exports[`Nav renders dashboard version for desktop 1`] = ` >
    @@ -269,51 +274,71 @@ exports[`Nav renders dashboard version for mobile 1`] = `
    -
    - -
    - +
    +

    + - - Test project name - - + + Test project name + + -

    -
    -
    - +
    + +
    - -
    -
    - -
      - - File - - - + - + - - Edit - - + + Edit + + - + - - Sketch - - + + Sketch + + - + - - Settings - - + + Settings + + - + - - Help - - + + Help + + - + - + -
    + + About + + + +
    -
    +
    @@ -513,6 +535,7 @@ exports[`Nav renders editor version for desktop 1`] = ` aria-expanded="false" aria-haspopup="menu" role="menuitem" + tabindex="0" > @@ -547,6 +571,7 @@ exports[`Nav renders editor version for desktop 1`] = ` aria-expanded="false" aria-haspopup="menu" role="menuitem" + tabindex="-1" > @@ -658,6 +689,7 @@ exports[`Nav renders editor version for desktop 1`] = ` > @@ -722,6 +757,7 @@ exports[`Nav renders editor version for desktop 1`] = ` href="https://p5js.org/reference/" rel="noopener noreferrer" role="menuitem" + tabindex="-1" target="_blank" > Reference @@ -733,6 +769,7 @@ exports[`Nav renders editor version for desktop 1`] = ` About @@ -970,51 +1007,71 @@ exports[`Nav renders editor version for mobile 1`] = `
    -
    - -
    - +
    +

    + - - Test project name - - + + Test project name + + -

    -
    -
    - +
    + +
    - -
    -
    - -
      - - File - - - + - + - - Edit - - + + Edit + + - + - - Sketch - - + + Sketch + + - + - - Settings - - + + Settings + + - + - - Help - - + + Help + + - + - + -
    + + About + + + +
    -
    +
    diff --git a/client/modules/IDE/hooks/usePrevious.js b/client/modules/IDE/hooks/usePrevious.js new file mode 100644 index 0000000000..4e67dbb23c --- /dev/null +++ b/client/modules/IDE/hooks/usePrevious.js @@ -0,0 +1,12 @@ +/* https://usehooks.com/usePrevious/ */ +import { useRef, useEffect } from 'react'; + +export default function usePrevious(value) { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +}