Skip to content

Commit

Permalink
refactor: decouple overflow logic to create independent overflow utility
Browse files Browse the repository at this point in the history
  • Loading branch information
davidmenendez committed Dec 27, 2024
1 parent 660c98a commit c325bba
Show file tree
Hide file tree
Showing 3 changed files with 116 additions and 125 deletions.
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 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;
/**
Expand Down Expand Up @@ -61,10 +59,19 @@ export interface TagOverflowProps {
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 blockClass = `${pkg.prefix}--tag-overflow`;
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 @@ export let TagOverflow = forwardRef(
allTagsModalTitle,
autoAlign,
className,
containingElementRef,
items,
maxVisible,
measurementOffset = 0,
multiline,
overflowAlign = 'bottom',
overflowClassName,
overflowType = 'default',
Expand All @@ -115,25 +119,16 @@ export let TagOverflow = forwardRef(
...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 @@ 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
Expand All @@ -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),

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 @@ export let TagOverflow = forwardRef(

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
93 changes: 93 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,93 @@
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 22 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#L21-L22

Added lines #L21 - L22 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 44 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#L44

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

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

Check warning on line 52 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#L52

Added line #L52 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 69 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#L68-L69

Added lines #L68 - L69 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,
};
}

0 comments on commit c325bba

Please sign in to comment.