diff --git a/packages/ibm-products-styles/src/components/TagOverflow/_tag-overflow.scss b/packages/ibm-products-styles/src/components/TagOverflow/_tag-overflow.scss index 207d86585d..897310cc29 100644 --- a/packages/ibm-products-styles/src/components/TagOverflow/_tag-overflow.scss +++ b/packages/ibm-products-styles/src/components/TagOverflow/_tag-overflow.scss @@ -26,13 +26,8 @@ $block-class: #{c4p-settings.$pkg-prefix}--tag-overflow; $block-class-overflow: #{$block-class}-popover; $block-class-modal: #{$block-class}-modal; -.#{$block-class} { +.#{$block-class}__visible-tags { display: flex; - width: 100%; - min-width: $spacing-12; - align-items: center; - justify-content: flex-start; - white-space: nowrap; } .#{$block-class}--align-end { diff --git a/packages/ibm-products/src/components/TagOverflow/TagOverflow.stories.jsx b/packages/ibm-products/src/components/TagOverflow/TagOverflow.stories.jsx index e7fd34845e..e10ac46038 100644 --- a/packages/ibm-products/src/components/TagOverflow/TagOverflow.stories.jsx +++ b/packages/ibm-products/src/components/TagOverflow/TagOverflow.stories.jsx @@ -75,6 +75,7 @@ export const TagsWithOverflowCount = Template.bind({}); TagsWithOverflowCount.args = { containerWidth: 250, items: fiveTags, + onOverflowTagChange: (items) => console.log(items), }; export const TagsWithTruncation = Template.bind({}); diff --git a/packages/ibm-products/src/components/TagOverflow/TagOverflow.test.js b/packages/ibm-products/src/components/TagOverflow/TagOverflow.test.js index 263e41f434..85727438fa 100644 --- a/packages/ibm-products/src/components/TagOverflow/TagOverflow.test.js +++ b/packages/ibm-products/src/components/TagOverflow/TagOverflow.test.js @@ -6,7 +6,7 @@ */ import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; // https://testing-library.com/docs/react-testing-library/intro +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; // https://testing-library.com/docs/react-testing-library/intro import { pkg } from '../../settings'; import uuidv4 from '../../global/js/utils/uuidv4'; @@ -104,12 +104,9 @@ describe(componentName, () => { it('Obeys max visible', async () => { render(); - - expect( - screen.getAllByText(/Tag [0-9]+/, { - selector: `.${blockClass}__item--tag span`, - }).length - ).toEqual(3); + await waitFor(() => { + expect(screen.getByText('+2')); + }); }); // The below test case is failing due to ResizeObserver mock diff --git a/packages/ibm-products/src/components/TagOverflow/TagOverflow.tsx b/packages/ibm-products/src/components/TagOverflow/TagOverflow.tsx index ee592090a9..582b0833a2 100644 --- a/packages/ibm-products/src/components/TagOverflow/TagOverflow.tsx +++ b/packages/ibm-products/src/components/TagOverflow/TagOverflow.tsx @@ -7,12 +7,10 @@ import React, { ReactNode, - Ref, RefObject, createElement, forwardRef, useCallback, - useEffect, useRef, useState, } from 'react'; @@ -26,7 +24,7 @@ import cx from 'classnames'; import { getDevtoolsProps } from '../../global/js/utils/devtools'; import { isRequiredIf } from '../../global/js/utils/props-helper'; import { pkg } from '../../settings'; -import { useResizeObserver } from '../../global/js/hooks/useResizeObserver'; +import { useOverflowItems } from '../../global/js/hooks/useOverflowItems'; export interface TagOverflowItem { className?: string; /** @@ -61,10 +59,19 @@ export interface TagOverflowProps { allTagsModalTitle?: string; autoAlign?: boolean; className?: string; - containingElementRef?: RefObject; + /** + * @deprecated The `containingElementRef` prop is no longer going to be used in favor of the forwarded ref. + */ + containingElementRef?: RefObject; items: TagOverflowItem[]; maxVisible?: number; + /** + * @deprecated The `measurementOffset` prop is no longer going to be used. This value will now be calculated automatically. + */ measurementOffset?: number; + /** + * @deprecated The `multiline` prop is no longer going to be used. This component should only be used when you need to hide overflowing items. + */ multiline?: boolean; overflowAlign?: | 'top' @@ -90,8 +97,8 @@ const blockClass = `${pkg.prefix}--tag-overflow`; const componentName = 'TagOverflow'; const allTagsModalSearchThreshold = 10; -export let TagOverflow = forwardRef( - (props: TagOverflowProps, ref: Ref) => { +export let TagOverflow = forwardRef( + (props, ref) => { const { align = 'start', allTagsModalAriaLabel, @@ -101,11 +108,8 @@ export let TagOverflow = forwardRef( allTagsModalTitle, autoAlign, className, - containingElementRef, items, maxVisible, - measurementOffset = 0, - multiline, overflowAlign = 'bottom', overflowClassName, overflowType = 'default', @@ -114,26 +118,22 @@ export let TagOverflow = forwardRef( tagComponent, ...rest } = props; - - const localContainerRef = useRef(null); - const containerRef = ref || localContainerRef; - const itemRefs = useRef | null>(null); - const overflowRef = useRef(null); - // itemOffset is the value of margin applied on each items - // This value is required for calculating how many items will fit within the container - const itemOffset = 4; - const overflowIndicatorWidth = 40; - - const [containerWidth, setContainerWidth] = useState(0); - const [visibleItems, setVisibleItems] = useState([]); - const [overflowItems, setOverflowItems] = useState([]); + const containerRef = useRef(null); + const offsetRef = useRef(null); const [showAllModalOpen, setShowAllModalOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false); - const resizeElm = - containingElementRef && containingElementRef.current - ? containingElementRef - : containerRef; + const { + visibleItems, + hiddenItems: overflowItems, + itemRefHandler, + } = useOverflowItems( + items, + containerRef, + offsetRef, + maxVisible, + onOverflowTagChange + ); const handleShowAllClick = () => { setShowAllModalOpen(true); @@ -143,110 +143,17 @@ export let TagOverflow = forwardRef( setShowAllModalOpen(false); }; - const handleResize = () => { - if (typeof resizeElm !== 'function' && resizeElm.current) { - setContainerWidth(resizeElm.current.offsetWidth); - } - }; - - useResizeObserver(resizeElm, handleResize); - - const getMap = () => { - if (!itemRefs.current) { - itemRefs.current = new Map(); - } - return itemRefs.current; - }; - - const itemRefHandler = (id, node) => { - const map = getMap(); - if (id && node && map.get(id) !== node.offsetWidth) { - map.set(id, node.offsetWidth); - } - }; - - const getVisibleItems = useCallback(() => { - if (!itemRefs.current) { - return items; - } - - if (multiline) { - const visibleItems = maxVisible ? items?.slice(0, maxVisible) : items; - return visibleItems; - } - - const map = getMap(); - const optionalContainingElement = containingElementRef?.current; - const measurementOffsetValue = - typeof measurementOffset === 'number' ? measurementOffset : 0; - const spaceAvailable = optionalContainingElement - ? optionalContainingElement.offsetWidth - measurementOffsetValue - : containerWidth; - - const overflowContainerWidth = - overflowRef && - overflowRef.current && - overflowRef.current.offsetWidth > overflowIndicatorWidth - ? overflowRef.current.offsetWidth - : overflowIndicatorWidth; - const maxWidth = spaceAvailable - overflowContainerWidth; - - let childrenWidth = 0; - let maxReached = false; - - return items.reduce((prev: TagOverflowItem[], cur: TagOverflowItem) => { - if (!maxReached) { - const itemWidth = (map ? Number(map.get(cur.id)) : 0) + itemOffset; - const fits = itemWidth + childrenWidth < maxWidth; - - if (fits) { - childrenWidth += itemWidth; - prev.push(cur); - } else { - maxReached = true; - } - } - return prev; - }, []); - }, [ - containerWidth, - containingElementRef, - items, - maxVisible, - measurementOffset, - multiline, - ]); - const getCustomComponent = ( item: TagOverflowItem, tagComponent: string ) => { - const { className, id, ...other } = item; + const { className, ...other } = item; return createElement(tagComponent, { ...other, - key: id, className: cx(`${blockClass}__item`, className), - ref: (node) => itemRefHandler(id, node), }); }; - useEffect(() => { - let visibleItemsArr = getVisibleItems(); - - if (maxVisible && maxVisible < visibleItemsArr.length) { - visibleItemsArr = visibleItemsArr?.slice(0, maxVisible); - } - - const hiddenItems = items?.slice(visibleItemsArr.length); - const overflowItemsArr = hiddenItems?.map(({ tagType, ...other }) => { - return { type: tagType, ...other }; - }); - - setVisibleItems(visibleItemsArr); - setOverflowItems(overflowItemsArr); - onOverflowTagChange?.(overflowItemsArr); - }, [getVisibleItems, items, maxVisible, onOverflowTagChange]); - const handleTagOnClose = useCallback( (onClose, index) => { onClose?.(); @@ -259,51 +166,52 @@ export let TagOverflow = forwardRef( return (
- {visibleItems?.length > 0 && - visibleItems.map((item, index) => { - // Render custom components - if (tagComponent) { - return getCustomComponent(item, tagComponent); - } else { - const { id, label, tagType, onClose, filter, ...other } = item; - // If there is no template prop, then render items as Tags - return ( -
itemRefHandler(id, node)} key={id}> - {typeof onClose === 'function' || filter ? ( - handleTagOnClose(onClose, index)} - text={label} - /> - ) : ( - - {label} - - )} -
- ); - } +
+ {visibleItems.map((item, index) => { + const { id, label, tagType, onClose, filter, ...other } = item; + return ( +
{ + itemRefHandler(id, node); + }} + key={id} + > + {tagComponent ? ( + getCustomComponent(item, tagComponent) + ) : typeof onClose === 'function' || filter ? ( + handleTagOnClose(onClose, index)} + text={label} + /> + ) : ( + + {label} + + )} +
+ ); })} - - - {overflowItems?.length > 0 && ( - <> + {overflowItems.length > 0 && ( +
- +
)} -
+
); } diff --git a/packages/ibm-products/src/global/js/hooks/useOverflowItems.ts b/packages/ibm-products/src/global/js/hooks/useOverflowItems.ts new file mode 100644 index 0000000000..f18955a9e3 --- /dev/null +++ b/packages/ibm-products/src/global/js/hooks/useOverflowItems.ts @@ -0,0 +1,99 @@ +/** + * Copyright IBM Corp. 2025, 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 { RefObject, useRef, useState } from 'react'; +import { useResizeObserver } from './useResizeObserver'; + +type Item = { + id: string; +}; + +export const useOverflowItems = ( + items: T[] = [], + containerRef: RefObject, + offsetRef?: RefObject, + maxItems?: number, + onChange?: (hiddenItems: T[]) => void +) => { + const itemsRef = useRef | null>(null); + const [maxWidth, setMaxWidth] = useState(0); + const visibleItemCount = useRef(0); + + const handleResize = () => { + if (containerRef.current) { + const offset = offsetRef?.current?.offsetWidth || 0; + const newMax = containerRef.current.offsetWidth - offset; + setMaxWidth(newMax); + } + }; + + useResizeObserver(containerRef, handleResize); + + const getMap = () => { + if (!itemsRef.current) { + itemsRef.current = new Map(); + } + return itemsRef.current; + }; + + const itemRefHandler = (id: string, node: HTMLDivElement | null) => { + const map = getMap(); + if (node) { + const style = getComputedStyle?.(node); + const totalWidth = + node.offsetWidth + + parseInt(style.marginLeft) + + parseInt(style.marginRight); + map.set(id, totalWidth); + } + + return () => { + map.delete(id); + }; + }; + + const getVisibleItems = () => { + if (!containerRef) { + return items; + } + + const map = getMap(); + let maxReached = false; + let accumulatedWidth = 0; + + const visibleItems = items.slice(0, maxItems).reduce((prev, cur) => { + if (maxReached) { + return prev; + } + + const itemWidth = map.get(cur.id) || 0; + const willFit = accumulatedWidth + itemWidth <= maxWidth; + if (willFit) { + accumulatedWidth += itemWidth; + prev.push(cur); + } else { + maxReached = true; + } + return prev; + }, [] as T[]); + return visibleItems; + }; + + const visibleItems = getVisibleItems(); + const hiddenItems = items.slice(visibleItems.length); + // only call the change handler when the number of visible items has changed + if (visibleItems.length !== visibleItemCount.current) { + visibleItemCount.current = visibleItems.length; + onChange?.(hiddenItems); + } + + return { + visibleItems, + itemRefHandler, + hiddenItems, + }; +};