UNPKG

@carbon/react

Version:

React components for the Carbon Design System

336 lines (334 loc) 11.8 kB
/** * Copyright IBM Corp. 2016, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ import { usePrefix } from "../../internal/usePrefix.js"; import useIsomorphicEffect from "../../internal/useIsomorphicEffect.js"; import { deprecateValuesWithin } from "../../prop-types/deprecateValuesWithin.js"; import { isComponentElement } from "../../internal/utils.js"; import { useMergedRefs } from "../../internal/useMergedRefs.js"; import { useEvent, useWindowEvent } from "../../internal/useEvent.js"; import { mapPopoverAlign } from "../../tools/mapPopoverAlign.js"; import { useFeatureFlag } from "../FeatureFlags/index.js"; import classNames from "classnames"; import React, { forwardRef, useEffect, useMemo, useRef } from "react"; import PropTypes from "prop-types"; import { jsx, jsxs } from "react/jsx-runtime"; import { arrow, autoUpdate, flip, hide, offset, useFloating } from "@floating-ui/react"; //#region src/components/Popover/index.tsx /** * Copyright IBM Corp. 2016, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ const PopoverContext = React.createContext({ setFloating: { current: null }, caretRef: { current: null }, autoAlign: null }); const Popover = React.forwardRef(function PopoverRenderFunction({ isTabTip, align: initialAlign = isTabTip ? "bottom-start" : "bottom", as: BaseComponent = "span", autoAlign = false, autoAlignBoundary, backgroundToken = "layer", caret = !isTabTip, className: customClassName, children, border = false, dropShadow = true, highContrast = false, onRequestClose, open, alignmentAxisOffset, ...rest }, forwardRef) { const prefix = usePrefix(); const floating = useRef(null); const caretRef = useRef(null); const popover = useRef(null); const enableFloatingStyles = useFeatureFlag("enable-v12-dynamic-floating-styles") || autoAlign; const lastClickWasInsidePopoverContent = useRef(false); let align = mapPopoverAlign(initialAlign); useEvent(popover, "mousedown", (event) => { const target = event.target; lastClickWasInsidePopoverContent.current = refs.floating.current?.contains(target) || false; if (lastClickWasInsidePopoverContent.current) setTimeout(() => { lastClickWasInsidePopoverContent.current = false; }, 0); }); useEvent(popover, "focusout", (event) => { const relatedTarget = event.relatedTarget; if (!relatedTarget) { if (lastClickWasInsidePopoverContent.current) { lastClickWasInsidePopoverContent.current = false; return; } onRequestClose?.(); } else if (relatedTarget && !popover.current?.contains(relatedTarget)) { const isOutsideFloating = enableFloatingStyles && refs.floating.current ? !refs.floating.current.contains(relatedTarget) : true; const isFocusableWrapper = relatedTarget && popover.current && relatedTarget.contains(popover.current); if (isOutsideFloating && !isFocusableWrapper) onRequestClose?.(); } }); useWindowEvent("click", ({ target }) => { if (open && target instanceof Node && !popover.current?.contains(target)) onRequestClose?.(); }); const popoverDimensions = useRef({ offset: 10, caretHeight: React.Children.toArray(children).some((x) => { return x?.props?.className?.includes("slug") || x?.props?.className?.includes("ai-label"); }) ? 7 : 6 }); useIsomorphicEffect(() => { if (caret && popover.current) { const getStyle = window.getComputedStyle(popover.current, null); const offsetProperty = getStyle.getPropertyValue(`--${prefix}-popover-offset`); const caretProperty = getStyle.getPropertyValue(`--${prefix}-popover-caret-height`); if (offsetProperty) popoverDimensions.current.offset = offsetProperty.includes("px") ? Number(offsetProperty.split("px", 1)[0]) * 1 : Number(offsetProperty.split("rem", 1)[0]) * 16; if (caretProperty) popoverDimensions.current.caretHeight = caretProperty.includes("px") ? Number(caretProperty.split("px", 1)[0]) * 1 : Number(caretProperty.split("rem", 1)[0]) * 16; } }); const { refs, floatingStyles, placement, middlewareData, elements, update } = useFloating(enableFloatingStyles ? { placement: align, strategy: "fixed", middleware: [ offset(!isTabTip ? { alignmentAxis: alignmentAxisOffset, mainAxis: caret ? popoverDimensions?.current?.offset : 4 } : 0), autoAlign && flip({ fallbackPlacements: isTabTip ? align.includes("bottom") ? [ "bottom-start", "bottom-end", "top-start", "top-end" ] : [ "top-start", "top-end", "bottom-start", "bottom-end" ] : align.includes("bottom") ? [ "bottom", "bottom-start", "bottom-end", "right", "right-start", "right-end", "left", "left-start", "left-end", "top", "top-start", "top-end" ] : [ "top", "top-start", "top-end", "left", "left-start", "left-end", "right", "right-start", "right-end", "bottom", "bottom-start", "bottom-end" ], fallbackStrategy: "initialPlacement", fallbackAxisSideDirection: "start", boundary: autoAlignBoundary }), arrow({ element: caretRef, padding: 16 }), autoAlign && hide() ] } : {}); useEffect(() => { if (!enableFloatingStyles) return; if (open && elements.reference && elements.floating) return autoUpdate(elements.reference, elements.floating, update); }, [ enableFloatingStyles, open, elements, update ]); const value = useMemo(() => { return { floating, setFloating: refs.setFloating, caretRef, autoAlign }; }, [refs.setFloating, autoAlign]); if (isTabTip) { if (!["bottom-start", "bottom-end"].includes(align)) align = "bottom-start"; } useEffect(() => { if (enableFloatingStyles) { const updatedFloatingStyles = { ...floatingStyles, visibility: middlewareData.hide?.referenceHidden ? "hidden" : "visible" }; Object.keys(updatedFloatingStyles).forEach((style) => { if (refs.floating.current) refs.floating.current.style[style] = updatedFloatingStyles[style]; }); if (caret && middlewareData && middlewareData.arrow && caretRef?.current) { const { x, y } = middlewareData.arrow; const staticSide = { top: "bottom", right: "left", bottom: "top", left: "right" }[placement.split("-")[0]]; caretRef.current.style.left = x != null ? `${x}px` : ""; caretRef.current.style.top = y != null ? `${y}px` : ""; caretRef.current.style.right = ""; caretRef.current.style.bottom = ""; if (staticSide) caretRef.current.style[staticSide] = `${-popoverDimensions?.current?.caretHeight}px`; } } }, [ floatingStyles, refs.floating, enableFloatingStyles, middlewareData, placement, caret ]); const ref = useMergedRefs([forwardRef, popover]); const currentAlignment = autoAlign && placement !== align ? placement : align; const className = classNames({ [`${prefix}--popover-container`]: true, [`${prefix}--popover--caret`]: caret, [`${prefix}--popover--drop-shadow`]: dropShadow, [`${prefix}--popover--border`]: border, [`${prefix}--popover--high-contrast`]: highContrast, [`${prefix}--popover--open`]: open, [`${prefix}--popover--auto-align ${prefix}--autoalign`]: enableFloatingStyles, [`${prefix}--popover--${currentAlignment}`]: true, [`${prefix}--popover--tab-tip`]: isTabTip, [`${prefix}--popover--background-token__background`]: backgroundToken === "background" && !highContrast }, customClassName); const mappedChildren = React.Children.map(children, (child) => { const item = child; const isToggletipButton = item?.type?.displayName === "ToggletipButton"; const isToggletipContent = item?.type?.displayName === "ToggletipContent"; const isPopoverContent = isComponentElement(item, PopoverContent); /** * Only trigger elements (button) or trigger components (ToggletipButton) should be * cloned because these will be decorated with a trigger-specific className and ref. * * There are also some specific components that should not be cloned when autoAlign * is on, even if they are a trigger element. */ const isTriggerElement = item?.type === "button"; const isTriggerComponent = enableFloatingStyles && isToggletipButton; const isAllowedTriggerComponent = enableFloatingStyles && !isToggletipContent && !isPopoverContent; if (React.isValidElement(item) && (isTriggerElement || isTriggerComponent || isAllowedTriggerComponent)) { const className = (item?.props)?.className; const ref = (item?.props).ref; const tabTipClasses = classNames(`${prefix}--popover--tab-tip__button`, className); return React.cloneElement(item, { className: isTabTip && item?.type === "button" ? tabTipClasses : className || "", ref: (node) => { if (enableFloatingStyles && !isPopoverContent) refs.setReference(node); if (typeof ref === "function") ref(node); else if (ref !== null && ref !== void 0) ref.current = node; } }); } else return item; }); return /* @__PURE__ */ jsx(PopoverContext.Provider, { value, children: /* @__PURE__ */ jsx(BaseComponent, { ...rest, className, ref, children: enableFloatingStyles || isTabTip ? mappedChildren : children }) }); }); Popover.displayName = "Popover"; Popover.propTypes = { align: deprecateValuesWithin(PropTypes.oneOf([ "top", "top-left", "top-right", "bottom", "bottom-left", "bottom-right", "left", "left-bottom", "left-top", "right", "right-bottom", "right-top", "top-start", "top-end", "bottom-start", "bottom-end", "left-end", "left-start", "right-end", "right-start" ]), [ "top", "top-start", "top-end", "bottom", "bottom-start", "bottom-end", "left", "left-start", "left-end", "right", "right-start", "right-end" ], mapPopoverAlign), alignmentAxisOffset: PropTypes.number, as: PropTypes.oneOfType([PropTypes.string, PropTypes.elementType]), autoAlign: PropTypes.bool, backgroundToken: PropTypes.oneOf(["layer", "background"]), autoAlignBoundary: PropTypes.oneOfType([ PropTypes.oneOf(["clippingAncestors"]), PropTypes.elementType, PropTypes.arrayOf(PropTypes.elementType), PropTypes.exact({ x: PropTypes.number.isRequired, y: PropTypes.number.isRequired, width: PropTypes.number.isRequired, height: PropTypes.number.isRequired }) ]), caret: PropTypes.bool, border: PropTypes.bool, children: PropTypes.node, className: PropTypes.string, dropShadow: PropTypes.bool, highContrast: PropTypes.bool, isTabTip: PropTypes.bool, onRequestClose: PropTypes.func, open: PropTypes.bool.isRequired }; const PopoverContent = forwardRef((props, forwardRef) => { const { className, children, ...rest } = props; const prefix = usePrefix(); const { setFloating, caretRef, autoAlign } = React.useContext(PopoverContext); const ref = useMergedRefs([setFloating, forwardRef]); const enableFloatingStyles = useFeatureFlag("enable-v12-dynamic-floating-styles") || autoAlign; return /* @__PURE__ */ jsxs("span", { ...rest, className: `${prefix}--popover`, children: [/* @__PURE__ */ jsxs("span", { className: classNames(`${prefix}--popover-content`, className), ref, children: [children, enableFloatingStyles && /* @__PURE__ */ jsx("span", { className: classNames({ [`${prefix}--popover-caret`]: true, [`${prefix}--popover--auto-align`]: true }), ref: caretRef })] }), !enableFloatingStyles && /* @__PURE__ */ jsx("span", { className: classNames({ [`${prefix}--popover-caret`]: true }), ref: caretRef })] }); }); PopoverContent.displayName = "PopoverContent"; PopoverContent.propTypes = { children: PropTypes.node, className: PropTypes.string }; //#endregion export { Popover, PopoverContent };