Build an accessible Popover component in React
Itās interesting and challenging to build reusable components. In this post Iāll show my implementation of Popover component, which is inspired from tamagui
- 1. Requirements
- 2. Letās try the result first
- 3. Key point 1 - use context to glue things up
- 4. Key point 2 - React.cloneElement() to modify children rather than render a new wrapper element
- 5. Key point 3 - useLayoutEffect() to position the popover
- 6. Key point 4 - split components to avoid complexity in hook dep array
- 7. Key point 5 - useFocusTrapping()
- 8. Key point 6 - mergeRef()
- 9. Key point 7 - uesClickOutside()
- 10. Summary
Requirements
From the tamagui UI we can set following requirements.
<Popover>
as the root for trigger and content<Popover.Trigger>
wraps the popover trigger<Popover.Content>
holds the popover content, position could be set and not bleed outside of viewport<Popover.Close>
wraps the element to dismiss the popover- click outside of popover to dismiss
- accessiblity:
- focusable elements should be focused after popover is displayed and should not be able to focus elements outside of popover by keyboard
- trigger should be re-focused once popover is dismissed
Letās try the result first
The full code is on stackblitz, you can view the code and try it out the demo
Notice that it is just a rough demo without any tests.
spent over an hour building a Popover component with proper keyboard navigation, focus trapping, popover positioning .etc. pic.twitter.com/l9DorXqoyk
ā jser (@JSer_ZANP)
February 13, 2023
Key point 1 - use context to glue things up
Here is how the component should be used.
tsx
<Popover preferredPosition="bottom-center"><Popover.Trigger><button>show popover</button></Popover.Trigger><Popover.Content><input type="text" /><Popover.Close><button>close</button></Popover.Close></Popover.Content></Popover>
tsx
<Popover preferredPosition="bottom-center"><Popover.Trigger><button>show popover</button></Popover.Trigger><Popover.Content><input type="text" /><Popover.Close><button>close</button></Popover.Close></Popover.Content></Popover>
Popover.Trigger
could toggle <Popover.Content>
, but they are siblings. Other than hoisting the state with context or use customized event system, I couldnāt think about any other way to achieve the same.
Actually we have a few things in the context
ts
type PopoverContextType = {/*** whether popover is displayed*/isShow: boolean;/*** toggle the popover*/setIsShow: React.Dispatch<React.SetStateAction<boolean>>;/*** initial popover position, notice that it might be chosen because of viewport*/preferredPosition: Position;/*** the rect of trigger to calculate position of popover*/triggerRect: Rect;setTriggerRect: React.Dispatch<React.SetStateAction<Rect>>;};
ts
type PopoverContextType = {/*** whether popover is displayed*/isShow: boolean;/*** toggle the popover*/setIsShow: React.Dispatch<React.SetStateAction<boolean>>;/*** initial popover position, notice that it might be chosen because of viewport*/preferredPosition: Position;/*** the rect of trigger to calculate position of popover*/triggerRect: Rect;setTriggerRect: React.Dispatch<React.SetStateAction<Rect>>;};
Key point 2 - React.cloneElement() to modify children rather than render a new wrapper element
In tamagui, it is a special prop asChild
(ref),
When true, Tamagui expects a single child element. Instead of rendering its own element, it will pass all props to that child, merging together any event handling props
It is perfect for <Popover.Trigger>
and <Popover.Close>
since they only add extra behavior to its children. It only creates trouble if they render their own DOM element, especially in complex layout.
We can achieve this by using React.cloneElement()
.
tsx
const childrenToTriggerPopover = React.cloneElement(children, {onClick, // TODO: we better merge the onClickref, // TODO: we better merge the ref});
tsx
const childrenToTriggerPopover = React.cloneElement(children, {onClick, // TODO: we better merge the onClickref, // TODO: we better merge the ref});
Key point 3 - useLayoutEffect() to position the popover
The final coordinates of popover is decided by 3 elements
- preferredPosition from props
- DOMRect of trigger
- DOMRect of popover
But we cannot get DOMRect of popover unless it is in the DOM, so we need get it from useLayoutEffect()
.
ts
useLayoutEffect(() => {const element = ref.current;if (element == null) {return;}const rect = element.getBoundingClientRect();const coords = getPopoverCoords(triggerRect, rect, preferredPosition);setCoords(coords);}, []);
ts
useLayoutEffect(() => {const element = ref.current;if (element == null) {return;}const rect = element.getBoundingClientRect();const coords = getPopoverCoords(triggerRect, rect, preferredPosition);setCoords(coords);}, []);
Key point 4 - split components to avoid complexity in hook dep array
I found it a bit hard to get it right in setting deps array of hooks, especially useEffect()
. In this task, we have to use quite some ref
to hold reference to the DOM and attach/detach event listener, it is quite easy to make mistakes.
One thing I learned is to keep the deps array simple as possible, better just []
to indicate the setup and cleanup on mount and unmount. For other conditions, put it in parent component, the example in our code is ContentInternal
.
tsx
function Content({ children }: { children: React.ReactNode }) {const { isShow } = useContext(PopoverContext);if (!isShow) {return null;}return <ContentInternal>{children}</ContentInternal>;}
tsx
function Content({ children }: { children: React.ReactNode }) {const { isShow } = useContext(PopoverContext);if (!isShow) {return null;}return <ContentInternal>{children}</ContentInternal>;}
If we put everything in Content
, the effect hooks in ContentInternal
will need a deps array of isShow
, which makes the code harder to read.
Key point 5 - useFocusTrapping()
The code explains itself so Iāll just leave the code here.
ts
function useFocusTrapping() {// @ts-ignore TODO: fix the typingsconst refTrigger = useRef<HTMLElement>(document.activeElement);const ref = useRef<HTMLElement>(null);const onKeyDown = useCallback((e: KeyboardEvent) => {const popover = ref.current;if (popover == null) {return;}const focusables = [...popover.querySelectorAll(focusableQuery)];switch (e.key) {case "Tab":// check if it is the last focusableconst lastFocusable = focusables[focusables.length - 1];if (document.activeElement === lastFocusable) {// @ts-ignore, TODO: fix typingfocusables[0]?.focus();e.preventDefault();}}}, []);useEffect(() => {const popover = ref.current;if (popover == null) {return;}const focusables = [...popover.querySelectorAll(focusableQuery)];// 1. focus the first focusable// @ts-ignore, TODO: fix typingfocusables[0]?.focus();console.log("mount popover focusing", focusables[0]);// 2. attach keyboard event listener to trap the focusdocument.addEventListener("keydown", onKeyDown);return () => {document.removeEventListener("keydown", onKeyDown);// 3. refocus the trigger after dismissing// but only if the current activeElement is body// since this happens after popover is gone// TODO: am I right about this?const trigger = refTrigger.current;const currentActiveElement = document.activeElement;if (currentActiveElement == document.body) {trigger?.focus();}};}, []);return ref;}
ts
function useFocusTrapping() {// @ts-ignore TODO: fix the typingsconst refTrigger = useRef<HTMLElement>(document.activeElement);const ref = useRef<HTMLElement>(null);const onKeyDown = useCallback((e: KeyboardEvent) => {const popover = ref.current;if (popover == null) {return;}const focusables = [...popover.querySelectorAll(focusableQuery)];switch (e.key) {case "Tab":// check if it is the last focusableconst lastFocusable = focusables[focusables.length - 1];if (document.activeElement === lastFocusable) {// @ts-ignore, TODO: fix typingfocusables[0]?.focus();e.preventDefault();}}}, []);useEffect(() => {const popover = ref.current;if (popover == null) {return;}const focusables = [...popover.querySelectorAll(focusableQuery)];// 1. focus the first focusable// @ts-ignore, TODO: fix typingfocusables[0]?.focus();console.log("mount popover focusing", focusables[0]);// 2. attach keyboard event listener to trap the focusdocument.addEventListener("keydown", onKeyDown);return () => {document.removeEventListener("keydown", onKeyDown);// 3. refocus the trigger after dismissing// but only if the current activeElement is body// since this happens after popover is gone// TODO: am I right about this?const trigger = refTrigger.current;const currentActiveElement = document.activeElement;if (currentActiveElement == document.body) {trigger?.focus();}};}, []);return ref;}
Key point 6 - mergeRef()
This is quite common and useful when we have multiple behavior on one same DOM element.
ts
function mergeRef(...refs) {return (el) => {refs.forEach((ref) => {if (typeof ref === "function") {ref(el);} else {ref.current = el;}});};}
ts
function mergeRef(...refs) {return (el) => {refs.forEach((ref) => {if (typeof ref === "function") {ref(el);} else {ref.current = el;}});};}
Key point 7 - uesClickOutside()
One thing I feel bad is the hack of setTimeout()
, do you happen to know a better way?
tsx
function uesClickOutside(callback: () => void) {const ref = useRef<HTMLElement>(null);useEffect(() => {const element = ref.current;if (element == null) {return;}const onClick = (e: MouseEvent) => {// @ts-ignoreif (!element.contains(e.target)) {console.log("clicked outside");callback();}};// delay it to avoid treating trigger click as click outsidewindow.setTimeout(() => document.addEventListener("click", onClick), 0);return () => {window.setTimeout(() => document.removeEventListener("click", onClick),0);};}, []);return ref;}
tsx
function uesClickOutside(callback: () => void) {const ref = useRef<HTMLElement>(null);useEffect(() => {const element = ref.current;if (element == null) {return;}const onClick = (e: MouseEvent) => {// @ts-ignoreif (!element.contains(e.target)) {console.log("clicked outside");callback();}};// delay it to avoid treating trigger click as click outsidewindow.setTimeout(() => document.addEventListener("click", onClick), 0);return () => {window.setTimeout(() => document.removeEventListener("click", onClick),0);};}, []);return ref;}
Summary
It is not easy to implement a reusable component. I plan to create more components and put them in my github as learning material, stay tuned.
Want to know more about how React works internally?
Check out my series - React Internals Deep Dive!