fix(desktop): stabilize virtual session scrolling
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import type * as React from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SidebarListRow } from '@/lib/session-date-groups'
|
||||
|
||||
import { VirtualSessionList } from './virtual-session-list'
|
||||
|
||||
const virtualizer = {
|
||||
getTotalSize: () => 68,
|
||||
getVirtualItems: () => [
|
||||
{ end: 26, index: 0, start: 0 },
|
||||
{ end: 68, index: 1, start: 26 }
|
||||
],
|
||||
measureElement: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@dnd-kit/sortable', () => ({ useSortable: vi.fn() }))
|
||||
vi.mock('@dnd-kit/utilities', () => ({ CSS: { Transform: { toString: vi.fn() } } }))
|
||||
vi.mock('@tanstack/react-virtual', () => ({ useVirtualizer: () => virtualizer }))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: {
|
||||
sidebar: {
|
||||
dateDivider: {
|
||||
earlierThisMonth: 'Earlier this month',
|
||||
lastMonth: 'Last month',
|
||||
lastWeek: 'Last week',
|
||||
older: 'Older',
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./chrome', () => ({
|
||||
SidebarDateDivider: ({ label, ...props }: { label: string } & React.ComponentProps<'div'>) => (
|
||||
<div data-testid={`divider-${label}`} {...props} />
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('./session-row', () => ({ SidebarSessionRow: () => null }))
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const rows: SidebarListRow[] = [
|
||||
{ key: 'today', kind: 'divider', label: 'Today' },
|
||||
{ key: 'older', kind: 'divider', label: 'Older' }
|
||||
]
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
describe('VirtualSessionList', () => {
|
||||
it('positions measured rows independently within a total-size spacer', () => {
|
||||
const { getByTestId } = render(
|
||||
<VirtualSessionList
|
||||
activeSessionId={null}
|
||||
onArchiveSession={noop}
|
||||
onDeleteSession={noop}
|
||||
onResumeSession={noop}
|
||||
onTogglePin={noop}
|
||||
pinned={false}
|
||||
rows={rows}
|
||||
sortable={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const firstItem = getByTestId('divider-Today').parentElement
|
||||
const secondItem = getByTestId('divider-Older').parentElement
|
||||
const spacer = firstItem?.parentElement
|
||||
|
||||
expect(firstItem?.dataset.index).toBe('0')
|
||||
expect(firstItem?.style.position).toBe('absolute')
|
||||
expect(firstItem?.style.transform).toBe('translateY(0px)')
|
||||
expect(secondItem?.dataset.index).toBe('1')
|
||||
expect(secondItem?.style.transform).toBe('translateY(26px)')
|
||||
expect(spacer?.className).toBe('relative')
|
||||
expect(spacer?.style.height).toBe('68px')
|
||||
expect(spacer?.style.paddingTop).toBe('')
|
||||
expect(spacer?.style.paddingBottom).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import type * as React from 'react'
|
||||
import { type FC, useCallback, useRef } from 'react'
|
||||
import { type FC, useRef } from 'react'
|
||||
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
@@ -88,8 +88,6 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems()
|
||||
const totalSize = virtualizer.getTotalSize()
|
||||
const paddingTop = virtualItems[0]?.start ?? 0
|
||||
const paddingBottom = Math.max(0, totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0))
|
||||
|
||||
const rows = virtualItems.map(virtualItem => {
|
||||
const row = listRows[virtualItem.index]
|
||||
@@ -98,16 +96,23 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
return null
|
||||
}
|
||||
|
||||
const itemStyle: React.CSSProperties = {
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
width: '100%'
|
||||
}
|
||||
|
||||
// Dividers are non-sortable, self-measured rows interleaved with sessions.
|
||||
if (row.kind === 'divider') {
|
||||
return (
|
||||
<SidebarDateDivider
|
||||
action={dividerAction}
|
||||
data-index={virtualItem.index}
|
||||
key={row.key}
|
||||
label={'label' in row ? row.label : sessionBucketLabel(row.bucket, dividerLabels)}
|
||||
ref={virtualizer.measureElement}
|
||||
/>
|
||||
<div data-index={virtualItem.index} key={row.key} ref={virtualizer.measureElement} style={itemStyle}>
|
||||
<SidebarDateDivider
|
||||
action={dividerAction}
|
||||
label={'label' in row ? row.label : sessionBucketLabel(row.bucket, dividerLabels)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -129,21 +134,13 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
}
|
||||
|
||||
return reorderable ? (
|
||||
<VirtualSortableRow
|
||||
index={virtualItem.index}
|
||||
key={session.id}
|
||||
measureRef={virtualizer.measureElement}
|
||||
rowProps={commonProps}
|
||||
session={session}
|
||||
/>
|
||||
<div data-index={virtualItem.index} key={session.id} ref={virtualizer.measureElement} style={itemStyle}>
|
||||
<VirtualSortableRow rowProps={commonProps} session={session} />
|
||||
</div>
|
||||
) : (
|
||||
<SidebarSessionRow
|
||||
{...commonProps}
|
||||
data-index={virtualItem.index}
|
||||
key={session.id}
|
||||
ref={virtualizer.measureElement}
|
||||
session={session}
|
||||
/>
|
||||
<div data-index={virtualItem.index} key={session.id} ref={virtualizer.measureElement} style={itemStyle}>
|
||||
<SidebarSessionRow {...commonProps} session={session} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -164,41 +161,25 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
)}
|
||||
ref={scrollerRef}
|
||||
>
|
||||
<div className="grid gap-px" style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
|
||||
{rows}
|
||||
</div>
|
||||
<div className="relative" style={{ height: `${totalSize}px` }}>{rows}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface VirtualSortableRowProps {
|
||||
index: number
|
||||
measureRef: (node: Element | null) => void
|
||||
rowProps: SessionRowCommonProps
|
||||
session: SessionInfo
|
||||
}
|
||||
|
||||
function VirtualSortableRow({ index, measureRef, rowProps, session }: VirtualSortableRowProps) {
|
||||
function VirtualSortableRow({ rowProps, session }: VirtualSortableRowProps) {
|
||||
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id: session.id })
|
||||
|
||||
// Merge dnd-kit's setNodeRef with the virtualizer's measureElement so
|
||||
// the row participates in both DnD hit-testing and TanStack height
|
||||
// measurement.
|
||||
const refMerged = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
setNodeRef(node)
|
||||
measureRef(node)
|
||||
},
|
||||
[measureRef, setNodeRef]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarSessionRow
|
||||
{...rowProps}
|
||||
data-index={index}
|
||||
dragging={isDragging}
|
||||
dragHandleProps={{ ...attributes, ...listeners }}
|
||||
ref={refMerged}
|
||||
ref={setNodeRef}
|
||||
reorderable
|
||||
session={session}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
|
||||
Reference in New Issue
Block a user