Repository: tailwindlabs/headlessui
Pull request #3239 (2024-05-24 16:58:38)

Ensure tabbing to a portalled `<PopoverPanel>` component moves focus inside (without using `<PortalGroup>`)
This PR fixes a bug where pressing <kbd>tab</kbd> on a `<PopoverButton>` does not move focus inside of a portalled `<PopoverPanel>` component when it is opened and when you are not using a wrapping `<PopoverGroup>` component.

If you didn't use a `<Portal />` or `portal` or `anchor` prop then this would not be an issue.
If you used a `<PopoverGroup />` then this would not be an issue.

Fixes: #3231



Patch:
diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 5f823bc..1f6c347 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - [internal] Don’t set a focus fallback for Dialog’s in demo mode ([#3194](https://github.com/tailwindlabs/headlessui/pull/3194))
 - Ensure page doesn't scroll down when pressing `Escape` to close the `Dialog` component ([#3218](https://github.com/tailwindlabs/headlessui/pull/3218))
 - Fix crash when toggling between `virtual` and non-virtual mode in `Combobox` component ([#3236](https://github.com/tailwindlabs/headlessui/pull/3236))
+- Ensure tabbing to a portalled `<PopoverPanel>` component moves focus inside (without using `<PortalGroup>`) ([#3239](https://github.com/tailwindlabs/headlessui/pull/3239))
 
 ### Deprecated
 
diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx
index 3940555..823b044 100644
--- a/packages/@headlessui-react/src/components/popover/popover.tsx
+++ b/packages/@headlessui-react/src/components/popover/popover.tsx
@@ -92,6 +92,7 @@ interface StateDefinition {
 
   beforePanelSentinel: MutableRefObject<HTMLButtonElement | null>
   afterPanelSentinel: MutableRefObject<HTMLButtonElement | null>
+  afterButtonSentinel: MutableRefObject<HTMLButtonElement | null>
 
   __demoMode: boolean
 }
@@ -256,9 +257,19 @@ function PopoverFn<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
     panelId: null,
     beforePanelSentinel: createRef(),
     afterPanelSentinel: createRef(),
+    afterButtonSentinel: createRef(),
   } as StateDefinition)
   let [
-    { popoverState, button, buttonId, panel, panelId, beforePanelSentinel, afterPanelSentinel },
+    {
+      popoverState,
+      button,
+      buttonId,
+      panel,
+      panelId,
+      beforePanelSentinel,
+      afterPanelSentinel,
+      afterButtonSentinel,
+    },
     dispatch,
   ] = reducerBag
 
@@ -346,6 +357,7 @@ function PopoverFn<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
       if (root.contains(event.target)) return
       if (beforePanelSentinel.current?.contains?.(event.target)) return
       if (afterPanelSentinel.current?.contains?.(event.target)) return
+      if (afterButtonSentinel.current?.contains?.(event.target)) return
 
       dispatch({ type: ActionTypes.ClosePopover })
     },
@@ -700,6 +712,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
       {visible && !isWithinPanel && isPortalled && (
         <Hidden
           id={sentinelId}
+          ref={state.afterButtonSentinel}
           features={HiddenFeatures.Focusable}
           data-headlessui-focus-guard
           as="button"


Test patch:
diff --git a/packages/@headlessui-react/src/components/popover/popover.test.tsx b/packages/@headlessui-react/src/components/popover/popover.test.tsx
index 3777ac9..02a21de 100644
--- a/packages/@headlessui-react/src/components/popover/popover.test.tsx
+++ b/packages/@headlessui-react/src/components/popover/popover.test.tsx
@@ -16,7 +16,7 @@ import { Keys, MouseButton, click, focus, press, shift } from '../../test-utils/
 import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
 import { Portal } from '../portal/portal'
 import { Transition } from '../transition/transition'
-import { Popover } from './popover'
+import { Popover, PopoverButton, PopoverPanel } from './popover'
 
 jest.mock('../../hooks/use-id')
 
@@ -1303,6 +1303,45 @@ describe('Keyboard interactions', () => {
   })
 
   describe('`Tab` key', () => {
+    it(
+      'should be possible to Tab through the panel contents and end up in the Button again (without PopoverGroup)',
+      suppressConsoleLogs(async () => {
+        render(
+          <Popover>
+            <PopoverButton>Trigger</PopoverButton>
+            <PopoverPanel portal>
+              <a href="/">Link 1</a>
+              <a href="/">Link 2</a>
+            </PopoverPanel>
+          </Popover>
+        )
+
+        // Focus the button of the first Popover
+        getByText('Trigger')?.focus()
+
+        // Open popover
+        await click(getByText('Trigger'))
+
+        // Verify we are focused on the first link
+        await press(Keys.Tab)
+        assertActiveElement(getByText('Link 1'))
+
+        // Verify we are focused on the second link
+        await press(Keys.Tab)
+        assertActiveElement(getByText('Link 2'))
+
+        // Let's Tab again
+        await press(Keys.Tab)
+
+        // Verify that the first Popover is still open
+        assertPopoverButton({ state: PopoverState.Visible })
+        assertPopoverPanel({ state: PopoverState.Visible })
+
+        // Verify that the button is focused again
+        assertActiveElement(getByText('Trigger'))
+      })
+    )
+
     it(
       'should be possible to Tab through the panel contents onto the next Popover.Button',
       suppressConsoleLogs(async () => {