Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 191 additions & 10 deletions src/components/Tag/TagList.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,215 @@
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<HTMLUListElement> {
label?: string;
}

function TagList({ children, className = "", label = "", ...otherProps }: TagListProps) {
const containerRef = React.useRef<HTMLUListElement>(null);
const measurementRef = React.useRef<HTMLUListElement>(null);
const moreTagRef = React.useRef<HTMLLIElement>(null);
const [visibleCount, setVisibleCount] = React.useState<number | null>(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 = (
<ul className={`${eccgui}-tag__list` + (className && !label ? " " + className : "")} {...otherProps}>
{React.Children.map(children, (child, i) => {
return child ? (
<li className={`${eccgui}-tag__list-item`} key={"tagitem_" + i}>
{child}
</li>
) : null;
})}
<ul
className={`${eccgui}-tag__list` + (className && !label ? " " + className : "")}
{...otherProps}
role="list"
aria-label={label || "Tag list"}
ref={containerRef}
>
{visibleChildren.map((child, i) => (
<li
className={
`${eccgui}-tag__list-item` + (showOverflowTag ? ` ${eccgui}-tag__list-item--overflow` : "")
}
role="listitem"
key={"tagitem_" + i}
>
{child}
</li>
))}
{showOverflowTag && (
<li
className={`${eccgui}-tag__list-item ${eccgui}-tag__list-item--more`}
role="listitem"
key="overflow-tag"
>
<Tooltip
content={
<div className={`${eccgui}-tag__list-overflow-content`}>
{childArray.map((child, i) => (
<React.Fragment key={"tooltip-tag-" + i}>{child}</React.Fragment>
))}
</div>
}
size="large"
>
<Tag
small
aria-label={`${hiddenCount} more ${
hiddenCount === 1 ? "tag" : "tags"
} hidden. Hover to see all ${childArray.length} tags.`}
>
+{hiddenCount} more
</Tag>
</Tooltip>
</li>
)}
</ul>
);

// Hidden measurement list - always rendered for measurements
const measurementList = (
<ul
className={`${eccgui}-tag__list--measure`}
style={{ width: containerRef.current?.clientWidth ?? "100%" }}
aria-hidden="true"
ref={measurementRef}
>
{childArray.map((child, i) => (
<li className={`${eccgui}-tag__list-item`} key={"measure_" + i}>
{child}
</li>
))}
<li className={`${eccgui}-tag__list-item`} key="measure-more-tag" ref={moreTagRef} data-more-tag="true">
<Tag small>+{childArray.length} more</Tag>
</li>
</ul>
);

if (label) {
return (
<div className={`${eccgui}-tag__list-wrapper` + (className ? " " + className : "")}>
<strong className={`${eccgui}-tag__list-label`}>{label}</strong>
<span className={`${eccgui}-tag__list-content`}>{tagList}</span>
<span className={`${eccgui}-tag__list-content`}>
{tagList}
{measurementList}
</span>
</div>
);
}

return tagList;
return (
<div className={`${eccgui}-tag__list-container` + (className ? " " + className : "")}>
{tagList}
{measurementList}
</div>
);
}

export default TagList;
22 changes: 22 additions & 0 deletions src/components/Tag/stories/TagList.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,25 @@ List.args = {
label: "Tag list",
children: [<Tag small>Short</Tag>, <Tag>List</Tag>, <Tag>Of</Tag>, <Tag large>Tags</Tag>],
};

export const ListWithOverflow: StoryFn<typeof TagList> = () => (
<div style={{ maxWidth: "240px", border: "1px solid #ddd", padding: "16px" }}>
<TagList label="Programming Languages">
<Tag small>Goooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo</Tag>
<Tag small>JavaScript</Tag>
<Tag small>TypeScript</Tag>
<Tag small>Python</Tag>
<Tag small>Java</Tag>
<Tag small>C++</Tag>
<Tag small>Ruby</Tag>
<Tag small>Rust</Tag>
</TagList>
</div>
);
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.',
},
},
};
46 changes: 46 additions & 0 deletions src/components/Tag/tag.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/components/Tooltip/tooltip.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
13 changes: 11 additions & 2 deletions src/test/setupTests.js
Original file line number Diff line number Diff line change
@@ -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 () {},
Expand Down