@carbon/ibm-products
Version:
Carbon for IBM Products
652 lines (630 loc) • 25.8 kB
JavaScript
/**
* 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;