Skip to content

Commit

Permalink
expose close function for Menu and Menu.Item components
Browse files Browse the repository at this point in the history
The `Menu` will already automatically close if you invoke the
`Menu.Item` (which is typically an `a` or a `button`). However you have
control over this, so if you add an explicit `onClick={e =>
e.preventDefault()}` then we respect that and don't execute the default
behavior, ergo closing the menu.

The problem occurs when you are using another component like the Inertia
`Link` component, that does have this `e.preventDefault()` built-in to
guarantee SPA-like page transitions without refreshing the browser.
Because of this, the menu will never close (unless you go to a totally
different page where the menu is not present of course).

This is where the explicit `close` function comes in, now you can use
that function to "force" close a menu, if your 3rd party tool already
bypassed the default behaviour.

This API is also how we do it in the `Popover` component for scenario's
where you can't rely on the default behaviour.
  • Loading branch information
RobinMalfait committed Oct 4, 2022
1 parent af68a34 commit 69b56ef
Show file tree
Hide file tree
Showing 4 changed files with 142 additions and 5 deletions.
72 changes: 72 additions & 0 deletions packages/@headlessui-react/src/components/menu/menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,43 @@ describe('Rendering', () => {
assertMenu({ state: MenuState.Visible })
})
)

it(
'should be possible to manually close the Menu using the exposed close function',
suppressConsoleLogs(async () => {
render(
<Menu>
{({ close }) => (
<>
<Menu.Button>Trigger</Menu.Button>
<Menu.Items>
<Menu.Item as="span">
<button
onClick={(e) => {
e.preventDefault()
close()
}}
>
Close
</button>
</Menu.Item>
</Menu.Items>
</>
)}
</Menu>
)

assertMenu({ state: MenuState.InvisibleUnmounted })

await click(getMenuButton())

assertMenu({ state: MenuState.Visible })

await click(getByText('Close'))

assertMenu({ state: MenuState.InvisibleUnmounted })
})
)
})

describe('Menu.Button', () => {
Expand Down Expand Up @@ -349,6 +386,41 @@ describe('Rendering', () => {
})
})
)

it(
'should be possible to manually close the Menu using the exposed close function',
suppressConsoleLogs(async () => {
render(
<Menu>
<Menu.Button>Trigger</Menu.Button>
<Menu.Items>
<Menu.Item as="span">
{({ close }) => (
<button
onClick={(e) => {
e.preventDefault()
close()
}}
>
Close
</button>
)}
</Menu.Item>
</Menu.Items>
</Menu>
)

assertMenu({ state: MenuState.InvisibleUnmounted })

await click(getMenuButton())

assertMenu({ state: MenuState.Visible })

await click(getByText('Close'))

assertMenu({ state: MenuState.InvisibleUnmounted })
})
)
})

it('should guarantee the order of DOM nodes when performing actions', async () => {
Expand Down
19 changes: 16 additions & 3 deletions packages/@headlessui-react/src/components/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ function stateReducer(state: StateDefinition, action: Actions) {
let DEFAULT_MENU_TAG = Fragment
interface MenuRenderPropArg {
open: boolean
close: () => void
}

let MenuRoot = forwardRefWithAs(function Menu<TTag extends ElementType = typeof DEFAULT_MENU_TAG>(
Expand Down Expand Up @@ -259,9 +260,13 @@ let MenuRoot = forwardRefWithAs(function Menu<TTag extends ElementType = typeof
menuState === MenuStates.Open
)

let close = useEvent(() => {
dispatch({ type: ActionTypes.CloseMenu })
})

let slot = useMemo<MenuRenderPropArg>(
() => ({ open: menuState === MenuStates.Open }),
[menuState]
() => ({ open: menuState === MenuStates.Open, close }),
[menuState, close]
)

let theirProps = props
Expand Down Expand Up @@ -563,6 +568,7 @@ let DEFAULT_ITEM_TAG = Fragment
interface ItemRenderPropArg {
active: boolean
disabled: boolean
close: () => void
}
type MenuItemPropsWeControl =
| 'id'
Expand Down Expand Up @@ -613,6 +619,10 @@ let Item = forwardRefWithAs(function Item<TTag extends ElementType = typeof DEFA
return () => dispatch({ type: ActionTypes.UnregisterItem, id })
}, [bag, id])

let close = useEvent(() => {
dispatch({ type: ActionTypes.CloseMenu })
})

let handleClick = useEvent((event: MouseEvent) => {
if (disabled) return event.preventDefault()
dispatch({ type: ActionTypes.CloseMenu })
Expand Down Expand Up @@ -641,7 +651,10 @@ let Item = forwardRefWithAs(function Item<TTag extends ElementType = typeof DEFA
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
})

let slot = useMemo<ItemRenderPropArg>(() => ({ active, disabled }), [active, disabled])
let slot = useMemo<ItemRenderPropArg>(
() => ({ active, disabled, close }),
[active, disabled, close]
)
let ourProps = {
id,
ref: itemRef,
Expand Down
52 changes: 52 additions & 0 deletions packages/@headlessui-vue/src/components/menu/menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,32 @@ describe('Rendering', () => {
})
})
)

it('should be possible to manually close the Menu using the exposed close function', async () => {
renderTemplate({
template: jsx`
<Menu v-slot="{ close }">
<MenuButton>Trigger</MenuButton>
<MenuItems>
<MenuItem as="span">
<button @click.prevent="close">Close</button>
</MenuItem>
</MenuItems>
</Menu>
`,
})

assertMenu({ state: MenuState.InvisibleUnmounted })

await click(getMenuButton())

assertMenu({ state: MenuState.Visible })

await click(getByText('Close'))

assertMenu({ state: MenuState.InvisibleUnmounted })
})
})

describe('MenuButton', () => {
Expand Down Expand Up @@ -712,6 +738,32 @@ describe('Rendering', () => {

await click(getMenuButton())
})

it('should be possible to manually close the Menu using the exposed close function', async () => {
renderTemplate({
template: jsx`
<Menu>
<MenuButton>Trigger</MenuButton>
<MenuItems>
<MenuItem as="span" v-slot="{ close }">
<button @click.prevent="close">Close</button>
</MenuItem>
</MenuItems>
</Menu>
`,
})

assertMenu({ state: MenuState.InvisibleUnmounted })

await click(getMenuButton())

assertMenu({ state: MenuState.Visible })

await click(getByText('Close'))

assertMenu({ state: MenuState.InvisibleUnmounted })
})
})

it('should guarantee the order of DOM nodes when performing actions', async () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/@headlessui-vue/src/components/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export let Menu = defineComponent({
)

return () => {
let slot = { open: menuState.value === MenuStates.Open }
let slot = { open: menuState.value === MenuStates.Open, close: api.closeMenu }
return render({ ourProps: {}, theirProps: props, slot, slots, attrs, name: 'Menu' })
}
},
Expand Down Expand Up @@ -554,7 +554,7 @@ export let MenuItem = defineComponent({

return () => {
let { disabled } = props
let slot = { active: active.value, disabled }
let slot = { active: active.value, disabled, close: api.closeMenu }
let ourProps = {
id,
ref: internalItemRef,
Expand Down

0 comments on commit 69b56ef

Please sign in to comment.