-
Notifications
You must be signed in to change notification settings - Fork 34
/
Copy pathusePortal.ts
200 lines (175 loc) Β· 7.93 KB
/
usePortal.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
import { useState, useRef, useEffect, useCallback, useMemo, ReactNode, DOMAttributes, SyntheticEvent, MutableRefObject, MouseEvent } from 'react'
import { createPortal, findDOMNode } from 'react-dom'
import useSSR from 'use-ssr'
type HTMLElRef = MutableRefObject<HTMLElement>
type CustomEvent = {
event?: SyntheticEvent<any, Event>
portal: HTMLElRef
targetEl: HTMLElRef
} & SyntheticEvent<any, Event>
type CustomEventHandler = (customEvent: CustomEvent) => void
type CustomEventHandlers = {
[K in keyof DOMAttributes<K>]?: CustomEventHandler
}
type EventListenerMap = { [K in keyof DOMAttributes<K>]: keyof GlobalEventHandlersEventMap }
type EventListenersRef = MutableRefObject<{
[K in keyof DOMAttributes<K>]?: (event: SyntheticEvent<any, Event>) => void
}>
export type UsePortalOptions = {
closeOnOutsideClick?: boolean
closeOnEsc?: boolean
bindTo?: HTMLElement // attach the portal to this node in the DOM
isOpen?: boolean
onOpen?: CustomEventHandler
onClose?: CustomEventHandler
onPortalClick?: CustomEventHandler
programmaticallyOpen?: boolean
} & CustomEventHandlers
type UsePortalObjectReturn = {} // TODO
type UsePortalArrayReturn = [] // TODO
export const errorMessage1 = 'You must either add a `ref` to the element you are interacting with or pass an `event` to openPortal(e) or togglePortal(e) when the `programmaticallyOpen` option is not set to `true`.'
export default function usePortal({
closeOnOutsideClick = true,
closeOnEsc = true,
bindTo, // attach the portal to this node in the DOM
isOpen: defaultIsOpen = false,
onOpen,
onClose,
onPortalClick,
programmaticallyOpen = false,
...eventHandlers
}: UsePortalOptions = {}): any {
const { isServer, isBrowser } = useSSR()
const [isOpen, makeOpen] = useState(defaultIsOpen)
// we use this ref because `isOpen` is stale for handleOutsideMouseClick
const open = useRef(isOpen)
const setOpen = useCallback((v: boolean) => {
// workaround to not have stale `isOpen` in the handleOutsideMouseClick
open.current = v
makeOpen(v)
}, [])
const targetEl = useRef() as HTMLElRef // this is the element you are clicking/hovering/whatever, to trigger opening the portal
const portal = useRef(isBrowser ? document.createElement('div') : null) as HTMLElRef
useEffect(() => {
if (isBrowser && !portal.current) portal.current = document.createElement('div')
}, [isBrowser, portal])
const elToMountTo = useMemo(() => {
if (isServer) return
return (bindTo && findDOMNode(bindTo)) || document.body
}, [isServer, bindTo])
const createCustomEvent = (e: any) => {
if (!e) return { portal, targetEl, event: e }
const event = e || {}
if (event.persist) event.persist()
event.portal = portal
event.targetEl = targetEl
event.event = e
const { currentTarget } = e
if (!targetEl.current && currentTarget && currentTarget !== document) targetEl.current = event.currentTarget
return event
}
// this should handle all eventHandlers like onClick, onMouseOver, etc. passed into the config
const customEventHandlers: CustomEventHandlers = Object
.entries(eventHandlers)
.reduce<any>((acc, [handlerName, eventHandler]) => {
acc[handlerName] = (event?: SyntheticEvent<any, Event>) => {
if (isServer) return
eventHandler(createCustomEvent(event))
}
return acc
}, {})
const openPortal = useCallback((e: any) => {
if (isServer) return
const customEvent = createCustomEvent(e)
// for some reason, when we don't have the event argument, there
// is a weird race condition. Would like to see if we can remove
// setTimeout, but for now this works
if (targetEl.current == null && !programmaticallyOpen) {
setTimeout(() => setOpen(true), 0)
throw Error(errorMessage1)
}
if (onOpen) onOpen(customEvent)
setOpen(true)
}, [isServer, portal, setOpen, targetEl, onOpen])
const closePortal = useCallback((e: any) => {
if (isServer) return
const customEvent = createCustomEvent(e)
if (onClose && open.current) onClose(customEvent)
if (open.current) setOpen(false)
}, [isServer, onClose, setOpen])
const togglePortal = useCallback((e: SyntheticEvent<any, Event>): void =>
open.current ? closePortal(e) : openPortal(e),
[closePortal, openPortal]
)
const handleKeydown = useCallback((e: KeyboardEvent): void =>
(e.key === 'Escape' && closeOnEsc) ? closePortal(e) : undefined,
[closeOnEsc, closePortal]
)
const handleOutsideMouseClick = useCallback((e: MouseEvent): void => {
const containsTarget = (target: HTMLElRef) => target.current.contains(e.target as HTMLElement)
// There might not be a targetEl if the portal was opened programmatically.
if (containsTarget(portal) || (e as any).button !== 0 || !open.current || (targetEl.current && containsTarget(targetEl))) return
if (closeOnOutsideClick) closePortal(e)
}, [isServer, closePortal, closeOnOutsideClick, portal])
const handleMouseDown = useCallback((e: MouseEvent): void => {
if (isServer || !(portal.current instanceof HTMLElement)) return
const customEvent = createCustomEvent(e)
if (portal.current.contains(customEvent.target as HTMLElement) && onPortalClick) onPortalClick(customEvent)
handleOutsideMouseClick(e)
}, [handleOutsideMouseClick])
// used to remove the event listeners on unmount
const eventListeners = useRef({}) as EventListenersRef
useEffect(() => {
if (isServer) return
if (!(elToMountTo instanceof HTMLElement) || !(portal.current instanceof HTMLElement)) return
// TODO: eventually will need to figure out a better solution for this.
// Surely we can find a way to map onScroll/onWheel -> scroll/wheel better,
// but for all other event handlers. For now this works.
const eventHandlerMap: EventListenerMap = {
onScroll: 'scroll',
onWheel: 'wheel',
}
const node = portal.current
elToMountTo.appendChild(portal.current)
// handles all special case handlers. Currently only onScroll and onWheel
Object.entries(eventHandlerMap).forEach(([handlerName /* onScroll */, eventListenerName /* scroll */]) => {
if (!eventHandlers[handlerName as keyof EventListenerMap]) return
eventListeners.current[handlerName as keyof EventListenerMap] = (e: any) => (eventHandlers[handlerName as keyof EventListenerMap] as any)(createCustomEvent(e))
document.addEventListener(eventListenerName as keyof GlobalEventHandlersEventMap, eventListeners.current[handlerName as keyof EventListenerMap] as any)
})
document.addEventListener('keydown', handleKeydown)
document.addEventListener('mousedown', handleMouseDown as any)
return () => {
// handles all special case handlers. Currently only onScroll and onWheel
Object.entries(eventHandlerMap).forEach(([handlerName, eventListenerName]) => {
if (!eventHandlers[handlerName as keyof EventListenerMap]) return
document.removeEventListener(eventListenerName as keyof GlobalEventHandlersEventMap, eventListeners.current[handlerName as keyof EventListenerMap] as any)
delete eventListeners.current[handlerName as keyof EventListenerMap]
})
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('mousedown', handleMouseDown as any)
elToMountTo.removeChild(node)
}
}, [isServer, handleOutsideMouseClick, handleKeydown, elToMountTo, portal])
const Portal = useCallback(({ children }: { children: ReactNode }) => {
if (portal.current != null) return createPortal(children, portal.current)
return null
}, [portal])
return Object.assign(
[openPortal, closePortal, open.current, Portal, togglePortal, targetEl, portal],
{
isOpen: open.current,
openPortal,
ref: targetEl,
closePortal,
togglePortal,
Portal,
portalRef: portal,
...customEventHandlers,
bind: { // used if you want to spread all html attributes onto the target element
ref: targetEl,
...customEventHandlers
}
}
)
}