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 e14c9c192c..aa042accc3 100644
--- a/packages/ibm-products-styles/src/components/TagOverflow/_tag-overflow.scss
+++ b/packages/ibm-products-styles/src/components/TagOverflow/_tag-overflow.scss
@@ -9,6 +9,9 @@
@use '../../global/styles/project-settings' as c4p-settings;
@use '../../global/styles/mixins';
@use '@carbon/styles/scss/spacing' as *;
+@use '@carbon/styles/scss/breakpoint' as *;
+@use '@carbon/styles/scss/theme' as *;
+@use '@carbon/styles/scss/type';
// Other Carbon settings if needed
// TODO: @use '@carbon/styles/scss/grid';
@@ -20,15 +23,26 @@
// The block part of our conventional BEM class names (blockClass__E--M).
$block-class: #{c4p-settings.$pkg-prefix}--tag-overflow;
+$block-class-overflow: #{$block-class}-popover;
+$block-class-modal: #{$block-class}-modal;
.#{$block-class} {
display: flex;
width: 100%;
min-width: $spacing-12;
align-items: center;
+ justify-content: flex-start;
white-space: nowrap;
}
+.#{$block-class}--align-end {
+ justify-content: flex-end;
+}
+
+.#{$block-class}--align-center {
+ justify-content: center;
+}
+
.#{$block-class}--multiline {
flex-wrap: wrap;
}
@@ -53,3 +67,146 @@ $block-class: #{c4p-settings.$pkg-prefix}--tag-overflow;
display: inline-block;
max-width: $spacing-09;
}
+
+.#{$block-class-overflow} {
+ display: inline-block;
+ vertical-align: bottom;
+ .#{c4p-settings.$carbon-prefix}--tag.#{c4p-settings.$carbon-prefix}--tag--interactive {
+ border: 0;
+ }
+
+ .#{c4p-settings.$carbon-prefix}--popover
+ .#{c4p-settings.$carbon-prefix}--popover-content {
+ padding: $spacing-05;
+ }
+}
+
+.#{$block-class-overflow}--hidden {
+ overflow: hidden;
+ max-width: 0;
+ visibility: hidden;
+}
+
+@include mixins.block-wrap('#{$block-class-overflow}__el') {
+ &.#{$block-class-overflow}__el {
+ // removes the min width in Carbon
+ min-width: initial;
+ text-align: left;
+ }
+
+ .#{$block-class-overflow}__trigger {
+ font-family: inherit;
+ }
+
+ .#{$block-class-overflow}__show-all-tags-link.#{c4p-settings.$carbon-prefix}--link:visited {
+ display: inline-block;
+ margin: $spacing-03 0 $spacing-02; // to match the tags
+ color: $link-inverse;
+ }
+
+ .#{c4p-settings.$carbon-prefix}--link:active,
+ .#{c4p-settings.$carbon-prefix}--link:active:visited,
+ .#{c4p-settings.$carbon-prefix}--link:active:visited:hover {
+ color: $text-inverse;
+ }
+
+ .#{$block-class-overflow}__tag-list {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .#{$block-class-overflow}__show-all-tags-link {
+ margin-top: $spacing-03;
+ color: $link-inverse;
+ }
+
+ .#{$block-class-overflow}__tag-item.#{$block-class-overflow}__tag-item--tag
+ .#{c4p-settings.$carbon-prefix}--tag {
+ background-color: $background-inverse-hover;
+ }
+
+ .#{$block-class-overflow}__tag-item.#{$block-class-overflow}__tag-item--default,
+ .#{$block-class-overflow}__tag-item.#{$block-class-overflow}__tag-item--default
+ .#{c4p-settings.$carbon-prefix}--tag {
+ @include type.type-style('body-compact-01');
+
+ display: block;
+ overflow: hidden;
+ min-width: initial;
+ min-height: initial;
+ padding: 0;
+ border-radius: 0;
+ margin: 0;
+ background-color: inherit;
+ color: inherit;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .#{$block-class-overflow}__tag
+ .#{c4p-settings.$carbon-prefix}--tag__close-icon {
+ // undo override by .#{c4p-settings.$carbon-prefix}--tooltip button
+ padding: 0;
+ }
+
+ .#{$block-class-overflow}__tag
+ .#{c4p-settings.$carbon-prefix}--tag--high-contrast {
+ background-color: $background;
+ color: $text-primary;
+ }
+
+ .#{$block-class-overflow}__tag
+ .#{c4p-settings.$carbon-prefix}--tag__close-icon:hover {
+ background-color: $background-hover;
+ }
+
+ .#{$block-class-overflow}__tag
+ .#{c4p-settings.$carbon-prefix}--tag__close-icon:focus {
+ box-shadow: inset 0 0 0 $spacing-01 $focus;
+ }
+}
+
+@include mixins.block-wrap('#{$block-class-modal}') {
+ &.#{$block-class-modal} {
+ // not to be overridden by use in tag set
+ text-align: initial;
+ white-space: initial;
+ }
+
+ .#{$block-class-modal}__container {
+ @include breakpoint(md) {
+ height: 90%;
+ max-height: 450px;
+ }
+ }
+
+ .#{$block-class-modal}__search {
+ margin-top: $spacing-05;
+ margin-bottom: 0;
+ }
+
+ &.#{$block-class-modal} .#{$block-class-modal}__fade {
+ position: relative;
+ margin-right: $spacing-05;
+ margin-left: $spacing-05;
+ }
+
+ .#{$block-class-modal}__body {
+ padding-bottom: $spacing-06;
+ }
+
+ .#{$block-class-modal}__header {
+ padding-right: 0;
+ margin-right: $spacing-05;
+ }
+
+ &.#{$block-class-modal} .#{$block-class-modal}__fade::after {
+ position: absolute;
+ top: calc(-1 * #{$spacing-11});
+ left: 0;
+ width: 100%;
+ height: $spacing-07;
+ background: linear-gradient(to bottom, transparent, $layer-01);
+ content: '';
+ }
+}
diff --git a/packages/ibm-products/src/components/TagOverflow/TagOverflow.js b/packages/ibm-products/src/components/TagOverflow/TagOverflow.js
index 4640bf575b..eea31b3400 100644
--- a/packages/ibm-products/src/components/TagOverflow/TagOverflow.js
+++ b/packages/ibm-products/src/components/TagOverflow/TagOverflow.js
@@ -10,19 +10,30 @@ import PropTypes from 'prop-types';
import cx from 'classnames';
import { getDevtoolsProps } from '../../global/js/utils/devtools';
+import { isRequiredIf } from '../../global/js/utils/props-helper';
import { pkg } from '../../settings';
import { Tag, Tooltip } from '@carbon/react';
-import { TagSet } from '../TagSet';
import { TYPES } from './constants';
import { useResizeObserver } from '../../global/js/hooks/useResizeObserver';
+import { TagOverflowPopover } from './TagOverflowPopover';
+import { TagOverflowModal } from './TagOverflowModal';
const blockClass = `${pkg.prefix}--tag-overflow`;
const componentName = 'TagOverflow';
+const allTagsModalSearchThreshold = 10;
+
+// TODO: support prop overflowType
+
// Default values for props
const defaults = {
items: [],
+ align: 'start',
+ measurementOffset: 0,
+ overflowAlign: 'bottom',
+ overflowType: 'default',
+ onOverflowTagChange: () => {},
};
/**
@@ -31,11 +42,24 @@ const defaults = {
export let TagOverflow = React.forwardRef(
(
{
- className,
items = defaults.items,
tagComponent,
+ align = defaults.align,
+ showAllTagsLabel,
+ allTagsModalSearchLabel,
+ allTagsModalSearchPlaceholderText,
+ allTagsModalTarget,
+ allTagsModalTitle,
+ className,
+ containingElementRef,
+ measurementOffset = defaults.measurementOffset,
maxVisible,
multiline,
+ overflowAlign = defaults.overflowAlign,
+ overflowClassName,
+ overflowType = defaults.overflowType,
+ onOverflowTagChange = defaults.onOverflowTagChange,
+
// Collect any other property values passed in.
...rest
},
@@ -45,20 +69,35 @@ export let TagOverflow = React.forwardRef(
const containerRef = ref || localContainerRef;
const itemRefs = useRef(null);
const overflowRef = useRef(null);
- // measurementOffset is the value of margin applied on each items
+ // 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 measurementOffset = 4;
+ 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 handleShowAllClick = () => {
+ setShowAllModalOpen(true);
+ };
+
+ const handleModalClose = () => {
+ setShowAllModalOpen(false);
+ };
const handleResize = () => {
- setContainerWidth(containerRef.current.offsetWidth);
+ setContainerWidth(resizeElm.current.offsetWidth);
};
- useResizeObserver(containerRef, handleResize);
+ useResizeObserver(resizeElm, handleResize);
const getMap = () => {
if (!itemRefs.current) {
@@ -85,18 +124,25 @@ export let TagOverflow = React.forwardRef(
}
const map = getMap();
+ const optionalContainingElement = containingElementRef?.current;
+ const measurementOffsetValue =
+ typeof measurementOffset === 'number' ? measurementOffset : 0;
+ let spaceAvailable = optionalContainingElement
+ ? optionalContainingElement.offsetWidth - measurementOffsetValue
+ : containerWidth;
+
const overflowContainerWidth =
- overflowRef.current.offsetWidth > overflowIndicatorWidth
+ overflowRef.current?.offsetWidth > overflowIndicatorWidth
? overflowRef.current.offsetWidth
: overflowIndicatorWidth;
- const maxWidth = containerWidth - overflowContainerWidth;
+ const maxWidth = spaceAvailable - overflowContainerWidth;
let childrenWidth = 0;
let maxReached = false;
return items.reduce((prev, cur) => {
if (!maxReached) {
- const itemWidth = map.get(cur.id) + measurementOffset;
+ const itemWidth = map.get(cur.id) + itemOffset;
const fits = itemWidth + childrenWidth < maxWidth;
if (fits) {
@@ -108,7 +154,16 @@ export let TagOverflow = React.forwardRef(
}
return prev;
}, []);
- }, [itemRefs, overflowRef, containerWidth, items, multiline, maxVisible]);
+ }, [
+ itemRefs,
+ overflowRef,
+ containerWidth,
+ items,
+ multiline,
+ maxVisible,
+ containingElementRef,
+ measurementOffset,
+ ]);
const getCustomComponent = (item) => {
const { className, id, ...other } = item;
@@ -128,12 +183,19 @@ export let TagOverflow = React.forwardRef(
const hiddenItems = items?.slice(visibleItemsArr.length);
const overflowItemsArr = hiddenItems?.map((item) => {
- return { type: item.tagType, label: item.label };
+ return { type: item.tagType, label: item.label, id: item.id };
});
setVisibleItems(visibleItemsArr);
setOverflowItems(overflowItemsArr);
- }, [containerWidth, items, maxVisible, getVisibleItems]);
+ onOverflowTagChange?.(overflowItemsArr);
+ }, [
+ containerWidth,
+ items,
+ maxVisible,
+ getVisibleItems,
+ onOverflowTagChange,
+ ]);
return (
{overflowItems.length > 0 && (
-
+ <>
+
+
+ >
)}
@@ -198,21 +277,57 @@ TagOverflow.displayName = componentName;
const tagTypes = Object.keys(TYPES);
+/**
+ * The strings shown in the showAllModal are only shown if we have more than allTagsModalSearchLThreshold
+ * @returns null if no problems
+ */
+export const string_required_if_more_than_10_tags = isRequiredIf(
+ PropTypes.string,
+ ({ items }) => items && items.length > allTagsModalSearchThreshold
+);
+
// The types and DocGen commentary for the component props,
// in alphabetical order (for consistency).
// See https://www.npmjs.com/package/prop-types#usage.
TagOverflow.propTypes = {
+ /**
+ * align the Tags displayed by the TagSet. Default start.
+ */
+ align: PropTypes.oneOf(['start', 'center', 'end']),
+ /**
+ * label text for the show all search. **Note: Required if more than 10 tags**
+ */
+ allTagsModalSearchLabel: string_required_if_more_than_10_tags,
+ /**
+ * placeholder text for the show all search. **Note: Required if more than 10 tags**
+ */
+ allTagsModalSearchPlaceholderText: string_required_if_more_than_10_tags,
+ /**
+ * portal target for the all tags modal
+ */
+ allTagsModalTarget: PropTypes.node,
+ /**
+ * title for the show all modal. **Note: Required if more than 10 tags**
+ */
+ allTagsModalTitle: string_required_if_more_than_10_tags,
/**
* Provide an optional class to be applied to the containing node.
*/
className: PropTypes.string,
+ /**
+ * Optional ref for custom resize container to measure available space
+ * Default will measure the available space of the TagSet container itself.
+ */
+ containingElementRef: PropTypes.object,
/**
* The items to be shown in the TagOverflow. Each item is specified as an object with properties:
- * **label**\* (required) to supply the item content,
- * **id**\* (required) to uniquely identify the each item.
- * **tagType** the type value to be passed to the Carbon Tag component
- * if you are passing an tagComponent prop for rendering custom components,
+ * **label**\* (required) to supply the content,
+ * **id**\* (required) to uniquely identify each item.
+ * **tagType** the type value to be passed to the Carbon Tag component.
+ * Refer https://react.carbondesignsystem.com/?path=/docs/components-tag--default to see the possible values for tagType
+ *
+ * If you want to render a custom component, pass it as tagComponent prop and
* then pass the props required for your custom component as the properties of item object
*/
items: PropTypes.arrayOf(
@@ -222,15 +337,54 @@ TagOverflow.propTypes = {
tagType: PropTypes.oneOf(tagTypes),
}).isRequired
),
-
/**
* maximum visible items
*/
maxVisible: PropTypes.number,
+ /**
+ * Specify offset amount for measure available space, only used when `containingElementSelector`
+ * is also provided
+ */
+ measurementOffset: PropTypes.number,
/**
* display items in multiple lines
*/
multiline: PropTypes.bool,
+ /**
+ * Handler to get overflow tags
+ */
+ onOverflowTagChange: PropTypes.func,
+ /**
+ * overflowAlign from the standard tooltip. Default center.
+ */
+ overflowAlign: PropTypes.oneOf([
+ 'top',
+ 'top-left',
+ 'top-right',
+ 'bottom',
+ 'bottom-left',
+ 'bottom-right',
+ 'left',
+ 'left-bottom',
+ 'left-top',
+ 'right',
+ 'right-bottom',
+ 'right-top',
+ ]),
+ /**
+ * overflowClassName for the tooltip popup
+ */
+ overflowClassName: PropTypes.string,
+ /**
+ * Type of rendering displayed inside of the tag overflow component
+ */
+ overflowType: PropTypes.oneOf(['default', 'tag']),
+ /**
+ * label for the overflow show all tags link.
+ *
+ * **Note:** Required if more than 10 tags
+ */
+ showAllTagsLabel: string_required_if_more_than_10_tags,
/** Component definition of the items to be rendered inside TagOverflow.
* If this is not passed, items will be rendered as Tag component
*/
diff --git a/packages/ibm-products/src/components/TagOverflow/TagOverflow.mdx b/packages/ibm-products/src/components/TagOverflow/TagOverflow.mdx
index 8fa5459b51..c1cfa9593c 100644
--- a/packages/ibm-products/src/components/TagOverflow/TagOverflow.mdx
+++ b/packages/ibm-products/src/components/TagOverflow/TagOverflow.mdx
@@ -27,13 +27,13 @@ tags associated with the object.
## Example usage
-### Tags
+### Tags with overflow count and overflow modal
-### Tags with truncation
+### Tags with truncation and overflow count
-### UserAvatars
+### UserAvatars with overflow count and overflow modal
-### Custom components
+### Custom components with overflow count and overflow modal
## Coded example
diff --git a/packages/ibm-products/src/components/TagOverflow/TagOverflow.stories.jsx b/packages/ibm-products/src/components/TagOverflow/TagOverflow.stories.jsx
index fe5f02e7e0..06dc6a678d 100644
--- a/packages/ibm-products/src/components/TagOverflow/TagOverflow.stories.jsx
+++ b/packages/ibm-products/src/components/TagOverflow/TagOverflow.stories.jsx
@@ -5,161 +5,30 @@
* LICENSE file in the root directory of this source tree.
*/
-import React, { forwardRef } from 'react';
-import * as CarbonIcons from '@carbon/icons-react';
+import React from 'react';
import { Theme } from '@carbon/react';
import { pkg } from '../../settings';
import { UserAvatar } from '../UserAvatar';
import { DisplayBox } from '../../global/js/utils/DisplayBox';
import { TagOverflow } from '.';
-import { TYPES } from './constants';
import mdx from './TagOverflow.mdx';
import styles from './_storybook-styles.scss?inline';
+import {
+ IconComponent,
+ IconComponentArr,
+ ManyUserAvatarArr,
+ UserAvatarArr,
+ fiveTags,
+ longTags,
+ overflowAndModalStrings,
+ tags,
+} from './utils';
const blockClass = `${pkg.prefix}--tag-set`;
const blockClassModal = `${blockClass}-modal`;
-const tagLabel = (index) => `Tag ${index + 1}`;
-
-const tags = Array.from({ length: 20 }, (v, k) => ({
- label: tagLabel(k),
- id: `id-${k}`,
-}));
-
-const fiveTags = tags.slice(0, 5);
-
-let longTagsArr = [...fiveTags];
-longTagsArr.splice(1, 1, { id: 'id-1', label: 'Business performance' });
-const tagTypes = Object.keys(TYPES);
-const longTags = longTagsArr.map((item, i) => {
- return { ...item, tagType: tagTypes[i % tagTypes.length] };
-});
-
-// UserAvatar background colors
-const colors = [
- 'order-1-cyan',
- 'order-2-gray',
- 'order-3-green',
- 'order-4-magenta',
- 'order-5-purple',
- 'order-6-teal',
- 'order-7-cyan',
- 'order-8-gray',
- 'order-9-green',
- 'order-10-magenta',
- 'order-11-purple',
- 'order-12-teal',
-];
-
-// Lists of first names and last names
-//cspell: disable
-const firstNames = [
- 'Aarav',
- 'Aditi',
- 'Akshay',
- 'Amit',
- 'Ananya',
- 'Arjun',
- 'Avani',
- 'Bhavya',
- 'Chetan',
- 'Devi',
- 'Divya',
- 'Gaurav',
- 'Isha',
- 'Kiran',
- 'Manoj',
- 'Neha',
- 'Preeti',
- 'Rajesh',
- 'Riya',
- 'Shreya',
- 'Varun',
- 'Saurabh',
- 'Ajay',
- 'Sandip',
- 'Sadan',
- 'Jyoti',
- 'Sapna',
- 'Prem',
-];
-
-const lastNames = [
- 'Agarwal',
- 'Bansal',
- 'Chopra',
- 'Gupta',
- 'Jain',
- 'Kapoor',
- 'Mehta',
- 'Patel',
- 'Rao',
- 'Sharma',
- 'Singh',
- 'Trivedi',
- 'Verma',
- 'Yadav',
-];
-//cspell: enable
-
-// Method to generate random names
-const generateName = () => {
- const randomFirstName =
- firstNames[Math.floor(Math.random() * firstNames.length)];
- const randomLastName =
- lastNames[Math.floor(Math.random() * lastNames.length)];
- return `${randomFirstName} ${randomLastName}`;
-};
-
-// Users for UserAvatar stories
-const ManyUserAvatarArr = Array.from({ length: 20 }, (v, k) => {
- const name = generateName();
- return {
- id: `id-${k}`,
- label: name,
- backgroundColor: colors[k % colors.length],
- name,
- tooltipText: name,
- };
-});
-
-const UserAvatarArr = ManyUserAvatarArr.slice(0, 10);
-
-// Custom component
-const IconComponent = forwardRef(({ iconName, iconSize, className }, ref) => {
- const Base = CarbonIcons[iconName];
- return (
-
-
-
- );
-});
-
-// Carbon Icon component names for custom component story
-const icons = [
- 'Add',
- 'Power',
- 'Play',
- 'SettingsAdjust',
- 'SidePanelClose',
- 'Stop',
- 'VideoPlayer',
- 'VolumeUpFilled',
- 'ChartBubble',
- 'ChartLine',
- 'ChartPie',
- 'ChartWinLoss',
- 'DatabaseMessaging',
- 'Playlist',
- 'OrderDetails',
-];
-
-const IconComponentArr = icons.map((icon, index) => {
- return { id: `id-${index}`, label: icon, iconName: icon, iconSize: 16 };
-});
-
export default {
title: 'IBM Products/Components/Tag overflow/TagOverflow',
component: TagOverflow,
@@ -202,8 +71,8 @@ const Template = (argsIn) => {
};
// Declaration of stories
-export const FiveTags = Template.bind({});
-FiveTags.args = {
+export const TagsWithOverflowCount = Template.bind({});
+TagsWithOverflowCount.args = {
containerWidth: 250,
items: fiveTags,
};
@@ -214,10 +83,11 @@ TagsWithTruncation.args = {
items: longTags,
};
-export const ManyTags = Template.bind({});
-ManyTags.args = {
+export const TagsWithOverflowModal = Template.bind({});
+TagsWithOverflowModal.args = {
containerWidth: 500,
items: tags,
+ ...overflowAndModalStrings,
};
export const MultilineTags = Template.bind({});
@@ -225,25 +95,28 @@ MultilineTags.args = {
containerWidth: 500,
items: tags,
multiline: true,
+ ...overflowAndModalStrings,
};
-export const UserAvatars = Template.bind({});
-UserAvatars.args = {
+export const UserAvatarsWithOverflowCount = Template.bind({});
+UserAvatarsWithOverflowCount.args = {
containerWidth: 250,
items: UserAvatarArr,
tagComponent: UserAvatar,
};
-export const ManyUserAvatars = Template.bind({});
-ManyUserAvatars.args = {
- containerWidth: 500,
+export const UserAvatarsWithOverflowModal = Template.bind({});
+UserAvatarsWithOverflowModal.args = {
+ containerWidth: 300,
items: ManyUserAvatarArr,
tagComponent: UserAvatar,
+ ...overflowAndModalStrings,
};
-export const CustomComponent = Template.bind({});
-CustomComponent.args = {
- containerWidth: 500,
+export const CustomComponentsWithOverflowModal = Template.bind({});
+CustomComponentsWithOverflowModal.args = {
+ containerWidth: 200,
items: IconComponentArr,
tagComponent: IconComponent,
+ ...overflowAndModalStrings,
};
diff --git a/packages/ibm-products/src/components/TagOverflow/TagOverflow.test.js b/packages/ibm-products/src/components/TagOverflow/TagOverflow.test.js
index d284d4ef35..632d48c82d 100644
--- a/packages/ibm-products/src/components/TagOverflow/TagOverflow.test.js
+++ b/packages/ibm-products/src/components/TagOverflow/TagOverflow.test.js
@@ -12,6 +12,7 @@ import { pkg } from '../../settings';
import uuidv4 from '../../global/js/utils/uuidv4';
import { TagOverflow } from '.';
+import { fiveTags } from './utils';
const blockClass = `${pkg.prefix}--tag-overflow`;
const componentName = TagOverflow.displayName;
@@ -21,29 +22,10 @@ const className = `class-${uuidv4()}`;
const dataTestId = uuidv4();
const tagWidth = 60;
-const tagLabel = (index) => `Tag ${index + 1}`;
-const tags = Array.from({ length: 20 }, (v, k) => ({
- label: tagLabel(k),
- id: `id-${k}`,
-}));
-
-const fiveTags = tags.slice(0, 5);
-
const tagOverflowProps = {
items: fiveTags,
};
-const FiveTags = (argsIn) => {
- const { containerWidth, ...args } = {
- ...argsIn,
- };
- return (
-
-
-
- );
-};
-
describe(componentName, () => {
const { ResizeObserver } = window;
let warn;
@@ -103,10 +85,9 @@ describe(componentName, () => {
it('Renders all as visible tags when space available', async () => {
const tagCount = tagOverflowProps.items.length;
-
window.innerWidth = tagWidth * tagCount + 40;
- render();
+ render();
const firstTagLabel = tagOverflowProps.items[0].label;
const lastTagLabel = tagOverflowProps.items[tagCount - 1].label;
@@ -122,7 +103,7 @@ describe(componentName, () => {
});
it('Obeys max visible', async () => {
- render();
+ render();
expect(
screen.getAllByText(/Tag [0-9]+/, {
@@ -134,7 +115,7 @@ describe(componentName, () => {
// The below test case is failing due to ResizeObserver mock
// it('Renders only the overflow when very little space', async () => {
// window.innerWidth = tagWidth / 2;
- // render();
+ // render();
// const visible = screen.queryAllByText(/Tag [1-5]+/, {
// selector: `.${blockClass}__item--tag span`,
diff --git a/packages/ibm-products/src/components/TagOverflow/TagOverflowModal.js b/packages/ibm-products/src/components/TagOverflow/TagOverflowModal.js
new file mode 100644
index 0000000000..ab48dcb1da
--- /dev/null
+++ b/packages/ibm-products/src/components/TagOverflow/TagOverflowModal.js
@@ -0,0 +1,134 @@
+//
+// Copyright IBM Corp. 2024, 2024
+//
+// 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 React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import {
+ ComposedModal,
+ ModalHeader,
+ ModalBody,
+ Search,
+ Tag,
+} from '@carbon/react';
+
+import { pkg } from '../../settings';
+import { prepareProps } from '../../global/js/utils/props-helper';
+import { usePortalTarget } from '../../global/js/hooks/usePortalTarget';
+
+const componentName = 'TagOverflowModal';
+const blockClass = `${pkg.prefix}--tag-overflow-modal`;
+
+// Default values for props
+const defaults = {
+ // marked as required by TagSet if needed, default used to satisfy component
+ searchLabel: '',
+};
+
+export const TagOverflowModal = ({
+ // The component props, in alphabetical order (for consistency).
+
+ allTags,
+ className,
+ title,
+ onClose,
+ open,
+ portalTarget: portalTargetIn,
+ searchLabel = defaults.searchLabel,
+ searchPlaceholder,
+
+ // Collect any other property values passed in.
+ ...rest
+}) => {
+ const [search, setSearch] = useState('');
+ const renderPortalUse = usePortalTarget(portalTargetIn);
+
+ const getFilteredItems = () => {
+ let newFilteredModalTags = [];
+ if (open) {
+ if (search === '') {
+ newFilteredModalTags = allTags.slice(0);
+ } else {
+ const lCaseSearch = search.toLocaleLowerCase();
+
+ allTags.forEach((tag) => {
+ const dataSearch = tag['data-search']
+ ?.toLocaleLowerCase()
+ ?.indexOf(lCaseSearch);
+ const labelSearch = tag.label
+ ?.toLocaleLowerCase()
+ ?.indexOf(lCaseSearch);
+
+ if (dataSearch > -1 || labelSearch > -1) {
+ newFilteredModalTags.push(tag);
+ }
+ });
+ }
+ }
+ return newFilteredModalTags;
+ };
+
+ const handleSearch = (evt) => {
+ setSearch(evt.target.value || '');
+ };
+
+ return renderPortalUse(
+
+
+
+
+
+ {getFilteredItems().map(({ label, id, ...other }) => (
+
+ {label}
+
+ ))}
+
+
+
+ );
+};
+
+TagOverflowModal.propTypes = {
+ allTags: PropTypes.arrayOf(
+ PropTypes.shape({
+ ...prepareProps(Tag.propTypes, 'filter'),
+ label: PropTypes.string.isRequired,
+ })
+ ),
+ className: PropTypes.string,
+ onClose: PropTypes.func,
+ open: PropTypes.bool,
+ portalTarget: PropTypes.node,
+ searchLabel: PropTypes.string,
+ searchPlaceholder: PropTypes.string,
+ title: PropTypes.string,
+};
+
+TagOverflowModal.displayName = componentName;
diff --git a/packages/ibm-products/src/components/TagOverflow/TagOverflowPopover.js b/packages/ibm-products/src/components/TagOverflow/TagOverflowPopover.js
new file mode 100644
index 0000000000..3d6d596a1a
--- /dev/null
+++ b/packages/ibm-products/src/components/TagOverflow/TagOverflowPopover.js
@@ -0,0 +1,199 @@
+//
+// Copyright IBM Corp. 2024, 2024
+//
+// 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 React, { useRef } from 'react';
+import PropTypes from 'prop-types';
+
+import cx from 'classnames';
+
+import { Link, Tag, Popover, PopoverContent } from '@carbon/react';
+import { useClickOutside } from '../../global/js/hooks';
+
+import { pkg } from '../../settings';
+
+const componentName = 'TagOverflowPopover';
+const blockClass = `${pkg.prefix}--tag-overflow-popover`;
+
+// Default values for props
+const defaults = {
+ allTagsModalSearchThreshold: 10,
+ overflowAlign: 'bottom',
+};
+
+export const TagOverflowPopover = React.forwardRef(
+ (
+ {
+ // The component props, in alphabetical order (for consistency).
+ allTagsModalSearchThreshold = defaults.allTagsModalSearchThreshold,
+ className,
+ onShowAllClick,
+ overflowAlign = defaults.overflowAlign,
+ overflowTags,
+ overflowType,
+ showAllTagsLabel,
+ popoverOpen,
+ setPopoverOpen,
+ // Collect any other property values passed in.
+ ...rest
+ },
+ ref
+ ) => {
+ const localRef = useRef();
+ const overflowTagContent = useRef(null);
+
+ useClickOutside(ref || localRef, () => {
+ if (popoverOpen) {
+ setPopoverOpen(false);
+ }
+ });
+
+ const handleShowAllTagsClick = (evt) => {
+ evt.stopPropagation();
+ evt.preventDefault();
+ setPopoverOpen(false);
+ onShowAllClick();
+ };
+
+ const handleEscKeyPress = (event) => {
+ const { key } = event;
+ if (key === 'Escape') {
+ setPopoverOpen(false);
+ }
+ };
+
+ const getOverflowPopoverItems = () => {
+ return overflowTags.filter((_, index) =>
+ overflowTags.length > allTagsModalSearchThreshold
+ ? index < allTagsModalSearchThreshold
+ : index <= allTagsModalSearchThreshold
+ );
+ };
+
+ return (
+
+
+ setPopoverOpen(!popoverOpen)}
+ className={cx(`${blockClass}__trigger`)}
+ >
+ +{overflowTags.length}
+
+
+
+
+ {getOverflowPopoverItems().map((tag) => {
+ const tagProps = {};
+ if (overflowType === 'tag') {
+ tagProps.type = 'high-contrast';
+ }
+ if (overflowType === 'default') {
+ tagProps.filter = false;
+ }
+ return (
+ -
+ {tag.label}
+ {/* {React.cloneElement(tag, tagProps)} */}
+
+ );
+ })}
+
+ {overflowTags.length > allTagsModalSearchThreshold && (
+
+ {showAllTagsLabel}
+
+ )}
+
+
+
+
+ );
+ }
+);
+
+TagOverflowPopover.displayName = componentName;
+
+TagOverflowPopover.propTypes = {
+ /**
+ * count of overflowTags over which a modal is offered
+ */
+ allTagsModalSearchThreshold: PropTypes.number,
+ /**
+ * className
+ */
+ className: PropTypes.string,
+ /**
+ * function to execute on clicking show all
+ */
+ onShowAllClick: PropTypes.func.isRequired,
+ /**
+ * overflowAlign from the standard tooltip
+ */
+ overflowAlign: PropTypes.oneOf([
+ 'top',
+ 'top-left',
+ 'top-right',
+ 'bottom',
+ 'bottom-left',
+ 'bottom-right',
+ 'left',
+ 'left-bottom',
+ 'left-top',
+ 'right',
+ 'right-bottom',
+ 'right-top',
+ ]),
+ /**
+ * tags shown in overflow
+ */
+ overflowTags: PropTypes.arrayOf(PropTypes.object).isRequired,
+ /**
+ * Type of rendering displayed inside of the tag overflow component
+ */
+ overflowType: PropTypes.oneOf(['default', 'tag']),
+ /**
+ * Open state of the popover
+ */
+ popoverOpen: PropTypes.bool,
+ /**
+ * Setter function for the popoverOpen state value
+ */
+ setPopoverOpen: PropTypes.func,
+ /**
+ * label for the overflow show all tags link
+ */
+ showAllTagsLabel: PropTypes.string,
+};
diff --git a/packages/ibm-products/src/components/TagOverflow/utils.js b/packages/ibm-products/src/components/TagOverflow/utils.js
new file mode 100644
index 0000000000..60a9febf4f
--- /dev/null
+++ b/packages/ibm-products/src/components/TagOverflow/utils.js
@@ -0,0 +1,161 @@
+/**
+ * Copyright IBM Corp. 2024, 2024
+ *
+ * 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 React, { forwardRef } from 'react';
+
+import * as CarbonIcons from '@carbon/icons-react';
+
+import { TYPES } from './constants';
+
+const tagLabel = (index) => `Tag ${index + 1}`;
+
+export const tags = Array.from({ length: 20 }, (v, k) => ({
+ label: tagLabel(k),
+ id: `id-${k}`,
+}));
+
+export const fiveTags = tags.slice(0, 5);
+
+let longTagsArr = [...fiveTags];
+longTagsArr.splice(1, 1, { id: 'id-1', label: 'Business performance' });
+const tagTypes = Object.keys(TYPES);
+
+export const longTags = longTagsArr.map((item, i) => {
+ return { ...item, tagType: tagTypes[i % tagTypes.length] };
+});
+
+// UserAvatar background colors
+const colors = [
+ 'order-1-cyan',
+ 'order-2-gray',
+ 'order-3-green',
+ 'order-4-magenta',
+ 'order-5-purple',
+ 'order-6-teal',
+ 'order-7-cyan',
+ 'order-8-gray',
+ 'order-9-green',
+ 'order-10-magenta',
+ 'order-11-purple',
+ 'order-12-teal',
+];
+
+// Lists of first names and last names
+//cspell: disable
+const firstNames = [
+ 'Aarav',
+ 'Aditi',
+ 'Akshay',
+ 'Amit',
+ 'Ananya',
+ 'Arjun',
+ 'Avani',
+ 'Bhavya',
+ 'Chetan',
+ 'Devi',
+ 'Divya',
+ 'Gaurav',
+ 'Isha',
+ 'Kiran',
+ 'Manoj',
+ 'Neha',
+ 'Preeti',
+ 'Rajesh',
+ 'Riya',
+ 'Shreya',
+ 'Varun',
+ 'Saurabh',
+ 'Ajay',
+ 'Sandip',
+ 'Sadan',
+ 'Jyoti',
+ 'Sapna',
+ 'Prem',
+];
+
+const lastNames = [
+ 'Agarwal',
+ 'Bansal',
+ 'Chopra',
+ 'Gupta',
+ 'Jain',
+ 'Kapoor',
+ 'Mehta',
+ 'Patel',
+ 'Rao',
+ 'Sharma',
+ 'Singh',
+ 'Trivedi',
+ 'Verma',
+ 'Yadav',
+];
+//cspell: enable
+
+// Method to generate random names
+const generateName = () => {
+ const randomFirstName =
+ firstNames[Math.floor(Math.random() * firstNames.length)];
+ const randomLastName =
+ lastNames[Math.floor(Math.random() * lastNames.length)];
+ return `${randomFirstName} ${randomLastName}`;
+};
+
+// Users for UserAvatar stories
+export const ManyUserAvatarArr = Array.from({ length: 20 }, (v, k) => {
+ const name = generateName();
+ return {
+ id: `id-${k}`,
+ label: name,
+ backgroundColor: colors[k % colors.length],
+ name,
+ tooltipText: name,
+ };
+});
+
+export const UserAvatarArr = ManyUserAvatarArr.slice(0, 10);
+
+// Custom component
+export const IconComponent = forwardRef(
+ // eslint-disable-next-line react/prop-types
+ ({ iconName, iconSize, className }, ref) => {
+ const Base = CarbonIcons[iconName];
+ return (
+
+
+
+ );
+ }
+);
+
+// Carbon Icon component names for custom component story
+const icons = [
+ 'Add',
+ 'Power',
+ 'Play',
+ 'SettingsAdjust',
+ 'SidePanelClose',
+ 'Stop',
+ 'VideoPlayer',
+ 'VolumeUpFilled',
+ 'ChartBubble',
+ 'ChartLine',
+ 'ChartPie',
+ 'ChartWinLoss',
+ 'DatabaseMessaging',
+ 'Playlist',
+ 'OrderDetails',
+];
+
+export const IconComponentArr = icons.map((icon, index) => {
+ return { id: `id-${index}`, label: icon, iconName: icon, iconSize: 16 };
+});
+
+export const overflowAndModalStrings = {
+ allTagsModalTitle: 'All tags',
+ allTagsModalSearchLabel: 'Search all tags',
+ allTagsModalSearchPlaceholderText: 'Search all tags',
+ showAllTagsLabel: 'View all tags',
+};