Skip to content

Commit

Permalink
Expose close function for Menu and Menu.Item components (#1897)
Browse files Browse the repository at this point in the history
* expose `close` function for `Menu` and `Menu.Item` components

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.

* update changelog
  • Loading branch information
RobinMalfait authored Oct 4, 2022
1 parent af68a34 commit 83a5f45
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 6 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Fix `<Popover.Button as={Fragment} />` crash ([#1889](https://github.com/tailwindlabs/headlessui/pull/1889))
- Expose `close` function for `Menu` and `Menu.Item` components ([#1897](https://github.com/tailwindlabs/headlessui/pull/1897))

This comment has been minimized.

Copy link
@methyl

methyl Oct 27, 2022

Do you think we could also expose open (under some different name as it's taken already)? It will make it possible to have a controlled Menu which doesn't rely on having left-click trigger.

Having it, we can create a workaround for issues like #649


## [1.7.3] - 2022-09-30

Expand Down
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>
<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>
{({ 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
4 changes: 3 additions & 1 deletion packages/@headlessui-vue/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Nothing yet!
### Fixed

- Expose `close` function for `Menu` and `MenuItem` components ([#1897](https://github.com/tailwindlabs/headlessui/pull/1897))

## [1.7.3] - 2022-09-30

Expand Down
50 changes: 50 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,31 @@ 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>
<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 +737,31 @@ 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 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 83a5f45

Please sign in to comment.