@primer/react
Version:
An implementation of GitHub's Primer Design System using React
333 lines (323 loc) • 13.7 kB
JavaScript
import { useState, useCallback, useEffect } from 'react';
import { useFocusTrap } from '../hooks/useFocusTrap.js';
import { useFocusZone } from '../hooks/useFocusZone.js';
import { useId } from '../hooks/useId.js';
import { IconButton } from '../Button/IconButton.js';
import { XIcon } from '@primer/octicons-react';
import classes from './AnchoredOverlay.module.css.js';
import { clsx } from 'clsx';
import Overlay, { widthMap } from '../Overlay/Overlay.js';
import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
import { useFeatureFlag } from '../FeatureFlags/useFeatureFlag.js';
import { useProvidedRefOrCreate } from '../hooks/useProvidedRefOrCreate.js';
import { useRenderForcingRef } from '../hooks/useRenderForcingRef.js';
import { useAnchoredPosition } from '../hooks/useAnchoredPosition.js';
const defaultVariant = {
regular: 'anchored',
narrow: 'anchored'
};
const defaultCloseButtonProps = {};
/**
* An `AnchoredOverlay` provides an anchor that will open a floating overlay positioned relative to the anchor.
* The overlay can be opened and navigated using keyboard or mouse.
*/
const AnchoredOverlay = ({
renderAnchor,
anchorRef: externalAnchorRef,
anchorId: externalAnchorId,
children,
open,
onOpen,
onClose,
height,
width,
overlayProps,
focusTrapSettings,
focusZoneSettings,
side = (overlayProps === null || overlayProps === void 0 ? void 0 : overlayProps['anchorSide']) || 'outside-bottom',
align = 'start',
alignmentOffset,
anchorOffset,
displayInViewport,
className,
pinPosition,
variant = defaultVariant,
preventOverflow = true,
onPositionChange,
displayCloseButton = true,
closeButtonProps = defaultCloseButtonProps,
renderAs = 'portal'
}) => {
const cssAnchorPositioningFlag = useFeatureFlag('primer_react_css_anchor_positioning');
// Lazy initial state so feature detection runs once per mount on the client.
// Guarded for SSR where `document` is undefined.
const [supportsNativeCSSAnchorPositioning] = useState(() => typeof document !== 'undefined' && 'anchorName' in document.documentElement.style);
const cssAnchorPositioning = cssAnchorPositioningFlag && supportsNativeCSSAnchorPositioning && !(overlayProps !== null && overlayProps !== void 0 && overlayProps.portalContainerName);
// Only use Popover API when both CSS anchor positioning is enabled AND renderAs is true
const shouldRenderAsPopover = cssAnchorPositioning && renderAs === 'popover';
const anchorRef = useProvidedRefOrCreate(externalAnchorRef);
const [anchorElement, setAnchorElement] = useState(null);
// eslint-disable-next-line react-hooks/refs
if (anchorRef.current !== anchorElement) {
setAnchorElement(anchorRef.current);
}
const [overlayRef, updateOverlayRef] = useRenderForcingRef();
const [overlayElement, setOverlayElement] = useState(null);
const anchorId = useId(externalAnchorId);
const onClickOutside = useCallback(() => onClose === null || onClose === void 0 ? void 0 : onClose('click-outside'), [onClose]);
const onEscape = useCallback(() => onClose === null || onClose === void 0 ? void 0 : onClose('escape'), [onClose]);
const onAnchorKeyDown = useCallback(event => {
if (!event.defaultPrevented) {
if (!open && ['ArrowDown', 'ArrowUp', ' ', 'Enter'].includes(event.key)) {
onOpen === null || onOpen === void 0 ? void 0 : onOpen('anchor-key-press', event);
event.preventDefault();
}
}
}, [open, onOpen]);
const onAnchorClick = useCallback(event => {
if (event.defaultPrevented || event.button !== 0) {
return;
}
// Prevent the browser's native popovertarget toggle so React
// stays the single source of truth for popover visibility.
if (cssAnchorPositioning) {
event.preventDefault();
}
if (!open) {
onOpen === null || onOpen === void 0 ? void 0 : onOpen('anchor-click');
} else {
onClose === null || onClose === void 0 ? void 0 : onClose('anchor-click');
}
}, [open, onOpen, onClose, cssAnchorPositioning]);
const positionChange = position => {
if (onPositionChange && position) {
onPositionChange({
position
});
}
};
const {
position
} = useAnchoredPosition({
anchorElementRef: anchorRef,
floatingElementRef: overlayRef,
pinPosition,
side,
align,
alignmentOffset,
anchorOffset,
displayInViewport,
onPositionChange: positionChange,
// When native CSS anchor positioning is active, skip JS-based position
// computation, scroll listeners, and resize observers since the browser
// handles repositioning natively.
enabled: !cssAnchorPositioning
}, [overlayElement]);
useEffect(() => {
// ensure overlay ref gets cleared when closed, so position can reset between closing/re-opening
if (!open && overlayRef.current) {
updateOverlayRef(null);
}
}, [open, overlayRef, updateOverlayRef]);
useFocusZone({
containerRef: overlayRef,
disabled: !open || !position && !cssAnchorPositioning,
...focusZoneSettings
});
useFocusTrap({
containerRef: overlayRef,
disabled: !open || !position && !cssAnchorPositioning,
...focusTrapSettings
});
const popoverId = useId();
const id = popoverId.replaceAll(':', '_'); // popoverId can contain colons which are invalid in CSS custom property names, so we replace them with underscores
const anchorName = `--anchored-overlay-anchor-${id}`;
// Manage `anchor-name` on the anchor independently of `open`/`width` so a
// parent re-render that re-runs the positioning effect below doesn't
// briefly flicker the anchor link off and back on.
useEffect(() => {
if (!cssAnchorPositioning || !anchorElement) return;
if (anchorElement.style.getPropertyValue('anchor-name')) return;
anchorElement.style.setProperty('anchor-name', anchorName);
return () => {
if (anchorElement.style.getPropertyValue('anchor-name') === anchorName) {
anchorElement.style.removeProperty('anchor-name');
}
};
}, [cssAnchorPositioning, anchorElement, anchorName]);
useEffect(() => {
if (!cssAnchorPositioning || !anchorElement) return;
const currentOverlay = overlayRef.current;
const resolvedAnchorName = anchorElement.style.getPropertyValue('anchor-name') || anchorName;
let pendingPositionFrame = null;
if (open && currentOverlay) {
currentOverlay.style.setProperty('position-anchor', resolvedAnchorName);
// Defer the getBoundingClientRect read into a `requestAnimationFrame` so the style write above
// does not force a synchronous layout.
pendingPositionFrame = requestAnimationFrame(() => {
pendingPositionFrame = null;
const fallbackWidth = width ? parseInt(widthMap[width]) : parseInt(widthMap.small);
const result = getDefaultPosition(anchorElement, currentOverlay, fallbackWidth);
currentOverlay.setAttribute('data-align', result.horizontal);
if (result.suggestedSide) {
currentOverlay.setAttribute('data-side', result.suggestedSide);
}
const offset = result.horizontal === 'left' ? result.leftOffset : result.rightOffset;
currentOverlay.style.setProperty(`--anchored-overlay-anchor-offset-${result.horizontal}`, `${offset || 0}px`);
// Set y-axis offset to prevent overflow if needed.
const settledRect = currentOverlay.getBoundingClientRect();
const overflowBottom = settledRect.bottom - window.innerHeight;
if (overflowBottom > 0) {
const clampedTop = Math.max(0, settledRect.top - overflowBottom - 8);
currentOverlay.style.setProperty('--anchored-overlay-top-override', `${clampedTop}px`);
} else {
currentOverlay.style.removeProperty('--anchored-overlay-top-override');
}
});
// Only call showPopover when shouldRenderAsPopover is enabled
if (shouldRenderAsPopover) {
try {
if (!currentOverlay.matches(':popover-open')) {
currentOverlay.showPopover();
}
} catch {
// Ignore if popover is already showing or not supported
}
}
}
return () => {
if (pendingPositionFrame !== null) cancelAnimationFrame(pendingPositionFrame);
// The overlay may no longer be in the DOM at this point, so we need to check for its presence before trying to update it.
if (currentOverlay) {
currentOverlay.style.removeProperty('position-anchor');
}
};
// overlayRef is a stable ref object; including it in deps is unnecessary.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cssAnchorPositioning, shouldRenderAsPopover, open, anchorElement, overlayElement, id, width]);
const showXIcon = onClose && variant.narrow === 'fullscreen' && displayCloseButton;
const XButtonAriaLabelledBy = closeButtonProps['aria-labelledby'];
const XButtonAriaLabel = closeButtonProps['aria-label'];
const {
className: overlayClassName,
_PrivateDisablePortal,
...restOverlayProps
} = overlayProps || {};
return /*#__PURE__*/jsxs(Fragment, {
children: [renderAnchor &&
// eslint-disable-next-line react-hooks/refs
renderAnchor({
ref: anchorRef,
id: anchorId,
'aria-haspopup': 'true',
'aria-expanded': open,
tabIndex: 0,
onClick: onAnchorClick,
onKeyDown: onAnchorKeyDown,
...(shouldRenderAsPopover ? {
popoverTarget: popoverId
} : {})
}), open ? /*#__PURE__*/jsxs(Overlay, {
returnFocusRef: anchorRef,
onClickOutside: onClickOutside,
ignoreClickRefs: [anchorRef],
onEscape: onEscape,
role: "none",
visibility: cssAnchorPositioning || position ? 'visible' : 'hidden',
height: height,
width: width,
top: cssAnchorPositioning ? undefined : (position === null || position === void 0 ? void 0 : position.top) || 0,
left: cssAnchorPositioning ? undefined : (position === null || position === void 0 ? void 0 : position.left) || 0,
responsiveVariant: variant.narrow === 'fullscreen' ? 'fullscreen' : undefined,
anchorSide: cssAnchorPositioning ? undefined : position === null || position === void 0 ? void 0 : position.anchorSide,
className: clsx(className, overlayClassName, cssAnchorPositioning ? classes.AnchoredOverlay : undefined),
preventOverflow: preventOverflow,
"data-component": "AnchoredOverlay",
_PrivateDisablePortal: _PrivateDisablePortal,
...(shouldRenderAsPopover ? {
popover: 'manual'
} : {}),
...restOverlayProps,
...(shouldRenderAsPopover ? {
id: popoverId
} : {}),
ref: node => {
if (overlayProps !== null && overlayProps !== void 0 && overlayProps.ref) {
assignRef(overlayProps.ref, node);
}
updateOverlayRef(node);
setOverlayElement(node);
},
"data-anchor-position": cssAnchorPositioning,
"data-side": cssAnchorPositioning ? side : position === null || position === void 0 ? void 0 : position.anchorSide,
children: [showXIcon ? /*#__PURE__*/jsx("div", {
className: classes.ResponsiveCloseButtonContainer,
children: /*#__PURE__*/jsx(IconButton, {
...closeButtonProps,
type: "button",
variant: "invisible",
icon: XIcon,
...(XButtonAriaLabelledBy ? {
'aria-labelledby': XButtonAriaLabelledBy,
'aria-label': undefined
} : {
'aria-label': XButtonAriaLabel !== null && XButtonAriaLabel !== void 0 ? XButtonAriaLabel : 'Close',
'aria-labelledby': undefined
}),
className: clsx(classes.ResponsiveCloseButton, closeButtonProps.className),
onClick: () => {
onClose('close');
}
})
}) : null, children]
}) : null]
});
};
function getDefaultPosition(anchorElement, overlayElement, fallbackWidth) {
const anchorRect = anchorElement.getBoundingClientRect();
const overlayRect = overlayElement.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const margin = 8;
const overlayWidth = overlayRect.width || fallbackWidth;
const spaceLeft = anchorRect.left;
const spaceRight = vw - anchorRect.right;
const horizontal = spaceLeft > spaceRight ? 'left' : 'right';
// Suggest a flip when the overlay is currently overflowing both axes:
// prefer the side of the anchor with enough room, otherwise fall back to
// outside-bottom and let the offsets keep it inside the viewport.
const overflowsX = overlayRect.right > vw || overlayRect.left < 0;
const overflowsY = overlayRect.bottom > vh || overlayRect.top < 0;
let suggestedSide;
if (overflowsX && overflowsY) {
if (spaceLeft >= overlayWidth + margin) {
suggestedSide = 'outside-left';
} else if (spaceRight >= overlayWidth + margin) {
suggestedSide = 'outside-right';
} else {
suggestedSide = 'outside-bottom';
}
}
// If the viewport is too narrow to fit the overlay on either side, calculate offsets to prevent overflow.
let leftOffset;
let rightOffset;
if (spaceLeft < overlayWidth + margin && spaceRight < overlayWidth + margin) {
leftOffset = Math.max(0, overlayWidth - anchorRect.right + margin);
rightOffset = Math.max(0, anchorRect.left + overlayWidth - vw + margin);
}
return {
horizontal,
leftOffset,
rightOffset,
suggestedSide
};
}
function assignRef(ref, value) {
if (typeof ref === 'function') {
ref(value);
} else if (ref) {
ref.current = value;
}
}
AnchoredOverlay.displayName = 'AnchoredOverlay';
export { AnchoredOverlay };