@carbon/ibm-products
Version:
Carbon for IBM Products
341 lines (321 loc) • 13.6 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.
*/
;
var _rollupPluginBabelHelpers = require('../../_virtual/_rollupPluginBabelHelpers.js');
var React = require('react');
var index = require('../../_virtual/index.js');
var cx = require('classnames');
var useResizeObserver = require('../../global/js/hooks/useResizeObserver.js');
var react = require('@carbon/react');
var settings = require('../../settings.js');
var icons = require('@carbon/react/icons');
var uuidv4 = require('../../global/js/utils/uuidv4.js');
require('../../global/js/utils/props-helper.js');
var TooltipTrigger = require('../TooltipTrigger/TooltipTrigger.js');
var _TooltipTrigger;
// The block part of our conventional BEM class names (blockClass__E--M).
const blockClass = `${settings.pkg.prefix}--breadcrumb-with-overflow`;
const componentName = 'BreadcrumbWithOverflow';
// NOTE: the component SCSS is not imported here: it is rolled up separately.
/**
* The BreadcrumbWithOverflow is used internally by the PageHeader to wrap BreadcrumbItems.
*/
exports.BreadcrumbWithOverflow = _ref => {
let {
breadcrumbs,
className,
label,
maxVisible,
noTrailingSlash,
overflowAriaLabel,
overflowTooltipAlign,
...other
} = _ref;
const carbonPrefix = react.usePrefix();
const [displayCount, setDisplayCount] = React.useState(3);
const [displayedBreadcrumbItems, setDisplayedBreadcrumbItems] = React.useState([]);
const breadcrumbItemWithOverflow = React.useRef(null);
const sizingContainerRef = React.useRef(null);
const internalId = React.useRef(uuidv4.default());
const [hiddenSizingItems, setHiddenSizingItems] = React.useState([]);
// eslint-disable-next-line react/prop-types
const BreadcrumbOverflowMenu = _ref2 => {
let {
overflowItems
} = _ref2;
const handleClick = (evt, item) => {
if (item?.props?.href) {
window.location.href = item.props.href;
}
item?.props?.onClick?.(evt);
};
return /*#__PURE__*/React.createElement(react.BreadcrumbItem, {
key: `breadcrumb-overflow-${internalId.current}`
}, /*#__PURE__*/React.createElement(react.unstable_FeatureFlags, {
enableV12Overflowmenu: true
}, /*#__PURE__*/React.createElement(react.OverflowMenu, {
"aria-label": overflowAriaLabel,
label: overflowAriaLabel // also needs setting to avoid a11y "Accessible name does not match or contain the visible label text"
,
renderIcon: props => /*#__PURE__*/React.createElement(icons.OverflowMenuHorizontal, _rollupPluginBabelHelpers.extends({
size: 32
}, props)),
className: `${blockClass}__overflow-menu`,
tooltipAlignment: overflowTooltipAlign
},
// eslint-disable-next-line react/prop-types
overflowItems.map((item, index) => /*#__PURE__*/React.createElement(react.MenuItem, {
key: `breadcrumb-overflow-menu-item-${internalId.current}-${index}`,
onClick: evt => handleClick(evt, item),
label: item.props.children
})))));
};
// create hidden sizing items
React.useEffect(() => {
// Hidden action bar and items used to calculate sizes
setHiddenSizingItems(/*#__PURE__*/React.createElement("div", {
className: `${blockClass}__breadcrumb-container ${blockClass}__breadcrumb-container--hidden`,
"aria-hidden": true,
ref: sizingContainerRef
}, /*#__PURE__*/React.createElement(react.Breadcrumb, {
"aria-label": `${label}-hidden`
}, /*#__PURE__*/React.createElement(react.BreadcrumbItem, {
key: `${blockClass}-hidden-overflow-${internalId}`
}, /*#__PURE__*/React.createElement(react.OverflowMenu, {
"aria-label": overflowAriaLabel,
renderIcon: props => /*#__PURE__*/React.createElement(icons.OverflowMenuHorizontal, _rollupPluginBabelHelpers.extends({
size: 32
}, props))
})), breadcrumbs.map(_ref3 => {
let {
label,
key,
title,
id,
// short title isn't necessary for the hidden sizing
// eslint-disable-next-line no-unused-vars
shortTitle,
...rest
} = _ref3;
return /*#__PURE__*/React.createElement(react.BreadcrumbItem, _rollupPluginBabelHelpers.extends({
key: key
}, rest, {
// ensure id is not duplicated
"data-original-id": id,
title: title ?? label
}), label);
}))));
}, [breadcrumbs, label, overflowAriaLabel]);
React.useEffect(() => {
// updates displayedBreadcrumbItems and overflowBreadcrumbItems based on displayCount and breadcrumbs
/* istanbul ignore if */
if (breadcrumbs.length === 0) {
setDisplayedBreadcrumbItems([]);
return;
}
const newDisplayedBreadcrumbItems = breadcrumbs.map((_ref4, index) => {
let {
className,
key,
label,
shortTitle,
title,
...rest
} = _ref4;
return /*#__PURE__*/React.createElement(react.BreadcrumbItem, _rollupPluginBabelHelpers.extends({
key: key,
className: index > 0 || displayCount > 1 ? cx([className, `${blockClass}__displayed-breadcrumb`]) : className,
title: index + 1 === breadcrumbs.length ? title : null
}, rest), shortTitle || label);
});
// The breadcrumb has the form [first item] [overflow] [items 2...(n-1)] [last item].
// The overflow is only shown if there isn't space to display all the items, and in that case:
// * the last item is always displayed (even if there isn't really space for it -- it can contract to an ellipsis);
// * the first item is the next to be displayed, if there's space once the last item and overflow are shown;
// * any remaining space after the first item, last item and overflow are shown is used to show items (n-1), (n-2), (n-3), ..., until the space is used up ;
// Note that displayCount (min 1) has been computed based on the available space and the above sequence.
const overflowPosition = displayCount > 1 ? 1 : 0;
let newOverflowBreadcrumbItems = newDisplayedBreadcrumbItems.splice(overflowPosition, breadcrumbs.length - displayCount);
// if needed add overflow menu
if (newOverflowBreadcrumbItems.length) {
newDisplayedBreadcrumbItems.splice(overflowPosition, 0, /*#__PURE__*/React.createElement(BreadcrumbOverflowMenu, {
overflowItems: newOverflowBreadcrumbItems,
key: `displayed-breadcrumb-${internalId}-overflow`
}));
}
setDisplayedBreadcrumbItems(newDisplayedBreadcrumbItems);
}, [breadcrumbs, displayCount]);
const checkFullyVisibleBreadcrumbItems = () => {
if (!breadcrumbItemWithOverflow.current) {
return;
}
const displayItemIndex = (itemCount, index) => {
// In this data set the overflow measuring item is [0]
// so the first displayItem in the list is [1]
// we never return 0;
if (index === 0) {
return itemCount - 1; // the last item in the list
} else if (index === 1) {
return 1; // the first item in the list
} else {
return itemCount - index; // count down from itemCount - 2 to 1
}
};
if (maxVisible <= 1) {
setDisplayCount(1);
} else {
// how many will fit?
let willFit = 0;
let spaceAvailable = breadcrumbItemWithOverflow.current.offsetWidth; // not sure how to test resize
/* istanbul ignore next */
if (sizingContainerRef.current) {
const sizingBreadcrumbItems = sizingContainerRef.current.querySelectorAll(`.${carbonPrefix}--breadcrumb-item`);
const breadcrumbWidthsIncludingMargin = [];
for (let item of sizingBreadcrumbItems) {
const computedStyle = window ? window.getComputedStyle(sizingBreadcrumbItems[0]) : null;
const marginWidths = computedStyle ? parseFloat(computedStyle.marginLeft || 0, 10) + parseFloat(computedStyle.marginRight || 0, 10) : 0;
breadcrumbWidthsIncludingMargin.push(item.offsetWidth + marginWidths);
}
let overflowWidth = breadcrumbWidthsIncludingMargin[0];
for (let i = 0; i < breadcrumbWidthsIncludingMargin.length - 1; i++) {
// count used one less than length to account for the included overflow item
const index = displayItemIndex(breadcrumbWidthsIncludingMargin.length, i);
if (spaceAvailable >= breadcrumbWidthsIncludingMargin[index]) {
spaceAvailable -= breadcrumbWidthsIncludingMargin[index];
willFit += 1;
} else {
break;
}
}
// if not enough space for all breadcrumb items
if (willFit < breadcrumbWidthsIncludingMargin.length - 1) {
// -1 for overflow item
while (willFit > 0 && spaceAvailable < overflowWidth) {
willFit -= 1;
// Highly unlikely any useful breadcrumb-item is smaller than the overflow menu, but we loop anyway just in case
// item removed is based on last item added which is the current value of willFit
const itemToRemove = displayItemIndex(breadcrumbWidthsIncludingMargin.length, willFit);
spaceAvailable += breadcrumbWidthsIncludingMargin[itemToRemove];
}
}
}
if (willFit <= 1) {
setDisplayCount(1);
} else {
setDisplayCount(maxVisible ? Math.min(willFit, maxVisible) : willFit);
}
}
};
React.useEffect(() => {
checkFullyVisibleBreadcrumbItems();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hiddenSizingItems, maxVisible]);
/* istanbul ignore next */ // not sure how to test resize
const handleResize = () => {
/* istanbul ignore next */ // not sure how to test resize
checkFullyVisibleBreadcrumbItems();
};
let backItem = breadcrumbs[breadcrumbs.length - 1];
/* istanbul ignore if */ // not sure how to test media queries
if (backItem.isCurrentPage) {
backItem = breadcrumbs[breadcrumbs.length - 2];
}
// container resize
useResizeObserver.useResizeObserver(sizingContainerRef, handleResize);
// item resize
useResizeObserver.useResizeObserver(breadcrumbItemWithOverflow, handleResize);
return /*#__PURE__*/React.createElement("div", {
className: cx(blockClass, className, {
[`${blockClass}__with-items`]: displayedBreadcrumbItems.length > 1
}),
ref: breadcrumbItemWithOverflow
}, /*#__PURE__*/React.createElement("div", {
className: cx([`${blockClass}__space`])
}, hiddenSizingItems, /*#__PURE__*/React.createElement(react.Breadcrumb, _rollupPluginBabelHelpers.extends({
"aria-label": label,
className: cx(`${blockClass}__breadcrumb-container`, {
[`${blockClass}__breadcrumb-container-with-items`]: displayedBreadcrumbItems.length > 1
}),
noTrailingSlash: noTrailingSlash
}, other), backItem?.href && (backItem?.label || backItem?.title) && /*#__PURE__*/React.createElement(react.BreadcrumbItem, {
className: cx(`${blockClass}__breadcrumb-back`)
}, /*#__PURE__*/React.createElement(react.Link, {
href: backItem.href,
renderIcon: () => /*#__PURE__*/React.createElement(react.Tooltip, {
align: "right",
label: backItem.title || backItem.label,
className: `${blockClass}__back__button ${carbonPrefix}--icon-tooltip`
}, _TooltipTrigger || (_TooltipTrigger = /*#__PURE__*/React.createElement(TooltipTrigger.TooltipTrigger, null, /*#__PURE__*/React.createElement(icons.ArrowLeft, {
size: 16
}))))
})), displayedBreadcrumbItems)));
};
// Return a placeholder if not released and not enabled by feature flag
exports.BreadcrumbWithOverflow = settings.pkg.checkComponentEnabled(exports.BreadcrumbWithOverflow, componentName);
exports.BreadcrumbWithOverflow.propTypes = {
breadcrumbs: index.default.arrayOf(index.default.shape({
/**
* Optional string representing the link location for the BreadcrumbItem
*/
href: index.default.string,
/**
* Provide if this breadcrumb item represents the current page
*/
isCurrentPage: index.default.bool,
/**
* Key required to render array efficiently
*/
key: index.default.string.isRequired,
/**
* Pass in content that will be inside of the BreadcrumbItem
*/
label: index.default.node,
/**
* An optional title label for extra long breadcrumb
*/
shortTitle: index.default.string,
/**
* A string based alternative to the children, required only if children is not of type string
*/
title: index.default.string.isRequired.if(_ref5 => {
let {
label
} = _ref5;
return typeof label !== 'string';
})
})),
/**
* className
*/
className: index.default.string,
/**
* Label for the Breadcrumb component
*/
label: index.default.string,
/**
* maxVisible: maximum visible breadcrumb-items before overflow is used (values less than 1 are treated as 1)
*/
maxVisible: index.default.number,
/**
* noTrailing slash - same as for Carbon
*/
noTrailingSlash: index.default.bool,
/**
* overflowAriaLabel label for open close button overflow used for breadcrumb items that do not fit.
*/
overflowAriaLabel: index.default.string.isRequired.if(_ref6 => {
let {
breadcrumbs
} = _ref6;
return breadcrumbs.length > 1;
}),
/**
* overflowTooltipAlign: align tooltip position
*/
overflowTooltipAlign: react.Tooltip.propTypes.align
};
exports.BreadcrumbWithOverflow.displayName = componentName;