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.tsx b/packages/ibm-products/src/components/TagOverflow/TagOverflow.tsx index ee592090a9..afaf53d5ed 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', @@ -115,25 +119,16 @@ export let TagOverflow = forwardRef( ...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 [showAllModalOpen, setShowAllModalOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false); - const resizeElm = - containingElementRef && containingElementRef.current - ? containingElementRef - : containerRef; + const { + visibleItems, + hiddenItems: overflowItems, + containerRef, + itemRefHandler, + } = useOverflowItems(items, ref, maxVisible, onOverflowTagChange); const handleShowAllClick = () => { setShowAllModalOpen(true); @@ -143,80 +138,6 @@ 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 @@ -226,27 +147,10 @@ export let TagOverflow = forwardRef( ...other, key: id, className: cx(`${blockClass}__item`, className), - ref: (node) => itemRefHandler(id, node), + ref: (node) => itemRefHandler(id, node as HTMLDivElement), }); }; - 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,24 +163,17 @@ 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 ? ( 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..0323a02191 --- /dev/null +++ b/packages/ibm-products/src/global/js/hooks/useOverflowItems.ts @@ -0,0 +1,100 @@ +/** + * Copyright IBM Corp. 2024, 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 { useState, useRef, ForwardedRef } from 'react'; +import { useResizeObserver } from './useResizeObserver'; + +type Item = { + id: string; +}; + +export function useOverflowItems( + items: T[] = [], + ref?: ForwardedRef, + maxVisible?: number, + onChange?: (hiddenItems: T[]) => void +) { + const localRef = useRef(null); + const itemRefs = useRef | null>(null); + const [containerWidth, setContainerWidth] = useState(0); + const containerRef = ref || localRef; + const visibleItemCount = useRef(0); + + const handleResize = () => { + if (typeof containerRef !== 'function' && containerRef.current) { + setContainerWidth(containerRef.current.offsetWidth); + } + }; + + const getMap = (): Map => { + if (!itemRefs.current) { + itemRefs.current = new Map(); + } + return itemRefs.current; + }; + + const itemRefHandler = ( + id: string | number, + node: HTMLElement | null + ): void => { + const map = getMap(); + if (node && !map.get(id)) { + const style = getComputedStyle?.(node); + const totalWidth = style + ? parseInt(style.marginLeft) + + parseInt(style.marginRight) + + node.offsetWidth + : node.offsetWidth; + map.set(id, totalWidth); + } + }; + + const getItems = (): T[] => { + const map = getMap(); + if (!map) { + return items; + } + const maxWidth = containerWidth; + let maxReached = false; + let totalWidth = 0; + + return items.reduce((prev, cur) => { + if (maxVisible && prev.length >= maxVisible) { + maxReached = true; + } + if (!maxReached) { + const itemWidth = map.get(cur.id) || 0; + const willFit = itemWidth + totalWidth <= maxWidth; + if (willFit) { + totalWidth += itemWidth; + prev.push(cur); + } else { + maxReached = true; + } + } + return prev; + }, [] as T[]); + }; + + useResizeObserver(containerRef, handleResize); + + const visibleItems = getItems(); + const visibleItemsNum = visibleItems.length; + const hiddenItems = items.slice(visibleItemsNum); + // only call the change handler when the number of visible items has changed + if (visibleItemsNum !== visibleItemCount.current) { + visibleItemCount.current = visibleItemsNum; + onChange?.(hiddenItems); + } + + return { + visibleItems, + hiddenItems, + containerRef, + itemRefHandler, + }; +}