Skip to content

Commit 80afd8d

Browse files
committed
fix(tables): stabilize reference preview scrolling
1 parent e4d355c commit 80afd8d

2 files changed

Lines changed: 245 additions & 69 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.test.tsx

Lines changed: 154 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -78,25 +78,43 @@ beforeEach(() => {
7878
afterEach(() => {
7979
act(() => root.unmount())
8080
container.remove()
81+
vi.restoreAllMocks()
82+
vi.unstubAllGlobals()
8183
})
8284

8385
function renderPreview() {
86+
const preview = (
87+
<table>
88+
<tbody>
89+
<ReferenceRowPreview
90+
workspaceId='workspace-1'
91+
referenceTableId='table-accounts'
92+
referenceRowId='row-account-1'
93+
colSpan={3}
94+
/>
95+
</tbody>
96+
</table>
97+
)
98+
8499
act(() => {
85-
root.render(
86-
<table>
87-
<tbody>
88-
<ReferenceRowPreview
89-
workspaceId='workspace-1'
90-
referenceTableId='table-accounts'
91-
referenceRowId='row-account-1'
92-
colSpan={3}
93-
/>
94-
</tbody>
95-
</table>
96-
)
100+
root.render(<div data-table-scroll>{preview}</div>)
97101
})
98102
}
99103

104+
function horizontalRect(left: number, right: number): DOMRect {
105+
return {
106+
bottom: 0,
107+
height: 0,
108+
left,
109+
right,
110+
top: 0,
111+
width: right - left,
112+
x: left,
113+
y: 0,
114+
toJSON: () => ({}),
115+
}
116+
}
117+
100118
describe('ReferenceRowPreview', () => {
101119
it('shows the referenced table schema and the matching row inline', () => {
102120
renderPreview()
@@ -120,17 +138,133 @@ describe('ReferenceRowPreview', () => {
120138
expect(container.querySelector('td > div')?.className).toContain(
121139
`h-[${REFERENCE_ROW_PREVIEW_HEIGHT}px]`
122140
)
123-
const subtable = container.querySelector('td table')
124-
expect(subtable?.className).toContain('w-[100cqw]')
125-
expect(subtable?.className).toContain('border-t')
126-
expect(subtable?.className).toContain('border-b')
127-
expect(subtable?.querySelectorAll('col')).toHaveLength(3)
128-
expect(container.querySelector('.overscroll-x-contain')?.className).toContain(
129-
'overscroll-x-contain'
130-
)
141+
const subtable = container.querySelector('[role="table"]')
142+
expect(subtable?.className).toContain('w-full')
143+
expect(subtable?.className).toContain('h-full')
144+
expect(subtable?.className).toContain('grid-rows-2')
145+
expect(subtable?.querySelectorAll('[role="row"]')).toHaveLength(2)
146+
expect(subtable?.querySelectorAll('[role="columnheader"]')).toHaveLength(2)
147+
expect(subtable?.querySelectorAll('[role="cell"]')).toHaveLength(2)
148+
const subtableViewport = container.querySelector('.overscroll-x-contain')
149+
expect(subtableViewport?.className).toContain('overflow-x-auto')
150+
expect(subtableViewport?.className).toContain('overflow-y-hidden')
151+
expect(subtableViewport?.className).toContain('border-y')
131152
expect(container.innerHTML).not.toContain('rounded-md')
132153
})
133154

155+
it('sizes the inner scroller to the visible portion of the preview cell', () => {
156+
let previewCellRight = 1_500
157+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () {
158+
if (this.matches('[data-table-scroll]')) return horizontalRect(100, 920)
159+
if (this.matches('tbody > tr > td')) return horizontalRect(-500, previewCellRight)
160+
return horizontalRect(0, 0)
161+
})
162+
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockImplementation(function () {
163+
return this.matches('[data-table-scroll]') ? 800 : 0
164+
})
165+
renderPreview()
166+
167+
const previewShell = container.querySelector<HTMLElement>('tbody > tr > td > div > div')
168+
expect(previewShell?.style.getPropertyValue('--reference-preview-width')).toBe('800px')
169+
170+
previewCellRight = 780
171+
const scrollRoot = container.querySelector<HTMLElement>('[data-table-scroll]')
172+
if (!scrollRoot) throw new Error('Expected the table scroll root to be rendered')
173+
scrollRoot.scrollLeft = 120
174+
act(() => {
175+
scrollRoot.dispatchEvent(new Event('scroll'))
176+
})
177+
178+
expect(previewShell?.style.getPropertyValue('--reference-preview-width')).toBe('680px')
179+
})
180+
181+
it('updates on resize and releases its observer and scroll listener', () => {
182+
let previewCellRight = 1_500
183+
let resizeCallback: ResizeObserverCallback | null = null
184+
let resizeObserver: ResizeObserver | null = null
185+
const observe = vi.fn()
186+
const disconnect = vi.fn()
187+
188+
class MockResizeObserver implements ResizeObserver {
189+
constructor(callback: ResizeObserverCallback) {
190+
resizeCallback = callback
191+
resizeObserver = this
192+
}
193+
194+
observe(target: Element, options?: ResizeObserverOptions) {
195+
observe(target, options)
196+
}
197+
198+
unobserve() {}
199+
200+
disconnect() {
201+
disconnect()
202+
}
203+
}
204+
205+
vi.stubGlobal('ResizeObserver', MockResizeObserver)
206+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () {
207+
if (this.matches('[data-table-scroll]')) return horizontalRect(100, 900)
208+
if (this.matches('tbody > tr > td')) return horizontalRect(-500, previewCellRight)
209+
return horizontalRect(0, 0)
210+
})
211+
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockImplementation(function () {
212+
return this.matches('[data-table-scroll]') ? 800 : 0
213+
})
214+
const registeredListeners: Array<{
215+
target: EventTarget
216+
type: string
217+
listener: EventListenerOrEventListenerObject | null
218+
}> = []
219+
const removedListeners: typeof registeredListeners = []
220+
const originalAddEventListener = EventTarget.prototype.addEventListener
221+
const originalRemoveEventListener = EventTarget.prototype.removeEventListener
222+
vi.spyOn(EventTarget.prototype, 'addEventListener').mockImplementation(
223+
function (type, listener, options) {
224+
registeredListeners.push({ target: this, type, listener })
225+
originalAddEventListener.call(this, type, listener, options)
226+
}
227+
)
228+
vi.spyOn(EventTarget.prototype, 'removeEventListener').mockImplementation(
229+
function (type, listener, options) {
230+
removedListeners.push({ target: this, type, listener })
231+
originalRemoveEventListener.call(this, type, listener, options)
232+
}
233+
)
234+
235+
renderPreview()
236+
237+
const previewShell = container.querySelector<HTMLElement>('tbody > tr > td > div > div')
238+
const scrollRoot = container.querySelector<HTMLElement>('[data-table-scroll]')
239+
const previewCell = container.querySelector<HTMLElement>('tbody > tr > td')
240+
if (!scrollRoot) throw new Error('Expected the table scroll root to be rendered')
241+
if (!previewCell) throw new Error('Expected the preview cell to be rendered')
242+
const scrollListener = registeredListeners.find(
243+
({ target, type }) => target === scrollRoot && type === 'scroll'
244+
)?.listener
245+
if (!scrollListener) throw new Error('Expected the scroll listener to be registered')
246+
expect(observe).toHaveBeenCalledTimes(2)
247+
expect(observe.mock.calls.some(([target]) => target === scrollRoot)).toBe(true)
248+
expect(observe.mock.calls.some(([target]) => target === previewCell)).toBe(true)
249+
250+
previewCellRight = 780
251+
if (!resizeCallback || !resizeObserver) {
252+
throw new Error('Expected the resize observer to be initialized')
253+
}
254+
act(() => resizeCallback([], resizeObserver))
255+
256+
expect(previewShell?.style.getPropertyValue('--reference-preview-width')).toBe('680px')
257+
258+
act(() => root.render(null))
259+
260+
expect(disconnect).toHaveBeenCalledOnce()
261+
expect(removedListeners).toContainEqual({
262+
target: scrollRoot,
263+
type: 'scroll',
264+
listener: scrollListener,
265+
})
266+
})
267+
134268
it('shows no match when the stored row ID does not resolve', () => {
135269
rowQuery.data = null
136270

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.tsx

Lines changed: 91 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { memo, type ReactNode, useMemo } from 'react'
3+
import { memo, type ReactNode, useLayoutEffect, useMemo, useRef } from 'react'
44
import { buttonVariants } from '@sim/emcn'
55
import { Loader } from '@sim/emcn/icons'
66
import { noop } from '@sim/utils/helpers'
@@ -14,8 +14,7 @@ import { useTable, useTableRow } from '@/hooks/queries/tables'
1414
/**
1515
* Must match the sticky anchor's `h-[144px]` class below because the row
1616
* virtualizer reserves this exact height. The zero-width anchor stays sticky
17-
* across the full table width, while its `100cqw` child uses TableGrid's
18-
* inline-size query container to cover the visible viewport.
17+
* across the full table width without JavaScript-driven positioning.
1918
*/
2019
export const REFERENCE_ROW_PREVIEW_HEIGHT = 144
2120

@@ -34,6 +33,8 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview({
3433
referenceRowId,
3534
colSpan,
3635
}: ReferenceRowPreviewProps) {
36+
const previewCellRef = useRef<HTMLTableCellElement>(null)
37+
const previewShellRef = useRef<HTMLDivElement>(null)
3738
const tableQuery = useTable(workspaceId, referenceTableId)
3839
const rowQuery = useTableRow(workspaceId, referenceTableId, referenceRowId)
3940
const table = tableQuery.data
@@ -43,6 +44,48 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview({
4344
[table?.schema.columns]
4445
)
4546

47+
useLayoutEffect(() => {
48+
const previewCell = previewCellRef.current
49+
const previewShell = previewShellRef.current
50+
const scrollRoot = previewCell?.closest<HTMLElement>('[data-table-scroll]')
51+
if (!previewCell || !previewShell || !scrollRoot) return
52+
53+
let previousWidth: number | null = null
54+
let previousScrollLeft = scrollRoot.scrollLeft
55+
56+
const updateWidth = () => {
57+
const cellBounds = previewCell.getBoundingClientRect()
58+
const viewportBounds = scrollRoot.getBoundingClientRect()
59+
const viewportLeft = viewportBounds.left + scrollRoot.clientLeft
60+
const viewportRight = viewportLeft + scrollRoot.clientWidth
61+
const visibleLeft = Math.max(cellBounds.left, viewportLeft)
62+
const visibleRight = Math.min(cellBounds.right, viewportRight)
63+
const width = Math.max(0, visibleRight - visibleLeft)
64+
if (width === previousWidth) return
65+
previousWidth = width
66+
previewShell.style.setProperty('--reference-preview-width', `${width}px`)
67+
}
68+
69+
const handleScroll = () => {
70+
if (scrollRoot.scrollLeft === previousScrollLeft) return
71+
previousScrollLeft = scrollRoot.scrollLeft
72+
updateWidth()
73+
}
74+
75+
updateWidth()
76+
scrollRoot.addEventListener('scroll', handleScroll, { passive: true })
77+
78+
const resizeObserver =
79+
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateWidth)
80+
resizeObserver?.observe(scrollRoot)
81+
resizeObserver?.observe(previewCell)
82+
83+
return () => {
84+
scrollRoot.removeEventListener('scroll', handleScroll)
85+
resizeObserver?.disconnect()
86+
}
87+
}, [])
88+
4689
let content: ReactNode
4790
if (tableQuery.isLoading || rowQuery.isLoading) {
4891
content = (
@@ -71,69 +114,68 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview({
71114
)
72115
} else {
73116
content = (
74-
<table className='w-[100cqw] min-w-max border-collapse border-[var(--border)] border-t border-b text-small'>
75-
<colgroup>
117+
<div role='table' className='grid h-full w-full min-w-max grid-rows-2 text-small'>
118+
<div role='row' className='flex min-w-max'>
119+
{columns.map((column) => (
120+
<div
121+
role='columnheader'
122+
key={column.key}
123+
className='flex w-40 min-w-40 items-center border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 font-normal'
124+
>
125+
<span className='flex min-w-0 items-center gap-1.5'>
126+
<ColumnTypeIcon type={column.type} />
127+
<span className='truncate text-[var(--text-secondary)]'>{column.name}</span>
128+
</span>
129+
</div>
130+
))}
131+
<div
132+
aria-hidden
133+
className='min-w-0 flex-1 border-[var(--border)] border-b bg-[var(--bg)]'
134+
/>
135+
</div>
136+
<div role='row' className='flex min-w-max'>
76137
{columns.map((column) => (
77-
<col key={column.key} className='w-40' />
138+
<div
139+
role='cell'
140+
key={column.key}
141+
className='flex w-40 min-w-40 max-w-60 items-center border-[var(--border)] border-r px-2 text-[var(--text-primary)]'
142+
>
143+
<div className='max-w-56 overflow-hidden text-ellipsis whitespace-nowrap'>
144+
<CellContent
145+
value={row.data[column.key]}
146+
column={column}
147+
workspaceId={workspaceId}
148+
isEditing={false}
149+
onSave={noop}
150+
onCancel={noop}
151+
/>
152+
</div>
153+
</div>
78154
))}
79-
<col />
80-
</colgroup>
81-
<thead>
82-
<tr>
83-
{columns.map((column) => (
84-
<th
85-
key={column.key}
86-
className='h-9 w-40 min-w-40 border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 text-left font-normal'
87-
>
88-
<span className='flex min-w-0 items-center gap-1.5'>
89-
<ColumnTypeIcon type={column.type} />
90-
<span className='truncate text-[var(--text-secondary)]'>{column.name}</span>
91-
</span>
92-
</th>
93-
))}
94-
<th aria-hidden className='h-9 border-[var(--border)] border-b bg-[var(--bg)]' />
95-
</tr>
96-
</thead>
97-
<tbody>
98-
<tr>
99-
{columns.map((column) => (
100-
<td
101-
key={column.key}
102-
className='h-9 w-40 min-w-40 max-w-60 border-[var(--border)] border-r px-2 text-[var(--text-primary)]'
103-
>
104-
<div className='max-w-56 overflow-hidden text-ellipsis whitespace-nowrap'>
105-
<CellContent
106-
value={row.data[column.key]}
107-
column={column}
108-
workspaceId={workspaceId}
109-
isEditing={false}
110-
onSave={noop}
111-
onCancel={noop}
112-
/>
113-
</div>
114-
</td>
115-
))}
116-
<td aria-hidden className='h-9' />
117-
</tr>
118-
</tbody>
119-
</table>
155+
<div aria-hidden className='min-w-0 flex-1 bg-[var(--bg)]' />
156+
</div>
157+
</div>
120158
)
121159
}
122160

123161
return (
124162
<tr>
125163
<td
164+
ref={previewCellRef}
126165
colSpan={colSpan}
127166
className='overflow-clip border-[var(--border)] border-r border-b bg-[var(--surface-2)] p-0'
128167
>
129168
<div className='sticky left-0 h-[144px] w-0'>
130-
<div className='flex h-full w-[100cqw] min-w-0 flex-col bg-[var(--surface-2)]'>
169+
<div
170+
ref={previewShellRef}
171+
className='flex h-full w-[var(--reference-preview-width,100cqw)] min-w-0 flex-col bg-[var(--surface-2)]'
172+
>
131173
<div className='flex h-9 shrink-0 items-center gap-1.5 px-3 text-[var(--text-primary)] text-small'>
132174
<ReferenceIcon className='size-[14px] text-[var(--text-icon)]' />
133175
<span className='font-medium'>{table?.name ?? 'Referenced table'}</span>
134176
</div>
135177

136-
<div className='h-[72px] shrink-0 overflow-auto overscroll-x-contain bg-[var(--bg)]'>
178+
<div className='h-[72px] shrink-0 overflow-x-auto overflow-y-hidden overscroll-x-contain border-[var(--border)] border-y bg-[var(--bg)]'>
137179
{content}
138180
</div>
139181

0 commit comments

Comments
 (0)