{ "name": "badge-overflow", "type": "registry:ui", "dependencies": [ "radix-ui" ], "files": [ { "path": "ui/badge-overflow.tsx", "type": "registry:ui", "content": "\"use client\";\n\nimport { Slot as SlotPrimitive } from \"radix-ui\";\nimport * as React from \"react\";\nimport { useComposedRefs } from \"@/lib/compose-refs\";\nimport { cn } from \"@/lib/utils\";\n\ninterface GetBadgeLabel {\n /**\n * Callback that returns a label string for each badge item.\n * Optional for primitive arrays (strings, numbers), required for object arrays.\n * @example getBadgeLabel={(item) => item.name}\n */\n getBadgeLabel: (item: T) => string;\n}\n\ntype BadgeOverflowElement = React.ComponentRef;\n\ntype BadgeOverflowProps = React.ComponentProps<\"div\"> &\n (T extends object ? GetBadgeLabel : Partial>) & {\n items: T[];\n lineCount?: number;\n renderBadge: (item: T, label: string) => React.ReactNode;\n renderOverflow?: (count: number) => React.ReactNode;\n asChild?: boolean;\n };\n\nfunction BadgeOverflow(props: BadgeOverflowProps) {\n const {\n items,\n getBadgeLabel: getBadgeLabelProp,\n lineCount = 1,\n renderBadge,\n renderOverflow,\n asChild,\n className,\n style,\n ref,\n ...rootProps\n } = props;\n\n const getBadgeLabel = React.useCallback(\n (item: T): string => {\n if (typeof item === \"object\" && !getBadgeLabelProp) {\n throw new Error(\n \"`getBadgeLabel` is required when using array of objects\",\n );\n }\n return getBadgeLabelProp ? getBadgeLabelProp(item) : (item as string);\n },\n [getBadgeLabelProp],\n );\n\n const rootRef = React.useRef(null);\n const composedRef = useComposedRefs(ref, rootRef);\n const measureRef = React.useRef(null);\n const [containerWidth, setContainerWidth] = React.useState(0);\n const [badgeGap, setBadgeGap] = React.useState(4);\n const [badgeHeight, setBadgeHeight] = React.useState(20);\n const [overflowBadgeWidth, setOverflowBadgeWidth] = React.useState(40);\n const [isMeasured, setIsMeasured] = React.useState(false);\n const [badgeWidths, setBadgeWidths] = React.useState>(\n new Map(),\n );\n\n React.useLayoutEffect(() => {\n if (!rootRef.current || !measureRef.current) return;\n\n function measureContainer() {\n if (!rootRef.current || !measureRef.current) return;\n\n const computedStyle = getComputedStyle(rootRef.current);\n\n const gapValue = computedStyle.gap;\n const gap = gapValue ? parseFloat(gapValue) : 4;\n setBadgeGap(gap);\n\n const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0;\n const paddingRight = parseFloat(computedStyle.paddingRight) || 0;\n const totalPadding = paddingLeft + paddingRight;\n\n const widthMap = new Map();\n const measureChildren = measureRef.current.children;\n\n for (let i = 0; i < items.length; i++) {\n const child = measureChildren[i] as HTMLElement | undefined;\n if (child) {\n const label = getBadgeLabel(items[i] as T);\n widthMap.set(label, child.offsetWidth);\n }\n }\n setBadgeWidths(widthMap);\n\n const firstBadge = measureChildren[0] as HTMLElement | undefined;\n if (firstBadge) {\n setBadgeHeight(firstBadge.offsetHeight || 20);\n }\n\n const overflowChild = measureChildren[items.length] as\n | HTMLElement\n | undefined;\n\n if (overflowChild) {\n setOverflowBadgeWidth(overflowChild.offsetWidth || 40);\n }\n\n const width = rootRef.current.clientWidth - totalPadding;\n setContainerWidth(width);\n setIsMeasured(true);\n }\n\n measureContainer();\n\n const resizeObserver = new ResizeObserver(measureContainer);\n resizeObserver.observe(rootRef.current);\n\n return () => {\n resizeObserver.disconnect();\n };\n }, [items, getBadgeLabel]);\n\n const placeholderHeight = React.useMemo(\n () => badgeHeight * lineCount + badgeGap * (lineCount - 1),\n [badgeHeight, badgeGap, lineCount],\n );\n\n const { visibleItems, hiddenCount } = React.useMemo(() => {\n if (!containerWidth || items.length === 0 || badgeWidths.size === 0) {\n return { visibleItems: items, hiddenCount: 0 };\n }\n\n let currentLineWidth = 0;\n let currentLine = 1;\n const visible: T[] = [];\n\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (!item) continue;\n\n const label = getBadgeLabel(item);\n const badgeWidth = badgeWidths.get(label);\n\n if (!badgeWidth) {\n // Skip items that haven't been measured yet\n continue;\n }\n\n const widthWithGap = badgeWidth + badgeGap;\n const isLastLine = currentLine === lineCount;\n const hasMoreItems = i < items.length - 1;\n\n const availableWidth =\n isLastLine && hasMoreItems\n ? containerWidth - overflowBadgeWidth - badgeGap\n : containerWidth;\n\n if (currentLineWidth + widthWithGap <= availableWidth) {\n currentLineWidth += widthWithGap;\n visible.push(item);\n } else if (currentLine < lineCount) {\n currentLine++;\n currentLineWidth = widthWithGap;\n visible.push(item);\n } else {\n // We're on the last line and this badge doesn't fit\n break;\n }\n }\n\n return {\n visibleItems: visible,\n hiddenCount: Math.max(0, items.length - visible.length),\n };\n }, [\n items,\n getBadgeLabel,\n containerWidth,\n lineCount,\n badgeGap,\n overflowBadgeWidth,\n badgeWidths,\n ]);\n\n const Comp = asChild ? SlotPrimitive.Slot : \"div\";\n\n return (\n <>\n \n {items.map((item, index) => (\n \n {renderBadge(item, getBadgeLabel(item))}\n \n ))}\n {renderOverflow ? (\n renderOverflow(99)\n ) : (\n
\n +99\n
\n )}\n \n {isMeasured ? (\n \n {visibleItems.map((item, index) => (\n \n {renderBadge(item, getBadgeLabel(item))}\n \n ))}\n {hiddenCount > 0 &&\n (renderOverflow ? (\n renderOverflow(hiddenCount)\n ) : (\n
\n +{hiddenCount}\n
\n ))}\n \n ) : (\n \n {items\n .slice(\n 0,\n Math.min(items.length, lineCount * 3 - (lineCount > 1 ? 1 : 0)),\n )\n .map((item, index) => (\n \n {renderBadge(item, getBadgeLabel(item))}\n \n ))}\n \n )}\n \n );\n}\n\nexport { BadgeOverflow };\n", "target": "" }, { "path": "lib/compose-refs.ts", "type": "registry:lib", "content": "/**\n * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx\n */\n\nimport * as React from \"react\";\n\ntype PossibleRef = React.Ref | undefined;\n\n/**\n * Set a given ref to a given value\n * This utility takes care of different types of refs: callback refs and RefObject(s)\n */\nfunction setRef(ref: PossibleRef, value: T) {\n if (typeof ref === \"function\") {\n return ref(value);\n }\n\n if (ref !== null && ref !== undefined) {\n ref.current = value;\n }\n}\n\n/**\n * A utility to compose multiple refs together\n * Accepts callback refs and RefObject(s)\n */\nfunction composeRefs(...refs: PossibleRef[]): React.RefCallback {\n return (node) => {\n let hasCleanup = false;\n const cleanups = refs.map((ref) => {\n const cleanup = setRef(ref, node);\n if (!hasCleanup && typeof cleanup === \"function\") {\n hasCleanup = true;\n }\n return cleanup;\n });\n\n // React <19 will log an error to the console if a callback ref returns a\n // value. We don't use ref cleanups internally so this will only happen if a\n // user's ref callback returns a value, which we only expect if they are\n // using the cleanup functionality added in React 19.\n if (hasCleanup) {\n return () => {\n for (let i = 0; i < cleanups.length; i++) {\n const cleanup = cleanups[i];\n if (typeof cleanup === \"function\") {\n cleanup();\n } else {\n setRef(refs[i], null);\n }\n }\n };\n }\n };\n}\n\n/**\n * A custom hook that composes multiple refs\n * Accepts callback refs and RefObject(s)\n */\nfunction useComposedRefs(...refs: PossibleRef[]): React.RefCallback {\n // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values\n return React.useCallback(composeRefs(...refs), refs);\n}\n\nexport { composeRefs, useComposedRefs };\n", "target": "" } ] }