diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 970b37e4c..d832f4b69 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -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 ` + + ) + + // 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 () => { diff --git a/packages/@headlessui-react/src/hooks/use-outside-click.ts b/packages/@headlessui-react/src/hooks/use-outside-click.ts index 3b48fc40f..d358023af 100644 --- a/packages/@headlessui-react/src/hooks/use-outside-click.ts +++ b/packages/@headlessui-react/src/hooks/use-outside-click.ts @@ -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 @@ -26,68 +26,96 @@ export function useOutsideClick( [enabled] ) - useWindowEvent( - 'click', - (event) => { - if (!enabledRef.current) return + function handleOutsideClick( + 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 `` 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 and click on another 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 `` 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 and click on another 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 + ) } diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index 3eb8a3064..574417580 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support `` 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 ` + + `) + + // 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` diff --git a/packages/@headlessui-vue/src/hooks/use-outside-click.ts b/packages/@headlessui-vue/src/hooks/use-outside-click.ts index 562e81d69..83d8e652b 100644 --- a/packages/@headlessui-vue/src/hooks/use-outside-click.ts +++ b/packages/@headlessui-vue/src/hooks/use-outside-click.ts @@ -9,71 +9,99 @@ 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: ComputedRef = computed(() => true) ) { - useWindowEvent( - 'click', - (event) => { - if (!enabled.value) 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 target = event.target as HTMLElement - - // Ignore if the target doesn't exist in the DOM anymore - if (!target.ownerDocument.documentElement.contains(target)) return - - let _containers = (function resolve(containers): ContainerCollection { - if (typeof containers === 'function') { - return resolve(containers()) - } - - if (Array.isArray(containers)) { - return containers - } - - if (containers instanceof Set) { - return containers - } - - return [containers] - })(containers) - - // 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 : dom(container) - if (domNode?.contains(target)) { - return - } + function handleOutsideClick( + event: E, + resolveTarget: (event: E) => HTMLElement | null + ) { + if (!enabled.value) 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 target = resolveTarget(event) + + if (target === null) { + return + } + + // Ignore if the target doesn't exist in the DOM anymore + if (!target.ownerDocument.documentElement.contains(target)) return + + let _containers = (function resolve(containers): ContainerCollection { + if (typeof containers === 'function') { + return resolve(containers()) + } + + if (Array.isArray(containers)) { + return containers + } + + if (containers instanceof Set) { + return containers } - // This allows us to check whether the event was defaultPrevented when you are nesting this - // inside a `` 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 and click on another 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 [containers] + })(containers) + + // 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 : dom(container) + if (domNode?.contains(target)) { + return } + } + + // This allows us to check whether the event was defaultPrevented when you are nesting this + // inside a `` 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 and click on another 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), - 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 + ) }