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

Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
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
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -104,12 +104,9 @@ describe(componentName, () => {

it('Obeys max visible', async () => {
render(<TagOverflow {...tagOverflowProps} maxVisible={3} />);

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
Expand Down
238 changes: 73 additions & 165 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 @@ -114,26 +118,22 @@
tagComponent,
...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 containerRef = useRef<HTMLDivElement>(null);
const offsetRef = useRef<HTMLDivElement>(null);
const [showAllModalOpen, setShowAllModalOpen] = useState<boolean>(false);
const [popoverOpen, setPopoverOpen] = useState<boolean>(false);

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

const handleShowAllClick = () => {
setShowAllModalOpen(true);
Expand All @@ -143,110 +143,17 @@
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;

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
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?.();
Expand All @@ -259,51 +166,52 @@

return (
<div
{
// Pass through any other property values as HTML attributes.
...rest
}
className={cx(blockClass, className, `${blockClass}--align-${align}`, {
[`${blockClass}--multiline`]: multiline,
})}
ref={containerRef}
{...rest}
className={cx(blockClass, className)}
{...getDevtoolsProps(componentName)}
ref={ref}
>
{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 ? (
<DismissibleTag
{...other}
className={`${blockClass}__item--tag`}
type={tagType}
onClose={() => handleTagOnClose(onClose, index)}
text={label}
/>
) : (
<Tag
{...other}
className={`${blockClass}__item--tag`}
type={tagType}
>
{label}
</Tag>
)}
</div>
);
}
<div
className={cx(
`${blockClass}__visible-tags`,
`${blockClass}--align-${align}`
)}
ref={containerRef}
>
{visibleItems.map((item, index) => {
const { id, label, tagType, onClose, filter, ...other } = item;
return (
<div
className={`${blockClass}__tag-container`}
ref={(node) => {
itemRefHandler(id, node);
}}
key={id}
>
{tagComponent ? (
getCustomComponent(item, tagComponent)

Check warning on line 192 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#L192

Added line #L192 was not covered by tests
) : typeof onClose === 'function' || filter ? (
<DismissibleTag

Check warning on line 194 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#L194

Added line #L194 was not covered by tests
{...other}
className={`${blockClass}__item--tag`}
type={tagType}
onClose={() => handleTagOnClose(onClose, index)}

Check warning on line 198 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#L198

Added line #L198 was not covered by tests
text={label}
/>
) : (
<Tag
{...other}
className={`${blockClass}__item--tag`}
type={tagType}
>
{label}
</Tag>
)}
</div>
);
})}

<span className={`${blockClass}__indicator`} ref={overflowRef}>
{overflowItems?.length > 0 && (
<>
{overflowItems.length > 0 && (
<div className={`${blockClass}__indicator`} ref={offsetRef}>
<TagOverflowPopover
allTagsModalSearchThreshold={allTagsModalSearchThreshold}
className={overflowClassName}
Expand All @@ -313,7 +221,7 @@
overflowType={overflowType}
showAllTagsLabel={showAllTagsLabel}
key="tag-overflow-popover"
ref={overflowRef}
ref={offsetRef}
popoverOpen={popoverOpen}
setPopoverOpen={setPopoverOpen}
autoAlign={autoAlign}
Expand All @@ -329,9 +237,9 @@
searchPlaceholder={allTagsModalSearchPlaceholderText}
portalTarget={allTagsModalTarget}
/>
</>
</div>
)}
</span>
</div>
</div>
);
}
Expand Down
Loading
Loading