Skip to content

Commit f2a813e

Browse files
Detect outside clicks from within <iframe> elements (#1552)
* Refactor * Detect “outside clicks” inside `<iframe>` elements * Update changelog
1 parent bdd1b3b commit f2a813e

File tree

6 files changed

+232
-103
lines changed

6 files changed

+232
-103
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
- Fix incorrect transitionend/transitioncancel events for the Transition component ([#1537](https://github.com/tailwindlabs/headlessui/pull/1537))
1818
- Improve outside click of `Dialog` component ([#1546](https://github.com/tailwindlabs/headlessui/pull/1546))
19+
- Detect outside clicks from within `<iframe>` elements ([#1552](https://github.com/tailwindlabs/headlessui/pull/1552))
1920

2021
## [1.6.4] - 2022-05-29
2122

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3070,6 +3070,44 @@ describe('Mouse interactions', () => {
30703070
})
30713071
)
30723072

3073+
// TODO: This test doesn't work — and it would be more suited for browser testing anyway
3074+
it.skip(
3075+
'should be possible to click outside of the menu into an iframe and which should close the menu',
3076+
suppressConsoleLogs(async () => {
3077+
render(
3078+
<div>
3079+
<Menu>
3080+
<Menu.Button>Trigger</Menu.Button>
3081+
<Menu.Items>
3082+
<Menu.Item as="a">alice</Menu.Item>
3083+
<Menu.Item as="a">bob</Menu.Item>
3084+
<Menu.Item as="a">charlie</Menu.Item>
3085+
</Menu.Items>
3086+
</Menu>
3087+
<iframe
3088+
srcDoc={'<button>Trigger</button>'}
3089+
frameBorder="0"
3090+
width="300"
3091+
height="300"
3092+
></iframe>
3093+
</div>
3094+
)
3095+
3096+
// Open menu
3097+
await click(getMenuButton())
3098+
assertMenu({ state: MenuState.Visible })
3099+
3100+
// Click the input element in the iframe
3101+
await click(document.querySelector('iframe')?.contentDocument!.querySelector('button')!)
3102+
3103+
// Should be closed now
3104+
assertMenu({ state: MenuState.InvisibleUnmounted })
3105+
3106+
// Verify the button is focused again
3107+
assertActiveElement(getMenuButton())
3108+
})
3109+
)
3110+
30733111
it(
30743112
'should be possible to hover an item and make it active',
30753113
suppressConsoleLogs(async () => {

packages/@headlessui-react/src/hooks/use-outside-click.ts

Lines changed: 76 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ type ContainerInput = Container | ContainerCollection
88

99
export function useOutsideClick(
1010
containers: ContainerInput | (() => ContainerInput),
11-
cb: (event: MouseEvent | PointerEvent, target: HTMLElement) => void,
11+
cb: (event: MouseEvent | PointerEvent | FocusEvent, target: HTMLElement) => void,
1212
enabled: boolean = true
1313
) {
1414
// TODO: remove this once the React bug has been fixed: https://github.com/facebook/react/issues/24657
@@ -26,68 +26,96 @@ export function useOutsideClick(
2626
[enabled]
2727
)
2828

29-
useWindowEvent(
30-
'click',
31-
(event) => {
32-
if (!enabledRef.current) return
29+
function handleOutsideClick<E extends MouseEvent | PointerEvent | FocusEvent>(
30+
event: E,
31+
resolveTarget: (event: E) => HTMLElement | null
32+
) {
33+
if (!enabledRef.current) return
3334

34-
// Check whether the event got prevented already. This can happen if you use the
35-
// useOutsideClick hook in both a Dialog and a Menu and the inner Menu "cancels" the default
36-
// behaviour so that only the Menu closes and not the Dialog (yet)
37-
if (event.defaultPrevented) return
35+
// Check whether the event got prevented already. This can happen if you use the
36+
// useOutsideClick hook in both a Dialog and a Menu and the inner Menu "cancels" the default
37+
// behaviour so that only the Menu closes and not the Dialog (yet)
38+
if (event.defaultPrevented) return
3839

39-
let _containers = (function resolve(containers): ContainerCollection {
40-
if (typeof containers === 'function') {
41-
return resolve(containers())
42-
}
40+
let _containers = (function resolve(containers): ContainerCollection {
41+
if (typeof containers === 'function') {
42+
return resolve(containers())
43+
}
4344

44-
if (Array.isArray(containers)) {
45-
return containers
46-
}
45+
if (Array.isArray(containers)) {
46+
return containers
47+
}
4748

48-
if (containers instanceof Set) {
49-
return containers
50-
}
49+
if (containers instanceof Set) {
50+
return containers
51+
}
5152

52-
return [containers]
53-
})(containers)
53+
return [containers]
54+
})(containers)
5455

55-
let target = event.target as HTMLElement
56+
let target = resolveTarget(event)
5657

57-
// Ignore if the target doesn't exist in the DOM anymore
58-
if (!target.ownerDocument.documentElement.contains(target)) return
58+
if (target === null) {
59+
return
60+
}
5961

60-
// Ignore if the target exists in one of the containers
61-
for (let container of _containers) {
62-
if (container === null) continue
63-
let domNode = container instanceof HTMLElement ? container : container.current
64-
if (domNode?.contains(target)) {
65-
return
66-
}
67-
}
62+
// Ignore if the target doesn't exist in the DOM anymore
63+
if (!target.ownerDocument.documentElement.contains(target)) return
6864

69-
// This allows us to check whether the event was defaultPrevented when you are nesting this
70-
// inside a `<Dialog />` for example.
71-
if (
72-
// This check alllows us to know whether or not we clicked on a "focusable" element like a
73-
// button or an input. This is a backwards compatibility check so that you can open a <Menu
74-
// /> and click on another <Menu /> which should close Menu A and open Menu B. We might
75-
// revisit that so that you will require 2 clicks instead.
76-
!isFocusableElement(target, FocusableMode.Loose) &&
77-
// This could be improved, but the `Combobox.Button` adds tabIndex={-1} to make it
78-
// unfocusable via the keyboard so that tabbing to the next item from the input doesn't
79-
// first go to the button.
80-
target.tabIndex !== -1
81-
) {
82-
event.preventDefault()
65+
// Ignore if the target exists in one of the containers
66+
for (let container of _containers) {
67+
if (container === null) continue
68+
let domNode = container instanceof HTMLElement ? container : container.current
69+
if (domNode?.contains(target)) {
70+
return
8371
}
72+
}
73+
74+
// This allows us to check whether the event was defaultPrevented when you are nesting this
75+
// inside a `<Dialog />` for example.
76+
if (
77+
// This check alllows us to know whether or not we clicked on a "focusable" element like a
78+
// button or an input. This is a backwards compatibility check so that you can open a <Menu
79+
// /> and click on another <Menu /> which should close Menu A and open Menu B. We might
80+
// revisit that so that you will require 2 clicks instead.
81+
!isFocusableElement(target, FocusableMode.Loose) &&
82+
// This could be improved, but the `Combobox.Button` adds tabIndex={-1} to make it
83+
// unfocusable via the keyboard so that tabbing to the next item from the input doesn't
84+
// first go to the button.
85+
target.tabIndex !== -1
86+
) {
87+
event.preventDefault()
88+
}
89+
90+
return cb(event, target)
91+
}
92+
93+
useWindowEvent(
94+
'click',
95+
(event) => handleOutsideClick(event, (event) => event.target as HTMLElement),
8496

85-
return cb(event, target)
86-
},
8797
// We will use the `capture` phase so that layers in between with `event.stopPropagation()`
8898
// don't "cancel" this outside click check. E.g.: A `Menu` inside a `DialogPanel` if the `Menu`
8999
// is open, and you click outside of it in the `DialogPanel` the `Menu` should close. However,
90100
// the `DialogPanel` has a `onClick(e) { e.stopPropagation() }` which would cancel this.
91101
true
92102
)
103+
104+
// When content inside an iframe is clicked `window` will receive a blur event
105+
// This can happen when an iframe _inside_ a window is clicked
106+
// Or, if headless UI is *in* the iframe, when a content in a window containing that iframe is clicked
107+
108+
// In this case we care only about the first case so we check to see if the active element is the iframe
109+
// If so this was because of a click, focus, or other interaction with the child iframe
110+
// and we can consider it an "outside click"
111+
useWindowEvent(
112+
'blur',
113+
(event) =>
114+
handleOutsideClick(event, () =>
115+
window.document.activeElement instanceof HTMLIFrameElement
116+
? window.document.activeElement
117+
: null
118+
),
119+
true
120+
)
93121
}

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
- Support `<slot>` children when using `as="template"` ([#1548](https://github.com/tailwindlabs/headlessui/pull/1548))
1818
- Improve outside click of `Dialog` component ([#1546](https://github.com/tailwindlabs/headlessui/pull/1546))
19+
- Detect outside clicks from within `<iframe>` elements ([#1552](https://github.com/tailwindlabs/headlessui/pull/1552))
1920

2021
## [1.6.4] - 2022-05-29
2122

packages/@headlessui-vue/src/components/menu/menu.test.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3092,6 +3092,39 @@ describe('Mouse interactions', () => {
30923092
})
30933093
)
30943094

3095+
// TODO: This test doesn't work — and it would be more suited for browser testing anyway
3096+
it.skip(
3097+
'should be possible to click outside of the menu into an iframe and which should close the menu',
3098+
suppressConsoleLogs(async () => {
3099+
renderTemplate(`
3100+
<div>
3101+
<Menu>
3102+
<MenuButton>Trigger</MenuButton>
3103+
<MenuItems>
3104+
<menuitem as="a">alice</menuitem>
3105+
<menuitem as="a">bob</menuitem>
3106+
<menuitem as="a">charlie</menuitem>
3107+
</MenuItems>
3108+
</Menu>
3109+
<iframe :srcdoc="'<button>Trigger</button>'" frameborder="0" width="300" height="300"></iframe>
3110+
</div>
3111+
`)
3112+
3113+
// Open menu
3114+
await click(getMenuButton())
3115+
assertMenu({ state: MenuState.Visible })
3116+
3117+
// Click the input element in the iframe
3118+
await click(document.querySelector('iframe')?.contentDocument!.querySelector('button')!)
3119+
3120+
// Should be closed now
3121+
assertMenu({ state: MenuState.InvisibleUnmounted })
3122+
3123+
// Verify the button is focused again
3124+
assertActiveElement(getMenuButton())
3125+
})
3126+
)
3127+
30953128
it('should be possible to hover an item and make it active', async () => {
30963129
renderTemplate(jsx`
30973130
<Menu>

0 commit comments

Comments
 (0)