@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
@@ -1,4 +1,4 @@
1
- import { type CellEditorOption, type Column, type ColumnDef, type Row, type RowData, type TableFeatures, type ChartType, type ChartSpec, type ChartValueFormat } from "./index";
1
+ import { type GridPredicateExpr, type CellEditorOption, type Column, type ColumnDef, type Row, type RowData, type TableFeatures, type ChartType, type ChartSpec, type ChartValueFormat } from "./index";
2
2
  import "./sv-grid-scrollbar";
3
3
  import { type ColumnStat } from "./conditional-formatting";
4
4
  import type { Props, SelectionRange, CellEditState, FilterOperator, MenuPosition, ContextMenuTarget, ChartingConfig } from "./SvGrid.types";
@@ -31,6 +31,7 @@ export declare function createSvGridController<TFeatures extends TableFeatures =
31
31
  valueTo2?: string;
32
32
  join?: "AND" | "OR";
33
33
  }>;
34
+ advancedFilter: GridPredicateExpr | null;
34
35
  verticalScrollbarEl: HTMLElement | null;
35
36
  horizontalScrollbarEl: HTMLElement | null;
36
37
  scrollVersion: number;
@@ -1,4 +1,4 @@
1
- import { applyGroupAggregate, compileExcelFilter, normalizeForFilter, createColumnVirtualizer, createCoreRowModel, createExpandedRowModel, createFilteredRowModel, createGroupedRowModel, createTreeRowModel, createSvelteVirtualizer, createSortedRowModel, createSvGrid, getGridCellDomId, sortFns, } from "./index";
1
+ import { applyGroupAggregate, applyRowPredicate, compileExcelFilter, getAdvancedFilterEngine, normalizeForFilter, createColumnVirtualizer, createCoreRowModel, createExpandedRowModel, createFilteredRowModel, createGroupedRowModel, createTreeRowModel, createSvelteVirtualizer, createSortedRowModel, createSvGrid, getGridCellDomId, sortFns, } from "./index";
2
2
  import { createRowScrollScaling, resolveMaxDomHeight, } from "./virtualization/scroll-scaling";
3
3
  import "./sv-grid-scrollbar";
4
4
  import { computeColumnStat, formatsNeedingStats, formatNeedsStats, } from "./conditional-formatting";
@@ -21,6 +21,8 @@ import { buildAutoGroupColumns, insertGroupFooters, paginateGroupedRows } from "
21
21
  import { createGridApi, } from "./build-api";
22
22
  import { createClipboard, } from "./clipboard";
23
23
  import { resolveGridMessages } from "./grid-messages";
24
+ import { announce } from "./a11y/live-region";
25
+ import { filterAnnouncement, selectionAnnouncement, } from "./a11y/grid-announcements";
24
26
  import { getPivotEngine, hasPivotEngine } from "./pivot-view.svelte";
25
27
  import { filterOperatorOptions, fallbackOperatorOption, TEXT_OPERATORS, NUMBER_OPERATORS, DATE_OPERATORS, CHECKBOX_OPERATORS, operatorOption, operatorsForColumn, defaultOperatorFor, operatorLabelFor, } from "./filter-operators";
26
28
  import { isBucketableColumn, buildBuckets, isInBucket, } from "./facet-buckets";
