@svgrid/grid 2.6.3 → 2.6.5

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 (66) hide show
  1. package/dist/SvGrid.controller.svelte.d.ts +2 -1
  2. package/dist/SvGrid.controller.svelte.js +145 -3
  3. package/dist/SvGrid.css +32 -0
  4. package/dist/SvGrid.svelte +64 -6
  5. package/dist/SvGrid.types.d.ts +61 -0
  6. package/dist/a11y/grid-announcements.d.ts +58 -0
  7. package/dist/a11y/grid-announcements.js +70 -0
  8. package/dist/advanced-filter.svelte.d.ts +42 -0
  9. package/dist/advanced-filter.svelte.js +13 -0
  10. package/dist/build-api.js +44 -2
  11. package/dist/cdn/{GridMenus-BoVKgAv8.js → GridMenus-CBvjL5u4.js} +14 -14
  12. package/dist/cdn/{GridMenus-DL1zrDwf.js → GridMenus-DAzp6YXc.js} +14 -14
  13. package/dist/cdn/{src-M2NfKnjX.js → src-3WKN7L4G.js} +5932 -5743
  14. package/dist/cdn/{src-CEpw6Ato.js → src-Cl7h5-Ux.js} +3371 -3182
  15. package/dist/cdn/svgrid.js +8 -8
  16. package/dist/cdn/svgrid.svelte-external.js +8 -8
  17. package/dist/filter-operators.d.ts +2 -1
  18. package/dist/filter-operators.js +5 -6
  19. package/dist/filtering/excel-filters.d.ts +1 -0
  20. package/dist/filtering/excel-filters.js +5 -0
  21. package/dist/filtering/filter-operator-catalogue.d.ts +42 -0
  22. package/dist/filtering/filter-operator-catalogue.js +42 -0
  23. package/dist/filtering/predicate-expr.d.ts +78 -0
  24. package/dist/filtering/predicate-expr.js +1 -0
  25. package/dist/filtering/row-predicate.d.ts +17 -0
  26. package/dist/filtering/row-predicate.js +55 -0
  27. package/dist/grid-messages.d.ts +7 -0
  28. package/dist/grid-messages.js +7 -0
  29. package/dist/index.d.ts +4 -0
  30. package/dist/index.js +5 -0
  31. package/dist/server-data-source.d.ts +34 -0
  32. package/dist/server-data-source.js +19 -0
  33. package/dist/svgrid-wrapper.types.d.ts +37 -5
  34. package/dist/themes/index.js +12 -4
  35. package/package.json +2 -1
  36. package/src/SvGrid.controller.svelte.ts +169 -2
  37. package/src/SvGrid.css +32 -0
  38. package/src/SvGrid.svelte +64 -6
  39. package/src/SvGrid.types.ts +61 -0
  40. package/src/a11y/grid-announcements.test.ts +78 -0
  41. package/src/a11y/grid-announcements.ts +94 -0
  42. package/src/a11y.announce.test.ts +250 -0
  43. package/src/a11y.axe.test.ts +194 -0
  44. package/src/advanced-filter.svelte.ts +59 -0
  45. package/src/build-api.ts +43 -2
  46. package/src/filter-operators.ts +9 -6
  47. package/src/filtering/excel-filters.ts +15 -0
  48. package/src/filtering/filter-operator-catalogue.ts +74 -0
  49. package/src/filtering/node-entry.test.ts +75 -0
  50. package/src/filtering/predicate-expr.ts +50 -0
  51. package/src/filtering/row-predicate.ts +57 -0
  52. package/src/grid-messages.ts +18 -0
  53. package/src/index.ts +28 -0
  54. package/src/server-data-source.test.ts +94 -0
  55. package/src/server-data-source.ts +56 -0
  56. package/src/svgrid-wrapper.types.ts +37 -5
  57. package/src/svgrid.advanced-filter.test.ts +481 -0
  58. package/src/svgrid.displayed-rows.test.ts +144 -0
  59. package/src/svgrid.group-props-reactive.svelte.test.ts +151 -0
  60. package/src/svgrid.group-props.test.ts +280 -0
  61. package/src/themes/contrast.test.ts +112 -0
  62. package/src/themes/index.ts +12 -4
  63. package/themes/catppuccin.css +3 -3
  64. package/themes/dracula.css +3 -3
  65. package/themes/nord.css +3 -3
  66. package/themes/notion.css +4 -4
