// Copyright (c) Meta Platforms, Inc. and affiliates. /** * @file computeOverflow.ts * @input Plain measurements (item widths, gap, available width, indicator width, limits) * @output Pure overflow computation: how many items are visible and their row layout * @position Pure helper consumed by useOverflow; contains no DOM or React code so the * fit/clamp math and the multi-row packing can be unit-tested in isolation. * * There is a single source of truth for `visibleCount`: * visibleCount = clamp(fitCount, minVisibleItems, maxVisibleItems ?? itemCount) * * SYNC: When modified, update: * - /packages/core/src/hooks/useOverflow.ts (the only caller) */ export interface ComputeOverflowInput { /** Measured widths of each item, in original DOM order. */ widths: number[]; /** Gap between items, in pixels. */ gap: number; /** Width available to lay items out, in pixels. */ availableWidth: number; /** Measured width of the overflow indicator (0 if none). */ indicatorWidth: number; /** Floor — always show at least this many items. */ minVisibleItems: number; /** Ceiling — never show more than this many items. `undefined` = no cap. */ maxVisibleItems?: number; /** * Bounded multi-row: wrap items across up to this many rows, then collapse * the rest into the overflow indicator. `undefined` (or `1`) = single line. */ maxRows?: number; /** Which end items collapse from. */ collapseFrom: 'start' | 'end'; } export interface ComputeOverflowResult { /** Number of items that should be rendered in the visible container. */ visibleCount: number; /** Number of rows the visible items occupy (always 1 for the single-line path). */ rows: number; } function clamp(value: number, min: number, max: number): number { return Math.max(Math.min(value, max), min); } /** * Resolve the ceiling and floor into a single [floor, ceiling] pair. * * `maxVisibleItems` is clamped to `itemCount` (a cap larger than the list is a * no-op). When `maxVisibleItems < minVisibleItems`, the floor wins (D1) via the * final clamp — the hook is responsible for the dev-only warning. */ function resolveBounds(itemCount: number, minVisibleItems: number, maxVisibleItems: number | undefined): { floor: number; ceiling: number; } { const floor = Math.max(0, Math.min(minVisibleItems, itemCount)); const rawCeiling = maxVisibleItems == null ? itemCount : maxVisibleItems; const ceiling = Math.max(0, Math.min(rawCeiling, itemCount)); return { floor, ceiling }; } /** * Single-line greedy fit: how many items fit in one row of `availableWidth`, * reserving indicator space for every non-final admitted item. Mirrors the * original in-hook loop so single-line behavior is unchanged. */ function computeSingleLineFit(orderedWidths: number[], gap: number, availableWidth: number, indicatorWidth: number, floor: number, ceiling: number): number { let totalWidth = 0; let count = 0; for (let i = 0; i < orderedWidths.length; i++) { if (count >= ceiling) { break; } const itemWidth = orderedWidths[i]; const gapWidth = i > 0 ? gap : 0; const candidateWidth = totalWidth + itemWidth + gapWidth; const isLastItem = i === orderedWidths.length - 1; const reservedWidth = isLastItem ? 0 : indicatorWidth + (count > 0 || indicatorWidth > 0 ? gap : 0); if (candidateWidth + reservedWidth > availableWidth && count >= floor) { break; } totalWidth = candidateWidth; count++; } return count; } /** * Pack items into rows, wrapping when the next item would exceed * `availableWidth`. On the last allowed row, keep `indicatorReserve` px free so * the overflow indicator fits. Stops admitting once no further item can be * placed within `maxRows`. */ function packRows(orderedWidths: number[], gap: number, availableWidth: number, indicatorReserve: number, maxRows: number): { placed: number; rows: number; } { let placed = 0; let row = 1; let rowWidth = 0; for (let i = 0; i < orderedWidths.length; i++) { const w = orderedWidths[i]; const isFirstInRow = rowWidth === 0; const candidate = isFirstInRow ? w : rowWidth + gap + w; const onLastRow = row === maxRows; const reserve = onLastRow && indicatorReserve > 0 ? indicatorReserve + gap : 0; if (candidate + reserve <= availableWidth) { rowWidth = candidate; placed++; continue; } if (isFirstInRow) { // A single item wider than the row occupies this row alone (it will be // clipped visually). But if it can't coexist with the reserved indicator // on the last row, stop here. if (onLastRow && reserve > 0) { break; } rowWidth = candidate; placed++; continue; } if (row >= maxRows) { break; } row++; rowWidth = 0; i--; // re-attempt this item as the first on the new row } return { placed, rows: row }; } /** Count how many rows a set of items occupies when wrapped at availableWidth. */ function countRows(orderedWidths: number[], gap: number, availableWidth: number): number { if (orderedWidths.length === 0) { return 0; } let rows = 1; let rowWidth = 0; for (let i = 0; i < orderedWidths.length; i++) { const w = orderedWidths[i]; const isFirstInRow = rowWidth === 0; const candidate = isFirstInRow ? w : rowWidth + gap + w; if (candidate <= availableWidth || isFirstInRow) { rowWidth = candidate; } else { rows++; rowWidth = w; } } return rows; } /** * Multi-row packing (Strategy A): simulate wrapping items into rows of * `availableWidth`, admitting items until a new item would require a row beyond * `maxRows`, reserving indicator space on the final row when items overflow. * Returns the admitted count and how many rows those items occupy. Assumes * uniform row height. */ function computeMultiRowFit(orderedWidths: number[], gap: number, availableWidth: number, indicatorWidth: number, maxRows: number): { count: number; rows: number; } { const n = orderedWidths.length; if (n === 0) { return { count: 0, rows: 0 }; } // If everything fits within maxRows without reserving indicator space, there // is no overflow and no indicator is needed. const packAll = packRows(orderedWidths, gap, availableWidth, 0, maxRows); if (packAll.placed === n) { return { count: n, rows: packAll.rows }; } // Overflow → reserve indicator space on the final row and re-pack. const packWithIndicator = packRows(orderedWidths, gap, availableWidth, indicatorWidth, maxRows); const count = packWithIndicator.placed; const rows = countRows(orderedWidths.slice(0, count), gap, availableWidth); return { count, rows: Math.max(count > 0 ? 1 : 0, rows) }; } /** * Pure overflow computation. Given plain measurements and the configured * limits, return how many items should be visible and how many rows they * occupy. No DOM, no React — safe to unit-test directly. */ export function computeOverflow(input: ComputeOverflowInput): ComputeOverflowResult { const { widths, gap, availableWidth, indicatorWidth, minVisibleItems, maxVisibleItems, maxRows, collapseFrom } = input; const itemCount = widths.length; if (itemCount === 0) { return { visibleCount: 0, rows: 0 }; } const { floor, ceiling } = resolveBounds(itemCount, minVisibleItems, maxVisibleItems); const orderedWidths = collapseFrom === 'end' ? widths : [...widths].reverse(); const multiRow = maxRows != null && maxRows > 1; if (!multiRow) { const fitCount = computeSingleLineFit(orderedWidths, gap, availableWidth, indicatorWidth, floor, ceiling); const visibleCount = clamp(fitCount, floor, ceiling); return { visibleCount, rows: visibleCount > 0 ? 1 : 0 }; } const { count, rows } = computeMultiRowFit(orderedWidths, gap, availableWidth, indicatorWidth, maxRows); const visibleCount = clamp(count, floor, ceiling); const resolvedRows = visibleCount === count ? rows : countRows(orderedWidths.slice(0, visibleCount), gap, availableWidth); return { visibleCount, rows: visibleCount > 0 ? Math.max(1, resolvedRows) : 0 }; }