@svgrid/grid 1.1.0 → 1.1.2

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 (148) hide show
  1. package/dist/FlexRender.svelte +96 -96
  2. package/dist/GridMenus.svelte +205 -127
  3. package/dist/SvGrid.controller.svelte.d.ts +44 -1
  4. package/dist/SvGrid.controller.svelte.js +240 -44
  5. package/dist/SvGrid.css +325 -22
  6. package/dist/SvGrid.helpers.test.d.ts +1 -0
  7. package/dist/SvGrid.helpers.test.js +298 -0
  8. package/dist/SvGrid.svelte +729 -203
  9. package/dist/SvGrid.types.d.ts +91 -4
  10. package/dist/aligned-grids.d.ts +6 -0
  11. package/dist/aligned-grids.js +84 -0
  12. package/dist/aligned-grids.test.d.ts +1 -0
  13. package/dist/aligned-grids.test.js +75 -0
  14. package/dist/build-api.coverage.test.d.ts +20 -0
  15. package/dist/build-api.coverage.test.js +505 -0
  16. package/dist/build-api.js +61 -31
  17. package/dist/cell-render.test.d.ts +1 -0
  18. package/dist/cell-render.test.js +338 -0
  19. package/dist/cell-values.d.ts +1 -1
  20. package/dist/cell-values.js +7 -7
  21. package/dist/chart-export.test.d.ts +1 -0
  22. package/dist/chart-export.test.js +302 -0
  23. package/dist/chart.coverage.test.d.ts +1 -0
  24. package/dist/chart.coverage.test.js +748 -0
  25. package/dist/clipboard.js +88 -24
  26. package/dist/clipboard.test.d.ts +1 -0
  27. package/dist/clipboard.test.js +700 -0
  28. package/dist/collaboration.coverage.test.d.ts +1 -0
  29. package/dist/collaboration.coverage.test.js +200 -0
  30. package/dist/column-groups.d.ts +19 -0
  31. package/dist/column-groups.js +62 -0
  32. package/dist/column-groups.test.d.ts +1 -0
  33. package/dist/column-groups.test.js +56 -0
  34. package/dist/column-types.d.ts +10 -0
  35. package/dist/column-types.js +63 -0
  36. package/dist/column-types.test.d.ts +1 -0
  37. package/dist/column-types.test.js +62 -0
  38. package/dist/columns.test.d.ts +1 -0
  39. package/dist/columns.test.js +625 -0
  40. package/dist/core.d.ts +86 -1
  41. package/dist/core.js +2 -2
  42. package/dist/editing.d.ts +7 -0
  43. package/dist/editing.js +191 -5
  44. package/dist/editing.test.d.ts +1 -0
  45. package/dist/editing.test.js +732 -0
  46. package/dist/editors/cell-editors.coverage.test.d.ts +1 -0
  47. package/dist/editors/cell-editors.coverage.test.js +139 -0
  48. package/dist/facet-buckets.test.d.ts +1 -0
  49. package/dist/facet-buckets.test.js +296 -0
  50. package/dist/filter-operators.test.d.ts +1 -0
  51. package/dist/filter-operators.test.js +135 -0
  52. package/dist/hyperformula-adapter.test.d.ts +1 -0
  53. package/dist/hyperformula-adapter.test.js +205 -0
  54. package/dist/index.d.ts +5 -2
  55. package/dist/index.js +4 -1
  56. package/dist/keyboard-handlers.coverage.test.d.ts +1 -0
  57. package/dist/keyboard-handlers.coverage.test.js +495 -0
  58. package/dist/keyboard-handlers.js +7 -2
  59. package/dist/menus.js +1 -0
  60. package/dist/menus.test.d.ts +1 -0
  61. package/dist/menus.test.js +560 -0
  62. package/dist/named-views.coverage.test.d.ts +1 -0
  63. package/dist/named-views.coverage.test.js +180 -0
  64. package/dist/row-drag.d.ts +49 -0
  65. package/dist/row-drag.js +221 -0
  66. package/dist/row-drag.test.d.ts +1 -0
  67. package/dist/row-drag.test.js +142 -0
  68. package/dist/row-resize.test.d.ts +1 -0
  69. package/dist/row-resize.test.js +329 -0
  70. package/dist/scroll-sync.js +3 -0
  71. package/dist/scroll-sync.test.d.ts +1 -0
  72. package/dist/scroll-sync.test.js +290 -0
  73. package/dist/selection.d.ts +7 -1
  74. package/dist/selection.js +76 -36
  75. package/dist/selection.multi-range.test.d.ts +1 -0
  76. package/dist/selection.multi-range.test.js +55 -0
  77. package/dist/selection.test.d.ts +1 -0
  78. package/dist/selection.test.js +647 -0
  79. package/dist/server-data-source.coverage.test.d.ts +1 -0
  80. package/dist/server-data-source.coverage.test.js +154 -0
  81. package/dist/spreadsheet.d.ts +30 -0
  82. package/dist/spreadsheet.js +48 -0
  83. package/dist/spreadsheet.test.d.ts +1 -0
  84. package/dist/spreadsheet.test.js +446 -0
  85. package/dist/summaries.js +4 -4
  86. package/dist/sv-grid-scrollbar.js +13 -1
  87. package/dist/svgrid-wrapper.types.d.ts +19 -0
  88. package/dist/svgrid.behavior.test.js +20 -0
  89. package/dist/svgrid.interaction.test.js +31 -0
  90. package/dist/svgrid.new-features.wrapper.test.js +34 -2
  91. package/dist/test-setup.js +9 -3
  92. package/dist/virtualization/scroll-scaling.d.ts +17 -0
  93. package/dist/virtualization/scroll-scaling.js +35 -0
  94. package/dist/virtualization/scroll-scaling.test.js +42 -1
  95. package/package.json +2 -1
  96. package/src/FlexRender.svelte +96 -96
  97. package/src/GridMenus.svelte +205 -127
  98. package/src/SvGrid.controller.svelte.ts +204 -36
  99. package/src/SvGrid.css +277 -12
  100. package/src/SvGrid.svelte +727 -205
  101. package/src/SvGrid.types.ts +85 -4
  102. package/src/a11y.contract.test.ts +49 -49
  103. package/src/a11y.test.ts +59 -59
  104. package/src/a11y.ts +59 -59
  105. package/src/aligned-grids.test.ts +80 -0
  106. package/src/aligned-grids.ts +87 -0
  107. package/src/build-api.ts +43 -23
  108. package/src/cell-formatting.ts +169 -169
  109. package/src/cell-values.ts +4 -4
  110. package/src/clipboard.test.ts +49 -0
  111. package/src/clipboard.ts +51 -23
  112. package/src/column-groups.test.ts +59 -0
  113. package/src/column-groups.ts +80 -0
  114. package/src/column-types.test.ts +68 -0
  115. package/src/column-types.ts +82 -0
  116. package/src/core.performance.test.ts +30 -30
  117. package/src/core.ts +1077 -999
  118. package/src/createGrid.svelte.ts +42 -42
  119. package/src/createGrid.test.ts +10 -10
  120. package/src/createGridState.svelte.ts +17 -17
  121. package/src/editing.ts +161 -5
  122. package/src/flex-render.ts +3 -3
  123. package/src/index.ts +208 -196
  124. package/src/keyboard.test.ts +59 -59
  125. package/src/keyboard.ts +97 -97
  126. package/src/menus.ts +1 -0
  127. package/src/merge-objects.ts +48 -48
  128. package/src/render-component.ts +28 -28
  129. package/src/row-drag.test.ts +168 -0
  130. package/src/row-drag.ts +255 -0
  131. package/src/scroll-sync.ts +2 -0
  132. package/src/selection.multi-range.test.ts +61 -0
  133. package/src/selection.ts +71 -37
  134. package/src/spreadsheet.test.ts +46 -2
  135. package/src/spreadsheet.ts +58 -0
  136. package/src/static-functions.ts +11 -11
  137. package/src/subscribe.ts +38 -38
  138. package/src/summaries.ts +4 -4
  139. package/src/svgrid-wrapper.types.ts +412 -393
  140. package/src/svgrid.features.test.ts +157 -157
  141. package/src/svgrid.new-features.wrapper.test.ts +2 -2
  142. package/src/svgrid.wrapper.test.ts +40 -40
  143. package/src/virtualization/column-virtualizer.test.ts +27 -27
  144. package/src/virtualization/column-virtualizer.ts +30 -30
  145. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  146. package/src/virtualization/types.ts +30 -30
  147. package/src/virtualization/virtualizer.test.ts +47 -47
  148. package/src/virtualization/virtualizer.ts +296 -296
