Skip to content

Commit

Permalink
Call useOnOutsideClick handlers in reverse order (#1251)
Browse files Browse the repository at this point in the history
Co-authored-by: dgreif <dustin.greif@gmail.com>
Co-authored-by: Clay Miller <clay@smockle.com>
  • Loading branch information
3 people committed Jun 16, 2021
1 parent dc17a49 commit 528e9a4
Show file tree
Hide file tree
Showing 3 changed files with 148 additions and 35 deletions.
5 changes: 5 additions & 0 deletions .changeset/fuzzy-seahorses-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/components": patch
---

Call `useOnOutsideClick` handlers in reverse order that they are registered, and allow propagation to stop if default is prevented or an non-outside click is detected.
92 changes: 58 additions & 34 deletions src/hooks/useOnOutsideClick.tsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,82 @@
import React, {useEffect, useCallback} from 'react'
import React, {useEffect, useCallback, useMemo} from 'react'

export type TouchOrMouseEvent = MouseEvent | TouchEvent
type TouchOrMouseEventCallback = (event: TouchOrMouseEvent) => boolean | undefined

export type UseOnOutsideClickSettings = {
containerRef: React.RefObject<HTMLDivElement>
ignoreClickRefs?: React.RefObject<HTMLElement>[]
onClickOutside: (e: TouchOrMouseEvent) => void
}

type ShouldCallClickHandlerSettings = {
ignoreClickRefs?: React.RefObject<HTMLElement>[]
containerRef: React.RefObject<HTMLDivElement>
e: TouchOrMouseEvent
}

const shouldCallClickHandler = ({ignoreClickRefs, containerRef, e}: ShouldCallClickHandlerSettings): boolean => {
let shouldCallHandler = true

// don't call click handler if the mouse event was triggered by an auxiliary button (right click/wheel button/etc)
if (e instanceof MouseEvent && e.button > 0) {
shouldCallHandler = false
}
// Because events are handled at the document level, we provide a mechanism for early return.
const stopPropagation = true

// don't call handler if the click happened inside of the container
if (containerRef.current?.contains(e.target as Node)) {
shouldCallHandler = false
// don't call handler if click happened on an ignored ref
} else if (ignoreClickRefs) {
for (const ignoreRef of ignoreClickRefs) {
if (ignoreRef.current?.contains(e.target as Node)) {
shouldCallHandler = false
// if we encounter one, break early, we don't need to go through the rest
/**
* Calls all handlers in reverse order
* @param event The MouseEvent generated by the click event.
*/
function handleClick(event: MouseEvent) {
if (!event.defaultPrevented) {
for (const handler of Object.values(registry).reverse()) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (handler(event) === stopPropagation || event.defaultPrevented) {
break
}
}
}
return shouldCallHandler
}

export const useOnOutsideClick = ({containerRef, ignoreClickRefs, onClickOutside}: UseOnOutsideClickSettings): void => {
const onOutsideClickInternal = useCallback(
(e: TouchOrMouseEvent) => {
if (shouldCallClickHandler({ignoreClickRefs, containerRef, e})) {
onClickOutside(e)
const registry: {[id: number]: TouchOrMouseEventCallback} = {}

function register(id: number, handler: TouchOrMouseEventCallback): void {
registry[id] = handler
}

function deregister(id: number) {
delete registry[id]
}

// For auto-incrementing unique identifiers for registered handlers.
let handlerId = 0

export const useOnOutsideClick = ({containerRef, ignoreClickRefs, onClickOutside}: UseOnOutsideClickSettings) => {
const id = useMemo(() => handlerId++, [])

const handler = useCallback<TouchOrMouseEventCallback>(
event => {
// don't call click handler if the mouse event was triggered by an auxiliary button (right click/wheel button/etc)
if (event instanceof MouseEvent && event.button > 0) {
return stopPropagation
}

// don't call handler if the click happened inside of the container
if (containerRef.current?.contains(event.target as Node)) {
return stopPropagation
}

// don't call handler if click happened on an ignored ref
if (ignoreClickRefs && ignoreClickRefs.some(({current}) => current?.contains(event.target as Node))) {
return stopPropagation
}

onClickOutside(event)
},
[onClickOutside, containerRef, ignoreClickRefs]
[containerRef, ignoreClickRefs, onClickOutside]
)

useEffect(() => {
// use capture to ensure we get all events
document.addEventListener('mousedown', onOutsideClickInternal, {capture: true})
if (Object.keys(registry).length === 0) {
// use capture to ensure we get all events
document.addEventListener('mousedown', handleClick, {capture: true})
}
register(id, handler)

return () => {
document.removeEventListener('mousedown', onOutsideClickInternal, {capture: true})
deregister(id)
if (Object.keys(registry).length === 0) {
document.removeEventListener('mousedown', handleClick, {capture: true})
}
}
}, [onOutsideClickInternal])
}, [id, handler])
}
86 changes: 85 additions & 1 deletion src/stories/Overlay.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React, {useState, useRef} from 'react'
import React, {useState, useRef, useCallback} from 'react'
import {Meta} from '@storybook/react'
import styled from 'styled-components'
import {BaseStyles, Overlay, Button, Text, ButtonDanger, ThemeProvider, Position, Flex} from '..'
import {DropdownMenu, DropdownButton} from '../DropdownMenu'
import {ItemInput} from '../ActionList/List'

export default {
title: 'Internal components/Overlay',
Expand Down Expand Up @@ -100,3 +102,85 @@ export const DialogOverlay = () => {
</Position>
)
}

export const OverlayOnTopOfOverlay = () => {
const [isOpen, setIsOpen] = useState(false)
const [isSecondaryOpen, setIsSecondaryOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
const secondaryButtonRef = useRef<HTMLButtonElement>(null)
const confirmButtonRef = useRef<HTMLButtonElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const closeOverlay = () => setIsOpen(false) // intentionally not memoized
const closeSecondaryOverlay = useCallback(() => setIsSecondaryOpen(false), [setIsSecondaryOpen])
const items = React.useMemo(
() => [
{
text: '🔵 Cyan',
onMouseDown: (e: React.MouseEvent) => {
e.preventDefault()
}
},
{
text: '🔴 Magenta',
onMouseDown: (e: React.MouseEvent) => {
e.preventDefault()
}
},
{
text: '🟡 Yellow',
onMouseDown: (e: React.MouseEvent) => {
e.preventDefault()
}
}
],
[]
)
const [selectedItem, setSelectedItem] = React.useState<ItemInput | undefined>()
return (
<Position position="absolute" top={0} left={0} bottom={0} right={0} ref={anchorRef}>
<input placeholder="Input for focus testing" />
<br />
<Button ref={buttonRef} onClick={() => setIsOpen(!isOpen)}>
open overlay
</Button>
{isOpen ? (
<Overlay
initialFocusRef={confirmButtonRef}
returnFocusRef={buttonRef}
onEscape={closeOverlay}
onClickOutside={closeOverlay}
width="small"
>
<Button ref={secondaryButtonRef} onClick={() => setIsSecondaryOpen(!isSecondaryOpen)}>
open overlay
</Button>
{isSecondaryOpen ? (
<Overlay
initialFocusRef={confirmButtonRef}
returnFocusRef={secondaryButtonRef}
onEscape={closeSecondaryOverlay}
onClickOutside={closeSecondaryOverlay}
width="small"
sx={{top: '40px'}}
>
<Flex flexDirection="column" p={2}>
<Text>Select an option!</Text>
<DropdownMenu
renderAnchor={({children, 'aria-labelledby': ariaLabelledBy, ...anchorProps}) => (
<DropdownButton aria-labelledby={`favorite-color-label ${ariaLabelledBy}`} {...anchorProps}>
{children}
</DropdownButton>
)}
placeholder="🎨"
items={items}
selectedItem={selectedItem}
onChange={setSelectedItem}
/>
</Flex>
</Overlay>
) : null}
</Overlay>
) : null}
</Position>
)
}

1 comment on commit 528e9a4

@vercel
Copy link

@vercel vercel bot commented on 528e9a4 Jun 16, 2021

Choose a reason for hiding this comment

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

Please sign in to comment.