-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
279 additions
and
200 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
Oops, something went wrong.