fix: outside click behaviour (#19)
* add failing tests to prove the outside-click issue * fix outside click when we have multiple menu's - We removed the `toggleMenu` since we only used it in a single spot, and had to do some side effect logic (focus & event.preventDefault). Wanted to make this consistent between React and Vue. - If, in the "outside click" logic we detect that we clicked on the button, we also ignore it. - If, we click on the button we will toggle the menu. Fixes: #18
This commit is contained in:
@@ -35,12 +35,24 @@ function getMenuButton(): HTMLElement | null {
|
||||
return document.querySelector('[role="button"],button')
|
||||
}
|
||||
|
||||
function getMenuButtons(): HTMLElement[] {
|
||||
// This is just an assumption for our tests. We assume that we only have 1 button. And if we have
|
||||
// more, than we assume that it is the first one.
|
||||
return Array.from(document.querySelectorAll('[role="button"],button'))
|
||||
}
|
||||
|
||||
function getMenu(): HTMLElement | null {
|
||||
// This is just an assumption for our tests. We assume that our menu has this role and that it is
|
||||
// the first item in the DOM.
|
||||
return document.querySelector('[role="menu"]')
|
||||
}
|
||||
|
||||
function getMenus(): HTMLElement[] {
|
||||
// This is just an assumption for our tests. We assume that our menu has this role and that it is
|
||||
// the first item in the DOM.
|
||||
return Array.from(document.querySelectorAll('[role="menu"]'))
|
||||
}
|
||||
|
||||
function getMenuItems(): HTMLElement[] {
|
||||
// This is just an assumption for our tests. We assume that all menu items have this role.
|
||||
return Array.from(document.querySelectorAll('[role="menuitem"]'))
|
||||
@@ -2248,6 +2260,50 @@ describe('Mouse interactions', () => {
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'should be possible to click outside of the menu on another menu button which should close the current menu and open the new menu',
|
||||
suppressConsoleLogs(async () => {
|
||||
render(
|
||||
<div>
|
||||
<Menu>
|
||||
<Menu.Button>Trigger</Menu.Button>
|
||||
<Menu.Items>
|
||||
<Menu.Item as="a">alice</Menu.Item>
|
||||
<Menu.Item as="a">bob</Menu.Item>
|
||||
<Menu.Item as="a">charlie</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
|
||||
<Menu>
|
||||
<Menu.Button>Trigger</Menu.Button>
|
||||
<Menu.Items>
|
||||
<Menu.Item as="a">alice</Menu.Item>
|
||||
<Menu.Item as="a">bob</Menu.Item>
|
||||
<Menu.Item as="a">charlie</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
|
||||
const [button1, button2] = getMenuButtons()
|
||||
|
||||
// Click the first menu button
|
||||
await click(button1)
|
||||
expect(getMenus()).toHaveLength(1) // Only 1 menu should be visible
|
||||
|
||||
// Ensure the open menu is linked to the first button
|
||||
assertMenuButtonLinkedWithMenu(button1, getMenu())
|
||||
|
||||
// Click the second menu button
|
||||
await click(button2)
|
||||
|
||||
expect(getMenus()).toHaveLength(1) // Only 1 menu should be visible
|
||||
|
||||
// Ensure the open menu is linked to the second button
|
||||
assertMenuButtonLinkedWithMenu(button2, getMenu())
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'should be possible to hover an item and make it active',
|
||||
suppressConsoleLogs(async () => {
|
||||
|
||||
@@ -47,7 +47,6 @@ type StateDefinition = {
|
||||
}
|
||||
|
||||
enum ActionTypes {
|
||||
ToggleMenu,
|
||||
OpenMenu,
|
||||
CloseMenu,
|
||||
|
||||
@@ -114,7 +113,6 @@ function calculateActiveItemIndex(
|
||||
}
|
||||
|
||||
type Actions =
|
||||
| { type: ActionTypes.ToggleMenu }
|
||||
| { type: ActionTypes.CloseMenu }
|
||||
| { type: ActionTypes.OpenMenu }
|
||||
| { type: ActionTypes.GoToItem; focus: Focus; id?: string }
|
||||
@@ -129,13 +127,6 @@ const reducers: {
|
||||
action: Extract<Actions, { type: P }>
|
||||
) => StateDefinition
|
||||
} = {
|
||||
[ActionTypes.ToggleMenu]: state => ({
|
||||
...state,
|
||||
menuState: match(state.menuState, {
|
||||
[MenuStates.Open]: MenuStates.Closed,
|
||||
[MenuStates.Closed]: MenuStates.Open,
|
||||
}),
|
||||
}),
|
||||
[ActionTypes.CloseMenu]: state => ({ ...state, menuState: MenuStates.Closed }),
|
||||
[ActionTypes.OpenMenu]: state => ({ ...state, menuState: MenuStates.Open }),
|
||||
[ActionTypes.GoToItem]: (state, action) => {
|
||||
@@ -237,17 +228,17 @@ export function Menu<TTag extends React.ElementType = typeof DEFAULT_MENU_TAG>(
|
||||
|
||||
React.useEffect(() => {
|
||||
function handler(event: PointerEvent) {
|
||||
if (event.defaultPrevented) return
|
||||
if (menuState !== MenuStates.Open) return
|
||||
if (buttonRef.current?.contains(event.target as HTMLElement)) return
|
||||
|
||||
if (!itemsRef.current?.contains(event.target as HTMLElement)) {
|
||||
dispatch({ type: ActionTypes.CloseMenu })
|
||||
d.nextFrame(() => buttonRef.current?.focus())
|
||||
if (!event.defaultPrevented) buttonRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pointerdown', handler)
|
||||
return () => window.removeEventListener('pointerdown', handler)
|
||||
window.addEventListener('pointerup', handler)
|
||||
return () => window.removeEventListener('pointerup', handler)
|
||||
}, [menuState, itemsRef, buttonRef, d, dispatch])
|
||||
|
||||
const propsBag = React.useMemo(() => ({ open: menuState === MenuStates.Open }), [menuState])
|
||||
@@ -272,7 +263,6 @@ type ButtonPropsWeControl =
|
||||
| 'onFocus'
|
||||
| 'onBlur'
|
||||
| 'onPointerUp'
|
||||
| 'onPointerDown'
|
||||
|
||||
const DEFAULT_BUTTON_TAG = 'button'
|
||||
|
||||
@@ -320,16 +310,18 @@ const Button = forwardRefWithAs(function Button<
|
||||
[dispatch, state, d]
|
||||
)
|
||||
|
||||
const handlePointerDown = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
// We have a `pointerdown` event listener in the menu for the 'outside click', so we just want
|
||||
// to prevent going there if we happen to click this button.
|
||||
event.preventDefault()
|
||||
}, [])
|
||||
|
||||
const handlePointerUp = React.useCallback(() => {
|
||||
dispatch({ type: ActionTypes.ToggleMenu })
|
||||
d.nextFrame(() => state.itemsRef.current?.focus())
|
||||
}, [dispatch, d, state])
|
||||
const handlePointerUp = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (state.menuState === MenuStates.Open) {
|
||||
dispatch({ type: ActionTypes.CloseMenu })
|
||||
} else {
|
||||
event.preventDefault()
|
||||
dispatch({ type: ActionTypes.OpenMenu })
|
||||
d.nextFrame(() => state.itemsRef.current?.focus())
|
||||
}
|
||||
},
|
||||
[dispatch, d, state]
|
||||
)
|
||||
|
||||
const handleFocus = React.useCallback(() => {
|
||||
if (state.menuState === MenuStates.Open) state.itemsRef.current?.focus()
|
||||
@@ -354,7 +346,6 @@ const Button = forwardRefWithAs(function Button<
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
onPointerUp: handlePointerUp,
|
||||
onPointerDown: handlePointerDown,
|
||||
}
|
||||
|
||||
return render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_BUTTON_TAG)
|
||||
|
||||
Reference in New Issue
Block a user