Skip to content

Commit fe9ec17

Browse files
authored
Improve focus management in shadow roots (#3794)
This PR improves the focus management when Headless UI components are used inside of a shadow DOM. We already tried to not use `document` directly, but instead rely on `someKnownElement.ownerDocument` to get the correct document context. This is unfortunately not enough, because if you have a focused element _inside_ the shadow DOM, then `ownerDocument.activeElement` will return the wrapping shadow DOM node and not the actual focused element inside of the shadow DOM. This PR improves that behavior by using `getRootNode()` instead and then we read information such as the `activeElement` from there. It's going to be easier if you look at this reproduction. The element with the black border is a shadow DOM element. The `toggle` button is a Popover button. Expected behavior: 1. You can tab through the elements in order from outside the shadow DOM into the shadow DOM and out again. 1. You can shift+tab in reverse order as well. 1. When the Popover is open, pressing `Escape` should close the Popover. 1. When tabbing outside of the open popover, the popover should close. Let's see these things in action: --- 1. Pressing escape on an open Popover should close it **before:** https://github.com/user-attachments/assets/99ff5768-f6ac-490f-a607-391bbdd21d65 The reason it doesn't is because we check the `activeElement`, and we only close the popover if it is the active element. But if you look at devtools, the button is not the active element so therefore it doesn't work. **after:** https://github.com/user-attachments/assets/22511835-98d3-452d-baac-447fa4e5a5e7 --- 2. Tabbing out of an open popover with <kbd>Tab</kbd> should close the Popover and focus the next element. **before:** https://github.com/user-attachments/assets/ab2c5849-f524-436b-aa7c-9e65e5debc67 Here it does all the wrong things, it doesn't go into the PopoverPanel, it doesn't go to the next button, but it goes to the next button _outside_ of the shadow dom. **after:** https://github.com/user-attachments/assets/c5f653f1-338d-41c4-a166-735e6c1d1435 --- ## Test plan 1. Existing code still passes (we don't have shadow DOM tests) 2. Tested the behavior from the reproduction and it all works as expected now: https://github.com/user-attachments/assets/a90206e6-ae5e-4748-a4e4-c65dfa69d8f6 Fixes: #2951
1 parent 8759a5c commit fe9ec17

File tree

18 files changed

+107
-84
lines changed

18 files changed

+107
-84
lines changed

packages/@headlessui-react/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+
- Improve focus management in shadow DOM roots ([#3794](https://github.com/tailwindlabs/headlessui/pull/3794))
1113

1214
## [2.2.8] - 2025-09-12
1315

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import { Focus } from '../../utils/calculate-active-index'
6767
import { disposables } from '../../utils/disposables'
6868
import * as DOM from '../../utils/dom'
6969
import { match } from '../../utils/match'
70+
import { isActiveElement } from '../../utils/owner'
7071
import { isMobile } from '../../utils/platform'
7172
import {
7273
RenderFeatures,
@@ -543,16 +544,13 @@ function InputFn<
543544
...theirProps
544545
} = props
545546

546-
let [inputElement] = useSlice(machine, (state) => [state.inputElement])
547-
548547
let internalInputRef = useRef<HTMLInputElement | null>(null)
549548
let inputRef = useSyncRefs(
550549
internalInputRef,
551550
ref,
552551
useFloatingReference(),
553552
machine.actions.setInputElement
554553
)
555-
let ownerDocument = useOwnerDocument(inputElement)
556554

557555
let [comboboxState, isTyping] = useSlice(machine, (state) => [
558556
state.comboboxState,
@@ -628,7 +626,7 @@ function InputFn<
628626
// Bail when the input is not the currently focused element. When it is not the focused
629627
// element, and we call the `setSelectionRange`, then it will become the focused
630628
// element which may be unwanted.
631-
if (ownerDocument?.activeElement !== input) return
629+
if (isActiveElement(input)) return
632630

633631
let { selectionStart, selectionEnd } = input
634632

@@ -642,7 +640,7 @@ function InputFn<
642640
input.setSelectionRange(input.value.length, input.value.length)
643641
})
644642
},
645-
[currentDisplayValue, comboboxState, ownerDocument, isTyping]
643+
[currentDisplayValue, comboboxState, isTyping]
646644
)
647645

648646
// Trick VoiceOver in behaving a little bit better. Manually "resetting" the input makes VoiceOver

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ let InternalDialog = forwardRefWithAs(function InternalDialog<
160160
let internalDialogRef = useRef<HTMLElement | null>(null)
161161
let dialogRef = useSyncRefs(internalDialogRef, ref)
162162

163-
let ownerDocument = useOwnerDocument(internalDialogRef)
163+
let ownerDocument = useOwnerDocument(internalDialogRef.current)
164164

165165
let dialogState = open ? DialogStates.Open : DialogStates.Closed
166166

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ function DisclosureFn<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_TAG>(
206206
let close = useEvent(
207207
(focusableElement?: HTMLOrSVGElement | MutableRefObject<HTMLOrSVGElement | null>) => {
208208
dispatch({ type: ActionTypes.CloseDisclosure })
209-
let ownerDocument = getOwnerDocument(internalDisclosureRef)
209+
let ownerDocument = getOwnerDocument(internalDisclosureRef.current)
210210
if (!ownerDocument) return
211211
if (!buttonId) return
212212

packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import * as DOM from '../../utils/dom'
2525
import { Focus, FocusResult, focusElement, focusIn } from '../../utils/focus-management'
2626
import { match } from '../../utils/match'
2727
import { microTask } from '../../utils/micro-task'
28+
import { isActiveElement } from '../../utils/owner'
2829
import { forwardRefWithAs, useRender, type HasDisplayName, type RefProp } from '../../utils/render'
2930

3031
type Containers =
@@ -108,7 +109,7 @@ function FocusTrapFn<TTag extends ElementType = typeof DEFAULT_FOCUS_TRAP_TAG>(
108109
features = FocusTrapFeatures.None
109110
}
110111

111-
let ownerDocument = useOwnerDocument(container)
112+
let ownerDocument = useOwnerDocument(container.current)
112113

113114
useRestoreFocus(features, { ownerDocument })
114115
let previousActiveElement = useInitialFocus(features, {
@@ -288,7 +289,7 @@ function useRestoreFocus(
288289
useWatch(() => {
289290
if (enabled) return
290291

291-
if (ownerDocument?.activeElement === ownerDocument?.body) {
292+
if (isActiveElement(ownerDocument?.body)) {
292293
focusElement(getRestoreElement())
293294
}
294295
}, [enabled])

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ import {
7171
} from '../../utils/focus-management'
7272
import { attemptSubmit } from '../../utils/form'
7373
import { match } from '../../utils/match'
74-
import { getOwnerDocument } from '../../utils/owner'
74+
import { isActiveElement } from '../../utils/owner'
7575
import {
7676
RenderFeatures,
7777
forwardRefWithAs,
@@ -666,7 +666,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
666666
let container = optionsElement
667667
if (!container) return
668668
if (listboxState !== ListboxStates.Open) return
669-
if (container === getOwnerDocument(container)?.activeElement) return
669+
if (isActiveElement(container)) return
670670

671671
container?.focus({ preventScroll: true })
672672
}, [listboxState, optionsElement])

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060
restoreFocusIfNecessary,
6161
} from '../../utils/focus-management'
6262
import { match } from '../../utils/match'
63+
import { isActiveElement } from '../../utils/owner'
6364
import {
6465
RenderFeatures,
6566
forwardRefWithAs,
@@ -453,10 +454,10 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
453454
let container = localItemsElement
454455
if (!container) return
455456
if (menuState !== MenuState.Open) return
456-
if (container === ownerDocument?.activeElement) return
457+
if (isActiveElement(container)) return
457458

458459
container.focus({ preventScroll: true })
459-
}, [menuState, localItemsElement, ownerDocument])
460+
}, [menuState, localItemsElement])
460461

461462
useTreeWalker(menuState === MenuState.Open, {
462463
container: localItemsElement,

packages/@headlessui-react/src/components/popover/popover-machine.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { stackMachines } from '../../machines/stack-machine'
44
import * as DOM from '../../utils/dom'
55
import { getFocusableElements } from '../../utils/focus-management'
66
import { match } from '../../utils/match'
7+
import { getOwnerDocument } from '../../utils/owner'
78

89
type MouseEvent<T> = Parameters<MouseEventHandler<T>>[0]
910

@@ -142,10 +143,12 @@ export class PopoverMachine extends Machine<State, Actions> {
142143
if (!state.button) return false
143144
if (!state.panel) return false
144145

146+
let ownerDocument = getOwnerDocument(state.button) ?? document
147+
145148
// We are part of a different "root" tree, so therefore we can consider it portalled. This is a
146149
// heuristic because 3rd party tools could use some form of portal, typically rendered at the
147150
// end of the body but we don't have an actual reference to that.
148-
for (let root of document.querySelectorAll('body > *')) {
151+
for (let root of ownerDocument.querySelectorAll('body > *')) {
149152
if (Number(root?.contains(state.button)) ^ Number(root?.contains(state.panel))) {
150153
return true
151154
}
@@ -157,7 +160,7 @@ export class PopoverMachine extends Machine<State, Actions> {
157160
// portalled or not because we can follow the default tab order. But if they
158161
// are not, then we can consider it being portalled so that we can ensure
159162
// that tab and shift+tab (hopefully) go to the correct spot.
160-
let elements = getFocusableElements()
163+
let elements = getFocusableElements(ownerDocument)
161164
let buttonIdx = elements.indexOf(state.button)
162165

163166
let beforeIdx = (buttonIdx + elements.length - 1) % elements.length

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

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
2828
import { useLatestValue } from '../../hooks/use-latest-value'
2929
import { useOnDisappear } from '../../hooks/use-on-disappear'
3030
import { useOutsideClick } from '../../hooks/use-outside-click'
31-
import { useOwnerDocument } from '../../hooks/use-owner'
31+
import { useOwnerDocument, useRootDocument } from '../../hooks/use-owner'
3232
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
3333
import {
3434
MainTreeProvider,
@@ -70,7 +70,7 @@ import {
7070
} from '../../utils/focus-management'
7171
import { match } from '../../utils/match'
7272
import { microTask } from '../../utils/micro-task'
73-
import { getOwnerDocument } from '../../utils/owner'
73+
import { getActiveElement, getRootNode } from '../../utils/owner'
7474
import {
7575
RenderFeatures,
7676
forwardRefWithAs,
@@ -156,7 +156,7 @@ function PopoverFn<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
156156
}, [])
157157
)
158158

159-
let ownerDocument = useOwnerDocument(internalPopoverRef.current ?? button)
159+
let rootDocument = useRootDocument(internalPopoverRef.current ?? button)
160160

161161
let buttonIdRef = useLatestValue(buttonId)
162162
let panelIdRef = useLatestValue(panelId)
@@ -173,11 +173,11 @@ function PopoverFn<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
173173
let groupContext = usePopoverGroupContext()
174174
let registerPopover = groupContext?.registerPopover
175175
let isFocusWithinPopoverGroup = useEvent(() => {
176+
let activeElement = getActiveElement(internalPopoverRef.current ?? button)
177+
176178
return (
177179
groupContext?.isFocusWithinPopoverGroup() ??
178-
(ownerDocument?.activeElement &&
179-
(button?.contains(ownerDocument.activeElement) ||
180-
panel?.contains(ownerDocument.activeElement)))
180+
(activeElement && (button?.contains(activeElement) || panel?.contains(activeElement)))
181181
)
182182
})
183183

@@ -204,7 +204,7 @@ function PopoverFn<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
204204

205205
// Handle focus out
206206
useEventListener(
207-
ownerDocument?.defaultView,
207+
rootDocument,
208208
'focus',
209209
(event) => {
210210
if (event.target === window) return
@@ -392,7 +392,6 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
392392
})
393393
)
394394
let withinPanelButtonRef = useSyncRefs(internalButtonRef, ref)
395-
let ownerDocument = useOwnerDocument(internalButtonRef)
396395

397396
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
398397
if (isWithinPanel) {
@@ -426,10 +425,8 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
426425
return closeOthers?.(machine.state.buttonId!)
427426
}
428427
if (!internalButtonRef.current) return
429-
if (
430-
ownerDocument?.activeElement &&
431-
!internalButtonRef.current.contains(ownerDocument.activeElement)
432-
) {
428+
let activeElement = getActiveElement(internalButtonRef.current)
429+
if (activeElement && !internalButtonRef.current.contains(activeElement)) {
433430
return
434431
}
435432
event.preventDefault()
@@ -535,7 +532,9 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
535532

536533
if (result === FocusResult.Error) {
537534
focusIn(
538-
getFocusableElements().filter((el) => el.dataset.headlessuiFocusGuard !== 'true'),
535+
getFocusableElements(getRootNode(machine.state.button)).filter(
536+
(el) => el.dataset.headlessuiFocusGuard !== 'true'
537+
),
539538
match(direction.current, {
540539
[TabDirection.Forwards]: Focus.Next,
541540
[TabDirection.Backwards]: Focus.Previous,
@@ -749,7 +748,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
749748
setLocalPanelElement
750749
)
751750
let portalOwnerDocument = useOwnerDocument(button)
752-
let ownerDocument = useOwnerDocument(internalPanelRef)
751+
let ownerDocument = useOwnerDocument(internalPanelRef.current)
753752

754753
useIsoMorphicEffect(() => {
755754
machine.actions.setPanelId(id)
@@ -777,10 +776,8 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
777776
case Keys.Escape:
778777
if (machine.state.popoverState !== PopoverStates.Open) return
779778
if (!internalPanelRef.current) return
780-
if (
781-
ownerDocument?.activeElement &&
782-
!internalPanelRef.current.contains(ownerDocument.activeElement)
783-
) {
779+
let activeElement = getActiveElement(internalPanelRef.current)
780+
if (activeElement && !internalPanelRef.current.contains(activeElement)) {
784781
return
785782
}
786783
event.preventDefault()
@@ -807,7 +804,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
807804
if (popoverState !== PopoverStates.Open) return
808805
if (!internalPanelRef.current) return
809806

810-
let activeElement = ownerDocument?.activeElement as HTMLElement
807+
let activeElement = getActiveElement(internalPanelRef.current)
811808
if (internalPanelRef.current.contains(activeElement)) return // Already focused within Dialog
812809

813810
focusIn(internalPanelRef.current, Focus.First)
@@ -889,7 +886,8 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
889886
[TabDirection.Forwards]: () => {
890887
if (!machine.state.button) return
891888

892-
let elements = getFocusableElements()
889+
let root = getRootNode(machine.state.button) ?? document.body
890+
let elements = getFocusableElements(root)
893891

894892
let idx = elements.indexOf(machine.state.button)
895893
let before = elements.slice(0, idx + 1)
@@ -1015,17 +1013,17 @@ function GroupFn<TTag extends ElementType = typeof DEFAULT_GROUP_TAG>(
10151013
})
10161014

10171015
let isFocusWithinPopoverGroup = useEvent(() => {
1018-
let ownerDocument = getOwnerDocument(internalGroupRef)
1019-
if (!ownerDocument) return false
1020-
let element = ownerDocument.activeElement
1016+
let root = getRootNode(internalGroupRef.current)
1017+
if (!root) return false
1018+
let activeElement = getActiveElement(internalGroupRef.current)
10211019

1022-
if (internalGroupRef.current?.contains(element)) return true
1020+
if (internalGroupRef.current?.contains(activeElement)) return true
10231021

10241022
// Check if the focus is in one of the button or panel elements. This is important in case you are rendering inside a Portal.
10251023
return popovers.some((bag) => {
10261024
return (
1027-
ownerDocument!.getElementById(bag.buttonId.current!)?.contains(element) ||
1028-
ownerDocument!.getElementById(bag.panelId.current!)?.contains(element)
1025+
root!.getElementById(bag.buttonId.current!)?.contains(activeElement) ||
1026+
root!.getElementById(bag.panelId.current!)?.contains(activeElement)
10291027
)
10301028
})
10311029
})

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ let InternalPortalFn = forwardRefWithAs(function InternalPortalFn<
8989
}),
9090
ref
9191
)
92-
let defaultOwnerDocument = useOwnerDocument(internalPortalRootRef)
92+
let defaultOwnerDocument = useOwnerDocument(internalPortalRootRef.current)
9393
let ownerDocument = incomingOwnerDocument ?? defaultOwnerDocument
9494
let target = usePortalTarget(ownerDocument)
9595
let parent = useContext(PortalParentContext)

0 commit comments

Comments
 (0)