UNPKG

@carbon/ibm-products

Version:

Carbon for IBM Products

652 lines (630 loc) 25.8 kB
/** * Copyright IBM Corp. 2020, 2025 * * 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 icons = require('@carbon/react/icons'); var react = require('@carbon/react'); var React = require('react'); var ActionSet = require('../ActionSet/ActionSet.js'); var index = require('../../_virtual/index.js'); var constants = require('./constants.js'); var propsHelper = require('../../global/js/utils/props-helper.js'); var cx = require('classnames'); var devtools = require('../../global/js/utils/devtools.js'); var carbonMotion = require('@carbon/motion'); var pconsole = require('../../global/js/utils/pconsole.js'); var settings = require('../../settings.js'); var useFocus = require('../../global/js/hooks/useFocus.js'); var usePreviousValue = require('../../global/js/hooks/usePreviousValue.js'); var usePrefersReducedMotion = require('../../global/js/hooks/usePrefersReducedMotion.js'); var usePresence = require('../../global/js/hooks/usePresence.js'); var _ArrowLeft; const blockClass = `${settings.pkg.prefix}--side-panel`; const componentName = 'SidePanel'; // Default values for props const defaults = { animateTitle: true, closeIconDescription: 'Close', currentStep: 0, navigationBackIconDescription: 'Back', placement: 'right', size: 'md' }; /** * Side panels keep users in-context of a page while performing tasks like navigating, editing, viewing details, or configuring something new. */ const SidePanelBase = /*#__PURE__*/React.forwardRef((_ref, ref) => { let { // The component props, in alphabetical order (for consistency). actionToolbarButtons, actions, aiLabel, animateTitle = defaults.animateTitle, children, className, closeIconDescription = defaults.closeIconDescription, condensedActions, currentStep = defaults.currentStep, decorator, id = blockClass, includeOverlay, labelText, navigationBackIconDescription = defaults.navigationBackIconDescription, onNavigationBack, onRequestClose, onUnmount, open, placement = defaults.placement, preventCloseOnClickOutside, selectorPageContent, selectorPrimaryFocus, size = defaults.size, slideIn, slug, subtitle, title, launcherButtonRef, // Collect any other property values passed in. ...rest } = _ref; const [animationComplete, setAnimationComplete] = React.useState(false); const localRef = React.useRef(null); const sidePanelRef = ref || localRef; const overlayRef = React.useRef(null); const innerContentRef = React.useRef(null); const closeRef = React.useRef(null); const animatedScrollRef = React.useRef(null); const headerRef = React.useRef(null); const titleRef = React.useRef(null); const labelTextRef = React.useRef(null); const subtitleRef = React.useRef(null); const previousState = usePreviousValue.usePreviousValue({ size, open, currentStep }); const [scrollAnimationDistance, setScrollAnimationDistance] = React.useState(-1); const [doAnimateTitle, setDoAnimateTitle] = React.useState(true); const { firstElement, keyDownListener } = useFocus.useFocus(sidePanelRef); const panelRefValue = sidePanelRef.current; const previousOpen = usePreviousValue.usePreviousValue(open); const shouldReduceMotion = usePrefersReducedMotion.usePrefersReducedMotion(); const exitAnimationName = shouldReduceMotion ? 'side-panel-exit-reduced' : placement === 'right' ? 'side-panel-exit-right' : 'side-panel-exit-left'; const { shouldRender } = usePresence.usePresence(open, sidePanelRef, exitAnimationName); // Title animation on scroll related state const [labelTextHeight, setLabelTextHeight] = React.useState(0); const handleEscapeKey = event => { if (event.key === 'Escape' && open) { onRequestClose?.(); } }; React.useEffect(() => { if (open && !titleRef?.current) { setDoAnimateTitle(false); } else { setDoAnimateTitle(animateTitle); } }, [animateTitle, open]); const titleItemsStyles = React.useCallback(progress => { if (subtitleRef?.current) { const subtitleEl = subtitleRef?.current; const height = subtitleEl?.clientHeight; const calculatedMargin = height * progress; subtitleEl?.style?.setProperty('margin-top', `${-calculatedMargin}px`); } if (labelTextRef?.current) { const calculatedMargin = labelTextHeight * progress; labelTextRef?.current?.style?.setProperty('margin-top', `${-calculatedMargin}px`); } }, [labelTextHeight]); React.useEffect(() => { if (open && animateTitle && labelTextRef?.current) { setLabelTextHeight(Number(labelTextRef?.current?.clientHeight || null)); } }, [animateTitle, labelTextRef, open]); const handleScroll = React.useCallback(() => { if (doAnimateTitle && innerContentRef?.current) { const scrollTop = innerContentRef?.current?.scrollTop; const animationProgress = Math.min(Number(scrollTop), scrollAnimationDistance) / scrollAnimationDistance; panelRefValue?.style.setProperty(`--${blockClass}--scroll-animation-progress`, animationProgress.toString()); titleItemsStyles(animationProgress); } }, [doAnimateTitle, panelRefValue?.style, scrollAnimationDistance, titleItemsStyles]); // scroll panel to top going between steps React.useEffect(() => { if (sidePanelRef && panelRefValue) { const scrollableSection = animatedScrollRef.current ?? innerContentRef.current; if (previousState && previousState['currentStep'] !== currentStep && scrollableSection) { scrollableSection.scrollTop = 0; } // The size of the panel has changed while it is still opened // so we need to scroll it to the top and reset the header // height css custom property if (previousState && previousState['size'] !== size && scrollableSection) { scrollableSection.scrollTop = 0; } } }, [currentStep, sidePanelRef, size, previousState, id, panelRefValue]); // Add console warning if labelText is provided without a title. // This combination is not allowed. React.useEffect(() => { if (!title && labelText) { console.warn(`${componentName}: The prop \`labelText\` was provided without a \`title\`. It is required to have a \`title\` when using the \`labelText\` prop.`); } }, [labelText, title]); React.useEffect(() => { if (previousOpen && !open && launcherButtonRef) { setTimeout(() => { launcherButtonRef?.current?.focus(); }, 0); } }, [launcherButtonRef, open, previousOpen]); const checkSetDoAnimateTitle = () => { let canDoAnimateTitle = false; if (panelRefValue && open && animateTitle && animationComplete && titleRef?.current && title && title.length && !shouldReduceMotion) { const titleEl = titleRef.current; const labelHeight = labelTextRef?.current?.offsetHeight ?? 0; const subtitleHeight = subtitleRef?.current?.offsetHeight ?? 0; // Adjusts space at bottom of titles by changing where scrolling finishes // Styles use border to save use of get computed style const titleVerticalBorder = actionToolbarButtons ? titleEl.offsetHeight - titleEl.clientHeight : 0; const scrollAnimationDistance = labelHeight + subtitleHeight + titleVerticalBorder; setScrollAnimationDistance(scrollAnimationDistance); // used to calculate the header moves panelRefValue?.style.setProperty(`--${blockClass}--scroll-animation-distance`, scrollAnimationDistance.toString()); const scrollEl = innerContentRef.current; if (scrollEl) { const innerComputed = window?.getComputedStyle(innerContentRef.current); const innerPaddingHeight = innerComputed ? parseFloat(innerComputed?.paddingTop) + parseFloat(innerComputed?.paddingBottom) : 0; canDoAnimateTitle = (!!labelText || !!actionToolbarButtons || !!subtitle) && scrollEl.scrollHeight - scrollEl.clientHeight >= scrollAnimationDistance + innerPaddingHeight; } } if (doAnimateTitle !== canDoAnimateTitle) { // will need updating on resize setDoAnimateTitle(canDoAnimateTitle); } }; React.useEffect(() => { if (!doAnimateTitle && sidePanelRef.current) { panelRefValue?.style.setProperty(`--${blockClass}--scroll-animation-progress`, '0'); } }, [doAnimateTitle, handleScroll, sidePanelRef, innerContentRef, open, panelRefValue?.style]); // Calculate scroll distances React.useEffect(() => { if (panelRefValue && open && animateTitle && animationComplete && titleRef?.current && title && title.length && !shouldReduceMotion) { checkSetDoAnimateTitle(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, doAnimateTitle /* use do instead of animateTitle directly */, animationComplete, handleScroll, title, size, shouldReduceMotion, id]); // click outside functionality if `includeOverlay` prop is set React.useEffect(() => { const handleOutsideClick = event => { if (panelRefValue && overlayRef.current && overlayRef.current.contains(event.target) && onRequestClose) { onRequestClose(); } }; const bodyElement = document.body; if (includeOverlay && open) { bodyElement.style.overflow = 'hidden'; } else if (includeOverlay && !open) { bodyElement.style.overflow = ''; } if (includeOverlay && !preventCloseOnClickOutside) { document.addEventListener('click', handleOutsideClick); } return () => { const bodyElement = document.body; bodyElement.style.overflow = ''; document.removeEventListener('click', handleOutsideClick); }; }, [includeOverlay, onRequestClose, open, preventCloseOnClickOutside, onUnmount, sidePanelRef, panelRefValue]); // initializes the side panel to close const onAnimationEnd = () => { if (!open) { onUnmount?.(); } setAnimationComplete(!animationComplete); }; // Set the internal state `animationComplete` to true if // prefers reduced motion is true React.useEffect(() => { if (shouldReduceMotion) { setAnimationComplete(true); } }, [shouldReduceMotion]); // initializes the side panel to open const onAnimationStart = () => { setAnimationComplete(false); }; // used to reset margins of the slide in panel when closed/closing React.useEffect(() => { if (!open && slideIn) { const pageContentElement = selectorPageContent ? document.querySelector(selectorPageContent) : null; if (placement && placement === 'right' && pageContentElement) { pageContentElement.style.marginInlineEnd = '0'; } else if (pageContentElement) { pageContentElement.style.marginInlineStart = '0'; } } }, [open, placement, selectorPageContent, slideIn]); React.useEffect(() => { if (!open && previousState && previousState['open'] && shouldReduceMotion) { onUnmount?.(); } }, [open, onUnmount, shouldReduceMotion, previousState]); // used to set margins of content for slide in panel version React.useEffect(() => { if (open && slideIn) { const pageContentElement = selectorPageContent ? document.querySelector(selectorPageContent) : null; if (pageContentElement) { pageContentElement.style.inlineSize = 'auto'; } else { pconsole.default.warn('SidePanel prop `selectorPageContent` was not provided a selector that matches any element on your page. If an element is not found, the panel will render as a slide over.'); } if (placement && placement === 'right' && pageContentElement) { pageContentElement.style.marginInlineEnd = '0'; pageContentElement.style.transition = !shouldReduceMotion ? `margin-inline-end ${carbonMotion.moderate02}` : ''; pageContentElement.style.marginInlineEnd = constants.SIDE_PANEL_SIZES[size]; } else if (pageContentElement) { pageContentElement.style.marginInlineStart = '0'; pageContentElement.style.transition = !shouldReduceMotion ? `margin-inline-start ${carbonMotion.moderate02}` : ''; pageContentElement.style.marginInlineStart = constants.SIDE_PANEL_SIZES[size]; } } }, [slideIn, selectorPageContent, placement, size, shouldReduceMotion, open]); React.useEffect(() => { if (open && animationComplete) { if (selectorPrimaryFocus && useFocus.getSpecificElement(sidePanelRef?.current, selectorPrimaryFocus)) { const primeFocusEl = useFocus.getSpecificElement(sidePanelRef?.current, selectorPrimaryFocus); if (primeFocusEl && window?.getComputedStyle(primeFocusEl)?.display !== 'none') { setTimeout(() => primeFocusEl?.focus(), 0); } } else if (!slideIn) { setTimeout(() => firstElement?.focus(), 0); } } }, [animationComplete, firstElement, open, selectorPrimaryFocus, sidePanelRef, slideIn]); const primaryActionContainerClassNames = cx([`${blockClass}__actions-container`, { [`${blockClass}__actions-container--condensed`]: condensedActions }]); const mainPanelClassNames = cx([blockClass, className, `${blockClass}`, `${blockClass}--${size}`, { [`${blockClass}--right-placement`]: placement === 'right', [`${blockClass}--left-placement`]: placement === 'left', [`${blockClass}--slide-in`]: slideIn, [`${blockClass}--has-decorator`]: decorator, [`${blockClass}--has-slug`]: slug, [`${blockClass}--has-ai-label`]: aiLabel, [`${blockClass}--condensed-actions`]: condensedActions, [`${blockClass}--has-overlay`]: includeOverlay, [`${blockClass}--open`]: open, [`${blockClass}--closing`]: !open, [`${blockClass}--reduced-motion`]: shouldReduceMotion }]); const renderTitle = () => /*#__PURE__*/React.createElement("div", { className: cx(`${blockClass}__title`, { [`${blockClass}__title--no-label`]: !labelTextRef.current }), ref: titleRef }, /*#__PURE__*/React.createElement(react.Heading, { className: `${blockClass}__title-text`, title: title, "aria-hidden": false }, title), doAnimateTitle && !shouldReduceMotion && /*#__PURE__*/React.createElement(react.Heading, { className: `${blockClass}__collapsed-title-text`, title: title, "aria-hidden": true }, title)); const renderHeader = () => { const closeSize = actions && actions.length && /l/.test(size) ? 'md' : 'sm'; let normalizedDecorator; /** * slug is deprecated * can remove this condition in future release */ if (slug && slug['type']?.displayName === 'AILabel') { normalizedDecorator = /*#__PURE__*/React.cloneElement(slug, { // slug size is sm unless actions and size > md size: closeSize }); } if (aiLabel && aiLabel['type']?.displayName === 'AILabel') { normalizedDecorator = /*#__PURE__*/React.cloneElement(aiLabel, { // aiLabel size is sm unless actions and size > md size: closeSize }); } if (decorator?.['type']?.displayName === 'AILabel') { normalizedDecorator = /*#__PURE__*/React.cloneElement(decorator, { // decorator size is sm unless actions and size > md size: closeSize }); } return /*#__PURE__*/React.createElement("div", { className: cx(`${blockClass}__header`, { [`${blockClass}__header--on-detail-step`]: currentStep > 0, [`${blockClass}__header--no-title-animation`]: !animateTitle, [`${blockClass}__header--reduced-motion`]: shouldReduceMotion, [`${blockClass}__header--has-title`]: title }), ref: headerRef }, currentStep > 0 && /*#__PURE__*/React.createElement(react.IconButton, { kind: "ghost", size: closeSize, align: "bottom", label: navigationBackIconDescription, className: `${blockClass}__navigation-back-button`, onClick: onNavigationBack }, _ArrowLeft || (_ArrowLeft = /*#__PURE__*/React.createElement(icons.ArrowLeft, null))), title && title.length && labelText && labelText.length && /*#__PURE__*/React.createElement("p", { className: `${blockClass}__label-text`, ref: labelTextRef }, labelText), title && title.length && renderTitle(), /*#__PURE__*/React.createElement("div", { className: `${blockClass}__decorator-and-close` }, normalizedDecorator, /*#__PURE__*/React.createElement(react.IconButton, { className: `${blockClass}__close-button`, label: closeIconDescription, onClick: onRequestClose, onKeyDown: slideIn ? undefined : handleEscapeKey, ref: closeRef, align: "left" }, /*#__PURE__*/React.createElement(icons.Close, { size: 20, "aria-hidden": "true", tabIndex: "-1", className: `${blockClass}--btn__icon` }))), subtitle && /*#__PURE__*/React.createElement("p", { className: cx(`${blockClass}__subtitle-text`, { [`${blockClass}__subtitle-text-no-animation-no-action-toolbar`]: !doAnimateTitle && (!actionToolbarButtons || !actionToolbarButtons.length) }), ref: subtitleRef }, subtitle), actionToolbarButtons && actionToolbarButtons.length && /*#__PURE__*/React.createElement("div", { className: `${blockClass}__action-toolbar` }, actionToolbarButtons.map(_ref2 => { let { label, kind, hasIconOnly = false, icon, renderIcon, tooltipPosition, tooltipAlignment, leading, disabled, className, onClick, ...rest } = _ref2; return /*#__PURE__*/React.createElement(react.Button, _rollupPluginBabelHelpers.extends({}, rest, { key: label, kind: kind || 'ghost', size: "sm", renderIcon: renderIcon || icon, iconDescription: label }, hasIconOnly && { tooltipPosition: tooltipPosition || 'bottom', tooltipAlignment: tooltipAlignment || 'start' }, { hasIconOnly: hasIconOnly, disabled: disabled, className: cx([`${blockClass}__action-toolbar-button`, className, { [`${blockClass}__action-toolbar-leading-button`]: leading }]), onClick: onClick }), leading && label); }))); }; const renderMain = () => { return /*#__PURE__*/React.createElement("div", { ref: innerContentRef, onScroll: handleScroll, className: cx(`${blockClass}__inner-content`, `${blockClass}--scrolls`, `${!doAnimateTitle && !animateTitle ? `${blockClass}__inner-content--no-animated-title` : ''}`) }, children); }; const handleKeyDown = event => { if (!slideIn) { handleEscapeKey(event); keyDownListener(event); } }; return shouldRender ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(react.Section, _rollupPluginBabelHelpers.extends({}, devtools.getDevtoolsProps(componentName), rest, { id: id, className: mainPanelClassNames, ref: sidePanelRef, as: "aside", "aria-label": title || rest['aria-label'], onAnimationEnd: onAnimationEnd, onAnimationStart: onAnimationStart, onKeyDown: handleKeyDown }), renderHeader(), renderMain(), /*#__PURE__*/React.createElement(ActionSet.ActionSet, { actions: actions ?? [], className: primaryActionContainerClassNames, size: size === 'xs' ? 'sm' : size })), includeOverlay && /*#__PURE__*/React.createElement("div", { ref: overlayRef, className: cx(`${blockClass}__overlay`, { [`${blockClass}__overlay--closing`]: !open }) })) : null; }); // Return a placeholder if not released and not enabled by feature flag const SidePanel = settings.pkg.checkComponentEnabled(SidePanelBase, componentName); const deprecatedProps = { /** * **deprecated** * **Experimental:** Provide a `Slug` component to be rendered inside the `SidePanel` component */ slug: index.default.node, /** * **deprecated** * Please use the `decorator` instead * Optional prop that is intended for any scenario where something is being generated by AI to reinforce AI transparency, accountability, and explainability at the UI level. */ aiLabel: index.default.node }; SidePanel.propTypes = { /** * Sets the action toolbar buttons */ /**@ts-ignore */ actionToolbarButtons: index.default.arrayOf(index.default.shape({ label: index.default.string, leading: index.default.bool, icon: index.default.oneOfType([index.default.object, index.default.func]), onClick: index.default.func, kind: index.default.oneOf(['ghost', 'tertiary', 'secondary', 'primary']), tooltipAlignment: index.default.oneOf(['top', 'right', 'bottom', 'left']), tooltipPosition: index.default.oneOf(['start', 'center', 'end']) })), /** * The primary actions to be shown in the side panel. Each action is * specified as an object with optional fields: 'label' to supply the button * label, 'kind' to select the button kind (must be 'primary', 'secondary' or * 'ghost'), 'tooltipPosition' to select where the tooltip is placed around * the button (must be 'top', 'right', 'bottom', or 'left'), 'tooltipAlignment' * to select how the tooltip is aligned with the button (must be 'start', * 'center', or 'end', 'loading' to display a loading indicator, and 'onClick' to * receive notifications when the button is clicked. Additional fields in the * object will be passed to the Button component, and these can include * 'disabled', 'ref', 'className', and any other Button props. Any other * fields in the object will be passed through to the button element as HTML * attributes. * * See https://react.carbondesignsystem.com/?path=/docs/components-button--default#component-api */ actions: propsHelper.allPropTypes([/**@ts-ignore*/ ActionSet.ActionSet.validateActions(), index.default.arrayOf(index.default.shape({ /**@ts-ignore */ ...react.Button.propTypes, kind: index.default.oneOf(['ghost', 'danger--ghost', 'secondary', 'danger', 'primary']), tooltipPosition: index.default.oneOf(['top', 'right', 'bottom', 'left']), tooltipAlignment: index.default.oneOf(['start', 'center', 'end']), label: index.default.string, loading: index.default.bool, // we duplicate this Button prop to improve the DocGen here /**@ts-ignore */ onClick: react.Button.propTypes.onClick }))]), /** * Determines if the title will animate on scroll */ animateTitle: index.default.bool, /** * Sets the body content of the side panel */ children: index.default.oneOfType([index.default.arrayOf(index.default.node), index.default.node]).isRequired, /** * Sets an optional className to be added to the side panel outermost element */ className: index.default.string, /** * Sets the close button icon description */ closeIconDescription: index.default.string, /** * Determines whether the side panel should render the condensed version (affects action buttons primarily) */ condensedActions: index.default.bool, /** * Sets the current step of the side panel */ currentStep: index.default.number, /** * Unique identifier */ id: index.default.string, /** * Determines whether the side panel should render with an overlay */ includeOverlay: index.default.bool, /** * Sets the label text which will display above the title text */ labelText: index.default.string, /** * Provide a ref to return focus to once the modal is closed. */ /**@ts-ignore */ launcherButtonRef: index.default.any, /** * Sets the icon description for the navigation back icon button */ navigationBackIconDescription: index.default.string, /** * Changes the current side panel page to the previous page */ onNavigationBack: index.default.func, /** * Specify a handler for closing the side panel. * This handler closes the modal, e.g. changing `open` prop. */ onRequestClose: index.default.func, /** * Optional function called when the side panel exit animation is complete. * This handler can be used for any state cleanup needed before the panel is removed from the DOM. */ onUnmount: index.default.func, /** * Determines whether the side panel should render or not */ open: index.default.bool.isRequired, /** * Determines if the side panel is on the right or left */ placement: index.default.oneOf(['left', 'right']), /** * Prevent closing on click outside of the panel */ preventCloseOnClickOutside: index.default.bool, /** * This is the selector to the element that contains all of the page content that will shrink if the panel is a slide in. * This prop is required when using the `slideIn` variant of the side panel. */ /**@ts-ignore*/ selectorPageContent: index.default.string.isRequired.if(_ref3 => { let { slideIn } = _ref3; return slideIn; }), /** * Specify a CSS selector that matches the DOM element that should * be focused when the side panel opens */ /**@ts-ignore*/ selectorPrimaryFocus: index.default.string, /** * Sets the size of the side panel */ /**@ts-ignore*/ size: index.default.oneOf(['xs', 'sm', 'md', 'lg', 'xl', '2xl']), /** * Determines if this panel slides in */ /**@ts-ignore*/ slideIn: index.default.bool, /** * Sets the subtitle text */ subtitle: index.default.node, /** * Sets the title text */ /**@ts-ignore*/ title: index.default.string.isRequired.if(_ref4 => { let { labelText } = _ref4; return labelText; }), ...deprecatedProps }; SidePanel.displayName = componentName; exports.SidePanel = SidePanel;