1
1
/**
2
- * @file Abstracts over MUI's Popover component to simplify using it (and hide)
3
- * some of the wonkier parts of the API.
2
+ * @file Abstracts over MUI's Popover component to simplify using it (and hide
3
+ * some of the wonkier parts of the API) .
4
4
*
5
5
* Just place a button and some content in the component, and things just work.
6
6
* No setup needed with hooks or refs.
7
7
*/
8
8
import {
9
9
type KeyboardEvent ,
10
+ type MouseEvent ,
11
+ type PropsWithChildren ,
10
12
type ReactElement ,
13
+ createContext ,
14
+ useCallback ,
15
+ useContext ,
11
16
useEffect ,
12
17
useRef ,
13
18
useState ,
14
- PropsWithChildren ,
15
19
} from "react" ;
16
20
17
- import { type Theme , type SystemStyleObject } from "@mui/system" ;
21
+ import { type Theme , type SystemStyleObject , Box } from "@mui/system" ;
18
22
import Popover , { type PopoverOrigin } from "@mui/material/Popover" ;
23
+ import { useNavigate , type LinkProps } from "react-router-dom" ;
24
+ import { useTheme } from "@emotion/react" ;
19
25
20
- type Props = PropsWithChildren < {
26
+ function getButton ( container : HTMLElement ) {
27
+ return (
28
+ container . querySelector ( "button" ) ??
29
+ container . querySelector ( '[aria-role="button"]' )
30
+ ) ;
31
+ }
32
+
33
+ const ClosePopoverContext = createContext < ( ( ) => void ) | null > ( null ) ;
34
+
35
+ type PopoverLinkProps = LinkProps & {
36
+ to : string ;
37
+ sx ?: SystemStyleObject < Theme > ;
38
+ } ;
39
+
40
+ /**
41
+ * A custom version of a React Router Link that makes sure to close the popover
42
+ * before starting a navigation.
43
+ *
44
+ * This is necessary because React Router's navigation logic doesn't work well
45
+ * with modals (including MUI's base Popover component).
46
+ *
47
+ * ---
48
+ * If the page being navigated to has lazy loading and isn't available yet, the
49
+ * previous components are supposed to be hidden during the transition, but
50
+ * because most React modals use React.createPortal to put content outside of
51
+ * the main DOM tree, React Router has no way of knowing about them. So open
52
+ * modals have a high risk of not disappearing until the page transition
53
+ * finishes and the previous components fully unmount.
54
+ */
55
+ export function PopoverLink ( {
56
+ children,
57
+ to,
58
+ sx,
59
+ ...linkProps
60
+ } : PopoverLinkProps ) {
61
+ const closePopover = useContext ( ClosePopoverContext ) ;
62
+ if ( closePopover === null ) {
63
+ throw new Error ( "PopoverLink is not located inside of a PopoverContainer" ) ;
64
+ }
65
+
66
+ // Luckily, useNavigate and Link are designed to be imperative/declarative
67
+ // mirrors of each other, so their inputs should never get out of sync
68
+ const navigate = useNavigate ( ) ;
69
+ const theme = useTheme ( ) ;
70
+
71
+ const onClick = ( event : MouseEvent < HTMLAnchorElement > ) => {
72
+ event . preventDefault ( ) ;
73
+ event . stopPropagation ( ) ;
74
+ closePopover ( ) ;
75
+
76
+ // Hacky, but by using a promise to push the navigation to resolve via the
77
+ // micro-task queue, there's guaranteed to be a period for the popover to
78
+ // close. Tried React DOM's flushSync function, but it was unreliable.
79
+ void Promise . resolve ( ) . then ( ( ) => {
80
+ navigate ( to , linkProps ) ;
81
+ } ) ;
82
+ } ;
83
+
84
+ return (
85
+ < Box
86
+ component = "a"
87
+ // Href still needed for accessibility reasons and semantic markup
88
+ href = "javascript:void(0)"
89
+ onClick = { onClick }
90
+ sx = { {
91
+ outline : "none" ,
92
+ textDecoration : "none" ,
93
+ "&:focus" : {
94
+ backgroundColor : theme . palette . action . focus ,
95
+ } ,
96
+ "&:hover" : {
97
+ textDecoration : "none" ,
98
+ backgroundColor : theme . palette . action . hover ,
99
+ } ,
100
+ ...sx ,
101
+ } }
102
+ >
103
+ { children }
104
+ </ Box >
105
+ ) ;
106
+ }
107
+
108
+ type PopoverContainerProps = PropsWithChildren < {
21
109
/**
22
110
* Does not require any hooks or refs to work. Also does not override any refs
23
111
* or event handlers attached to the button.
24
112
*/
25
113
anchorButton : ReactElement ;
114
+
26
115
width ?: number ;
27
116
originX ?: PopoverOrigin [ "horizontal" ] ;
28
117
originY ?: PopoverOrigin [ "vertical" ] ;
29
118
sx ?: SystemStyleObject < Theme > ;
30
119
} > ;
31
120
32
- function getButton ( container : HTMLElement ) {
33
- return (
34
- container . querySelector ( "button" ) ??
35
- container . querySelector ( '[aria-role="button"]' )
36
- ) ;
37
- }
38
-
39
121
export function PopoverContainer ( {
40
122
children,
41
123
anchorButton,
42
124
originX = 0 ,
43
125
originY = 0 ,
44
126
width = 320 ,
45
127
sx = { } ,
46
- } : Props ) {
128
+ } : PopoverContainerProps ) {
129
+ const parentClosePopover = useContext ( ClosePopoverContext ) ;
130
+ if ( parentClosePopover !== null ) {
131
+ throw new Error (
132
+ "Popover detected inside of Popover - this will always be a bad user experience" ,
133
+ ) ;
134
+ }
135
+
47
136
const buttonContainerRef = useRef < HTMLDivElement > ( null ) ;
48
137
49
138
// Ref value is for effects and event listeners; state value is for React
@@ -107,6 +196,10 @@ export function PopoverContainer({
107
196
}
108
197
} ;
109
198
199
+ const closePopover = useCallback ( ( ) => {
200
+ setLoadedButton ( undefined ) ;
201
+ } , [ ] ) ;
202
+
110
203
return (
111
204
< >
112
205
{ /* Cannot switch with Box component; breaks implementation */ }
@@ -124,26 +217,28 @@ export function PopoverContainer({
124
217
{ anchorButton }
125
218
</ div >
126
219
127
- < Popover
128
- open = { loadedButton !== undefined }
129
- anchorEl = { loadedButton }
130
- onClose = { ( ) => setLoadedButton ( undefined ) }
131
- anchorOrigin = { { horizontal : originX , vertical : originY } }
132
- sx = { {
133
- "& .MuiPaper-root" : {
134
- overflowY : "hidden" ,
135
- width,
136
- paddingY : 0 ,
137
- ...sx ,
138
- } ,
139
- } }
140
- transitionDuration = { {
141
- enter : 300 ,
142
- exit : 0 ,
143
- } }
144
- >
145
- { children }
146
- </ Popover >
220
+ < ClosePopoverContext . Provider value = { closePopover } >
221
+ < Popover
222
+ open = { loadedButton !== undefined }
223
+ anchorEl = { loadedButton }
224
+ onClose = { closePopover }
225
+ anchorOrigin = { { horizontal : originX , vertical : originY } }
226
+ sx = { {
227
+ "& .MuiPaper-root" : {
228
+ overflowY : "hidden" ,
229
+ width,
230
+ paddingY : 0 ,
231
+ ...sx ,
232
+ } ,
233
+ } }
234
+ transitionDuration = { {
235
+ enter : 300 ,
236
+ exit : 0 ,
237
+ } }
238
+ >
239
+ { children }
240
+ </ Popover >
241
+ </ ClosePopoverContext . Provider >
147
242
</ >
148
243
) ;
149
244
}
0 commit comments