UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

272 lines (271 loc) • 12.5 kB
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 };