/ utils / horizontalScroll.ts
horizontalScroll.ts
  1  export type HorizontalScrollWindow = {
  2    startIndex: number
  3    endIndex: number
  4    showLeftArrow: boolean
  5    showRightArrow: boolean
  6  }
  7  
  8  /**
  9   * Calculate the visible window of items that fit within available width,
 10   * ensuring the selected item is always visible. Uses edge-based scrolling:
 11   * the window only scrolls when the selected item would be outside the visible
 12   * range, and positions the selected item at the edge (not centered).
 13   *
 14   * @param itemWidths - Array of item widths (each width should include separator if applicable)
 15   * @param availableWidth - Total available width for items
 16   * @param arrowWidth - Width of scroll indicator arrow (including space)
 17   * @param selectedIdx - Index of selected item (must stay visible)
 18   * @param firstItemHasSeparator - Whether first item's width includes a separator that should be ignored
 19   * @returns Visible window bounds and whether to show scroll arrows
 20   */
 21  export function calculateHorizontalScrollWindow(
 22    itemWidths: number[],
 23    availableWidth: number,
 24    arrowWidth: number,
 25    selectedIdx: number,
 26    firstItemHasSeparator = true,
 27  ): HorizontalScrollWindow {
 28    const totalItems = itemWidths.length
 29  
 30    if (totalItems === 0) {
 31      return {
 32        startIndex: 0,
 33        endIndex: 0,
 34        showLeftArrow: false,
 35        showRightArrow: false,
 36      }
 37    }
 38  
 39    // Clamp selectedIdx to valid range
 40    const clampedSelected = Math.max(0, Math.min(selectedIdx, totalItems - 1))
 41  
 42    // If all items fit, show them all
 43    const totalWidth = itemWidths.reduce((sum, w) => sum + w, 0)
 44    if (totalWidth <= availableWidth) {
 45      return {
 46        startIndex: 0,
 47        endIndex: totalItems,
 48        showLeftArrow: false,
 49        showRightArrow: false,
 50      }
 51    }
 52  
 53    // Calculate cumulative widths for efficient range calculations
 54    const cumulativeWidths: number[] = [0]
 55    for (let i = 0; i < totalItems; i++) {
 56      cumulativeWidths.push(cumulativeWidths[i]! + itemWidths[i]!)
 57    }
 58  
 59    // Helper to get width of range [start, end)
 60    function rangeWidth(start: number, end: number): number {
 61      const baseWidth = cumulativeWidths[end]! - cumulativeWidths[start]!
 62      // When starting after index 0 and first item has separator baked in,
 63      // subtract 1 because we don't render leading separator on first visible item
 64      if (firstItemHasSeparator && start > 0) {
 65        return baseWidth - 1
 66      }
 67      return baseWidth
 68    }
 69  
 70    // Calculate effective available width based on whether we'll show arrows
 71    function getEffectiveWidth(start: number, end: number): number {
 72      let width = availableWidth
 73      if (start > 0) width -= arrowWidth // left arrow
 74      if (end < totalItems) width -= arrowWidth // right arrow
 75      return width
 76    }
 77  
 78    // Edge-based scrolling: Start from the beginning and only scroll when necessary
 79    // First, calculate how many items fit starting from index 0
 80    let startIndex = 0
 81    let endIndex = 1
 82  
 83    // Expand from start as much as possible
 84    while (
 85      endIndex < totalItems &&
 86      rangeWidth(startIndex, endIndex + 1) <=
 87        getEffectiveWidth(startIndex, endIndex + 1)
 88    ) {
 89      endIndex++
 90    }
 91  
 92    // If selected is within visible range, we're done
 93    if (clampedSelected >= startIndex && clampedSelected < endIndex) {
 94      return {
 95        startIndex,
 96        endIndex,
 97        showLeftArrow: startIndex > 0,
 98        showRightArrow: endIndex < totalItems,
 99      }
100    }
101  
102    // Selected is outside visible range - need to scroll
103    if (clampedSelected >= endIndex) {
104      // Selected is to the right - scroll so selected is at the right edge
105      endIndex = clampedSelected + 1
106      startIndex = clampedSelected
107  
108      // Expand left as much as possible (selected stays at right edge)
109      while (
110        startIndex > 0 &&
111        rangeWidth(startIndex - 1, endIndex) <=
112          getEffectiveWidth(startIndex - 1, endIndex)
113      ) {
114        startIndex--
115      }
116    } else {
117      // Selected is to the left - scroll so selected is at the left edge
118      startIndex = clampedSelected
119      endIndex = clampedSelected + 1
120  
121      // Expand right as much as possible (selected stays at left edge)
122      while (
123        endIndex < totalItems &&
124        rangeWidth(startIndex, endIndex + 1) <=
125          getEffectiveWidth(startIndex, endIndex + 1)
126      ) {
127        endIndex++
128      }
129    }
130  
131    return {
132      startIndex,
133      endIndex,
134      showLeftArrow: startIndex > 0,
135      showRightArrow: endIndex < totalItems,
136    }
137  }