@@ -150,7 +150,32 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
150
150
  showFilterMenu?: boolean;
151
151
  showFilterRow?: boolean;
152
152
  enableCellSelection?: boolean;
153
+ /**
154
+ * Prepend a header row (the column labels) to copied cell ranges, so pasting
155
+ * into Excel / Sheets includes the headers. Applies per copied range.
156
+ */
157
+ copyHeadersToClipboard?: boolean;
158
+ /**
159
+ * Transform each cell value on its way to the clipboard - e.g. strip
160
+ * currency symbols, expand codes to labels, or redact. Receives the display
161
+ * value plus the row/column context; return the string (or value) to copy.
162
+ */
163
+ processCellForClipboard?: (params: {
164
+ value: unknown;
165
+ column: unknown;
166
+ row: TData;
167
+ rowIndex: number;
168
+ columnId: string;
169
+ }) => unknown;
153
170
  enableInlineEditing?: boolean;
171
+ /**
172
+ * Full-row editing. When `true`, starting an edit puts the WHOLE row into
173
+ * edit mode - every editable cell shows an inline editor at once - and a
174
+ * single Enter (or focus leaving the row) commits all of them; Esc cancels
175
+ * the whole row. Requires `enableInlineEditing`. The full-row editor covers
176
+ * text / number / date / datetime / checkbox / list-select editor types.
177
+ */
178
+ fullRowEditing?: boolean;
154
179
  enableRowSummaries?: boolean;
