"use client"; import { useCallback, useLayoutEffect, useRef, useState, type ReactNode, type Ref } from "react"; import { IconDots } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { cn } from "@kandev/ui/lib/utils"; export type TopbarOverflowItem = { id: string; label: string; priority: number; required?: boolean; content: ReactNode; }; type TopbarOverflowMetricItem = Pick; type HiddenTopbarActionArgs = { items: TopbarOverflowMetricItem[]; availableWidth: number; itemWidths: ReadonlyMap; gap: number; overflowTriggerWidth: number; fallbackItemWidth?: number; }; const DEFAULT_FALLBACK_ITEM_WIDTH = 88; const DEFAULT_OVERFLOW_TRIGGER_WIDTH = 40; function setsMatch(set: Set, values: string[]) { if (set.size !== values.length) return false; return values.every((value) => set.has(value)); } function itemWidth( item: TopbarOverflowMetricItem, widths: ReadonlyMap, fallback: number, ) { return widths.get(item.id) ?? fallback; } function totalActionWidth({ items, hiddenIds, itemWidths, gap, overflowTriggerWidth, fallbackItemWidth, }: { items: TopbarOverflowMetricItem[]; hiddenIds: Set; itemWidths: ReadonlyMap; gap: number; overflowTriggerWidth: number; fallbackItemWidth: number; }) { const visibleItems = items.filter((item) => !hiddenIds.has(item.id)); const visibleWidth = visibleItems.reduce( (total, item) => total + itemWidth(item, itemWidths, fallbackItemWidth), 0, ); const controlCount = visibleItems.length + (hiddenIds.size > 0 ? 1 : 0); const gapWidth = Math.max(0, controlCount - 1) * gap; return visibleWidth + (hiddenIds.size > 0 ? overflowTriggerWidth : 0) + gapWidth; } export function getHiddenTopbarActionIds({ items, availableWidth, itemWidths, gap, overflowTriggerWidth, fallbackItemWidth = DEFAULT_FALLBACK_ITEM_WIDTH, }: HiddenTopbarActionArgs): string[] { const hiddenIds = new Set(); const hideableItems = [...items] .filter((item) => !item.required) .sort((a, b) => a.priority - b.priority); while ( totalActionWidth({ items, hiddenIds, itemWidths, gap, overflowTriggerWidth, fallbackItemWidth, }) > availableWidth ) { const nextItem = hideableItems.find((item) => !hiddenIds.has(item.id)); if (!nextItem) break; hiddenIds.add(nextItem.id); } return items.filter((item) => hiddenIds.has(item.id)).map((item) => item.id); } function readFlexGap(element: HTMLElement) { const styles = window.getComputedStyle(element); const gap = Number.parseFloat(styles.columnGap || styles.gap || "0"); return Number.isFinite(gap) ? gap : 0; } type TopbarActionOverflowProps = { items: TopbarOverflowItem[]; className?: string; }; function measureVisibleItems( itemRefs: Map, itemWidths: Map, ) { for (const [id, element] of itemRefs) { const width = Math.ceil(element.getBoundingClientRect().width); if (width > 0) itemWidths.set(id, width); } } function useTopbarOverflowState(items: TopbarOverflowItem[]) { const containerRef = useRef(null); const overflowTriggerRef = useRef(null); const itemRefs = useRef(new Map()); const itemWidths = useRef(new Map()); const [hiddenIds, setHiddenIds] = useState>(() => new Set()); const [measuredWidth, setMeasuredWidth] = useState(0); const registerItem = useCallback( (id: string) => (element: HTMLDivElement | null) => { if (element) { itemRefs.current.set(id, element); } else { itemRefs.current.delete(id); } }, [], ); useLayoutEffect(() => { const element = containerRef.current; if (!element) return; const updateWidth = () => setMeasuredWidth(element.clientWidth); updateWidth(); const observer = new ResizeObserver(updateWidth); observer.observe(element); return () => observer.disconnect(); }, []); useLayoutEffect(() => { const container = containerRef.current; if (!container || container.clientWidth <= 0) return; measureVisibleItems(itemRefs.current, itemWidths.current); const overflowWidth = overflowTriggerRef.current?.getBoundingClientRect().width || DEFAULT_OVERFLOW_TRIGGER_WIDTH; const nextHiddenIds = getHiddenTopbarActionIds({ items, availableWidth: container.clientWidth, itemWidths: itemWidths.current, gap: readFlexGap(container), overflowTriggerWidth: overflowWidth, }); setHiddenIds((current) => setsMatch(current, nextHiddenIds) ? current : new Set(nextHiddenIds), ); }, [items, measuredWidth]); return { containerRef, overflowTriggerRef, registerItem, hiddenIds }; } function OverflowTrigger({ triggerRef }: { triggerRef: Ref }) { return ( More actions ); } function OverflowPopover({ items, triggerRef, }: { items: TopbarOverflowItem[]; triggerRef: Ref; }) { if (items.length === 0) return null; return (
{items.map((item) => (
{item.content}
))}
); } export function TopbarActionOverflow({ items, className }: TopbarActionOverflowProps) { const { containerRef, overflowTriggerRef, registerItem, hiddenIds } = useTopbarOverflowState(items); const hiddenItems = items.filter((item) => hiddenIds.has(item.id)); return (
{items.map((item) => hiddenIds.has(item.id) ? null : (
{item.content}
), )}
); }