From 76b7cedd228aeef5449fcbf6aabe5e930bbaecea Mon Sep 17 00:00:00 2001 From: ZVN DEV <78920650+zvndev@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:53:34 -0400 Subject: [PATCH 1/2] fix(react): render resize handles in an overlay so the divider grabs from either side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under sticky headers (the stickyHeader prop and the row-virtualization surface) every header th forms an equal-z stacking context, so a neighbouring cell paints over the previous cell's overhanging resize handle. The hit zone — and the col-resize cursor — only worked when grabbing from the inner side of the divider; the outer half was covered. No z-index on an in-th handle can escape its own cell's stacking context. Lift the handles into one overlay layer (ResizeHandleOverlay), a child of .yable-main outside every th, so they paint above all header cells with a single z-index and each hit zone straddles its divider symmetrically. Handle positions are MEASURED from the real header rects (relative to the layer) and refreshed on scroll, container resize, and column size/order changes — so alignment holds across sticky/non-sticky headers, pinned columns, row/column virtualization, horizontal scroll, and RTL. The visible bar stays exactly on the divider; the outer (grid-edge) handle stays flush inside to avoid phantom horizontal scroll. The React table no longer renders the in-cell .yable-resize-handle; handles are .yable-resize-overlay-handle in .yable-resize-overlay. The vanilla package still renders the in-cell handle (its themes CSS is retained). e2e: new both-sides drag test under a sticky virtualized header (grabbing the OUTER side of the divider now resizes — the regression Kirby kept hitting); all handle-alignment and live-resize tests migrated to the overlay handle. Co-Authored-By: Claude Fable 5 --- .changeset/resize-handle-overlay.md | 23 +++ e2e/alignment.spec.ts | 9 +- e2e/interactions.spec.ts | 42 ++++- e2e/table-interactions.spec.ts | 10 +- packages/react/src/__tests__/Table.test.tsx | 2 +- .../src/components/ResizeHandleOverlay.tsx | 168 ++++++++++++++++++ packages/react/src/components/Table.tsx | 22 +++ packages/react/src/components/TableHeader.tsx | 40 +---- packages/react/src/components/resizeGuard.ts | 19 ++ packages/themes/src/base.css | 73 ++++++++ packages/themes/src/tokens.css | 3 + 11 files changed, 359 insertions(+), 52 deletions(-) create mode 100644 .changeset/resize-handle-overlay.md create mode 100644 packages/react/src/components/ResizeHandleOverlay.tsx create mode 100644 packages/react/src/components/resizeGuard.ts diff --git a/.changeset/resize-handle-overlay.md b/.changeset/resize-handle-overlay.md new file mode 100644 index 0000000..6e4968f --- /dev/null +++ b/.changeset/resize-handle-overlay.md @@ -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.) diff --git a/e2e/alignment.spec.ts b/e2e/alignment.spec.ts index 35db8b0..4d9208e 100644 --- a/e2e/alignment.spec.ts +++ b/e2e/alignment.spec.ts @@ -112,15 +112,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() diff --git a/e2e/interactions.spec.ts b/e2e/interactions.spec.ts index 3762cd9..dacb4d2 100644 --- a/e2e/interactions.spec.ts +++ b/e2e/interactions.spec.ts @@ -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') @@ -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())! @@ -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())! @@ -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 @@ -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 diff --git a/e2e/table-interactions.spec.ts b/e2e/table-interactions.spec.ts index 569543e..b5d309b 100644 --- a/e2e/table-interactions.spec.ts +++ b/e2e/table-interactions.spec.ts @@ -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() diff --git a/packages/react/src/__tests__/Table.test.tsx b/packages/react/src/__tests__/Table.test.tsx index ce5664a..4f9df56 100644 --- a/packages/react/src/__tests__/Table.test.tsx +++ b/packages/react/src/__tests__/Table.test.tsx @@ -267,7 +267,7 @@ describe('Table', () => { it('updates shared column widths when dragging a resize handle', () => { const { container } = render() - 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 }) diff --git a/packages/react/src/components/ResizeHandleOverlay.tsx b/packages/react/src/components/ResizeHandleOverlay.tsx new file mode 100644 index 0000000..697df73 --- /dev/null +++ b/packages/react/src/components/ResizeHandleOverlay.tsx @@ -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 { + table: Table + /** The scrolling/positioned ancestor the layer is a child of. */ + mainRef: React.RefObject + 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 { + id: string + header: HeaderType + /** 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({ + table, + mainRef, + isRtl, + signature, +}: ResizeHandleOverlayProps) { + const layerRef = useRef(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[] = [] + 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( + `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) => (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 ( + - - {canResize && ( -
- )} ) } diff --git a/packages/react/src/components/resizeGuard.ts b/packages/react/src/components/resizeGuard.ts new file mode 100644 index 0000000..6cfe51f --- /dev/null +++ b/packages/react/src/components/resizeGuard.ts @@ -0,0 +1,19 @@ +// @zvndev/yable-react — Resize/sort click guard +// +// A drag-resize now starts from the `ResizeHandleOverlay` (a sibling of the +// table), while click-to-sort lives on the header `th` (in `TableHeader`). +// When a resize ends the trailing `click` can land on a `th` and toggle sort. +// The overlay stamps the resize-end time per table here; the header reads it to +// swallow that stray click — without prop-drilling across the two subtrees. + +import type { RowData, Table } from '@zvndev/yable-core' + +const lastResizeEndByTable = new WeakMap, number>() + +export function markResizeEnd(table: Table): void { + lastResizeEndByTable.set(table as unknown as Table, Date.now()) +} + +export function recentResizeEndAt(table: Table): number { + return lastResizeEndByTable.get(table as unknown as Table) ?? 0 +} diff --git a/packages/themes/src/base.css b/packages/themes/src/base.css index aa2274e..0371157 100644 --- a/packages/themes/src/base.css +++ b/packages/themes/src/base.css @@ -270,6 +270,71 @@ height: 70%; } +/* ── Column Resize Overlay (React) ─────────────────────────────────────────── + The React table renders resize handles in ONE layer OUTSIDE every `th` + (`.yable-resize-overlay`, a child of `.yable-main`) instead of an absolutely + positioned child of each header cell. Under sticky headers every `th` becomes + an equal-z stacking context, so a later sibling paints over the previous + cell's overhanging handle and only its inner half is grabbable. Lifting the + handles into a single higher layer lets each hit zone straddle its divider + symmetrically — the grab works identically from either side. Handle + `left`/`top`/`height` are measured from the real header rects in JS + (see ResizeHandleOverlay), so this tracks sticky/pinned/virtualized/scrolled + layouts. The in-`th` `.yable-resize-handle` above is retained for the vanilla + package, which renders its handles inside the cell. */ +.yable-resize-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: var(--yable-z-resize-overlay); +} + +.yable-resize-overlay-handle { + position: absolute; + /* Centered ON the measured divider so both the visible bar and the pointer + hit zone straddle it symmetrically. `left`/`top`/`height` set imperatively. */ + transform: translateX(-50%); + width: calc(var(--yable-resize-handle-width) * 3); + cursor: col-resize; + pointer-events: auto; + touch-action: none; + display: flex; + align-items: center; + justify-content: center; +} + +/* The grid's outer edge (last column LTR / first RTL) has no next column to + straddle; keep the hotspot flush INSIDE so its overhang can't inflate the + grid's scrollWidth (phantom horizontal scroll). */ +.yable-resize-overlay-handle[data-edge] { + transform: translateX(-100%); + justify-content: flex-end; +} + +.yable-resize-overlay-handle::after { + content: ''; + width: var(--yable-resize-handle-width); + height: 0%; + border-radius: 2px; + background: var(--yable-resize-handle-color); + transition: + background var(--yable-transition), + height var(--yable-transition); +} + +.yable-resize-overlay-handle:hover::after { + background: var(--yable-border-color-strong); + height: 50%; +} + +.yable-resize-overlay-handle[data-resizing='true']::after { + background: var(--yable-resize-handle-color-active); + height: 70%; +} + /* ── Column Reorder ──────────────────────────────────────────────────────── */ .yable-th[data-reorderable='true'] > .yable-th-content { @@ -2649,6 +2714,14 @@ transform: translateX(-50%); } +/* Overlay edge handle in RTL: the grid's outer edge is the FIRST column's + leading (left) boundary, so keep the hotspot flush to the RIGHT of it (inside + the grid) rather than overhanging left past the grid edge. */ +.yable--rtl .yable-resize-overlay-handle[data-edge] { + transform: translateX(0); + justify-content: flex-start; +} + .yable--rtl .yable-tr[data-selected='true'] { box-shadow: inset -2px 0 0 0 var(--yable-accent); } diff --git a/packages/themes/src/tokens.css b/packages/themes/src/tokens.css index abaa03e..6f363ec 100644 --- a/packages/themes/src/tokens.css +++ b/packages/themes/src/tokens.css @@ -179,6 +179,9 @@ --yable-z-header: 10; --yable-z-pinned: 20; --yable-z-resize: 30; + /* Resize handle overlay layer — above sticky+pinned header cells + (z-header + z-pinned = 30) but below content overlays (loading, etc.). */ + --yable-z-resize-overlay: 35; --yable-z-overlay: 40; --yable-z-tooltip: 9999; --yable-z-context-menu: 9999; From 43c9f4649e4987662a6638e015d06e30fa06a18c Mon Sep 17 00:00:00 2001 From: ZVN DEV <78920650+zvndev@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:59:25 -0400 Subject: [PATCH 2/2] test: drop now-unused firstHeader locator in alignment resize test Co-Authored-By: Claude Fable 5 --- e2e/alignment.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/e2e/alignment.spec.ts b/e2e/alignment.spec.ts index 4d9208e..54dbaaf 100644 --- a/e2e/alignment.spec.ts +++ b/e2e/alignment.spec.ts @@ -111,7 +111,6 @@ 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 = root.locator('.yable-resize-overlay-handle').first() const handleBox = await handle.boundingBox() if (!handleBox) throw new Error('resize handle not visible')