155
180
  /**
156
181
  * Excel-style status bar under the grid showing live aggregates of the
@@ -166,12 +191,21 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
166
191
  >;
167
192
  };
168
193
  /**
169
- * Show a docked Columns tool panel - the enterprise sidebar for toggling
170
- * column visibility, reordering, and grouping without a right-click. A
171
- * toggle button appears at the grid's top-right; the panel itself docks
172
- * on the right edge.
194
+ * Show the docked tool panel - the enterprise sidebar with Columns and
195
+ * Filters tabs. A "Columns & Filters" button appears in a toolbar above the
196
+ * grid; the panel docks on the right edge.
173
197
  */
174
198
  toolPanel?: boolean;
199
+ /**
200
+ * Render the header column menu (⋮) as a tabbed popover - **General**,
201
+ * **Filter**, and **Columns** tabs (the AG-Grid layout). Defaults to `false`,
202
+ * which keeps the flat menu (actions list + "Choose columns" submenu).
203
+ */
204
+ columnMenuTabs?: boolean;
205
+ /** Open the tool panel on first render (instead of collapsed). */
206
+ toolPanelDefaultOpen?: boolean;
207
+ /** Which tab the tool panel starts on. Defaults to `'columns'`. */
208
+ toolPanelDefaultTab?: "columns" | "filters";
175
209
  /**
176
210
  * Quick way to pick which selection surfaces are active. `'row'` shows the
177
211
  * selection checkbox column only, `'cell'` allows rectangle/range cell
@@ -419,6 +453,53 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
419
453
  columnOrder?: ReadonlyArray<string>;
420
454
  /** Fires every time the column order changes (drag or `api.setColumnOrder`). */
421
455
  onColumnOrderChange?: (order: ReadonlyArray<string>) => void;
