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 }) {
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 (
-
+ <>
-
{user && user.username !== undefined ? (
@@ -247,7 +249,8 @@ const ProjectMenu = () => {
{t('Nav.Help.About')}
-
+ {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
-
- -
-
-
- -
-
+
+ -
- Save
-
-
- -
-
+ Save
+
+
+ -
- Examples
-
-
-
- Edit
-
- -
-
+
+ Edit
+
+ -
- Tidy Code
-
-
- -
-
+
+ -
- Find
-
-
-
- Sketch
-
- -
-
+
+
+ Sketch
+
+ -
- Add File
-
-
- -
-
+
+ -
- Add Folder
-
-
-
- Settings
-
- -
-
+
+
+ Settings
+
+ -
- Preferences
-
-
- -
-
+
+ -
- Language
-
-
-
- Help
-
- -
-
+
+
+ Help
+
+ -
- Keyboard Shortcuts
-
-
- -
-
+ Keyboard Shortcuts
+
+
+ -
- Reference
-
-
- -
-
+ Reference
+
+
+ -
- About
-
-
-
+
+ About
+
+
+
+
-
+
@@ -513,6 +535,7 @@ exports[`Nav renders editor version for desktop 1`] = `
aria-expanded="false"
aria-haspopup="menu"
role="menuitem"
+ tabindex="0"
>