Skip to content

Commit

Permalink
Allow clicks inside dialog panel when target is inside shadow root (#…
Browse files Browse the repository at this point in the history
…2079)

* Allow clicks inside dialog panel when target is inside shadow root

* fixup

* Update changelog
  • Loading branch information
thecrypticace committed Dec 8, 2022
1 parent 2e941f8 commit 724ee37
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 1 deletion.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Expand Up @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Allow passing in your own `id` prop ([#2060](https://github.com/tailwindlabs/headlessui/pull/2060))
- Fix `Dialog` unmounting problem due to incorrect `transitioncancel` event in the `Transition` component on Android ([#2071](https://github.com/tailwindlabs/headlessui/pull/2071))
- Ignore pointer events in Listbox, Menu, and Combobox when cursor hasn't moved ([#2069](https://github.com/tailwindlabs/headlessui/pull/2069))
- Allow clicks inside dialog panel when target is inside shadow root ([#2079](https://github.com/tailwindlabs/headlessui/pull/2079))

## [1.7.4] - 2022-11-03

Expand Down
92 changes: 91 additions & 1 deletion packages/@headlessui-react/src/components/dialog/dialog.test.tsx
@@ -1,4 +1,4 @@
import React, { createElement, useRef, useState, Fragment } from 'react'
import React, { createElement, useRef, useState, Fragment, useEffect } from 'react'
import { render } from '@testing-library/react'

import { Dialog } from './dialog'
Expand Down Expand Up @@ -1144,6 +1144,96 @@ describe('Mouse interactions', () => {
})
)

it(
'should be possible to click elements inside the dialog when they reside inside a shadow boundary',
suppressConsoleLogs(async () => {
let fn = jest.fn()
function ShadowChildren({ id, buttonId }: { id: string; buttonId: string }) {
let container = useRef<HTMLDivElement | null>(null)

useEffect(() => {
if (!container.current || container.current.shadowRoot) {
return
}

let shadowRoot = container.current.attachShadow({ mode: 'open' })
let button = document.createElement('button')
button.id = buttonId
button.textContent = 'Inside shadow root'
button.addEventListener('click', fn)
shadowRoot.appendChild(button)
}, [])

return <div id={id} ref={container}></div>
}

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

return (
<div>
<button onClick={() => setIsOpen(true)}>open</button>
<Dialog open={isOpen} onClose={() => setIsOpen(false)}>
<div>
<button id="btn_outside_light" onClick={fn}>
Button
</button>
<ShadowChildren id="outside_shadow" buttonId="btn_outside_shadow" />
</div>
<Dialog.Panel>
<button id="btn_inside_light" onClick={fn}>
Button
</button>
<ShadowChildren id="inside_shadow" buttonId="btn_inside_shadow" />
</Dialog.Panel>
</Dialog>
</div>
)
}

render(<Example />)

await nextFrame()

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

// Click the button inside the dialog (light DOM)
await click(document.querySelector('#btn_inside_light'))

// Verify the button was clicked
expect(fn).toHaveBeenCalledTimes(1)

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

// Click the button inside the dialog (shadow DOM)
await click(
document.querySelector('#inside_shadow')?.shadowRoot?.querySelector('#btn_inside_shadow') ??
null
)

// Verify the button was clicked
expect(fn).toHaveBeenCalledTimes(2)

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

// Click the button outside the dialog (shadow DOM)
await click(
document
.querySelector('#outside_shadow')
?.shadowRoot?.querySelector('#btn_outside_shadow') ?? null
)

// Verify the button was clicked
expect(fn).toHaveBeenCalledTimes(3)

// Verify the dialog is closed
assertDialog({ state: DialogState.InvisibleUnmounted })
})
)

it(
'should close the Dialog if we click outside the Dialog.Panel',
suppressConsoleLogs(async () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/@headlessui-react/src/hooks/use-outside-click.ts
Expand Up @@ -69,6 +69,12 @@ export function useOutsideClick(
if (domNode?.contains(target)) {
return
}

// If the click crossed a shadow boundary, we need to check if the container
// is inside the tree by using `composedPath` to "pierce" the shadow boundary
if (event.composed && event.composedPath().includes(domNode as EventTarget)) {
return
}
}

// This allows us to check whether the event was defaultPrevented when you are nesting this
Expand Down
1 change: 1 addition & 0 deletions packages/@headlessui-vue/CHANGELOG.md
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add `null` as a valid type for Listbox and Combobox in Vue ([#2064](https://github.com/tailwindlabs/headlessui/pull/2064), [#2067](https://github.com/tailwindlabs/headlessui/pull/2067))
- Improve SSR for Tabs in Vue ([#2068](https://github.com/tailwindlabs/headlessui/pull/2068))
- Ignore pointer events in Listbox, Menu, and Combobox when cursor hasn't moved ([#2069](https://github.com/tailwindlabs/headlessui/pull/2069))
- Allow clicks inside dialog panel when target is inside shadow root ([#2079](https://github.com/tailwindlabs/headlessui/pull/2079))

## [1.7.4] - 2022-11-03

Expand Down
101 changes: 101 additions & 0 deletions packages/@headlessui-vue/src/components/dialog/dialog.test.ts
Expand Up @@ -1481,6 +1481,107 @@ describe('Mouse interactions', () => {
})
)

it(
'should be possible to click elements inside the dialog when they reside inside a shadow boundary',
suppressConsoleLogs(async () => {
let fn = jest.fn()

let ShadowChildren = defineComponent({
props: ['id', 'buttonId'],
setup(props) {
let container = ref<HTMLDivElement | null>(null)

onMounted(() => {
if (!container.value || container.value.shadowRoot) {
return
}

let shadowRoot = container.value.attachShadow({ mode: 'open' })
let button = document.createElement('button')
button.id = props.buttonId
button.textContent = 'Inside shadow root'
button.addEventListener('click', fn)
shadowRoot.appendChild(button)
})

return () => h('div', { id: props.id, ref: container })
},
})

renderTemplate({
components: { ShadowChildren },
template: `
<div>
<button @click="setIsOpen(true)">open</button>
<Dialog :open="isOpen" @close="setIsOpen(false)">
<div>
<button id="btn_outside_light" @click="fn">
Button
</button>
<ShadowChildren id="outside_shadow" buttonId="btn_outside_shadow" />
</div>
<DialogPanel>
<button id="btn_inside_light" @click="fn">
Button
</button>
<ShadowChildren id="inside_shadow" buttonId="btn_inside_shadow" />
</DialogPanel>
</Dialog>
</div>
`,
setup() {
let isOpen = ref(true)
return {
fn,
isOpen,
setIsOpen(value: boolean) {
isOpen.value = value
},
}
},
})

await nextFrame()

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

// Click the button inside the dialog (light DOM)
await click(document.querySelector('#btn_inside_light'))

// Verify the button was clicked
expect(fn).toHaveBeenCalledTimes(1)

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

// Click the button inside the dialog (shadow DOM)
await click(
document.querySelector('#inside_shadow')?.shadowRoot?.querySelector('#btn_inside_shadow') ??
null
)

// Verify the button was clicked
expect(fn).toHaveBeenCalledTimes(2)

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

// Click the button outside the dialog (shadow DOM)
await click(
document
.querySelector('#outside_shadow')
?.shadowRoot?.querySelector('#btn_outside_shadow') ?? null
)

// Verify the button was clicked
expect(fn).toHaveBeenCalledTimes(3)

// Verify the dialog is closed
assertDialog({ state: DialogState.InvisibleUnmounted })
})
)

it(
'should close the Dialog if we click outside the DialogPanel',
suppressConsoleLogs(async () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/@headlessui-vue/src/hooks/use-outside-click.ts
Expand Up @@ -55,6 +55,12 @@ export function useOutsideClick(
if (domNode?.contains(target)) {
return
}

// If the click crossed a shadow boundary, we need to check if the container
// is inside the tree by using `composedPath` to "pierce" the shadow boundary
if (event.composed && event.composedPath().includes(domNode as EventTarget)) {
return
}
}

// This allows us to check whether the event was defaultPrevented when you are nesting this
Expand Down

0 comments on commit 724ee37

Please sign in to comment.