UNPKG

@carbon/react

Version:

React components for the Carbon Design System

405 lines (386 loc) 16.1 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. */ import { extends as _extends } from '../../_virtual/_rollupPluginBabelHelpers.js'; import cx from 'classnames'; import PropTypes from 'prop-types'; import deprecateValuesWithin from '../../prop-types/deprecateValuesWithin.js'; import React, { useRef, useMemo, useEffect } from 'react'; import useIsomorphicEffect from '../../internal/useIsomorphicEffect.js'; import { useMergedRefs } from '../../internal/useMergedRefs.js'; import { usePrefix } from '../../internal/usePrefix.js'; import { useEvent, useWindowEvent } from '../../internal/useEvent.js'; import { mapPopoverAlign } from '../../tools/mapPopoverAlign.js'; import { useFloating, autoUpdate, offset, flip, arrow, hide } from '@floating-ui/react'; import { useFeatureFlag } from '../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, caret = isTabTip ? false : true, className: customClassName, children, dropShadow = true, highContrast = false, onRequestClose, open, alignmentAxisOffset, ...rest }, //this is a workaround, have to come back and fix this. 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; let align = mapPopoverAlign(initialAlign); // The `Popover` should close whenever it and its children loses focus useEvent(popover, 'focusout', event => { const relatedTarget = event.relatedTarget; // No relatedTarget, focus moved to nowhere, so close the popover if (!relatedTarget) { onRequestClose?.(); return; } const isOutsideMainContainer = !popover.current?.contains(relatedTarget); const isOutsideFloating = enableFloatingStyles && refs.floating.current ? !refs.floating.current.contains(relatedTarget) : true; // Only close if focus moved outside both containers if (isOutsideMainContainer && isOutsideFloating) { onRequestClose?.(); } }); 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 x?.props?.className?.includes('slug') || x?.props?.className?.includes('ai-label'); }) ? 7 : 6; // These defaults match the defaults defined in packages/styles/scss/components/popover/_popover.scss const popoverDimensions = useRef({ offset: 10, caretHeight: initialCaretHeight }); useIsomorphicEffect(() => { // 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('--cds-popover-offset'); const caretProperty = getStyle.getPropertyValue('--cds-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 } = 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: [offset(!isTabTip ? { alignmentAxis: alignmentAxisOffset, mainAxis: popoverDimensions?.current?.offset } : 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 }), autoAlign && hide()], whileElementsMounted: autoUpdate } : {} // When autoAlign is turned off & the `enable-v12-dynamic-floating-styles` feature flag is not // enabled, floating-ui will not be used ); const value = 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'; } } 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([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--high-contrast`]: highContrast, [`${prefix}--popover--open`]: open, [`${prefix}--popover--auto-align ${prefix}--autoalign`]: enableFloatingStyles, [`${prefix}--popover--${currentAlignment}`]: true, [`${prefix}--popover--tab-tip`]: isTabTip }, customClassName); const mappedChildren = React.Children.map(children, child => { 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)) { const className = item?.props?.className; const ref = (item?.props).ref; const tabTipClasses = cx(`${prefix}--popover--tab-tip__button`, className); 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, _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(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), /** * **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 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, /** * 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 }; function PopoverContentRenderFunction( // eslint-disable-next-line react/prop-types { className, children, ...rest }, forwardRef) { 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__*/React.createElement("span", _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 })); } const PopoverContent = /*#__PURE__*/React.forwardRef(PopoverContentRenderFunction); 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 }; export { Popover, PopoverContent };