Skip to content

Commit bdd1b3b

Browse files
authored
Improve outside click of Dialog component (#1546)
* convert dialog in playground to use Dialog.Panel * convert `tabs-in-dialog` example to use `Dialog.Panel` * add scrollable dialog example to the playground * simplify `outside click` behaviour Here is a little story. We used to use the `click` event listener on the window to try and detect whether we clicked outside of the main area we are working in. This all worked fine, until we got a bug report that it didn't work properly on Mobile, especially iOS. After a bit of debugging we switched this behaviour to use `pointerdown` instead of the `click` event listener. Worked great! Maybe... The reason the `click` didn't work was because of another bug fix. In React if you render a `<form><Dialog></form>` and your `Dialog` contains a button without a type, (or an input where you press enter) then the form would submit... even though we portalled the `Dialog` to a different location, but it bubbled the event up via the SyntethicEvent System. To fix this, we've added a "simple" `onClick(e) { e.stopPropagation() }` to make sure that click events didn't leak out. Alright no worries, but, now that we switched to `pointerdown` we got another bug report that it didn't work on older iOS devices. Fine, let's add a `mousedown` next to the `pointerdown` event. Now this works all great! Maybe... This doesn't work quite as we expected because it could happen that both events fire and then the `onClose` of the Dialog component would fire twice. In fact, there is an open issue about this: #1490 at the time of writing this commit message. We tried to only call the close function once by checking if those events happen within the same "tick", which is not always the case... Alright, let's ignore that issue for a second, there is another issue that popped up... If you have a Dialog that is scrollable (because it is greater than the current viewport) then a wild scrollbar appears (what a weird Pokémon). The moment you try to click the scrollbar or drag it the Dialog closes. What in the world...? Well... turns out that `pointerdown` gets fired if you happen to "click" (or touch) on the scrollbar. A click event does not get fired. No worries we can fix this! Maybe... (Narrator: ... nope ...) One thing we can try is to measure the scrollbar width, and if you happen to click near the edge then we ignore this click. You can think of it like `let safeArea = viewportWidth - scrollBarWidth`. Everything works great now! Maybe... Well, let me tell you about macOS and "floating" scrollbars... you can't measure those... AAAAAAAARGHHHH Alright, scratch that, let's add an invisible 20px gap all around the viewport without measuring as a safe area. Nobody will click in the 20px gap, right, right?! Everything works great now! Maybe... Mobile devices, yep, Dialogs are used there as well and usually there is not a lot of room around those Dialogs so you almost always hit the "safe area". Should we now try and detect the device people are using...? /me takes a deep breath... Inhales... Exhales... Alright, time to start thinking again... The outside click with a "simple" click worked on Menu and Listbox not on the Dialog so this should be enough right? WAIT A MINUTE Remember this piece of code from earlier: ```js onClick(event) { event.stopPropagation() } ``` The click event never ever reaches the `window` so we can't detect the click outside... Let's move that code to the `Dialog.Panel` instead of on the `Dialog` itself, this will make sure that we stop the click event from leaking if you happen to nest a Dialog in a form and have a submitable button/input in the `Dialog.Panel`. But if you click outside of the `Dialog.Panel` the "click" event will bubble to the `window` so that we can detect a click and check whether it was outside or not. Time to start cleaning: - ☑️ Remove all the scrollbar measuring code... - Closing works on mobile now, no more safe area hack - ☑️ Remove the pointerdown & mousedown event - Outside click doesn't fire twice anymore - ☑️ Use a "simple" click event listener - We can click the scrollbar and the browser ignores it for us All issues have been fixed! (Until the next one of course...) * ensure a `Dialog.Panel` exists * cleanup unnecessary code * use capture phase for outside click behaviour * further improve outside click We added event.preventDefault() & event.defaultPrevented checks to make sure that we only handle 1 layer at a time. E.g.: ```js <Dialog> <Menu> <Menu.Button>Button</Menu.Button> <Menu.Items>...</Menu.Items> </Menu> </Dialog> ``` If you open the Dialog, then open the Menu, pressing `Escape` will close the Menu but not the Dialog, pressing `Escape` again will close the Dialog. Now this is also applied to the outside click behaviour. If you open the Dialog, then open the Menu, clicking outside will close the Menu but not the Dialog, outside again will close the Dialog. * add explicit `enabled` value to the `useOutsideClick` hook * ensure outside click properly works with Poratl components Usually this works out of the box, however our Portal components will render inside the Dialog component "root" to ensure that it is inside the non-inert tree and is inside the Dialog visually. This means that the Portal is not in a separate container and technically outside of the `Dialog.Panel` which means that it will close when you click on a non-interactive item inside that Portal... This fixes that and allows all Portal components. * update changelog
1 parent 70333a9 commit bdd1b3b

File tree

25 files changed

+361
-234
lines changed

25 files changed

+361
-234
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
### Fixed
1616

1717
- Fix incorrect transitionend/transitioncancel events for the Transition component ([#1537](https://github.com/tailwindlabs/headlessui/pull/1537))
18+
- Improve outside click of `Dialog` component ([#1546](https://github.com/tailwindlabs/headlessui/pull/1546))
1819

1920
## [1.6.4] - 2022-05-29
2021

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { forwardRefWithAs, render, compact, PropsForFeatures, Features } from '.
3434
import { isDisabledReactIssue7711 } from '../../utils/bugs'
3535
import { match } from '../../utils/match'
3636
import { objectToFormEntries } from '../../utils/form'
37-
import { sortByDomNode } from '../../utils/focus-management'
37+
import { FocusableMode, isFocusableElement, sortByDomNode } from '../../utils/focus-management'
3838

3939
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
4040
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
@@ -415,11 +415,11 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
415415
}, [data])
416416

417417
// Handle outside click
418-
useOutsideClick([data.buttonRef, data.inputRef, data.optionsRef], () => {
419-
if (data.comboboxState !== ComboboxState.Open) return
420-
421-
dispatch({ type: ActionTypes.CloseCombobox })
422-
})
418+
useOutsideClick(
419+
[data.buttonRef, data.inputRef, data.optionsRef],
420+
() => dispatch({ type: ActionTypes.CloseCombobox }),
421+
data.comboboxState === ComboboxState.Open
422+
)
423423

424424
let slot = useMemo<ComboboxRenderPropArg<TType>>(
425425
() => ({

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -889,9 +889,11 @@ describe('Mouse interactions', () => {
889889
return (
890890
<div onClick={wrapperFn}>
891891
<Dialog open={isOpen} onClose={setIsOpen}>
892-
Contents
893-
<button onClick={() => setIsOpen(false)}>Inside</button>
894-
<TabSentinel />
892+
<Dialog.Panel>
893+
Contents
894+
<button onClick={() => setIsOpen(false)}>Inside</button>
895+
<TabSentinel />
896+
</Dialog.Panel>
895897
</Dialog>
896898
</div>
897899
)

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

Lines changed: 19 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { Description, useDescriptions } from '../description/description'
3232
import { useOpenClosed, State } from '../../internal/open-closed'
3333
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
3434
import { StackProvider, StackMessage } from '../../internal/stack-context'
35-
import { useOutsideClick, Features as OutsideClickFeatures } from '../../hooks/use-outside-click'
35+
import { useOutsideClick } from '../../hooks/use-outside-click'
3636
import { getOwnerDocument } from '../../utils/owner'
3737
import { useOwnerDocument } from '../../hooks/use-owner'
3838
import { useEventListener } from '../../hooks/use-event-listener'
@@ -100,13 +100,7 @@ let DEFAULT_DIALOG_TAG = 'div' as const
100100
interface DialogRenderPropArg {
101101
open: boolean
102102
}
103-
type DialogPropsWeControl =
104-
| 'id'
105-
| 'role'
106-
| 'aria-modal'
107-
| 'aria-describedby'
108-
| 'aria-labelledby'
109-
| 'onClick'
103+
type DialogPropsWeControl = 'id' | 'role' | 'aria-modal' | 'aria-describedby' | 'aria-labelledby'
110104

111105
let DialogRenderFeatures = Features.RenderStrategy | Features.Static
112106

@@ -204,27 +198,22 @@ let DialogRoot = forwardRefWithAs(function Dialog<
204198
useOutsideClick(
205199
() => {
206200
// Third party roots
207-
let rootContainers = Array.from(ownerDocument?.querySelectorAll('body > *') ?? []).filter(
208-
(container) => {
209-
if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements
210-
if (container.contains(mainTreeNode.current)) return false // Skip if it is the main app
211-
if (state.panelRef.current && container.contains(state.panelRef.current)) return false
212-
return true // Keep
213-
}
214-
)
201+
let rootContainers = Array.from(
202+
ownerDocument?.querySelectorAll('body > *, [data-headlessui-portal]') ?? []
203+
).filter((container) => {
204+
if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements
205+
if (container.contains(mainTreeNode.current)) return false // Skip if it is the main app
206+
if (state.panelRef.current && container.contains(state.panelRef.current)) return false
207+
return true // Keep
208+
})
215209

216210
return [
217211
...rootContainers,
218212
state.panelRef.current ?? internalDialogRef.current,
219213
] as HTMLElement[]
220214
},
221-
() => {
222-
if (dialogState !== DialogStates.Open) return
223-
if (hasNestedDialogs) return
224-
225-
close()
226-
},
227-
OutsideClickFeatures.IgnoreScrollbars
215+
close,
216+
enabled && !hasNestedDialogs
228217
)
229218

230219
// Handle `Escape` to close
@@ -311,9 +300,6 @@ let DialogRoot = forwardRefWithAs(function Dialog<
311300
'aria-modal': dialogState === DialogStates.Open ? true : undefined,
312301
'aria-labelledby': state.titleId,
313302
'aria-describedby': describedby,
314-
onClick(event: ReactMouseEvent) {
315-
event.stopPropagation()
316-
},
317303
}
318304

319305
return (
@@ -492,10 +478,17 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
492478
[dialogState]
493479
)
494480

481+
// Prevent the click events inside the Dialog.Panel from bubbling through the React Tree which
482+
// could submit wrapping <form> elements even if we portalled the Dialog.
483+
let handleClick = useEvent((event: ReactMouseEvent) => {
484+
event.stopPropagation()
485+
})
486+
495487
let theirProps = props
496488
let ourProps = {
497489
ref: panelRef,
498490
id,
491+
onClick: handleClick,
499492
}
500493

501494
return render({

packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,10 @@ function useRestoreFocus({ ownerDocument }: { ownerDocument: Document | null },
147147
useWatch(() => {
148148
if (enabled) return
149149

150-
focusElement(restoreElement.current)
150+
if (ownerDocument?.activeElement === ownerDocument?.body) {
151+
focusElement(restoreElement.current)
152+
}
153+
151154
restoreElement.current = null
152155
}, [enabled])
153156

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

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -396,16 +396,18 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
396396
)
397397

398398
// Handle outside click
399-
useOutsideClick([buttonRef, optionsRef], (event, target) => {
400-
if (listboxState !== ListboxStates.Open) return
401-
402-
dispatch({ type: ActionTypes.CloseListbox })
399+
useOutsideClick(
400+
[buttonRef, optionsRef],
401+
(event, target) => {
402+
dispatch({ type: ActionTypes.CloseListbox })
403403

404-
if (!isFocusableElement(target, FocusableMode.Loose)) {
405-
event.preventDefault()
406-
buttonRef.current?.focus()
407-
}
408-
})
404+
if (!isFocusableElement(target, FocusableMode.Loose)) {
405+
event.preventDefault()
406+
buttonRef.current?.focus()
407+
}
408+
},
409+
listboxState === ListboxStates.Open
410+
)
409411

410412
let slot = useMemo<ListboxRenderPropArg>(
411413
() => ({ open: listboxState === ListboxStates.Open, disabled }),

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

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -239,16 +239,18 @@ let MenuRoot = forwardRefWithAs(function Menu<TTag extends ElementType = typeof
239239
let menuRef = useSyncRefs(ref)
240240

241241
// Handle outside click
242-
useOutsideClick([buttonRef, itemsRef], (event, target) => {
243-
if (menuState !== MenuStates.Open) return
244-
245-
dispatch({ type: ActionTypes.CloseMenu })
242+
useOutsideClick(
243+
[buttonRef, itemsRef],
244+
(event, target) => {
245+
dispatch({ type: ActionTypes.CloseMenu })
246246

247-
if (!isFocusableElement(target, FocusableMode.Loose)) {
248-
event.preventDefault()
249-
buttonRef.current?.focus()
250-
}
251-
})
247+
if (!isFocusableElement(target, FocusableMode.Loose)) {
248+
event.preventDefault()
249+
buttonRef.current?.focus()
250+
}
251+
},
252+
menuState === MenuStates.Open
253+
)
252254

253255
let slot = useMemo<MenuRenderPropArg>(
254256
() => ({ open: menuState === MenuStates.Open }),
@@ -344,7 +346,6 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
344346
d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
345347
} else {
346348
event.preventDefault()
347-
event.stopPropagation()
348349
dispatch({ type: ActionTypes.OpenMenu })
349350
}
350351
})

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

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -258,16 +258,18 @@ let PopoverRoot = forwardRefWithAs(function Popover<
258258
)
259259

260260
// Handle outside click
261-
useOutsideClick([button, panel], (event, target) => {
262-
if (popoverState !== PopoverStates.Open) return
263-
264-
dispatch({ type: ActionTypes.ClosePopover })
261+
useOutsideClick(
262+
[button, panel],
263+
(event, target) => {
264+
dispatch({ type: ActionTypes.ClosePopover })
265265

266-
if (!isFocusableElement(target, FocusableMode.Loose)) {
267-
event.preventDefault()
268-
button?.focus()
269-
}
270-
})
266+
if (!isFocusableElement(target, FocusableMode.Loose)) {
267+
event.preventDefault()
268+
button?.focus()
269+
}
270+
},
271+
popoverState === PopoverStates.Open
272+
)
271273

272274
let close = useEvent((focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null>) => {
273275
dispatch({ type: ActionTypes.ClosePopover })

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,6 @@ it('should be possible to force the Portal into a specific element using Portal.
299299
render(<Example />)
300300

301301
expect(document.body.innerHTML).toMatchInlineSnapshot(
302-
`"<div><main><aside id=\\"group-1\\">A<div>Next to A</div></aside><section id=\\"group-2\\"><span>B</span></section></main></div><div id=\\"headlessui-portal-root\\"><div>I am in the portal root</div></div>"`
302+
`"<div><main><aside id=\\"group-1\\">A<div data-headlessui-portal=\\"\\">Next to A</div></aside><section id=\\"group-2\\"><span>B</span></section></main></div><div id=\\"headlessui-portal-root\\"><div data-headlessui-portal=\\"\\">I am in the portal root</div></div>"`
303303
)
304304
})

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ let PortalRoot = forwardRefWithAs(function Portal<
9595
// Element already exists in target, always calling target.appendChild(element) will cause a
9696
// brief unmount/remount.
9797
if (!target.contains(element)) {
98+
element.setAttribute('data-headlessui-portal', '')
9899
target.appendChild(element)
99100
}
100101

0 commit comments

Comments
 (0)