Skip to content

Commit

Permalink
Detect outside clicks from within <iframe> elements (#1552)
Browse files Browse the repository at this point in the history
* Refactor

* Detect “outside clicks” inside `<iframe>` elements

* Update changelog
  • Loading branch information
thecrypticace committed Jun 3, 2022
1 parent bdd1b3b commit f2a813e
Show file tree
Hide file tree
Showing 6 changed files with 232 additions and 103 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

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

## [1.6.4] - 2022-05-29

Expand Down
38 changes: 38 additions & 0 deletions packages/@headlessui-react/src/components/menu/menu.test.tsx
Expand Up @@ -3070,6 +3070,44 @@ describe('Mouse interactions', () => {
})
)

// TODO: This test doesn't work — and it would be more suited for browser testing anyway
it.skip(
'should be possible to click outside of the menu into an iframe and which should close the menu',
suppressConsoleLogs(async () => {
render(
<div>
<Menu>
<Menu.Button>Trigger</Menu.Button>
<Menu.Items>
<Menu.Item as="a">alice</Menu.Item>
<Menu.Item as="a">bob</Menu.Item>
<Menu.Item as="a">charlie</Menu.Item>
</Menu.Items>
</Menu>
<iframe
srcDoc={'<button>Trigger</button>'}
frameBorder="0"
width="300"
height="300"
></iframe>
</div>
)

// Open menu
await click(getMenuButton())
assertMenu({ state: MenuState.Visible })

// Click the input element in the iframe
await click(document.querySelector('iframe')?.contentDocument!.querySelector('button')!)

// Should be closed now
assertMenu({ state: MenuState.InvisibleUnmounted })

// Verify the button is focused again
assertActiveElement(getMenuButton())
})
)

it(
'should be possible to hover an item and make it active',
suppressConsoleLogs(async () => {
Expand Down
124 changes: 76 additions & 48 deletions packages/@headlessui-react/src/hooks/use-outside-click.ts
Expand Up @@ -8,7 +8,7 @@ type ContainerInput = Container | ContainerCollection

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

useWindowEvent(
'click',
(event) => {
if (!enabledRef.current) return
function handleOutsideClick<E extends MouseEvent | PointerEvent | FocusEvent>(
event: E,
resolveTarget: (event: E) => HTMLElement | null
) {
if (!enabledRef.current) return

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

let _containers = (function resolve(containers): ContainerCollection {
if (typeof containers === 'function') {
return resolve(containers())
}
let _containers = (function resolve(containers): ContainerCollection {
if (typeof containers === 'function') {
return resolve(containers())
}

if (Array.isArray(containers)) {
return containers
}
if (Array.isArray(containers)) {
return containers
}

if (containers instanceof Set) {
return containers
}
if (containers instanceof Set) {
return containers
}

return [containers]
})(containers)
return [containers]
})(containers)

let target = event.target as HTMLElement
let target = resolveTarget(event)

// Ignore if the target doesn't exist in the DOM anymore
if (!target.ownerDocument.documentElement.contains(target)) return
if (target === null) {
return
}

// Ignore if the target exists in one of the containers
for (let container of _containers) {
if (container === null) continue
let domNode = container instanceof HTMLElement ? container : container.current
if (domNode?.contains(target)) {
return
}
}
// Ignore if the target doesn't exist in the DOM anymore
if (!target.ownerDocument.documentElement.contains(target)) return

// This allows us to check whether the event was defaultPrevented when you are nesting this
// inside a `<Dialog />` for example.
if (
// This check alllows us to know whether or not we clicked on a "focusable" element like a
// button or an input. This is a backwards compatibility check so that you can open a <Menu
// /> and click on another <Menu /> which should close Menu A and open Menu B. We might
// revisit that so that you will require 2 clicks instead.
!isFocusableElement(target, FocusableMode.Loose) &&
// This could be improved, but the `Combobox.Button` adds tabIndex={-1} to make it
// unfocusable via the keyboard so that tabbing to the next item from the input doesn't
// first go to the button.
target.tabIndex !== -1
) {
event.preventDefault()
// Ignore if the target exists in one of the containers
for (let container of _containers) {
if (container === null) continue
let domNode = container instanceof HTMLElement ? container : container.current
if (domNode?.contains(target)) {
return
}
}

// This allows us to check whether the event was defaultPrevented when you are nesting this
// inside a `<Dialog />` for example.
if (
// This check alllows us to know whether or not we clicked on a "focusable" element like a
// button or an input. This is a backwards compatibility check so that you can open a <Menu
// /> and click on another <Menu /> which should close Menu A and open Menu B. We might
// revisit that so that you will require 2 clicks instead.
!isFocusableElement(target, FocusableMode.Loose) &&
// This could be improved, but the `Combobox.Button` adds tabIndex={-1} to make it
// unfocusable via the keyboard so that tabbing to the next item from the input doesn't
// first go to the button.
target.tabIndex !== -1
) {
event.preventDefault()
}

return cb(event, target)
}

useWindowEvent(
'click',
(event) => handleOutsideClick(event, (event) => event.target as HTMLElement),

return cb(event, target)
},
// We will use the `capture` phase so that layers in between with `event.stopPropagation()`
// don't "cancel" this outside click check. E.g.: A `Menu` inside a `DialogPanel` if the `Menu`
// is open, and you click outside of it in the `DialogPanel` the `Menu` should close. However,
// the `DialogPanel` has a `onClick(e) { e.stopPropagation() }` which would cancel this.
true
)

// When content inside an iframe is clicked `window` will receive a blur event
// This can happen when an iframe _inside_ a window is clicked
// Or, if headless UI is *in* the iframe, when a content in a window containing that iframe is clicked

// In this case we care only about the first case so we check to see if the active element is the iframe
// If so this was because of a click, focus, or other interaction with the child iframe
// and we can consider it an "outside click"
useWindowEvent(
'blur',
(event) =>
handleOutsideClick(event, () =>
window.document.activeElement instanceof HTMLIFrameElement
? window.document.activeElement
: null
),
true
)
}
1 change: 1 addition & 0 deletions packages/@headlessui-vue/CHANGELOG.md
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

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

## [1.6.4] - 2022-05-29

Expand Down
33 changes: 33 additions & 0 deletions packages/@headlessui-vue/src/components/menu/menu.test.tsx
Expand Up @@ -3092,6 +3092,39 @@ describe('Mouse interactions', () => {
})
)

// TODO: This test doesn't work — and it would be more suited for browser testing anyway
it.skip(
'should be possible to click outside of the menu into an iframe and which should close the menu',
suppressConsoleLogs(async () => {
renderTemplate(`
<div>
<Menu>
<MenuButton>Trigger</MenuButton>
<MenuItems>
<menuitem as="a">alice</menuitem>
<menuitem as="a">bob</menuitem>
<menuitem as="a">charlie</menuitem>
</MenuItems>
</Menu>
<iframe :srcdoc="'<button>Trigger</button>'" frameborder="0" width="300" height="300"></iframe>
</div>
`)

// Open menu
await click(getMenuButton())
assertMenu({ state: MenuState.Visible })

// Click the input element in the iframe
await click(document.querySelector('iframe')?.contentDocument!.querySelector('button')!)

// Should be closed now
assertMenu({ state: MenuState.InvisibleUnmounted })

// Verify the button is focused again
assertActiveElement(getMenuButton())
})
)

it('should be possible to hover an item and make it active', async () => {
renderTemplate(jsx`
<Menu>
Expand Down

0 comments on commit f2a813e

Please sign in to comment.