@svgrid/grid 2.6.21 → 2.6.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +22 -0
  3. package/dist/GridMenus.svelte +17 -12
  4. package/dist/SvGrid.controller.svelte.d.ts +13 -8
  5. package/dist/SvGrid.controller.svelte.js +150 -72
  6. package/dist/SvGrid.css +1 -1
  7. package/dist/SvGrid.svelte +110 -56
  8. package/dist/SvGrid.types.d.ts +41 -1
  9. package/dist/cdn/{GridMenus-BfTAKn84.js → GridMenus-BuoBPqxx.js} +137 -132
  10. package/dist/cdn/GridMenus-n4llxoOI.js +494 -0
  11. package/dist/cdn/column-resize-DsfNXMom.js +102 -0
  12. package/dist/cdn/row-resize-BRcimkUT.js +95 -0
  13. package/dist/cdn/{src-BYq-qyrp.js → src-C9Hihx1W.js} +3456 -3459
  14. package/dist/cdn/{src-DBel9wRZ.js → src-D1lXwq1l.js} +8283 -8286
  15. package/dist/cdn/svgrid.js +10 -8
  16. package/dist/cdn/svgrid.svelte-external.js +10 -8
  17. package/dist/column-groups.js +1 -1
  18. package/dist/column-resize.d.ts +46 -0
  19. package/dist/column-resize.js +205 -0
  20. package/dist/columns.d.ts +0 -3
  21. package/dist/columns.js +0 -57
  22. package/dist/core.d.ts +19 -4
  23. package/dist/core.js +460 -119
  24. package/dist/filtering/excel-filters.js +28 -0
  25. package/dist/group-display.d.ts +1 -1
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.js +6 -0
  28. package/dist/menus.js +1 -1
  29. package/dist/row-resize.d.ts +11 -0
  30. package/dist/row-resize.js +7 -1
  31. package/dist/selection.js +9 -0
  32. package/dist/spreadsheet.d.ts +1 -1
  33. package/dist/spreadsheet.js +1 -1
  34. package/package.json +1 -1
  35. package/src/GridMenus.svelte +17 -12
  36. package/src/SvGrid.controller.svelte.ts +155 -74
  37. package/src/SvGrid.css +1 -1
  38. package/src/SvGrid.svelte +110 -56
  39. package/src/SvGrid.types.ts +41 -1
  40. package/src/column-groups.ts +1 -1
  41. package/src/column-resize.test.ts +381 -0
  42. package/src/column-resize.ts +227 -0
  43. package/src/columns.test.ts +0 -103
  44. package/src/columns.ts +0 -58
  45. package/src/core.aggregate.test.ts +134 -0
  46. package/src/core.filter.test.ts +156 -0
  47. package/src/core.grouping.test.ts +146 -0
  48. package/src/core.row-shape.test.ts +119 -0
  49. package/src/core.rowmodel-cache.test.ts +121 -0
  50. package/src/core.sort.test.ts +293 -0
  51. package/src/core.ts +516 -119
  52. package/src/filtering/excel-filters.ts +30 -0
  53. package/src/filtering/normalize-fast-path.test.ts +104 -0
  54. package/src/group-display.ts +1 -1
  55. package/src/index.ts +12 -1
  56. package/src/menus.ts +1 -1
  57. package/src/resize-props.test.ts +361 -0
  58. package/src/row-resize.test.ts +31 -0
  59. package/src/row-resize.ts +21 -3
  60. package/src/selection.ts +9 -0
  61. package/src/spreadsheet.ts +1 -1
  62. package/dist/cdn/GridMenus-C3bJd7w8.js +0 -489
package/src/SvGrid.svelte CHANGED
@@ -15,6 +15,8 @@
15
15
  } from "./index";
16
16
  import "./sv-grid-scrollbar";
17
17
  import "./SvGrid.css";
