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.

  1. <Popover> as the root for trigger and content
  2. <Popover.Trigger> wraps the popover trigger
  3. <Popover.Content> holds the popover content, position could be set and not bleed outside of viewport
  4. <Popover.Close> wraps the element to dismiss the popover
  5. click outside of popover to dismiss
  6. 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.

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 onClick
ref, // TODO: we better merge the ref
});
tsx
const childrenToTriggerPopover = React.cloneElement(children, {
onClick, // TODO: we better merge the onClick
ref, // TODO: we better merge the ref
});

Key point 3 - useLayoutEffect() to position the popover

The final coordinates of popover is decided by 3 elements

  1. preferredPosition from props
  2. DOMRect of trigger
  3. 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 typings
const 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 focusable
const lastFocusable = focusables[focusables.length - 1];
if (document.activeElement === lastFocusable) {
// @ts-ignore, TODO: fix typing
focusables[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 typing
focusables[0]?.focus();
console.log("mount popover focusing", focusables[0]);
// 2. attach keyboard event listener to trap the focus
document.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 typings
const 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 focusable
const lastFocusable = focusables[focusables.length - 1];
if (document.activeElement === lastFocusable) {
// @ts-ignore, TODO: fix typing
focusables[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 typing
focusables[0]?.focus();
console.log("mount popover focusing", focusables[0]);
// 2. attach keyboard event listener to trap the focus
document.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-ignore
if (!element.contains(e.target)) {
console.log("clicked outside");
callback();
}
};
// delay it to avoid treating trigger click as click outside
window.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-ignore
if (!element.contains(e.target)) {
console.log("clicked outside");
callback();
}
};
// delay it to avoid treating trigger click as click outside
window.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!

😳 Would you like to share my post to more people ?    

❮ Prev: Distributivity in Typescript

Next: First look at fine-grained reactivity in Solid - SolidJS Source Code Walkthrough 1