Skip to content

Commit 3b628c4

Browse files
committed
refactor: move placement logic into hook
1 parent 8e1497b commit 3b628c4

File tree

3 files changed

+115
-31
lines changed

3 files changed

+115
-31
lines changed

src/internal/hooks/use-virtual/__tests__/use-virtual-offset.test.tsx renamed to src/internal/hooks/use-virtual/__tests__/use-virtual.test.tsx

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { render } from '@testing-library/react';
66

77
import { useVirtual } from '../index';
88

9-
// Mock the vendor react-virtual to avoid ES module issues
109
jest.mock('../../../vendor/react-virtual', () => ({
1110
useVirtual: jest.fn(),
1211
}));
@@ -129,4 +128,94 @@ describe('useVirtual', () => {
129128
// totalSize = 160 - (3 items * 1 overlap) - 60 (firstItemSize) + 2 = 99
130129
expect(capturedResult!.totalSize).toBe(99);
131130
});
131+
132+
test('calculates item positions without itemOverlap=0', () => {
133+
const items: TestItem[] = [{ id: '1' }, { id: '2' }, { id: '3' }];
134+
135+
mockUseVirtual.mockReturnValue({
136+
virtualItems: [
137+
{ index: 0, size: 50, start: 0, end: 50, measureRef: mockMeasureRef },
138+
{ index: 1, size: 50, start: 50, end: 100, measureRef: mockMeasureRef },
139+
{ index: 2, size: 50, start: 100, end: 150, measureRef: mockMeasureRef },
140+
],
141+
totalSize: 150,
142+
scrollToIndex: mockScrollToIndex,
143+
});
144+
145+
let capturedResult: ReturnType<typeof useVirtual<TestItem>> | null = null;
146+
render(<TestComponent items={items} onResult={result => (capturedResult = result)} />);
147+
148+
expect(capturedResult!.virtualItems[0].start).toBe(0);
149+
expect(capturedResult!.virtualItems[1].start).toBe(50);
150+
expect(capturedResult!.virtualItems[2].start).toBe(100);
151+
});
152+
153+
test('calculates item positions with itemOverlap=1', () => {
154+
const items: TestItem[] = [{ id: '1' }, { id: '2' }, { id: '3' }];
155+
156+
mockUseVirtual.mockReturnValue({
157+
virtualItems: [
158+
{ index: 0, size: 50, start: 0, end: 50, measureRef: mockMeasureRef },
159+
{ index: 1, size: 50, start: 50, end: 100, measureRef: mockMeasureRef },
160+
{ index: 2, size: 50, start: 100, end: 150, measureRef: mockMeasureRef },
161+
],
162+
totalSize: 150,
163+
scrollToIndex: mockScrollToIndex,
164+
});
165+
166+
let capturedResult: ReturnType<typeof useVirtual<TestItem>> | null = null;
167+
render(<TestComponent items={items} itemOverlap={1} onResult={result => (capturedResult = result)} />);
168+
169+
expect(capturedResult!.virtualItems[0].start).toBe(0);
170+
expect(capturedResult!.virtualItems[1].start).toBe(49);
171+
expect(capturedResult!.virtualItems[2].start).toBe(98);
172+
});
173+
174+
test('calculates item positions with sticky first item and itemOverlap=0', () => {
175+
const items: TestItem[] = [{ id: '1' }, { id: '2' }, { id: '3' }];
176+
177+
mockUseVirtual.mockReturnValue({
178+
virtualItems: [
179+
{ index: 0, size: 60, start: 0, end: 60, measureRef: mockMeasureRef },
180+
{ index: 1, size: 50, start: 60, end: 110, measureRef: mockMeasureRef },
181+
{ index: 2, size: 50, start: 110, end: 160, measureRef: mockMeasureRef },
182+
],
183+
totalSize: 160,
184+
scrollToIndex: mockScrollToIndex,
185+
});
186+
187+
let capturedResult: ReturnType<typeof useVirtual<TestItem>> | null = null;
188+
render(<TestComponent items={items} firstItemSticky={true} onResult={result => (capturedResult = result)} />);
189+
190+
expect(capturedResult!.virtualItems[1].start).toBe(62);
191+
expect(capturedResult!.virtualItems[2].start).toBe(112);
192+
});
193+
194+
test('calculates item positions with sticky first item and itemOverlap=1', () => {
195+
const items: TestItem[] = [{ id: '1' }, { id: '2' }, { id: '3' }];
196+
197+
mockUseVirtual.mockReturnValue({
198+
virtualItems: [
199+
{ index: 0, size: 60, start: 0, end: 60, measureRef: mockMeasureRef },
200+
{ index: 1, size: 50, start: 60, end: 110, measureRef: mockMeasureRef },
201+
{ index: 2, size: 50, start: 110, end: 160, measureRef: mockMeasureRef },
202+
],
203+
totalSize: 160,
204+
scrollToIndex: mockScrollToIndex,
205+
});
206+
207+
let capturedResult: ReturnType<typeof useVirtual<TestItem>> | null = null;
208+
render(
209+
<TestComponent
210+
items={items}
211+
firstItemSticky={true}
212+
itemOverlap={1}
213+
onResult={result => (capturedResult = result)}
214+
/>
215+
);
216+
217+
expect(capturedResult!.virtualItems[0].start).toBe(1);
218+
expect(capturedResult!.virtualItems[1].start).toBe(61);
219+
expect(capturedResult!.virtualItems[2].start).toBe(110);
220+
});
132221
});

