Skip to content

Commit

Permalink
Create new Popover component
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Nov 21, 2024
1 parent 08dc645 commit 5c46c89
Show file tree
Hide file tree
Showing 3 changed files with 279 additions and 200 deletions.
259 changes: 259 additions & 0 deletions src/components/feedback/Popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import classnames from 'classnames';
import type { ComponentChildren, RefObject } from 'preact';
import { useCallback, useLayoutEffect, useRef } from 'preact/hooks';

import { ListenerCollection } from '../../util/listener-collection';

/** Small space to apply between the relative element and the popover */
const POPOVER_RELATIVE_EL_GAP = '.25rem';

/**
* Space in pixels to apply between the popover and the viewport sides to
* prevent it from growing to the very edges.
*/
export const POPOVER_VIEWPORT_HORIZONTAL_GAP = 8;

type PopoverCSSProps =
| 'top'
| 'left'
| 'minWidth'
| 'marginBottom'
| 'bottom'
| 'marginTop';

/**
* Manages the popover position manually to make sure it renders "next" to the
* relative element (above or below). This is mainly needed when using the
* popover API, as that makes it render in the top layer, making it impossible
* to position it relative to the element via regular CSS.
*
* @param asNativePopover - Native popover API is used to toggle the popover
* @param alignToRight - Whether the popover should be aligned to the right side
* of the relative element or not
*/
function usePopoverPositioning(
relativeElementRef: RefObject<HTMLElement | undefined>,
popoverRef: RefObject<HTMLElement | undefined>,
popoverOpen: boolean,
asNativePopover: boolean,
alignToRight: boolean,
) {
const adjustPopoverPositioning = useCallback(() => {
const popoverEl = popoverRef.current;
const relativeEl = relativeElementRef.current;

if (!relativeEl || !popoverEl || !popoverOpen) {
return () => {};
}

/**
* Set the positioning styles synchronously (not via `style` prop and a
* piece of state), to make sure positioning happens before other side
* effects.
*/
const setPopoverCSSProps = (
props: Partial<Record<PopoverCSSProps, string>>,
) => {
Object.assign(popoverEl.style, props);
const keys = Object.keys(props) as PopoverCSSProps[];
return () => keys.map(prop => (popoverEl.style[prop] = ''));
};

const viewportHeight = window.innerHeight;
const {
top: relativeElDistanceToTop,
bottom: relativeElBottom,
left: relativeElLeft,
height: relativeElHeight,
width: relativeElWidth,
} = relativeEl.getBoundingClientRect();
const relativeElDistanceToBottom = viewportHeight - relativeElBottom;
const { height: popoverHeight, width: popoverWidth } =
popoverEl.getBoundingClientRect();

// The popover should render above only if there's not enough space below to
// fit it, and also, there's more absolute space above than below
const shouldBeAbove =
relativeElDistanceToBottom < popoverHeight &&
relativeElDistanceToTop > relativeElDistanceToBottom;

if (!asNativePopover) {
// Set styles for non-popover mode
if (shouldBeAbove) {
return setPopoverCSSProps({
bottom: '100%',
marginBottom: POPOVER_RELATIVE_EL_GAP,
});
}

return setPopoverCSSProps({
top: '100%',
marginTop: POPOVER_RELATIVE_EL_GAP,
});
}

const { top: bodyTop, width: bodyWidth } =
document.body.getBoundingClientRect();
const absBodyTop = Math.abs(bodyTop);

// The available space is:
// - left-aligned popovers: distance from left side of relative element to
// right side of viewport
// - right-aligned popovers: distance from right side of relative element
// to left side of viewport
const availableSpace =
(alignToRight
? relativeElLeft + relativeElWidth
: bodyWidth - relativeElLeft) - POPOVER_VIEWPORT_HORIZONTAL_GAP;

let left = relativeElLeft;
if (popoverWidth > availableSpace) {
// If the popover is not going to fit the available space, let it "grow"
// in the opposite direction
left = alignToRight
? POPOVER_VIEWPORT_HORIZONTAL_GAP
: left - (popoverWidth - availableSpace);
} else if (alignToRight && popoverWidth > relativeElWidth) {
// If a right-aligned popover fits the available space, but it's bigger
// than the relative element, move it to the left so that it is aligned
// with the right side of the element
left -= popoverWidth - relativeElWidth;
}

return setPopoverCSSProps({
minWidth: `${relativeElWidth}px`,
top: shouldBeAbove
? `calc(${absBodyTop + relativeElDistanceToTop - popoverHeight}px - ${POPOVER_RELATIVE_EL_GAP})`
: `calc(${absBodyTop + relativeElDistanceToTop + relativeElHeight}px + ${POPOVER_RELATIVE_EL_GAP})`,
left: `${Math.max(POPOVER_VIEWPORT_HORIZONTAL_GAP, left)}px`,
});
}, [
asNativePopover,
relativeElementRef,
popoverOpen,
popoverRef,
alignToRight,
]);

useLayoutEffect(() => {
// First of all, toggle popover if it's using the native API, otherwise its
// size is 0x0 and positioning calculations won't work.
if (asNativePopover) {
popoverRef.current?.togglePopover(popoverOpen);
}

const cleanup = adjustPopoverPositioning();

if (!asNativePopover) {
return cleanup;
}

// Readjust popover position when any element scrolls, just in case that
// affected the relative element position.
const listeners = new ListenerCollection();
listeners.add(document.body, 'scroll', adjustPopoverPositioning, {
capture: true,
});

return () => {
cleanup();
listeners.removeAll();
};
}, [adjustPopoverPositioning, asNativePopover, popoverOpen, popoverRef]);
}

