Skip to content

Commit 2420167

Browse files
committed
chore: Add PopoverContainer component
1 parent f0a2aae commit 2420167

File tree

1 file changed

+142
-0
lines changed

1 file changed

+142
-0
lines changed
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import {
2+
type KeyboardEvent,
3+
type ReactElement,
4+
useEffect,
5+
useRef,
6+
useState,
7+
PropsWithChildren,
8+
} from "react";
9+
10+
import { type Theme, type SystemStyleObject } from "@mui/system";
11+
import Popover, { type PopoverOrigin } from "@mui/material/Popover";
12+
13+
type Props = PropsWithChildren<{
14+
/**
15+
* Does not require any hooks or refs to work. Also does not override any refs
16+
* or event handlers attached to the button.
17+
*/
18+
anchorButton: ReactElement;
19+
width?: number;
20+
originX?: PopoverOrigin["horizontal"];
21+
originY?: PopoverOrigin["vertical"];
22+
sx?: SystemStyleObject<Theme>;
23+
}>;
24+
25+
function getButton(container: HTMLElement) {
26+
return (
27+
container.querySelector("button") ??
28+
container.querySelector('[aria-role="button"]')
29+
);
30+
}
31+
32+
export function PopoverContainer({
33+
children,
34+
anchorButton,
35+
originX = 0,
36+
originY = 0,
37+
width = 320,
38+
sx = {},
39+
}: Props) {
40+
const buttonContainerRef = useRef<HTMLDivElement>(null);
41+
42+
// Ref value is for effects and event listeners; state value is for React
43+
// renders. Have to duplicate state because after the initial render, it's
44+
// never safe to reference ref contents inside a render path, especially with
45+
// React 18 concurrency. Duplication is a necessary evil because of MUI's
46+
// weird, clunky APIs
47+
const anchorButtonRef = useRef<HTMLButtonElement | null>(null);
48+
const [loadedButton, setLoadedButton] = useState<HTMLButtonElement>();
49+
50+
// Makes container listen to changes in button. If this approach becomes
51+
// untenable in the future, it can be replaced with React.cloneElement, but
52+
// the trade-off there is that every single anchorButton will need to be
53+
// wrapped inside React.forwardRef, making the abstraction leak a little more
54+
useEffect(() => {
55+
const buttonContainer = buttonContainerRef.current;
56+
if (buttonContainer === null) {
57+
throw new Error("Please attach container ref to button container");
58+
}
59+
60+
const initialButton = getButton(buttonContainer);
61+
if (initialButton === null) {
62+
throw new Error("Initial ref query failed");
63+
}
64+
anchorButtonRef.current = initialButton;
65+
66+
const onContainerMutation: MutationCallback = () => {
67+
const newButton = getButton(buttonContainer);
68+
if (newButton === null) {
69+
throw new Error("Semantic button removed after DOM update");
70+
}
71+
72+
anchorButtonRef.current = newButton;
73+
setLoadedButton((current) => {
74+
return current === undefined ? undefined : newButton;
75+
});
76+
};
77+
78+
const observer = new MutationObserver(onContainerMutation);
79+
observer.observe(buttonContainer, {
80+
childList: true,
81+
subtree: true,
82+
});
83+
84+
return () => observer.disconnect();
85+
}, []);
86+
87+
// Not using useInteractive because the container element is just meant to
88+
// catch events from the inner button, not act as a button itself
89+
const onInnerButtonInteraction = () => {
90+
if (anchorButtonRef.current === null) {
91+
throw new Error("Usable ref value is unavailable");
92+
}
93+
94+
setLoadedButton(anchorButtonRef.current);
95+
};
96+
97+
const onInnerButtonKeydown = (event: KeyboardEvent) => {
98+
if (event.key === "Enter" || event.key === " ") {
99+
onInnerButtonInteraction();
100+
}
101+
};
102+
103+
return (
104+
<>
105+
{/* Cannot switch with Box component; breaks implementation */}
106+
<div
107+
// Disabling semantics for the container does not affect the button
108+
// placed inside; the button should still be fully accessible
109+
role="none"
110+
tabIndex={-1}
111+
ref={buttonContainerRef}
112+
onClick={onInnerButtonInteraction}
113+
onKeyDown={onInnerButtonKeydown}
114+
// Only style that container should ever need
115+
style={{ width: "fit-content" }}
116+
>
117+
{anchorButton}
118+
</div>
119+
120+
<Popover
121+
open={loadedButton !== undefined}
122+
anchorEl={loadedButton}
123+
onClose={() => setLoadedButton(undefined)}
124+
anchorOrigin={{ horizontal: originX, vertical: originY }}
125+
sx={{
126+
"& .MuiPaper-root": {
127+
overflowY: "hidden",
128+
width,
129+
paddingY: 0,
130+
...sx,
131+
},
132+
}}
133+
transitionDuration={{
134+
enter: 300,
135+
exit: 0,
136+
}}
137+
>
138+
{children}
139+
</Popover>
140+
</>
141+
);
142+
}

0 commit comments

Comments
 (0)