UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

333 lines (323 loc) • 13.7 kB
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 };