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 @@ -45,16 +45,16 @@ tags associated with the object. -### 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', +};