import type { VirtualItem } from '@tanstack/react-virtual' import { getColumnOffset, isInViewHorizontal, isInViewVertical, scrollToRowIndex, } from './scroll' describe('scroll', () => { describe('getColumnOffset', () => { it('should return start and end for first index', () => { const result = getColumnOffset(0, [100, 200, 300]) expect(result).toEqual([0, 100]) }) it('should return start and end for last index', () => { const result = getColumnOffset(2, [100, 200, 300]) expect(result).toEqual([300, 600]) }) }) describe('isInViewHorizontal', () => { it('should return false if element or scroller is undefined', () => { expect(isInViewHorizontal(undefined, null, 0, 0)).toBe(false) }) it('should return false if element is not in view (off to the right)', () => { const scroller = document.createElement('div') scroller.scrollLeft = 0 Object.defineProperty(scroller, 'clientWidth', { value: 500 }) const result = isInViewHorizontal([400, 450], scroller, 200, 200) expect(result).toBe(false) }) it('should return false if element is not in view (off to the left)', () => { const scroller = document.createElement('div') scroller.scrollLeft = 200 Object.defineProperty(scroller, 'clientWidth', { value: 500 }) const result = isInViewHorizontal([200, 300], scroller, 200, 100) expect(result).toBe(false) }) it('should return true if element is in view', () => { const scroller = document.createElement('div') scroller.scrollLeft = 50 Object.defineProperty(scroller, 'clientWidth', { value: 200 }) const result = isInViewHorizontal([80, 150], scroller, 20, 30) expect(result).toBe(true) }) }) describe('isInViewVertical', () => { it('should return false if element or scroller is undefined', () => { expect(isInViewVertical(undefined, null, 0, 0)).toBe(false) }) it('should return false if element is not in view', () => { const item: VirtualItem = { start: 100, end: 300, key: '1', size: 50, lane: 0, index: 0, } const scroller = document.createElement('div') scroller.scrollTop = 50 Object.defineProperty(scroller, 'clientHeight', { value: 200 }) const result = isInViewVertical(item, scroller, 36, 20) expect(result).toBe(false) }) it('should return true if element is in view', () => { const item: VirtualItem = { start: 100, end: 150, key: '1', size: 50, lane: 0, index: 0, } const scroller = document.createElement('div') scroller.scrollTop = 50 Object.defineProperty(scroller, 'clientHeight', { value: 200 }) const result = isInViewVertical(item, scroller, 36, 20) expect(result).toBe(true) }) it('should return true if element is in view at very top', () => { const item: VirtualItem = { start: 0, end: 75, key: '1', size: 50, lane: 0, index: 0, } const scroller = document.createElement('div') scroller.scrollTop = 0 Object.defineProperty(scroller, 'clientHeight', { value: 200 }) const result = isInViewVertical(item, scroller, 36, 20) expect(result).toBe(true) }) it('should return true if element is in view at very bottom (with taller headers)', () => { const item: VirtualItem = { start: 26, end: 72, key: '1', size: 46, lane: 0, index: 0, } const scroller = document.createElement('div') scroller.scrollTop = 0 Object.defineProperty(scroller, 'clientHeight', { value: 200 }) const result = isInViewVertical(item, scroller, 108, 20) expect(result).toBe(true) }) it('should return false if element is just out of view at very bottom (with taller headers)', () => { const item: VirtualItem = { start: 28, end: 74, key: '1', size: 46, lane: 0, index: 0, } const scroller = document.createElement('div') scroller.scrollTop = 0 Object.defineProperty(scroller, 'clientHeight', { value: 200 }) const result = isInViewVertical(item, scroller, 108, 20) expect(result).toBe(false) }) }) describe('scrollToRowIndex', () => { let scroller: HTMLDivElement beforeEach(() => { scroller = document.createElement('div') scroller.scrollTop = 0 Object.defineProperty(scroller, 'clientHeight', { value: 200 }) scroller.scrollTo = jest.fn( (options?: ScrollToOptions | number, y?: number) => { if ( typeof options === 'object' && options?.top !== undefined ) { scroller.scrollTop = options.top } else if (typeof options === 'number' && y !== undefined) { scroller.scrollTop = y } } ) }) it('should scroll to the correct position', async () => { // Scroll to row 5 (height 50) in a grid with total size 1000 scrollToRowIndex(5, 50, 1000, scroller, 36, 20) // Wait for potential RAF retries to complete await new Promise((resolve) => setTimeout(resolve, 50)) // Expected: rowIndex * rowHeight = 5 * 50 = 250 expect(scroller.scrollTo).toHaveBeenCalledWith({ top: 250 }) expect(scroller.scrollTop).toBe(250) }) it('should not scroll beyond bottom boundary', async () => { // Try to scroll to row 5 (height 50) in a small grid (total size 250) scrollToRowIndex(5, 50, 250, scroller, 36, 20) // Wait for potential RAF retries to complete await new Promise((resolve) => setTimeout(resolve, 50)) // Should be capped at: totalSize - containerHeight = 250 - (200-36-20) = 250 - 144 = 106 expect(scroller.scrollTo).toHaveBeenCalledWith({ top: 106 }) expect(scroller.scrollTop).toBe(106) }) it('should scroll to bottom when footer is present', async () => { // Scroll to last row in a grid with total size 1000 scrollToRowIndex(19, 50, 1000, scroller, 36, 50) // Wait for potential RAF retries to complete await new Promise((resolve) => setTimeout(resolve, 50)) // Should be capped at: totalSize - containerHeight = 1000 - (200-36-50) = 1000 - 114 = 886 expect(scroller.scrollTo).toHaveBeenCalledWith({ top: 886 }) expect(scroller.scrollTop).toBe(886) }) it('should return early for invalid container', () => { scrollToRowIndex(5, 50, 1000, null, 36, 20) // Should not throw or do anything expect(true).toBe(true) }) it('should return early for invalid parameters', () => { scrollToRowIndex(-1, 50, 1000, scroller, 36, 20) expect(scroller.scrollTo).not.toHaveBeenCalled() scrollToRowIndex(5, 0, 1000, scroller, 36, 20) expect(scroller.scrollTo).not.toHaveBeenCalled() scrollToRowIndex(5, 50, -1, scroller, 36, 20) expect(scroller.scrollTo).not.toHaveBeenCalled() }) it('should return early for invalid container height', () => { // Create a new container with height too small after header/footer const smallScroller = document.createElement('div') smallScroller.scrollTop = 0 Object.defineProperty(smallScroller, 'clientHeight', { value: 50 }) smallScroller.scrollTo = jest.fn() scrollToRowIndex(5, 50, 1000, smallScroller, 30, 30) expect(smallScroller.scrollTo).not.toHaveBeenCalled() }) it('should retry scrolling if not at desired position', async () => { // Mock scrollTo to not set scrollTop on first two calls let callCount = 0 scroller.scrollTo = jest.fn( (options?: ScrollToOptions | number, y?: number) => { callCount++ if (callCount > 2) { if ( typeof options === 'object' && options?.top !== undefined ) { scroller.scrollTop = options.top } else if ( typeof options === 'number' && y !== undefined ) { scroller.scrollTop = y } } } ) // Scroll to row 4 (height 50) in a grid with total size 1000 scrollToRowIndex(4, 50, 1000, scroller, 36, 20) // Wait for potential RAF retries to complete await new Promise((resolve) => setTimeout(resolve, 100)) // Expected: rowIndex * rowHeight = 4 * 50 = 200 expect(scroller.scrollTo).toHaveBeenCalledWith({ top: 200 }) expect(scroller.scrollTop).toBe(200) expect(scroller.scrollTo).toHaveBeenCalledTimes(3) // Initial + 2 retries }) }) })