Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add keyboard behaviors to menubar #3220

Draft
wants to merge 21 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b9535b3
feat: adding usePrevious hook
tespin Jul 26, 2024
919b94b
feat: adding roving focus implementation
tespin Jul 26, 2024
3706d60
fix: tab focus is tracked in menubar instead of menuitem
tespin Aug 7, 2024
9d15664
fix: removed menuitem role from login and signup, moved language drop…
tespin Aug 8, 2024
be4ba67
feat: change focused item with based on directional keys
tespin Aug 8, 2024
0c98e43
chore: cleanup
tespin Aug 8, 2024
04e26e0
chore: cleanup again
tespin Aug 8, 2024
c612b31
fix: updated snapshots
tespin Aug 15, 2024
5b00ec3
Merge branch 'develop' into tespin/add-menubar-keyboard-interactions
tespin Aug 15, 2024
5545111
Merge branch 'develop' into tespin/add-menubar-keyboard-interactions
raclim Aug 22, 2024
0a5e398
Merge branch 'processing:develop' into tespin/add-menubar-keyboard-in…
tespin Aug 23, 2024
097dab4
chore: made separate trigger component for menubar
tespin Aug 21, 2024
8c631fb
adding submenu context
tespin Aug 22, 2024
572d9ef
chore: edit to trigger component, added list component for dropdown menu
tespin Aug 22, 2024
c8770f5
fix: moved user menu out of navbar
tespin Aug 22, 2024
04d5e12
feat: crefactored, added functionality to implement roving tab index
tespin Aug 23, 2024
530147f
chore: refacted to include ul element and methods for navigating menu…
tespin Aug 23, 2024
c5be1e7
feat: refactored to separate components, implemented navigating list …
tespin Aug 23, 2024
697b19c
fix: optional chaining for mobile view
tespin Aug 23, 2024
0eac300
fix: snapshots updated
tespin Aug 23, 2024
654889e
fix: fixed lint errors
tespin Aug 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 77 additions & 5 deletions client/components/Nav/NavBar.jsx
Original file line number Diff line number Diff line change
@@ -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]);
Expand All @@ -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) => ({
Expand Down Expand Up @@ -61,17 +124,26 @@ function NavBar({ children, className }) {
setDropdownOpen(dropdown);
}
}),
toggleDropdownOpen
toggleDropdownOpen,
menuItems
}),
[setDropdownOpen, toggleDropdownOpen, clearHideTimeout, handleBlur]
[
setDropdownOpen,
toggleDropdownOpen,
clearHideTimeout,
handleBlur,
menuItems
]
);

return (
<NavBarContext.Provider value={contextValue}>
<header>
<div className={className} ref={nodeRef}>
<MenuOpenContext.Provider value={dropdownOpen}>
{children}
<ul className="nav__items-left" role="menubar">
{children}
</ul>
</MenuOpenContext.Provider>
</div>
</header>
Expand Down
206 changes: 184 additions & 22 deletions client/components/Nav/NavDropdownMenu.jsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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 (
<button {...triggerProps}>
<span className="nav__item-header">{title}</span>
<TriangleIcon
className="nav__item-header-triangle"
focusable="false"
aria-hidden="true"
/>
</button>
);
}

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 (
<ul className="nav__dropdown" {...listProps}>
<ParentMenuContext.Provider value={id}>
{children}
</ParentMenuContext.Provider>
</ul>
);
}

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 (
<li className={classNames('nav__item', isOpen && 'nav__item--open')}>
<button
{...handlers}
role="menuitem"
aria-haspopup="menu"
aria-expanded={isOpen}
<SubmenuContext.Provider value={value}>
<li
className={classNames('nav__item', isOpen && 'nav__item--open')}
ref={menuItemRef}
>
<span className="nav__item-header">{title}</span>
<TriangleIcon
className="nav__item-header-triangle"
focusable="false"
aria-hidden="true"
/>
</button>
<ul className="nav__dropdown" role="menu">
<ParentMenuContext.Provider value={id}>
{children}
</ParentMenuContext.Provider>
</ul>
</li>
<NavTrigger id={id} title={title} />
<NavList id={id}>{children}</NavList>
</li>
</SubmenuContext.Provider>
);
}

Expand Down
36 changes: 31 additions & 5 deletions client/components/Nav/NavMenuItem.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<li className={className}>
<ButtonOrLink {...rest} {...handlers} role="menuitem" />
<li className={className} ref={menuItemRef}>
<ButtonOrLink {...buttonProps} />
</li>
);
}
Expand Down
Loading
Loading