Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix incorrect closing while interacting with third party libraries in Dialog component #1268

Merged
merged 3 commits into from
Mar 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Stop propagation on the Popover Button ([#1263](https://github.com/tailwindlabs/headlessui/pull/1263))
- Fix incorrect `active` option in the Listbox/Combobox component ([#1264](https://github.com/tailwindlabs/headlessui/pull/1264))
- Properly merge incoming props ([#1265](https://github.com/tailwindlabs/headlessui/pull/1265))
- Fix incorrect closing while interacting with third party libraries in `Dialog` component ([#1268](https://github.com/tailwindlabs/headlessui/pull/1268))

### Added

Expand Down Expand Up @@ -64,6 +65,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improve Combobox Input value ([#1248](https://github.com/tailwindlabs/headlessui/pull/1248))
- Fix Tree-shaking support ([#1247](https://github.com/tailwindlabs/headlessui/pull/1247))
- Stop propagation on the Popover Button ([#1263](https://github.com/tailwindlabs/headlessui/pull/1263))
- Fix incorrect closing while interacting with third party libraries in `Dialog` component ([#1268](https://github.com/tailwindlabs/headlessui/pull/1268))

### Added

Expand Down
48 changes: 48 additions & 0 deletions packages/@headlessui-react/src/components/dialog/dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { click, press, Keys } from '../../test-utils/interactions'
import { PropsOf } from '../../types'
import { Transition } from '../transitions/transition'
import { createPortal } from 'react-dom'

jest.mock('../../hooks/use-id')

Expand Down Expand Up @@ -843,6 +844,53 @@ describe('Mouse interactions', () => {
assertDialog({ state: DialogState.Visible })
})
)

it(
'should be possible to click on elements created by third party libraries',
suppressConsoleLogs(async () => {
let fn = jest.fn()
function ThirdPartyLibrary() {
return createPortal(
<>
<button data-lib onClick={fn}>
3rd party button
</button>
</>,
document.body
)
}

function Example() {
let [isOpen, setIsOpen] = useState(true)

return (
<div>
<span>Main app</span>
<Dialog open={isOpen} onClose={setIsOpen}>
<div>
Contents
<TabSentinel />
</div>
</Dialog>
<ThirdPartyLibrary />
</div>
)
}
render(<Example />)

// Verify it is open
assertDialog({ state: DialogState.Visible })

// Click the button inside the 3rd party library
await click(document.querySelector('[data-lib]'))

// Verify we clicked on the 3rd party button
expect(fn).toHaveBeenCalledTimes(1)

// Verify the dialog is still open
assertDialog({ state: DialogState.Visible })
})
)
})

describe('Nesting', () => {
Expand Down
26 changes: 20 additions & 6 deletions packages/@headlessui-react/src/components/dialog/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
// in between. We only care abou whether you are the top most one or not.
let position = !hasNestedDialogs ? 'leaf' : 'parent'

useFocusTrap(
let previousElement = useFocusTrap(
internalDialogRef,
enabled
? match(position, {
Expand All @@ -213,12 +213,26 @@ let DialogRoot = forwardRefWithAs(function Dialog<
useInertOthers(internalDialogRef, hasNestedDialogs ? enabled : false)

// Handle outside click
useOutsideClick(internalDialogRef, () => {
if (dialogState !== DialogStates.Open) return
if (hasNestedDialogs) return
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(previousElement.current)) return false // Skip if it is the main app
return true // Keep
}
)

close()
})
return [...rootContainers, internalDialogRef.current] as HTMLElement[]
},
() => {
if (dialogState !== DialogStates.Open) return
if (hasNestedDialogs) return

close()
}
)

// Handle `Escape` to close
useEventListener(ownerDocument?.defaultView, 'keydown', (event) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/@headlessui-react/src/hooks/use-focus-trap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ export function useFocusTrap(
},
true
)

return restoreElement
}

function contains(containers: Set<MutableRefObject<HTMLElement | null>>, element: HTMLElement) {
Expand Down
38 changes: 21 additions & 17 deletions packages/@headlessui-react/src/hooks/use-outside-click.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,14 @@ function microTask(cb: () => void) {
}
}

type Container = MutableRefObject<HTMLElement | null> | HTMLElement | null
type ContainerCollection = Container[] | Set<Container>
type ContainerInput = Container | ContainerCollection

export function useOutsideClick(
containers:
| HTMLElement
| MutableRefObject<HTMLElement | null>
| (MutableRefObject<HTMLElement | null> | HTMLElement | null)[]
| Set<HTMLElement>,
containers: ContainerInput | (() => ContainerInput),
cb: (event: MouseEvent | PointerEvent, target: HTMLElement) => void
) {
let _containers = useMemo(() => {
if (Array.isArray(containers)) {
return containers
}

if (containers instanceof Set) {
return containers
}

return [containers]
}, [containers])

let called = useRef(false)
let handler = useLatestValue((event: MouseEvent | PointerEvent) => {
if (called.current) return
Expand All @@ -45,6 +33,22 @@ export function useOutsideClick(
called.current = false
})

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)

let target = event.target as HTMLElement

// Ignore if the target doesn't exist in the DOM anymore
Expand Down
53 changes: 53 additions & 0 deletions packages/@headlessui-vue/src/components/dialog/dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1035,6 +1035,59 @@ describe('Mouse interactions', () => {
assertDialog({ state: DialogState.Visible })
})
)

it(
'should be possible to click on elements created by third party libraries',
suppressConsoleLogs(async () => {
let fn = jest.fn()

let ThirdPartyLibrary = defineComponent({
template: html`
<teleport to="body">
<button data-lib @click="fn">3rd party button</button>
</teleport>
`,
setup: () => ({ fn }),
})

renderTemplate({
components: { ThirdPartyLibrary },
template: `
<div>
<span>Main app</span>
<Dialog :open="isOpen" @close="setIsOpen">
<div>
Contents
<TabSentinel />
</div>
</Dialog>
<ThirdPartyLibrary />

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My issue in #432 was with third-party components inside a Dialog, it seems like this test doesn't do that? Maybe I'm misunderstanding. I would have expected something like:

          <div>
            <span>Main app</span>
            <Dialog :open="isOpen" @close="setIsOpen">
              <div>
                <ThirdPartyLibrary />
                <TabSentinel />
              </div>
            </Dialog>
          </div>

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yep I should probably add a test for that case as well. But the way it is implemented it doesn't really matter where the 3rd party gets initiated from. I'll explain the process in the other question you asked 👍

</div>
`,
setup() {
let isOpen = ref(true)
return {
isOpen,
setIsOpen(value: boolean) {
isOpen.value = value
},
}
},
})

// Verify it is open
assertDialog({ state: DialogState.Visible })

// Click the button inside the 3rd party library
await click(document.querySelector('[data-lib]'))

// Verify we clicked on the 3rd party button
expect(fn).toHaveBeenCalledTimes(1)

// Verify the dialog is still open
assertDialog({ state: DialogState.Visible })
})
)
})

describe('Nesting', () => {
Expand Down
29 changes: 22 additions & 7 deletions packages/@headlessui-vue/src/components/dialog/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export let Dialog = defineComponent({
// in between. We only care abou whether you are the top most one or not.
let position = computed(() => (!hasNestedDialogs.value ? 'leaf' : 'parent'))

useFocusTrap(
let previousElement = useFocusTrap(
internalDialogRef,
computed(() => {
return enabled.value
Expand Down Expand Up @@ -191,13 +191,28 @@ export let Dialog = defineComponent({
provide(DialogContext, api)

// Handle outside click
useOutsideClick(internalDialogRef, (_event, target) => {
if (dialogState.value !== DialogStates.Open) return
if (hasNestedDialogs.value) return
useOutsideClick(
() => {
// Third party roots
let rootContainers = Array.from(
ownerDocument.value?.querySelectorAll('body > *') ?? []

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't completely understand what's happening here but I'm curious whether this fix works with elements that are not direct children of the body tag? What about clicks on an element nested inside a few others?

Copy link
Member Author

@RobinMalfait RobinMalfait Mar 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright so the idea with this code is to allow for 3rd party plugins.

We don't allow to interact with elements behind the Dialog component for accessibility reasons and what not. We still don't allow that, but there are a few exceptions that technically violate some of the rules but you can think of it as progressive enhancement.

We already allowed our Portal component to be used inside the Dialog (and nested Dialogs). The HTML looks something like this:

<body>
  <div id="app"><!-- Your main application --></div>
  <div id="headlessui-portal-root">
    <div><!-- The main Dialog --></div>
    <div><!-- ... other Portal components --></div>
  </div>
</body>

What you will notice is that the other portal components live outside the main Dialog, so technically we should close the Dialog if you click on any of those items because they are "outside" the Dialog component.

The reason this is already possible with our provided Portal is because we control that component and we can have a reference to the elements it renders in the DOM.

The issue this PR fixes is with 3rd party plugins. Often 3rd party plugins will render the popup elements in a portal as well. The problem is that we don't know when this happens and we also can't get a DOM reference easily to those elements. We also can't ask every library on planet earth to expose some of the information we need.

So this fix is definitely not perfect, but I think it will solve a lot of the issues people experience today.

How does it work?

Let's imagine you have this structure again:

<body>
  <div id="app">
    <div>
      <!--
        This is an example button in the App that is **not** inside the Dialog.
        Trying to click this button will close the Dialog because this is
        "outside" of the Dialog which is not allowed.
      -->
      <button>in app</button>
    </div>
  </div>
  <div id="headlessui-portal-root">
    <div>
      <!-- This is a button in the Dialog, interacting with this is allowed. -->
      <button>in dialog</button>
    </div>
  </div>
  <div id="third-party-library-portal">
    <!--
      Interacting with this button is technically not allowed since it lives
      outside of the Dialog. However we make an exception that you _can_
      interact with this one because it lives in another "parent" than the main
      application. This means that we are currently making an assumption that
      interacteable elements that live in a parent outside of the main app are allowed.

      This trade-off is necessary since we don't know when 3rd party libraries
      will render certain elements in the DOM and we don't get a stable
      reference to those elements.
    -->
    <button>Deeply nested button inside the 3rd party library</button>
  </div>
</body>

How it works is that we collect all the direct "root" containers, that's what the body > * is for. In this case we will get 3 elements:

  • <div id="app">
  • <div id="headlessui-portal-root">
  • <div id="third-party-library-portal">

  • Then we filter out the roots we know about, in this case we don't allow interactions in the <div id="app"> because that would violate our initial rule of "don't interact with elements outside of the Dialog".
  • Next, we still run the outside click logic, this function gives us a target which is the target element that we are clicking.
  • Last but not least we check if one of those root containers contains the target. The contains function checks whether one element is contained within another (also does check deeply nested).
  • This means that:
    • If the target is contained inside the <div id="app"> then we should run the outside click callback which in this case closes the Dialog
    • But if the target is contained inside the <div id="third-party-library-portal"> then that's fine because we assume that this is the 3rd party library.

Does that answer your question?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow yeah that does answer my question, thanks! I really appreciate the detailed explanation. At one point I had thought it would be easier to just listen for clicks directly on the backdrop, but now I'm realizing there doesn't even necessarily have to be a backdrop... not to mention all the other possibilities. This is way more complicated than I thought and your solution looks great. Thanks for breaking it down 👍🏻

).filter((container) => {
if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements
if (container.contains(previousElement.value)) return false // Skip if it is the main app
return true // Keep
})

api.close()
nextTick(() => target?.focus())
})
return [...rootContainers, internalDialogRef.value] as HTMLElement[]
},

(_event, target) => {
if (dialogState.value !== DialogStates.Open) return
if (hasNestedDialogs.value) return

api.close()
nextTick(() => target?.focus())
}
)

// Handle `Escape` to close
useEventListener(ownerDocument.value?.defaultView, 'keydown', (event) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/@headlessui-vue/src/hooks/use-focus-trap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ export function useFocusTrap(
},
true
)

return restoreElement
}

function contains(containers: Set<Ref<HTMLElement | null>>, element: HTMLElement) {
Expand Down
18 changes: 11 additions & 7 deletions packages/@headlessui-vue/src/hooks/use-outside-click.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ function microTask(cb: () => void) {
}
}

type Container = Ref<HTMLElement | null> | HTMLElement | null
type ContainerCollection = Container[] | Set<Container>
type ContainerInput = Container | ContainerCollection

export function useOutsideClick(
containers:
| HTMLElement
| Ref<HTMLElement | null>
| (Ref<HTMLElement | null> | HTMLElement | null)[]
| Set<HTMLElement>,
containers: ContainerInput | (() => ContainerInput),
cb: (event: MouseEvent | PointerEvent, target: HTMLElement) => void
) {
let called = false
Expand All @@ -38,7 +38,11 @@ export function useOutsideClick(
// Ignore if the target doesn't exist in the DOM anymore
if (!target.ownerDocument.documentElement.contains(target)) return

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

if (Array.isArray(containers)) {
return containers
}
Expand All @@ -48,7 +52,7 @@ export function useOutsideClick(
}

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

// Ignore if the target exists in one of the containers
for (let container of _containers) {
Expand Down
3 changes: 2 additions & 1 deletion packages/playground-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"framer-motion": "^6.0.0",
"next": "^12.0.8",
"react": "16.14.0",
"react-dom": "16.14.0"
"react-dom": "16.14.0",
"react-flatpickr": "^3.10.9"
}
}
6 changes: 6 additions & 0 deletions packages/playground-react/pages/dialog/dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import React, { useState, Fragment } from 'react'
import Flatpickr from 'react-flatpickr'
import { Dialog, Menu, Portal, Transition } from '@headlessui/react'
import { usePopper } from '../../utils/hooks/use-popper'
import { classNames } from '../../utils/class-names'

import 'flatpickr/dist/themes/light.css'

function resolveClass({ active, disabled }) {
return classNames(
'flex justify-between w-full px-4 py-2 text-sm leading-5 text-left',
Expand Down Expand Up @@ -53,6 +56,8 @@ export default function Home() {
modifiers: [{ name: 'offset', options: { offset: [0, 10] } }],
})

let [date, setDate] = useState(new Date())

return (
<>
<button
Expand Down Expand Up @@ -207,6 +212,7 @@ export default function Home() {
</Transition>
</Menu>
</div>
<Flatpickr value={date} onChange={([date]) => setDate(date)} />
</div>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions packages/playground-vue/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"dependencies": {
"@headlessui/vue": "*",
"vue": "^3.2.27",
"vue-flatpickr-component": "^9.0.5",
"vue-router": "^4.0.0"
},
"devDependencies": {
Expand Down
Loading