@ornery/ui-grid-vanilla 1.0.5 → 1.0.7
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/README.md +75 -11
- package/demo/main.ts +1 -2
- package/dist/browser/ui-grid-element.js +9586 -0
- package/dist/index.d.mts +43 -2
- package/dist/index.d.ts +43 -2
- package/dist/index.js +281 -100
- package/dist/index.mjs +283 -102
- package/package.json +14 -6
- package/src/browser.test.ts +9 -0
- package/src/browser.ts +8 -0
- package/src/cell-state-management.test.ts +419 -0
- package/src/events.ts +77 -26
- package/src/framework-slots.ts +18 -0
- package/src/grid-controller.ts +166 -98
- package/src/index.integration.test.ts +51 -8
- package/src/index.test.ts +0 -4
- package/src/index.ts +6 -9
- package/src/keyboard-navigation.test.ts +1 -7
- package/src/patch.ts +118 -1
- package/src/row-edit.test.ts +1 -1
- package/src/ui-grid-standalone.element.ts +79 -1
- package/tsup.config.ts +22 -7
- package/vitest.config.ts +30 -0
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ornery/ui-grid-vanilla",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Vanilla DOM wrapper for the Rust/WASM-backed @ornery/ui-grid-core custom element",
|
|
5
5
|
"publishConfig": {
|
|
6
|
+
"registry": "https://registry.npmjs.org/",
|
|
6
7
|
"access": "public"
|
|
7
8
|
},
|
|
8
9
|
"main": "dist/index.js",
|
|
@@ -13,10 +14,16 @@
|
|
|
13
14
|
"types": "./dist/index.d.ts",
|
|
14
15
|
"import": "./dist/index.mjs",
|
|
15
16
|
"require": "./dist/index.js"
|
|
16
|
-
}
|
|
17
|
+
},
|
|
18
|
+
"./browser": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/browser/ui-grid-element.js",
|
|
21
|
+
"default": "./dist/browser/ui-grid-element.js"
|
|
22
|
+
},
|
|
23
|
+
"./browser/ui-grid-element.js": "./dist/browser/ui-grid-element.js"
|
|
17
24
|
},
|
|
18
25
|
"dependencies": {
|
|
19
|
-
"@ornery/ui-grid-core": "1.0.
|
|
26
|
+
"@ornery/ui-grid-core": "1.0.6"
|
|
20
27
|
},
|
|
21
28
|
"devDependencies": {
|
|
22
29
|
"happy-dom": "^20.9.0",
|
|
@@ -29,8 +36,9 @@
|
|
|
29
36
|
},
|
|
30
37
|
"scripts": {
|
|
31
38
|
"start": "SASS_PATH=node_modules vite serve",
|
|
32
|
-
"build": "
|
|
33
|
-
"watch": "
|
|
34
|
-
"test": "SASS_PATH=node_modules vitest run"
|
|
39
|
+
"build": "SASS_PATH=node_modules tsup",
|
|
40
|
+
"watch": "SASS_PATH=node_modules tsup --watch",
|
|
41
|
+
"test": "SASS_PATH=node_modules vitest run --config vitest.config.ts",
|
|
42
|
+
"test:coverage": "SASS_PATH=node_modules vitest run --config vitest.config.ts --coverage"
|
|
35
43
|
}
|
|
36
44
|
}
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './index';
|
|
2
|
+
export { defineStandaloneUiGridElement } from './ui-grid-standalone.element';
|
|
3
|
+
|
|
4
|
+
import { defineStandaloneUiGridElement } from './ui-grid-standalone.element';
|
|
5
|
+
|
|
6
|
+
if (typeof window !== 'undefined' && typeof customElements !== 'undefined') {
|
|
7
|
+
void defineStandaloneUiGridElement();
|
|
8
|
+
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { clearRustWasmGridEngine } from '@ornery/ui-grid-core';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
mountVanillaUiGrid,
|
|
7
|
+
type GridOptions,
|
|
8
|
+
type UiGridApi,
|
|
9
|
+
type VanillaUiGridElement,
|
|
10
|
+
} from './index';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Regression tests for two bugs reported around the cell selection / row
|
|
14
|
+
* selection / expandable-rows interaction:
|
|
15
|
+
*
|
|
16
|
+
* 1. Click-on-projected-content focus retention — when a consumer projects
|
|
17
|
+
* a `<span slot="cell-…">` into a body cell (Angular ng-template, React
|
|
18
|
+
* portal, or vanilla `<template slot="cell-…">`), clicks on that
|
|
19
|
+
* light-DOM content used to fail to update `cell-focused` because the
|
|
20
|
+
* handler used `closest('.body-cell')` which doesn't cross shadow
|
|
21
|
+
* boundaries. The fix walks `composedPath()` instead.
|
|
22
|
+
*
|
|
23
|
+
* 2. Patch-path row-state cache — a per-row visual fingerprint short-
|
|
24
|
+
* circuits the patch loop when nothing relevant changed (selection /
|
|
25
|
+
* focus / expand / dirty / saving / error / validate / treeLevel /
|
|
26
|
+
* indent). This makes selection toggles in grids with both row
|
|
27
|
+
* selection and expandable rows enabled cheap. The tests below pin the
|
|
28
|
+
* correctness of the cache: selecting one row only touches that row's
|
|
29
|
+
* cells; toggling expand only touches that row; validate state changes
|
|
30
|
+
* flip the invalid class.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
async function waitFor<T>(resolve: () => T | null | undefined, timeoutMs = 5000): Promise<T> {
|
|
34
|
+
const startedAt = Date.now();
|
|
35
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
36
|
+
const value = resolve();
|
|
37
|
+
if (value) return value;
|
|
38
|
+
await new Promise((r) => window.setTimeout(r, 10));
|
|
39
|
+
}
|
|
40
|
+
throw new Error('Timed out waiting for expected state');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TAG = 'ui-grid-element-vanilla-cell-state-test';
|
|
44
|
+
|
|
45
|
+
interface Row extends Record<string, unknown> {
|
|
46
|
+
id: string;
|
|
47
|
+
name: string;
|
|
48
|
+
status: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function rows(): Row[] {
|
|
52
|
+
return [
|
|
53
|
+
{ id: 'r1', name: 'Alpha', status: 'Active' },
|
|
54
|
+
{ id: 'r2', name: 'Beta', status: 'Pilot' },
|
|
55
|
+
{ id: 'r3', name: 'Gamma', status: 'Draft' },
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function baseOptions(overrides: Partial<GridOptions> = {}): GridOptions {
|
|
60
|
+
let capturedApi: UiGridApi | undefined;
|
|
61
|
+
const options: GridOptions = {
|
|
62
|
+
id: 'cell-state-grid',
|
|
63
|
+
data: rows(),
|
|
64
|
+
columnDefs: [
|
|
65
|
+
{ name: 'name', displayName: 'Name' },
|
|
66
|
+
{ name: 'status', displayName: 'Status' },
|
|
67
|
+
],
|
|
68
|
+
rowIdentity: (entity) => String((entity as { id: string }).id),
|
|
69
|
+
enableRowSelection: true,
|
|
70
|
+
onRegisterApi: (api) => {
|
|
71
|
+
capturedApi = api as UiGridApi;
|
|
72
|
+
},
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
Object.defineProperty(options, '__api', { enumerable: false, get: () => capturedApi });
|
|
76
|
+
return options;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getApi(options: GridOptions): UiGridApi {
|
|
80
|
+
const api = (options as unknown as { __api: UiGridApi | undefined }).__api;
|
|
81
|
+
if (!api) throw new Error('Grid API was never registered');
|
|
82
|
+
return api;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function mountGrid(options: GridOptions): Promise<{
|
|
86
|
+
grid: VanillaUiGridElement;
|
|
87
|
+
shadow: ShadowRoot;
|
|
88
|
+
}> {
|
|
89
|
+
const target = document.getElementById('app')!;
|
|
90
|
+
const grid = await mountVanillaUiGrid(target, options, undefined, TAG);
|
|
91
|
+
const shadow = await waitFor(() => grid.shadowRoot);
|
|
92
|
+
await waitFor(() => shadow.querySelector('.body-cell[data-row="r1"][data-column="name"]'));
|
|
93
|
+
return { grid, shadow };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cellIn(root: ShadowRoot, rowId: string, columnName: string): HTMLElement {
|
|
97
|
+
const selector = `.body-cell[data-row="${rowId}"][data-column="${columnName}"]`;
|
|
98
|
+
const el = root.querySelector<HTMLElement>(selector);
|
|
99
|
+
if (!el) throw new Error(`Cell ${rowId}/${columnName} not rendered`);
|
|
100
|
+
return el;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function dispatchClick(el: HTMLElement): MouseEvent {
|
|
104
|
+
const event = new MouseEvent('click', {
|
|
105
|
+
bubbles: true,
|
|
106
|
+
cancelable: true,
|
|
107
|
+
composed: true,
|
|
108
|
+
button: 0,
|
|
109
|
+
});
|
|
110
|
+
el.dispatchEvent(event);
|
|
111
|
+
return event;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
describe('cell selection state retention', () => {
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
117
|
+
});
|
|
118
|
+
afterEach(() => {
|
|
119
|
+
clearRustWasmGridEngine();
|
|
120
|
+
document.body.innerHTML = '';
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('moves the cell-focused class from the previous cell to the newly clicked cell', async () => {
|
|
124
|
+
const { shadow } = await mountGrid(baseOptions({ enableRowSelection: false }));
|
|
125
|
+
|
|
126
|
+
const a = cellIn(shadow, 'r1', 'name');
|
|
127
|
+
const b = cellIn(shadow, 'r2', 'status');
|
|
128
|
+
|
|
129
|
+
dispatchClick(a);
|
|
130
|
+
expect(a.classList.contains('cell-focused')).toBe(true);
|
|
131
|
+
expect(a.getAttribute('data-focused')).toBe('true');
|
|
132
|
+
|
|
133
|
+
dispatchClick(b);
|
|
134
|
+
expect(b.classList.contains('cell-focused')).toBe(true);
|
|
135
|
+
expect(b.getAttribute('data-focused')).toBe('true');
|
|
136
|
+
// Critical: the previously focused cell must lose the class, otherwise
|
|
137
|
+
// both cells appear "selected" simultaneously.
|
|
138
|
+
expect(a.classList.contains('cell-focused')).toBe(false);
|
|
139
|
+
expect(a.getAttribute('data-focused')).toBe('false');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('clicks on a deeply nested element inside a body cell focus the host body cell (composedPath traversal)', async () => {
|
|
143
|
+
// Sanity-check: the click handler walks composedPath() instead of
|
|
144
|
+
// closest('.body-cell'), so any descendant click — including child
|
|
145
|
+
// elements far below the body-cell — still resolves to the host.
|
|
146
|
+
// This is the same code path that handles framework-projected
|
|
147
|
+
// light-DOM content (Angular ng-template, React render props), where
|
|
148
|
+
// the deepest target sits OUTSIDE the body-cell's DOM ancestor chain.
|
|
149
|
+
const { shadow } = await mountGrid(baseOptions({ enableRowSelection: false }));
|
|
150
|
+
|
|
151
|
+
const cell = cellIn(shadow, 'r1', 'name');
|
|
152
|
+
const cellValue = cell.querySelector<HTMLElement>('.cell-value');
|
|
153
|
+
if (!cellValue) throw new Error('Expected default .cell-value span inside the cell');
|
|
154
|
+
|
|
155
|
+
expect(cell.classList.contains('cell-focused')).toBe(false);
|
|
156
|
+
dispatchClick(cellValue);
|
|
157
|
+
expect(cell.classList.contains('cell-focused')).toBe(true);
|
|
158
|
+
expect(cell.getAttribute('data-focused')).toBe('true');
|
|
159
|
+
|
|
160
|
+
// Clicking inside another cell's deepest descendant clears the prior
|
|
161
|
+
// cell's focus and applies it to the new one.
|
|
162
|
+
const other = cellIn(shadow, 'r2', 'status');
|
|
163
|
+
const otherValue = other.querySelector<HTMLElement>('.cell-value')!;
|
|
164
|
+
dispatchClick(otherValue);
|
|
165
|
+
expect(cell.classList.contains('cell-focused')).toBe(false);
|
|
166
|
+
expect(cell.getAttribute('data-focused')).toBe('false');
|
|
167
|
+
expect(other.classList.contains('cell-focused')).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('renders a per-cell <slot> placeholder when a column is registered as framework-rendered', async () => {
|
|
171
|
+
// Pins the bridge that the Angular wrapper depends on: declaring a
|
|
172
|
+
// column as framework-rendered swaps its body cell from the default
|
|
173
|
+
// string-interpolated content to a `<slot name="cell-…-rowId">`
|
|
174
|
+
// placeholder. The wrapper then attaches a light-DOM node with a
|
|
175
|
+
// matching `slot` attribute to project its rendered template.
|
|
176
|
+
const { grid, shadow } = await mountGrid(baseOptions({ enableRowSelection: false }));
|
|
177
|
+
|
|
178
|
+
grid.setFrameworkRenderedSlots({ cells: ['name'] });
|
|
179
|
+
const slotInCell = await waitFor(() => {
|
|
180
|
+
const cell = shadow.querySelector<HTMLElement>(
|
|
181
|
+
'.body-cell[data-row="r1"][data-column="name"]',
|
|
182
|
+
);
|
|
183
|
+
return cell?.querySelector<HTMLSlotElement>('slot') ?? null;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Slot name must include the row identity so the wrapper can match
|
|
187
|
+
// each projected content node to its cell.
|
|
188
|
+
const slotName = slotInCell.getAttribute('name');
|
|
189
|
+
expect(slotName).toBe('cell-name-r1');
|
|
190
|
+
// Each row gets its own unique slot name.
|
|
191
|
+
const r2Slot = shadow.querySelector<HTMLSlotElement>(
|
|
192
|
+
'.body-cell[data-row="r2"][data-column="name"] slot',
|
|
193
|
+
);
|
|
194
|
+
expect(r2Slot?.getAttribute('name')).toBe('cell-name-r2');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('uses composedPath() to locate the body cell so framework-projected clicks resolve correctly in real browsers', async () => {
|
|
198
|
+
// The Angular / React wrappers project light-DOM content (Angular
|
|
199
|
+
// ng-template, React render props) into per-cell slots. In a real
|
|
200
|
+
// browser the resulting click event has the shadow-DOM body-cell in
|
|
201
|
+
// its composedPath. Test environments (jsdom + happy-dom alike)
|
|
202
|
+
// don't simulate slot-driven event flow, so we directly validate the
|
|
203
|
+
// helper logic by dispatching a click whose composedPath synthetically
|
|
204
|
+
// includes the body cell — which is what a real browser produces.
|
|
205
|
+
const { shadow } = await mountGrid(baseOptions({ enableRowSelection: false }));
|
|
206
|
+
|
|
207
|
+
const targetCell = cellIn(shadow, 'r2', 'status');
|
|
208
|
+
|
|
209
|
+
// Build a click event whose `composedPath` is overridden to include a
|
|
210
|
+
// synthetic light-DOM node FOLLOWED by the real shadow-DOM body cell —
|
|
211
|
+
// mirroring the path real browsers produce for a click on slotted
|
|
212
|
+
// content. Without the composedPath fix the click handler would only
|
|
213
|
+
// see the light-DOM portion (everything before the body-cell) and miss
|
|
214
|
+
// the cell entirely.
|
|
215
|
+
const projected = document.createElement('span');
|
|
216
|
+
projected.className = 'projected-cell';
|
|
217
|
+
const path: EventTarget[] = [
|
|
218
|
+
projected,
|
|
219
|
+
targetCell,
|
|
220
|
+
shadow,
|
|
221
|
+
];
|
|
222
|
+
const event = new MouseEvent('click', {
|
|
223
|
+
bubbles: true,
|
|
224
|
+
cancelable: true,
|
|
225
|
+
composed: true,
|
|
226
|
+
button: 0,
|
|
227
|
+
});
|
|
228
|
+
Object.defineProperty(event, 'composedPath', {
|
|
229
|
+
value: () => path,
|
|
230
|
+
configurable: true,
|
|
231
|
+
});
|
|
232
|
+
Object.defineProperty(event, 'target', { value: projected, configurable: true });
|
|
233
|
+
|
|
234
|
+
expect(targetCell.classList.contains('cell-focused')).toBe(false);
|
|
235
|
+
shadow.dispatchEvent(event);
|
|
236
|
+
expect(targetCell.classList.contains('cell-focused')).toBe(true);
|
|
237
|
+
expect(targetCell.getAttribute('data-focused')).toBe('true');
|
|
238
|
+
|
|
239
|
+
// A subsequent normal click on a different cell clears the focus.
|
|
240
|
+
const other = cellIn(shadow, 'r3', 'name');
|
|
241
|
+
dispatchClick(other);
|
|
242
|
+
expect(targetCell.classList.contains('cell-focused')).toBe(false);
|
|
243
|
+
expect(other.classList.contains('cell-focused')).toBe(true);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('row selection + expand patch correctness', () => {
|
|
248
|
+
beforeEach(() => {
|
|
249
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
250
|
+
});
|
|
251
|
+
afterEach(() => {
|
|
252
|
+
clearRustWasmGridEngine();
|
|
253
|
+
document.body.innerHTML = '';
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
function selectionExpandOptions(): GridOptions {
|
|
257
|
+
// Provide a no-op expandable template object so canGridExpandRows
|
|
258
|
+
// returns true. The template body itself isn't exercised here.
|
|
259
|
+
const template = { createEmbeddedView: () => undefined };
|
|
260
|
+
return baseOptions({
|
|
261
|
+
enableRowSelection: true,
|
|
262
|
+
enableExpandable: true,
|
|
263
|
+
expandableRowTemplate:
|
|
264
|
+
template as unknown as GridOptions['expandableRowTemplate'],
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
it('toggling selection on one row only flips the ui-grid-row-selected class on cells of that row', async () => {
|
|
269
|
+
const options = selectionExpandOptions();
|
|
270
|
+
const { shadow } = await mountGrid(options);
|
|
271
|
+
|
|
272
|
+
const before = {
|
|
273
|
+
r1Name: cellIn(shadow, 'r1', 'name').classList.contains('ui-grid-row-selected'),
|
|
274
|
+
r2Name: cellIn(shadow, 'r2', 'name').classList.contains('ui-grid-row-selected'),
|
|
275
|
+
r3Name: cellIn(shadow, 'r3', 'name').classList.contains('ui-grid-row-selected'),
|
|
276
|
+
};
|
|
277
|
+
expect(before).toEqual({ r1Name: false, r2Name: false, r3Name: false });
|
|
278
|
+
|
|
279
|
+
getApi(options).selection.selectRow(options.data[1] as Record<string, unknown>);
|
|
280
|
+
await waitFor(() =>
|
|
281
|
+
cellIn(shadow, 'r2', 'name').classList.contains('ui-grid-row-selected') ? {} : null,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
// Only r2's cells flipped — r1 / r3 remain unselected. Both cell
|
|
285
|
+
// columns of r2 must be flagged so the row stripe shows across all
|
|
286
|
+
// columns.
|
|
287
|
+
expect(cellIn(shadow, 'r1', 'name').classList.contains('ui-grid-row-selected')).toBe(false);
|
|
288
|
+
expect(cellIn(shadow, 'r1', 'status').classList.contains('ui-grid-row-selected')).toBe(false);
|
|
289
|
+
expect(cellIn(shadow, 'r2', 'name').classList.contains('ui-grid-row-selected')).toBe(true);
|
|
290
|
+
expect(cellIn(shadow, 'r2', 'status').classList.contains('ui-grid-row-selected')).toBe(true);
|
|
291
|
+
expect(cellIn(shadow, 'r3', 'name').classList.contains('ui-grid-row-selected')).toBe(false);
|
|
292
|
+
expect(cellIn(shadow, 'r3', 'status').classList.contains('ui-grid-row-selected')).toBe(false);
|
|
293
|
+
|
|
294
|
+
// Unselect — class must drop.
|
|
295
|
+
getApi(options).selection.unSelectRow(options.data[1] as Record<string, unknown>);
|
|
296
|
+
await waitFor(() =>
|
|
297
|
+
!cellIn(shadow, 'r2', 'name').classList.contains('ui-grid-row-selected') ? {} : null,
|
|
298
|
+
);
|
|
299
|
+
expect(cellIn(shadow, 'r2', 'name').classList.contains('ui-grid-row-selected')).toBe(false);
|
|
300
|
+
expect(cellIn(shadow, 'r2', 'status').classList.contains('ui-grid-row-selected')).toBe(false);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('toggling expansion on a row inserts/removes the expandable detail and updates that row only', async () => {
|
|
304
|
+
const options = selectionExpandOptions();
|
|
305
|
+
const { shadow } = await mountGrid(options);
|
|
306
|
+
|
|
307
|
+
expect(shadow.querySelector('.expandable-row')).toBeNull();
|
|
308
|
+
|
|
309
|
+
getApi(options).expandable.toggleRowExpansion(options.data[1] as Record<string, unknown>);
|
|
310
|
+
await waitFor(() => shadow.querySelector('.expandable-row'));
|
|
311
|
+
|
|
312
|
+
// Exactly one expandable-row appears after toggling a single row.
|
|
313
|
+
expect(shadow.querySelectorAll('.expandable-row').length).toBe(1);
|
|
314
|
+
|
|
315
|
+
// Other rows' selection / focus state must not have flipped.
|
|
316
|
+
expect(cellIn(shadow, 'r1', 'name').classList.contains('ui-grid-row-selected')).toBe(false);
|
|
317
|
+
expect(cellIn(shadow, 'r3', 'name').classList.contains('ui-grid-row-selected')).toBe(false);
|
|
318
|
+
|
|
319
|
+
// Collapse — the detail row goes away.
|
|
320
|
+
getApi(options).expandable.toggleRowExpansion(options.data[1] as Record<string, unknown>);
|
|
321
|
+
await waitFor(() => (shadow.querySelector('.expandable-row') === null ? {} : null));
|
|
322
|
+
expect(shadow.querySelector('.expandable-row')).toBeNull();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('selection toggle preserves cell-focused on a different row', async () => {
|
|
326
|
+
// The patch path's per-row fingerprint must NOT clobber the cell-focused
|
|
327
|
+
// class on a row that wasn't touched by the selection toggle. This was
|
|
328
|
+
// the actual bug — without proper "focused cell touches this row"
|
|
329
|
+
// tracking, the focused cell could either lose its class on the next
|
|
330
|
+
// refresh or keep it on a row it has moved off of.
|
|
331
|
+
const options = selectionExpandOptions();
|
|
332
|
+
const { shadow } = await mountGrid(options);
|
|
333
|
+
|
|
334
|
+
const r1Cell = cellIn(shadow, 'r1', 'name');
|
|
335
|
+
dispatchClick(r1Cell);
|
|
336
|
+
expect(r1Cell.classList.contains('cell-focused')).toBe(true);
|
|
337
|
+
|
|
338
|
+
// Selecting a different row triggers a full refresh; r1's cell-focused
|
|
339
|
+
// class must survive because focusedCell didn't move.
|
|
340
|
+
getApi(options).selection.selectRow(options.data[1] as Record<string, unknown>);
|
|
341
|
+
await waitFor(() =>
|
|
342
|
+
cellIn(shadow, 'r2', 'name').classList.contains('ui-grid-row-selected') ? {} : null,
|
|
343
|
+
);
|
|
344
|
+
expect(r1Cell.classList.contains('cell-focused')).toBe(true);
|
|
345
|
+
expect(r1Cell.getAttribute('data-focused')).toBe('true');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('expand toggle attaches to the first data column (not the selection checkbox column) and renders to the left of cell content', async () => {
|
|
349
|
+
// Regression test for the visual bug where the expand toggle
|
|
350
|
+
// rendered inside the selectionRowHeaderCol cell (column index 0).
|
|
351
|
+
// isGridPrimaryColumn() now skips the synthetic selection column so
|
|
352
|
+
// the toggle attaches to the actual first data column. The toggle
|
|
353
|
+
// sits BEFORE `.cell-content` so the disclosure chevron hugs the
|
|
354
|
+
// leading edge of the cell, matching the tree-toggle convention.
|
|
355
|
+
const options = selectionExpandOptions();
|
|
356
|
+
const { shadow } = await mountGrid(options);
|
|
357
|
+
|
|
358
|
+
const selectionCellR1 = shadow.querySelector<HTMLElement>(
|
|
359
|
+
'.body-cell[data-row="r1"][data-column="selectionRowHeaderCol"]',
|
|
360
|
+
);
|
|
361
|
+
expect(selectionCellR1).not.toBeNull();
|
|
362
|
+
expect(selectionCellR1!.querySelector('.row-toggle-expand')).toBeNull();
|
|
363
|
+
|
|
364
|
+
const nameCellR1 = cellIn(shadow, 'r1', 'name');
|
|
365
|
+
const expandToggle = nameCellR1.querySelector<HTMLElement>('.row-toggle-expand');
|
|
366
|
+
expect(expandToggle).not.toBeNull();
|
|
367
|
+
|
|
368
|
+
// Inside the cell-shell the toggle must come BEFORE .cell-content
|
|
369
|
+
// so it sits on the leading edge of the cell. DOCUMENT_POSITION_FOLLOWING
|
|
370
|
+
// (0x04) means cellContent is AFTER expandToggle.
|
|
371
|
+
const cellContent = nameCellR1.querySelector<HTMLElement>('.cell-content')!;
|
|
372
|
+
const expectedFollowing = Node.DOCUMENT_POSITION_FOLLOWING;
|
|
373
|
+
expect(expandToggle!.compareDocumentPosition(cellContent) & expectedFollowing).toBe(
|
|
374
|
+
expectedFollowing,
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const selectionCheckbox = selectionCellR1!.querySelector<HTMLElement>(
|
|
378
|
+
'.ui-grid-selection-row-header-buttons',
|
|
379
|
+
);
|
|
380
|
+
expect(selectionCheckbox).not.toBeNull();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('validate state flip flows through the patch fast-path so the invalid class appears', async () => {
|
|
384
|
+
// Validate writes `$$invalid<col>` directly on the row entity. The
|
|
385
|
+
// entity reference doesn't change, so the row fingerprint cache must
|
|
386
|
+
// sample those keys to detect the validity flip.
|
|
387
|
+
const options = selectionExpandOptions();
|
|
388
|
+
options.enableCellEdit = true;
|
|
389
|
+
options.columnDefs = options.columnDefs.map((c) =>
|
|
390
|
+
c.name === 'name'
|
|
391
|
+
? { ...c, enableCellEdit: true, validators: { required: true } }
|
|
392
|
+
: c,
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
const { shadow } = await mountGrid(options);
|
|
396
|
+
const api = getApi(options);
|
|
397
|
+
|
|
398
|
+
// Drive r1 invalid. Refresh emits a new snapshot; the patch path's
|
|
399
|
+
// fingerprint cache must detect the new invalid bit and flip the class.
|
|
400
|
+
const col = options.columnDefs.find((c) => c.name === 'name')!;
|
|
401
|
+
const target = options.data[0] as Record<string, unknown>;
|
|
402
|
+
await api.validate.runValidators(target, col, '', 'Alpha');
|
|
403
|
+
api.core.refresh();
|
|
404
|
+
|
|
405
|
+
await waitFor(() =>
|
|
406
|
+
shadow.querySelector('.body-cell[data-row="r1"][data-column="name"].ui-grid-cell-invalid'),
|
|
407
|
+
);
|
|
408
|
+
expect(
|
|
409
|
+
cellIn(shadow, 'r1', 'name').classList.contains('ui-grid-cell-invalid'),
|
|
410
|
+
).toBe(true);
|
|
411
|
+
// Other rows / columns must NOT be flagged.
|
|
412
|
+
expect(
|
|
413
|
+
cellIn(shadow, 'r1', 'status').classList.contains('ui-grid-cell-invalid'),
|
|
414
|
+
).toBe(false);
|
|
415
|
+
expect(
|
|
416
|
+
cellIn(shadow, 'r2', 'name').classList.contains('ui-grid-cell-invalid'),
|
|
417
|
+
).toBe(false);
|
|
418
|
+
});
|
|
419
|
+
});
|