UNPKG

@carbon/ibm-products

Version:

Carbon for IBM Products

650 lines (629 loc) 25.5 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. */ import { extends as _extends } from '../../_virtual/_rollupPluginBabelHelpers.js'; import { ArrowLeft, Close } from '@carbon/react/icons'; import { Button, Section, IconButton, Heading } from '@carbon/react'; import React__default, { useState, useRef, useEffect, useCallback } from 'react'; import { ActionSet } from '../ActionSet/ActionSet.js'; import PropTypes from '../../_virtual/index.js'; import { SIDE_PANEL_SIZES } from './constants.js'; import { allPropTypes } from '../../global/js/utils/props-helper.js'; import cx from 'classnames'; import { getDevtoolsProps } from '../../global/js/utils/devtools.js'; import { moderate02 } from '@carbon/motion'; import pconsole from '../../global/js/utils/pconsole.js'; import { pkg } from '../../settings.js'; import { useFocus, getSpecificElement } from '../../global/js/hooks/useFocus.js'; import { usePreviousValue } from '../../global/js/hooks/usePreviousValue.js'; import { usePrefersReducedMotion } from '../../global/js/hooks/usePrefersReducedMotion.js'; import { usePresence } from '../../global/js/hooks/usePresence.js'; var _ArrowLeft; const blockClass = `${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__default.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] = useState(false); const localRef = useRef(null); const sidePanelRef = ref || localRef; const overlayRef = useRef(null); const innerContentRef = useRef(null); const closeRef = useRef(null); const animatedScrollRef = useRef(null); const headerRef = useRef(null); const titleRef = useRef(null); const labelTextRef = useRef(null); const subtitleRef = useRef(null); const previousState = usePreviousValue({ size, open, currentStep }); const [scrollAnimationDistance, setScrollAnimationDistance] = useState(-1); const [doAnimateTitle, setDoAnimateTitle] = useState(true); const { firstElement, keyDownListener } = useFocus(sidePanelRef); const panelRefValue = sidePanelRef.current; const previousOpen = usePreviousValue(open); const shouldReduceMotion = usePrefersReducedMotion(); const exitAnimationName = shouldReduceMotion ? 'side-panel-exit-reduced' : placement === 'right' ? 'side-panel-exit-right' : 'side-panel-exit-left'; const { shouldRender } = usePresence(open, sidePanelRef, exitAnimationName); // Title animation on scroll related state const [labelTextHeight, setLabelTextHeight] = useState(0); const handleEscapeKey = event => { if (event.key === 'Escape' && open) { onRequestClose?.(); } }; useEffect(() => { if (open && !titleRef?.current) { setDoAnimateTitle(false); } else { setDoAnimateTitle(animateTitle); } }, [animateTitle, open]); const titleItemsStyles = 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]); useEffect(() => { if (open && animateTitle && labelTextRef?.current) { setLabelTextHeight(Number(labelTextRef?.current?.clientHeight || null)); } }, [animateTitle, labelTextRef, open]); const handleScroll = 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 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. 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]); 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); } }; useEffect(() => { if (!doAnimateTitle && sidePanelRef.current) { panelRefValue?.style.setProperty(`--${blockClass}--scroll-animation-progress`, '0'); } }, [doAnimateTitle, handleScroll, sidePanelRef, innerContentRef, open, panelRefValue?.style]); // Calculate scroll distances 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 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 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 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]); useEffect(() => { if (!open && previousState && previousState['open'] && shouldReduceMotion) { onUnmount?.(); } }, [open, onUnmount, shouldReduceMotion, previousState]); // used to set margins of content for slide in panel version useEffect(() => { if (open && slideIn) { const pageContentElement = selectorPageContent ? document.querySelector(selectorPageContent) : null; if (pageContentElement) { pageContentElement.style.inlineSize = 'auto'; } else { pconsole.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 ${moderate02}` : ''; pageContentElement.style.marginInlineEnd = SIDE_PANEL_SIZES[size]; } else if (pageContentElement) { pageContentElement.style.marginInlineStart = '0'; pageContentElement.style.transition = !shouldReduceMotion ? `margin-inline-start ${moderate02}` : ''; pageContentElement.style.marginInlineStart = SIDE_PANEL_SIZES[size]; } } }, [slideIn, selectorPageContent, placement, size, shouldReduceMotion, open]); useEffect(() => { if (open && animationComplete) { if (selectorPrimaryFocus && getSpecificElement(sidePanelRef?.current, selectorPrimaryFocus)) { const primeFocusEl = 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__default.createElement("div", { className: cx(`${blockClass}__title`, { [`${blockClass}__title--no-label`]: !labelTextRef.current }), ref: titleRef }, /*#__PURE__*/React__default.createElement(Heading, { className: `${blockClass}__title-text`, title: title, "aria-hidden": false }, title), doAnimateTitle && !shouldReduceMotion && /*#__PURE__*/React__default.createElement(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__default.cloneElement(slug, { // slug size is sm unless actions and size > md size: closeSize }); } if (aiLabel && aiLabel['type']?.displayName === 'AILabel') { normalizedDecorator = /*#__PURE__*/React__default.cloneElement(aiLabel, { // aiLabel size is sm unless actions and size > md size: closeSize }); } if (decorator?.['type']?.displayName === 'AILabel') { normalizedDecorator = /*#__PURE__*/React__default.cloneElement(decorator, { // decorator size is sm unless actions and size > md size: closeSize }); } return /*#__PURE__*/React__default.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__default.createElement(IconButton, { kind: "ghost", size: closeSize, align: "bottom", label: navigationBackIconDescription, className: `${blockClass}__navigation-back-button`, onClick: onNavigationBack }, _ArrowLeft || (_ArrowLeft = /*#__PURE__*/React__default.createElement(ArrowLeft, null))), title && title.length && labelText && labelText.length && /*#__PURE__*/React__default.createElement("p", { className: `${blockClass}__label-text`, ref: labelTextRef }, labelText), title && title.length && renderTitle(), /*#__PURE__*/React__default.createElement("div", { className: `${blockClass}__decorator-and-close` }, normalizedDecorator, /*#__PURE__*/React__default.createElement(IconButton, { className: `${blockClass}__close-button`, label: closeIconDescription, onClick: onRequestClose, onKeyDown: slideIn ? undefined : handleEscapeKey, ref: closeRef, align: "left" }, /*#__PURE__*/React__default.createElement(Close, { size: 20, "aria-hidden": "true", tabIndex: "-1", className: `${blockClass}--btn__icon` }))), subtitle && /*#__PURE__*/React__default.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__default.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__default.createElement(Button, _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__default.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__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(Section, _extends({}, 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__default.createElement(ActionSet, { actions: actions ?? [], className: primaryActionContainerClassNames, size: size === 'xs' ? 'sm' : size })), includeOverlay && /*#__PURE__*/React__default.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 = pkg.checkComponentEnabled(SidePanelBase, componentName); const deprecatedProps = { /** * **deprecated** * **Experimental:** Provide a `Slug` component to be rendered inside the `SidePanel` component */ slug: PropTypes.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: PropTypes.node }; SidePanel.propTypes = { /** * Sets the action toolbar buttons */ /**@ts-ignore */ actionToolbarButtons: PropTypes.arrayOf(PropTypes.shape({ label: PropTypes.string, leading: PropTypes.bool, icon: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), onClick: PropTypes.func, kind: PropTypes.oneOf(['ghost', 'tertiary', 'secondary', 'primary']), tooltipAlignment: PropTypes.oneOf(['top', 'right', 'bottom', 'left']), tooltipPosition: PropTypes.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: allPropTypes([/**@ts-ignore*/ ActionSet.validateActions(), PropTypes.arrayOf(PropTypes.shape({ /**@ts-ignore */ ...Button.propTypes, kind: PropTypes.oneOf(['ghost', 'danger--ghost', 'secondary', 'danger', 'primary']), tooltipPosition: PropTypes.oneOf(['top', 'right', 'bottom', 'left']), tooltipAlignment: PropTypes.oneOf(['start', 'center', 'end']), label: PropTypes.string, loading: PropTypes.bool, // we duplicate this Button prop to improve the DocGen here /**@ts-ignore */ onClick: Button.propTypes.onClick }))]), /** * Determines if the title will animate on scroll */ animateTitle: PropTypes.bool, /** * Sets the body content of the side panel */ children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired, /** * Sets an optional className to be added to the side panel outermost element */ className: PropTypes.string, /** * Sets the close button icon description */ closeIconDescription: PropTypes.string, /** * Determines whether the side panel should render the condensed version (affects action buttons primarily) */ condensedActions: PropTypes.bool, /** * Sets the current step of the side panel */ currentStep: PropTypes.number, /** * Unique identifier */ id: PropTypes.string, /** * Determines whether the side panel should render with an overlay */ includeOverlay: PropTypes.bool, /** * Sets the label text which will display above the title text */ labelText: PropTypes.string, /** * Provide a ref to return focus to once the modal is closed. */ /**@ts-ignore */ launcherButtonRef: PropTypes.any, /** * Sets the icon description for the navigation back icon button */ navigationBackIconDescription: PropTypes.string, /** * Changes the current side panel page to the previous page */ onNavigationBack: PropTypes.func, /** * Specify a handler for closing the side panel. * This handler closes the modal, e.g. changing `open` prop. */ onRequestClose: PropTypes.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: PropTypes.func, /** * Determines whether the side panel should render or not */ open: PropTypes.bool.isRequired, /** * Determines if the side panel is on the right or left */ placement: PropTypes.oneOf(['left', 'right']), /** * Prevent closing on click outside of the panel */ preventCloseOnClickOutside: PropTypes.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: PropTypes.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: PropTypes.string, /** * Sets the size of the side panel */ /**@ts-ignore*/ size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl', '2xl']), /** * Determines if this panel slides in */ /**@ts-ignore*/ slideIn: PropTypes.bool, /** * Sets the subtitle text */ subtitle: PropTypes.node, /** * Sets the title text */ /**@ts-ignore*/ title: PropTypes.string.isRequired.if(_ref4 => { let { labelText } = _ref4; return labelText; }), ...deprecatedProps }; SidePanel.displayName = componentName; export { SidePanel };