Repository: tailwindlabs/headlessui
Pull request #3096 (2024-04-12 09:13:34)

Add new `CloseButton` component and `useClose` hook
This PR adds a new `CloseButton` component to Headless UI as well as a new `useClose` hook. 

These can be used inside a `Dialog`, `Disclosure` or `Popover` component and will close it when used.

Both the button and the hook are forgiving, meaning if you use them outside of a "closable" component they will do nothing.

### `CloseButton` component

Use the `CloseButton` component within a "closable" component to automatically close it when the button is clicked:

```jsx
import { CloseButton } from '@headlessui/react'

<MyDialog>
  /* ... */
  <CloseButton>
    Close
  </CloseButton>
<MyDialog>
```

This component also supports the `as` prop allowing you to wrap an existing component with it:

```jsx
import { CloseButton } from '@headlessui/react'
import { MyButton, MyDialog } from './components'

<MyDialog>
  /* ... */
  <CloseButton as={MyButton}>
    Close
  </CloseButton>
<MyDialog>
```

### `useClose` Hook

The `useClose` hook is useful in situations where you need more control over when the "closable" component is closed. For example, maybe you want to close a dialog when you submit a form within it:

```jsx
import { Dialog, useClose } from '@headlessui/react'

export function MySearchForm() {
  let close = useClose()

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault()
        /* perform search... */          
        close()
      }}
    >
      <input type="search" />
    </form>
    /* ... */
  )
}

export function MyDialog() {
  return (
    <Dialog>
      <MySearchForm />
      /* ... */
    </Dialog>
  )
}
```

vercel[bot]: [vc]: #T/neAq55TpD1eRjwPVtJn+CymVN9m9L2gnKfpYaZiHU=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJoZWFkbGVzc3VpLXJlYWN0Iiwicm9vdERpcmVjdG9yeSI6InBsYXlncm91bmRzL3JlYWN0IiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RhaWx3aW5kbGFicy9oZWFkbGVzc3VpLXJlYWN0L0hzYzV1b0dReTJLbmZqRkhZaXE2dUEzNUVyUjIiLCJwcmV2aWV3VXJsIjoiaGVhZGxlc3N1aS1yZWFjdC1naXQtZmVhdC1hZGQtY2xvc2UtYnV0dG9uLXRhaWx3aW5kbGFicy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IlBFTkRJTkciLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiaGVhZGxlc3N1aS1yZWFjdC1naXQtZmVhdC1hZGQtY2xvc2UtYnV0dG9uLXRhaWx3aW5kbGFicy52ZXJjZWwuYXBwIn19XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **headlessui-react** | 🔄 Building ([Inspect](https://vercel.com/tailwindlabs/headlessui-react/Hsc5uoGQy2KnfjFHYiq6uA35ErR2)) | [Visit Preview](https://vercel.live/open-feedback/headlessui-react-git-feat-add-close-button-tailwindlabs.vercel.app?via=pr-comment-visit-preview-link&passThrough=1) | 💬 [**Add feedback**](https://vercel.live/open-feedback/headlessui-react-git-feat-add-close-button-tailwindlabs.vercel.app?via=pr-comment-feedback-link) | Apr 12, 2024 9:13am |



