@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.
- package/dist/SvGrid.controller.svelte.d.ts +2 -1
- package/dist/SvGrid.controller.svelte.js +145 -3
- package/dist/SvGrid.css +32 -0
- package/dist/SvGrid.svelte +64 -6
- package/dist/SvGrid.types.d.ts +61 -0
- package/dist/a11y/grid-announcements.d.ts +58 -0
- package/dist/a11y/grid-announcements.js +70 -0
- package/dist/advanced-filter.svelte.d.ts +42 -0
- package/dist/advanced-filter.svelte.js +13 -0
- package/dist/build-api.js +44 -2
- package/dist/cdn/{GridMenus-BoVKgAv8.js → GridMenus-CBvjL5u4.js} +14 -14
- package/dist/cdn/{GridMenus-DL1zrDwf.js → GridMenus-DAzp6YXc.js} +14 -14
- package/dist/cdn/{src-M2NfKnjX.js → src-3WKN7L4G.js} +5932 -5743
- package/dist/cdn/{src-CEpw6Ato.js → src-Cl7h5-Ux.js} +3371 -3182
- package/dist/cdn/svgrid.js +8 -8
- package/dist/cdn/svgrid.svelte-external.js +8 -8
- package/dist/filter-operators.d.ts +2 -1
- package/dist/filter-operators.js +5 -6
- package/dist/filtering/excel-filters.d.ts +1 -0
- package/dist/filtering/excel-filters.js +5 -0
- package/dist/filtering/filter-operator-catalogue.d.ts +42 -0
- package/dist/filtering/filter-operator-catalogue.js +42 -0
- package/dist/filtering/predicate-expr.d.ts +78 -0
- package/dist/filtering/predicate-expr.js +1 -0
- package/dist/filtering/row-predicate.d.ts +17 -0
- package/dist/filtering/row-predicate.js +55 -0
- package/dist/grid-messages.d.ts +7 -0
- package/dist/grid-messages.js +7 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/server-data-source.d.ts +34 -0
- package/dist/server-data-source.js +19 -0
- package/dist/svgrid-wrapper.types.d.ts +37 -5
- package/dist/themes/index.js +12 -4
- package/package.json +2 -1
- package/src/SvGrid.controller.svelte.ts +169 -2
- package/src/SvGrid.css +32 -0
- package/src/SvGrid.svelte +64 -6
- package/src/SvGrid.types.ts +61 -0
- package/src/a11y/grid-announcements.test.ts +78 -0
- package/src/a11y/grid-announcements.ts +94 -0
- package/src/a11y.announce.test.ts +250 -0
- package/src/a11y.axe.test.ts +194 -0
- package/src/advanced-filter.svelte.ts +59 -0
- package/src/build-api.ts +43 -2
- package/src/filter-operators.ts +9 -6
- package/src/filtering/excel-filters.ts +15 -0
- package/src/filtering/filter-operator-catalogue.ts +74 -0
- package/src/filtering/node-entry.test.ts +75 -0
- package/src/filtering/predicate-expr.ts +50 -0
- package/src/filtering/row-predicate.ts +57 -0
- package/src/grid-messages.ts +18 -0
- package/src/index.ts +28 -0
- package/src/server-data-source.test.ts +94 -0
- package/src/server-data-source.ts +56 -0
- package/src/svgrid-wrapper.types.ts +37 -5
- package/src/svgrid.advanced-filter.test.ts +481 -0
- package/src/svgrid.displayed-rows.test.ts +144 -0
- package/src/svgrid.group-props-reactive.svelte.test.ts +151 -0
- package/src/svgrid.group-props.test.ts +280 -0
- package/src/themes/contrast.test.ts +112 -0
- package/src/themes/index.ts +12 -4
- package/themes/catppuccin.css +3 -3
- package/themes/dracula.css +3 -3
- package/themes/nord.css +3 -3
- 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
|
-
|
|
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
|
-
></
|
|
3128
|
+
></td>
|
|
3074
3129
|
{/if}
|
|
3075
3130
|
{#if showRowSelectionEffective}
|
|
3076
|
-
<!-- Selection column is checkbox-only; no aggregate.
|
|
3077
|
-
|
|
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
|
-
></
|
|
3138
|
+
></td>
|
|
3081
3139
|
{/if}
|
|
3082
3140
|
{#if columnVirtualizationEnabled && columnWindowStart > 0}
|
|
3083
3141
|
<th
|
package/src/SvGrid.types.ts
CHANGED
|
@@ -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
|
+
})
|