Skip to content

Commit c2754bc

Browse files
authored
Ensure tabbing to a portalled <PopoverPanel> component moves focus inside (without using <PortalGroup>) (#3239)
* ensure we allow focus in the focus sentinel button We already checked this button when inside of a `PopoverGroup`, but we didn't when you weren't using a `PopoverGroup` component. * add test * update changelog
1 parent b822c8a commit c2754bc

File tree

3 files changed

+55
-2
lines changed

3 files changed

+55
-2
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- [internal] Don’t set a focus fallback for Dialog’s in demo mode ([#3194](https://github.com/tailwindlabs/headlessui/pull/3194))
1313
- Ensure page doesn't scroll down when pressing `Escape` to close the `Dialog` component ([#3218](https://github.com/tailwindlabs/headlessui/pull/3218))
1414
- Fix crash when toggling between `virtual` and non-virtual mode in `Combobox` component ([#3236](https://github.com/tailwindlabs/headlessui/pull/3236))
15+
- Ensure tabbing to a portalled `<PopoverPanel>` component moves focus inside (without using `<PortalGroup>`) ([#3239](https://github.com/tailwindlabs/headlessui/pull/3239))
1516

1617
### Deprecated
1718

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { Keys, MouseButton, click, focus, press, shift } from '../../test-utils/
1616
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
1717
import { Portal } from '../portal/portal'
1818
import { Transition } from '../transition/transition'
19-
import { Popover } from './popover'
19+
import { Popover, PopoverButton, PopoverPanel } from './popover'
2020

2121
jest.mock('../../hooks/use-id')
2222

@@ -1303,6 +1303,45 @@ describe('Keyboard interactions', () => {
13031303
})
13041304

13051305
describe('`Tab` key', () => {
1306+
it(
1307+
'should be possible to Tab through the panel contents and end up in the Button again (without PopoverGroup)',
1308+
suppressConsoleLogs(async () => {
1309+
render(
1310+
<Popover>
1311+
<PopoverButton>Trigger</PopoverButton>
1312+
<PopoverPanel portal>
1313+
<a href="/">Link 1</a>
1314+
<a href="/">Link 2</a>
1315+
</PopoverPanel>
1316+
</Popover>
1317+
)
1318+
1319+
// Focus the button of the first Popover
1320+
getByText('Trigger')?.focus()
1321+
1322+
// Open popover
1323+
await click(getByText('Trigger'))
1324+
1325+
// Verify we are focused on the first link
1326+
await press(Keys.Tab)
1327+
assertActiveElement(getByText('Link 1'))
1328+
1329+
// Verify we are focused on the second link
1330+
await press(Keys.Tab)
1331+
assertActiveElement(getByText('Link 2'))
1332+
1333+
// Let's Tab again
1334+
await press(Keys.Tab)
1335+
1336+
// Verify that the first Popover is still open
1337+
assertPopoverButton({ state: PopoverState.Visible })
1338+
assertPopoverPanel({ state: PopoverState.Visible })
1339+
1340+
// Verify that the button is focused again
1341+
assertActiveElement(getByText('Trigger'))
1342+
})
1343+
)
1344+
13061345
it(
13071346
'should be possible to Tab through the panel contents onto the next Popover.Button',
13081347
suppressConsoleLogs(async () => {

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ interface StateDefinition {
9292

9393
beforePanelSentinel: MutableRefObject<HTMLButtonElement | null>
9494
afterPanelSentinel: MutableRefObject<HTMLButtonElement | null>
95+
afterButtonSentinel: MutableRefObject<HTMLButtonElement | null>
9596

9697
__demoMode: boolean
9798
}
@@ -256,9 +257,19 @@ function PopoverFn<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
256257
panelId: null,
257258
beforePanelSentinel: createRef(),
258259
afterPanelSentinel: createRef(),
260+
afterButtonSentinel: createRef(),
259261
} as StateDefinition)
260262
let [
261-
{ popoverState, button, buttonId, panel, panelId, beforePanelSentinel, afterPanelSentinel },
263+
{
264+
popoverState,
265+
button,
266+
buttonId,
267+
panel,
268+
panelId,
269+
beforePanelSentinel,
270+
afterPanelSentinel,
271+
afterButtonSentinel,
272+
},
262273
dispatch,
263274
] = reducerBag
264275

@@ -346,6 +357,7 @@ function PopoverFn<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
346357
if (root.contains(event.target)) return
347358
if (beforePanelSentinel.current?.contains?.(event.target)) return
348359
if (afterPanelSentinel.current?.contains?.(event.target)) return
360+
if (afterButtonSentinel.current?.contains?.(event.target)) return
349361

350362
dispatch({ type: ActionTypes.ClosePopover })
351363
},
@@ -700,6 +712,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
700712
{visible && !isWithinPanel && isPortalled && (
701713
<Hidden
702714
id={sentinelId}
715+
ref={state.afterButtonSentinel}
703716
features={HiddenFeatures.Focusable}
704717
data-headlessui-focus-guard
705718
as="button"

0 commit comments

Comments
 (0)