Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: decouple overflow logic to create independent overflow utility #6632

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand Down
147 changes: 22 additions & 125 deletions packages/ibm-products/src/components/TagOverflow/TagOverflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,10 @@

import React, {
ReactNode,
Ref,
RefObject,
createElement,
forwardRef,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
Expand All @@ -26,7 +24,7 @@
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;
/**
Expand Down Expand Up @@ -61,10 +59,19 @@
allTagsModalTitle?: string;
autoAlign?: boolean;
className?: string;
containingElementRef?: RefObject<HTMLElement>;
/**
* @deprecated The `containingElementRef` prop is no longer going to be used in favor of the forwarded ref.
*/
containingElementRef?: RefObject<HTMLDivElement>;
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'
Expand All @@ -90,8 +97,8 @@
const componentName = 'TagOverflow';
const allTagsModalSearchThreshold = 10;

export let TagOverflow = forwardRef(
(props: TagOverflowProps, ref: Ref<HTMLDivElement>) => {
export let TagOverflow = forwardRef<HTMLDivElement, TagOverflowProps>(
(props, ref) => {
const {
align = 'start',
allTagsModalAriaLabel,
Expand All @@ -101,11 +108,8 @@
allTagsModalTitle,
autoAlign,
className,
containingElementRef,
items,
maxVisible,
measurementOffset = 0,
multiline,
overflowAlign = 'bottom',
overflowClassName,
overflowType = 'default',
Expand All @@ -115,25 +119,16 @@
...rest
} = props;

const localContainerRef = useRef<HTMLDivElement>(null);
const containerRef = ref || localContainerRef;
const itemRefs = useRef<Map<string, string> | null>(null);
const overflowRef = useRef<HTMLDivElement>(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<number>(0);
const [visibleItems, setVisibleItems] = useState<TagOverflowItem[]>([]);
const [overflowItems, setOverflowItems] = useState<TagOverflowItem[]>([]);
const [showAllModalOpen, setShowAllModalOpen] = useState<boolean>(false);
const [popoverOpen, setPopoverOpen] = useState<boolean>(false);

const resizeElm =
containingElementRef && containingElementRef.current
? containingElementRef
: containerRef;
const {
visibleItems,
hiddenItems: overflowItems,
containerRef,
itemRefHandler,
} = useOverflowItems(items, ref, maxVisible, onOverflowTagChange);

const handleShowAllClick = () => {
setShowAllModalOpen(true);
Expand All @@ -143,80 +138,6 @@
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
Expand All @@ -226,27 +147,10 @@
...other,
key: id,
className: cx(`${blockClass}__item`, className),
ref: (node) => itemRefHandler(id, node),
ref: (node) => itemRefHandler(id, node as HTMLDivElement),

Check warning on line 150 in packages/ibm-products/src/components/TagOverflow/TagOverflow.tsx

View check run for this annotation

Codecov / codecov/patch

packages/ibm-products/src/components/TagOverflow/TagOverflow.tsx#L150

Added line #L150 was not covered by tests
});
};

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?.();
Expand All @@ -259,24 +163,17 @@

return (
<div
{
// Pass through any other property values as HTML attributes.
...rest
}
className={cx(blockClass, className, `${blockClass}--align-${align}`, {
[`${blockClass}--multiline`]: multiline,
})}
{...rest}
className={cx(blockClass, className, `${blockClass}--align-${align}`)}
ref={containerRef}
{...getDevtoolsProps(componentName)}
>
{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 (
<div ref={(node) => itemRefHandler(id, node)} key={id}>
{typeof onClose === 'function' || filter ? (
Expand Down
100 changes: 100 additions & 0 deletions packages/ibm-products/src/global/js/hooks/useOverflowItems.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Item>(
items: T[] = [],
ref?: ForwardedRef<HTMLDivElement>,
maxVisible?: number,
onChange?: (hiddenItems: T[]) => void
) {
const localRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Map<number, number> | null>(null);
const [containerWidth, setContainerWidth] = useState<number>(0);
const containerRef = ref || localRef;
const visibleItemCount = useRef<number>(0);

const handleResize = () => {
if (typeof containerRef !== 'function' && containerRef.current) {
setContainerWidth(containerRef.current.offsetWidth);

Check warning on line 29 in packages/ibm-products/src/global/js/hooks/useOverflowItems.ts

View check run for this annotation

Codecov / codecov/patch

packages/ibm-products/src/global/js/hooks/useOverflowItems.ts#L28-L29

Added lines #L28 - L29 were not covered by tests
}
};

const getMap = (): Map<string | number, number> => {
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;

Check warning on line 51 in packages/ibm-products/src/global/js/hooks/useOverflowItems.ts

View check run for this annotation

Codecov / codecov/patch

packages/ibm-products/src/global/js/hooks/useOverflowItems.ts#L51

Added line #L51 was not covered by tests
map.set(id, totalWidth);
}
};

const getItems = (): T[] => {
const map = getMap();
if (!map) {
return items;

Check warning on line 59 in packages/ibm-products/src/global/js/hooks/useOverflowItems.ts

View check run for this annotation

Codecov / codecov/patch

packages/ibm-products/src/global/js/hooks/useOverflowItems.ts#L59

Added line #L59 was not covered by tests
}
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;

Check warning on line 76 in packages/ibm-products/src/global/js/hooks/useOverflowItems.ts

View check run for this annotation

Codecov / codecov/patch

packages/ibm-products/src/global/js/hooks/useOverflowItems.ts#L75-L76

Added lines #L75 - L76 were not covered by tests
}
}
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,
};
}
Loading