Skip to content

Commit c1023f7

Browse files
authored
Fix incorrect closing while interacting with third party libraries in Dialog component (#1268)
* ensure to keep the Dialog open when clicking on 3rd party elements * update playground with a Flatpickr example * update changelog
1 parent 3e19aa5 commit c1023f7

File tree

14 files changed

+218
-39
lines changed

14 files changed

+218
-39
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3232
- Stop propagation on the Popover Button ([#1263](https://github.com/tailwindlabs/headlessui/pull/1263))
3333
- Fix incorrect `active` option in the Listbox/Combobox component ([#1264](https://github.com/tailwindlabs/headlessui/pull/1264))
3434
- Properly merge incoming props ([#1265](https://github.com/tailwindlabs/headlessui/pull/1265))
35+
- Fix incorrect closing while interacting with third party libraries in `Dialog` component ([#1268](https://github.com/tailwindlabs/headlessui/pull/1268))
3536

3637
### Added
3738

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

6870
### Added
6971

packages/@headlessui-react/src/components/dialog/dialog.test.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import { click, press, Keys } from '../../test-utils/interactions'
2020
import { PropsOf } from '../../types'
2121
import { Transition } from '../transitions/transition'
22+
import { createPortal } from 'react-dom'
2223

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

@@ -843,6 +844,53 @@ describe('Mouse interactions', () => {
843844
assertDialog({ state: DialogState.Visible })
844845
})
845846
)
847+
848+
it(
849+
'should be possible to click on elements created by third party libraries',
850+
suppressConsoleLogs(async () => {
851+
let fn = jest.fn()
852+
function ThirdPartyLibrary() {
853+
return createPortal(
854+
<>
855+
<button data-lib onClick={fn}>
856+
3rd party button
857+
</button>
858+
</>,
859+
document.body
860+
)
861+
}
862+
863+
function Example() {
864+
let [isOpen, setIsOpen] = useState(true)
865+
866+
return (
867+
<div>
868+
<span>Main app</span>
869+
<Dialog open={isOpen} onClose={setIsOpen}>
870+
<div>
871+
Contents
872+
<TabSentinel />
873+
</div>
874+
</Dialog>
875+
<ThirdPartyLibrary />
876+
</div>
877+
)
878+
}
879+
render(<Example />)
880+
881+
// Verify it is open
882+
assertDialog({ state: DialogState.Visible })
883+
884+
// Click the button inside the 3rd party library
885+
await click(document.querySelector('[data-lib]'))
886+
887+
// Verify we clicked on the 3rd party button
888+
expect(fn).toHaveBeenCalledTimes(1)
889+
890+
// Verify the dialog is still open
891+
assertDialog({ state: DialogState.Visible })
892+
})
893+
)
846894
})
847895

848896
describe('Nesting', () => {

packages/@headlessui-react/src/components/dialog/dialog.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
200200
// in between. We only care abou whether you are the top most one or not.
201201
let position = !hasNestedDialogs ? 'leaf' : 'parent'
202202

203-
useFocusTrap(
203+
let previousElement = useFocusTrap(
204204
internalDialogRef,
205205
enabled
206206
? match(position, {
@@ -213,12 +213,26 @@ let DialogRoot = forwardRefWithAs(function Dialog<
213213
useInertOthers(internalDialogRef, hasNestedDialogs ? enabled : false)
214214

215215
// Handle outside click
216-
useOutsideClick(internalDialogRef, () => {
217-
if (dialogState !== DialogStates.Open) return
218-
if (hasNestedDialogs) return
216+
useOutsideClick(
217+
() => {
218+
// Third party roots
219+
let rootContainers = Array.from(ownerDocument?.querySelectorAll('body > *') ?? []).filter(
220+
(container) => {
221+
if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements
222+
if (container.contains(previousElement.current)) return false // Skip if it is the main app
223+
return true // Keep
224+
}
225+
)
219226

220-
close()
221-
})
227+
return [...rootContainers, internalDialogRef.current] as HTMLElement[]
228+
},
229+
() => {
230+
if (dialogState !== DialogStates.Open) return
231+
if (hasNestedDialogs) return
232+
233+
close()
234+
}
235+
)
222236

223237
// Handle `Escape` to close
224238
useEventListener(ownerDocument?.defaultView, 'keydown', (event) => {

packages/@headlessui-react/src/hooks/use-focus-trap.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ export function useFocusTrap(
152152
},
153153
true
154154
)
155+
156+
return restoreElement
155157
}
156158

157159
function contains(containers: Set<MutableRefObject<HTMLElement | null>>, element: HTMLElement) {

packages/@headlessui-react/src/hooks/use-outside-click.ts

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,14 @@ function microTask(cb: () => void) {
1717
}
1818
}
1919

20+
type Container = MutableRefObject<HTMLElement | null> | HTMLElement | null
21+
type ContainerCollection = Container[] | Set<Container>
22+
type ContainerInput = Container | ContainerCollection
23+
2024
export function useOutsideClick(
21-
containers:
22-
| HTMLElement
23-
| MutableRefObject<HTMLElement | null>
24-
| (MutableRefObject<HTMLElement | null> | HTMLElement | null)[]
25-
| Set<HTMLElement>,
25+
containers: ContainerInput | (() => ContainerInput),
2626
cb: (event: MouseEvent | PointerEvent, target: HTMLElement) => void
2727
) {
28-
let _containers = useMemo(() => {
29-
if (Array.isArray(containers)) {
30-
return containers
31-
}
32-
33-
if (containers instanceof Set) {
34-
return containers
35-
}
36-
37-
return [containers]
38-
}, [containers])
39-
4028
let called = useRef(false)
4129
let handler = useLatestValue((event: MouseEvent | PointerEvent) => {
4230
if (called.current) return
@@ -45,6 +33,22 @@ export function useOutsideClick(
4533
called.current = false
4634
})
4735

36+
let _containers = (function resolve(containers): ContainerCollection {
37+
if (typeof containers === 'function') {
38+
return resolve(containers())
39+
}
40+
41+
if (Array.isArray(containers)) {
42+
return containers
43+
}
44+
45+
if (containers instanceof Set) {
46+
return containers
47+
}
48+
49+
return [containers]
50+
})(containers)
51+
4852
let target = event.target as HTMLElement
4953

5054
// Ignore if the target doesn't exist in the DOM anymore

packages/@headlessui-vue/src/components/dialog/dialog.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,6 +1035,59 @@ describe('Mouse interactions', () => {
10351035
assertDialog({ state: DialogState.Visible })
10361036
})
10371037
)
1038+
1039+
it(
1040+
'should be possible to click on elements created by third party libraries',
1041+
suppressConsoleLogs(async () => {
1042+
let fn = jest.fn()
1043+
1044+
let ThirdPartyLibrary = defineComponent({
1045+
template: html`
1046+
<teleport to="body">
1047+
<button data-lib @click="fn">3rd party button</button>
1048+
</teleport>
1049+
`,
1050+
setup: () => ({ fn }),
1051+
})
1052+
1053+
renderTemplate({
1054+
components: { ThirdPartyLibrary },
1055+
template: `
1056+
<div>
1057+
<span>Main app</span>
1058+
<Dialog :open="isOpen" @close="setIsOpen">
1059+
<div>
1060+
Contents
1061+
<TabSentinel />
1062+
</div>
1063+
</Dialog>
1064+
<ThirdPartyLibrary />
1065+
</div>
1066+
`,
1067+
setup() {
1068+
let isOpen = ref(true)
1069+
return {
1070+
isOpen,
1071+
setIsOpen(value: boolean) {
1072+
isOpen.value = value
1073+
},
1074+
}
1075+
},
1076+
})
1077+
1078+
// Verify it is open
1079+
assertDialog({ state: DialogState.Visible })
1080+
1081+
// Click the button inside the 3rd party library
1082+
await click(document.querySelector('[data-lib]'))
1083+
1084+
// Verify we clicked on the 3rd party button
1085+
expect(fn).toHaveBeenCalledTimes(1)
1086+
1087+
// Verify the dialog is still open
1088+
assertDialog({ state: DialogState.Visible })
1089+
})
1090+
)
10381091
})
10391092

10401093
describe('Nesting', () => {

packages/@headlessui-vue/src/components/dialog/dialog.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export let Dialog = defineComponent({
129129
// in between. We only care abou whether you are the top most one or not.
130130
let position = computed(() => (!hasNestedDialogs.value ? 'leaf' : 'parent'))
131131

132-
useFocusTrap(
132+
let previousElement = useFocusTrap(
133133
internalDialogRef,
134134
computed(() => {
135135
return enabled.value
@@ -191,13 +191,28 @@ export let Dialog = defineComponent({
191191
provide(DialogContext, api)
192192

193193
// Handle outside click
194-
useOutsideClick(internalDialogRef, (_event, target) => {
195-
if (dialogState.value !== DialogStates.Open) return
196-
if (hasNestedDialogs.value) return
194+
useOutsideClick(
195+
() => {
196+
// Third party roots
197+
let rootContainers = Array.from(
198+
ownerDocument.value?.querySelectorAll('body > *') ?? []
199+
).filter((container) => {
200+
if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements
201+
if (container.contains(previousElement.value)) return false // Skip if it is the main app
202+
return true // Keep
203+
})
197204

198-
api.close()
199-
nextTick(() => target?.focus())
200-
})
205+
return [...rootContainers, internalDialogRef.value] as HTMLElement[]
206+
},
207+
208+
(_event, target) => {
209+
if (dialogState.value !== DialogStates.Open) return
210+
if (hasNestedDialogs.value) return
211+
212+
api.close()
213+
nextTick(() => target?.focus())
214+
}
215+
)
201216

202217
// Handle `Escape` to close
203218
useEventListener(ownerDocument.value?.defaultView, 'keydown', (event) => {

packages/@headlessui-vue/src/hooks/use-focus-trap.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ export function useFocusTrap(
175175
},
176176
true
177177
)
178+
179+
return restoreElement
178180
}
179181

180182
function contains(containers: Set<Ref<HTMLElement | null>>, element: HTMLElement) {

packages/@headlessui-vue/src/hooks/use-outside-click.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ function microTask(cb: () => void) {
1717
}
1818
}
1919

20+
type Container = Ref<HTMLElement | null> | HTMLElement | null
21+
type ContainerCollection = Container[] | Set<Container>
22+
type ContainerInput = Container | ContainerCollection
23+
2024
export function useOutsideClick(
21-
containers:
22-
| HTMLElement
23-
| Ref<HTMLElement | null>
24-
| (Ref<HTMLElement | null> | HTMLElement | null)[]
25-
| Set<HTMLElement>,
25+
containers: ContainerInput | (() => ContainerInput),
2626
cb: (event: MouseEvent | PointerEvent, target: HTMLElement) => void
2727
) {
2828
let called = false
@@ -38,7 +38,11 @@ export function useOutsideClick(
3838
// Ignore if the target doesn't exist in the DOM anymore
3939
if (!target.ownerDocument.documentElement.contains(target)) return
4040

41-
let _containers = (() => {
41+
let _containers = (function resolve(containers): ContainerCollection {
42+
if (typeof containers === 'function') {
43+
return resolve(containers())
44+
}
45+
4246
if (Array.isArray(containers)) {
4347
return containers
4448
}
@@ -48,7 +52,7 @@ export function useOutsideClick(
4852
}
4953

5054
return [containers]
51-
})()
55+
})(containers)
5256

5357
// Ignore if the target exists in one of the containers
5458
for (let container of _containers) {

packages/playground-react/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"framer-motion": "^6.0.0",
1818
"next": "^12.0.8",
1919
"react": "16.14.0",
20-
"react-dom": "16.14.0"
20+
"react-dom": "16.14.0",
21+
"react-flatpickr": "^3.10.9"
2122
}
2223
}

0 commit comments

Comments
 (0)