@ornery/ui-grid-vanilla 1.0.5 → 1.0.6
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/demo/main.ts +1 -2
- package/dist/index.d.mts +40 -2
- package/dist/index.d.ts +40 -2
- package/dist/index.js +263 -100
- package/dist/index.mjs +263 -102
- package/package.json +6 -5
- 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 +142 -98
- package/src/index.integration.test.ts +27 -10
- 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/vitest.config.ts +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ornery/ui-grid-vanilla",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Vanilla DOM wrapper for the Rust/WASM-backed @ornery/ui-grid-core custom element",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@ornery/ui-grid-core": "1.0.
|
|
19
|
+
"@ornery/ui-grid-core": "1.0.6"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"happy-dom": "^20.9.0",
|
|
@@ -29,8 +29,9 @@
|
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"start": "SASS_PATH=node_modules vite serve",
|
|
32
|
-
"build": "
|
|
33
|
-
"watch": "
|
|
34
|
-
"test": "SASS_PATH=node_modules vitest run"
|
|
32
|
+
"build": "SASS_PATH=node_modules tsup",
|
|
33
|
+
"watch": "SASS_PATH=node_modules tsup --watch",
|
|
34
|
+
"test": "SASS_PATH=node_modules vitest run --config vitest.config.ts",
|
|
35
|
+
"test:coverage": "SASS_PATH=node_modules vitest run --config vitest.config.ts --coverage"
|
|
35
36
|
}
|
|
36
37
|
}
|
|
@@ -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
|
+
});
|
package/src/events.ts
CHANGED
|
@@ -28,6 +28,24 @@ import type { UiGridStandaloneElement } from './ui-grid-standalone.element';
|
|
|
28
28
|
|
|
29
29
|
type BoundShadowRoot = ShadowRoot & { __uiGridBound?: boolean };
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Find the `.body-cell[data-row][data-column]` ancestor of an event by walking
|
|
33
|
+
* the composed event path. Plain `closest()` from the click target stops at
|
|
34
|
+
* shadow boundaries — for clicks on framework-projected light-DOM cell
|
|
35
|
+
* content (Angular ng-template, React render props), the body cell sits in
|
|
36
|
+
* the shadow tree on the other side of the slot. composedPath includes both
|
|
37
|
+
* sides, so we just scan it.
|
|
38
|
+
*/
|
|
39
|
+
function bodyCellFromEvent(event: Event): HTMLElement | null {
|
|
40
|
+
const path = event.composedPath();
|
|
41
|
+
for (const node of path) {
|
|
42
|
+
if (node instanceof HTMLElement && node.classList.contains('body-cell')) {
|
|
43
|
+
if (node.dataset['row'] && node.dataset['column']) return node;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
31
49
|
/**
|
|
32
50
|
* Watch the element's light DOM for `<template slot="…">` changes so
|
|
33
51
|
* consumer template edits trigger a re-render. Ports the old grid's
|
|
@@ -37,7 +55,23 @@ type BoundShadowRoot = ShadowRoot & { __uiGridBound?: boolean };
|
|
|
37
55
|
export function observeTemplateSlots(el: UiGridStandaloneElement): void {
|
|
38
56
|
if (el.templateObserver) return;
|
|
39
57
|
|
|
40
|
-
el.templateObserver = new MutationObserver(() => {
|
|
58
|
+
el.templateObserver = new MutationObserver((mutations) => {
|
|
59
|
+
// We only care about consumer-provided `<template slot="…">` nodes —
|
|
60
|
+
// their innerHTML feeds the string-interpolation render path. Skip
|
|
61
|
+
// mutations that only touched non-template slotted nodes (e.g. the
|
|
62
|
+
// `<span slot="cell-…">` wrappers Angular / React wrappers append to
|
|
63
|
+
// project framework-rendered content). Otherwise those framework
|
|
64
|
+
// wrappers would re-trigger ensureController on every slot delta and
|
|
65
|
+
// loop forever.
|
|
66
|
+
const involvesTemplate = mutationsTouchTemplateSlot(mutations);
|
|
67
|
+
if (!involvesTemplate) return;
|
|
68
|
+
|
|
69
|
+
// Consumer light-DOM templates (`<template slot="cell-…">`) feed the
|
|
70
|
+
// string-interpolation render path. When they're added/removed/edited
|
|
71
|
+
// the per-row visual fingerprint cache becomes stale (the fingerprint
|
|
72
|
+
// doesn't track template content), so blow it away to force the next
|
|
73
|
+
// patch pass to re-render every cell shell.
|
|
74
|
+
el.lastRowStateFingerprints.clear();
|
|
41
75
|
if (el.activeOptions) {
|
|
42
76
|
el.ensureController(el.buildEffectiveOptions(el.activeOptions));
|
|
43
77
|
} else {
|
|
@@ -53,6 +87,23 @@ export function observeTemplateSlots(el: UiGridStandaloneElement): void {
|
|
|
53
87
|
});
|
|
54
88
|
}
|
|
55
89
|
|
|
90
|
+
function mutationsTouchTemplateSlot(mutations: MutationRecord[]): boolean {
|
|
91
|
+
for (const m of mutations) {
|
|
92
|
+
if (m.type === 'attributes' && m.target instanceof HTMLTemplateElement) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
if (m.type === 'childList') {
|
|
96
|
+
for (const node of m.addedNodes) {
|
|
97
|
+
if (node instanceof HTMLTemplateElement && node.hasAttribute('slot')) return true;
|
|
98
|
+
}
|
|
99
|
+
for (const node of m.removedNodes) {
|
|
100
|
+
if (node instanceof HTMLTemplateElement && node.hasAttribute('slot')) return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
56
107
|
/**
|
|
57
108
|
* Mirror horizontal scroll from the body viewport onto the header +
|
|
58
109
|
* filter strips. The strips are real scroll containers (necessary for
|
|
@@ -115,7 +166,7 @@ export function bindEvents(el: UiGridStandaloneElement): void {
|
|
|
115
166
|
// don't reliably focus a div with tabindex="0" on click, so we force it
|
|
116
167
|
// here. This is required for arrow-key nav to work — otherwise focus
|
|
117
168
|
// stays on the grid-table scroll container and arrows just scroll.
|
|
118
|
-
const clickedCell =
|
|
169
|
+
const clickedCell = bodyCellFromEvent(event);
|
|
119
170
|
if (clickedCell && !realTarget.closest('[data-role="editor"]')) {
|
|
120
171
|
const rowId = clickedCell.dataset['row'];
|
|
121
172
|
const columnName = clickedCell.dataset['column'];
|
|
@@ -140,11 +191,8 @@ export function bindEvents(el: UiGridStandaloneElement): void {
|
|
|
140
191
|
// Row-header checkbox column click — mirrors the old
|
|
141
192
|
// selectionRowHeaderButtons directive: even when enableFullRowSelection
|
|
142
193
|
// is off, clicking the header checkbox selects the row.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
);
|
|
146
|
-
if (checkboxNode) {
|
|
147
|
-
const rowId = checkboxNode.dataset['row'];
|
|
194
|
+
if (clickedCell?.dataset['column'] === 'selectionRowHeaderCol') {
|
|
195
|
+
const rowId = clickedCell.dataset['row'];
|
|
148
196
|
if (rowId) {
|
|
149
197
|
event.stopPropagation();
|
|
150
198
|
handleRowHeaderCheckboxClick(el, rowId, event);
|
|
@@ -255,9 +303,10 @@ export function bindEvents(el: UiGridStandaloneElement): void {
|
|
|
255
303
|
}
|
|
256
304
|
|
|
257
305
|
if (action === 'benchmark') {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
306
|
+
void el.controller.gridApi.core.benchmark().then((result) => {
|
|
307
|
+
el.benchmarkAverage = result.averageMs.toFixed(2);
|
|
308
|
+
el.render();
|
|
309
|
+
});
|
|
261
310
|
return;
|
|
262
311
|
}
|
|
263
312
|
|
|
@@ -270,13 +319,16 @@ export function bindEvents(el: UiGridStandaloneElement): void {
|
|
|
270
319
|
// focusin / focusout: keep `focusedCell` in sync with DOM focus
|
|
271
320
|
// ─────────────────────────────────────────────────────────────────
|
|
272
321
|
root.addEventListener('focusin', (event) => {
|
|
273
|
-
const
|
|
274
|
-
if (!target) return;
|
|
275
|
-
const cell = target.closest<HTMLElement>('.body-cell[data-row][data-column]');
|
|
322
|
+
const cell = bodyCellFromEvent(event);
|
|
276
323
|
const rowId = cell?.dataset['row'];
|
|
277
324
|
const columnName = cell?.dataset['column'];
|
|
278
325
|
if (rowId && columnName) {
|
|
279
|
-
el.focusedCell
|
|
326
|
+
const previous = el.focusedCell;
|
|
327
|
+
const next = { rowId, columnName };
|
|
328
|
+
if (!previous || previous.rowId !== rowId || previous.columnName !== columnName) {
|
|
329
|
+
el.focusedCell = next;
|
|
330
|
+
applyFocusedCellClass(el, previous, next);
|
|
331
|
+
}
|
|
280
332
|
}
|
|
281
333
|
});
|
|
282
334
|
|
|
@@ -325,7 +377,7 @@ export function bindEvents(el: UiGridStandaloneElement): void {
|
|
|
325
377
|
if (realTarget.closest('.filter-cell')) return;
|
|
326
378
|
const resolved = el.controller.getResolvedSelectionOptions();
|
|
327
379
|
if (!resolved.enableRowSelection || !resolved.multiSelect) return;
|
|
328
|
-
const startCell =
|
|
380
|
+
const startCell = bodyCellFromEvent(event);
|
|
329
381
|
if (!startCell) return;
|
|
330
382
|
const startRowId = startCell.dataset['row'];
|
|
331
383
|
if (!startRowId) return;
|
|
@@ -356,9 +408,7 @@ export function bindEvents(el: UiGridStandaloneElement): void {
|
|
|
356
408
|
};
|
|
357
409
|
|
|
358
410
|
const handleMove = (moveEvent: MouseEvent): void => {
|
|
359
|
-
const
|
|
360
|
-
const firstEl = path[0] as HTMLElement | undefined;
|
|
361
|
-
const cell = firstEl?.closest?.<HTMLElement>('.body-cell[data-row][data-column]');
|
|
411
|
+
const cell = bodyCellFromEvent(moveEvent);
|
|
362
412
|
if (!cell) return;
|
|
363
413
|
const rowId = cell.dataset['row'];
|
|
364
414
|
if (!rowId) return;
|
|
@@ -455,7 +505,7 @@ export function bindEvents(el: UiGridStandaloneElement): void {
|
|
|
455
505
|
}
|
|
456
506
|
}
|
|
457
507
|
|
|
458
|
-
const cell =
|
|
508
|
+
const cell = bodyCellFromEvent(event);
|
|
459
509
|
if (!cell) return;
|
|
460
510
|
|
|
461
511
|
const rowId = cell.dataset['row'];
|
|
@@ -473,11 +523,13 @@ export function bindEvents(el: UiGridStandaloneElement): void {
|
|
|
473
523
|
const target = event.target as HTMLElement | null;
|
|
474
524
|
if (!target || !el.controller) return;
|
|
475
525
|
|
|
526
|
+
const onBodyCell = bodyCellFromEvent(event);
|
|
527
|
+
|
|
476
528
|
// cellNav.keyDownOverrides: declared overrides skip cellnav's default
|
|
477
529
|
// handling and raise viewPortKeyDown so consumers can disable built-in
|
|
478
530
|
// key handling per-key.
|
|
479
531
|
const overrides = el.controller.getOptions().keyDownOverrides ?? [];
|
|
480
|
-
if (overrides.length &&
|
|
532
|
+
if (overrides.length && onBodyCell) {
|
|
481
533
|
for (const override of overrides) {
|
|
482
534
|
if (matchesKeyOverride(override, keyboardEvent)) {
|
|
483
535
|
el.controller.raiseCellNavKeyEvent('keydown', keyboardEvent);
|
|
@@ -490,7 +542,7 @@ export function bindEvents(el: UiGridStandaloneElement): void {
|
|
|
490
542
|
if (
|
|
491
543
|
(keyboardEvent.ctrlKey || keyboardEvent.metaKey) &&
|
|
492
544
|
(keyboardEvent.key === 'a' || keyboardEvent.key === 'A') &&
|
|
493
|
-
|
|
545
|
+
onBodyCell
|
|
494
546
|
) {
|
|
495
547
|
const resolved = el.controller.getResolvedSelectionOptions();
|
|
496
548
|
if (resolved.enableRowSelection && resolved.multiSelect) {
|
|
@@ -502,7 +554,7 @@ export function bindEvents(el: UiGridStandaloneElement): void {
|
|
|
502
554
|
|
|
503
555
|
// Space toggles the row when on the checkbox column or in full-row-selection mode.
|
|
504
556
|
if (keyboardEvent.key === ' ' || keyboardEvent.key === 'Spacebar') {
|
|
505
|
-
const cell =
|
|
557
|
+
const cell = onBodyCell;
|
|
506
558
|
const rowId = cell?.dataset['row'];
|
|
507
559
|
const columnName = cell?.dataset['column'];
|
|
508
560
|
if (cell && rowId && columnName) {
|
|
@@ -567,10 +619,9 @@ export function bindEvents(el: UiGridStandaloneElement): void {
|
|
|
567
619
|
// cell).
|
|
568
620
|
let rowId: string | undefined;
|
|
569
621
|
let columnName: string | undefined;
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
columnName = cell.dataset['column'];
|
|
622
|
+
if (onBodyCell) {
|
|
623
|
+
rowId = onBodyCell.dataset['row'];
|
|
624
|
+
columnName = onBodyCell.dataset['column'];
|
|
574
625
|
} else if (el.focusedCell) {
|
|
575
626
|
rowId = el.focusedCell.rowId;
|
|
576
627
|
columnName = el.focusedCell.columnName;
|
package/src/framework-slots.ts
CHANGED
|
@@ -261,6 +261,24 @@ export class FrameworkSlotBridge {
|
|
|
261
261
|
});
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Carry forward every previously-known cell slot for `rowId` into the
|
|
266
|
+
* current pending set. Used by the structural patch path when a row's
|
|
267
|
+
* fingerprint matches the previous render's: `stageCell` would normally
|
|
268
|
+
* re-record the slot during cell rendering, but the fingerprint
|
|
269
|
+
* short-circuit skips the render pass for unchanged rows. Without this,
|
|
270
|
+
* `flush()` would diff pending (missing the row's slots) against last
|
|
271
|
+
* (containing them) and emit a spurious `removed` for every cell of the
|
|
272
|
+
* unchanged row, destroying the framework wrappers' projected content.
|
|
273
|
+
*/
|
|
274
|
+
carryRowCells(rowId: string): void {
|
|
275
|
+
for (const [slotName, slot] of this.lastCellSlots) {
|
|
276
|
+
if (slot.rowId === rowId) {
|
|
277
|
+
this.pendingCellSlots.set(slotName, slot);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
264
282
|
stageExpandableRow(row: GridRow, rowIndex: number): void {
|
|
265
283
|
const slotName = expandableRowSlotName(row);
|
|
266
284
|
this.pendingExpandableRowSlots.set(slotName, {
|