Patch:
diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 3694842..4c926d0 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Expose `--input-width` and `--button-width` CSS variables on the `ComboboxOptions` component ([#3057](https://github.com/tailwindlabs/headlessui/pull/3057))
 - Expose the `--button-width` CSS variable on the `PopoverPanel` component ([#3058](https://github.com/tailwindlabs/headlessui/pull/3058))
 - Close the `Combobox`, `Dialog`, `Listbox`, `Menu` and `Popover` components when the trigger disappears ([#3075](https://github.com/tailwindlabs/headlessui/pull/3075))
+- Add new `CloseButton` component and `useClose` hook ([#3096](https://github.com/tailwindlabs/headlessui/pull/3096))
 
 ## [2.0.0-alpha.4] - 2024-01-03
 
diff --git a/packages/@headlessui-react/src/components/close-button/close-button.tsx b/packages/@headlessui-react/src/components/close-button/close-button.tsx
new file mode 100644
index 0000000..e873284
--- /dev/null
+++ b/packages/@headlessui-react/src/components/close-button/close-button.tsx
@@ -0,0 +1,21 @@
+'use client'
+
+import React, { type ElementType, type Ref } from 'react'
+import { useClose } from '../../internal/close-provider'
+import { forwardRefWithAs, mergeProps } from '../../utils/render'
+import { Button, type ButtonProps, type _internal_ComponentButton } from '../button/button'
+
+let DEFAULT_BUTTON_TAG = 'button' as const
+
+export type CloseButtonProps<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG> =
+  ButtonProps<TTag>
+
+function CloseButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
+  props: ButtonProps<TTag>,
+  ref: Ref<HTMLElement>
+) {
+  let close = useClose()
+  return <Button ref={ref} {...mergeProps({ onClick: close }, props)} />
+}
+
+export let CloseButton = forwardRefWithAs(CloseButtonFn) as _internal_ComponentButton
diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx
index 93a5118..dc6329f 100644
--- a/packages/@headlessui-react/src/components/dialog/dialog.tsx
+++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx
@@ -30,6 +30,7 @@ import { useOwnerDocument } from '../../hooks/use-owner'
 import { useRootContainers } from '../../hooks/use-root-containers'
 import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
 import { useSyncRefs } from '../../hooks/use-sync-refs'
+import { CloseProvider } from '../../internal/close-provider'
 import { HoistFormFields } from '../../internal/form-fields'
 import { State, useOpenClosed } from '../../internal/open-closed'
 import { ForcePortalRoot } from '../../internal/portal-force-root'
@@ -410,15 +411,17 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
                       containers={resolveRootContainers}
                       features={focusTrapFeatures}
                     >
-                      {render({
-                        ourProps,
-                        theirProps,
-                        slot,
-                        defaultTag: DEFAULT_DIALOG_TAG,
-                        features: DialogRenderFeatures,
-                        visible: dialogState === DialogStates.Open,
-                        name: 'Dialog',
-                      })}
+                      <CloseProvider value={close}>
+                        {render({
+                          ourProps,
+                          theirProps,
+                          slot,
+                          defaultTag: DEFAULT_DIALOG_TAG,
+                          features: DialogRenderFeatures,
+                          visible: dialogState === DialogStates.Open,
+                          name: 'Dialog',
+                        })}
+                      </CloseProvider>
                     </FocusTrap>
                   </PortalWrapper>
                 </DescriptionProvider>
diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
index 775a594..c49551c 100644
--- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
+++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
@@ -24,6 +24,7 @@ import { useEvent } from '../../hooks/use-event'
 import { useId } from '../../hooks/use-id'
 import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
 import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs'
+import { CloseProvider } from '../../internal/close-provider'
 import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
 import type { Props } from '../../types'
 import { isDisabledReactIssue7711 } from '../../utils/bugs'
@@ -234,20 +235,22 @@ function DisclosureFn<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_TAG>(
   return (
     <DisclosureContext.Provider value={reducerBag}>
       <DisclosureAPIContext.Provider value={api}>
-        <OpenClosedProvider
-          value={match(disclosureState, {
-            [DisclosureStates.Open]: State.Open,
-            [DisclosureStates.Closed]: State.Closed,
-          })}
-        >
-          {render({
-            ourProps,
-            theirProps,
-            slot,
-            defaultTag: DEFAULT_DISCLOSURE_TAG,
-            name: 'Disclosure',
-          })}
-        </OpenClosedProvider>
+        <CloseProvider value={close}>
+          <OpenClosedProvider
+            value={match(disclosureState, {
+              [DisclosureStates.Open]: State.Open,
+              [DisclosureStates.Closed]: State.Closed,
+            })}
+          >
+            {render({
+              ourProps,
+              theirProps,
+              slot,
+              defaultTag: DEFAULT_DISCLOSURE_TAG,
+              name: 'Disclosure',
+            })}
+          </OpenClosedProvider>
+        </CloseProvider>
       </DisclosureAPIContext.Provider>
     </DisclosureContext.Provider>
   )
diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx
index 8ac30ef..829f109 100644
--- a/packages/@headlessui-react/src/components/popover/popover.tsx
+++ b/packages/@headlessui-react/src/components/popover/popover.tsx
@@ -36,6 +36,7 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
 import { useMainTreeNode, useRootContainers } from '../../hooks/use-root-containers'
 import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs'
 import { Direction as TabDirection, useTabDirection } from '../../hooks/use-tab-direction'
+import { CloseProvider } from '../../internal/close-provider'
 import {
   FloatingProvider,
   useFloatingPanel,
@@ -410,23 +411,25 @@ function PopoverFn<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
       <PopoverPanelContext.Provider value={null}>
         <PopoverContext.Provider value={reducerBag}>
           <PopoverAPIContext.Provider value={api}>
-            <OpenClosedProvider
-              value={match(popoverState, {
-                [PopoverStates.Open]: State.Open,
-                [PopoverStates.Closed]: State.Closed,
-              })}
-            >
-              <PortalWrapper>
-                {render({
-                  ourProps,
-                  theirProps,
-                  slot,
-                  defaultTag: DEFAULT_POPOVER_TAG,
-                  name: 'Popover',
+            <CloseProvider value={close}>
+              <OpenClosedProvider
+                value={match(popoverState, {
+                  [PopoverStates.Open]: State.Open,
+                  [PopoverStates.Closed]: State.Closed,
                 })}
-                <root.MainTreeNode />
-              </PortalWrapper>
-            </OpenClosedProvider>
+              >
+                <PortalWrapper>
+                  {render({
+                    ourProps,
+                    theirProps,
+                    slot,
+                    defaultTag: DEFAULT_POPOVER_TAG,
+                    name: 'Popover',
+                  })}
+                  <root.MainTreeNode />
+                </PortalWrapper>
+              </OpenClosedProvider>
+            </CloseProvider>
           </PopoverAPIContext.Provider>
         </PopoverContext.Provider>
       </PopoverPanelContext.Provider>
diff --git a/packages/@headlessui-react/src/index.ts b/packages/@headlessui-react/src/index.ts
index 9b18ff0..10b00fb 100644
--- a/packages/@headlessui-react/src/index.ts
+++ b/packages/@headlessui-react/src/index.ts
@@ -1,5 +1,6 @@
 export * from './components/button/button'
 export * from './components/checkbox/checkbox'
+export * from './components/close-button/close-button'
 export * from './components/combobox/combobox'
 export * from './components/data-interactive/data-interactive'
 export { Description, type DescriptionProps } from './components/description/description'
@@ -20,6 +21,7 @@ export * from './components/select/select'
 export * from './components/switch/switch'
 export * from './components/tabs/tabs'
 export * from './components/textarea/textarea'
+export { useClose } from './internal/close-provider'
 // TODO: Enable when ready
 // export * from './components/tooltip/tooltip'
 export * from './components/transition/transition'
diff --git a/packages/@headlessui-react/src/internal/close-provider.tsx b/packages/@headlessui-react/src/internal/close-provider.tsx
new file mode 100644
index 0000000..3d12524
--- /dev/null
+++ b/packages/@headlessui-react/src/internal/close-provider.tsx
@@ -0,0 +1,11 @@
+import React, { createContext, useContext } from 'react'
+
+let CloseContext = createContext(() => {})
+
+export function useClose() {
+  return useContext(CloseContext)
+}
+
+export function CloseProvider({ value, children }: React.PropsWithChildren<{ value: () => void }>) {
+  return <CloseContext.Provider value={value}>{children}</CloseContext.Provider>
+}


Test patch:
diff --git a/packages/@headlessui-react/src/index.test.ts b/packages/@headlessui-react/src/index.test.ts
index 9d7c396..02082ce 100644
--- a/packages/@headlessui-react/src/index.test.ts
+++ b/packages/@headlessui-react/src/index.test.ts
@@ -10,6 +10,8 @@ it('should expose the correct components', () => {
 
     'Checkbox',
 
+    'CloseButton',
+
     'Combobox',
     'ComboboxButton',
     'ComboboxInput',
@@ -90,5 +92,7 @@ it('should expose the correct components', () => {
 
     'Transition',
     'TransitionChild',
+
+    'useClose',
   ])
 })