@@ -190,6 +192,11 @@ export function createSvGridController(rawProps, domIdBase) {
190
192
  let globalFilter = $state("");
191
193
  let scrollContainer = $state(null);
192
194
  let gridRootEl = $state(null);
195
+ // Advanced filter (Pro). Seeded once from `initialAdvancedFilter` and driven
196
+ // thereafter through the API - deliberately NOT a live prop, so it cannot
197
+ // repeat the `externalFilter` trap of looking controlled while being read
198
+ // exactly once.
199
+ let advancedFilter = $state(props.initialAdvancedFilter ?? null);
193
200
  let filterRowValues = $state({});
194
201
  let filterMenuValues = $state({});
195
202
  let verticalScrollbarEl = $state(null);
@@ -508,13 +515,15 @@ export function createSvGridController(rawProps, domIdBase) {
508
515
  },
509
516
  state: {
510
517
  columnFilters: [],
511
- grouping: [],
518
+ // svelte-ignore state_referenced_locally
519
+ grouping: treeDataConfig ? [] : [...(props.groupBy ?? [])],
512
520
  // svelte-ignore state_referenced_locally
513
521
  sorting: props.initialSorting ?? [],
514
522
  // svelte-ignore state_referenced_locally
515
523
  pagination: { pageIndex: 0, pageSize: props.pageSize ?? 10 },
516
524
  rowSelection: {},
517
- expanded: {},
525
+ // svelte-ignore state_referenced_locally
526
+ expanded: { ...(props.expanded ?? {}) },
518
527
  activeCell: { rowIndex: 0, colIndex: 0, cellId: null },
519
528
  },
520
529
  });
@@ -571,6 +580,44 @@ export function createSvGridController(rawProps, domIdBase) {
571
580
  lastSeededOrder = incoming;
572
581
  userColumnOrder = props.columnOrder ? [...props.columnOrder] : [];
573
582
  });
583
+ // `groupBy` prop -> engine grouping state. Same shape as the `columnOrder`
584
+ // re-seed above: we only write when the PROP itself changes, so a group-by
585
+ // set from the column menu or `api.setGroupBy()` is not clobbered on the next
586
+ // unrelated re-render.
587
+ let lastSeededGroupBy = (props.groupBy ?? []).join("|");
588
+ $effect(() => {
589
+ if (treeDataConfig)
590
+ return;
591
+ const incoming = (props.groupBy ?? []).join("|");
592
+ if (incoming === lastSeededGroupBy)
593
+ return;
594
+ lastSeededGroupBy = incoming;
595
+ grid.setGrouping(() => [...(props.groupBy ?? [])]);
596
+ });
597
+ // `expanded` prop -> engine expansion state, and the store -> the
598
+ // `onExpandedChange` callback. `lastSeededExpanded` is written from BOTH
599
+ // directions so a controlled parent that echoes the callback value straight
600
+ // back into the prop does not trigger a redundant re-seed (and so no
601
+ // prop -> state -> callback -> prop loop can form).
602
+ let lastSeededExpanded = JSON.stringify(props.expanded ?? {});
603
+ $effect(() => {
604
+ const incoming = JSON.stringify(props.expanded ?? {});
605
+ if (incoming === lastSeededExpanded)
606
+ return;
607
+ lastSeededExpanded = incoming;
608
+ grid.setExpanded(() => ({ ...(props.expanded ?? {}) }));
609
+ });
610
+ $effect(() => {
611
+ let previous = grid.getState().expanded ?? {};
612
+ return grid.store.subscribe(() => {
613
+ const next = grid.getState().expanded ?? {};
614
+ if (next === previous)
615
+ return;
616
+ previous = next;
617
+ lastSeededExpanded = JSON.stringify(next);
618
+ props.onExpandedChange?.({ ...next });
619
+ });
620
+ });
574
621
  // Declared here rather than beside the other pagination/state derivations:
575
622
  // the auto-group column pipeline below reads it, and a `const` used above its
576
623
  // declaration is a type error even though `$derived` is lazy enough at runtime.
@@ -1039,6 +1086,33 @@ export function createSvGridController(rawProps, domIdBase) {
1039
1086
  return allowed.has(String(raw ?? ""));
1040
1087
  }));
1041
1088
  }
