UNPKG

@carbon/ibm-products

Version:

Carbon for IBM Products

724 lines (698 loc) 24.9 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 React__default, { forwardRef, useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback } from 'react'; import PropTypes from '../../../_virtual/index.js'; import cx from 'classnames'; import { Grid, Column, DefinitionTooltip, unstable_Text, IconButton, BreadcrumbItem, usePrefix, Breadcrumb, Tag, Popover, OperationalTag, PopoverContent, MenuButton, MenuItem, AspectRatio } from '@carbon/react'; import { breakpoints } from '@carbon/layout'; import { blockClass } from '../PageHeaderUtils.js'; import { createOverflowHandler } from './overflowHandler.js'; import { createOverflowHandler as createOverflowHandler$1 } from '@carbon/utilities'; import { useOverflowItems } from '../../../global/js/hooks/useOverflowItems/useOverflowItems.js'; import { useId } from '../../../global/js/utils/useId.js'; import { ChevronUp } from '@carbon/react/icons'; import { PageHeaderContext, usePageHeader } from './context.js'; import { getHeaderOffset, scrollableAncestor } from './utils.js'; import { pkg } from '../../../settings.js'; import { useResizeObserver } from '../../../global/js/hooks/useResizeObserver.js'; /** * ---------- * PageHeader * ---------- */ const PageHeader = /*#__PURE__*/React__default.forwardRef(function PageHeader(_ref, ref) { let { className, children, ...other } = _ref; const [refs, setRefs] = useState({}); const tempRef = useRef(null); const componentRef = ref ?? tempRef; const classNames = cx({ [`${blockClass}`]: true, [`${blockClass}__next`]: true }, className); // Used to set CSS custom property with PageHeaderContent height to be used // for sticky positioning useResizeObserver(componentRef, () => { if (componentRef?.current && refs?.contentRef?.current) { const pageHeaderContentHeight = refs?.contentRef?.current?.offsetHeight; const totalHeaderOffset = getHeaderOffset(componentRef?.current); componentRef?.current.style.setProperty(`--${pkg.prefix}-page-header-header-top`, `${(Math.round(pageHeaderContentHeight) - totalHeaderOffset) * -1}px`); componentRef?.current.style.setProperty(`--${pkg.prefix}-page-header-breadcrumb-top`, `${totalHeaderOffset}px`); } }); const [fullyCollapsed, setFullyCollapsed] = useState(false); const [titleClipped, setTitleClipped] = useState(false); // Intersection Observer setup, tracks if the PageHeaderContent is visible on page. // If it is not visible, we should set fully collapsed to true so that the // scroller button will know if it is clicked to expand rather than // collapse the header. useEffect(() => { if (!refs?.contentRef || !componentRef?.current) { return; } const totalHeaderOffset = getHeaderOffset(componentRef?.current); const predefinedContentPadding = 24; const contentObserver = new IntersectionObserver(entries => { entries.forEach(entry => { if (entry.target === refs?.contentRef.current) { setFullyCollapsed(!entry.isIntersecting); } }); }, { root: null, rootMargin: `${(predefinedContentPadding + totalHeaderOffset + 40) * -1}px 0px 0px 0px`, threshold: 0.1 }); if (!refs?.titleRef?.current) { return; } const totalTitleHeight = refs?.titleRef.current.offsetHeight; const titleObserver = new IntersectionObserver(entries => { entries.forEach(entry => { if (entry.target === refs?.titleRef.current) { setTitleClipped(!entry.isIntersecting); } }); }, { root: null, rootMargin: `${(predefinedContentPadding + totalTitleHeight + totalHeaderOffset + 24) * -1}px 0px 0px 0px`, threshold: 0.1 }); if (refs?.contentRef.current) { contentObserver.observe(refs?.contentRef.current); } if (refs?.titleRef.current) { titleObserver.observe(refs?.titleRef.current); } return () => { if (!refs?.contentRef?.current) { return; } contentObserver.unobserve(refs?.contentRef.current); if (!refs?.titleRef?.current) { return; } contentObserver.unobserve(refs?.titleRef.current); }; }, [refs, componentRef]); return /*#__PURE__*/React__default.createElement(PageHeaderContext.Provider, { value: { refs, setRefs, fullyCollapsed, titleClipped } }, /*#__PURE__*/React__default.createElement("div", _extends({ className: classNames, ref: componentRef }, other), children)); }); PageHeader.displayName = 'PageHeader'; /** * ----------------------- * PageHeaderBreadcrumbBar * ----------------------- */ const PageHeaderBreadcrumbBar = /*#__PURE__*/React__default.forwardRef(function PageHeaderBreadcrumbBar(_ref2, ref) { let { border = true, className, children, renderIcon: IconElement, contentActions, contentActionsFlush, pageActions, pageActionsFlush, ...other } = _ref2; const classNames = cx({ [`${blockClass}__breadcrumb-bar`]: true, [`${blockClass}__breadcrumb-bar-border`]: border, [`${blockClass}__breadcrumb__actions-flush`]: pageActionsFlush }, className); const contentActionsClasses = cx({ [`${blockClass}__breadcrumb__content-actions`]: !contentActionsFlush }); return /*#__PURE__*/React__default.createElement("div", _extends({ className: classNames, ref: ref }, other), /*#__PURE__*/React__default.createElement(Grid, null, /*#__PURE__*/React__default.createElement(Column, { lg: 16, md: 8, sm: 4 }, /*#__PURE__*/React__default.createElement("div", { className: `${blockClass}__breadcrumb-container` }, /*#__PURE__*/React__default.createElement("div", { className: `${blockClass}__breadcrumb-wrapper` }, IconElement && /*#__PURE__*/React__default.createElement("div", { className: `${blockClass}__breadcrumb__icon` }, /*#__PURE__*/React__default.createElement(IconElement, null)), children), /*#__PURE__*/React__default.createElement("div", { className: `${blockClass}__breadcrumb__actions` }, /*#__PURE__*/React__default.createElement("div", { className: contentActionsClasses }, contentActions), pageActions))))); }); PageHeaderBreadcrumbBar.displayName = 'PageHeaderBreadcrumbBar'; /** * ----------------- * PageHeaderContent * ----------------- */ const PageHeaderContent = /*#__PURE__*/React__default.forwardRef(function PageHeaderContent(_ref3, ref) { let { className, children, title, renderIcon: IconElement, contextualActions, pageActions, ...other } = _ref3; const contentRef = useRef(null); const componentRef = ref ?? contentRef; const { setRefs } = usePageHeader(); const classNames = cx({ [`${blockClass}__content`]: true }, className); const titleRef = useRef(null); useEffect(() => { if (componentRef?.current) { setRefs(prev => ({ ...prev, contentRef: componentRef, titleRef })); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const [isEllipsisApplied, setIsEllipsisApplied] = useState(false); const isEllipsisActive = element => { setIsEllipsisApplied(element.offsetHeight < element.scrollHeight); return element.offsetHeight < element.scrollHeight; }; useLayoutEffect(() => { titleRef.current && isEllipsisActive(titleRef.current); }, [title]); return /*#__PURE__*/React__default.createElement("div", _extends({ className: classNames, ref: componentRef }, other), /*#__PURE__*/React__default.createElement(Grid, null, /*#__PURE__*/React__default.createElement(Column, { lg: 16, md: 8, sm: 4 }, /*#__PURE__*/React__default.createElement("div", { className: `${blockClass}__content__title-wrapper` }, /*#__PURE__*/React__default.createElement("div", { className: `${blockClass}__content__start` }, /*#__PURE__*/React__default.createElement("div", { className: `${blockClass}__content__title-container` }, IconElement && /*#__PURE__*/React__default.createElement("div", { className: `${blockClass}__content__icon` }, /*#__PURE__*/React__default.createElement(IconElement, null)), isEllipsisApplied ? /*#__PURE__*/React__default.createElement(DefinitionTooltip, { definition: title }, /*#__PURE__*/React__default.createElement(unstable_Text, { ref: titleRef, as: "h4", className: `${blockClass}__content__title` }, title)) : /*#__PURE__*/React__default.createElement(unstable_Text, { ref: titleRef, as: "h4", className: `${blockClass}__content__title` }, title)), contextualActions && /*#__PURE__*/React__default.createElement("div", { className: `${blockClass}__content__contextual-actions` }, contextualActions)), pageActions), children))); }); PageHeaderContent.displayName = 'PageHeaderContent'; PageHeaderContent.propTypes = { /** * Provide child elements to be rendered inside PageHeaderContent. */ children: PropTypes.node, /** * Specify an optional className to be added to your PageHeaderContent */ className: PropTypes.string, /** * The PageHeaderContent's contextual actions */ contextualActions: PropTypes.node, /** * The PageHeaderContent's page actions */ pageActions: PropTypes.node, /** * Provide an optional icon to render in front of the PageHeaderContent's title. */ renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), /** * The PageHeaderContent's subtitle */ subtitle: PropTypes.string, /** * The PageHeaderContent's title */ title: PropTypes.string.isRequired }; /** * ---------------- * PageHeaderContentPageActions * ---------------- */ const PageHeaderContentPageActions = _ref4 => { let { className, children, menuButtonLabel = 'Actions', actions, ...other } = _ref4; const classNames = cx({ [`${blockClass}__content__page-actions`]: true }, className); const containerRef = useRef(null); const offsetRef = useRef(null); const [menuButtonVisibility, setMenuButtonVisibility] = useState(false); const [hiddenItems, setHiddenItems] = useState([]); // need to set the grid columns width based on the menu button's width // to avoid overlapping when resizing useLayoutEffect(() => { if (menuButtonVisibility && offsetRef.current) { const width = offsetRef.current.offsetWidth; document.documentElement.style.setProperty('--page-header-title-grid-width', `${width}px`); } }, [menuButtonVisibility]); useEffect(() => { if (!containerRef.current || !Array.isArray(actions)) { return; } createOverflowHandler$1({ container: containerRef.current, // exclude the hidden menu button from children maxVisibleItems: containerRef.current.children.length - 1, onChange: (visible, hidden) => { setHiddenItems(actions?.slice(visible.length)); if (hidden.length > 0) { setMenuButtonVisibility(true); } } }); }, [actions]); return /*#__PURE__*/React__default.createElement("div", _extends({ className: classNames, ref: containerRef }, other), Array.isArray(actions) && /*#__PURE__*/React__default.createElement(React__default.Fragment, null, actions.map(action => /*#__PURE__*/React__default.createElement("div", { key: action.id }, /*#__PURE__*/React__default.cloneElement(action.body, { ...action.body.props, onClick: action.onClick }))), /*#__PURE__*/React__default.createElement("span", { "data-offset": true, "data-hidden": true, ref: offsetRef }, /*#__PURE__*/React__default.createElement(MenuButton, { menuAlignment: "bottom-end", label: menuButtonLabel, size: "md" }, [...hiddenItems].reverse().map(item => /*#__PURE__*/React__default.createElement(MenuItem, _extends({ key: item.id, onClick: item.onClick }, item.menuItem))))))); }; PageHeaderContentPageActions.displayName = 'PageHeaderContentPageActions'; PageHeaderContentPageActions.propTypes = { /** * The PageHeaderContent's page actions */ actions: PropTypes.oneOfType([PropTypes.node, PropTypes.array]), /** * Provide child elements to be rendered inside PageHeaderContentPageActions. */ children: PropTypes.node, /** * Specify an optional className to be added to your PageHeaderContentPageActions */ className: PropTypes.string, /** * The PageHeaderContent's collapsible Menu button label */ menuButtonLabel: PropTypes.string }; /** * ---------------- * PageHeaderContentText * ---------------- */ const PageHeaderContentText = _ref5 => { let { className, children, subtitle, ...other } = _ref5; const classNames = cx({ [`${blockClass}__content__body`]: true }, className); return /*#__PURE__*/React__default.createElement("div", _extends({ className: classNames }, other), subtitle && /*#__PURE__*/React__default.createElement(unstable_Text, { as: "h3", className: `${blockClass}__content__subtitle` }, subtitle), children); }; PageHeaderContentText.displayName = 'PageHeaderContentText'; PageHeaderContentText.propTypes = { /** * Provide child elements to be rendered inside PageHeaderContentText. */ children: PropTypes.node, /** * Specify an optional className to be added to your PageHeaderContentText */ className: PropTypes.string, /** * The PageHeaderContent's subtitle */ subtitle: PropTypes.string }; /** * ---------------- * PageHeaderHeroImage * ---------------- */ const PageHeaderHeroImage = _ref6 => { let { className, children, ...other } = _ref6; const [lgBreakpoint, setLgBreakpoint] = useState(false); const classNames = cx({ [`${blockClass}__hero-image`]: true }, className); const lgMediaQuery = `(min-width: ${breakpoints.lg.width})`; useEffect(() => { const listener = event => { setLgBreakpoint(event.matches); }; const mediaQueryList = window.matchMedia(lgMediaQuery); mediaQueryList.addEventListener('change', listener); setLgBreakpoint(mediaQueryList.matches); return () => { mediaQueryList.removeEventListener('change', listener); }; }, [lgMediaQuery]); return /*#__PURE__*/React__default.createElement(AspectRatio, _extends({ className: classNames }, other, { ratio: lgBreakpoint ? '2x1' : '3x2' }), children); }; PageHeaderHeroImage.displayName = 'PageHeaderHeroImage'; PageHeaderHeroImage.propTypes = { /** * Provide child elements to be rendered inside PageHeaderHeroImage. */ children: PropTypes.node, /** * Specify an optional className to be added to your PageHeaderHeroImage */ className: PropTypes.string }; /** * ---------------- * PageHeaderTabBar * ---------------- */ const PageHeaderTabBar = /*#__PURE__*/React__default.forwardRef(function PageHeaderTabBar(_ref7, ref) { let { className, children, tags = [], scroller, ...other } = _ref7; const classNames = cx({ [`${blockClass}__tab-bar`]: true }, className); const renderScroller = () => scroller && /*#__PURE__*/React__default.createElement("div", { className: `${pkg.prefix}--page-header--scroller-button-container` }, scroller); // Early return if no tags are provided if (!tags.length) { return /*#__PURE__*/React__default.createElement("div", _extends({ className: classNames, ref: ref }, other), /*#__PURE__*/React__default.createElement(Grid, null, /*#__PURE__*/React__default.createElement(Column, { lg: 16, md: 8, sm: 4 }, children, renderScroller()))); } const [openPopover, setOpenPopover] = useState(false); const tagSize = tags[0]?.size || 'md'; const instanceId = useId('PageHeaderTabBar'); const tagsWithIds = useMemo(() => { return tags.map((tag, index) => ({ ...tag, id: tag.id || `tag-${index}-${instanceId}` })); }, [instanceId, tags]); const tagsContainerRef = useRef(null); const offsetRef = useRef(null); // To close popover when window resizes useEffect(() => { const handleResize = () => { // Close the popover when window resizes to prevent unwanted opens setOpenPopover(false); }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); }; }, []); // overflow items hook const { visibleItems = [], hiddenItems = [], itemRefHandler = () => {} } = useOverflowItems(tagsWithIds, tagsContainerRef, offsetRef) || { visibleItems: [], hiddenItems: [], itemRefHandler: () => {} }; const handleOverflowClick = useCallback(event => { event.stopPropagation(); setOpenPopover(prev => !prev); }, []); // Function to render tags const renderTags = () => /*#__PURE__*/React__default.createElement("div", { className: `${blockClass}__tags`, ref: tagsContainerRef }, visibleItems.map(tag => /*#__PURE__*/React__default.createElement(Tag, { key: tag.id, ref: node => itemRefHandler(tag.id, node), type: tag.type, size: tag.size, className: `${blockClass}__tag-item` }, tag.text)), hiddenItems.length > 0 && /*#__PURE__*/React__default.createElement(Popover, { open: openPopover, onRequestClose: () => setOpenPopover(false) }, /*#__PURE__*/React__default.createElement(OperationalTag, { onClick: handleOverflowClick, "aria-expanded": openPopover, text: `+${hiddenItems.length}`, size: tagSize }), /*#__PURE__*/React__default.createElement(PopoverContent, { className: "tag-popover-content" }, /*#__PURE__*/React__default.createElement("div", { className: `${blockClass}__tags-popover-list` }, hiddenItems.map(tag => /*#__PURE__*/React__default.createElement(Tag, { key: tag.id, type: tag.type, size: tag.size }, tag.text)))))); return /*#__PURE__*/React__default.createElement("div", _extends({ className: classNames, ref: ref }, other), /*#__PURE__*/React__default.createElement(Grid, null, /*#__PURE__*/React__default.createElement(Column, { lg: 16, md: 8, sm: 4 }, /*#__PURE__*/React__default.createElement("div", { className: cx(`${blockClass}__tab-bar--tablist`, { [`${pkg.prefix}--page-header__tab-bar--with-scroller`]: !!scroller }) }, children, tags.length > 0 && renderTags(), renderScroller())))); }); PageHeaderTabBar.displayName = 'PageHeaderTabBar'; const PageHeaderScrollButton = /*#__PURE__*/React__default.forwardRef(function PageHeaderExpander(_ref8, ref) { let { className, children, label, onClick, collapseText = 'Collapse', expandText = 'Expand', ...other } = _ref8; const { refs, fullyCollapsed } = usePageHeader(); const handleScroller = isFullyCollapsed => { if (!refs?.contentRef?.current) { return; } const scrollableTarget = scrollableAncestor(refs?.contentRef.current); // Page header content is not fully collapsed if (!isFullyCollapsed) { const pageHeaderContentHeight = refs?.contentRef.current.offsetHeight; scrollableTarget?.scrollTo({ top: pageHeaderContentHeight, // headerTopValue, check if breadcrumb bar is included behavior: 'smooth' }); } else { // Page header content is fully collapsed scrollableTarget?.scrollTo({ top: 0, behavior: 'smooth' }); } }; return /*#__PURE__*/React__default.createElement(IconButton, _extends({ ref: ref, label: fullyCollapsed ? expandText : collapseText, size: "md", kind: "ghost", autoAlign: true }, other, { onClick: event => { onClick?.(event); handleScroller(!!fullyCollapsed); }, className: cx(className, `${pkg.prefix}--page-header--scroller-button`) }), /*#__PURE__*/React__default.createElement(ChevronUp, { className: cx(`${pkg.prefix}--page-header--scroller-button-icon`, { [`${pkg.prefix}--page-header--scroller-button-icon-collapsed`]: fullyCollapsed }) })); }); const PageHeaderTitleBreadcrumb = /*#__PURE__*/forwardRef((_ref9, ref) => { let { className, children, ...other } = _ref9; const { titleClipped, refs } = usePageHeader(); return /*#__PURE__*/React__default.createElement(BreadcrumbItem, _extends({ ref: ref, isCurrentPage: true }, other, { className: cx(className, `${pkg.prefix}--page-header-title-breadcrumb`, { [`${pkg.prefix}--page-header-title-breadcrumb-show`]: titleClipped && refs?.titleRef }) }), children); }); // This component is a wrapper for the Breadcrumb, and renders breadcrumb items as children // including the overflow breadcrumb item. The overflowHandler determines what elements // are visible and hidden and passes the hidden elements back to the render prop used // to display the overflow breadcrumb const PageHeaderBreadcrumbOverflow = /*#__PURE__*/forwardRef((_ref10, ref) => { let { renderOverflowBreadcrumb, className, children, ...other } = _ref10; const [hiddenBreadcrumbs, setHiddenBreadcrumbs] = React__default.useState([]); const fallbackRef = useRef(null); const componentRef = ref ?? fallbackRef; // Initialize overflow resize handler const carbonPrefix = usePrefix(); useEffect(() => { if (!componentRef) { return; } const breadcrumbList = componentRef?.current.querySelector(`.${carbonPrefix}--breadcrumb`); createOverflowHandler({ container: breadcrumbList, onChange: (_, hidden) => { setHiddenBreadcrumbs(hidden); } }); // Don't want ref or carbon prefix in dependency array // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const renderChildren = () => { // Only BreadcrumbItems and TitleBreadcrumbs are valid children const filteredBreadcrumbs = React__default.Children.toArray(children).filter(child => { if (/*#__PURE__*/React__default.isValidElement(child)) { return child.type === BreadcrumbItem || PageHeaderTitleBreadcrumb; } }); // We need to clone the renderProp for the overflow breadcrumb item // to place it before the title breadcrumb according to the design if (filteredBreadcrumbs) { const overflowBreadcrumb = renderOverflowBreadcrumb?.(hiddenBreadcrumbs); // If no overflow breadcrumb provided, return here with the rest of the children if (!overflowBreadcrumb) { return children; } const clonedTitleBreadcrumb = /*#__PURE__*/React__default.cloneElement(overflowBreadcrumb, { key: 'cloned overflow breadcrumb item', 'data-fixed': true, className: cx(`${pkg.prefix}--page-header-breadcrumb-overflow-item`, { [`${pkg.prefix}--page-header-overflow-breadcrumb-item-with-items`]: hiddenBreadcrumbs.length }) }); const clonedChildren = [...filteredBreadcrumbs]; clonedChildren.splice(filteredBreadcrumbs.length - 1, 0, clonedTitleBreadcrumb); // second to last position return clonedChildren; } return children; }; return /*#__PURE__*/React__default.createElement(Breadcrumb, _extends({ className: cx(className, `${pkg.prefix}--page-header-breadcrumb-overflow`), ref: componentRef }, other), renderChildren()); }); /** * ------- * Exports * ------- */ const Root = PageHeader; Root.displayName = 'PageHeader.Root'; const BreadcrumbBar = PageHeaderBreadcrumbBar; BreadcrumbBar.displayName = 'PageHeaderBreadcrumbBar'; const Content = PageHeaderContent; Content.displayName = 'PageHeaderContent'; const ContentPageActions = PageHeaderContentPageActions; ContentPageActions.displayName = 'PageHeaderContentPageActions'; const ContentText = PageHeaderContentText; ContentText.displayName = 'PageHeaderContentText'; const HeroImage = PageHeaderHeroImage; HeroImage.displayName = 'PageHeaderHeroImage'; const TabBar = PageHeaderTabBar; TabBar.displayName = 'PageHeaderTabBar'; const ScrollButton = PageHeaderScrollButton; ScrollButton.displayName = 'PageHeaderScrollButton'; const TitleBreadcrumb = PageHeaderTitleBreadcrumb; TitleBreadcrumb.displayName = 'PageHeaderTitleBreadcrumb'; const BreadcrumbOverflow = PageHeaderBreadcrumbOverflow; BreadcrumbOverflow.displayName = 'PageHeaderBreadcrumbOverflow'; export { BreadcrumbBar, BreadcrumbOverflow, Content, ContentPageActions, ContentText, HeroImage, PageHeader, PageHeaderBreadcrumbBar, PageHeaderBreadcrumbOverflow, PageHeaderContent, PageHeaderContentPageActions, PageHeaderContentText, PageHeaderHeroImage, PageHeaderScrollButton, PageHeaderTabBar, PageHeaderTitleBreadcrumb, Root, ScrollButton, TabBar, TitleBreadcrumb };