export interface ResponsiveGroupInput { expandedWidth: number; collapsedWidth: number; currentlyShrunk: boolean; } export interface ComputeResponsiveTargetStateParams { availableWidth: number; groups: ResponsiveGroupInput[]; expandThresholdBuffer: number; currentlyOverflowing?: boolean; overflowButtonWidth?: number; overflowableGroupIndices?: number[]; } export interface ResponsiveGroupOutput { shouldShrink: boolean; } export interface ComputeResponsiveTargetStateResult { groups: ResponsiveGroupOutput[]; shouldOverflow: boolean; } export function computeResponsiveTargetState( params: ComputeResponsiveTargetStateParams, ): ComputeResponsiveTargetStateResult { const { availableWidth, groups, expandThresholdBuffer, currentlyOverflowing = false, overflowButtonWidth = 0, overflowableGroupIndices = [], } = params; if (groups.length === 0) { return { groups: [], shouldOverflow: false }; } const result: ResponsiveGroupOutput[] = groups.map((g) => ({ shouldShrink: g.currentlyShrunk, })); const widthOf = (group: ResponsiveGroupInput, index: number) => result[index].shouldShrink ? group.collapsedWidth : group.expandedWidth; let total = groups.reduce((sum, g, i) => sum + widthOf(g, i), 0); // Shrink pass for (let i = 0; i < groups.length; i++) { if (total <= availableWidth) { break; } if (!result[i].shouldShrink) { const savings = groups[i].expandedWidth - groups[i].collapsedWidth; result[i].shouldShrink = true; total -= savings; } } // Overflow detection: the "More" button is the step AFTER all groups have // shrunk to their collapsed widths and it still doesn't fit. // Flow: actions shrink → filters shrink → search shrinks → overflow. const allShrunk = result.every((r) => r.shouldShrink); const hasOverflowConfig = overflowableGroupIndices.length > 0 && overflowButtonWidth > 0; let shouldOverflow = false; if (hasOverflowConfig && allShrunk && total > availableWidth) { shouldOverflow = true; } else if (hasOverflowConfig && currentlyOverflowing) { // Hysteresis: stay in overflow until collapsed total + buffer fits, // preventing flicker at the boundary. const collapsedTotal = groups.reduce((sum, g) => sum + g.collapsedWidth, 0); if (collapsedTotal + expandThresholdBuffer > availableWidth) { shouldOverflow = true; } } if (shouldOverflow) { // Replace overflowable groups' widths with the single overflow button width const overflowableTotal = overflowableGroupIndices.reduce( (sum, idx) => sum + groups[idx].collapsedWidth, 0, ); total = total - overflowableTotal + overflowButtonWidth; } // Expand pass — skip entirely while in overflow; everything stays collapsed // until the container is wide enough to exit overflow, then groups expand // in priority order (search last). if (!shouldOverflow) { for (let i = groups.length - 1; i >= 0; i--) { if (!result[i].shouldShrink) { continue; } const expandCost = groups[i].expandedWidth - groups[i].collapsedWidth; if (expandCost === 0) { continue; } if (total + expandCost + expandThresholdBuffer <= availableWidth) { result[i].shouldShrink = false; total += expandCost; } else { // Strict priority: if a higher-priority group (lower index) cannot expand, // no lower-priority groups should expand either to avoid visual inconsistency. break; } } } return { groups: result, shouldOverflow }; }