18
+ import type { RowResizeOptions } from "./row-resize";
19
+ import type { ColumnResizeOptions } from "./column-resize";
18
20
  import {
19
21
  RenderSnippetConfig,
20
22
  RenderComponentConfig,
@@ -167,7 +169,9 @@
167
169
  const hideTooltip = $derived(ctrl.hideTooltip);
168
170
  const findHits = $derived(ctrl.findHits);
169
171
  const headerHeight = $derived(ctrl.headerHeight);
170
- const resizingColumnId = $derived(ctrl.resizingColumnId);
172
+ // Column resizing has always been unconditional; `columnResize={false}` is
173
+ // the opt-out, so the default has to stay on when the prop is absent.
174
+ const columnResizeEnabled = $derived(opt.columnResize === true);
171
175
  const selectionColumnWidth = $derived(ctrl.selectionColumnWidth);
172
176
  const rowNumberColumnWidth = $derived(ctrl.rowNumberColumnWidth);
173
177
  const showRowNumbersEffective = $derived(ctrl.showRowNumbersEffective);
@@ -264,6 +268,23 @@
264
268
  // Row-grouping display modes: group state lives in synthetic columns instead
265
269
  // of a full-width banner row.
266
270
  const groupColumnMode = $derived(ctrl.groupColumnMode);
271
+ /**
272
+ * True when a cell's content needs none of the wrapper snippets.
273
+ *
274
+ * A cell body normally renders through `cellBodyWithFormat` ->
275
+ * `cellBodyFormatted` -> `cellBody`, and with no grouping, no tree data and
276
+ * no conditional formats the first two are pure pass-throughs: three snippet
277
+ * renders and two `{#if}` blocks per cell to reach content that one render
278
+ * produces. Measured by ablation, that chain is 4.2 ms of a 17.4 ms mount at
279
+ * 28 rows x 9 columns - about a quarter of the whole mount, and the largest
280
+ * single per-cell cost.
281
+ *
282
+ * Computed once per render rather than per cell, so the fast path costs one
283
+ * boolean read at each of the ~250 cells instead of two snippet frames.
284
+ */
285
+ const plainCellBody = $derived(
286
+ !groupColumnMode && !treeData && !hasConditionalFormats,
287
+ );
267
288
  const autoGroupCell = $derived(ctrl.autoGroupCell);
268
289
  const isAutoGroupColumn = (id: string): boolean =>
269
290
  id === "__autoGroup" || id.startsWith("__group_");
@@ -280,7 +301,60 @@
280
301
  // The fixed row height, matching what the virtualized path takes from the virtualizer.
281
302
  // The non-virtualized (`virtualization={false}`) body must apply this too, else its rows
282
303
  // fall back to content height and look shorter than a virtualized grid's.
304
+ /**
305
+ * `rowResize` and `columnResize` are both opt-in and both default to OFF, so
306
+ * neither module is imported statically - a grid that leaves them alone must
307
+ * not carry their bytes. This wraps an action so its module is fetched on
308
+ * first enable and never otherwise. The handles appear a microtask after the
309
+ * first paint, which is invisible for a drag affordance.
310
+ *
311
+ * One helper for both: two hand-rolled copies of this bookkeeping would cost
312
+ * more base bundle than the deferral saves.
313
+ */
314
+ type LazyHandle<O> = { update(o: O): void; destroy(): void };
315
+ function lazyAction<O extends { disabled?: boolean }>(
316
+ load: () => Promise<(node: HTMLElement, opts: O) => LazyHandle<O>>,
317
+ ) {
318
+ return (node: HTMLElement, opts: O): LazyHandle<O> => {
319
+ let handle: LazyHandle<O> | null = null;
320
+ let current = opts;
321
+ let destroyed = false;
322
+ const ensure = () => {
323
+ if (handle || destroyed || current.disabled) return;
324
+ void load().then((make) => {
325
+ // The prop can be switched back off, or the grid unmounted, while
326
+ // the chunk is still in flight.
327
+ if (destroyed || current.disabled || handle) return;
328
+ handle = make(node, current);
329
+ });
330
+ };
331
+ ensure();
332
+ return {
333
+ update(next: O) {
334
+ current = next;
335
+ if (handle) handle.update(next);
336
+ else ensure();
337
+ },
338
+ destroy() {
339
+ destroyed = true;
340
+ handle?.destroy();
341
+ },
342
+ };
343
+ };
344
+ }
345
+
346
+ const lazyRowResize = lazyAction<RowResizeOptions>(() =>
347
+ import("./row-resize").then((m) => m.rowResize),
348
+ );
349
+ const lazyColumnResize = lazyAction<ColumnResizeOptions>(() =>
350
+ import("./column-resize").then((m) => m.columnResize),
351
+ );
352
+
283
353
  const rowSizePx = (i: number): number => {
354
+ // A height the user dragged wins over the declared one, so the row stays
355
+ // where they put it across re-renders and virtualization recycling.
356
+ const dragged = ctrl.rowResizeHeightPx(i);
357
+ if (dragged != null) return dragged;
284
358
  const rh = opt.rowHeight;
285
359
  return typeof rh === "function" ? rh(i) : (rh ?? 30);
286
360
  };
@@ -358,26 +432,10 @@
358
432
  return "sv-grid-cell-flash";
359
433
  }
360
434
 
