{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "badge-overflow", "type": "registry:ui", "title": "Badge Overflow", "description": "Responsive badge list that measures available space, wraps to a configurable line count, and replaces hidden items with an overflow count.", "dependencies": [ "@base-ui/react", "tw-animate-css" ], "registryDependencies": [ "https://zeron-ui.vercel.app/r/surfaces.json", "https://zeron-ui.vercel.app/r/utils.json", "https://zeron-ui.vercel.app/r/popover.json", "https://zeron-ui.vercel.app/r/select.json" ], "files": [ { "path": "packages/ui/src/components/badge-overflow.tsx", "content": "\"use client\";\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport * as React from \"react\";\nimport { useComposedRefs } from \"@lib/compose-refs\";\nimport { cn } from \"@lib/utils\";\nimport { Popover, PopoverContent, PopoverTrigger, } from \"@ui/popover\";\nimport { selectItemClassName } from \"@ui/select\";\ninterface GetBadgeLabel {\n /** Returns the text label used for rendering, measuring, and default keys. */\n getBadgeLabel: (item: T) => string;\n}\ntype BadgeOverflowElement = HTMLDivElement;\ntype BadgeOverflowProps = Omit, \"children\"> & (T extends object ? GetBadgeLabel : Partial>) & {\n /** Ordered items to fit into the available lines. */\n items: T[];\n /** Maximum number of visible badge rows. */\n lineCount?: number;\n /** Returns a stable React key. Labels are used by default. */\n getBadgeKey?: (item: T, label: string) => React.Key;\n /** Renders each visible badge. */\n renderBadge: (item: T, label: string) => React.ReactNode;\n /** Renders the final hidden-count badge. */\n renderOverflow?: (count: number) => React.ReactNode;\n};\nfunction BadgeOverflow(props: BadgeOverflowProps) {\n const { items, getBadgeLabel: getBadgeLabelProp, getBadgeKey: getBadgeKeyProp, lineCount: lineCountProp = 1, render, renderBadge, renderOverflow, className, style, ref, ...rootProps } = props;\n const lineCount = Math.max(1, Math.floor(lineCountProp));\n const getBadgeLabel = React.useCallback((item: T): string => {\n if (typeof item === \"object\" && item !== null && !getBadgeLabelProp) {\n throw new Error(\"`getBadgeLabel` is required when using an array of objects\");\n }\n return getBadgeLabelProp ? getBadgeLabelProp(item) : String(item);\n }, [getBadgeLabelProp]);\n const getBadgeKey = React.useCallback((item: T): React.Key => {\n const label = getBadgeLabel(item);\n return getBadgeKeyProp ? getBadgeKeyProp(item, label) : label;\n }, [getBadgeKeyProp, getBadgeLabel]);\n const rootRef = React.useRef(null);\n const measureRef = React.useRef(null);\n const composedRef = useComposedRefs(ref, rootRef);\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>(new Map());\n React.useLayoutEffect(() => {\n if (!rootRef.current || !measureRef.current)\n return;\n function measureContainer() {\n if (!rootRef.current || !measureRef.current)\n return;\n const computedStyle = getComputedStyle(rootRef.current);\n const gap = parseFloat(computedStyle.gap) || 4;\n const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0;\n const paddingRight = parseFloat(computedStyle.paddingRight) || 0;\n const widthMap = new Map();\n const measureChildren = measureRef.current.children;\n for (let index = 0; index < items.length; index++) {\n const child = measureChildren[index] as HTMLElement | undefined;\n if (child)\n widthMap.set(index, child.offsetWidth);\n }\n const firstBadge = measureChildren[0] as HTMLElement | undefined;\n const overflowBadge = measureChildren[items.length] as HTMLElement | undefined;\n setBadgeGap(gap);\n setBadgeWidths(widthMap);\n setBadgeHeight(firstBadge?.offsetHeight || 20);\n setOverflowBadgeWidth(overflowBadge?.offsetWidth || 40);\n setContainerWidth(rootRef.current.clientWidth - paddingLeft - paddingRight);\n setIsMeasured(true);\n }\n measureContainer();\n const resizeObserver = new ResizeObserver(measureContainer);\n resizeObserver.observe(rootRef.current);\n return () => resizeObserver.disconnect();\n }, [items, getBadgeLabel, renderBadge, renderOverflow]);\n const placeholderHeight = React.useMemo(() => badgeHeight * lineCount + badgeGap * (lineCount - 1), [badgeHeight, badgeGap, lineCount]);\n const { visibleItems, hiddenCount } = React.useMemo(() => {\n if (!containerWidth || items.length === 0 || badgeWidths.size === 0) {\n return { visibleItems: items, hiddenCount: 0 };\n }\n let currentLineWidth = 0;\n let currentLine = 1;\n const visible: T[] = [];\n for (let index = 0; index < items.length; index++) {\n const item = items[index];\n const badgeWidth = badgeWidths.get(index);\n if (item === undefined || !badgeWidth)\n continue;\n const widthWithGap = badgeWidth + badgeGap;\n const isLastLine = currentLine === lineCount;\n const hasMoreItems = index < items.length - 1;\n const availableWidth = isLastLine && hasMoreItems\n ? containerWidth - overflowBadgeWidth - badgeGap\n : containerWidth;\n if (currentLineWidth + widthWithGap <= availableWidth) {\n currentLineWidth += widthWithGap;\n visible.push(item);\n }\n else if (currentLine < lineCount) {\n currentLine++;\n currentLineWidth = widthWithGap;\n visible.push(item);\n }\n else {\n break;\n }\n }\n return {\n visibleItems: visible,\n hiddenCount: Math.max(0, items.length - visible.length),\n };\n }, [\n items,\n containerWidth,\n lineCount,\n badgeGap,\n overflowBadgeWidth,\n badgeWidths,\n ]);\n const fallbackItems = React.useMemo(() => items.slice(0, Math.min(items.length, lineCount * 3 - (lineCount > 1 ? 1 : 0))), [items, lineCount]);\n const overflow = (count: number) => renderOverflow ? (renderOverflow(count)) : (
\n +{count}\n
);\n const overflowTrigger = (count: number) => (\n \n {overflow(count)}\n }/>\n \n
\n {items.map((item) => (
\n {renderBadge(item, getBadgeLabel(item))}\n
))}\n
\n
\n
);\n const children = isMeasured ? (<>\n {visibleItems.map((item) => (\n {renderBadge(item, getBadgeLabel(item))}\n ))}\n {hiddenCount > 0 && overflowTrigger(hiddenCount)}\n ) : (fallbackItems.map((item) => (\n {renderBadge(item, getBadgeLabel(item))}\n )));\n const element = useRender({\n defaultTagName: \"div\",\n render,\n props: mergeProps<\"div\">({\n ref: composedRef,\n className: cn(\"flex flex-wrap\", className),\n style: {\n gap: badgeGap,\n minHeight: isMeasured ? undefined : placeholderHeight,\n ...style,\n },\n children,\n }, rootProps),\n state: { slot: \"badge-overflow\" },\n });\n return (<>\n
\n {items.map((item) => (\n {renderBadge(item, getBadgeLabel(item))}\n ))}\n {overflow(Math.max(99, items.length))}\n
\n {element}\n );\n}\nexport { BadgeOverflow };\nexport type { BadgeOverflowProps };\n", "type": "registry:ui", "target": "components/ui/badge-overflow.tsx" }, { "path": "packages/ui/src/system/compose-refs.ts", "content": "/**\n * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx\n */\nimport * as React from \"react\";\ntype PossibleRef = React.Ref | undefined;\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 if (ref !== null && ref !== undefined) {\n ref.current = value;\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 // 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 }\n else {\n setRef(refs[i], null);\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}\nexport { composeRefs, useComposedRefs };\n", "type": "registry:lib", "target": "lib/compose-refs.ts" } ] }