Skip to content
Merged
4 changes: 4 additions & 0 deletions registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,10 @@
"type": "registry:component",
"target": "src/components/data-grid/data-grid-search.tsx"
},
{
"path": "src/hooks/use-badge-truncation.ts",
"type": "registry:hook"
},
{
"path": "src/hooks/use-callback-ref.ts",
"type": "registry:hook"
Expand Down
30 changes: 20 additions & 10 deletions src/components/data-grid/data-grid-cell-variants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { useBadgeTruncation } from "@/hooks/use-badge-truncation";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { getLineCount } from "@/lib/data-grid";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -794,15 +795,15 @@ export function MultiSelectCell<TData>({
.filter(Boolean);

const rowHeight = table.options.meta?.rowHeight ?? "short";

const lineCount = getLineCount(rowHeight);
const maxVisibleBadgeCount = lineCount * 3;

const visibleLabels = displayLabels.slice(0, maxVisibleBadgeCount);
const hiddenBadgeCount = Math.max(
0,
displayLabels.length - maxVisibleBadgeCount,
);
const { visibleItems: visibleLabels, hiddenCount: hiddenBadgeCount } =
useBadgeTruncation({
items: displayLabels,
getLabel: (label) => label,
containerRef,
lineCount,
});

return (
<DataGridCellWrapper
Expand Down Expand Up @@ -1511,10 +1512,19 @@ export function FileCell<TData>({

const rowHeight = table.options.meta?.rowHeight ?? "short";
const lineCount = getLineCount(rowHeight);
const maxVisibleBadgeCount = lineCount * 3;

const visibleFiles = files.slice(0, maxVisibleBadgeCount);
const hiddenFileCount = Math.max(0, files.length - maxVisibleBadgeCount);
const { visibleItems: visibleFiles, hiddenCount: hiddenFileCount } =
useBadgeTruncation({
items: files,
getLabel: (file) => file.name,
containerRef,
lineCount,
cacheKeyPrefix: "file",
measureOpts: {
iconSize: 12,
maxWidth: 100,
},
});

return (
<DataGridCellWrapper
Expand Down
146 changes: 146 additions & 0 deletions src/hooks/use-badge-truncation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import * as React from "react";

const badgeWidthCache = new Map<string, number>();

interface MeasureBadgeOpts {
iconSize?: number;
maxWidth?: number;
className?: string;
}

function measureBadgeWidth(
label: string,
cacheKey: string,
opts?: MeasureBadgeOpts
): number {
const cached = badgeWidthCache.get(cacheKey);
if (cached !== undefined) {
return cached;
}

const measureEl = document.createElement("div");
measureEl.className = `inline-flex items-center rounded-md border px-1.5 text-xs font-semibold h-5 gap-1 shrink-0 absolute invisible pointer-events-none ${
opts?.className ?? ""
}`;
measureEl.style.whiteSpace = "nowrap";

if (opts?.iconSize) {
const icon = document.createElement("span");
icon.className = "shrink-0";
icon.style.width = `${opts.iconSize}px`;
icon.style.height = `${opts.iconSize}px`;
measureEl.appendChild(icon);
}

if (opts?.maxWidth) {
const text = document.createElement("span");
text.className = "truncate";
text.style.maxWidth = `${opts.maxWidth}px`;
text.textContent = label;
measureEl.appendChild(text);
} else {
measureEl.textContent = label;
}

document.body.appendChild(measureEl);
const width = measureEl.offsetWidth;
document.body.removeChild(measureEl);

badgeWidthCache.set(cacheKey, width);
return width;
}

interface UseBadgeTruncationOptions<T> {
items: T[];
getLabel: (item: T) => string;
containerRef: React.RefObject<HTMLElement | null>;
lineCount: number;
cacheKeyPrefix?: string;
measureOpts?: MeasureBadgeOpts;
}

interface UseBadgeTruncationResult<T> {
visibleItems: T[];
hiddenCount: number;
containerWidth: number;
}

export function useBadgeTruncation<T>({
items,
getLabel,
containerRef,
lineCount,
cacheKeyPrefix = "",
measureOpts,
}: UseBadgeTruncationOptions<T>): UseBadgeTruncationResult<T> {
const [containerWidth, setContainerWidth] = React.useState(0);

React.useEffect(() => {
if (!containerRef.current) return;

function measureWidth() {
if (containerRef.current) {
const width = containerRef.current.clientWidth - 16;
setContainerWidth(width);
}
}

measureWidth();

const resizeObserver = new ResizeObserver(measureWidth);
resizeObserver.observe(containerRef.current);

return () => {
resizeObserver.disconnect();
};
}, [containerRef]);

const result = React.useMemo(() => {
if (!containerWidth || items.length === 0) {
return { visibleItems: items, hiddenCount: 0, containerWidth };
}

const gapWidth = 4;
const plusBadgeWidth = 40;
let currentLineWidth = 0;
let currentLine = 1;
const visible: T[] = [];

for (const item of items) {
const label = getLabel(item);
const cacheKey = cacheKeyPrefix ? `${cacheKeyPrefix}:${label}` : label;
const badgeWidth = measureBadgeWidth(label, cacheKey, measureOpts);
const widthWithGap = badgeWidth + gapWidth;

if (currentLineWidth + widthWithGap <= containerWidth) {
currentLineWidth += widthWithGap;
visible.push(item);
} else if (currentLine < lineCount) {
currentLine++;
currentLineWidth = widthWithGap;
visible.push(item);
} else {
if (
currentLineWidth + plusBadgeWidth > containerWidth &&
visible.length > 0
) {
visible.pop();
}

break;
}
}

return {
visibleItems: visible,
hiddenCount: Math.max(0, items.length - visible.length),
containerWidth,
};
}, [items, getLabel, containerWidth, lineCount, cacheKeyPrefix, measureOpts]);

return result;
}

export function clearBadgeWidthCache(): void {
badgeWidthCache.clear();
}
Loading