-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
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
[Menubar] Base implementation of refactored NavBar #3279
base: develop
Are you sure you want to change the base?
Changes from 10 commits
b16dada
0f9cf65
ab818ad
246597f
9f3096c
7c3b896
b2eb033
06e9152
905ac20
30f2c1b
b8a45d5
dbc98dd
72daf4d
e5e2ec1
f122ec6
5c8a912
2177976
0e9a967
fd3e3a6
48f9ad8
f84ebd9
a361329
b63c031
c8118f6
16644f3
9444470
060b13a
096edb8
89a81d8
41b10f9
50077b6
043eb44
71fd59e
d42872f
4d2ae14
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<MenubarContext.Provider value={contextValue}> | ||
<div className={className} ref={nodeRef}> | ||
<MenuOpenContext.Provider value={menuOpen}> | ||
{children} | ||
</MenuOpenContext.Provider> | ||
</div> | ||
</MenubarContext.Provider> | ||
); | ||
} | ||
|
||
Menubar.propTypes = { | ||
children: PropTypes.node, | ||
className: PropTypes.string | ||
}; | ||
|
||
Menubar.defaultProps = { | ||
children: null, | ||
className: 'nav' | ||
}; | ||
|
||
export default Menubar; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
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, ...props }) { | ||
const { isOpen, handlers } = useMenuProps(id); | ||
|
||
return ( | ||
<button | ||
{...handlers} | ||
{...props} | ||
role="menuitem" | ||
aria-haspopup="menu" | ||
aria-expanded={isOpen} | ||
> | ||
<span className="nav__item-header">{title}</span> | ||
<TriangleIcon | ||
className="nav__item-header-triangle" | ||
focusable="false" | ||
aria-hidden="true" | ||
/> | ||
</button> | ||
); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. based on what i've read about we could consider using aria-live in conjunction with the language menu, since selecting an option there updates the entire page. I can do some tests with a screen reader and see how that goes! |
||
} | ||
|
||
MenubarTrigger.propTypes = { | ||
id: PropTypes.string.isRequired, | ||
title: PropTypes.node.isRequired | ||
}; | ||
|
||
/* ------------------------------------------------------------------------------------------------- | ||
* MenubarList | ||
* -----------------------------------------------------------------------------------------------*/ | ||
|
||
function MenubarList({ id, children }) { | ||
return ( | ||
<ul className="nav__dropdown" role="menu"> | ||
<ParentMenuContext.Provider value={id}> | ||
{children} | ||
</ParentMenuContext.Provider> | ||
</ul> | ||
); | ||
} | ||
|
||
MenubarList.propTypes = { | ||
id: PropTypes.string.isRequired, | ||
children: PropTypes.node | ||
}; | ||
|
||
MenubarList.defaultProps = { | ||
children: null | ||
}; | ||
|
||
/* ------------------------------------------------------------------------------------------------- | ||
* MenubarMenu | ||
* -----------------------------------------------------------------------------------------------*/ | ||
|
||
function MenubarMenu({ id, title, children }) { | ||
const { isOpen } = useMenuProps(id); | ||
|
||
return ( | ||
<li className={classNames('nav__item', isOpen && 'nav__item--open')}> | ||
<MenubarTrigger id={id} title={title} /> | ||
<MenubarList id={id}>{children}</MenubarList> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it maybe make sense to conditionally render the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
great point. the wai aria menubar example doesn't conditionally render its list component, but I've seen component libraries handle this by rendering the component within a portal. definitely something that can be built in! |
||
</li> | ||
); | ||
} | ||
|
||
MenubarMenu.propTypes = { | ||
id: PropTypes.string.isRequired, | ||
title: PropTypes.node.isRequired, | ||
children: PropTypes.node | ||
}; | ||
|
||
MenubarMenu.defaultProps = { | ||
children: null | ||
}; | ||
|
||
export default MenubarMenu; |
This file was deleted.
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I really, really love the changes here and the way this file is organized!
I know it's already within the tagged issue, but do you think it might be helpful to reference the article you've used as a comment here? Just came to mind since we have a few areas throughout the app that make these types of annotations!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also found this article that touches on implementing focus traps for dropdown menus. Was wondering if you feel that might be applicable here, or if it might be unnecessary?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
absolutely -- will add it as a comment so that it's clearer as a reference.
thanks for this! i feel that focus trapping is something i associate more with modals and dialogs, where users can't tab out of the element unless a certain action is performed. i think we also want to be able to tab to the next element from the menubar without completely restricting focus in the way that we would with modals / dialogs / overlays. i'll see if i can find a menubar implementation in the wild that uses focus trapping, but here it might not be unnecessary.
that being said, we will definitely need a level of focus management to handle tabbing into the component as well as focusing various submenus / menuitems -- in the examples i've found, this has been done with a roving tabindex.