/**
* Row-resize Svelte action - drag the bottom edge of a row to grow /
* shrink it. Pair with a function-valued `rowHeight` prop so the grid
* reads per-row heights from a reactive store.
*
* The action is OFF until a consumer attaches it AND a row-header
* gutter cell exists in each row. A row-header cell is either the
* grid's built-in row-number gutter (enable `showRowNumbers`) or any
* TD tagged with the `sv-row-gutter` class (set
* `cellClass: 'sv-row-gutter'` on a custom gutter column).
*
*
*
*
*
*
*
* Visual: the strip sits along the bottom edge of the row-header
* cell, 5px tall, transparent by default, accent-tinted on hover -
* matching the column resize handle's look. Cursor is `row-resize`.
*/
const STRIP_CLASS = 'sv-grid-row-resize-handle'
export type RowResizeOptions = {
/** Called when the user releases the drag with the final height. */
onResize: (rowIndex: number, height: number) => void
/** Optional callback fired during the drag (every pointermove). */
onResizeMove?: (rowIndex: number, height: number) => void
/** Min row height in px. Default 20. */
min?: number
/** Max row height in px. Default 320. */
max?: number
/** When true, the action removes its strips and ignores events. */
disabled?: boolean
}
type ActiveDrag = {
rowIndex: number
startY: number
startHeight: number
trEl: HTMLTableRowElement
strip: HTMLElement
}
export function rowResize(node: HTMLElement, opts: RowResizeOptions) {
let current = opts
let drag: ActiveDrag | null = null
function rowIndexOf(tr: HTMLTableRowElement): number {
const dataRow = tr.querySelector('[data-svgrid-row]')
const val = dataRow?.dataset.svgridRow
return val != null ? Number(val) : NaN
}
function onPointerMove(e: PointerEvent) {
if (!drag) return
const min = current.min ?? 20
const max = current.max ?? 320
const next = Math.max(min, Math.min(max, drag.startHeight + (e.clientY - drag.startY)))
drag.trEl.style.height = `${Math.round(next)}px`
current.onResizeMove?.(drag.rowIndex, Math.round(next))
}
function onPointerUp(e: PointerEvent) {
if (!drag) return
const min = current.min ?? 20
const max = current.max ?? 320
const next = Math.max(min, Math.min(max, drag.startHeight + (e.clientY - drag.startY)))
current.onResize(drag.rowIndex, Math.round(next))
drag.strip.classList.remove('is-resizing')
try { drag.trEl.releasePointerCapture(e.pointerId) } catch { /* ignore */ }
drag = null
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
document.body.style.cursor = ''
}
/**
* Keyboard resize (#79). The strip is `role="separator"` acting as a
* splitter, so it has to be reachable by Tab and respond to arrow keys.
* Up/Down shrink/grow the row, Shift gives a fine 1px step - the same
* contract the column resize handle uses (Left/Right, Shift = 1px).
*/
function onKeyDown(e: KeyboardEvent) {
if (current.disabled) return
const t = e.target as HTMLElement | null
if (!t?.classList.contains(STRIP_CLASS)) return
const step = e.shiftKey ? 1 : 10
let delta = 0
if (e.key === 'ArrowUp') delta = -step
else if (e.key === 'ArrowDown') delta = step
else return
const tr = t.closest('tr.sv-grid-row')
if (!tr) return
const rowIndex = rowIndexOf(tr)
if (!Number.isFinite(rowIndex)) return
e.preventDefault()
e.stopPropagation()
const min = current.min ?? 20
const max = current.max ?? 320
const next = Math.round(
Math.max(min, Math.min(max, tr.getBoundingClientRect().height + delta)),
)
tr.style.height = `${next}px`
current.onResizeMove?.(rowIndex, next)
current.onResize(rowIndex, next)
}
function onPointerDown(e: PointerEvent) {
if (current.disabled) return
const t = e.target as HTMLElement | null
if (!t?.classList.contains(STRIP_CLASS)) return
const tr = t.closest('tr.sv-grid-row')
if (!tr) return
const rowIndex = rowIndexOf(tr)
if (!Number.isFinite(rowIndex)) return
e.preventDefault()
e.stopPropagation()
t.classList.add('is-resizing')
drag = {
rowIndex,
startY: e.clientY,
startHeight: tr.getBoundingClientRect().height,
trEl: tr,
strip: t,
}
try { tr.setPointerCapture(e.pointerId) } catch { /* ignore */ }
document.body.style.cursor = 'row-resize'
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
/** Inject one strip into every row's gutter cell. When disabled or
* when a row lacks a `.sv-row-gutter` cell, no strip is added. */
function decorate() {
if (current.disabled) {
removeAllStrips()
return
}
const rows = node.querySelectorAll('tr.sv-grid-row')
for (const tr of rows) {
if (tr.classList.contains('sv-grid-header-row')) continue
if (tr.classList.contains('sv-grid-row-spacer')) continue
// A row-header gutter cell: either a user column tagged
// `sv-row-gutter`, or the grid's built-in row-number gutter
// (`showRowNumbers`), whose cell is `.sv-grid-row-number-cell`.
const gutter = tr.querySelector(
'.sv-grid-cell.sv-row-gutter, .sv-grid-cell.sv-grid-row-number-cell',
)
if (!gutter) continue
if (gutter.querySelector(`:scope > .${STRIP_CLASS}`)) continue
// Anchor the strip to the gutter cell + allow it to overflow
// downward into the row gap so the hit-area straddles the
// row boundary the way Excel does.
if (getComputedStyle(gutter).position === 'static') gutter.style.position = 'relative'
gutter.style.overflow = 'visible'
const strip = document.createElement('div')
strip.className = STRIP_CLASS
strip.setAttribute('role', 'separator')
strip.setAttribute('aria-orientation', 'horizontal')
strip.setAttribute('aria-label', 'Resize row')
// A separator used as an interactive splitter has to be focusable so
// keyboard-only users can reach it and drive `onKeyDown` (#79).
strip.tabIndex = 0
// Inline the geometry + pointer-events so the strip works even
// before SvGrid.css is parsed; the hover tint still comes from
// the stylesheet. We keep the strip fully inside the gutter
// cell (no negative offset) to avoid stacking-context surprises
// with pinned / virtualised neighbour cells.
// 5px tall + sitting flush with the row bottom matches the
// column resize handle's 5px width.
strip.style.cssText =
'position:absolute;left:0;right:0;bottom:0;height:5px;' +
'cursor:row-resize;user-select:none;z-index:60;pointer-events:auto;'
gutter.appendChild(strip)
}
}
function removeAllStrips() {
node.querySelectorAll(`.${STRIP_CLASS}`).forEach((el) => el.remove())
}
node.addEventListener('pointerdown', onPointerDown, { capture: true })
node.addEventListener('keydown', onKeyDown, { capture: true })
const observer = new MutationObserver(() => decorate())
observer.observe(node, { childList: true, subtree: true })
decorate()
return {
update(next: RowResizeOptions) {
const wasDisabled = current.disabled
current = next
// Toggle: when going from enabled <-> disabled, repaint immediately.
if (wasDisabled !== current.disabled) decorate()
},
destroy() {
node.removeEventListener('pointerdown', onPointerDown, { capture: true })
node.removeEventListener('keydown', onKeyDown, { capture: true })
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
observer.disconnect()
removeAllStrips()
},
}
}