UNPKG

@hypothesis/frontend-shared

Version:

Shared components, styles and utilities for Hypothesis projects

270 lines (260 loc) 10.8 kB
var _jsxFileName = "/home/runner/work/frontend-shared/frontend-shared/src/components/feedback/Popover.tsx"; import classnames from 'classnames'; import { useCallback, useEffect, useLayoutEffect } from 'preact/hooks'; import { useClickAway } from '../../hooks/use-click-away'; import { useKeyPress } from '../../hooks/use-key-press'; import { useSyncedRef } from '../../hooks/use-synced-ref'; import { ListenerCollection } from '../../util/listener-collection'; import { downcastRef } from '../../util/typing'; /** Small space to apply between the anchor element and the popover */ import { jsxDEV as _jsxDEV } from "preact/jsx-dev-runtime"; const POPOVER_ANCHOR_EL_GAP = '.15rem'; /** * 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; /** * Manages the popover position manually to make sure it renders "next" to the * anchor 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 anchor 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 anchor element or not */ function usePopoverPositioning(anchorElementRef, popoverRef, popoverOpen, asNativePopover, alignToRight) { const adjustPopoverPositioning = useCallback(() => { const popoverEl = popoverRef.current; const anchorEl = anchorElementRef.current; /** * Set the positioning styles synchronously (not via <div style={computedStyles} />), * to make sure positioning happens before other side effects. * @return - A callback that undoes the property assignments */ const setPopoverCSSProps = props => { Object.assign(popoverEl.style, props); const keys = Object.keys(props); return () => keys.map(prop => popoverEl.style[prop] = ''); }; const viewportHeight = window.innerHeight; const { top: anchorElDistanceToTop, bottom: anchorElBottom, left: anchorElLeft, height: anchorElHeight, width: anchorElWidth } = anchorEl.getBoundingClientRect(); const anchorElDistanceToBottom = viewportHeight - anchorElBottom; const { height: popoverHeight, width: popoverWidth } = popoverEl.getBoundingClientRect(); // The popover should render above only if there's not enough space below to // fit it and there's more absolute space above than below const shouldBeAbove = anchorElDistanceToBottom < popoverHeight && anchorElDistanceToTop > anchorElDistanceToBottom; if (!asNativePopover) { // Set styles for non-popover mode if (shouldBeAbove) { return setPopoverCSSProps({ bottom: '100%', marginBottom: POPOVER_ANCHOR_EL_GAP }); } return setPopoverCSSProps({ top: '100%', marginTop: POPOVER_ANCHOR_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 anchor element to // right side of viewport // - right-aligned popovers: distance from right side of anchor element to // left side of viewport const availableSpace = (alignToRight ? anchorElLeft + anchorElWidth : bodyWidth - anchorElLeft) - POPOVER_VIEWPORT_HORIZONTAL_GAP; let left = anchorElLeft; 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 > anchorElWidth) { // If a right-aligned popover fits the available space, but it's bigger // than the anchor element, move it to the left so that it is aligned with // the right side of the element left -= popoverWidth - anchorElWidth; } return setPopoverCSSProps({ minWidth: `${anchorElWidth}px`, top: shouldBeAbove ? `calc(${absBodyTop + anchorElDistanceToTop - popoverHeight}px - ${POPOVER_ANCHOR_EL_GAP})` : `calc(${absBodyTop + anchorElDistanceToTop + anchorElHeight}px + ${POPOVER_ANCHOR_EL_GAP})`, left: `${Math.max(POPOVER_VIEWPORT_HORIZONTAL_GAP, left)}px` }); }, [asNativePopover, anchorElementRef, popoverRef, alignToRight]); useLayoutEffect(() => { if (!popoverOpen) { return () => {}; } // First of all, open popover if it's using the native API, otherwise its // size is 0x0 and positioning calculations won't work. const popover = popoverRef.current; if (asNativePopover) { popover.togglePopover(true); } const cleanup = adjustPopoverPositioning(); if (!asNativePopover) { return cleanup; } // Readjust popover position when any element scrolls, just in case that // affected the anchor element position. const listeners = new ListenerCollection(); listeners.add(document.body, 'scroll', adjustPopoverPositioning, { capture: true }); // Readjust popover positioning if its resized, in case it dropped-up, and // it needs to be moved down const observer = new ResizeObserver(adjustPopoverPositioning); observer.observe(popover); return () => { if (asNativePopover) { popover === null || popover === void 0 || popover.togglePopover(false); } cleanup(); listeners.removeAll(); observer.disconnect(); }; }, [adjustPopoverPositioning, asNativePopover, popoverOpen, popoverRef]); } /** * Add the right listeners to the popover so that `onClose` is called when * clicking away or pressing `Escape`. */ function useOnClose(popoverRef, anchorElementRef, onClose, popoverOpen, asNativePopover) { // When the popover API is used, listen for the `toggle` event and call // onClose() when transitioning from `open` to `closed`. // This happens when clicking away or pressing `Escape` key. useEffect(() => { if (!asNativePopover) { return () => {}; } const popover = popoverRef.current; const toggleListener = e => { if (e.oldState === 'open' && e.newState === 'closed') { onClose(); } }; popover.addEventListener('toggle', toggleListener); return () => popover.removeEventListener('toggle', toggleListener); }, [asNativePopover, onClose, popoverRef]); // When the popover API is not used, manually add listeners for Escape key // press and click away, to mimic the native popover behavior. // Disable these while the popover is closed, otherwise trying to open it // by interacting with some other element will trigger a click-away and // immediately close the popover after it opens.. const enabled = popoverOpen && !asNativePopover; useClickAway(popoverRef, e => { // Ignore clicking "away" when the target is the anchor element. // In most cases, popovers will be anchored to a "toggle" which is // supposed to open/close the popover on click, so closing-on-click-away // when they are the target will cause the popover to close and // immediately open again. if (!e.composedPath().includes(anchorElementRef.current)) { onClose(); } }, { enabled }); useKeyPress(['Escape'], onClose, { enabled }); } /** * Restore focus to the previously active element when a popover is closed. */ function useRestoreFocusOnClose({ popoverRef, open }) { useLayoutEffect(() => { const container = popoverRef.current; const restoreFocusTo = open ? document.activeElement : null; if (!container || !restoreFocusTo) { return () => {}; } return () => { // When a popover is opened and then closed, there are several // possibilities for what happens to the focus: // // 1. The focus may be unchanged from before the popover was opened. // // 2. The focus may have moved into the popover when it was opened, and // then back to either the previously focused element or the body when // it was closed. // // When a native popover is closed via `togglePopover` or `hidePopover`, // focus will revert to the element that was focused at the time the // popover was shown. See https://html.spec.whatwg.org/multipage/popover.html#dom-hidepopover. // // 3. The user may have clicked an element outside the popover, focusing // that element and causing the popover to close. // // From the above cases, we only need to restore focus if it is still // inside the popover, or focus reverted to the document body. const currentFocus = document.activeElement; if (currentFocus && !container.contains(currentFocus) && currentFocus !== document.body) { return; } restoreFocusTo.focus(); }; }, [popoverRef, open]); } export default function Popover({ anchorElementRef, children, open, onClose, align = 'left', classes, variant = 'panel', onScroll, elementRef, /* eslint-disable-next-line no-prototype-builtins */ asNativePopover = HTMLElement.prototype.hasOwnProperty('popover') }) { const popoverRef = useSyncedRef(elementRef); usePopoverPositioning(anchorElementRef, popoverRef, open, asNativePopover, align === 'right'); useOnClose(popoverRef, anchorElementRef, onClose, open, asNativePopover); useRestoreFocusOnClose({ open, popoverRef: popoverRef }); return _jsxDEV("div", { className: classnames('absolute z-5', variant === 'panel' && ['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': align === 'right', 'min-w-full': true }, classes), ref: downcastRef(popoverRef), popover: asNativePopover && 'auto', onScroll: onScroll, "data-testid": "popover", "data-component": "Popover", children: open && children }, void 0, false, { fileName: _jsxFileName, lineNumber: 356, columnNumber: 5 }, this); } //# sourceMappingURL=Popover.js.map