Skip to content

Commit 83a5f45

Browse files
authored
Expose close function for Menu and Menu.Item components (#1897)
* 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
1 parent af68a34 commit 83a5f45

File tree

6 files changed

+144
-6
lines changed

6 files changed

+144
-6
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

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

1415
## [1.7.3] - 2022-09-30
1516

packages/@headlessui-react/src/components/menu/menu.test.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,43 @@ describe('Rendering', () => {
116116
assertMenu({ state: MenuState.Visible })
117117
})
118118
)
119+
120+
it(
121+
'should be possible to manually close the Menu using the exposed close function',
122+
suppressConsoleLogs(async () => {
123+
render(
124+
<Menu>
125+
{({ close }) => (
126+
<>
127+
<Menu.Button>Trigger</Menu.Button>
128+
<Menu.Items>
129+
<Menu.Item>
130+
<button
131+
onClick={(e) => {
132+
e.preventDefault()
133+
close()
134+
}}
135+
>
136+
Close
137+
</button>
138+
</Menu.Item>
139+
</Menu.Items>
140+
</>
141+
)}
142+
</Menu>
143+
)
144+
145+
assertMenu({ state: MenuState.InvisibleUnmounted })
146+
147+
await click(getMenuButton())
148+
149+
assertMenu({ state: MenuState.Visible })
150+
151+
await click(getByText('Close'))
152+
153+
assertMenu({ state: MenuState.InvisibleUnmounted })
154+
})
155+
)
119156
})
120157

121158
describe('Menu.Button', () => {
@@ -349,6 +386,41 @@ describe('Rendering', () => {
349386
})
350387
})
351388
)
389+
390+
it(
391+
'should be possible to manually close the Menu using the exposed close function',
392+
suppressConsoleLogs(async () => {
393+
render(
394+
<Menu>
395+
<Menu.Button>Trigger</Menu.Button>
396+
<Menu.Items>
397+
<Menu.Item>
398+
{({ close }) => (
399+
<button
400+
onClick={(e) => {
401+
e.preventDefault()
402+
close()
403+
}}
404+
>
405+
Close
406+
</button>
407+
)}
408+
</Menu.Item>
409+
</Menu.Items>
410+
</Menu>
411+
)
412+
413+
assertMenu({ state: MenuState.InvisibleUnmounted })
414+
415+
await click(getMenuButton())
416+
417+
assertMenu({ state: MenuState.Visible })
418+
419+
await click(getByText('Close'))
420+
421+
assertMenu({ state: MenuState.InvisibleUnmounted })
422+
})
423+
)
352424
})
353425

354426
it('should guarantee the order of DOM nodes when performing actions', async () => {

packages/@headlessui-react/src/components/menu/menu.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ function stateReducer(state: StateDefinition, action: Actions) {
227227
let DEFAULT_MENU_TAG = Fragment
228228
interface MenuRenderPropArg {
229229
open: boolean
230+
close: () => void
230231
}
231232

232233
let MenuRoot = forwardRefWithAs(function Menu<TTag extends ElementType = typeof DEFAULT_MENU_TAG>(
@@ -259,9 +260,13 @@ let MenuRoot = forwardRefWithAs(function Menu<TTag extends ElementType = typeof
259260
menuState === MenuStates.Open
260261
)
261262

263+
let close = useEvent(() => {
264+
dispatch({ type: ActionTypes.CloseMenu })
265+
})
266+
262267
let slot = useMemo<MenuRenderPropArg>(
263-
() => ({ open: menuState === MenuStates.Open }),
264-
[menuState]
268+
() => ({ open: menuState === MenuStates.Open, close }),
269+
[menuState, close]
265270
)
266271

267272
let theirProps = props
@@ -563,6 +568,7 @@ let DEFAULT_ITEM_TAG = Fragment
563568
interface ItemRenderPropArg {
564569
active: boolean
565570
disabled: boolean
571+
close: () => void
566572
}
567573
type MenuItemPropsWeControl =
568574
| 'id'
@@ -613,6 +619,10 @@ let Item = forwardRefWithAs(function Item<TTag extends ElementType = typeof DEFA
613619
return () => dispatch({ type: ActionTypes.UnregisterItem, id })
614620
}, [bag, id])
615621

622+
let close = useEvent(() => {
623+
dispatch({ type: ActionTypes.CloseMenu })
624+
})
625+
616626
let handleClick = useEvent((event: MouseEvent) => {
617627
if (disabled) return event.preventDefault()
618628
dispatch({ type: ActionTypes.CloseMenu })
@@ -641,7 +651,10 @@ let Item = forwardRefWithAs(function Item<TTag extends ElementType = typeof DEFA
641651
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
642652
})
643653

644-
let slot = useMemo<ItemRenderPropArg>(() => ({ active, disabled }), [active, disabled])
654+
let slot = useMemo<ItemRenderPropArg>(
655+
() => ({ active, disabled, close }),
656+
[active, disabled, close]
657+
)
645658
let ourProps = {
646659
id,
647660
ref: itemRef,

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
- Nothing yet!
10+
### Fixed
11+
12+
- Expose `close` function for `Menu` and `MenuItem` components ([#1897](https://github.com/tailwindlabs/headlessui/pull/1897))
1113

1214
## [1.7.3] - 2022-09-30
1315

packages/@headlessui-vue/src/components/menu/menu.test.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,31 @@ describe('Rendering', () => {
193193
})
194194
})
195195
)
196+
197+
it('should be possible to manually close the Menu using the exposed close function', async () => {
198+
renderTemplate({
199+
template: jsx`
200+
<Menu v-slot="{ close }">
201+
<MenuButton>Trigger</MenuButton>
202+
<MenuItems>
203+
<MenuItem>
204+
<button @click.prevent="close">Close</button>
205+
</MenuItem>
206+
</MenuItems>
207+
</Menu>
208+
`,
209+
})
210+
211+
assertMenu({ state: MenuState.InvisibleUnmounted })
212+
213+
await click(getMenuButton())
214+
215+
assertMenu({ state: MenuState.Visible })
216+
217+
await click(getByText('Close'))
218+
219+
assertMenu({ state: MenuState.InvisibleUnmounted })
220+
})
196221
})
197222

198223
describe('MenuButton', () => {
@@ -712,6 +737,31 @@ describe('Rendering', () => {
712737

713738
await click(getMenuButton())
714739
})
740+
741+
it('should be possible to manually close the Menu using the exposed close function', async () => {
742+
renderTemplate({
743+
template: jsx`
744+
<Menu>
745+
<MenuButton>Trigger</MenuButton>
746+
<MenuItems>
747+
<MenuItem v-slot="{ close }">
748+
<button @click.prevent="close">Close</button>
749+
</MenuItem>
750+
</MenuItems>
751+
</Menu>
752+
`,
753+
})
754+
755+
assertMenu({ state: MenuState.InvisibleUnmounted })
756+
757+
await click(getMenuButton())
758+
759+
assertMenu({ state: MenuState.Visible })
760+
761+
await click(getByText('Close'))
762+
763+
assertMenu({ state: MenuState.InvisibleUnmounted })
764+
})
715765
})
716766

717767
it('should guarantee the order of DOM nodes when performing actions', async () => {

packages/@headlessui-vue/src/components/menu/menu.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ export let Menu = defineComponent({
234234
)
235235

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

555555
return () => {
556556
let { disabled } = props
557-
let slot = { active: active.value, disabled }
557+
let slot = { active: active.value, disabled, close: api.closeMenu }
558558
let ourProps = {
559559
id,
560560
ref: internalItemRef,

0 commit comments

Comments
 (0)