361
- // Keyboard-accessible column resize (#79): focus a resize handle and use the
362
- // arrow keys (Shift = fine 1px step). Complements the pointer-drag resize.
363
- function resizeColumnByKeyboard(e: KeyboardEvent, columnId: string) {
364
- let delta = 0;
365
- const step = e.shiftKey ? 1 : 10;
366
- if (e.key === "ArrowLeft") delta = -step;
367
- else if (e.key === "ArrowRight") delta = step;
368
- else return;
369
- e.preventDefault();
370
- const current = ctrl.getColumnWidth(columnId);
371
- ctrl.columnWidths = {
372
- ...ctrl.columnWidths,
373
- [columnId]: Math.max(40, current + delta),
374
- };
375
- }
376
-
377
435
  // ---- Full-row editing -------------------------------------------------
378
436
  const fullRowEdit = $derived(ctrl.fullRowEdit);
379
437
  // Commit the whole row when the user clicks away from its editors (Excel /
380
- // AG-Grid feel). Clicking within any full-row editor keeps editing.
438
+ // spreadsheet feel). Clicking within any full-row editor keeps editing.
381
439
  // Stays here rather than in SvGridCellEditor: that component is instantiated
382
440
  // once PER editable cell during a full-row edit, so a document listener there
383
441
  // would be attached N times and commit N times.
@@ -429,7 +487,6 @@
429
487
  const setActiveCell = $derived(ctrl.setActiveCell);
430
488
  const scrollActiveCellIntoView = $derived(ctrl.scrollActiveCellIntoView);
431
489
  const getColumnWidth = $derived(ctrl.getColumnWidth);
432
- const startColumnResize = $derived(ctrl.startColumnResize);
433
490
  const getCellRangeEdges = $derived(ctrl.getCellRangeEdges);
434
491
  const fillHandleCell = $derived(ctrl.fillHandleCell);
435
492
  const isInFillPreview = $derived(ctrl.isInFillPreview);
@@ -1294,6 +1351,26 @@
1294
1351
  class="sv-grid-root"
1295
1352
  class:sv-grid-root-fill={opt.containerHeight === "100%"}
1296
1353
  style={chartDockReserveStyle}
1354
+ use:lazyRowResize={{
1355
+ disabled: !ctrl.rowResizeOn,
1356
+ // No gutter column on most grids, so fall back to the first body cell -
1357
+ // otherwise `rowResize` would be a prop that silently does nothing.
1358
+ anchor: "row",
1359
+ onResize: (index, height) => ctrl.setRowResizeHeight(index, height),
1360
+ }}
1361
+ use:lazyColumnResize={{
1362
+ disabled: !columnResizeEnabled,
1363
+ getWidth: (columnId) => ctrl.getColumnWidth(columnId),
1364
+ onResize: (columnId, width) => {
1365
+ ctrl.columnWidths = { ...ctrl.columnWidths, [columnId]: width };
1366
+ },
1367
+ label: (columnId) => {
1368
+ const col = ctrl.findColumnById(columnId);
1369
+ return col ? toolPanelHeaderLabel(col) : columnId;
1370
+ },
1371
+ canResize: (columnId) => ctrl.columnResizable(columnId),
1372
+ onAutosize: (columnId) => ctrl.autosizeColumn(columnId),
1373
+ }}
1297
1374
  >
1298
1375
  {#if showGlobalFilterEffective}
1299
1376
  <label class="sv-grid-global-filter">
@@ -1747,32 +1824,6 @@
1747
1824
  }}
1748
1825
  />
1749
1826
  {/if}
1750
- <div
1751
- class="sv-grid-resize-handle"
1752
- class:is-resizing={resizingColumnId ===
1753
- header.column.id}
1754
- role="separator"
1755
- aria-orientation="vertical"
1756
- aria-label={`Resize ${toolPanelHeaderLabel(header.column)}`}
1757
- tabindex="0"
1758
- {...(() => {
1759
- // A focusable separator is a widget, so ARIA requires
1760
- // aria-valuenow. The value it exposes is the column
1761
- // width the arrow keys change; 40 is the floor
1762
- // enforced in resizeColumnByKeyboard.
1763
- const w = Math.round(ctrl.getColumnWidth(header.column.id));
1764
- return {
1765
- "aria-valuenow": w,
1766
- "aria-valuemin": 40,
1767
- "aria-valuetext": `${w} pixels`,
1768
- };
1769
- })()}
1770
- onpointerdown={(event) =>
1771
- startColumnResize(event, header.column.id)}
1772
- onkeydown={(event) =>
1773
- resizeColumnByKeyboard(event, header.column.id)}
1774
- ondblclick={(event) => event.stopPropagation()}
1775
- ></div>
1776
1827
  {/if}
1777
1828
  </th>
1778
1829
  {/if}
@@ -2177,12 +2228,11 @@
2177
2228
  rendered.column.columnDef.cellFlash,
2178
2229
  ),