export type PopoverProps = {
children?: ComponentChildren;
classes?: string | string[];

/** Whether the popover is currently open or not. Defaults to false */
open?: boolean;
/** The element relative to which the popover should be positioned */
relativeTo: RefObject<HTMLElement | undefined>;

elementRef?: RefObject<HTMLElement | undefined>;

/**
* Determines to what side of the relative element should the popover be
* aligned
*/
alignSide?: 'right' | 'left';

/**
* Determines if focus should be restored when the popover is closed.
* Defaults to true.
*/
restoreFocusOnClose?: boolean;

/**
* Used to determine if the popover API should be used.
* Defaults to true, as long as the browser supports it.
*/
asNativePopover?: boolean;
};

export default function Popover({
relativeTo,
children,
open = false,
alignSide = 'left',
classes,
restoreFocusOnClose = true,
/* eslint-disable-next-line no-prototype-builtins */
asNativePopover = HTMLElement.prototype.hasOwnProperty('popover'),
}: PopoverProps) {
const popoverRef = useRef<HTMLDivElement | null>(null);
const restoreFocusEl = useRef<HTMLElement | null>(null);

usePopoverPositioning(
relativeTo,
popoverRef,
open,
asNativePopover,
alignSide === 'right',
);

useLayoutEffect(() => {
restoreFocusEl.current = open
? (document.activeElement as HTMLElement)
: null;

return () => {
if (restoreFocusOnClose) {
restoreFocusEl.current?.focus();
}
};
}, [open, restoreFocusOnClose]);

return (
<div
className={classnames(
'absolute z-5 max-h-80 overflow-y-auto overflow-x-hidden',
'rounded border bg-white shadow hover:shadow-md focus-within:shadow-md',
asNativePopover && [
// We don't want the popover to ever render outside the viewport,
// and we give it a 16px gap
'max-w-[calc(100%-16px)]',
// Overwrite [popover] default styles
'p-0 m-0',
],
!asNativePopover && {
// Hiding instead of unmounting so that popover size can be computed
// to position it above or below
hidden: !open,
'right-0': alignSide === 'right',
'min-w-full': true,
},
classes,
)}
ref={popoverRef}
// nb. Use `undefined` rather than `false` because Preact doesn't
// handle boolean values correctly for this attribute (it will set
// `popover="false"` instead of removing the attribute).
popover={asNativePopover ? 'auto' : undefined}
data-testid="popover"
>
{children}
</div>
);
}
Loading

0 comments on commit 5c46c89

Please sign in to comment.