1089
+ // --- Advanced filter (Pro) ---------------------------------------------
1090
+ // Runs LAST for two reasons: it evaluates over the smallest row set, and
1091
+ // its aggregates (`SUM(amount) > 1000`) fold over what the user is
1092
+ // currently looking at rather than the raw dataset. Composed with AND
1093
+ // against the three stages above.
1094
+ //
1095
+ // Fails OPEN throughout. No engine registered (enterprise absent), an
1096
+ // expression the engine cannot compile, or a throw from inside it all
1097
+ // leave `rows` untouched. A half-applied filter that quietly shows the
1098
+ // wrong rows is worse than one that visibly did nothing.
1099
+ if (advancedFilter) {
1100
+ const engine = getAdvancedFilterEngine();
1101
+ if (engine) {
1102
+ try {
1103
+ const predicate = engine(advancedFilter, {
1104
+ getValue: getRowColumnValue,
1105
+ locale: props.filterLocale ?? props.localization?.locale,
1106
+ rows,
1107
+ });
1108
+ if (predicate)
1109
+ rows = applyRowPredicate(rows, predicate, isGroupRow);
1110
+ }
1111
+ catch {
1112
+ // Malformed expression: leave the rows alone.
1113
+ }
1114
+ }
1115
+ }
1042
1116
  return rows;
1043
1117
  });
1044
1118
  /**
@@ -1334,6 +1408,72 @@ export function createSvGridController(rawProps, domIdBase) {
1334
1408
  // Resolved chrome messages (English defaults merged with `localeText`). One
1335
1409
  // map every consumer (SvGrid.svelte, GridFooter, menus, filter labels) reads.
1336
1410
  const gridMessages = $derived(resolveGridMessages(props.localization?.text));
1411
+ // ---- Screen-reader status announcements (WCAG 4.1.3) ----
1412
+ // Only genuine status messages belong here: information no focus change
1413
+ // reveals. `a11y/grid-announcements.ts` documents what is deliberately left
1414
+ // unsaid, and why saying it would make the grid talk over itself.
1415
+ /** Data rows before any local filtering - the denominator in "12 of 250". */
1416
+ const unfilteredRowTotal = $derived.by(() => {
1417
+ dataStateVersion;
1418
+ void internalData;
1419
+ return grid.getRowModel().rows.length;
1420
+ });
1421
+ /** Whether any of the five filter surfaces is currently narrowing the rows. */
1422
+ const anyFilterActive = $derived(globalFilter !== "" ||
1423
+ advancedFilter !== null ||
1424
+ Object.keys(filterRowValues).length > 0 ||
1425
+ Object.keys(filterMenuValues).length > 0 ||
1426
+ Object.keys(valueFilters).length > 0);
1427
+ let filtersWereActive = false;
1428
+ let filterAnnounceTimer;
1429
+ $effect(() => {
1430
+ // In external-filter mode the server decides what matched, so the local
1431
+ // counts describe only the page in hand. Announcing them would misreport
1432
+ // the result set, which is worse than staying silent.
1433
+ if (externalFilterEnabled)
1434
+ return;
1435
+ const visible = paginatedRowTotal;
1436
+ const total = unfilteredRowTotal;
1437
+ const active = anyFilterActive;
1438
+ const messages = gridMessages;
1439
+ untrack(() => {
1440
+ const message = filterAnnouncement(visible, total, active, filtersWereActive, messages);
1441
+ filtersWereActive = active;
1442
+ if (!message)
1443
+ return;
1444
+ // Typing in the global filter re-runs this per keystroke. A polite region
1445
+ // queues rather than replaces, so without a debounce the user hears the
1446
+ // count for every prefix they typed before the one they care about.
1447
+ clearTimeout(filterAnnounceTimer);
1448
+ filterAnnounceTimer = setTimeout(() => announce(message), 400);
1449
+ });
1450
+ return () => clearTimeout(filterAnnounceTimer);
1451
+ });
1452
+ // Forward advanced-filter changes to the consumer. Same dedupe-and-skip-first
1453
+ // pattern as the selection forwarder: the panel that authors the expression
1454
+ // lives outside the grid, so a change the grid makes itself (the toolbar's
1455
+ // clear, clearAllFilters) is invisible to it otherwise.
1456
+ let lastAdvancedFilterSerialized = JSON.stringify(untrack(() => advancedFilter) ?? null);
1457
+ $effect(() => {
1458
+ const serialized = JSON.stringify(advancedFilter ?? null);
1459
+ if (serialized === untrack(() => lastAdvancedFilterSerialized))
1460
+ return;
1461
+ untrack(() => {
1462
+ lastAdvancedFilterSerialized = serialized;
1463
+ props.onAdvancedFilterChange?.(advancedFilter ?? null);
1464
+ });
1465
+ });
1466
+ let lastAnnouncedSelectionCount = 0;
1467
+ $effect(() => {
1468
+ const count = Object.values(rowSelectionState).filter(Boolean).length;
1469
+ const messages = gridMessages;
1470
+ untrack(() => {
1471
+ const message = selectionAnnouncement(lastAnnouncedSelectionCount, count, messages);
1472
+ lastAnnouncedSelectionCount = count;
1473
+ if (message)
1474
+ announce(message);
1475
+ });
1476
+ });
1337
1477
  // ---- In-grid pivot mode ----