src/internal/hooks/use-virtual/index.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,30 @@ export function useVirtual<Item extends object>({
6363

6464
const virtualItems = useMemo(
6565
() =>
66-
rowVirtualizer.virtualItems.map(virtualItem => ({
67-
...virtualItem,
68-
measureRef: (node: null | HTMLElement) => {
69-
const mountedCount = measuresCache.current.get(items[virtualItem.index]) ?? 0;
70-
if (mountedCount < MAX_ITEM_MOUNTS) {
71-
virtualItem.measureRef(node);
72-
measuresCache.current.set(items[virtualItem.index], mountedCount + 1);
73-
}
74-
},
75-
})),
76-
[items, rowVirtualizer.virtualItems]
66+
rowVirtualizer.virtualItems.map((virtualItem, index) => {
67+
let adjustedStart: number;
68+
69+
if (firstItemSticky && virtualItem.index === 0) {
70+
adjustedStart = virtualItem.start + 1;
71+
} else if (firstItemSticky) {
72+
adjustedStart = virtualItem.start + 2 - index * itemOverlap;
73+
} else {
74+
adjustedStart = virtualItem.start - index * itemOverlap;
75+
}
76+
77+
return {
78+
...virtualItem,
79+
start: adjustedStart,
80+
measureRef: (node: null | HTMLElement) => {
81+
const mountedCount = measuresCache.current.get(items[virtualItem.index]) ?? 0;
82+
if (mountedCount < MAX_ITEM_MOUNTS) {
83+
virtualItem.measureRef(node);
84+
measuresCache.current.set(items[virtualItem.index], mountedCount + 1);
85+
}
86+
},
87+
};
88+
}),
89+
[items, rowVirtualizer.virtualItems, firstItemSticky, itemOverlap]
7790
);
7891

7992
// Adjust totalSize to account for 1px overlap per item When firstItemSticky

src/select/utils/render-options.tsx

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -65,29 +65,11 @@ export const renderOptions = ({
6565
const ListItem = useInteractiveGroups ? MultiselectItem : Item;
6666
const isSticky = firstOptionSticky && globalIndex === 0;
6767

68-
// Adjust virtual position to create 1px overlap between consecutive selected items
69-
// When firstOptionSticky is enabled (enableSelectAll), the select-all option needs to be shifted down by 1,
70-
// and all subsequent items need to be shifted up by (index + 1) instead of just index
71-
let adjustedVirtualPosition: number | undefined = undefined;
72-
73-
if (!virtualItem) {
74-
adjustedVirtualPosition = undefined;
75-
} else if (!firstOptionSticky) {
76-
// Shift every item up by one to create a 1px overlap
77-
adjustedVirtualPosition = virtualItem.start - index * 1;
78-
} else if (globalIndex === 0) {
79-
// Shift select-all down by one
80-
adjustedVirtualPosition = virtualItem.start + 1;
81-
} else {
82-
// Shift items down by 2 if first item is sticky
83-
adjustedVirtualPosition = virtualItem.start + 2 - index * 1;
84-
}
85-
8668
return (
8769
<ListItem
8870
key={globalIndex}
8971
{...props}
90-
virtualPosition={adjustedVirtualPosition}
72+
virtualPosition={virtualItem?.start}
9173
ref={isSticky && stickyOptionRef ? stickyOptionRef : virtualItem && virtualItem.measureRef}
9274
padBottom={padBottom}
9375
screenReaderContent={screenReaderContent}

0 commit comments

Comments
 (0)