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..54dbaaf 100644 --- a/e2e/alignment.spec.ts +++ b/e2e/alignment.spec.ts @@ -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() 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;