456
+ /**
457
+ * Infer each column's data type (number / boolean / date / ISO date-string /
458
+ * text) from the first data row, for columns that declare neither an
459
+ * explicit `editorType` nor a `cellDataType`. Sets the matching editor,
460
+ * alignment, date format, and filter operators automatically. Explicit
461
+ * column config always wins. Defaults to `false`.
462
+ */
463
+ inferColumnTypes?: boolean;
464
+ /**
465
+ * Enables managed row dragging. When `true`, every row becomes a drag
466
+ * source (grab cursor + a grip in the row-number cell) and a drop
467
+ * indicator paints between rows during a drag. On drop the grid mutates
468
+ * its own internal data - reordering within the grid, or moving the row
469
+ * across grids that share the same {@link rowDragGroup}. Defaults to
470
+ * `false`.
471
+ */
472
+ rowDragManaged?: boolean;
473
+ /**
474
+ * Connection group for cross-grid row dragging. Grids that share the same
475
+ * non-empty `rowDragGroup` string (and have `rowDragManaged` on) can
476
+ * exchange rows: dragging a row out of one and dropping it into another
477
+ * removes it from the source and inserts it into the target. Omit to keep
478
+ * dragging confined to reordering within a single grid.
479
+ */
480
+ rowDragGroup?: string;
481
+ /**
482
+ * Fires on the TARGET grid after a managed row drag settles, with the
483
+ * moved row, its landing index, whether it stayed in the same grid, and
484
+ * the source / target grid ids. Use it to mirror the move into your own
485
+ * state (persistence, server sync). The grid has already applied the
486
+ * change to its internal data by the time this fires.
487
+ */
488
+ onRowDragEnd?: (event: {
489
+ row: TData;
490
+ toIndex: number;
491
+ sameGrid: boolean;
492
+ fromGridId: number;
493
+ toGridId: number;
494
+ }) => void;
495
+ /**
496
+ * Align this grid with others that share the same non-empty
497
+ * `alignedGridGroup` string: horizontal scroll and column-resize widths are
498
+ * kept in lockstep across every grid in the group. Use for a totals/header
499
+ * grid above a body grid, or side-by-side comparison grids that must line up.
500
+ * The grids should declare the same columns (matched by id) for widths to map.
501
+ */
502
+ alignedGridGroup?: string;
422
503
  /**
423
504
  * BCP-47 locale tag (or array of fallbacks) used for accent- and
424
505
  * case-insensitive text filtering / sorting / search. Powered by
@@ -1,49 +1,49 @@
1
- import { describe, expect, it } from 'vitest'
2
- import {
3
- getGridCellA11yProps,
4
- getGridHeaderA11yProps,
5
- getGridRootA11yProps,
6
- getGridRowA11yProps,
7
- } from './a11y'
8
-
9
- function attrsToHtml(attrs: Record<string, string | number | boolean>) {
10
- return Object.entries(attrs)
11
- .map(([key, value]) => `${key}="${String(value)}"`)
12
- .join(' ')
13
- }
14
-
15
- describe('a11y contract rendering', () => {
16
- it('builds expected headless markup attributes', () => {
17
- const root = getGridRootA11yProps()
18
- const header = getGridHeaderA11yProps({
19
- sortable: true,
20
- sortDirection: 'descending',
21
- })
22
- const row = getGridRowA11yProps(1)
23
- const cell = getGridCellA11yProps({
24
- rowIndex: 1,
25
- colIndex: 2,
26
- selected: true,
27
- })
28
-
29
- const html = [
30
- `<table ${attrsToHtml(root)}><thead>`,
31
- `<tr ${attrsToHtml(row)}>`,
32
- `<th ${attrsToHtml(header)}>Age</th>`,
33
- `</tr></thead><tbody>`,
34
- `<tr ${attrsToHtml(row)}><td ${attrsToHtml(cell)}>42</td></tr>`,
35
- `</tbody></table>`,
36
- ].join('')
37
-
38
- expect(html).toContain('role="grid"')
39
- expect(html).toContain('role="columnheader"')
40
- expect(html).toContain('aria-sort="descending"')
41
- expect(html).toContain('aria-selected="true"')
42
- expect(html).toContain('aria-colindex="2"')
43
- })
44
-
45
- it('creates stable active descendant ids', async () => {
46
- const { getGridCellDomId } = await import('./a11y')
47
- expect(getGridCellDomId('svgrid', 3, 4)).toBe('svgrid_cell_3_4')
48
- })
49
- })
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ getGridCellA11yProps,
4
+ getGridHeaderA11yProps,
5
+ getGridRootA11yProps,
6
+ getGridRowA11yProps,
7
+ } from './a11y'
8
+
9
+ function attrsToHtml(attrs: Record<string, string | number | boolean>) {
10
+ return Object.entries(attrs)
11
+ .map(([key, value]) => `${key}="${String(value)}"`)
12
+ .join(' ')
13
+ }
14
+
15
+ describe('a11y contract rendering', () => {
16
+ it('builds expected headless markup attributes', () => {
17
+ const root = getGridRootA11yProps()
18
+ const header = getGridHeaderA11yProps({
19
+ sortable: true,
20
+ sortDirection: 'descending',
21
+ })
22
+ const row = getGridRowA11yProps(1)
23
+ const cell = getGridCellA11yProps({
24
+ rowIndex: 1,
25
+ colIndex: 2,
26
+ selected: true,
27
+ })
28
+
29
+ const html = [
30
+ `<table ${attrsToHtml(root)}><thead>`,
31
+ `<tr ${attrsToHtml(row)}>`,
32
+ `<th ${attrsToHtml(header)}>Age</th>`,
33
+ `</tr></thead><tbody>`,
34
+ `<tr ${attrsToHtml(row)}><td ${attrsToHtml(cell)}>42</td></tr>`,
35
+ `</tbody></table>`,
36
+ ].join('')
37
+
38
+ expect(html).toContain('role="grid"')
39
+ expect(html).toContain('role="columnheader"')
40
+ expect(html).toContain('aria-sort="descending"')
41
+ expect(html).toContain('aria-selected="true"')
42
+ expect(html).toContain('aria-colindex="2"')
43
+ })
44
+
45
+ it('creates stable active descendant ids', async () => {
46
+ const { getGridCellDomId } = await import('./a11y')
47
+ expect(getGridCellDomId('svgrid', 3, 4)).toBe('svgrid_cell_3_4')
48
+ })
49
+ })
package/src/a11y.test.ts CHANGED
@@ -1,59 +1,59 @@
1
- import { describe, expect, it } from 'vitest'
2
- import {
3
- getGridCellA11yProps,
4
- getGridHeaderA11yProps,
5
- getGridRootA11yProps,
6
- getGridRowA11yProps,
7
- } from './a11y'
8
-
9
- describe('a11y helpers', () => {
10
- it('returns grid role props', () => {
11
- expect(
12
- getGridRootA11yProps({
13
- activeDescendantId: 'svgrid_cell_0_0',
14
- rowCount: 10,
15
- colCount: 5,
16
- }),
17
- ).toEqual({
18
- role: 'grid',
19
- tabindex: 0,
20
- 'aria-activedescendant': 'svgrid_cell_0_0',
21
- 'aria-rowcount': 10,
22
- 'aria-colcount': 5,
23
- })
24
- })
25
-
26
- it('returns header sort semantics', () => {
27
- expect(
28
- getGridHeaderA11yProps({
29
- sortable: true,
30
- sortDirection: 'ascending',
31
- }),
32
- ).toEqual({
33
- role: 'columnheader',
34
- 'aria-sort': 'ascending',
35
- })
36
- })
37
-
38
- it('returns row and cell indices for accessibility metadata', () => {
39
- expect(getGridRowA11yProps(2)).toEqual({
40
- role: 'row',
41
- 'aria-rowindex': 2,
42
- })
43
-
44
- expect(
45
- getGridCellA11yProps({
46
- id: 'svgrid_cell_2_3',
47
- rowIndex: 2,
48
- colIndex: 3,
49
- selected: true,
50
- }),
51
- ).toEqual({
52
- role: 'gridcell',
53
- id: 'svgrid_cell_2_3',
54
- 'aria-colindex': 3,
55
- 'aria-rowindex': 2,
56
- 'aria-selected': true,
57
- })
58
- })
59
- })
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ getGridCellA11yProps,
4
+ getGridHeaderA11yProps,
5
+ getGridRootA11yProps,
6
+ getGridRowA11yProps,
7
+ } from './a11y'
8
+
9
+ describe('a11y helpers', () => {
10
+ it('returns grid role props', () => {
11
+ expect(
12
+ getGridRootA11yProps({
13
+ activeDescendantId: 'svgrid_cell_0_0',
14
+ rowCount: 10,
15
+ colCount: 5,
16
+ }),
17
+ ).toEqual({
18
+ role: 'grid',
19
+ tabindex: 0,
20
+ 'aria-activedescendant': 'svgrid_cell_0_0',
21
+ 'aria-rowcount': 10,
22
+ 'aria-colcount': 5,
23
+ })
24
+ })
25
+
26
+ it('returns header sort semantics', () => {
27
+ expect(
28
+ getGridHeaderA11yProps({
29
+ sortable: true,
30
+ sortDirection: 'ascending',
31
+ }),
32
+ ).toEqual({
33
+ role: 'columnheader',
34
+ 'aria-sort': 'ascending',
35
+ })
36
+ })
37
+
38
+ it('returns row and cell indices for accessibility metadata', () => {
39
+ expect(getGridRowA11yProps(2)).toEqual({
40
+ role: 'row',
41
+ 'aria-rowindex': 2,
42
+ })
43
+
44
+ expect(
45
+ getGridCellA11yProps({
46
+ id: 'svgrid_cell_2_3',
47
+ rowIndex: 2,
48
+ colIndex: 3,
49
+ selected: true,
50
+ }),
51
+ ).toEqual({
52
+ role: 'gridcell',
53
+ id: 'svgrid_cell_2_3',
54
+ 'aria-colindex': 3,
55
+ 'aria-rowindex': 2,
56
+ 'aria-selected': true,
57
+ })
58
+ })
59
+ })
package/src/a11y.ts CHANGED
@@ -1,59 +1,59 @@
1
- export type GridSortDirection = 'ascending' | 'descending' | 'none' | 'other'
2
-
3
- export type GridColumnA11yInput = {
4
- sortable?: boolean
5
- sortDirection?: GridSortDirection
6
- }
7
-
8
- export type GridCellA11yInput = {
9
- colIndex?: number
10
- rowIndex?: number
11
- selected?: boolean
12
- id?: string
13
- }
14
-
15
- export function getGridRootA11yProps(input: {
16
- activeDescendantId?: string | null
17
- rowCount?: number
18
- colCount?: number
19
- } = {}) {
20
- return {
21
- role: 'grid',
22
- tabindex: 0,
23
- ...(input.activeDescendantId
24
- ? { 'aria-activedescendant': input.activeDescendantId }
25
- : {}),
26
- ...(input.rowCount !== undefined ? { 'aria-rowcount': input.rowCount } : {}),
27
- ...(input.colCount !== undefined ? { 'aria-colcount': input.colCount } : {}),
28
- } as const
29
- }
30
-
31
- export function getGridHeaderA11yProps(input: GridColumnA11yInput = {}) {
32
- const { sortable = false, sortDirection = 'none' } = input
33
- return {
34
- role: 'columnheader',
35
- ...(sortable ? { 'aria-sort': sortDirection } : {}),
36
- } as const
37
- }
38
-
39
- export function getGridRowA11yProps(rowIndex?: number) {
40
- return {
41
- role: 'row',
42
- ...(rowIndex !== undefined ? { 'aria-rowindex': rowIndex } : {}),
43
- } as const
44
- }
45
-
46
- export function getGridCellA11yProps(input: GridCellA11yInput = {}) {
47
- const { colIndex, rowIndex, selected = false, id } = input
48
- return {
49
- role: 'gridcell',
50
- ...(id ? { id } : {}),
51
- ...(colIndex !== undefined ? { 'aria-colindex': colIndex } : {}),
52
- ...(rowIndex !== undefined ? { 'aria-rowindex': rowIndex } : {}),
53
- ...(selected ? { 'aria-selected': true } : {}),
54
- } as const
55
- }
56
-
57
- export function getGridCellDomId(baseId: string, rowIndex: number, colIndex: number) {
58
- return `${baseId}_cell_${rowIndex}_${colIndex}`
59
- }
1
+ export type GridSortDirection = 'ascending' | 'descending' | 'none' | 'other'
2
+
3
+ export type GridColumnA11yInput = {
4
+ sortable?: boolean
5
+ sortDirection?: GridSortDirection
6
+ }
7
+
8
+ export type GridCellA11yInput = {
9
+ colIndex?: number
10
+ rowIndex?: number
11
+ selected?: boolean
12
+ id?: string
13
+ }
14
+
15
+ export function getGridRootA11yProps(input: {
16
+ activeDescendantId?: string | null
17
+ rowCount?: number
18
+ colCount?: number
19
+ } = {}) {
20
+ return {
21
+ role: 'grid',
22
+ tabindex: 0,
23
+ ...(input.activeDescendantId
24
+ ? { 'aria-activedescendant': input.activeDescendantId }
25
+ : {}),
26
+ ...(input.rowCount !== undefined ? { 'aria-rowcount': input.rowCount } : {}),
27
+ ...(input.colCount !== undefined ? { 'aria-colcount': input.colCount } : {}),
28
+ } as const
29
+ }
30
+
31
+ export function getGridHeaderA11yProps(input: GridColumnA11yInput = {}) {
32
+ const { sortable = false, sortDirection = 'none' } = input
33
+ return {
34
+ role: 'columnheader',
35
+ ...(sortable ? { 'aria-sort': sortDirection } : {}),
36
+ } as const
37
+ }
38
+
39
+ export function getGridRowA11yProps(rowIndex?: number) {
40
+ return {
41
+ role: 'row',
42
+ ...(rowIndex !== undefined ? { 'aria-rowindex': rowIndex } : {}),
43
+ } as const
44
+ }
45
+
46
+ export function getGridCellA11yProps(input: GridCellA11yInput = {}) {
47
+ const { colIndex, rowIndex, selected = false, id } = input
48
+ return {
49
+ role: 'gridcell',
50
+ ...(id ? { id } : {}),
51
+ ...(colIndex !== undefined ? { 'aria-colindex': colIndex } : {}),
52
+ ...(rowIndex !== undefined ? { 'aria-rowindex': rowIndex } : {}),
53
+ ...(selected ? { 'aria-selected': true } : {}),
54
+ } as const
55
+ }
56
+
57
+ export function getGridCellDomId(baseId: string, rowIndex: number, colIndex: number) {
58
+ return `${baseId}_cell_${rowIndex}_${colIndex}`
59
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Unit tests for aligned grids: two members sharing a group mirror horizontal
3
+ * scroll and column-resize widths to each other. Driven with minimal fake ctxs.
4
+ */
5
+ import { describe, expect, it } from 'vitest'
6
+ import { createAlignedGrids } from './aligned-grids'
7
+
8
+ function makeCtx(group: string | undefined) {
9
+ return {
10
+ props: { alignedGridGroup: group },
11
+ scrollContainer: { scrollLeft: 0 } as { scrollLeft: number },
12
+ columnWidths: {} as Record<string, number>,
13
+ } as Record<string, any>
14
+ }
15
+
16
+ describe('aligned grids', () => {
17
+ it('mirrors horizontal scroll to peers in the same group', () => {
18
+ const a = makeCtx('g')
19
+ const b = makeCtx('g')
20
+ const ha = createAlignedGrids(a)
21
+ const hb = createAlignedGrids(b)
22
+ const un1 = ha.register()
23
+ const un2 = hb.register()
24
+
25
+ ha.broadcastScroll(240)
26
+ expect(b.scrollContainer.scrollLeft).toBe(240)
27
+ // and the other way
28
+ hb.broadcastScroll(80)
29
+ expect(a.scrollContainer.scrollLeft).toBe(80)
30
+ un1(); un2()
31
+ })
32
+
33
+ it('mirrors column-resize widths to peers (matched by column id)', () => {
34
+ const a = makeCtx('g')
35
+ const b = makeCtx('g')
36
+ const ha = createAlignedGrids(a)
37
+ const hb = createAlignedGrids(b)
38
+ ha.register(); hb.register()
39
+
40
+ a.columnWidths = { Feb: 180 }
41
+ ha.broadcastWidths()
42
+ expect(b.columnWidths.Feb).toBe(180)
43
+ })
44
+
45
+ it('does NOT mirror across different groups', () => {
46
+ const a = makeCtx('one')
47
+ const b = makeCtx('two')
48
+ const ha = createAlignedGrids(a)
49
+ const hb = createAlignedGrids(b)
50
+ ha.register(); hb.register()
51
+
52
+ ha.broadcastScroll(200)
53
+ expect(b.scrollContainer.scrollLeft).toBe(0)
54
+ a.columnWidths = { Feb: 999 }
55
+ ha.broadcastWidths()
56
+ expect(b.columnWidths.Feb).toBeUndefined()
57
+ })
58
+
59
+ it('is inert with no group set, and after unregister', () => {
60
+ const a = makeCtx(undefined)
61
+ const b = makeCtx(undefined)
62
+ const ha = createAlignedGrids(a)
63
+ const hb = createAlignedGrids(b)
64
+ ha.register(); hb.register()
65
+ ha.broadcastScroll(120)
66
+ expect(b.scrollContainer.scrollLeft).toBe(0)
67
+
68
+ // Grouped, then unregistered -> no longer receives updates.
69
+ const c = makeCtx('gg')
70
+ const d = makeCtx('gg')
71
+ const hc = createAlignedGrids(c)
72
+ const hd = createAlignedGrids(d)
73
+ const unc = hc.register()
74
+ const und = hd.register()
75
+ und() // d leaves the group
76
+ hc.broadcastScroll(55)
77
+ expect(d.scrollContainer.scrollLeft).toBe(0)
78
+ unc()
79
+ })
80
+ })
@@ -0,0 +1,87 @@
1
+ // Aligned grids - keep two or more independent grids in lockstep on horizontal
2
+ // scroll and column widths. Grids that share the same `alignedGridGroup` string
3
+ // register with a module-level registry (same pattern as managed row dragging);
4
+ // when one scrolls horizontally or a column is resized, the others follow.
5
+ //
6
+ // Loop-safety without flags: scroll sync no-ops when the target already has the
7
+ // value (so the echo scroll event never fires); width sync compares a serialized
8
+ // snapshot and pre-seeds it on apply, so the reactive effect that fires after
9
+ // applying a peer's widths sees "no change" and doesn't echo.
10
+
11
+ type Member = {
12
+ id: number;
13
+ applyScroll: (left: number) => void;
14
+ applyWidths: (widths: Record<string, number>) => void;
15
+ };
16
+
17
+ // Shared across every grid in the bundle.
18
+ const groups = new Map<string, Map<number, Member>>();
19
+ let seq = 0;
20
+
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ export function createAlignedGrids<TFeatures, TData>(ctx: any) {
23
+ const id = ++seq;
24
+ let lastWidthsSerial = "";
25
+
26
+ const group = (): string | null => ctx.props.alignedGridGroup ?? null;
27
+
28
+ function peers(): Member[] {
29
+ const g = group();
30
+ if (!g) return [];
31
+ const m = groups.get(g);
32
+ if (!m) return [];
33
+ const out: Member[] = [];
34
+ for (const member of m.values()) if (member.id !== id) out.push(member);
35
+ return out;
36
+ }
37
+
38
+ const self: Member = {
39
+ id,
40
+ applyScroll(left: number) {
41
+ const el = ctx.scrollContainer as HTMLElement | null;
42
+ // Only set when it differs, so no echo scroll event fires -> no loop.
43
+ if (el && el.scrollLeft !== left) el.scrollLeft = left;
44
+ },
45
+ applyWidths(widths: Record<string, number>) {
46
+ const merged = { ...(ctx.columnWidths as Record<string, number>), ...widths };
47
+ // Pre-seed the serial so the columnWidths effect that fires after this
48
+ // set sees an unchanged snapshot and skips its own broadcast.
49
+ lastWidthsSerial = JSON.stringify(merged);
50
+ ctx.columnWidths = merged;
51
+ },
52
+ };
53
+
54
+ // Register on mount; returns the unregister for the effect's cleanup.
55
+ function register(): () => void {
56
+ const g = group();
57
+ if (!g) return () => {};
58
+ let m = groups.get(g);
59
+ if (!m) {
60
+ m = new Map();
61
+ groups.set(g, m);
62
+ }
63
+ m.set(id, self);
64
+ return () => {
65
+ const mm = groups.get(g);
66
+ if (!mm) return;
67
+ mm.delete(id);
68
+ if (!mm.size) groups.delete(g);
69
+ };
70
+ }
71
+
72
+ function broadcastScroll(left: number) {
73
+ const p = peers();
74
+ for (const member of p) member.applyScroll(left);
75
+ }
76
+
77
+ function broadcastWidths() {
78
+ if (!group()) return;
79
+ const serial = JSON.stringify((ctx.columnWidths as Record<string, number>) ?? {});
80
+ if (serial === lastWidthsSerial) return; // nothing new (or echo of an apply)
81
+ lastWidthsSerial = serial;
82
+ const w = ctx.columnWidths as Record<string, number>;
83
+ for (const member of peers()) member.applyWidths(w);
84
+ }
85
+
86
+ return { register, broadcastScroll, broadcastWidths, alignedGridId: id };
87
+ }