-
Notifications
You must be signed in to change notification settings - Fork 526
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Call useOnOutsideClick handlers in reverse order (#1251)
Co-authored-by: dgreif <dustin.greif@gmail.com> Co-authored-by: Clay Miller <clay@smockle.com>
- Loading branch information
1 parent
dc17a49
commit 528e9a4
Showing
3 changed files
with
148 additions
and
35 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
528e9a4
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Successfully deployed to the following URLs: