From b9535b36139d582124446ceebda326d459175785 Mon Sep 17 00:00:00 2001 From: Tristan Espinoza Date: Fri, 26 Jul 2024 12:17:42 -0700 Subject: [PATCH 01/18] feat: adding usePrevious hook --- client/modules/IDE/hooks/usePrevious.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 client/modules/IDE/hooks/usePrevious.js diff --git a/client/modules/IDE/hooks/usePrevious.js b/client/modules/IDE/hooks/usePrevious.js new file mode 100644 index 0000000000..42ca1895fe --- /dev/null +++ b/client/modules/IDE/hooks/usePrevious.js @@ -0,0 +1,12 @@ +/* https://usehooks.com/usePrevious/ */ +import React, { useRef, useEffect } from 'react'; + +export default function usePrevious(value) { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +} From 919b94b2cdea029bab35e0c40fdf77aa5c0a018d Mon Sep 17 00:00:00 2001 From: Tristan Espinoza Date: Fri, 26 Jul 2024 12:19:59 -0700 Subject: [PATCH 02/18] feat: adding roving focus implementation --- client/components/Nav/NavBar.jsx | 12 +++++- client/components/Nav/NavDropdownMenu.jsx | 45 ++++++++++++++++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/client/components/Nav/NavBar.jsx b/client/components/Nav/NavBar.jsx index c8ae7c6377..4f4952881b 100644 --- a/client/components/Nav/NavBar.jsx +++ b/client/components/Nav/NavBar.jsx @@ -5,6 +5,7 @@ import { MenuOpenContext, NavBarContext } from './contexts'; function NavBar({ children, className }) { const [dropdownOpen, setDropdownOpen] = useState('none'); + const menuItems = useRef(new Set()).current; const timerRef = useRef(null); @@ -61,9 +62,16 @@ function NavBar({ children, className }) { setDropdownOpen(dropdown); } }), - toggleDropdownOpen + toggleDropdownOpen, + menuItems }), - [setDropdownOpen, toggleDropdownOpen, clearHideTimeout, handleBlur] + [ + setDropdownOpen, + toggleDropdownOpen, + clearHideTimeout, + handleBlur, + menuItems + ] ); return ( diff --git a/client/components/Nav/NavDropdownMenu.jsx b/client/components/Nav/NavDropdownMenu.jsx index d2c5744c46..41f949af12 100644 --- a/client/components/Nav/NavDropdownMenu.jsx +++ b/client/components/Nav/NavDropdownMenu.jsx @@ -1,7 +1,8 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; -import React, { useContext, useMemo } from 'react'; +import React, { useContext, useRef, useEffect, useMemo, useState } from 'react'; import TriangleIcon from '../../images/down-filled-triangle.svg'; +import usePrevious from '../../modules/IDE/hooks/usePrevious'; import { MenuOpenContext, NavBarContext, ParentMenuContext } from './contexts'; export function useMenuProps(id) { @@ -19,8 +20,48 @@ export function useMenuProps(id) { return { isOpen, handlers }; } +export function useRovingFocusProps() { + const [currentIndex, setCurrentIndex] = useState(0); + const prevIndex = usePrevious(currentIndex) ?? null; + + const [isFirstChild, setIsFirstChild] = useState(false); + const { menuItems } = useContext(NavBarContext); + const menuItemRef = useRef(); + + useEffect(() => { + const menuItemNode = menuItemRef.current; + + if (menuItemNode) { + if (menuItems.size === 0) { + setIsFirstChild(true); + } + + menuItems.add(menuItemNode); + } + + return () => { + menuItems.delete(menuItemNode); + }; + }, [menuItems]); + + 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]); + + return { menuItemRef, isFirstChild }; +} + function NavDropdownMenu({ id, title, children }) { const { isOpen, handlers } = useMenuProps(id); + const { menuItemRef, isFirstChild } = useRovingFocusProps(); return (
  • @@ -29,6 +70,8 @@ function NavDropdownMenu({ id, title, children }) { role="menuitem" aria-haspopup="menu" aria-expanded={isOpen} + ref={menuItemRef} + tabIndex={isFirstChild ? 0 : -1} > {title} Date: Wed, 7 Aug 2024 16:14:01 -0700 Subject: [PATCH 03/18] fix: tab focus is tracked in menubar instead of menuitem --- client/components/Nav/NavBar.jsx | 57 ++++++++++++++++++++++- client/components/Nav/NavDropdownMenu.jsx | 17 +------ 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/client/components/Nav/NavBar.jsx b/client/components/Nav/NavBar.jsx index 4f4952881b..bc18c5721d 100644 --- a/client/components/Nav/NavBar.jsx +++ b/client/components/Nav/NavBar.jsx @@ -1,14 +1,69 @@ 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'; 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); + }; + const match = (e) => { + const items = Array.from(menuItems); + + const reorderedItems = [ + ...items.slice(currentIndex), + ...items.slice(0, currentIndex) + ]; + + const matches = reorderedItems.filter((menuItem) => { + const { textContent } = menuItem.firstChild; + const firstChar = textContent[0].toLowerCase(); + return e.key === firstChar; + }); + + if (!matches.length) { + return; + } + + const currentNode = items[currentIndex]; + const nextMatch = matches.includes(currentNode) ? matches[1] : matches[0]; + const index = items.findIndex((item) => item === nextMatch); + setCurrentIndex(index); + }; + + 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]); diff --git a/client/components/Nav/NavDropdownMenu.jsx b/client/components/Nav/NavDropdownMenu.jsx index 41f949af12..87864ad21c 100644 --- a/client/components/Nav/NavDropdownMenu.jsx +++ b/client/components/Nav/NavDropdownMenu.jsx @@ -21,12 +21,9 @@ export function useMenuProps(id) { } export function useRovingFocusProps() { - const [currentIndex, setCurrentIndex] = useState(0); - const prevIndex = usePrevious(currentIndex) ?? null; - const [isFirstChild, setIsFirstChild] = useState(false); - const { menuItems } = useContext(NavBarContext); const menuItemRef = useRef(); + const { menuItems } = useContext(NavBarContext); useEffect(() => { const menuItemNode = menuItemRef.current; @@ -44,18 +41,6 @@ export function useRovingFocusProps() { }; }, [menuItems]); - 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]); - return { menuItemRef, isFirstChild }; } From 9d15664cbd7dbf2b584e0bf0f65b91dea93fa0ff Mon Sep 17 00:00:00 2001 From: Tristan Espinoza Date: Wed, 7 Aug 2024 19:04:59 -0700 Subject: [PATCH 04/18] fix: removed menuitem role from login and signup, moved language dropdown to left menu --- client/modules/IDE/components/Header/Nav.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/modules/IDE/components/Header/Nav.jsx b/client/modules/IDE/components/Header/Nav.jsx index 5bfdc1162e..ff9f794711 100644 --- a/client/modules/IDE/components/Header/Nav.jsx +++ b/client/modules/IDE/components/Header/Nav.jsx @@ -244,6 +244,7 @@ const ProjectMenu = () => { {t('Nav.Help.About')} + {getConfig('TRANSLATIONS_ENABLED') && } ); }; @@ -273,9 +274,8 @@ const UnauthenticatedUserMenu = () => { const { t } = useTranslation(); return (
      - {getConfig('TRANSLATIONS_ENABLED') && }
    • - + {t('Nav.Login')} @@ -283,7 +283,7 @@ const UnauthenticatedUserMenu = () => {
    • {t('Nav.LoginOr')}
    • - + {t('Nav.SignUp')} From be4ba67b22d4d3475335e50c4d81494fa0053518 Mon Sep 17 00:00:00 2001 From: Tristan Espinoza Date: Wed, 7 Aug 2024 19:07:45 -0700 Subject: [PATCH 05/18] feat: change focused item with based on directional keys --- client/components/Nav/NavBar.jsx | 102 ++++++++++++++-------- client/components/Nav/NavDropdownMenu.jsx | 21 ++--- 2 files changed, 73 insertions(+), 50 deletions(-) diff --git a/client/components/Nav/NavBar.jsx b/client/components/Nav/NavBar.jsx index bc18c5721d..401aa481df 100644 --- a/client/components/Nav/NavBar.jsx +++ b/client/components/Nav/NavBar.jsx @@ -9,6 +9,7 @@ import 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'); @@ -18,48 +19,14 @@ function NavBar({ children, className }) { 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); - }; - const match = (e) => { - const items = Array.from(menuItems); - - const reorderedItems = [ - ...items.slice(currentIndex), - ...items.slice(0, currentIndex) - ]; - - const matches = reorderedItems.filter((menuItem) => { - const { textContent } = menuItem.firstChild; - const firstChar = textContent[0].toLowerCase(); - return e.key === firstChar; - }); - - if (!matches.length) { - return; - } - - const currentNode = items[currentIndex]; - const nextMatch = matches.includes(currentNode) ? matches[1] : matches[0]; - const index = items.findIndex((item) => item === nextMatch); - setCurrentIndex(index); - }; - 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'); + prevNode?.setAttribute('tabindex', -1); + currentNode?.setAttribute('tabindex', 0); currentNode?.focus(); } }, [currentIndex, prevIndex, menuItems]); @@ -90,6 +57,69 @@ function NavBar({ children, className }) { [setDropdownOpen] ); + const first = () => { + console.log('first'); + setCurrentIndex(0); + }; + const last = () => { + console.log('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); + }; + const match = (e) => { + const items = Array.from(menuItems); + + const reorderedItems = [ + ...items.slice(currentIndex), + ...items.slice(0, currentIndex) + ]; + + const matches = reorderedItems.filter((menuItem) => { + const { textContent } = menuItem.firstChild; + const firstChar = textContent[0].toLowerCase(); + return e.key === firstChar; + }); + + if (!matches.length) { + return; + } + + const currentNode = items[currentIndex]; + const nextMatch = matches.includes(currentNode) ? matches[1] : matches[0]; + const index = items.findIndex((item) => item === nextMatch); + setCurrentIndex(index); + }; + + 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(); + } + }); + const contextValue = useMemo( () => ({ createDropdownHandlers: (dropdown) => ({ diff --git a/client/components/Nav/NavDropdownMenu.jsx b/client/components/Nav/NavDropdownMenu.jsx index 87864ad21c..ff7150ee63 100644 --- a/client/components/Nav/NavDropdownMenu.jsx +++ b/client/components/Nav/NavDropdownMenu.jsx @@ -2,7 +2,6 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React, { useContext, useRef, useEffect, useMemo, useState } from 'react'; import TriangleIcon from '../../images/down-filled-triangle.svg'; -import usePrevious from '../../modules/IDE/hooks/usePrevious'; import { MenuOpenContext, NavBarContext, ParentMenuContext } from './contexts'; export function useMenuProps(id) { @@ -20,19 +19,18 @@ export function useMenuProps(id) { return { isOpen, handlers }; } -export function useRovingFocusProps() { +function NavDropdownMenu({ id, title, children }) { + const { isOpen, handlers } = useMenuProps(id); const [isFirstChild, setIsFirstChild] = useState(false); const menuItemRef = useRef(); const { menuItems } = useContext(NavBarContext); useEffect(() => { const menuItemNode = menuItemRef.current; - if (menuItemNode) { - if (menuItems.size === 0) { + if (!menuItems.size) { setIsFirstChild(true); } - menuItems.add(menuItemNode); } @@ -41,21 +39,16 @@ export function useRovingFocusProps() { }; }, [menuItems]); - return { menuItemRef, isFirstChild }; -} - -function NavDropdownMenu({ id, title, children }) { - const { isOpen, handlers } = useMenuProps(id); - const { menuItemRef, isFirstChild } = useRovingFocusProps(); - return ( -
    • +
    • + ); +} + function NavDropdownMenu({ id, title, children }) { const { isOpen, handlers } = useMenuProps(id); const [isFirstChild, setIsFirstChild] = useState(false); @@ -44,20 +82,7 @@ function NavDropdownMenu({ id, title, children }) { className={classNames('nav__item', isOpen && 'nav__item--open')} ref={menuItemRef} > - +
        {children} @@ -77,4 +102,9 @@ NavDropdownMenu.defaultProps = { children: null }; +NavTrigger.propTypes = { + id: PropTypes.string.isRequired, + title: PropTypes.node.isRequired +}; + export default NavDropdownMenu; From 8c631fb4f3f7cca8801aca02266c62e1f1a3a4e0 Mon Sep 17 00:00:00 2001 From: Tristan Espinoza Date: Wed, 21 Aug 2024 17:36:06 -0700 Subject: [PATCH 10/18] adding submenu context --- client/components/Nav/contexts.jsx | 2 ++ 1 file changed, 2 insertions(+) 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({ From 572d9ef44d8581ec11dc1852e078207291dd0536 Mon Sep 17 00:00:00 2001 From: Tristan Espinoza Date: Thu, 22 Aug 2024 08:47:25 -0700 Subject: [PATCH 11/18] chore: edit to trigger component, added list component for dropdown menu --- client/components/Nav/NavDropdownMenu.jsx | 40 ++++++++++++++++------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/client/components/Nav/NavDropdownMenu.jsx b/client/components/Nav/NavDropdownMenu.jsx index 7770025b63..353b3691dd 100644 --- a/client/components/Nav/NavDropdownMenu.jsx +++ b/client/components/Nav/NavDropdownMenu.jsx @@ -38,11 +38,14 @@ function NavTrigger({ id, title, ...props }) { const triggerProps = { ...handlers, + ...props, role: 'menuitem', 'aria-haspopup': 'menu', 'aria-expanded': isOpen, tabIndex: 0, - onKeyDown: keyDown + onKeyDown: (e) => { + keyDown(e); + } }; return ( @@ -57,6 +60,30 @@ function NavTrigger({ id, title, ...props }) { ); } +NavTrigger.propTypes = { + id: PropTypes.string.isRequired, + title: PropTypes.node.isRequired +}; + +function NavList({ children, id }) { + return ( +
          + + {children} + +
        + ); +} + +NavList.propTypes = { + id: PropTypes.string.isRequired, + children: PropTypes.node +}; + +NavList.defaultProps = { + children: null +}; + function NavDropdownMenu({ id, title, children }) { const { isOpen, handlers } = useMenuProps(id); const [isFirstChild, setIsFirstChild] = useState(false); @@ -83,11 +110,7 @@ function NavDropdownMenu({ id, title, children }) { ref={menuItemRef} > -
          - - {children} - -
        + {children} ); } @@ -102,9 +125,4 @@ NavDropdownMenu.defaultProps = { children: null }; -NavTrigger.propTypes = { - id: PropTypes.string.isRequired, - title: PropTypes.node.isRequired -}; - export default NavDropdownMenu; From c8770f58b6a449de30de7dceab281ae23e0f34fc Mon Sep 17 00:00:00 2001 From: Tristan Espinoza Date: Thu, 22 Aug 2024 13:23:48 -0700 Subject: [PATCH 12/18] fix: moved user menu out of navbar --- client/modules/IDE/components/Header/Nav.jsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/client/modules/IDE/components/Header/Nav.jsx b/client/modules/IDE/components/Header/Nav.jsx index e0464d1641..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 ? ( @@ -248,7 +250,7 @@ const ProjectMenu = () => { {t('Nav.Help.About')} {getConfig('TRANSLATIONS_ENABLED') && } -
        + ); }; From 04d5e12bf555dee68a291b3cbd22bd41b0b2874d Mon Sep 17 00:00:00 2001 From: Tristan Espinoza Date: Thu, 22 Aug 2024 17:52:41 -0700 Subject: [PATCH 13/18] feat: crefactored, added functionality to implement roving tab index --- client/components/Nav/NavMenuItem.jsx | 36 +++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/client/components/Nav/NavMenuItem.jsx b/client/components/Nav/NavMenuItem.jsx index 09436e43ee..73c5f3fbee 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 ( -
      • - +
      • +
      • ); } From 530147f52850debf0642545e7653d800049add0b Mon Sep 17 00:00:00 2001 From: Tristan Espinoza Date: Thu, 22 Aug 2024 17:55:08 -0700 Subject: [PATCH 14/18] chore: refacted to include ul element and methods for navigating menuitems --- client/components/Nav/NavBar.jsx | 80 ++++++++++++++++---------------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/client/components/Nav/NavBar.jsx b/client/components/Nav/NavBar.jsx index 9f19a6be5b..722fdc7819 100644 --- a/client/components/Nav/NavBar.jsx +++ b/client/components/Nav/NavBar.jsx @@ -18,44 +18,6 @@ function NavBar({ children, className }) { const menuItems = useRef(new Set()).current; const timerRef = useRef(null); - 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]); - - 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 first = () => { setCurrentIndex(0); }; @@ -95,6 +57,44 @@ function NavBar({ children, className }) { setCurrentIndex(index); }; + 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]); + + 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] + ); + useKeyDownHandlers({ ArrowLeft: (e) => { e.preventDefault(); @@ -163,7 +163,9 @@ function NavBar({ children, className }) {
        - {children} +
          + {children} +
        From c5be1e7e1a50b254db23c12d0c709091bc248058 Mon Sep 17 00:00:00 2001 From: Tristan Espinoza Date: Thu, 22 Aug 2024 17:57:41 -0700 Subject: [PATCH 15/18] feat: refactored to separate components, implemented navigating list with up and down arrows --- client/components/Nav/NavDropdownMenu.jsx | 147 ++++++++++++++++++---- 1 file changed, 120 insertions(+), 27 deletions(-) diff --git a/client/components/Nav/NavDropdownMenu.jsx b/client/components/Nav/NavDropdownMenu.jsx index 353b3691dd..d25eaefbff 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, useRef, useEffect, useMemo, useState } 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); @@ -20,21 +48,22 @@ export function useMenuProps(id) { } function NavTrigger({ id, title, ...props }) { + const submenuContext = useContext(SubmenuContext); const { isOpen, handlers } = useMenuProps(id); - const menubarContext = useContext(NavBarContext); - - const keyDown = (e) => { - switch (e.key) { - case 'Enter': - case 'Space': - e.stopPropagation(); - // first(); - console.log('space'); - break; - default: - break; + + const { isFirstChild, first, last } = 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, @@ -42,10 +71,7 @@ function NavTrigger({ id, title, ...props }) { role: 'menuitem', 'aria-haspopup': 'menu', 'aria-expanded': isOpen, - tabIndex: 0, - onKeyDown: (e) => { - keyDown(e); - } + tabIndex: isFirstChild ? 0 : -1 }; return ( @@ -66,8 +92,40 @@ NavTrigger.propTypes = { }; function NavList({ children, id }) { + const submenuContext = useContext(SubmenuContext); + + const { submenuItems, currentIndex, dispatch, first, last } = 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 ( -
          +
            {children} @@ -89,6 +147,18 @@ function NavDropdownMenu({ id, title, children }) { 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; @@ -104,14 +174,37 @@ function NavDropdownMenu({ id, title, children }) { }; }, [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} -
          • + +
          • + + {children} +
          • +
            ); } From 697b19cb714a826e63fd41680f06267c401a46ad Mon Sep 17 00:00:00 2001 From: Tristan Espinoza Date: Thu, 22 Aug 2024 18:08:26 -0700 Subject: [PATCH 16/18] fix: optional chaining for mobile view --- client/components/Nav/NavMenuItem.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/components/Nav/NavMenuItem.jsx b/client/components/Nav/NavMenuItem.jsx index 73c5f3fbee..812633fae7 100644 --- a/client/components/Nav/NavMenuItem.jsx +++ b/client/components/Nav/NavMenuItem.jsx @@ -21,14 +21,14 @@ function NavMenuItem({ hideIf, className, ...rest }) { useEffect(() => { const menuItemNode = menuItemRef.current; if (menuItemNode) { - if (!submenuItems.size) { + if (!submenuItems?.size) { setIsFirstChild(true); } - submenuItems.add(menuItemNode); + submenuItems?.add(menuItemNode); } return () => { - submenuItems.delete(menuItemNode); + submenuItems?.delete(menuItemNode); }; }, [submenuItems]); From 0eac300fb1db4567b1afe3f11e3220b359588df6 Mon Sep 17 00:00:00 2001 From: Tristan Espinoza Date: Thu, 22 Aug 2024 18:08:55 -0700 Subject: [PATCH 17/18] fix: snapshots updated --- .../__snapshots__/Nav.unit.test.jsx.snap | 808 ++++++++++-------- 1 file changed, 429 insertions(+), 379 deletions(-) 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 243c6cec3f..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 + + +
        + - +
      @@ -535,6 +557,7 @@ exports[`Nav renders editor version for desktop 1`] = ` > @@ -570,6 +593,7 @@ exports[`Nav renders editor version for desktop 1`] = ` > @@ -661,6 +689,7 @@ exports[`Nav renders editor version for desktop 1`] = ` > @@ -726,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 @@ -737,6 +769,7 @@ exports[`Nav renders editor version for desktop 1`] = ` About @@ -974,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 + +
    • +
    + - + From 654889e343fdf41a64ab42fa7ec2824f5a402d6e Mon Sep 17 00:00:00 2001 From: Tristan Espinoza Date: Thu, 22 Aug 2024 18:09:05 -0700 Subject: [PATCH 18/18] fix: fixed lint errors --- client/components/Nav/NavBar.jsx | 24 +---------------------- client/components/Nav/NavDropdownMenu.jsx | 6 +++--- client/modules/IDE/hooks/usePrevious.js | 2 +- 3 files changed, 5 insertions(+), 27 deletions(-) diff --git a/client/components/Nav/NavBar.jsx b/client/components/Nav/NavBar.jsx index 722fdc7819..5826613c81 100644 --- a/client/components/Nav/NavBar.jsx +++ b/client/components/Nav/NavBar.jsx @@ -32,30 +32,8 @@ function NavBar({ children, className }) { const index = currentIndex === 0 ? menuItems.size - 1 : currentIndex - 1; setCurrentIndex(index); }; - const match = (e) => { - console.log(e.key); - const items = Array.from(menuItems); - - const reorderedItems = [ - ...items.slice(currentIndex), - ...items.slice(0, currentIndex) - ]; - - const matches = reorderedItems.filter((menuItem) => { - const { textContent } = menuItem.firstChild; - const firstChar = textContent[0].toLowerCase(); - return e.key === firstChar; - }); - - if (!matches.length) { - return; - } - const currentNode = items[currentIndex]; - const nextMatch = matches.includes(currentNode) ? matches[1] : matches[0]; - const index = items.findIndex((item) => item === nextMatch); - setCurrentIndex(index); - }; + // match focused item to typed character; if no match, focus is not moved useEffect(() => { if (currentIndex !== prevIndex) { diff --git a/client/components/Nav/NavDropdownMenu.jsx b/client/components/Nav/NavDropdownMenu.jsx index d25eaefbff..c120401f6a 100644 --- a/client/components/Nav/NavDropdownMenu.jsx +++ b/client/components/Nav/NavDropdownMenu.jsx @@ -51,7 +51,7 @@ function NavTrigger({ id, title, ...props }) { const submenuContext = useContext(SubmenuContext); const { isOpen, handlers } = useMenuProps(id); - const { isFirstChild, first, last } = submenuContext; + const { isFirstChild, first } = submenuContext; useKeyDownHandlers({ Space: (e) => { @@ -94,7 +94,7 @@ NavTrigger.propTypes = { function NavList({ children, id }) { const submenuContext = useContext(SubmenuContext); - const { submenuItems, currentIndex, dispatch, first, last } = submenuContext; + const { submenuItems, currentIndex, dispatch } = submenuContext; const prev = () => { const index = currentIndex === 0 ? submenuItems.size - 1 : currentIndex - 1; @@ -143,7 +143,7 @@ NavList.defaultProps = { }; function NavDropdownMenu({ id, title, children }) { - const { isOpen, handlers } = useMenuProps(id); + const { isOpen } = useMenuProps(id); const [isFirstChild, setIsFirstChild] = useState(false); const menuItemRef = useRef(); const { menuItems } = useContext(NavBarContext); diff --git a/client/modules/IDE/hooks/usePrevious.js b/client/modules/IDE/hooks/usePrevious.js index 42ca1895fe..4e67dbb23c 100644 --- a/client/modules/IDE/hooks/usePrevious.js +++ b/client/modules/IDE/hooks/usePrevious.js @@ -1,5 +1,5 @@ /* https://usehooks.com/usePrevious/ */ -import React, { useRef, useEffect } from 'react'; +import { useRef, useEffect } from 'react'; export default function usePrevious(value) { const ref = useRef();