UNPKG

@carbon/react

Version:

React components for the Carbon Design System

458 lines (436 loc) 18.9 kB
/** * Copyright IBM Corp. 2016, 2023 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ 'use strict'; var _rollupPluginBabelHelpers = require('../../_virtual/_rollupPluginBabelHelpers.js'); var cx = require('classnames'); var PropTypes = require('prop-types'); var deprecateValuesWithin = require('../../prop-types/deprecateValuesWithin.js'); var React = require('react'); var useIsomorphicEffect = require('../../internal/useIsomorphicEffect.js'); var useMergedRefs = require('../../internal/useMergedRefs.js'); var usePrefix = require('../../internal/usePrefix.js'); var useEvent = require('../../internal/useEvent.js'); var mapPopoverAlign = require('../../tools/mapPopoverAlign.js'); var react = require('@floating-ui/react'); var index = require('../FeatureFlags/index.js'); const PopoverContext = /*#__PURE__*/React.createContext({ setFloating: { current: null }, caretRef: { current: null }, autoAlign: null }); /** * Deprecated popover alignment values. * @deprecated Use NewPopoverAlignment instead. */ const Popover = /*#__PURE__*/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 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 }, //this is a workaround, have to come back and fix this. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 forwardRef) { const prefix = usePrefix.usePrefix(); const floating = React.useRef(null); const caretRef = React.useRef(null); const popover = React.useRef(null); const enableFloatingStyles = index.useFeatureFlag('enable-v12-dynamic-floating-styles') || autoAlign; const lastClickWasInsidePopoverContent = React.useRef(false); let align = mapPopoverAlign.mapPopoverAlign(initialAlign); // Tracks clicks inside PopoverContent to prevent it from closing when clicked, this handles an edge // case where the popover will close when clicking non-focusable elements (e.g. text) useEvent.useEvent(popover, 'mousedown', event => { const target = event.target; lastClickWasInsidePopoverContent.current = refs.floating.current?.contains(target) || false; // reset flag if (lastClickWasInsidePopoverContent.current) { setTimeout(() => { lastClickWasInsidePopoverContent.current = false; }, 0); } }); // The `Popover` should close whenever it and its children loses focus useEvent.useEvent(popover, 'focusout', event => { const relatedTarget = event.relatedTarget; if (!relatedTarget) { // do not close if PopoverContent was clicked 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); // Only close if focus moved outside both containers and not to an interactive parent wrapper if (isOutsideFloating && !isFocusableWrapper) { onRequestClose?.(); } } }); useEvent.useWindowEvent('click', ({ target }) => { if (open && target instanceof Node && !popover.current?.contains(target)) { onRequestClose?.(); } }); // Slug styling places a border around the popover content so the caret // needs to be placed 1px further outside the popover content. To do so, // we look to see if any of the children has a className containing "slug" const initialCaretHeight = React.Children.toArray(children).some(x => { return ( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 x?.props?.className?.includes('slug') || // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 x?.props?.className?.includes('ai-label') ); }) ? 7 : 6; // These defaults match the defaults defined in packages/styles/scss/components/popover/_popover.scss const popoverDimensions = React.useRef({ offset: 10, caretHeight: initialCaretHeight }); useIsomorphicEffect.default(() => { // The popover is only offset when a caret is present. Technically, the custom properties // accessed below can be set by a user even if caret=false, but doing so does not follow // the design specification for Popover. if (caret && popover.current) { // Gather the dimensions of the caret and prefer the values set via custom properties. // If a value is not set via a custom property, provide a default value that matches the // default values defined in the sass style file const getStyle = window.getComputedStyle(popover.current, null); const offsetProperty = getStyle.getPropertyValue(`--${prefix}-popover-offset`); const caretProperty = getStyle.getPropertyValue(`--${prefix}-popover-caret-height`); // Handle if the property values are in px or rem. // We want to store just the base number value without a unit suffix 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 } = react.useFloating(enableFloatingStyles ? { placement: align, // The floating element is positioned relative to its nearest // containing block (usually the viewport). It will in many cases also // “break” the floating element out of a clipping ancestor. // https://floating-ui.com/docs/misc#clipping strategy: 'fixed', // Middleware order matters, arrow should be last middleware: [react.offset(!isTabTip ? { alignmentAxis: alignmentAxisOffset, mainAxis: popoverDimensions?.current?.offset } : 0), autoAlign && react.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 }), react.arrow({ element: caretRef, padding: 16 }), autoAlign && react.hide()] } : {} // When autoAlign is turned off & the `enable-v12-dynamic-floating-styles` feature flag is not // enabled, floating-ui will not be used ); React.useEffect(() => { if (!enableFloatingStyles) return; if (open && elements.reference && elements.floating) { const cleanup = react.autoUpdate(elements.reference, elements.floating, update); return cleanup; } }, [enableFloatingStyles, open, elements, update]); const value = React.useMemo(() => { return { floating, setFloating: refs.setFloating, caretRef, autoAlign: autoAlign }; }, [refs.setFloating, autoAlign]); if (isTabTip) { const tabTipAlignments = ['bottom-start', 'bottom-end']; if (!tabTipAlignments.includes(align)) { align = 'bottom-start'; } } React.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` : ''; // Ensure the static side gets unset when flipping to other placements' axes. 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.useMergedRefs([forwardRef, popover]); const currentAlignment = autoAlign && placement !== align ? placement : align; const className = cx({ [`${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 => { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 const item = child; const displayName = item?.type?.displayName; /** * 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 && displayName && ['ToggletipButton'].includes(displayName); const isAllowedTriggerComponent = enableFloatingStyles && !['ToggletipContent', 'PopoverContent'].includes(displayName); if (/*#__PURE__*/React.isValidElement(item) && (isTriggerElement || isTriggerComponent || isAllowedTriggerComponent)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 const className = item?.props?.className; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 const ref = (item?.props).ref; const tabTipClasses = cx(`${prefix}--popover--tab-tip__button`, className); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 return /*#__PURE__*/React.cloneElement(item, { className: isTabTip && item?.type === 'button' ? tabTipClasses : className || '', // With cloneElement, if you pass a `ref`, it overrides the original ref. // https://react.dev/reference/react/cloneElement#parameters // The block below works around this and ensures that the original ref is still // called while allowing the floating-ui reference element to be set as well. // `useMergedRefs` can't be used here because hooks can't be called from within a callback. // More here: https://github.com/facebook/react/issues/8873#issuecomment-489579878 ref: node => { // For a popover, there isn't an explicit trigger component, it's just the first child that's // passed in which should *not* be PopoverContent. // For a toggletip there is a specific trigger component, ToggletipButton. // In either of these cases we want to set this as the reference node for floating-ui autoAlign // positioning. if (enableFloatingStyles && item?.type !== PopoverContent || enableFloatingStyles && item?.type['displayName'] === 'ToggletipButton') { // Set the reference element for floating-ui refs.setReference(node); } // Call the original ref, if any if (typeof ref === 'function') { ref(node); } else if (ref !== null && ref !== undefined) { ref.current = node; } } }); } else { return item; } }); const BaseComponentAsAny = BaseComponent; return /*#__PURE__*/React.createElement(PopoverContext.Provider, { value: value }, /*#__PURE__*/React.createElement(BaseComponentAsAny, _rollupPluginBabelHelpers.extends({}, rest, { className: className, ref: ref }), enableFloatingStyles || isTabTip ? mappedChildren : children)); }); // Note: this displayName is temporarily set so that Storybook ArgTable // correctly displays the name of this component if (process.env.NODE_ENV !== 'production') { Popover.displayName = 'Popover'; } Popover.propTypes = { /** * Specify how the popover should align with the trigger element */ align: deprecateValuesWithin.deprecateValuesWithin(PropTypes.oneOf(['top', 'top-left', // deprecated use top-start instead 'top-right', // deprecated use top-end instead 'bottom', 'bottom-left', // deprecated use bottom-start instead 'bottom-right', // deprecated use bottom-end instead 'left', 'left-bottom', // deprecated use left-end instead 'left-top', // deprecated use left-start instead 'right', 'right-bottom', // deprecated use right-end instead 'right-top', // deprecated use right-start instead // new values to match floating-ui '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.mapPopoverAlign), /** * **Experimental:** Provide an offset value for alignment axis. Only takes effect when `autoalign` is enabled. */ alignmentAxisOffset: PropTypes.number, /** * Provide a custom element or component to render the top-level node for the * component. */ as: PropTypes.oneOfType([PropTypes.string, PropTypes.elementType]), /** * Will auto-align the popover on first render if it is not visible. This prop * is currently experimental and is subject to future changes. Requires * React v17+ * @see https://github.com/carbon-design-system/carbon/issues/18714 */ autoAlign: PropTypes.bool, /** * Specify the background token to use. Default is 'layer'. */ backgroundToken: PropTypes.oneOf(['layer', 'background']), /** * Specify a bounding element to be used for autoAlign calculations. The viewport is used by default. This prop is currently experimental and is subject to future changes. */ 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 })]), /** * Specify whether a caret should be rendered */ caret: PropTypes.bool, /** * Specify whether a border should be rendered on the popover */ border: PropTypes.bool, /** * Provide elements to be rendered inside of the component */ children: PropTypes.node, /** * Provide a custom class name to be added to the outermost node in the * component */ className: PropTypes.string, /** * Specify whether a drop shadow should be rendered on the popover */ dropShadow: PropTypes.bool, /** * Render the component using the high-contrast variant */ highContrast: PropTypes.bool, /** * Render the component using the tab tip variant */ isTabTip: PropTypes.bool, /** * Specify a handler for closing popover. * The handler should take care of closing the popover, e.g. changing the `open` prop. */ onRequestClose: PropTypes.func, /** * Specify whether the component is currently open or closed */ open: PropTypes.bool.isRequired }; const frFn = React.forwardRef; const PopoverContent = frFn((props, forwardRef) => { const { className, children, ...rest } = props; const prefix = usePrefix.usePrefix(); const { setFloating, caretRef, autoAlign } = React.useContext(PopoverContext); const ref = useMergedRefs.useMergedRefs([setFloating, forwardRef]); const enableFloatingStyles = index.useFeatureFlag('enable-v12-dynamic-floating-styles') || autoAlign; return /*#__PURE__*/React.createElement("span", _rollupPluginBabelHelpers.extends({}, rest, { className: `${prefix}--popover` }), /*#__PURE__*/React.createElement("span", { className: cx(`${prefix}--popover-content`, className), ref: ref }, children, enableFloatingStyles && /*#__PURE__*/React.createElement("span", { className: cx({ [`${prefix}--popover-caret`]: true, [`${prefix}--popover--auto-align`]: true }), ref: caretRef })), !enableFloatingStyles && /*#__PURE__*/React.createElement("span", { className: cx({ [`${prefix}--popover-caret`]: true }), ref: caretRef })); }); PopoverContent.displayName = 'PopoverContent'; PopoverContent.propTypes = { /** * Provide elements to be rendered inside of the component */ children: PropTypes.node, /** * Provide a custom class name to be added to the outermost node in the * component */ className: PropTypes.string }; exports.Popover = Popover; exports.PopoverContent = PopoverContent;