diff --git a/client/components/Menubar/Menubar.jsx b/client/components/Menubar/Menubar.jsx
new file mode 100644
index 0000000000..b806246515
--- /dev/null
+++ b/client/components/Menubar/Menubar.jsx
@@ -0,0 +1,86 @@
+import PropTypes from 'prop-types';
+import React, { useCallback, useMemo, useRef, useState } from 'react';
+import useModalClose from '../../common/useModalClose';
+import { MenuOpenContext, MenubarContext } from './contexts';
+
+function Menubar({ children, className }) {
+ const [menuOpen, setMenuOpen] = useState('none');
+
+ const timerRef = useRef(null);
+
+ const handleClose = useCallback(() => {
+ setMenuOpen('none');
+ }, [setMenuOpen]);
+
+ const nodeRef = useModalClose(handleClose);
+
+ const clearHideTimeout = useCallback(() => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ }, [timerRef]);
+
+ const handleBlur = useCallback(() => {
+ timerRef.current = setTimeout(() => setMenuOpen('none'), 10);
+ }, [timerRef, setMenuOpen]);
+
+ const toggleMenuOpen = useCallback(
+ (menu) => {
+ setMenuOpen((prevState) => (prevState === menu ? 'none' : menu));
+ },
+ [setMenuOpen]
+ );
+
+ const contextValue = useMemo(
+ () => ({
+ createMenuHandlers: (menu) => ({
+ onMouseOver: () => {
+ setMenuOpen((prevState) => (prevState === 'none' ? 'none' : menu));
+ },
+ onClick: () => {
+ toggleMenuOpen(menu);
+ },
+ onBlur: handleBlur,
+ onFocus: clearHideTimeout
+ }),
+ createMenuItemHandlers: (menu) => ({
+ onMouseUp: (e) => {
+ if (e.button === 2) {
+ return;
+ }
+ setMenuOpen('none');
+ },
+ onBlur: handleBlur,
+ onFocus: () => {
+ clearHideTimeout();
+ setMenuOpen(menu);
+ }
+ }),
+ toggleMenuOpen
+ }),
+ [setMenuOpen, toggleMenuOpen, clearHideTimeout, handleBlur]
+ );
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+
+Menubar.propTypes = {
+ children: PropTypes.node,
+ className: PropTypes.string
+};
+
+Menubar.defaultProps = {
+ children: null,
+ className: 'nav'
+};
+
+export default Menubar;
diff --git a/client/components/Menubar/MenubarItem.jsx b/client/components/Menubar/MenubarItem.jsx
new file mode 100644
index 0000000000..8d595bb5cd
--- /dev/null
+++ b/client/components/Menubar/MenubarItem.jsx
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React, { useContext, useMemo } from 'react';
+import ButtonOrLink from '../../common/ButtonOrLink';
+import { MenubarContext, ParentMenuContext } from './contexts';
+
+function MenubarItem({
+ hideIf,
+ className,
+ role: customRole,
+ selected,
+ ...rest
+}) {
+ const parent = useContext(ParentMenuContext);
+
+ const { createMenuItemHandlers } = useContext(MenubarContext);
+
+ const handlers = useMemo(() => createMenuItemHandlers(parent), [
+ createMenuItemHandlers,
+ parent
+ ]);
+
+ if (hideIf) {
+ return null;
+ }
+
+ const role = customRole || 'menuitem';
+ const ariaSelected = role === 'option' ? { 'aria-selected': selected } : {};
+
+ return (
+
+
+
+ );
+}
+
+MenubarItem.propTypes = {
+ ...ButtonOrLink.propTypes,
+ onClick: PropTypes.func,
+ value: PropTypes.string,
+ /**
+ * Provides a way to deal with optional items.
+ */
+ hideIf: PropTypes.bool,
+ className: PropTypes.string,
+ role: PropTypes.oneOf(['menuitem', 'option']),
+ selected: PropTypes.bool
+};
+
+MenubarItem.defaultProps = {
+ onClick: null,
+ value: null,
+ hideIf: false,
+ className: 'nav__dropdown-item',
+ role: 'menuitem',
+ selected: false
+};
+
+export default MenubarItem;
diff --git a/client/components/Menubar/MenubarSubmenu.jsx b/client/components/Menubar/MenubarSubmenu.jsx
new file mode 100644
index 0000000000..13b0e33177
--- /dev/null
+++ b/client/components/Menubar/MenubarSubmenu.jsx
@@ -0,0 +1,135 @@
+// https://blog.logrocket.com/building-accessible-menubar-component-react
+
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React, { useContext, useMemo } from 'react';
+import TriangleIcon from '../../images/down-filled-triangle.svg';
+import { MenuOpenContext, MenubarContext, ParentMenuContext } from './contexts';
+
+export function useMenuProps(id) {
+ const activeMenu = useContext(MenuOpenContext);
+
+ const isOpen = id === activeMenu;
+
+ const { createMenuHandlers } = useContext(MenubarContext);
+
+ const handlers = useMemo(() => createMenuHandlers(id), [
+ createMenuHandlers,
+ id
+ ]);
+
+ return { isOpen, handlers };
+}
+
+/* -------------------------------------------------------------------------------------------------
+ * MenubarTrigger
+ * -----------------------------------------------------------------------------------------------*/
+
+function MenubarTrigger({ id, title, role, hasPopup, ...props }) {
+ const { isOpen, handlers } = useMenuProps(id);
+
+ return (
+
+ );
+}
+
+MenubarTrigger.propTypes = {
+ id: PropTypes.string.isRequired,
+ title: PropTypes.node.isRequired,
+ role: PropTypes.string,
+ hasPopup: PropTypes.oneOf(['menu', 'listbox', 'true'])
+};
+
+MenubarTrigger.defaultProps = {
+ role: 'menuitem',
+ hasPopup: 'menu'
+};
+
+/* -------------------------------------------------------------------------------------------------
+ * MenubarList
+ * -----------------------------------------------------------------------------------------------*/
+
+function MenubarList({ id, children, role, ...props }) {
+ return (
+
+ );
+}
+
+MenubarList.propTypes = {
+ id: PropTypes.string.isRequired,
+ children: PropTypes.node,
+ role: PropTypes.oneOf(['menu', 'listbox'])
+};
+
+MenubarList.defaultProps = {
+ children: null,
+ role: 'menu'
+};
+
+/* -------------------------------------------------------------------------------------------------
+ * MenubarSubmenu
+ * -----------------------------------------------------------------------------------------------*/
+
+function MenubarSubmenu({
+ id,
+ title,
+ children,
+ triggerRole: customTriggerRole,
+ listRole: customListRole,
+ ...props
+}) {
+ const { isOpen } = useMenuProps(id);
+
+ const triggerRole = customTriggerRole || 'menuitem';
+ const listRole = customListRole || 'menu';
+
+ const hasPopup = listRole === 'listbox' ? 'listbox' : 'menu';
+
+ return (
+
+
+
+ {children}
+
+
+ );
+}
+
+MenubarSubmenu.propTypes = {
+ id: PropTypes.string.isRequired,
+ title: PropTypes.node.isRequired,
+ children: PropTypes.node,
+ triggerRole: PropTypes.string,
+ listRole: PropTypes.string
+};
+
+MenubarSubmenu.defaultProps = {
+ children: null,
+ triggerRole: 'menuitem',
+ listRole: 'menu'
+};
+
+export default MenubarSubmenu;
diff --git a/client/components/Nav/contexts.jsx b/client/components/Menubar/contexts.jsx
similarity index 62%
rename from client/components/Nav/contexts.jsx
rename to client/components/Menubar/contexts.jsx
index 896d7283f4..ab3bb9ffcf 100644
--- a/client/components/Nav/contexts.jsx
+++ b/client/components/Menubar/contexts.jsx
@@ -4,8 +4,8 @@ export const ParentMenuContext = createContext('none');
export const MenuOpenContext = createContext('none');
-export const NavBarContext = createContext({
- createDropdownHandlers: () => ({}),
+export const MenubarContext = createContext({
+ createMenuHandlers: () => ({}),
createMenuItemHandlers: () => ({}),
- toggleDropdownOpen: () => {}
+ toggleMenuOpen: () => {}
});
diff --git a/client/components/Nav/NavBar.jsx b/client/components/Nav/NavBar.jsx
deleted file mode 100644
index c8ae7c6377..0000000000
--- a/client/components/Nav/NavBar.jsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { useCallback, useMemo, useRef, useState } from 'react';
-import useModalClose from '../../common/useModalClose';
-import { MenuOpenContext, NavBarContext } from './contexts';
-
-function NavBar({ children, className }) {
- const [dropdownOpen, setDropdownOpen] = useState('none');
-
- const timerRef = useRef(null);
-
- const handleClose = useCallback(() => {
- setDropdownOpen('none');
- }, [setDropdownOpen]);
-
- const nodeRef = useModalClose(handleClose);
-
- const clearHideTimeout = useCallback(() => {
- if (timerRef.current) {
- clearTimeout(timerRef.current);
- timerRef.current = null;
- }
- }, [timerRef]);
-
- const handleBlur = useCallback(() => {
- timerRef.current = setTimeout(() => setDropdownOpen('none'), 10);
- }, [timerRef, setDropdownOpen]);
-
- const toggleDropdownOpen = useCallback(
- (dropdown) => {
- setDropdownOpen((prevState) =>
- prevState === dropdown ? 'none' : dropdown
- );
- },
- [setDropdownOpen]
- );
-
- const contextValue = useMemo(
- () => ({
- createDropdownHandlers: (dropdown) => ({
- onMouseOver: () => {
- setDropdownOpen((prevState) =>
- prevState === 'none' ? 'none' : dropdown
- );
- },
- onClick: () => {
- toggleDropdownOpen(dropdown);
- },
- onBlur: handleBlur,
- onFocus: clearHideTimeout
- }),
- createMenuItemHandlers: (dropdown) => ({
- onMouseUp: (e) => {
- if (e.button === 2) {
- return;
- }
- setDropdownOpen('none');
- },
- onBlur: handleBlur,
- onFocus: () => {
- clearHideTimeout();
- setDropdownOpen(dropdown);
- }
- }),
- toggleDropdownOpen
- }),
- [setDropdownOpen, toggleDropdownOpen, clearHideTimeout, handleBlur]
- );
-
- return (
-
-
-
- );
-}
-
-NavBar.propTypes = {
- children: PropTypes.node,
- className: PropTypes.string
-};
-
-NavBar.defaultProps = {
- children: null,
- className: 'nav'
-};
-
-export default NavBar;
diff --git a/client/components/Nav/NavDropdownMenu.jsx b/client/components/Nav/NavDropdownMenu.jsx
deleted file mode 100644
index d2c5744c46..0000000000
--- a/client/components/Nav/NavDropdownMenu.jsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { useContext, useMemo } from 'react';
-import TriangleIcon from '../../images/down-filled-triangle.svg';
-import { MenuOpenContext, NavBarContext, ParentMenuContext } from './contexts';
-
-export function useMenuProps(id) {
- const activeMenu = useContext(MenuOpenContext);
-
- const isOpen = id === activeMenu;
-
- const { createDropdownHandlers } = useContext(NavBarContext);
-
- const handlers = useMemo(() => createDropdownHandlers(id), [
- createDropdownHandlers,
- id
- ]);
-
- return { isOpen, handlers };
-}
-
-function NavDropdownMenu({ id, title, children }) {
- const { isOpen, handlers } = useMenuProps(id);
-
- return (
-
-
-
-
- );
-}
-
-NavDropdownMenu.propTypes = {
- id: PropTypes.string.isRequired,
- title: PropTypes.node.isRequired,
- children: PropTypes.node
-};
-
-NavDropdownMenu.defaultProps = {
- children: null
-};
-
-export default NavDropdownMenu;
diff --git a/client/components/Nav/NavMenuItem.jsx b/client/components/Nav/NavMenuItem.jsx
deleted file mode 100644
index 09436e43ee..0000000000
--- a/client/components/Nav/NavMenuItem.jsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { useContext, useMemo } from 'react';
-import ButtonOrLink from '../../common/ButtonOrLink';
-import { NavBarContext, ParentMenuContext } from './contexts';
-
-function NavMenuItem({ hideIf, className, ...rest }) {
- const parent = useContext(ParentMenuContext);
-
- const { createMenuItemHandlers } = useContext(NavBarContext);
-
- const handlers = useMemo(() => createMenuItemHandlers(parent), [
- createMenuItemHandlers,
- parent
- ]);
-
- if (hideIf) {
- return null;
- }
-
- return (
-
-
-
- );
-}
-
-NavMenuItem.propTypes = {
- ...ButtonOrLink.propTypes,
- onClick: PropTypes.func,
- value: PropTypes.string,
- /**
- * Provides a way to deal with optional items.
- */
- hideIf: PropTypes.bool,
- className: PropTypes.string
-};
-
-NavMenuItem.defaultProps = {
- onClick: null,
- value: null,
- hideIf: false,
- className: 'nav__dropdown-item'
-};
-
-export default NavMenuItem;
diff --git a/client/modules/IDE/components/Header/MobileNav.jsx b/client/modules/IDE/components/Header/MobileNav.jsx
index 37fa16bed3..349d3d1709 100644
--- a/client/modules/IDE/components/Header/MobileNav.jsx
+++ b/client/modules/IDE/components/Header/MobileNav.jsx
@@ -5,10 +5,10 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { sortBy } from 'lodash';
import classNames from 'classnames';
-import { ParentMenuContext } from '../../../../components/Nav/contexts';
-import NavBar from '../../../../components/Nav/NavBar';
-import { useMenuProps } from '../../../../components/Nav/NavDropdownMenu';
-import NavMenuItem from '../../../../components/Nav/NavMenuItem';
+import { ParentMenuContext } from '../../../../components/Menubar/contexts';
+import Menubar from '../../../../components/Menubar/Menubar';
+import { useMenuProps } from '../../../../components/Menubar/MenubarSubmenu';
+import NavMenuItem from '../../../../components/Menubar/MenubarItem';
import { prop, remSize } from '../../../../theme';
import AsteriskIcon from '../../../../images/p5-asterisk.svg';
import IconButton from '../../../../common/IconButton';
@@ -36,7 +36,7 @@ import Overlay from '../../../App/components/Overlay';
import ProjectName from './ProjectName';
import CollectionCreate from '../../../User/components/CollectionCreate';
-const Nav = styled(NavBar)`
+const Nav = styled(Menubar)`
background: ${prop('MobilePanel.default.background')};
color: ${prop('primaryTextColor')};
padding: ${remSize(8)} 0;
diff --git a/client/modules/IDE/components/Header/Nav.jsx b/client/modules/IDE/components/Header/Nav.jsx
index 3492c4388a..f40e9137bf 100644
--- a/client/modules/IDE/components/Header/Nav.jsx
+++ b/client/modules/IDE/components/Header/Nav.jsx
@@ -4,13 +4,13 @@ import { sortBy } from 'lodash';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
-import NavDropdownMenu from '../../../../components/Nav/NavDropdownMenu';
-import NavMenuItem from '../../../../components/Nav/NavMenuItem';
+import MenubarSubmenu from '../../../../components/Menubar/MenubarSubmenu';
+import MenubarItem from '../../../../components/Menubar/MenubarItem';
import { availableLanguages, languageKeyToLabel } from '../../../../i18n';
import getConfig from '../../../../utils/getConfig';
import { showToast } from '../../actions/toast';
import { setLanguage } from '../../actions/preferences';
-import NavBar from '../../../../components/Nav/NavBar';
+import Menubar from '../../../../components/Menubar/Menubar';
import CaretLeftIcon from '../../../../images/left-arrow.svg';
import LogoIcon from '../../../../images/p5js-logo-small.svg';
import { selectRootFile } from '../../selectors/files';
@@ -37,10 +37,14 @@ const Nav = ({ layout }) => {
return isMobile ? (
) : (
-
-
-
-
+ <>
+
+ >
);
};
@@ -159,9 +163,9 @@ const ProjectMenu = () => {
)}
-
- {t('Nav.File.New')}
-
+ {t('Nav.File.New')}
+ {
>
{t('Common.Save')}
{metaKeyName}+S
-
-
+ dispatch(cloneProject())}
>
{t('Nav.File.Duplicate')}
-
-
+
+
{t('Nav.File.Share')}
-
-
+
+
{t('Nav.File.Download')}
-
-
+
{t('Nav.File.Open')}
-
-
+ {
href={`/${user.username}/sketches/${project?.id}/add-to-collection`}
>
{t('Nav.File.AddToCollection')}
-
-
+
{t('Nav.File.Examples')}
-
-
-
-
+
+
+
+
{t('Nav.Edit.TidyCode')}
{metaKeyName}+Shift+F
-
-
+
+
{t('Nav.Edit.Find')}
{metaKeyName}+F
-
-
+
+
{t('Nav.Edit.Replace')}
{replaceCommand}
-
-
-
- dispatch(newFile(rootFile.id))}>
+
+
+
+ dispatch(newFile(rootFile.id))}>
{t('Nav.Sketch.AddFile')}
{newFileCommand}
-
- dispatch(newFolder(rootFile.id))}>
+
+ dispatch(newFolder(rootFile.id))}>
{t('Nav.Sketch.AddFolder')}
-
- dispatch(startSketch())}>
+
+ dispatch(startSketch())}>
{t('Nav.Sketch.Run')}
{metaKeyName}+Enter
-
- dispatch(stopSketch())}>
+
+ dispatch(stopSketch())}>
{t('Nav.Sketch.Stop')}
Shift+{metaKeyName}+Enter
-
-
-
- dispatch(showKeyboardShortcutModal())}>
+
+
+
+ dispatch(showKeyboardShortcutModal())}>
{t('Nav.Help.KeyboardShortcuts')}
-
-
+
+
{t('Nav.Help.Reference')}
-
- {t('Nav.Help.About')}
-
+
+ {t('Nav.Help.About')}
+
+ {getConfig('TRANSLATIONS_ENABLED') && }
);
};
@@ -261,32 +266,44 @@ const LanguageMenu = () => {
}
return (
-
+
{sortBy(availableLanguages).map((key) => (
// eslint-disable-next-line react/jsx-no-bind
-
+
{languageKeyToLabel(key)}
-
+
))}
-
+
);
};
const UnauthenticatedUserMenu = () => {
const { t } = useTranslation();
return (
-
- {getConfig('TRANSLATIONS_ENABLED') && }
+
-
-
+
{t('Nav.Login')}
- - {t('Nav.LoginOr')}
+ -
+ {t('Nav.LoginOr')}
+
-
-
+
{t('Nav.SignUp')}
@@ -303,9 +320,8 @@ const AuthenticatedUserMenu = () => {
const dispatch = useDispatch();
return (
-
- {getConfig('TRANSLATIONS_ENABLED') && }
-
+
@@ -313,23 +329,23 @@ const AuthenticatedUserMenu = () => {
}
>
-
+
{t('Nav.Auth.MySketches')}
-
-
+
{t('Nav.Auth.MyCollections')}
-
-
+
+
{t('Nav.Auth.MyAssets')}
-
- {t('Preferences.Settings')}
- dispatch(logoutUser())}>
+
+ {t('Preferences.Settings')}
+ dispatch(logoutUser())}>
{t('Nav.Auth.LogOut')}
-
-
+
+
);
};
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..d9140653c1 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
@@ -2,7 +2,9 @@
exports[`Nav renders dashboard version for desktop 1`] = `
-