2179
2230
  }}
2180
- {...getGridCellA11yProps({
2181
- id: getGridCellDomId(ctrl.gridDomId, rowIndex, colIndex),
2182
- rowIndex: rowIndex + 1,
2183
- colIndex: colIndex + 1,
2184
- selected: isRowSelected(row.id),
2185
- })}
2231
+ role="gridcell"
2232
+ id={getGridCellDomId(ctrl.gridDomId, rowIndex, colIndex)}
2233
+ aria-colindex={colIndex + 1}
2234
+ aria-rowindex={rowIndex + 1}
2235
+ aria-selected={isRowSelected(row.id)}
2186
2236
  >
2187
2237
  {#if inRowEdit || isEditing}
2188
2238
  <!-- The editing cell stays empty until the lazy
@@ -2194,11 +2244,15 @@
2194
2244
  <CellEditor {ctrl} column={rendered.column} {row} fullRow={inRowEdit} />
2195
2245
  {/if}
2196
2246
  {:else}
2197
- {@render cellBodyWithFormat(
2198
- row,
2199
- rendered.column,
2200
- cellValue,
2201
- )}
2247
+ {#if plainCellBody}
2248
+ {@render cellBody(row, rendered.column, cellValue)}
2249
+ {:else}
2250
+ {@render cellBodyWithFormat(
2251
+ row,
2252
+ rendered.column,
2253
+ cellValue,
2254
+ )}
2255
+ {/if}
2202
2256
  {/if}
2203
2257
  {#if !isEditing && fillHandleCell && fillHandleCell.rowIndex === rowIndex && fillHandleCell.colIndex === colIndex}
2204
2258
  <!-- Excel-style fill handle: drag down/right to
@@ -1329,6 +1329,28 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
1329
1329
  * supplying per-row heights).
1330
1330
  */
1331
1331
  autoRowHeight?: boolean;
1332
+ /**
1333
+ * Let the user drag a row's bottom edge to change its height. **Off by
1334
+ * default**, because a resizable row needs somewhere to grab and most grids
1335
+ * do not want a drag target on every row.
1336
+ *
1337
+ * The grid remembers the heights itself, so this works as a bare boolean -
1338
+ * no `rowHeight` function required. Drag, or focus the grip and use Up/Down
1339
+ * (Shift for 1px steps).
1340
+ *
1341
+ * Turning this on also turns on the **row header column** ({@link
1342
+ * showRowNumbers}), because that is where the grip lives and where a
1343
+ * spreadsheet puts it - a drag target on the edge of a data cell works but
1344
+ * reads as an accident. An explicit `showRowNumbers={false}` still wins; the
1345
+ * grip then falls back to the row's first cell, and a column of your own
1346
+ * carrying `cellClass: 'sv-row-gutter'` is used ahead of either.
1347
+ *
1348
+ * Ignored under `autoRowHeight`, where the content decides the height and a
1349
+ * manual one would immediately be overwritten. For full control of the
1350
+ * heights - persisting them, sharing them between grids - pass a
1351
+ * function-valued {@link rowHeight} and use the `rowResize` action directly.
1352
+ */
1353
+ rowResize?: boolean;
1332
1354
  /**
1333
1355
  * Height (px) of a single column-header level row. With multi-level
1334
1356
  * (grouped) headers the total header height is `levels * headerHeight`,
@@ -1387,6 +1409,24 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
1387
1409
  * win once they happen.
1388
1410
  */
1389
1411
  fitColumns?: boolean;
1412
+ /**
1413
+ * Let the user drag a column's edge to change its width. **Off by default**,
1414
+ * so `width` means the width you asked for until you say otherwise.
1415
+ *
1416
+ * Turn it on for grids the reader is meant to arrange - a spreadsheet, a
1417
+ * dense report, anything with columns whose content varies in length. Leave
1418
+ * it off for layouts you control, and for columns that must keep their size:
1419
+ * a row-number gutter, a checkbox column, an icon column.
1420
+ *
1421
+ * It is grid-wide rather than per-column: there is no `resizable: false` on a
1422
+ * single column definition. `api.autosizeColumn()`, `fitColumns` and
1423
+ * programmatic width changes work either way - this only governs the drag
1424
+ * handle.
1425
+ *
1426
+ * The handles come from the `columnResize` action, loaded on demand: a grid
1427
+ * that leaves this off never fetches that code.
1428
+ */
1429
+ columnResize?: boolean;
1390
1430
  /**
1391
1431
  * Make the grid usable on narrow screens. When the grid's own width drops
1392
1432
  * below the breakpoint (default `640`px), pinned columns are un-pinned so the
@@ -1498,7 +1538,7 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
1498
1538
  charting?: boolean | ChartingConfig<TData>;
1499
1539
  /**
1500
1540
  * Render the header column menu (⋮) as a tabbed popover - **General**,
1501
- * **Filter**, and **Columns** tabs (the AG-Grid layout). Defaults to `false`,
1541
+ * **Filter**, and **Columns** tabs (the tabbed layout). Defaults to `false`,
1502
1542
  * which keeps the flat menu (actions list + "Choose columns" submenu).
1503
1543
  */
1504
1544
  columnMenuTabs?: boolean;
@@ -1,4 +1,4 @@
1
- // Collapsible column groups (AG-Grid `columnGroupShow`). A column group can
1
+ // Collapsible column groups (a per-column `columnGroupShow` flag). A column group can
2
2
  // carry a collapse toggle: its child columns tagged `columnGroupShow: 'open'`
3
3
  // show only when the group is expanded, `'closed'` show only when collapsed,
4
4
  // and untagged children always show.
@@ -0,0 +1,381 @@
1
+ /**
2
+ * The `columnResize` action. This coverage moved here from columns.test.ts when
3
+ * the drag logic left the controller: the same behaviours (min clamp, rAF
4
+ * coalescing, final commit, teardown) are asserted, but against real DOM rather
5
+ * than a synthetic ctx, because the action now owns the handles as well as the
6
+ * drag.
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9
+ import { columnResize } from './column-resize'
10
+
11
+ const HANDLE = 'sv-grid-resize-handle'
12
+
13
+ /** A header row shaped like SvGrid's: `th.sv-grid-column[data-svgrid-header-col]`. */
14
+ function buildHeaders(
15
+ specs: Array<{ id?: string; classes?: string[] }>,
16
+ ): { host: HTMLElement; ths: HTMLElement[] } {
17
+ const host = document.createElement('div')
18
+ const table = document.createElement('table')
19
+ const thead = document.createElement('thead')
20
+ const tr = document.createElement('tr')
21
+ const ths: HTMLElement[] = []
22
+ specs.forEach((spec, i) => {
23
+ const th = document.createElement('th')
24
+ th.className = ['sv-grid-column', ...(spec.classes ?? [])].join(' ')
25
+ if (spec.id !== undefined) th.dataset.svgridHeaderCol = spec.id
26
+ else if (!spec.classes) th.dataset.svgridHeaderCol = `c${i}`
27
+ tr.appendChild(th)
28
+ ths.push(th)
29
+ })
30
+ thead.appendChild(tr)
31
+ table.appendChild(thead)
32
+ host.appendChild(table)
33
+ document.body.appendChild(host)
34
+ return { host, ths }
35
+ }
36
+
37
+ const handleOf = (th: HTMLElement) => th.querySelector<HTMLElement>(`.${HANDLE}`)
38
+
39
+ function pointer(type: string, init: Partial<PointerEvent> = {}): PointerEvent {
40
+ return new PointerEvent(type, {
41
+ bubbles: true,
42
+ cancelable: true,
43
+ pointerId: 1,
44
+ ...init,
45
+ } as PointerEventInit)
46
+ }
47
+
48
+ const hosts: HTMLElement[] = []
49
+ const track = (h: HTMLElement) => (hosts.push(h), h)
50
+
51
+ let rafCb: FrameRequestCallback | null = null
52
+ beforeEach(() => {
53
+ rafCb = null
54
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
55
+ rafCb = cb
56
+ return 1
57
+ })
58
+ vi.stubGlobal('cancelAnimationFrame', vi.fn())
59
+ if (!HTMLElement.prototype.setPointerCapture) {
60
+ HTMLElement.prototype.setPointerCapture = function () {}
61
+ }
62
+ if (!HTMLElement.prototype.releasePointerCapture) {
63
+ HTMLElement.prototype.releasePointerCapture = function () {}
64
+ }
65
+ })
66
+ afterEach(() => {
67
+ while (hosts.length) hosts.pop()!.remove()
68
+ document.body.innerHTML = ''
69
+ document.body.style.cursor = ''
70
+ vi.unstubAllGlobals()
71
+ })
72
+
73
+ /** Widths a test can read back, standing in for the controller. */
74
+ function widthStore(initial: Record<string, number> = {}) {
75
+ const widths = { ...initial }
76
+ return {
77
+ widths,
78
+ getWidth: (id: string) => widths[id] ?? 100,
79
+ onResize: (id: string, w: number) => {
80
+ widths[id] = w
81
+ },
82
+ }
83
+ }
84
+
85
+ describe('columnResize - handle injection', () => {
86
+ it('injects one handle per identified header', () => {
87
+ const { host, ths } = buildHeaders([{ id: 'a' }, { id: 'b' }])
88
+ track(host)
89
+ const a = columnResize(host, widthStore())
90
+ expect(handleOf(ths[0]!)).not.toBeNull()
91
+ expect(handleOf(ths[1]!)).not.toBeNull()
92
+ a.destroy()
93
+ })
94
+
95
+ it('skips spacer columns and headers with no column id', () => {
96
+ // Group headers and the virtualization spacers have nothing to resize.
97
+ const { host, ths } = buildHeaders([
98
+ { id: 'a' },
99
+ { classes: ['sv-grid-column-spacer'] },
100
+ { classes: ['sv-grid-group-header'] },
101
+ ])
102
+ track(host)
103
+ const a = columnResize(host, widthStore())
104
+ expect(handleOf(ths[0]!)).not.toBeNull()
105
+ expect(handleOf(ths[1]!)).toBeNull()
106
+ expect(handleOf(ths[2]!)).toBeNull()
107
+ a.destroy()
108
+ })
109
+
110
+ it('does not double-inject on re-decorate', () => {
111
+ const { host, ths } = buildHeaders([{ id: 'a' }])
112
+ track(host)
113
+ const a = columnResize(host, widthStore())
114
+ a.update({ ...widthStore(), disabled: false })
115
+ expect(ths[0]!.querySelectorAll(`.${HANDLE}`).length).toBe(1)
116
+ a.destroy()
117
+ })
118
+
119
+ it('injects nothing when created disabled', () => {
120
+ const { host, ths } = buildHeaders([{ id: 'a' }])
121
+ track(host)
122
+ const a = columnResize(host, { ...widthStore(), disabled: true })
123
+ expect(handleOf(ths[0]!)).toBeNull()
124
+ a.destroy()
125
+ })
126
+
127
+ it('removes handles when toggled off and restores them when back on', () => {
128
+ const { host, ths } = buildHeaders([{ id: 'a' }])
129
+ track(host)
130
+ const store = widthStore()
131
+ const a = columnResize(host, store)
132
+ expect(handleOf(ths[0]!)).not.toBeNull()
133
+ a.update({ ...store, disabled: true })
134
+ expect(handleOf(ths[0]!)).toBeNull()
135
+ a.update({ ...store, disabled: false })
136
+ expect(handleOf(ths[0]!)).not.toBeNull()
137
+ a.destroy()
138
+ })
139
+
140
+ it('gives the handle an accessible separator role and a name', () => {
141
+ const { host, ths } = buildHeaders([{ id: 'a' }])
142
+ track(host)
143
+ const a = columnResize(host, { ...widthStore({ a: 150 }), label: () => 'Name' })
144
+ const h = handleOf(ths[0]!)!
145
+ expect(h.getAttribute('role')).toBe('separator')
146
+ expect(h.getAttribute('aria-orientation')).toBe('vertical')
147
+ expect(h.getAttribute('aria-label')).toBe('Resize Name')
148
+ expect(h.tabIndex).toBe(0)
149
+ expect(h.getAttribute('aria-valuenow')).toBe('150')
150
+ a.destroy()
151
+ })
152
+ })
153
+
154
+ describe('columnResize - drag', () => {
155
+ it('clamps to the minimum width and commits on the frame', () => {
156
+ const { host, ths } = buildHeaders([{ id: 'a' }])
157
+ track(host)
158
+ const store = widthStore({ a: 120 })
159
+ const a = columnResize(host, store)
160
+ const h = handleOf(ths[0]!)!
161
+ h.dispatchEvent(pointer('pointerdown', { clientX: 100 }))
162
+ window.dispatchEvent(pointer('pointermove', { clientX: -1000 }))
163
+ expect(rafCb).not.toBeNull()
164
+ rafCb!(0)
165
+ expect(store.widths.a).toBe(40)
166
+ a.destroy()
167
+ })
168
+
169
+ it('coalesces multiple moves into a single frame', () => {
170
+ const { host, ths } = buildHeaders([{ id: 'a' }])
171
+ track(host)
172
+ const store = widthStore({ a: 120 })
173
+ const a = columnResize(host, store)
174
+ const h = handleOf(ths[0]!)!
175
+ h.dispatchEvent(pointer('pointerdown', { clientX: 100 }))
176
+ window.dispatchEvent(pointer('pointermove', { clientX: 150 })) // +50 -> 170
177
+ const first = rafCb
178
+ window.dispatchEvent(pointer('pointermove', { clientX: 200 })) // +100 -> 220
179
+ expect(rafCb).toBe(first) // not rescheduled
180
+ rafCb!(0)
181
+ expect(store.widths.a).toBe(220)
182
+ a.destroy()
183
+ })
184
+
185
+ it('commits the final width on pointerup even if the frame never ran', () => {
186
+ const { host, ths } = buildHeaders([{ id: 'a' }])
187
+ track(host)
188
+ const store = widthStore({ a: 120 })
189
+ const a = columnResize(host, store)
190
+ const h = handleOf(ths[0]!)!
191
+ h.dispatchEvent(pointer('pointerdown', { clientX: 100 }))
192
+ window.dispatchEvent(pointer('pointermove', { clientX: 190 }))
193
+ window.dispatchEvent(pointer('pointerup', { clientX: 190 })) // rAF cancelled
194
+ expect(store.widths.a).toBe(210)
195
+ a.destroy()
196
+ })
197
+
198
+ it('marks the handle while dragging and clears it afterwards', () => {
199
+ const { host, ths } = buildHeaders([{ id: 'a' }])
200
+ track(host)
201
+ const a = columnResize(host, widthStore({ a: 120 }))
202
+ const h = handleOf(ths[0]!)!
203
+ h.dispatchEvent(pointer('pointerdown', { clientX: 100 }))
204
+ expect(h.classList.contains('is-resizing')).toBe(true)
205
+ window.dispatchEvent(pointer('pointerup', { clientX: 100 }))
206
+ expect(h.classList.contains('is-resizing')).toBe(false)
207
+ a.destroy()
208
+ })
209
+
210
+ it('ignores pointerdown when disabled, and on a non-handle target', () => {
211
+ const { host, ths } = buildHeaders([{ id: 'a' }])
212
+ track(host)
213
+ const store = widthStore({ a: 120 })
214
+ const a = columnResize(host, store)
215
+ const h = handleOf(ths[0]!)!
216
+ a.update({ ...store, disabled: true })
217
+ h.dispatchEvent(pointer('pointerdown', { clientX: 100 }))
218
+ window.dispatchEvent(pointer('pointermove', { clientX: 300 }))
219
+ expect(rafCb).toBeNull()
220
+ // A click on the header itself must not start a drag either.
221
+ ths[0]!.dispatchEvent(pointer('pointerdown', { clientX: 100 }))
222
+ expect(rafCb).toBeNull()
223
+ a.destroy()
224
+ })
225
+ })
226
+
227
+ describe('columnResize - keyboard', () => {
228
+ it('ArrowRight grows by 10 and ArrowLeft shrinks by 10', () => {
229
+ const { host, ths } = buildHeaders([{ id: 'a' }])
230
+ track(host)
231
+ const store = widthStore({ a: 120 })
232
+ const a = columnResize(host, store)
233
+ const h = handleOf(ths[0]!)!
234
+ h.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
235
+ expect(store.widths.a).toBe(130)
236
+ h.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))
237
+ expect(store.widths.a).toBe(120)
238
+ a.destroy()
239
+ })
240
+
241
+ it('Shift gives a 1px step and the minimum still holds', () => {
242
+ const { host, ths } = buildHeaders([{ id: 'a' }])
243
+ track(host)
244
+ const store = widthStore({ a: 41 })
245
+ const a = columnResize(host, store)
246
+ const h = handleOf(ths[0]!)!
247
+ h.dispatchEvent(
248
+ new KeyboardEvent('keydown', { key: 'ArrowLeft', shiftKey: true, bubbles: true }),
249
+ )
250
+ expect(store.widths.a).toBe(40)
251
+ h.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))
252
+ expect(store.widths.a).toBe(40) // clamped, not 30
253
+ a.destroy()
254
+ })
255
+
256
+ it('ignores other keys and does nothing while disabled', () => {
257
+ const { host, ths } = buildHeaders([{ id: 'a' }])
258
+ track(host)
259
+ const store = widthStore({ a: 120 })
260
+ const a = columnResize(host, store)
261
+ const h = handleOf(ths[0]!)!
262
+ h.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
263
+ expect(store.widths.a).toBe(120)
264
+ a.update({ ...store, disabled: true })
265
+ h.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
266
+ expect(store.widths.a).toBe(120)
267
+ a.destroy()
268
+ })
269
+ })
270
+
271
+ describe('columnResize - teardown', () => {
272
+ it('destroy removes every handle and detaches the drag listeners', () => {
273
+ const { host, ths } = buildHeaders([{ id: 'a' }, { id: 'b' }])
274
+ track(host)
275
+ const store = widthStore({ a: 120 })
276
+ const a = columnResize(host, store)
277
+ const h = handleOf(ths[0]!)!
278
+ h.dispatchEvent(pointer('pointerdown', { clientX: 100 }))
279
+ a.destroy()
280
+ expect(host.querySelectorAll(`.${HANDLE}`).length).toBe(0)
281
+ // An in-flight drag must not keep writing after unmount (#58).
282
+ window.dispatchEvent(pointer('pointermove', { clientX: 400 }))
283
+ expect(rafCb).toBeNull()
284
+ expect(store.widths.a).toBe(120)
285
+ })
286
+ })
287
+
288
+ describe('columnResize - per-column resizable', () => {
289
+ it('creates no handle for a column that opts out', () => {
290
+ const { host, ths } = buildHeaders([{ id: 'a' }, { id: 'b' }])
291
+ track(host)
292
+ const a = columnResize(host, { ...widthStore(), canResize: (id) => id !== 'b' })
293
+ expect(handleOf(ths[0]!)).not.toBeNull()
294
+ expect(handleOf(ths[1]!)).toBeNull()
295
+ a.destroy()
296
+ })
297
+
298
+ it('takes the handle away when a column stops being resizable', () => {
299
+ // A re-decorate has to remove, not merely stop adding: the handle is
300
+ // already in the DOM by then.
301
+ const { host, ths } = buildHeaders([{ id: 'a' }])
302
+ track(host)
303
+ const store = widthStore()
304
+ let allow = true
305
+ const a = columnResize(host, { ...store, canResize: () => allow })
306
+ expect(handleOf(ths[0]!)).not.toBeNull()
307
+ allow = false
308
+ a.update({ ...store, canResize: () => allow, disabled: true })
309
+ a.update({ ...store, canResize: () => allow, disabled: false })
310
+ expect(handleOf(ths[0]!)).toBeNull()
311
+ a.destroy()
312
+ })
313
+
314
+ it('refuses the drag and the arrow keys even if a handle is reached', () => {
315
+ // Defence in depth: no handle is created, so this can only happen if one
316
+ // survives a stale render. It must still not resize.
317
+ const { host, ths } = buildHeaders([{ id: 'a' }])
318
+ track(host)
319
+ const store = widthStore({ a: 120 })
320
+ let allow = true
321
+ const a = columnResize(host, { ...store, canResize: () => allow })
322
+ const h = handleOf(ths[0]!)!
323
+ allow = false
324
+ a.update({ ...store, canResize: () => allow })
325
+
326
+ h.dispatchEvent(pointer('pointerdown', { clientX: 100 }))
327
+ window.dispatchEvent(pointer('pointermove', { clientX: 300 }))
328
+ expect(rafCb).toBeNull()
329
+ h.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
330
+ expect(store.widths.a).toBe(120)
331
+ a.destroy()
332
+ })
333
+ })
334
+
335
+ describe('columnResize - double-click autosize', () => {
336
+ it('double-clicking the handle autosizes that column', () => {
337
+ const { host, ths } = buildHeaders([{ id: 'a' }])
338
+ track(host)
339
+ const onAutosize = vi.fn()
340
+ const a = columnResize(host, { ...widthStore(), onAutosize })
341
+ handleOf(ths[0]!)!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
342
+ expect(onAutosize).toHaveBeenCalledWith('a')
343
+ a.destroy()
344
+ })
345
+
346
+ it('does not autosize a column that opted out - it has no handle to hit', () => {
347
+ const { host, ths } = buildHeaders([{ id: 'a' }])
348
+ track(host)
349
+ const onAutosize = vi.fn()
350
+ const a = columnResize(host, { ...widthStore(), onAutosize, canResize: () => false })
351
+ expect(handleOf(ths[0]!)).toBeNull()
352
+ // The header itself is all that is left, and it carries no autosize.
353
+ ths[0]!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
354
+ expect(onAutosize).not.toHaveBeenCalled()
355
+ a.destroy()
356
+ })
357
+
358
+ it('does not let the double-click reach the header underneath', () => {
359
+ const { host, ths } = buildHeaders([{ id: 'a' }])
360
+ track(host)
361
+ const onHeader = vi.fn()
362
+ ths[0]!.addEventListener('dblclick', onHeader)
363
+ const a = columnResize(host, { ...widthStore(), onAutosize: vi.fn() })
364
+ handleOf(ths[0]!)!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
365
+ expect(onHeader).not.toHaveBeenCalled()
366
+ a.destroy()
367
+ })
368
+
369
+ it('does nothing on double-click while disabled', () => {
370
+ const { host, ths } = buildHeaders([{ id: 'a' }])
371
+ track(host)
372
+ const onAutosize = vi.fn()
373
+ const store = widthStore()
374
+ const a = columnResize(host, { ...store, onAutosize })
375
+ const h = handleOf(ths[0]!)!
376
+ a.update({ ...store, onAutosize, disabled: true })
377
+ h.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
378
+ expect(onAutosize).not.toHaveBeenCalled()
379
+ a.destroy()
380
+ })
381
+ })