Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/resize-handle-overlay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@zvndev/yable-react': minor
'@zvndev/yable-themes': patch
---

Column resize handles now render in a single overlay layer above the header
instead of an absolutely-positioned child inside each header cell. Under sticky
headers (the `stickyHeader` prop and the row-virtualization surface) every `th`
formed an equal-`z` stacking context, so a neighbouring cell painted over the
previous handle's overhanging half — the resize hit zone (and the `col-resize`
cursor) only worked when grabbing from the inner side of the divider. Lifting
the handles into one higher layer makes each hit zone straddle its divider
symmetrically: the grab works identically from either side of the line, with the
cursor shown across the full zone. Handle positions are measured from the real
header rects, so alignment holds across sticky/non-sticky headers, pinned
columns, row/column virtualization, horizontal scroll, and RTL. The visible
indicator stays exactly on the divider.

The React table no longer renders the in-cell `.yable-resize-handle` element;
its handles are `.yable-resize-overlay-handle` inside `.yable-resize-overlay`.
Consumers who styled `.yable-resize-handle` for the React grid should target the
overlay classes instead. (The `@zvndev/yable-vanilla` package still renders
`.yable-resize-handle` inside the cell, and its styling is unchanged.)
10 changes: 4 additions & 6 deletions e2e/alignment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,16 +111,14 @@ test.describe('header/body column alignment (virtualized)', () => {

test(`stays pixel-aligned after a column resize (${label})`, async ({ page }) => {
const root = grid(page, id)
const firstHeader = root.locator('thead th').first()
const handle = firstHeader.locator('.yable-resize-handle')
const handle = root.locator('.yable-resize-overlay-handle').first()
const handleBox = await handle.boundingBox()
if (!handleBox) throw new Error('resize handle not visible')

const beforeWidth = (await headerBoxes(root))[0]!.width
// Grab from the inward half of the handle: under a sticky virtualized
// header the next th paints over the handle's overhanging (right) half, so
// the reliable grab zone is up to the divider from the left.
const grabX = handleBox.x + handleBox.width / 2 - 4
// Grab the handle centre — the overlay layer sits above every header cell
// so the hit zone straddles the divider and is grabbable from either side.
const grabX = handleBox.x + handleBox.width / 2
const grabY = handleBox.y + handleBox.height / 2
await page.mouse.move(grabX, grabY)
await page.mouse.down()
Expand Down
42 changes: 38 additions & 4 deletions e2e/interactions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ test.describe('real-pointer core interactions', () => {
test(`column resize tracks the pointer LIVE during drag (${label})`, async ({ page }) => {
const root = grid(page, id)
const nameTh = root.locator('th[data-column-id="name"]')
const handle = nameTh.locator('.yable-resize-handle')
const handle = root.locator('.yable-resize-overlay-handle[data-column-id="name"]')
const hb = await handle.boundingBox()
if (!hb) throw new Error('resize handle not visible')

Expand Down Expand Up @@ -631,7 +631,7 @@ test.describe('resize handle alignment', () => {

const nameTh = root.locator('.yable-thead th[data-column-id="name"]')
const nextTh = root.locator('.yable-thead th[data-column-id="action"]')
const handle = nameTh.locator('.yable-resize-handle')
const handle = root.locator('.yable-resize-overlay-handle[data-column-id="name"]')

const nameBox = (await nameTh.boundingBox())!
const nextBox = (await nextTh.boundingBox())!
Expand All @@ -656,7 +656,7 @@ test.describe('resize handle alignment', () => {
await expect(root.locator('.yable-thead th[data-column-id="id"]')).toBeVisible()

const idTh = root.locator('.yable-thead th[data-column-id="id"]')
const handle = idTh.locator('.yable-resize-handle')
const handle = root.locator('.yable-resize-overlay-handle[data-column-id="id"]')
const idBox = (await idTh.boundingBox())!
const handleBox = (await handle.boundingBox())!

Expand All @@ -666,6 +666,40 @@ test.describe('resize handle alignment', () => {
expect(handleBox.x).toBeLessThan(boundary)
expect(handleBox.x + handleBox.width).toBeGreaterThan(boundary)
})

// The regression Kirby keeps hitting: under a sticky (here: virtualized)
// header the neighbouring th painted OVER the handle's outer half, so a grab
// from the right side of the divider missed and did nothing — resizing only
// worked when aiming left of the line. The overlay layer paints above every
// header cell, so a drag must start identically from BOTH sides of the
// divider. `grid-virtual` uses the row-virtualization surface (sticky `th`).
for (const side of [-4, 4] as const) {
const label = side < 0 ? 'inner' : 'outer'
test(`drag-resize starts from the ${label} side of the divider under a sticky header`, async ({
page,
}) => {
await page.goto('/e2e/interactions')
const root = grid(page, 'grid-virtual')
const nameTh = root.locator('.yable-thead th[data-column-id="name"]')
await expect(nameTh).toBeVisible()
const handle = root.locator('.yable-resize-overlay-handle[data-column-id="name"]')
await expect(handle).toBeVisible()

const before = (await nameTh.boundingBox())!
const boundary = before.x + before.width
const handleBox = (await handle.boundingBox())!
const grabX = boundary + side
const grabY = handleBox.y + handleBox.height / 2

await page.mouse.move(grabX, grabY)
await page.mouse.down()
await page.mouse.move(grabX + 70, grabY, { steps: 10 })
await page.mouse.up()

const after = (await nameTh.boundingBox())!.width
expect(after).toBeGreaterThan(before.width + 40)
})
}
})

// The header-label ellipsis clip (added in 0.6.1) must only apply to STRING
Expand Down Expand Up @@ -701,7 +735,7 @@ test.describe('resizeMaxSize lets user drag past maxSize', () => {
const nameTh = root.locator('thead th[data-column-id="name"]')
await expect(nameTh).toBeVisible()

const handle = nameTh.locator('.yable-resize-handle')
const handle = root.locator('.yable-resize-overlay-handle[data-column-id="name"]')
const hb = (await handle.boundingBox())!
const before = (await nameTh.boundingBox())!.width

Expand Down
10 changes: 5 additions & 5 deletions e2e/table-interactions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,14 @@ test.describe('Yable browser interactions', () => {
const visibleNameCell = firstVisibleCell(page, 'name')
await expectColumnWidthsAligned(nameHeader, visibleNameCell)

const handle = nameHeader.locator('.yable-resize-handle')
const handle = page.locator('.yable-resize-overlay-handle[data-column-id="name"]')
const handleBox = await box(handle)
const beforeWidth = await width(nameHeader)

// Grab the inward half of the handle: a sticky virtualized header paints
// over the handle's overhanging (right) half, so the reliable grab zone is
// up to the divider from the left.
const grabX = handleBox.x + handleBox.width / 2 - 4
// Grab the handle centre — the overlay layer sits above every header cell so
// the hit zone straddles the divider and is grabbable from either side, even
// under a sticky virtualized header.
const grabX = handleBox.x + handleBox.width / 2
const grabY = handleBox.y + handleBox.height / 2
await page.mouse.move(grabX, grabY)
await page.mouse.down()
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/__tests__/Table.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ describe('Table', () => {
it('updates shared column widths when dragging a resize handle', () => {
const { container } = render(<SizedTable />)

const resizeHandle = container.querySelector('.yable-resize-handle') as HTMLDivElement
const resizeHandle = container.querySelector('.yable-resize-overlay-handle') as HTMLDivElement
fireEvent.mouseDown(resizeHandle, { clientX: 100 })
fireEvent.mouseMove(document, { clientX: 140 })
fireEvent.mouseUp(document, { clientX: 140 })
Expand Down
168 changes: 168 additions & 0 deletions packages/react/src/components/ResizeHandleOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// @zvndev/yable-react — Column Resize Handle Overlay
//
// Why an overlay layer instead of a handle inside each `th`:
// Under sticky headers (the `stickyHeader` prop AND the row-virtualization
// surface, which pins header `th` unconditionally) every header cell gets
// `position: sticky; z-index: var(--yable-z-header)`. Equal-z sibling `th`
// elements each form their own stacking context, so a later `th` paints on top
// of the previous cell's OVERHANGING resize handle — the half of the hit zone
// that straddles into the next column is covered, and a user can only grab the
// divider from its inner (left) side. No z-index on the in-`th` handle can
// escape its own cell's stacking context.
//
// This layer lives OUTSIDE every `th` (a child of `.yable-main`), so it paints
// above all header cells with a single z-index and its hit zones straddle each
// divider symmetrically. Handle positions are MEASURED from the real header
// cell rects (relative to this layer), so the layer is correct in every mode —
// sticky/non-sticky headers, pinned columns, row/column virtualization,
// horizontal scroll, and RTL — because it simply mirrors where the browser
// actually painted each column boundary. Positions are refreshed on scroll
// (rAF-throttled), container resize, and whenever the column size/order
// signature changes (which also tracks a live drag-resize).

import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react'
import type { RowData, Table, Header as HeaderType } from '@zvndev/yable-core'
import { markResizeEnd } from './resizeGuard'

interface ResizeHandleOverlayProps<TData extends RowData> {
table: Table<TData>
/** The scrolling/positioned ancestor the layer is a child of. */
mainRef: React.RefObject<HTMLDivElement | null>
isRtl: boolean
/**
* Changes whenever visible column ids or sizes change (incl. per-frame during
* a live drag-resize), driving a re-measure so handles track the divider.
*/
signature: string
}

interface HandleEntry<TData extends RowData> {
id: string
header: HeaderType<TData, unknown>
/** The rightmost (LTR) / leftmost (RTL) boundary is the grid edge: keep the
* hit zone flush inside so its overhang can't inflate the grid scrollWidth. */
edge: boolean
}

export function ResizeHandleOverlay<TData extends RowData>({
table,
mainRef,
isRtl,
signature,
}: ResizeHandleOverlayProps<TData>) {
const layerRef = useRef<HTMLDivElement>(null)

const leafColumns = table.getVisibleLeafColumns()
const leafHeaders = table.getHeaderGroups().at(-1)?.headers ?? []
const headerById = new Map(leafHeaders.map((h) => [h.column.id, h]))

const handles: HandleEntry<TData>[] = []
leafColumns.forEach((column, i) => {
if (!column.getCanResize()) return
const header = headerById.get(column.id)
if (!header) return
// The grid's outer boundary is the last leaf in LTR, the first in RTL.
const edge = isRtl ? i === 0 : i === leafColumns.length - 1
handles.push({ id: column.id, header, edge })
})

const reposition = useCallback(() => {
const layer = layerRef.current
if (!layer) return
const layerRect = layer.getBoundingClientRect()
for (const node of Array.from(layer.children) as HTMLElement[]) {
const colId = node.dataset.columnId
if (!colId) continue
const th = mainRef.current?.querySelector<HTMLElement>(
`thead th[data-column-id="${CSS.escape(colId)}"]`,
)
if (!th) {
node.style.display = 'none'
continue
}
const r = th.getBoundingClientRect()
if (r.width === 0 && r.height === 0) {
node.style.display = 'none'
continue
}
// The divider is the cell's trailing edge: right in LTR, left in RTL.
const boundary = (isRtl ? r.left : r.right) - layerRect.left
node.style.display = ''
node.style.left = `${boundary}px`
node.style.top = `${r.top - layerRect.top}px`
node.style.height = `${r.height}px`
}
}, [mainRef, isRtl])

// Measure after every layout-affecting render (mount, resize-drag frame,
// reorder, virtualization window change) before the browser paints.
useLayoutEffect(() => {
reposition()
}, [reposition, signature])

useEffect(() => {
const main = mainRef.current
if (!main) return
let raf = 0
const schedule = () => {
if (raf) return
raf = requestAnimationFrame(() => {
raf = 0
reposition()
})
}
// Capture: scroll doesn't bubble, but the real scroller may be a nested
// horizontal/virtual container inside `.yable-main`.
main.addEventListener('scroll', schedule, { capture: true, passive: true })
window.addEventListener('resize', schedule)
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(schedule) : null
ro?.observe(main)
return () => {
main.removeEventListener('scroll', schedule, { capture: true } as EventListenerOptions)
window.removeEventListener('resize', schedule)
ro?.disconnect()
if (raf) cancelAnimationFrame(raf)
}
}, [mainRef, reposition])

const startResize = useCallback(
(header: HeaderType<TData, unknown>) => (e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation()
const handler = header.getResizeHandler()
if (!handler) return
;(handler as (ev: unknown) => void)(e.nativeEvent)
// Stamp resize-end so the trailing click can't toggle sort on a th.
const onEnd = () => {
markResizeEnd(table)
document.removeEventListener('mouseup', onEnd, true)
document.removeEventListener('touchend', onEnd, true)
}
document.addEventListener('mouseup', onEnd, true)
document.addEventListener('touchend', onEnd, true)
},
[table],
)

const swallowClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
}, [])

if (handles.length === 0) return null

return (
<div className="yable-resize-overlay" aria-hidden="true" ref={layerRef}>
{handles.map(({ id, header, edge }) => (
<div
key={id}
className="yable-resize-overlay-handle"
data-column-id={id}
data-edge={edge || undefined}
data-resizing={header.column.getIsResizing() || undefined}
onMouseDown={startResize(header)}
onTouchStart={startResize(header)}
onClick={swallowClick}
/>
))}
</div>
)
}
22 changes: 22 additions & 0 deletions packages/react/src/components/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useYableDefaults } from '../YableProvider'
import { resolveYableProfile } from '../config'
import type { TableProps } from '../types'
import { TableHeader } from './TableHeader'
import { ResizeHandleOverlay } from './ResizeHandleOverlay'
import { TableBody } from './TableBody'
import { AdaptiveTableCards, type RequiredAdaptiveLayout } from './AdaptiveTableCards'
import { TableFooter } from './TableFooter'
Expand Down Expand Up @@ -307,6 +308,18 @@ export function Table<TData extends RowData>({
0,
)

// Resize handles live in an overlay layer (see ResizeHandleOverlay) so their
// hit zones straddle each divider even under sticky headers. This signature
// re-measures handle positions whenever column ids/sizes change — including
// per-frame during a live `onChange` drag-resize — plus RTL and the
// column-virtualization horizontal offset.
const anyResizable = visibleLeafColumns.some((column) => column.getCanResize())
const resizeOverlaySignature = anyResizable
? `${visibleLeafColumns.map((c) => `${c.id}:${c.getSize()}:${c.getCanResize() ? 1 : 0}`).join('|')}|${
isRtl ? 1 : 0
}|${columnVirtualState.startOffset ?? 0}|${baseState.columnSizingInfo?.isResizingColumn ?? ''}`
: ''

const colgroup =
visibleLeafColumns.length === 0 ? null : (
<colgroup>
Expand Down Expand Up @@ -454,6 +467,15 @@ export function Table<TData extends RowData>({
tableNode
)}

{anyResizable && !adaptiveLayoutActive && (
<ResizeHandleOverlay
table={renderTable}
mainRef={mainRef}
isRtl={isRtl}
signature={resizeOverlaySignature}
/>
)}

<LoadingOverlay
loading={loading}
loadingComponent={renderLoading ? renderLoading() : loadingComponent}
Expand Down
Loading
Loading