@primer/react
Version:
An implementation of GitHub's Primer Design System using React
272 lines (271 loc) • 12.5 kB
JavaScript
import { reactMajorVersion } from "../utils/environment.js";
import { useProvidedRefOrCreate } from "../hooks/useProvidedRefOrCreate.js";
import { useAnchoredPosition } from "../hooks/useAnchoredPosition.js";
import { useRenderForcingRef } from "../hooks/useRenderForcingRef.js";
import { useId as useId$1 } from "../hooks/useId.js";
import { IconButton } from "../Button/IconButton.js";
import { useFocusTrap } from "../hooks/useFocusTrap.js";
import { useFocusZone } from "../hooks/useFocusZone.js";
import { useFeatureFlag } from "../FeatureFlags/useFeatureFlag.js";
import Overlay from "../Overlay/Overlay.js";
import AnchoredOverlay_module_css_default from "./AnchoredOverlay.module.css.js";
import { widthMap } from "../Overlay/constants.js";
import { clsx } from "clsx";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { useCallback, useEffect, useState } from "react";
import { XIcon } from "@primer/octicons-react";
//#region src/AnchoredOverlay/AnchoredOverlay.tsx
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", cssAnchorPositioningSettings }) => {
const cssAnchorPositioningFlag = useFeatureFlag("primer_react_css_anchor_positioning");
const [supportsNativeCSSAnchorPositioning] = useState(() => typeof document !== "undefined" && "anchorName" in document.documentElement.style && "positionTryFallbacks" in document.documentElement.style && "positionVisibility" in document.documentElement.style);
const cssAnchorPositioning = cssAnchorPositioningFlag && supportsNativeCSSAnchorPositioning && !(overlayProps !== null && overlayProps !== void 0 && overlayProps.portalContainerName) && !(cssAnchorPositioningSettings !== null && cssAnchorPositioningSettings !== void 0 && cssAnchorPositioningSettings.disable);
const shouldRenderAsPopover = cssAnchorPositioning && renderAs === "popover";
const anchorRef = useProvidedRefOrCreate(externalAnchorRef);
const [anchorElement, setAnchorElement] = useState(null);
if (anchorRef.current !== anchorElement) setAnchorElement(anchorRef.current);
const [overlayRef, updateOverlayRef] = useRenderForcingRef();
const [overlayElement, setOverlayElement] = useState(null);
const anchorId = useId$1(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 || onOpen("anchor-key-press", event);
event.preventDefault();
}
}
}, [open, onOpen]);
const onAnchorClick = useCallback((event) => {
if (event.defaultPrevented || event.button !== 0) return;
if (cssAnchorPositioning) event.preventDefault();
if (!open) onOpen === null || onOpen === void 0 || onOpen("anchor-click");
else onClose === null || onClose === 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,
enabled: open && !cssAnchorPositioning
}, [overlayElement]);
useEffect(() => {
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$1();
const popoverTargetProps = shouldRenderAsPopover ? reactMajorVersion >= 19 ? { popoverTarget: popoverId } : { popovertarget: popoverId } : {};
const id = popoverId.replaceAll(":", "_");
const anchorName = `--anchored-overlay-anchor-${id}`;
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) {
var _cssAnchorPositioning;
currentOverlay.style.setProperty("position-anchor", resolvedAnchorName);
const positionTryFallbacks = getCSSAnchorPositionTryFallbacks(side, (_cssAnchorPositioning = cssAnchorPositioningSettings === null || cssAnchorPositioningSettings === void 0 ? void 0 : cssAnchorPositioningSettings.fallbackStrategy) !== null && _cssAnchorPositioning !== void 0 ? _cssAnchorPositioning : "default");
if (positionTryFallbacks) currentOverlay.style.setProperty("position-try-fallbacks", positionTryFallbacks);
else currentOverlay.style.removeProperty("position-try-fallbacks");
pendingPositionFrame = requestAnimationFrame(() => {
pendingPositionFrame = null;
const result = getDefaultPosition(anchorElement, currentOverlay, width ? parseInt(widthMap[width]) : parseInt(widthMap.small));
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`);
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");
});
if (shouldRenderAsPopover) try {
if (!currentOverlay.matches(":popover-open")) currentOverlay.showPopover();
} catch {}
}
return () => {
if (pendingPositionFrame !== null) cancelAnimationFrame(pendingPositionFrame);
if (currentOverlay) {
currentOverlay.style.removeProperty("position-anchor");
currentOverlay.style.removeProperty("position-try-fallbacks");
}
};
}, [
cssAnchorPositioning,
shouldRenderAsPopover,
open,
anchorElement,
overlayElement,
id,
width,
side,
cssAnchorPositioningSettings === null || cssAnchorPositioningSettings === void 0 ? void 0 : cssAnchorPositioningSettings.fallbackStrategy
]);
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 && renderAnchor({
ref: anchorRef,
id: anchorId,
"aria-haspopup": "true",
"aria-expanded": open,
tabIndex: 0,
onClick: onAnchorClick,
onKeyDown: onAnchorKeyDown,
...popoverTargetProps
}), open ? /*#__PURE__*/ jsxs(Overlay, {
returnFocusRef: anchorRef,
onClickOutside,
ignoreClickRefs: [anchorRef],
onEscape,
role: "none",
visibility: cssAnchorPositioning || position ? "visible" : "hidden",
height,
width,
top: cssAnchorPositioning ? void 0 : (position === null || position === void 0 ? void 0 : position.top) || 0,
left: cssAnchorPositioning ? void 0 : (position === null || position === void 0 ? void 0 : position.left) || 0,
responsiveVariant: variant.narrow === "fullscreen" ? "fullscreen" : void 0,
anchorSide: cssAnchorPositioning ? void 0 : position === null || position === void 0 ? void 0 : position.anchorSide,
className: clsx(className, overlayClassName, cssAnchorPositioning ? AnchoredOverlay_module_css_default.AnchoredOverlay : void 0),
preventOverflow,
"data-component": "AnchoredOverlay",
_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: AnchoredOverlay_module_css_default.ResponsiveCloseButtonContainer,
children: /*#__PURE__*/ jsx(IconButton, {
...closeButtonProps,
"data-component": "AnchoredOverlay.CloseButton",
type: "button",
variant: "invisible",
icon: XIcon,
...XButtonAriaLabelledBy ? {
"aria-labelledby": XButtonAriaLabelledBy,
"aria-label": void 0
} : {
"aria-label": XButtonAriaLabel !== null && XButtonAriaLabel !== void 0 ? XButtonAriaLabel : "Close",
"aria-labelledby": void 0
},
className: clsx(AnchoredOverlay_module_css_default.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";
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";
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 getCSSAnchorPositionTryFallbacks(side, strategy) {
if (strategy === "none") return "none";
if (strategy === "opposite-side") switch (side) {
case "outside-top":
case "outside-bottom":
case "inside-top":
case "inside-bottom": return "flip-block";
case "outside-left":
case "outside-right":
case "inside-left":
case "inside-right": return "flip-inline";
default: return;
}
}
function assignRef(ref, value) {
if (typeof ref === "function") ref(value);
else if (ref) ref.current = value;
}
AnchoredOverlay.displayName = "AnchoredOverlay";
//#endregion
export { AnchoredOverlay };