1338
1478
  // The pivot ENGINE ships in @svgrid/enterprise (registered via the pivot-view
1339
1479
  // seam). When `pivot` is set and the engine is present, the grid runs it over
@@ -2638,6 +2778,8 @@ export function createSvGridController(rawProps, domIdBase) {
2638
2778
  set filterRowValues(v) { filterRowValues = v; },
2639
2779
  get filterMenuValues() { return filterMenuValues; },
2640
2780
  set filterMenuValues(v) { filterMenuValues = v; },
2781
+ get advancedFilter() { return advancedFilter; },
2782
+ set advancedFilter(v) { advancedFilter = v; },
2641
2783
  get verticalScrollbarEl() { return verticalScrollbarEl; },
2642
2784
  set verticalScrollbarEl(v) { verticalScrollbarEl = v; },
2643
2785
  get horizontalScrollbarEl() { return horizontalScrollbarEl; },
package/dist/SvGrid.css CHANGED
@@ -122,6 +122,38 @@
122
122
  border-color: var(--sg-accent, #2563eb);
123
123
  color: #fff;
124
124
  }
125
+ /* Advanced-filter indicator. Reads as state rather than as another action, so
126
+ it uses the accent fill the toolbar buttons reserve for `is-active` instead
127
+ of the neutral button shell. */
128
+ .sv-grid-advf-chip {
129
+ display: inline-flex;
130
+ align-items: center;
131
+ gap: 6px;
132
+ padding: 5px 6px 5px 11px;
133
+ border-radius: 999px;
134
+ border: 1px solid color-mix(in srgb, var(--sg-accent, #2563eb) 35%, transparent);
135
+ background: color-mix(in srgb, var(--sg-accent, #2563eb) 12%, transparent);
136
+ color: var(--sg-accent, #2563eb);
137
+ font-size: 12.5px;
138
+ font-weight: 600;
139
+ }
140
+ .sv-grid-advf-clear {
141
+ display: inline-flex;
142
+ align-items: center;
143
+ justify-content: center;
144
+ width: 18px;
145
+ height: 18px;
146
+ border: 0;
147
+ border-radius: 999px;
148
+ background: transparent;
149
+ color: inherit;
150
+ font-size: 11px;
151
+ cursor: pointer;
152
+ transition: background-color 120ms ease;
153
+ }
154
+ .sv-grid-advf-clear:hover {
155
+ background: color-mix(in srgb, var(--sg-accent, #2563eb) 25%, transparent);
156
+ }
125
157
  .sv-grid-tool-panel {
126
158
  position: absolute;
127
159
  top: 0;
@@ -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
@@ -4,6 +4,7 @@ import type { ConditionalFormat } from "./conditional-formatting";
4
4
  import type { GroupDisplayType } from "./group-display";
5
5
  import type { GridMessages } from "./grid-messages";
6
6
  import type { GridPivotConfig } from "./pivot-view.svelte";
7
+ import type { GridPredicateExpr } from "./filtering/predicate-expr";
7
8
  import type { MenuItem } from "./SvMenuList.svelte";
8
9
  /** The calendar views the scheduler can render. {@link SchedulerConfig}. The
9
10
  * `timeline*` views are horizontal: time runs left→right and resources are
@@ -1024,6 +1025,44 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
1024
1025
  indentPx?: number;
1025
1026
  };
1026
1027
  groupable?: boolean;
1028
+ /**
1029
+ * Group the rows by these column ids, outermost first - `['region',
1030
+ * 'country']` rolls country up inside region.
1031
+ *
1032
+ * ```svelte
1033
+ * <SvGrid {data} {columns} {features} groupBy={['department']} />
1034
+ * ```
1035
+ *
1036
+ * The prop seeds the group-by list and re-applies whenever it changes, so it
1037
+ * works both as initial state and as a controlled value. The column menu and
1038
+ * `api.setGroupBy()` write the same state; a change from either survives
1039
+ * until this prop's own value changes.
1040
+ *
1041
+ * Ignored when `treeData` is set - a row cannot be both a hierarchy node and
1042
+ * bucketed under a group banner.
1043
+ */
1044
+ groupBy?: ReadonlyArray<string>;
1045
+ /**
1046
+ * Which group / tree rows are expanded, keyed by row id.
1047
+ *
1048
+ * Expansion is owned by the engine by default; pass this prop to hoist it
1049
+ * (saved views, "expand everything on load", persisted UI state). Like
1050
+ * `groupBy` it seeds the state and re-applies whenever it changes.
1051
+ *
1052
+ * Group row ids are built from the grouping path, not the display label:
1053
+ * `group_department_Engineering`, and one level deeper
1054
+ * `group_department_Engineering_role_Senior`. Tree rows key off the engine's
1055
+ * row id instead (set `getRowId` to make that your own id). Prefer capturing
1056
+ * the map from `onExpandedChange` over hand-building these keys.
1057
+ */
1058
+ expanded?: Record<string, boolean>;
1059
+ /**
1060
+ * Fired whenever the expanded set changes - chevron clicks,
1061
+ * `api.setRowExpanded()`, `expandAllGroups()` / `collapseAllGroups()`.
1062
+ * Receives the full next map, so it can be written straight back into
1063
+ * `expanded` for a controlled setup.
1064
+ */
1065
+ onExpandedChange?: (expanded: Record<string, boolean>) => void;
1027
1066
  /**
1028
1067
  * Render a subtotal row after each group's children, carrying that group's
1029
1068
  * aggregate values under the columns they belong to (the columns with an
@@ -1335,6 +1374,28 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
1335
1374
  id: string;
1336
1375
  desc: boolean;
1337
1376
  }>;
1377
+ /**
1378
+ * The advanced-filter expression to start in (Pro). Applied once at mount;
1379
+ * change it afterwards through `api.setAdvancedFilter()`.
1380
+ *
1381
+ * Filtering only happens once `@svgrid/enterprise`'s `enableAdvancedFilter()`
1382
+ * has registered a compiler - without it the expression is stored and
1383
+ * reported but no rows are removed, so the free grid degrades to a no-op
1384
+ * rather than to a wrong row set.
1385
+ */
1386
+ initialAdvancedFilter?: GridPredicateExpr | null;
1387
+ /**
1388
+ * Fires whenever the advanced-filter expression changes, whatever changed it:
1389
+ * `api.setAdvancedFilter()`, `clearAllFilters()`, or the toolbar's clear
1390
+ * control.
1391
+ *
1392
+ * The panel that authors the expression is mounted by you, outside the grid,
1393
+ * so it cannot see a change the grid made on its own. Without this, clearing
1394
+ * from the toolbar leaves that panel showing a filter the grid is no longer
1395
+ * applying. Feed this back into the panel's `expression` prop to keep them
1396
+ * agreeing.
1397
+ */
1398
+ onAdvancedFilterChange?: (expr: GridPredicateExpr | null) => void;
1338
1399
  /**
1339
1400
  * When `true`, the grid still records column-filter / global-filter /
1340
1401
  * facet state (so the menu UI works and indicators light up) but does
@@ -0,0 +1,58 @@
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
+ /** The subset of `GridMessages` this module reads. */
24
+ export type AnnouncementMessages = {
25
+ announceFilterResults: string;
26
+ announceNoMatches: string;
27
+ announceFiltersCleared: string;
28
+ announceRowsSelected: string;
29
+ announceSelectionCleared: string;
30
+ };
31
+ /**
32
+ * Interpolate `{name}` placeholders.
33
+ *
34
+ * The rest of `GridMessages` is single words assembled by the caller (`of`,
35
+ * `rowsSuffix`). That works for labels but not for sentences: languages order
36
+ * "12 of 250 match" differently, and a translator handed three separate atoms
37
+ * cannot fix the order. Announcements are whole sentences, so they get whole
38
+ * templates. An unknown placeholder is left alone rather than blanked, so a
39
+ * typo in an override is visible instead of silently eating the number.
40
+ */
41
+ export declare function formatMessage(template: string, vars: Record<string, string | number>): string;
42
+ /**
43
+ * What to say after the row set changes, or null to stay quiet.
44
+ *
45
+ * `visible` is the count after filtering but BEFORE pagination - the answer to
46
+ * "how many rows matched", not "how many are on this page".
47
+ */
48
+ export declare function filterAnnouncement(visible: number, total: number, filtersActive: boolean, filtersWereActive: boolean, messages: AnnouncementMessages): string | null;
49
+ /**
50
+ * What to say after the selection changes, or null to stay quiet.
51
+ *
52
+ * Only BULK changes are announced. Selecting one row moves focus to it and the
53
+ * screen reader reads it as selected from `aria-selected`; announcing "1 row
54
+ * selected" on top of that is the grid talking over itself. A change of more
55
+ * than one row cannot have come from a single focus move, so it is the
56
+ * select-all / range / clear case that otherwise passes silently.
57
+ */
58
+ export declare function selectionAnnouncement(previousCount: number, count: number, messages: AnnouncementMessages): string | null;
@@ -0,0 +1,70 @@
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
+ * Interpolate `{name}` placeholders.
25
+ *
26
+ * The rest of `GridMessages` is single words assembled by the caller (`of`,
27
+ * `rowsSuffix`). That works for labels but not for sentences: languages order
28
+ * "12 of 250 match" differently, and a translator handed three separate atoms
29
+ * cannot fix the order. Announcements are whole sentences, so they get whole
30
+ * templates. An unknown placeholder is left alone rather than blanked, so a
31
+ * typo in an override is visible instead of silently eating the number.
32
+ */
33
+ export function formatMessage(template, vars) {
34
+ return template.replace(/\{(\w+)\}/g, (whole, key) => key in vars ? String(vars[key]) : whole);
35
+ }
36
+ /**
37
+ * What to say after the row set changes, or null to stay quiet.
38
+ *
39
+ * `visible` is the count after filtering but BEFORE pagination - the answer to
40
+ * "how many rows matched", not "how many are on this page".
41
+ */
42
+ export function filterAnnouncement(visible, total, filtersActive, filtersWereActive, messages) {
43
+ if (filtersActive) {
44
+ if (visible === 0)
45
+ return messages.announceNoMatches;
46
+ return formatMessage(messages.announceFilterResults, { visible, total });
47
+ }
48
+ // Clearing a filter is itself a status change worth confirming; without this
49
+ // the user hears nothing back and cannot tell the clear took effect.
50
+ if (filtersWereActive) {
51
+ return formatMessage(messages.announceFiltersCleared, { total });
52
+ }
53
+ return null;
54
+ }
55
+ /**
56
+ * What to say after the selection changes, or null to stay quiet.
57
+ *
58
+ * Only BULK changes are announced. Selecting one row moves focus to it and the
59
+ * screen reader reads it as selected from `aria-selected`; announcing "1 row
60
+ * selected" on top of that is the grid talking over itself. A change of more
61
+ * than one row cannot have come from a single focus move, so it is the
62
+ * select-all / range / clear case that otherwise passes silently.
63
+ */
64
+ export function selectionAnnouncement(previousCount, count, messages) {
65
+ if (Math.abs(count - previousCount) <= 1)
66
+ return null;
67
+ if (count === 0)
68
+ return messages.announceSelectionCleared;
69
+ return formatMessage(messages.announceRowsSelected, { count });
70
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Registration seam for the advanced filter (a `@svgrid/enterprise` feature).
3
+ *
4
+ * The grid owns the state and the pipeline slot; enterprise owns the compiler.
5
+ * Same shape as `pivot-view.svelte.ts`: a module-level `$state` holder with a
6
+ * register / get / has triple, so the free package carries no commercial code.
7
+ */
8
+ import type { GridPredicateExpr } from './filtering/predicate-expr';
9
+ /** What the engine needs in order to compile an expression into a predicate. */
10
+ export type AdvancedFilterCompileContext<TRow> = {
11
+ /**
12
+ * Resolve a column id to its cell value for a row. The grid passes the same
13
+ * accessor the column-menu filter stage uses, so a `cmp` on a `fieldFn` or
14
+ * value-getter column resolves identically in both.
15
+ */
16
+ getValue: (row: TRow, columnId: string) => unknown;
17
+ /** Locale for text folding, matching the column filters. */
18
+ locale?: string | ReadonlyArray<string>;
19
+ /**
20
+ * The rows entering this stage. Aggregates (`SUM(amount) > 1000`) are folded
21
+ * over exactly these, once, so they see the rows the user is currently
22
+ * looking at rather than the raw dataset.
23
+ */
24
+ rows: ReadonlyArray<TRow>;
25
+ };
26
+ /** A compiled expression: cheap to call once per row. */
27
+ export type CompiledRowPredicate<TRow> = (row: TRow) => boolean;
28
+ /**
29
+ * Compile an expression to a row predicate.
30
+ *
31
+ * Returns `null` when the expression cannot be compiled (an unknown node kind,
32
+ * an unresolvable column). Must never throw: the grid treats both `null` and a
33
+ * thrown error as "do not filter", because a half-applied filter silently
34
+ * showing the wrong rows is worse than an unapplied one.
35
+ */
36
+ export type AdvancedFilterEngine = <TRow>(expr: GridPredicateExpr, ctx: AdvancedFilterCompileContext<TRow>) => CompiledRowPredicate<TRow> | null;
37
+ /** Register the engine. Enterprise calls this from `enableAdvancedFilter()`. */
38
+ export declare function registerAdvancedFilterEngine(fn: AdvancedFilterEngine | null): void;
39
+ /** The registered engine, or null when enterprise is not installed. */
40
+ export declare function getAdvancedFilterEngine(): AdvancedFilterEngine | null;
41
+ /** Whether an advanced-filter engine has been registered. */
42
+ export declare function hasAdvancedFilterEngine(): boolean;
@@ -0,0 +1,13 @@
1
+ let engine = $state(null);
2
+ /** Register the engine. Enterprise calls this from `enableAdvancedFilter()`. */
3
+ export function registerAdvancedFilterEngine(fn) {
4
+ engine = fn;
5
+ }
6
+ /** The registered engine, or null when enterprise is not installed. */
7
+ export function getAdvancedFilterEngine() {
8
+ return engine;
9
+ }
10
+ /** Whether an advanced-filter engine has been registered. */
11
+ export function hasAdvancedFilterEngine() {
12
+ return engine != null;
13
+ }