From c70b747ebf1c5c324d710a14d443d3ce4f41dfff Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 3 Oct 2022 16:07:44 +0200 Subject: [PATCH] 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. --- .../src/components/menu/menu.test.tsx | 74 +++++++++++++++++++ .../src/components/menu/menu.tsx | 19 ++++- .../src/components/menu/menu.test.tsx | 52 +++++++++++++ .../src/components/menu/menu.ts | 4 +- 4 files changed, 144 insertions(+), 5 deletions(-) diff --git a/packages/@headlessui-react/src/components/menu/menu.test.tsx b/packages/@headlessui-react/src/components/menu/menu.test.tsx index 818d3ac993..607325f6a4 100644 --- a/packages/@headlessui-react/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.test.tsx @@ -116,6 +116,45 @@ describe('Rendering', () => { assertMenu({ state: MenuState.Visible }) }) ) + + it( + 'should be possible to manually close the Menu using the exposed close function', + suppressConsoleLogs(async () => { + render( + + {({ close }) => { + return ( + <> + Trigger + + + + + + + ) + }} + + ) + + assertMenu({ state: MenuState.InvisibleUnmounted }) + + await click(getMenuButton()) + + assertMenu({ state: MenuState.Visible }) + + await click(getByText('Close')) + + assertMenu({ state: MenuState.InvisibleUnmounted }) + }) + ) }) describe('Menu.Button', () => { @@ -349,6 +388,41 @@ describe('Rendering', () => { }) }) ) + + it( + 'should be possible to manually close the Menu using the exposed close function', + suppressConsoleLogs(async () => { + render( + + Trigger + + + {({ close }) => ( + + )} + + + + ) + + 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 () => { diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index ad6474f185..014a8b1f22 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -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( @@ -259,9 +260,13 @@ let MenuRoot = forwardRefWithAs(function Menu { + dispatch({ type: ActionTypes.CloseMenu }) + }) + let slot = useMemo( - () => ({ open: menuState === MenuStates.Open }), - [menuState] + () => ({ open: menuState === MenuStates.Open, close }), + [menuState, close] ) let theirProps = props @@ -563,6 +568,7 @@ let DEFAULT_ITEM_TAG = Fragment interface ItemRenderPropArg { active: boolean disabled: boolean + close: () => void } type MenuItemPropsWeControl = | 'id' @@ -613,6 +619,10 @@ let Item = forwardRefWithAs(function Item 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 }) @@ -641,7 +651,10 @@ let Item = forwardRefWithAs(function Item(() => ({ active, disabled }), [active, disabled]) + let slot = useMemo( + () => ({ active, disabled, close }), + [active, disabled, close] + ) let ourProps = { id, ref: itemRef, diff --git a/packages/@headlessui-vue/src/components/menu/menu.test.tsx b/packages/@headlessui-vue/src/components/menu/menu.test.tsx index ab408c2bc1..06475e87ad 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-vue/src/components/menu/menu.test.tsx @@ -193,6 +193,32 @@ describe('Rendering', () => { }) }) ) + + it('should be possible to manually close the Menu using the exposed close function', async () => { + renderTemplate({ + template: jsx` + + Trigger + + + … + + + + + `, + }) + + assertMenu({ state: MenuState.InvisibleUnmounted }) + + await click(getMenuButton()) + + assertMenu({ state: MenuState.Visible }) + + await click(getByText('Close')) + + assertMenu({ state: MenuState.InvisibleUnmounted }) + }) }) describe('MenuButton', () => { @@ -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` + + Trigger + + + … + + + + + `, + }) + + 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 () => { diff --git a/packages/@headlessui-vue/src/components/menu/menu.ts b/packages/@headlessui-vue/src/components/menu/menu.ts index 4b9e8d18a8..8790f4c2b1 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.ts +++ b/packages/@headlessui-vue/src/components/menu/menu.ts @@ -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' }) } }, @@ -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,