"use client" import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cn } from "../../../lib/utils" interface GetBadgeLabel { /** * Callback that returns a label string for each badge item. * Optional for primitive arrays (strings, numbers), required for object arrays. * @example getBadgeLabel={(item) => item.name} */ getBadgeLabel: (item: T) => string } type BadgeOverflowElement = HTMLDivElement type BadgeOverflowProps = React.ComponentProps<"div"> & (T extends object ? GetBadgeLabel : Partial>) & { items: T[] lineCount?: number renderBadge: (item: T, label: string) => React.ReactNode renderOverflow?: (count: number) => React.ReactNode asChild?: boolean } function BadgeOverflow(props: BadgeOverflowProps) { const { items, getBadgeLabel: getBadgeLabelProp, lineCount = 1, renderBadge, renderOverflow, asChild, className, style, ref, ...rootProps } = props const getBadgeLabel = React.useCallback( (item: T): string => { if (typeof item === "object" && !getBadgeLabelProp) { throw new Error( "`getBadgeLabel` is required when using array of objects", ) } return getBadgeLabelProp ? getBadgeLabelProp(item) : (item as string) }, [getBadgeLabelProp], ) const rootRef = React.useRef(null) const measureRef = React.useRef(null) const [containerWidth, setContainerWidth] = React.useState(0) const [badgeGap, setBadgeGap] = React.useState(4) const [badgeHeight, setBadgeHeight] = React.useState(20) const [overflowBadgeWidth, setOverflowBadgeWidth] = React.useState(40) const [isMeasured, setIsMeasured] = React.useState(false) const [badgeWidths, setBadgeWidths] = React.useState>( new Map(), ) React.useLayoutEffect(() => { if (!rootRef.current || !measureRef.current) return function measureContainer() { if (!rootRef.current || !measureRef.current) return const computedStyle = getComputedStyle(rootRef.current) const gapValue = computedStyle.gap const gap = gapValue ? parseFloat(gapValue) : 4 setBadgeGap(gap) const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0 const paddingRight = parseFloat(computedStyle.paddingRight) || 0 const totalPadding = paddingLeft + paddingRight const widthMap = new Map() const measureChildren = measureRef.current.children for (let i = 0; i < items.length; i++) { const child = measureChildren[i] as HTMLElement | undefined if (child) { const label = getBadgeLabel(items[i] as T) widthMap.set(label, child.offsetWidth) } } setBadgeWidths(widthMap) const firstBadge = measureChildren[0] as HTMLElement | undefined if (firstBadge) { setBadgeHeight(firstBadge.offsetHeight || 20) } const overflowChild = measureChildren[items.length] as | HTMLElement | undefined if (overflowChild) { setOverflowBadgeWidth(overflowChild.offsetWidth || 40) } const width = rootRef.current.clientWidth - totalPadding setContainerWidth(width) setIsMeasured(true) } measureContainer() const resizeObserver = new ResizeObserver(measureContainer) resizeObserver.observe(rootRef.current) return () => { resizeObserver.disconnect() } }, [items, getBadgeLabel]) const placeholderHeight = React.useMemo( () => badgeHeight * lineCount + badgeGap * (lineCount - 1), [badgeHeight, badgeGap, lineCount], ) const { visibleItems, hiddenCount } = React.useMemo(() => { if (!containerWidth || items.length === 0 || badgeWidths.size === 0) { return { visibleItems: items, hiddenCount: 0 } } let currentLineWidth = 0 let currentLine = 1 const visible: T[] = [] for (let i = 0; i < items.length; i++) { const item = items[i] if (!item) continue const label = getBadgeLabel(item) const badgeWidth = badgeWidths.get(label) if (!badgeWidth) { // Skip items that haven't been measured yet continue } const widthWithGap = badgeWidth + badgeGap const isLastLine = currentLine === lineCount const hasMoreItems = i < items.length - 1 const availableWidth = isLastLine && hasMoreItems ? containerWidth - overflowBadgeWidth - badgeGap : containerWidth if (currentLineWidth + widthWithGap <= availableWidth) { currentLineWidth += widthWithGap visible.push(item) } else if (currentLine < lineCount) { currentLine++ currentLineWidth = widthWithGap visible.push(item) } else { // We're on the last line and this badge doesn't fit break } } return { visibleItems: visible, hiddenCount: Math.max(0, items.length - visible.length), } }, [ items, getBadgeLabel, containerWidth, lineCount, badgeGap, overflowBadgeWidth, badgeWidths, ]) const Comp = asChild ? Slot : "div" return ( <>
{items.map((item, index) => ( {renderBadge(item, getBadgeLabel(item))} ))} {renderOverflow ? ( renderOverflow(99) ) : (
+99
)}
{isMeasured ? ( {visibleItems.map((item, index) => ( {renderBadge(item, getBadgeLabel(item))} ))} {hiddenCount > 0 && (renderOverflow ? ( renderOverflow(hiddenCount) ) : (
+{hiddenCount}
))}
) : ( {items .slice( 0, Math.min(items.length, lineCount * 3 - (lineCount > 1 ? 1 : 0)), ) .map((item, index) => ( {renderBadge(item, getBadgeLabel(item))} ))} )} ) } export { BadgeOverflow } export type { BadgeOverflowProps }