diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index e40f5d50a..970b37e4c 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - 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)) ## [1.6.4] - 2022-05-29 diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 1e68c36cb..e00d55fdd 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -34,7 +34,7 @@ import { forwardRefWithAs, render, compact, PropsForFeatures, Features } from '. import { isDisabledReactIssue7711 } from '../../utils/bugs' import { match } from '../../utils/match' import { objectToFormEntries } from '../../utils/form' -import { sortByDomNode } from '../../utils/focus-management' +import { FocusableMode, isFocusableElement, sortByDomNode } from '../../utils/focus-management' import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed' @@ -415,11 +415,11 @@ let ComboboxRoot = forwardRefWithAs(function Combobox< }, [data]) // Handle outside click - useOutsideClick([data.buttonRef, data.inputRef, data.optionsRef], () => { - if (data.comboboxState !== ComboboxState.Open) return - - dispatch({ type: ActionTypes.CloseCombobox }) - }) + useOutsideClick( + [data.buttonRef, data.inputRef, data.optionsRef], + () => dispatch({ type: ActionTypes.CloseCombobox }), + data.comboboxState === ComboboxState.Open + ) let slot = useMemo>( () => ({ diff --git a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx index c59e3041c..21971b840 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx @@ -889,9 +889,11 @@ describe('Mouse interactions', () => { return (
- Contents - - + + Contents + + +
) diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index 8d4b42cec..351c28ad2 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -32,7 +32,7 @@ import { Description, useDescriptions } from '../description/description' import { useOpenClosed, State } from '../../internal/open-closed' import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete' import { StackProvider, StackMessage } from '../../internal/stack-context' -import { useOutsideClick, Features as OutsideClickFeatures } from '../../hooks/use-outside-click' +import { useOutsideClick } from '../../hooks/use-outside-click' import { getOwnerDocument } from '../../utils/owner' import { useOwnerDocument } from '../../hooks/use-owner' import { useEventListener } from '../../hooks/use-event-listener' @@ -100,13 +100,7 @@ let DEFAULT_DIALOG_TAG = 'div' as const interface DialogRenderPropArg { open: boolean } -type DialogPropsWeControl = - | 'id' - | 'role' - | 'aria-modal' - | 'aria-describedby' - | 'aria-labelledby' - | 'onClick' +type DialogPropsWeControl = 'id' | 'role' | 'aria-modal' | 'aria-describedby' | 'aria-labelledby' let DialogRenderFeatures = Features.RenderStrategy | Features.Static @@ -204,27 +198,22 @@ let DialogRoot = forwardRefWithAs(function Dialog< useOutsideClick( () => { // Third party roots - let rootContainers = Array.from(ownerDocument?.querySelectorAll('body > *') ?? []).filter( - (container) => { - if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements - if (container.contains(mainTreeNode.current)) return false // Skip if it is the main app - if (state.panelRef.current && container.contains(state.panelRef.current)) return false - return true // Keep - } - ) + let rootContainers = Array.from( + ownerDocument?.querySelectorAll('body > *, [data-headlessui-portal]') ?? [] + ).filter((container) => { + if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements + if (container.contains(mainTreeNode.current)) return false // Skip if it is the main app + if (state.panelRef.current && container.contains(state.panelRef.current)) return false + return true // Keep + }) return [ ...rootContainers, state.panelRef.current ?? internalDialogRef.current, ] as HTMLElement[] }, - () => { - if (dialogState !== DialogStates.Open) return - if (hasNestedDialogs) return - - close() - }, - OutsideClickFeatures.IgnoreScrollbars + close, + enabled && !hasNestedDialogs ) // Handle `Escape` to close @@ -311,9 +300,6 @@ let DialogRoot = forwardRefWithAs(function Dialog< 'aria-modal': dialogState === DialogStates.Open ? true : undefined, 'aria-labelledby': state.titleId, 'aria-describedby': describedby, - onClick(event: ReactMouseEvent) { - event.stopPropagation() - }, } return ( @@ -492,10 +478,17 @@ let Panel = forwardRefWithAs(function Panel elements even if we portalled the Dialog. + let handleClick = useEvent((event: ReactMouseEvent) => { + event.stopPropagation() + }) + let theirProps = props let ourProps = { ref: panelRef, id, + onClick: handleClick, } return render({ diff --git a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx index 0cdf662b6..bbab04b21 100644 --- a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx +++ b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx @@ -147,7 +147,10 @@ function useRestoreFocus({ ownerDocument }: { ownerDocument: Document | null }, useWatch(() => { if (enabled) return - focusElement(restoreElement.current) + if (ownerDocument?.activeElement === ownerDocument?.body) { + focusElement(restoreElement.current) + } + restoreElement.current = null }, [enabled]) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index b6643312d..1c1c6472f 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -396,16 +396,18 @@ let ListboxRoot = forwardRefWithAs(function Listbox< ) // Handle outside click - useOutsideClick([buttonRef, optionsRef], (event, target) => { - if (listboxState !== ListboxStates.Open) return - - dispatch({ type: ActionTypes.CloseListbox }) + useOutsideClick( + [buttonRef, optionsRef], + (event, target) => { + dispatch({ type: ActionTypes.CloseListbox }) - if (!isFocusableElement(target, FocusableMode.Loose)) { - event.preventDefault() - buttonRef.current?.focus() - } - }) + if (!isFocusableElement(target, FocusableMode.Loose)) { + event.preventDefault() + buttonRef.current?.focus() + } + }, + listboxState === ListboxStates.Open + ) let slot = useMemo( () => ({ open: listboxState === ListboxStates.Open, disabled }), diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index 1dae520d4..2c7e3d1ac 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -239,16 +239,18 @@ let MenuRoot = forwardRefWithAs(function Menu { - if (menuState !== MenuStates.Open) return - - dispatch({ type: ActionTypes.CloseMenu }) + useOutsideClick( + [buttonRef, itemsRef], + (event, target) => { + dispatch({ type: ActionTypes.CloseMenu }) - if (!isFocusableElement(target, FocusableMode.Loose)) { - event.preventDefault() - buttonRef.current?.focus() - } - }) + if (!isFocusableElement(target, FocusableMode.Loose)) { + event.preventDefault() + buttonRef.current?.focus() + } + }, + menuState === MenuStates.Open + ) let slot = useMemo( () => ({ open: menuState === MenuStates.Open }), @@ -344,7 +346,6 @@ let Button = forwardRefWithAs(function Button state.buttonRef.current?.focus({ preventScroll: true })) } else { event.preventDefault() - event.stopPropagation() dispatch({ type: ActionTypes.OpenMenu }) } }) diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index a1fa990ee..9d9314fb1 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -258,16 +258,18 @@ let PopoverRoot = forwardRefWithAs(function Popover< ) // Handle outside click - useOutsideClick([button, panel], (event, target) => { - if (popoverState !== PopoverStates.Open) return - - dispatch({ type: ActionTypes.ClosePopover }) + useOutsideClick( + [button, panel], + (event, target) => { + dispatch({ type: ActionTypes.ClosePopover }) - if (!isFocusableElement(target, FocusableMode.Loose)) { - event.preventDefault() - button?.focus() - } - }) + if (!isFocusableElement(target, FocusableMode.Loose)) { + event.preventDefault() + button?.focus() + } + }, + popoverState === PopoverStates.Open + ) let close = useEvent((focusableElement?: HTMLElement | MutableRefObject) => { dispatch({ type: ActionTypes.ClosePopover }) diff --git a/packages/@headlessui-react/src/components/portal/portal.test.tsx b/packages/@headlessui-react/src/components/portal/portal.test.tsx index 6b21ea7a5..d434dd608 100644 --- a/packages/@headlessui-react/src/components/portal/portal.test.tsx +++ b/packages/@headlessui-react/src/components/portal/portal.test.tsx @@ -299,6 +299,6 @@ it('should be possible to force the Portal into a specific element using Portal. render() expect(document.body.innerHTML).toMatchInlineSnapshot( - `"
B
I am in the portal root
"` + `"
B
I am in the portal root
"` ) }) diff --git a/packages/@headlessui-react/src/components/portal/portal.tsx b/packages/@headlessui-react/src/components/portal/portal.tsx index c6b55d817..86bdba171 100644 --- a/packages/@headlessui-react/src/components/portal/portal.tsx +++ b/packages/@headlessui-react/src/components/portal/portal.tsx @@ -95,6 +95,7 @@ let PortalRoot = forwardRefWithAs(function Portal< // Element already exists in target, always calling target.appendChild(element) will cause a // brief unmount/remount. if (!target.contains(element)) { + element.setAttribute('data-headlessui-portal', '') target.appendChild(element) } diff --git a/packages/@headlessui-react/src/hooks/use-outside-click.ts b/packages/@headlessui-react/src/hooks/use-outside-click.ts index 8cef785e0..3b48fc40f 100644 --- a/packages/@headlessui-react/src/hooks/use-outside-click.ts +++ b/packages/@headlessui-react/src/hooks/use-outside-click.ts @@ -1,82 +1,93 @@ -import { MutableRefObject, useRef } from 'react' -import { microTask } from '../utils/micro-task' -import { useEvent } from './use-event' +import { MutableRefObject, useEffect, useRef } from 'react' +import { FocusableMode, isFocusableElement } from '../utils/focus-management' import { useWindowEvent } from './use-window-event' type Container = MutableRefObject | HTMLElement | null type ContainerCollection = Container[] | Set type ContainerInput = Container | ContainerCollection -export enum Features { - None = 1 << 0, - IgnoreScrollbars = 1 << 1, -} - export function useOutsideClick( containers: ContainerInput | (() => ContainerInput), cb: (event: MouseEvent | PointerEvent, target: HTMLElement) => void, - features: Features = Features.None + enabled: boolean = true ) { - let called = useRef(false) - let handler = useEvent((event: MouseEvent | PointerEvent) => { - if (called.current) return - called.current = true - microTask(() => { - called.current = false - }) + // TODO: remove this once the React bug has been fixed: https://github.com/facebook/react/issues/24657 + let enabledRef = useRef(false) + useEffect( + process.env.NODE_ENV === 'test' + ? () => { + enabledRef.current = enabled + } + : () => { + requestAnimationFrame(() => { + enabledRef.current = enabled + }) + }, + [enabled] + ) - let _containers = (function resolve(containers): ContainerCollection { - if (typeof containers === 'function') { - return resolve(containers()) - } + useWindowEvent( + 'click', + (event) => { + if (!enabledRef.current) return - if (Array.isArray(containers)) { - return containers - } + // 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 - if (containers instanceof Set) { - return containers - } + let _containers = (function resolve(containers): ContainerCollection { + if (typeof containers === 'function') { + return resolve(containers()) + } - return [containers] - })(containers) + if (Array.isArray(containers)) { + return containers + } - let target = event.target as HTMLElement + if (containers instanceof Set) { + return containers + } - // Ignore if the target doesn't exist in the DOM anymore - if (!target.ownerDocument.documentElement.contains(target)) return + return [containers] + })(containers) - // Ignore scrollbars: - // This is a bit hacky, and is only necessary because we are checking for `pointerdown` and - // `mousedown` events. They _are_ being called if you click on a scrollbar. The `click` event - // is not called when clicking on a scrollbar, but we can't use that otherwise it won't work - // on mobile devices where only pointer events are being used. - if ((features & Features.IgnoreScrollbars) === Features.IgnoreScrollbars) { - // TODO: We can calculate this dynamically~is. On macOS if you have the "Automatically based - // on mouse or trackpad" setting enabled, then the scrollbar will float on top and therefore - // you can't calculate its with by checking the clientWidth and scrollWidth of the element. - // Therefore we are currently hardcoding this to be 20px. - let scrollbarWidth = 20 + let target = event.target as HTMLElement - let viewport = target.ownerDocument.documentElement - if (event.clientX > viewport.clientWidth - scrollbarWidth) return - if (event.clientX < scrollbarWidth) return - if (event.clientY > viewport.clientHeight - scrollbarWidth) return - if (event.clientY < scrollbarWidth) return - } + // Ignore if the target doesn't exist in the DOM anymore + if (!target.ownerDocument.documentElement.contains(target)) 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 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 + } } - } - return cb(event, target) - }) + // 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() + } - useWindowEvent('pointerdown', handler) - useWindowEvent('mousedown', handler) + 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 + ) } diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index 4c6b3d963..3eb8a3064 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - 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)) ## [1.6.4] - 2022-05-29 diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index 76bb0c425..4bdb9c193 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -396,10 +396,11 @@ export let Combobox = defineComponent({ } // Handle outside click - useOutsideClick([inputRef, buttonRef, optionsRef], () => { - if (comboboxState.value !== ComboboxStates.Open) return - api.closeCombobox() - }) + useOutsideClick( + [inputRef, buttonRef, optionsRef], + () => api.closeCombobox(), + computed(() => comboboxState.value === ComboboxStates.Open) + ) watch([api.value, api.inputRef], () => api.syncInputValue(), { immediate: true, diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.ts b/packages/@headlessui-vue/src/components/dialog/dialog.ts index 0845d073f..dd1b24c7f 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.ts @@ -29,7 +29,7 @@ import { ForcePortalRoot } from '../../internal/portal-force-root' import { Description, useDescriptions } from '../description/description' import { dom } from '../../utils/dom' import { useOpenClosed, State } from '../../internal/open-closed' -import { useOutsideClick, Features as OutsideClickFeatures } from '../../hooks/use-outside-click' +import { useOutsideClick } from '../../hooks/use-outside-click' import { getOwnerDocument } from '../../utils/owner' import { useEventListener } from '../../hooks/use-event-listener' import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' @@ -179,7 +179,7 @@ export let Dialog = defineComponent({ () => { // Third party roots let rootContainers = Array.from( - ownerDocument.value?.querySelectorAll('body > *') ?? [] + ownerDocument.value?.querySelectorAll('body > *, [data-headlessui-portal]') ?? [] ).filter((container) => { if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements if (container.contains(dom(mainTreeNode))) return false // Skip if it is the main app @@ -191,13 +191,10 @@ export let Dialog = defineComponent({ }, (_event, target) => { - if (dialogState.value !== DialogStates.Open) return - if (hasNestedDialogs.value) return - api.close() nextTick(() => target?.focus()) }, - OutsideClickFeatures.IgnoreScrollbars + computed(() => dialogState.value === DialogStates.Open && !hasNestedDialogs.value) ) // Handle `Escape` to close @@ -264,10 +261,6 @@ export let Dialog = defineComponent({ onInvalidate(() => observer.disconnect()) }) - function handleClick(event: MouseEvent) { - event.stopPropagation() - } - return () => { let ourProps = { // Manually passthrough the attributes, because Vue can't automatically pass @@ -279,7 +272,6 @@ export let Dialog = defineComponent({ 'aria-modal': dialogState.value === DialogStates.Open ? true : undefined, 'aria-labelledby': titleId.value, 'aria-describedby': describedby.value, - onClick: handleClick, } let { open: _, initialFocus, ...incomingProps } = props @@ -417,10 +409,15 @@ export let DialogPanel = defineComponent({ expose({ el: api.panelRef, $el: api.panelRef }) + function handleClick(event: MouseEvent) { + event.stopPropagation() + } + return () => { let ourProps = { id, ref: api.panelRef, + onClick: handleClick, } let incomingProps = props diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts index b3a123ab3..5bd9062a1 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.ts +++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts @@ -298,16 +298,18 @@ export let Listbox = defineComponent({ } // Handle outside click - useOutsideClick([buttonRef, optionsRef], (event, target) => { - if (listboxState.value !== ListboxStates.Open) return - - api.closeListbox() + useOutsideClick( + [buttonRef, optionsRef], + (event, target) => { + api.closeListbox() - if (!isFocusableElement(target, FocusableMode.Loose)) { - event.preventDefault() - dom(buttonRef)?.focus() - } - }) + if (!isFocusableElement(target, FocusableMode.Loose)) { + event.preventDefault() + dom(buttonRef)?.focus() + } + }, + computed(() => listboxState.value === ListboxStates.Open) + ) // @ts-expect-error Types of property 'dataRef' are incompatible. provide(ListboxContext, api) diff --git a/packages/@headlessui-vue/src/components/menu/menu.ts b/packages/@headlessui-vue/src/components/menu/menu.ts index e34a83146..4a4a54322 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.ts +++ b/packages/@headlessui-vue/src/components/menu/menu.ts @@ -201,16 +201,18 @@ export let Menu = defineComponent({ } // Handle outside click - useOutsideClick([buttonRef, itemsRef], (event, target) => { - if (menuState.value !== MenuStates.Open) return - - api.closeMenu() + useOutsideClick( + [buttonRef, itemsRef], + (event, target) => { + api.closeMenu() - if (!isFocusableElement(target, FocusableMode.Loose)) { - event.preventDefault() - dom(buttonRef)?.focus() - } - }) + if (!isFocusableElement(target, FocusableMode.Loose)) { + event.preventDefault() + dom(buttonRef)?.focus() + } + }, + computed(() => menuState.value === MenuStates.Open) + ) // @ts-expect-error Types of property 'dataRef' are incompatible. provide(MenuContext, api) @@ -289,7 +291,6 @@ export let MenuButton = defineComponent({ nextTick(() => dom(api.buttonRef)?.focus({ preventScroll: true })) } else { event.preventDefault() - event.stopPropagation() api.openMenu() nextFrame(() => dom(api.itemsRef)?.focus({ preventScroll: true })) } diff --git a/packages/@headlessui-vue/src/components/popover/popover.ts b/packages/@headlessui-vue/src/components/popover/popover.ts index fc9a26f21..d79f64587 100644 --- a/packages/@headlessui-vue/src/components/popover/popover.ts +++ b/packages/@headlessui-vue/src/components/popover/popover.ts @@ -211,16 +211,18 @@ export let Popover = defineComponent({ ) // Handle outside click - useOutsideClick([button, panel], (event, target) => { - if (popoverState.value !== PopoverStates.Open) return - - api.closePopover() + useOutsideClick( + [button, panel], + (event, target) => { + api.closePopover() - if (!isFocusableElement(target, FocusableMode.Loose)) { - event.preventDefault() - dom(button)?.focus() - } - }) + if (!isFocusableElement(target, FocusableMode.Loose)) { + event.preventDefault() + dom(button)?.focus() + } + }, + computed(() => popoverState.value === PopoverStates.Open) + ) return () => { let slot = { open: popoverState.value === PopoverStates.Open, close: api.close } diff --git a/packages/@headlessui-vue/src/components/portal/portal.test.ts b/packages/@headlessui-vue/src/components/portal/portal.test.ts index ff8f2c942..0425e4588 100644 --- a/packages/@headlessui-vue/src/components/portal/portal.test.ts +++ b/packages/@headlessui-vue/src/components/portal/portal.test.ts @@ -389,6 +389,6 @@ it('should be possible to force the Portal into a specific element using PortalG await new Promise(nextTick) expect(document.body.innerHTML).toMatchInlineSnapshot( - `"
B
"` + `"
B
"` ) }) diff --git a/packages/@headlessui-vue/src/components/portal/portal.ts b/packages/@headlessui-vue/src/components/portal/portal.ts index 3ee164d0b..fc8e1aabe 100644 --- a/packages/@headlessui-vue/src/components/portal/portal.ts +++ b/packages/@headlessui-vue/src/components/portal/portal.ts @@ -79,6 +79,7 @@ export let Portal = defineComponent({ let ourProps = { ref: element, + 'data-headlessui-portal': '', } return h( diff --git a/packages/@headlessui-vue/src/hooks/use-outside-click.ts b/packages/@headlessui-vue/src/hooks/use-outside-click.ts index fc2891b7a..562e81d69 100644 --- a/packages/@headlessui-vue/src/hooks/use-outside-click.ts +++ b/packages/@headlessui-vue/src/hooks/use-outside-click.ts @@ -1,82 +1,79 @@ import { useWindowEvent } from './use-window-event' -import { Ref } from 'vue' +import { computed, Ref, ComputedRef } from 'vue' +import { FocusableMode, isFocusableElement } from '../utils/focus-management' import { dom } from '../utils/dom' -import { microTask } from '../utils/micro-task' type Container = Ref | HTMLElement | null type ContainerCollection = Container[] | Set type ContainerInput = Container | ContainerCollection -export enum Features { - None = 1 << 0, - IgnoreScrollbars = 1 << 1, -} - export function useOutsideClick( containers: ContainerInput | (() => ContainerInput), cb: (event: MouseEvent | PointerEvent, target: HTMLElement) => void, - features: Features = Features.None + enabled: ComputedRef = computed(() => true) ) { - let called = false - function handle(event: MouseEvent | PointerEvent) { - if (called) return - called = true - microTask(() => { - called = false - }) + useWindowEvent( + 'click', + (event) => { + if (!enabled.value) return - let target = event.target as HTMLElement + // 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 - // Ignore if the target doesn't exist in the DOM anymore - if (!target.ownerDocument.documentElement.contains(target)) return + let target = event.target as HTMLElement - let _containers = (function resolve(containers): ContainerCollection { - if (typeof containers === 'function') { - return resolve(containers()) - } + // Ignore if the target doesn't exist in the DOM anymore + if (!target.ownerDocument.documentElement.contains(target)) return - if (Array.isArray(containers)) { - return containers - } + let _containers = (function resolve(containers): ContainerCollection { + if (typeof containers === 'function') { + return resolve(containers()) + } - if (containers instanceof Set) { - return containers - } - - return [containers] - })(containers) + if (Array.isArray(containers)) { + return containers + } - // Ignore scrollbars: - // This is a bit hacky, and is only necessary because we are checking for `pointerdown` and - // `mousedown` events. They _are_ being called if you click on a scrollbar. The `click` event - // is not called when clicking on a scrollbar, but we can't use that otherwise it won't work - // on mobile devices where only pointer events are being used. - if ((features & Features.IgnoreScrollbars) === Features.IgnoreScrollbars) { - // TODO: We can calculate this dynamically~is. On macOS if you have the "Automatically based - // on mouse or trackpad" setting enabled, then the scrollbar will float on top and therefore - // you can't calculate its with by checking the clientWidth and scrollWidth of the element. - // Therefore we are currently hardcoding this to be 20px. - let scrollbarWidth = 20 + if (containers instanceof Set) { + return containers + } - let viewport = target.ownerDocument.documentElement - if (event.clientX > viewport.clientWidth - scrollbarWidth) return - if (event.clientX < scrollbarWidth) return - if (event.clientY > viewport.clientHeight - scrollbarWidth) return - if (event.clientY < scrollbarWidth) return - } + 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 + // 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 + } } - } - cb(event, target) - } + // 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() + } - useWindowEvent('pointerdown', handle) - useWindowEvent('mousedown', handle) + 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 + ) } diff --git a/packages/playground-react/package.json b/packages/playground-react/package.json index bd8d99a1b..bcd4afebc 100644 --- a/packages/playground-react/package.json +++ b/packages/playground-react/package.json @@ -16,6 +16,7 @@ "dependencies": { "@headlessui/react": "*", "@headlessui/tailwindcss": "*", + "@heroicons/react": "^1.0.6", "@popperjs/core": "^2.6.0", "@tailwindcss/forms": "^0.5.2", "@tailwindcss/typography": "^0.5.2", diff --git a/packages/playground-react/pages/combinations/tabs-in-dialog.tsx b/packages/playground-react/pages/combinations/tabs-in-dialog.tsx index bca77d568..1bf057c9c 100644 --- a/packages/playground-react/pages/combinations/tabs-in-dialog.tsx +++ b/packages/playground-react/pages/combinations/tabs-in-dialog.tsx @@ -8,8 +8,8 @@ export default function App() { <> - -
+
+
@@ -24,7 +24,7 @@ export default function App() {
-
+
) diff --git a/packages/playground-react/pages/dialog/dialog.tsx b/packages/playground-react/pages/dialog/dialog.tsx index 9e4f25f32..527484f2f 100644 --- a/packages/playground-react/pages/dialog/dialog.tsx +++ b/packages/playground-react/pages/dialog/dialog.tsx @@ -85,7 +85,12 @@ export default function Home() { beforeLeave={() => console.log('[Transition] Before leave')} afterLeave={() => console.log('[Transition] After leave')} > - + { + console.log('close') + setIsOpen(false) + }} + >
console.log('[Transition.Child] [Overlay] Before leave')} afterLeave={() => console.log('[Transition.Child] [Overlay] After leave')} > - +
​ -
+
@@ -254,7 +259,7 @@ export default function Home() { Cancel
-
+
diff --git a/packages/playground-react/pages/dialog/scrollable-dialog.tsx b/packages/playground-react/pages/dialog/scrollable-dialog.tsx new file mode 100644 index 000000000..aa382e673 --- /dev/null +++ b/packages/playground-react/pages/dialog/scrollable-dialog.tsx @@ -0,0 +1,98 @@ +import { Fragment, useRef, useState } from 'react' +import { Dialog, Transition } from '@headlessui/react' +import { ExclamationIcon } from '@heroicons/react/outline' + +export default function Example() { + const [open, setOpen] = useState(false) + + const cancelButtonRef = useRef(null) + + return ( + <> +
+ +
+ + + +
+ + +
+
+ + +
+
+
+
+ + Deactivate account + +
+

+ Are you sure you want to deactivate your account? All of your data will be + permanently removed from our servers forever. This action cannot be + undone. +

+ {Array(20) + .fill(null) + .map((_, i) => ( +

+ Are you sure you want to deactivate your account? All of your data + will be permanently removed from our servers forever. This action + cannot be undone. +

+ ))} +
+
+
+
+ + +
+
+
+
+
+
+
+ + ) +} diff --git a/yarn.lock b/yarn.lock index 7aa92138f..5bf11e631 100644 --- a/yarn.lock +++ b/yarn.lock @@ -326,6 +326,11 @@ resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb" integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw== +"@heroicons/react@^1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-1.0.6.tgz#35dd26987228b39ef2316db3b1245c42eb19e324" + integrity sha512-JJCXydOFWMDpCP4q13iEplA503MQO3xLoZiKum+955ZCtHINWnx26CUxVxxFQu/uLb4LW3ge15ZpzIkXKkJ8oQ== + "@heroicons/vue@^1.0.6": version "1.0.6" resolved "https://registry.yarnpkg.com/@heroicons/vue/-/vue-1.0.6.tgz#d8b90734b436eb5a87f40cc300b64a0fb0031f7f"