export interface TabWidth { key: T; width: number; } export interface TabRowInput { tabs: TabWidth[]; /** Width the row may occupy, padding already taken off. */ available: number; gap: number; /** Width of the button that opens the hidden ones; only claimed when something is hidden. */ overflowWidth: number; active: T | null; } export interface TabRowLayout { shown: T[]; hidden: T[]; } function rowWidth(tabs: TabWidth[], gap: number): number { if (tabs.length === 0) return 0; return tabs.reduce((sum, tab) => sum + tab.width, 0) + gap * (tabs.length - 1); } /** * Which tabs fit in the row and which go behind the overflow button. Tabs keep their order; the * active one is always shown, even if the row can hold nothing else. */ export function tabRowLayout(input: TabRowInput): TabRowLayout { const { tabs, available, gap, overflowWidth, active } = input; if (rowWidth(tabs, gap) <= available) return { shown: tabs.map((tab) => tab.key), hidden: [] }; const budget = available - overflowWidth - gap; const shown: TabWidth[] = []; for (const tab of tabs) { if (rowWidth([...shown, tab], gap) > budget) break; shown.push(tab); } if (!shown.some((tab) => tab.key === active)) { const activeTab = tabs.find((tab) => tab.key === active); if (activeTab) { // The prefix stops before the active tab, so appending it keeps the row in order. while (shown.length > 0 && rowWidth([...shown, activeTab], gap) > budget) shown.pop(); shown.push(activeTab); } } const kept = new Set(shown.map((tab) => tab.key)); return { shown: [...kept], hidden: tabs.map((tab) => tab.key).filter((key) => !kept.has(key)) }; }