package/src/SvGrid.svelte CHANGED
@@ -1900,8 +1900,29 @@
1900
1900
  </label>
1901
1901
  {/if}
1902
1902
 
1903
- {#if toolPanelEnabled || ctrl.chartingEnabled}
1903
+ {#if toolPanelEnabled || ctrl.chartingEnabled || ctrl.advancedFilter}
1904
1904
  <div class="sv-grid-toolbar">
1905
+ {#if ctrl.advancedFilter}
1906
+ <!--
1907
+ An advanced filter is set from a panel the consumer mounts, which
1908
+ may be scrolled away or in a drawer. Without something here, rows
1909
+ are missing from the grid with nothing on screen to explain why or
1910
+ to undo it. This is that something: state plus a way out.
1911
+ -->
1912
+ <span class="sv-grid-advf-chip">
1913
+ <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1914
+ <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
1915
+ </svg>
1916
+ {messages.advancedFilterActive}
1917
+ <button
1918
+ type="button"
1919
+ class="sv-grid-advf-clear"
1920
+ aria-label={messages.advancedFilterClear}
1921
+ title={messages.advancedFilterClear}
1922
+ onclick={() => (ctrl.advancedFilter = null)}
1923
+ >✕</button>
1924
+ </span>
1925
+ {/if}
1905
1926
  {#if toolPanelEnabled}
1906
1927
  <button
1907
1928
  type="button"
@@ -2332,6 +2353,18 @@
2332
2353
  aria-orientation="vertical"
2333
2354
  aria-label={`Resize ${toolPanelHeaderLabel(header.column)}`}
2334
2355
  tabindex="0"
2356
+ {...(() => {
2357
+ // A focusable separator is a widget, so ARIA requires
2358
+ // aria-valuenow. The value it exposes is the column
2359
+ // width the arrow keys change; 40 is the floor
2360
+ // enforced in resizeColumnByKeyboard.
2361
+ const w = Math.round(ctrl.getColumnWidth(header.column.id));
2362
+ return {
2363
+ "aria-valuenow": w,
2364
+ "aria-valuemin": 40,
2365
+ "aria-valuetext": `${w} pixels`,
2366
+ };
2367
+ })()}
2335
2368
  onpointerdown={(event) =>
2336
2369
  startColumnResize(event, header.column.id)}
2337
2370
  onkeydown={(event) =>
@@ -2359,9 +2392,14 @@
2359
2392
  ></th>
2360
2393
  {/if}
2361
2394
  {#if showRowSelectionEffective}
2395
+ <!-- Filter-row spacer over the selection column. Blank and
2396
+ non-interactive, so hide it from the a11y tree the same
2397
+ way the header-row twin does; an exposed empty th is an
2398
+ axe `empty-table-header` violation. -->
2362
2399
  <th
2363
2400
  class="sv-grid-column sv-grid-selection-column"
2364
2401
  style={`width: ${selectionColumnWidth}px; min-width: ${selectionColumnWidth}px; max-width: ${selectionColumnWidth}px; left: ${showRowNumbersEffective ? rowNumberColumnWidth : 0}px;`}
2402
+ aria-hidden="true"
2365
2403
  ></th>
2366
2404
  {/if}
2367
2405
  {#if columnVirtualizationEnabled && columnWindowStart > 0}
@@ -2688,6 +2726,7 @@
2688
2726
  class:sv-grid-cell-cf={hasConditionalFormats}
2689
2727
  class:sv-grid-cell-invalid={cellValidity.invalid}
2690
2728
  class:sv-grid-cell-has-note={cellNote != null}
2729
+ aria-invalid={cellValidity.invalid ? "true" : undefined}
2691
2730
  title={cellValidity.message ?? undefined}
2692
2731
  data-svgrid-row={rowIndex}
2693
2732
  data-svgrid-col={colIndex}
@@ -2935,6 +2974,7 @@
2935
2974
  class:sv-grid-cell-cf={hasConditionalFormats}
2936
2975
  class:sv-grid-cell-invalid={cellValidity.invalid}
2937
2976
  class:sv-grid-cell-has-note={cellNote != null}
2977
+ aria-invalid={cellValidity.invalid ? "true" : undefined}
2938
2978
  data-svgrid-row={rowIndex}
2939
2979
  data-svgrid-col={colIndex}
2940
2980
  data-col-id={rendered.column.id}
@@ -3008,6 +3048,20 @@
3008
3048
  cellValue,
3009
3049
  )}
3010
3050
  {/if}
3051
+ {#if cellValidity.invalid && cellValidity.message}
3052
+ <!-- The validation message, for assistive tech.
3053
+ On this path the message is shown visually by
3054
+ the pointerenter tooltip above, which is mouse-
3055
+ only - a keyboard or screen-reader user would
3056
+ get `aria-invalid` with no reason attached.
3057
+ A visually-hidden span inside the cell is read
3058
+ as part of the cell when focus lands on it, and
3059
+ unlike `title` it adds no second native tooltip
3060
+ competing with the custom one. -->
3061
+ <span class="sv-grid-sr-only"
3062
+ >{cellValidity.message}</span
3063
+ >
3064
+ {/if}
3011
3065
  {#if cellNote != null && !isEditing}
3012
3066
  <!-- Excel-style per-cell note indicator. The
3013
3067
  triangle itself is the hot-zone; hover
@@ -3067,17 +3121,21 @@
3067
3121
  {#if showRowNumbersEffective}
3068
3122
  <!-- Row-number column has no aggregate; the digit it normally
3069
3123
  shows is the row index, which doesn't make sense to sum. -->
3070
- <th
3124
+ <!-- Blank spacer, not a header - see the selection cell below. -->
3125
+ <td
3071
3126
  class="sv-grid-column sv-grid-summary-column sv-grid-row-number-column"
3072
3127
  style={`width: ${rowNumberColumnWidth}px; min-width: ${rowNumberColumnWidth}px; max-width: ${rowNumberColumnWidth}px; left: 0;`}
3073
- ></th>
3128
+ ></td>
3074
3129
  {/if}
3075
3130
  {#if showRowSelectionEffective}
3076
- <!-- Selection column is checkbox-only; no aggregate. -->
3077
- <th
3131
+ <!-- Selection column is checkbox-only; no aggregate. A blank
3132
+ spacer in the summary row carries no header semantics, so
3133
+ it is a td: an empty th is a real a11y violation
3134
+ (axe `empty-table-header`). -->
3135
+ <td
3078
3136
  class="sv-grid-column sv-grid-summary-column sv-grid-selection-column"
3079
3137
  style={`width: ${selectionColumnWidth}px; min-width: ${selectionColumnWidth}px; max-width: ${selectionColumnWidth}px; left: ${showRowNumbersEffective ? rowNumberColumnWidth : 0}px;`}
3080
- ></th>
3138
+ ></td>
3081
3139
  {/if}
3082
3140
  {#if columnVirtualizationEnabled && columnWindowStart > 0}
3083
3141
  <th
@@ -12,6 +12,7 @@ import type { ConditionalFormat } from "./conditional-formatting";
12
12
  import type { GroupDisplayType } from "./group-display";
13
13
  import type { GridMessages } from "./grid-messages";
14
14
  import type { GridPivotConfig } from "./pivot-view.svelte";
15
+ import type { GridPredicateExpr } from "./filtering/predicate-expr";
15
16
  import type { MenuItem } from "./SvMenuList.svelte";
16
17
 
17
18
  /** The calendar views the scheduler can render. {@link SchedulerConfig}. The
@@ -1060,6 +1061,44 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
1060
1061
  indentPx?: number;
1061
1062
  };
1062
1063
  groupable?: boolean;
1064
+ /**
1065
+ * Group the rows by these column ids, outermost first - `['region',
1066
+ * 'country']` rolls country up inside region.
1067
+ *
1068
+ * ```svelte
1069
+ * <SvGrid {data} {columns} {features} groupBy={['department']} />
1070
+ * ```
1071
+ *
1072
+ * The prop seeds the group-by list and re-applies whenever it changes, so it
1073
+ * works both as initial state and as a controlled value. The column menu and
1074
+ * `api.setGroupBy()` write the same state; a change from either survives
1075
+ * until this prop's own value changes.
1076
+ *
1077
+ * Ignored when `treeData` is set - a row cannot be both a hierarchy node and
1078
+ * bucketed under a group banner.
1079
+ */
1080
+ groupBy?: ReadonlyArray<string>;
1081
+ /**
1082
+ * Which group / tree rows are expanded, keyed by row id.
1083
+ *
1084
+ * Expansion is owned by the engine by default; pass this prop to hoist it
1085
+ * (saved views, "expand everything on load", persisted UI state). Like
1086
+ * `groupBy` it seeds the state and re-applies whenever it changes.
1087
+ *
1088
+ * Group row ids are built from the grouping path, not the display label:
1089
+ * `group_department_Engineering`, and one level deeper
1090
+ * `group_department_Engineering_role_Senior`. Tree rows key off the engine's
1091
+ * row id instead (set `getRowId` to make that your own id). Prefer capturing
1092
+ * the map from `onExpandedChange` over hand-building these keys.
1093
+ */
1094
+ expanded?: Record<string, boolean>;
1095
+ /**
1096
+ * Fired whenever the expanded set changes - chevron clicks,
1097
+ * `api.setRowExpanded()`, `expandAllGroups()` / `collapseAllGroups()`.
1098
+ * Receives the full next map, so it can be written straight back into
1099
+ * `expanded` for a controlled setup.
1100
+ */
1101
+ onExpandedChange?: (expanded: Record<string, boolean>) => void;
1063
1102
  /**
1064
1103
  * Render a subtotal row after each group's children, carrying that group's
1065
1104
  * aggregate values under the columns they belong to (the columns with an
@@ -1369,6 +1408,28 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
1369
1408
  * sorted data on first render. Applied once at mount; user sorting takes over.
1370
1409
  */
1371
1410
  initialSorting?: Array<{ id: string; desc: boolean }>;
1411
+ /**
1412
+ * The advanced-filter expression to start in (Pro). Applied once at mount;
1413
+ * change it afterwards through `api.setAdvancedFilter()`.
1414
+ *
1415
+ * Filtering only happens once `@svgrid/enterprise`'s `enableAdvancedFilter()`
1416
+ * has registered a compiler - without it the expression is stored and
1417
+ * reported but no rows are removed, so the free grid degrades to a no-op
1418
+ * rather than to a wrong row set.
1419
+ */
1420
+ initialAdvancedFilter?: GridPredicateExpr | null;
1421
+ /**
1422
+ * Fires whenever the advanced-filter expression changes, whatever changed it:
1423
+ * `api.setAdvancedFilter()`, `clearAllFilters()`, or the toolbar's clear
1424
+ * control.
1425
+ *
1426
+ * The panel that authors the expression is mounted by you, outside the grid,
1427
+ * so it cannot see a change the grid made on its own. Without this, clearing
1428
+ * from the toolbar leaves that panel showing a filter the grid is no longer
1429
+ * applying. Feed this back into the panel's `expression` prop to keep them
1430
+ * agreeing.
1431
+ */
1432
+ onAdvancedFilterChange?: (expr: GridPredicateExpr | null) => void;
1372
1433
  /**
1373
1434
  * When `true`, the grid still records column-filter / global-filter /
1374
1435
  * facet state (so the menu UI works and indicators light up) but does
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ formatMessage,
4
+ filterAnnouncement,
5
+ selectionAnnouncement,
6
+ } from './grid-announcements'
7
+ import { defaultGridMessages } from '../grid-messages'
8
+
9
+ const m = defaultGridMessages
10
+
11
+ describe('formatMessage', () => {
12
+ it('substitutes placeholders', () => {
13
+ expect(formatMessage('{a} of {b}', { a: 1, b: 2 })).toBe('1 of 2')
14
+ })
15
+
16
+ it('leaves an unknown placeholder alone rather than blanking it', () => {
17
+ // A typo in a consumer override should be visible, not silently swallow
18
+ // the number it was meant to carry.
19
+ expect(formatMessage('{visible} of {totl}', { visible: 3 })).toBe('3 of {totl}')
20
+ })
21
+
22
+ it('substitutes a placeholder used more than once', () => {
23
+ expect(formatMessage('{n}/{n}', { n: 7 })).toBe('7/7')
24
+ })
25
+ })
26
+
27
+ describe('filterAnnouncement', () => {
28
+ it('reports the match count while a filter is active', () => {
29
+ expect(filterAnnouncement(12, 250, true, false, m)).toBe(
30
+ '12 of 250 rows match the current filters',
31
+ )
32
+ })
33
+
34
+ it('has a distinct message for zero matches', () => {
35
+ // "0 of 250 rows match" is technically true but reads as a broken counter.
36
+ expect(filterAnnouncement(0, 250, true, false, m)).toBe(
37
+ 'No rows match the current filters',
38
+ )
39
+ })
40
+
41
+ it('confirms that clearing the filters took effect', () => {
42
+ expect(filterAnnouncement(250, 250, false, true, m)).toBe(
43
+ 'Filters cleared, showing all 250 rows',
44
+ )
45
+ })
46
+
47
+ it('stays silent when no filter is or was active', () => {
48
+ // Replacing the data array must not make the grid announce a count nobody
49
+ // asked for.
50
+ expect(filterAnnouncement(250, 250, false, false, m)).toBeNull()
51
+ })
52
+ })
53
+
54
+ describe('selectionAnnouncement', () => {
55
+ it('announces select-all', () => {
56
+ expect(selectionAnnouncement(0, 250, m)).toBe('250 rows selected')
57
+ })
58
+
59
+ it('announces clearing a multi-row selection', () => {
60
+ expect(selectionAnnouncement(250, 0, m)).toBe('Selection cleared')
61
+ })
62
+
63
+ it('stays silent for a single-row change', () => {
64
+ // Focus moves to the row and the reader announces it from aria-selected;
65
+ // a second announcement would talk over that.
66
+ expect(selectionAnnouncement(0, 1, m)).toBeNull()
67
+ expect(selectionAnnouncement(3, 4, m)).toBeNull()
68
+ expect(selectionAnnouncement(1, 0, m)).toBeNull()
69
+ })
70
+
71
+ it('stays silent when the count did not change', () => {
72
+ expect(selectionAnnouncement(5, 5, m)).toBeNull()
73
+ })
74
+
75
+ it('announces a shrinking range selection', () => {
76
+ expect(selectionAnnouncement(10, 4, m)).toBe('4 rows selected')
77
+ })
78
+ })
@@ -0,0 +1,94 @@
1
+ /**
2
+ * grid-announcements - what the grid says to a screen reader, and when.
3
+ *
4
+ * WCAG 4.1.3 covers *status messages*: information the user needs that does not
5
+ * receive focus. That is a narrow set, and getting the boundary right matters
6
+ * more than announcing a lot.
7
+ *
8
+ * Things the grid deliberately does NOT announce here, because the
9
+ * accessibility tree already conveys them when focus lands:
10
+ *
11
+ * - the value of the cell you moved to (roving tabindex moves focus)
12
+ * - the sort state of a header (`aria-sort` on the columnheader)
13
+ * - whether the row you are on is selected (`aria-selected` on the row)
14
+ *
15
+ * Announcing those again would make the grid talk over itself. What is left is
16
+ * genuinely invisible to a screen reader: how many rows survived a filter, and
17
+ * bulk selection changes that move no focus (select-all, clear).
18
+ *
19
+ * These builders are pure so they can be unit-tested without a DOM, and take
20
+ * their strings from `GridMessages` so an announcement is translated like any
21
+ * other label.
22
+ */
23
+
24
+ /** The subset of `GridMessages` this module reads. */
25
+ export type AnnouncementMessages = {
26
+ announceFilterResults: string
27
+ announceNoMatches: string
28
+ announceFiltersCleared: string
29
+ announceRowsSelected: string
30
+ announceSelectionCleared: string
31
+ }
32
+
33
+ /**
34
+ * Interpolate `{name}` placeholders.
35
+ *
36
+ * The rest of `GridMessages` is single words assembled by the caller (`of`,
37
+ * `rowsSuffix`). That works for labels but not for sentences: languages order
38
+ * "12 of 250 match" differently, and a translator handed three separate atoms
39
+ * cannot fix the order. Announcements are whole sentences, so they get whole
40
+ * templates. An unknown placeholder is left alone rather than blanked, so a
41
+ * typo in an override is visible instead of silently eating the number.
42
+ */
43
+ export function formatMessage(
44
+ template: string,
45
+ vars: Record<string, string | number>,
46
+ ): string {
47
+ return template.replace(/\{(\w+)\}/g, (whole, key: string) =>
48
+ key in vars ? String(vars[key]) : whole,
49
+ )
50
+ }
51
+
52
+ /**
53
+ * What to say after the row set changes, or null to stay quiet.
54
+ *
55
+ * `visible` is the count after filtering but BEFORE pagination - the answer to
56
+ * "how many rows matched", not "how many are on this page".
57
+ */
58
+ export function filterAnnouncement(
59
+ visible: number,
60
+ total: number,
61
+ filtersActive: boolean,
62
+ filtersWereActive: boolean,
63
+ messages: AnnouncementMessages,
64
+ ): string | null {
65
+ if (filtersActive) {
66
+ if (visible === 0) return messages.announceNoMatches
67
+ return formatMessage(messages.announceFilterResults, { visible, total })
68
+ }
69
+ // Clearing a filter is itself a status change worth confirming; without this
70
+ // the user hears nothing back and cannot tell the clear took effect.
71
+ if (filtersWereActive) {
72
+ return formatMessage(messages.announceFiltersCleared, { total })
73
+ }
74
+ return null
75
+ }
76
+
77
+ /**
78
+ * What to say after the selection changes, or null to stay quiet.
79
+ *
80
+ * Only BULK changes are announced. Selecting one row moves focus to it and the
81
+ * screen reader reads it as selected from `aria-selected`; announcing "1 row
82
+ * selected" on top of that is the grid talking over itself. A change of more
83
+ * than one row cannot have come from a single focus move, so it is the
84
+ * select-all / range / clear case that otherwise passes silently.
85
+ */
86
+ export function selectionAnnouncement(
87
+ previousCount: number,
88
+ count: number,
89
+ messages: AnnouncementMessages,
90
+ ): string | null {
91
+ if (Math.abs(count - previousCount) <= 1) return null
92
+ if (count === 0) return messages.announceSelectionCleared
93
+ return formatMessage(messages.announceRowsSelected, { count })
94
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Verifies the grid actually SPEAKS - that the announcement builders are wired
3
+ * to a real live region and fire on the state changes they were written for.
4
+ *
5
+ * `a11y/grid-announcements.test.ts` proves the message text is right. This file
6
+ * proves the wiring exists, which is the part that was missing: `announce()`
7
+ * shipped and was exported for months while `<SvGrid>` never called it, so the
8
+ * documented "filter and selection announcements" were not happening at all.
9
+ */
10
+ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
11
+ import { mount, unmount, flushSync } from 'svelte'
12
+ import SvGrid from './SvGrid.svelte'
13
+ import { _resetLiveRegions } from './a11y/live-region'
14
+ import {
15
+ tableFeatures,
16
+ columnFilteringFeature,
17
+ rowSortingFeature,
18
+ rowSelectionFeature,
19
+ createCoreRowModel,
20
+ createFilteredRowModel,
21
+ createSortedRowModel,
22
+ } from './index'
23
+ import type { ColumnDef, SvGridApi } from './index'
24
+
25
+ type Row = { id: string; name: string; team: string }
26
+
27
+ const features = tableFeatures({
28
+ columnFilteringFeature,
29
+ rowSortingFeature,
30
+ rowSelectionFeature,
31
+ })
32
+
33
+ const rows: Row[] = [
34
+ { id: 'r1', name: 'Ada', team: 'Platform' },
35
+ { id: 'r2', name: 'Grace', team: 'Compilers' },
36
+ { id: 'r3', name: 'Karen', team: 'Search' },
37
+ { id: 'r4', name: 'Barbara', team: 'Platform' },
38
+ ]
39
+
40
+ const cols: ColumnDef<typeof features, Row>[] = [
41
+ { field: 'name', header: 'Name', width: 200 },
42
+ { field: 'team', header: 'Team', width: 140 },
43
+ ]
44
+
45
+ /** Text currently in the polite live region. */
46
+ const politeText = () =>
47
+ document.querySelector('[aria-live="polite"]')?.textContent ?? ''
48
+
49
+ /** `announce()` sets the text on a microtask so a repeat message re-fires. */
50
+ const settle = async () => {
51
+ await Promise.resolve()
52
+ await Promise.resolve()
53
+ }
54
+
55
+ function mountGrid(extraProps: Record<string, unknown> = {}) {
56
+ const target = document.createElement('div')
57
+ document.body.appendChild(target)
58
+ let api: SvGridApi<typeof features, Row> | null = null
59
+ const app = mount(SvGrid, {
60
+ target,
61
+ props: {
62
+ data: rows,
63
+ columns: cols,
64
+ features,
65
+ _rowModels: {
66
+ coreRowModel: createCoreRowModel(),
67
+ filteredRowModel: createFilteredRowModel(),
68
+ sortedRowModel: createSortedRowModel({} as never),
69
+ },
70
+ containerHeight: 300,
71
+ virtualization: false,
72
+ columnVirtualization: false,
73
+ onApiReady: (a: SvGridApi<typeof features, Row>) => {
74
+ api = a
75
+ },
76
+ ...extraProps,
77
+ } as never,
78
+ })
79
+ flushSync()
80
+ return {
81
+ target,
82
+ get api() {
83
+ return api!
84
+ },
85
+ destroy: () => {
86
+ unmount(app)
87
+ target.remove()
88
+ },
89
+ }
90
+ }
91
+
92
+ beforeEach(() => {
93
+ vi.useFakeTimers()
94
+ _resetLiveRegions()
95
+ })
96
+
97
+ afterEach(() => {
98
+ vi.useRealTimers()
99
+ _resetLiveRegions()
100
+ })
101
+
102
+ describe('filter announcements', () => {
103
+ it('says nothing on mount', async () => {
104
+ const g = mountGrid()
105
+ try {
106
+ await vi.advanceTimersByTimeAsync(500)
107
+ // An unfiltered grid announcing "4 of 4 rows match" would be noise on
108
+ // every page load.
109
+ expect(politeText()).toBe('')
110
+ } finally {
111
+ g.destroy()
112
+ }
113
+ })
114
+
115
+ it('reports the match count after a filter narrows the rows', async () => {
116
+ const g = mountGrid()
117
+ try {
118
+ g.api.setState({ globalFilter: 'Platform' })
119
+ flushSync()
120
+ await vi.advanceTimersByTimeAsync(500)
121
+ await settle()
122
+ expect(politeText()).toBe('2 of 4 rows match the current filters')
123
+ } finally {
124
+ g.destroy()
125
+ }
126
+ })
127
+
128
+ it('reports no matches distinctly', async () => {
129
+ const g = mountGrid()
130
+ try {
131
+ g.api.setState({ globalFilter: 'nothing-matches-this' })
132
+ flushSync()
133
+ await vi.advanceTimersByTimeAsync(500)
134
+ await settle()
135
+ expect(politeText()).toBe('No rows match the current filters')
136
+ } finally {
137
+ g.destroy()
138
+ }
139
+ })
140
+
141
+ it('debounces so only the final count is announced while typing', async () => {
142
+ const g = mountGrid()
143
+ try {
144
+ // Three keystrokes in quick succession, as a user typing "Pla".
145
+ for (const s of ['P', 'Pl', 'Pla']) {
146
+ g.api.setState({ globalFilter: s })
147
+ flushSync()
148
+ await vi.advanceTimersByTimeAsync(50)
149
+ }
150
+ await vi.advanceTimersByTimeAsync(500)
151
+ await settle()
152
+ // A polite region queues rather than replaces, so without the debounce
153
+ // the user would sit through the count for every prefix.
154
+ expect(politeText()).toBe('2 of 4 rows match the current filters')
155
+ } finally {
156
+ g.destroy()
157
+ }
158
+ })
159
+
160
+ it('confirms that clearing the filters took effect', async () => {
161
+ const g = mountGrid()
162
+ try {
163
+ g.api.setState({ globalFilter: 'Platform' })
164
+ flushSync()
165
+ await vi.advanceTimersByTimeAsync(500)
166
+ g.api.clearAllFilters()
167
+ flushSync()
168
+ await vi.advanceTimersByTimeAsync(500)
169
+ await settle()
170
+ expect(politeText()).toBe('Filters cleared, showing all 4 rows')
171
+ } finally {
172
+ g.destroy()
173
+ }
174
+ })
175
+
176
+ it('stays silent in external-filter mode', async () => {
177
+ // The server decided what matched; the local count describes only the page
178
+ // in hand, so announcing it would misreport the result set.
179
+ const g = mountGrid({ externalFilter: true })
180
+ try {
181
+ g.api.setState({ globalFilter: 'Platform' })
182
+ flushSync()
183
+ await vi.advanceTimersByTimeAsync(500)
184
+ expect(politeText()).toBe('')
185
+ } finally {
186
+ g.destroy()
187
+ }
188
+ })
189
+ })
190
+
191
+ describe('selection announcements', () => {
192
+ it('announces select-all', async () => {
193
+ const g = mountGrid()
194
+ try {
195
+ g.api.selectAllRows()
196
+ flushSync()
197
+ await settle()
198
+ expect(politeText()).toBe('4 rows selected')
199
+ } finally {
200
+ g.destroy()
201
+ }
202
+ })
203
+
204
+ it('announces clearing a multi-row selection', async () => {
205
+ const g = mountGrid()
206
+ try {
207
+ g.api.selectAllRows()
208
+ flushSync()
209
+ await settle()
210
+ g.api.clearRowSelection()
211
+ flushSync()
212
+ await settle()
213
+ expect(politeText()).toBe('Selection cleared')
214
+ } finally {
215
+ g.destroy()
216
+ }
217
+ })
218
+
219
+ it('stays silent when a single row is selected', async () => {
220
+ const g = mountGrid()
221
+ try {
222
+ g.api.selectRows(['r1'])
223
+ flushSync()
224
+ await settle()
225
+ // Focus lands on the row and the reader announces it from aria-selected.
226
+ expect(politeText()).toBe('')
227
+ } finally {
228
+ g.destroy()
229
+ }
230
+ })
231
+ })
232
+
233
+ describe('announcements are localizable', () => {
234
+ it('uses a consumer-supplied template, placeholders and all', async () => {
235
+ const g = mountGrid({
236
+ localization: {
237
+ text: { announceFilterResults: '{visible} van de {total} rijen komen overeen' },
238
+ },
239
+ })
240
+ try {
241
+ g.api.setState({ globalFilter: 'Platform' })
242
+ flushSync()
243
+ await vi.advanceTimersByTimeAsync(500)
244
+ await settle()
245
+ expect(politeText()).toBe('2 van de 4 rijen komen overeen')
246
+ } finally {
247
+ g.destroy()
248
+ }
249
+ })
250
+ })