diff --git a/src/components/Tag/TagList.tsx b/src/components/Tag/TagList.tsx index 6aed6706c..ddb72b32f 100644 --- a/src/components/Tag/TagList.tsx +++ b/src/components/Tag/TagList.tsx @@ -1,21 +1,194 @@ import React from "react"; import { CLASSPREFIX as eccgui } from "../../configuration/constants"; +import Tooltip from "../Tooltip/Tooltip"; + +import Tag from "./Tag"; export interface TagListProps extends React.HTMLAttributes { label?: string; } function TagList({ children, className = "", label = "", ...otherProps }: TagListProps) { + const containerRef = React.useRef(null); + const measurementRef = React.useRef(null); + const moreTagRef = React.useRef(null); + const [visibleCount, setVisibleCount] = React.useState(null); + + const childArray = React.useMemo(() => React.Children.toArray(children).filter(Boolean), [children]); + + React.useEffect(() => { + let rafId: number | null = null; + + const checkOverflow = () => { + if (!containerRef.current || !measurementRef.current || !moreTagRef.current || childArray.length === 0) { + return; + } + + const container = containerRef.current; + const measurement = measurementRef.current; + const containerWidth = container.clientWidth; + + // If no size constraints, show all tags + if (containerWidth === 0) { + setVisibleCount(null); + return; + } + + const items = Array.from(measurement.children).filter( + (child) => !(child as HTMLElement).dataset.moreTag + ) as HTMLLIElement[]; + + if (items.length === 0) { + setVisibleCount(null); + return; + } + + // Get the actual width of the "+X more" tag + const moreTagWidth = moreTagRef.current.offsetWidth; + + let totalWidth = 0; + let count = 0; + + // Calculate how many items fit in one row + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const itemWidth = item.offsetWidth; + + if (totalWidth + itemWidth <= containerWidth) { + totalWidth += itemWidth; + count++; + } else { + // This item doesn't fit + break; + } + } + + // If not all items fit, adjust count to leave room for "+X more" tag + if (count < childArray.length) { + let adjustedWidth = 0; + let adjustedCount = 0; + + for (let i = 0; i < count; i++) { + const item = items[i]; + const itemWidth = item.offsetWidth; + + if (adjustedWidth + itemWidth + moreTagWidth <= containerWidth) { + adjustedWidth += itemWidth; + adjustedCount++; + } else { + break; + } + } + + // Ensure at least one tag is visible before the "+X more" tag + if (adjustedCount > 0) { + setVisibleCount(adjustedCount); + } else { + // No tags fit alongside the "+X more" tag (e.g. first tag is very wide). + // Force 1 visible tag so the overflow indicator is still shown; + // CSS (min-width: 0 + overflow: hidden on the li) handles clipping. + setVisibleCount(1); + } + } else { + // All items fit + setVisibleCount(null); + } + }; + + // Use RAF to ensure DOM is ready + rafId = requestAnimationFrame(() => { + checkOverflow(); + }); + + // Watch for size changes + const resizeObserver = new ResizeObserver(() => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(checkOverflow); + }); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + resizeObserver.disconnect(); + }; + }, [childArray]); + + const showOverflowTag = visibleCount !== null && visibleCount < childArray.length; + const visibleChildren = showOverflowTag ? childArray.slice(0, visibleCount) : childArray; + const hiddenCount = childArray.length - (visibleCount ?? childArray.length); + const tagList = ( -
    - {React.Children.map(children, (child, i) => { - return child ? ( -
  • - {child} -
  • - ) : null; - })} +
      + {visibleChildren.map((child, i) => ( +
    • + {child} +
    • + ))} + {showOverflowTag && ( +
    • + + {childArray.map((child, i) => ( + {child} + ))} + + } + size="large" + > + + +{hiddenCount} more + + +
    • + )} +
    + ); + + // Hidden measurement list - always rendered for measurements + const measurementList = ( + ); @@ -23,12 +196,20 @@ function TagList({ children, className = "", label = "", ...otherProps }: TagLis return (
    {label} - {tagList} + + {tagList} + {measurementList} +
    ); } - return tagList; + return ( +
    + {tagList} + {measurementList} +
    + ); } export default TagList; diff --git a/src/components/Tag/stories/TagList.stories.tsx b/src/components/Tag/stories/TagList.stories.tsx index 27221c360..6d09d1161 100644 --- a/src/components/Tag/stories/TagList.stories.tsx +++ b/src/components/Tag/stories/TagList.stories.tsx @@ -20,3 +20,25 @@ List.args = { label: "Tag list", children: [Short, List, Of, Tags], }; + +export const ListWithOverflow: StoryFn = () => ( +
    + + Goooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo + JavaScript + TypeScript + Python + Java + C++ + Ruby + Rust + +
    +); +ListWithOverflow.parameters = { + docs: { + description: { + story: 'When tags exceed the container width, a "+X more" button appears. Hover over it to see all tags in a tooltip.', + }, + }, +}; diff --git a/src/components/Tag/tag.scss b/src/components/Tag/tag.scss index 8abfb2e12..a6e31afcc 100644 --- a/src/components/Tag/tag.scss +++ b/src/components/Tag/tag.scss @@ -272,6 +272,52 @@ $tag-round-adjustment: 0 !default; } } +.#{$eccgui}-tag__list { + display: flex; + flex-wrap: nowrap; + align-items: flex-start; + overflow: hidden; +} + +.#{$eccgui}-tag__list-content { + position: relative; +} + +.#{$eccgui}-tag__list-container { + position: relative; + display: flex; + align-items: center; +} + +.#{$eccgui}-tag__list--measure { + position: absolute; + display: flex; + visibility: hidden; + flex-wrap: nowrap; + pointer-events: none; +} + +.#{$eccgui}-tag__list-item--overflow { + min-width: 0; + overflow: hidden; +} + +.#{$eccgui}-tag__list-item--more { + flex-shrink: 0; + align-items: flex-start; + + > .#{$eccgui}-tooltip__wrapper { + align-self: flex-start; + } +} + +.#{$eccgui}-tag__list-overflow-content { + display: flex; + flex-wrap: wrap; + gap: $eccgui-size-margin-tag; + max-width: 400px; +} + @media print { .#{$eccgui}-tag__item { print-color-adjust: exact; diff --git a/src/components/Tooltip/tooltip.scss b/src/components/Tooltip/tooltip.scss index 3e9678bee..b40c466ae 100644 --- a/src/components/Tooltip/tooltip.scss +++ b/src/components/Tooltip/tooltip.scss @@ -34,6 +34,10 @@ $tooltip-padding-horizontal: $eccgui-size-block-whitespace * 0.5; // !default; } .#{$eccgui}-tooltip__wrapper:not(.#{$ns}-tooltip-indicator) { + display: flex; + align-items: flex-start; + padding: 0; + margin: 0; cursor: inherit; } diff --git a/src/test/setupTests.js b/src/test/setupTests.js index 192736502..9a4b38ce5 100644 --- a/src/test/setupTests.js +++ b/src/test/setupTests.js @@ -1,7 +1,16 @@ import "regenerator-runtime/runtime"; -if (window.document) { - window.document.body.createTextRange = function () { +// In jsdom (which Jest uses), globalThis === window — they're the same object. jsdom sets up the global environment to mimic a browser, so globalThis.document is identical to window.document. +// In plain Node.js (where ESLint runs), globalThis exists but has no document property, so the if (globalThis.document) guard correctly skips the block. +// So yes, it works correctly in all three contexts: browser, jsdom, and Node.js. +globalThis.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +if (globalThis.document) { + globalThis.document.body.createTextRange = function () { return { setEnd: function () {}, setStart: function () {},