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 ( - -
    -
    - - {children} - -
    -
    -
    - ); -} - -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 ( - - + `; diff --git a/client/styles/components/_nav.scss b/client/styles/components/_nav.scss index 1f4a7fc364..58691ff251 100644 --- a/client/styles/components/_nav.scss +++ b/client/styles/components/_nav.scss @@ -3,26 +3,34 @@ .nav { height: #{math.div(42, $base-font-size)}rem; display: flex; + width: 100%; flex-direction: row; justify-content: space-between; - @include themify() { - border-bottom: 1px dashed map-get($theme-map, 'nav-border-color'); - } - & button { padding: 0; } } +.nav__header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + @include themify() { + border-bottom: 1px dashed map-get($theme-map, 'nav-border-color'); + } + // padding-left: #{math.div(20, $base-font-size)}rem; +} + .nav__items-left, .nav__items-right { list-style: none; display: flex; flex-direction: row; - justify-content: flex-end; - height: 100%; align-items: center; + height: 100%; } .preview-nav__editor-svg {