@ornery/ui-grid-vanilla 0.1.9 → 1.0.0
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 +196 -0
- package/demo/index.html +3 -3
- package/demo/main.ts +1 -1
- package/dist/index.d.mts +584 -55
- package/dist/index.d.ts +584 -55
- package/dist/index.js +4887 -1404
- package/dist/index.mjs +5056 -1526
- package/package.json +11 -8
- package/scss-plugin.ts +65 -0
- package/src/adopt-styles.ts +39 -0
- package/src/attribute-bridge.ts +291 -0
- package/src/auto-height.test.ts +142 -0
- package/src/auto-resize.test.ts +206 -0
- package/src/cell-nav.test.ts +262 -0
- package/src/components/grid-body-cell/grid-body-cell.ts +23 -0
- package/src/components/grid-body-cell/index.ts +1 -0
- package/src/components/grid-cell-editor/grid-cell-editor.ts +104 -0
- package/src/components/grid-cell-editor/index.ts +1 -0
- package/src/components/grid-filter-cell/grid-filter-cell.scss +14 -0
- package/src/components/grid-filter-cell/grid-filter-cell.template.ts +20 -0
- package/src/components/grid-filter-cell/grid-filter-cell.ts +121 -0
- package/src/components/grid-filter-cell/index.ts +1 -0
- package/src/components/grid-group-row/grid-group-row.scss +24 -0
- package/src/components/grid-group-row/grid-group-row.template.ts +23 -0
- package/src/components/grid-group-row/grid-group-row.ts +94 -0
- package/src/components/grid-group-row/index.ts +1 -0
- package/src/components/grid-header-cell/grid-header-cell.ts +17 -0
- package/src/components/grid-header-cell/index.ts +1 -0
- package/src/components/grid-pagination/grid-pagination.scss +39 -0
- package/src/components/grid-pagination/grid-pagination.template.ts +40 -0
- package/src/components/grid-pagination/grid-pagination.ts +204 -0
- package/src/components/grid-pagination/index.ts +1 -0
- package/src/components/grid-template/grid-template.ts +166 -0
- package/src/components/grid-template/index.ts +1 -0
- package/src/events.ts +815 -0
- package/src/exporter.test.ts +186 -0
- package/src/focus.ts +353 -0
- package/src/framework-slots.ts +440 -0
- package/src/grid-controller.ts +1431 -20
- package/src/icons.ts +88 -0
- package/src/importer.test.ts +147 -0
- package/src/index.integration.test.ts +2 -2
- package/src/index.ts +9 -0
- package/src/infinite-scroll.test.ts +220 -0
- package/src/keyboard-navigation.test.ts +579 -0
- package/src/patch.ts +722 -0
- package/src/render.ts +465 -0
- package/src/row-edit.test.ts +191 -0
- package/src/row-selection.test.ts +550 -0
- package/src/save-state.test.ts +235 -0
- package/src/scss.d.ts +4 -0
- package/src/templates/index.ts +62 -0
- package/src/test-setup.ts +23 -0
- package/src/ui-grid-empty.template.ts +23 -0
- package/src/ui-grid-shell.scss +25 -0
- package/src/ui-grid-shell.template.ts +45 -0
- package/src/ui-grid-standalone.element.ts +751 -1032
- package/src/utils/cell-class.ts +70 -0
- package/src/utils/display-items.ts +15 -0
- package/src/utils/dom.ts +64 -0
- package/src/validate.test.ts +170 -0
- package/tsup.config.ts +10 -0
- package/vite.config.ts +10 -1
|
@@ -0,0 +1,579 @@
|
|
|
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
|
+
* Keyboard navigation tests for the vanilla grid element.
|
|
14
|
+
*
|
|
15
|
+
* These tests pin the behaviour that was historically missing after the
|
|
16
|
+
* refactor: arrow/Tab navigation between body cells, commit/cancel flow from
|
|
17
|
+
* the cell editor, and focus tracking across those transitions. Regressions
|
|
18
|
+
* here would mean the user can no longer navigate with the keyboard.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
async function waitFor<T>(resolve: () => T | null | undefined, timeoutMs = 5000): Promise<T> {
|
|
22
|
+
const startedAt = Date.now();
|
|
23
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
24
|
+
const value = resolve();
|
|
25
|
+
if (value) return value;
|
|
26
|
+
await new Promise((r) => window.setTimeout(r, 10));
|
|
27
|
+
}
|
|
28
|
+
throw new Error('Timed out waiting for expected element state');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TAG = 'ui-grid-element-vanilla-keyboard-test';
|
|
32
|
+
|
|
33
|
+
function sampleOptions(): GridOptions {
|
|
34
|
+
let capturedApi: UiGridApi | undefined;
|
|
35
|
+
const options: GridOptions = {
|
|
36
|
+
id: 'vanilla-keyboard-grid',
|
|
37
|
+
// Selection defaults to on in the old grid; nav tests assert against
|
|
38
|
+
// the two declared columns, so we explicitly opt out here.
|
|
39
|
+
enableRowSelection: false,
|
|
40
|
+
data: [
|
|
41
|
+
{ id: 'r1', name: 'Alpha', status: 'Active' },
|
|
42
|
+
{ id: 'r2', name: 'Beta', status: 'Pilot' },
|
|
43
|
+
{ id: 'r3', name: 'Gamma', status: 'Draft' },
|
|
44
|
+
],
|
|
45
|
+
columnDefs: [
|
|
46
|
+
{ name: 'name', displayName: 'Name' },
|
|
47
|
+
{ name: 'status', displayName: 'Status' },
|
|
48
|
+
],
|
|
49
|
+
rowIdentity: (entity) => String((entity as { id: string }).id),
|
|
50
|
+
onRegisterApi: (api) => {
|
|
51
|
+
capturedApi = api as UiGridApi;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
Object.defineProperty(options, '__api', { enumerable: false, get: () => capturedApi });
|
|
55
|
+
return options;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getApi(options: GridOptions): UiGridApi | undefined {
|
|
59
|
+
return (options as unknown as { __api: UiGridApi | undefined }).__api;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function cellIn(root: ShadowRoot, rowId: string, columnName: string): HTMLElement {
|
|
63
|
+
const selector = `.body-cell[data-row="${rowId}"][data-column="${columnName}"]`;
|
|
64
|
+
const el = root.querySelector<HTMLElement>(selector);
|
|
65
|
+
if (!el) throw new Error(`Expected cell ${rowId}/${columnName}; DOM missing it`);
|
|
66
|
+
return el;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function pressKey(
|
|
70
|
+
el: HTMLElement,
|
|
71
|
+
key: string,
|
|
72
|
+
opts: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean } = {},
|
|
73
|
+
): void {
|
|
74
|
+
const event = new KeyboardEvent('keydown', {
|
|
75
|
+
key,
|
|
76
|
+
bubbles: true,
|
|
77
|
+
cancelable: true,
|
|
78
|
+
composed: true,
|
|
79
|
+
...opts,
|
|
80
|
+
});
|
|
81
|
+
el.dispatchEvent(event);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Options where every column is editable. Used to exercise the "edit
|
|
85
|
+
* follows navigation" behaviour: Tab/Enter while editing should land in
|
|
86
|
+
* edit mode on the next editable cell; arrow nav outside edit mode must
|
|
87
|
+
* NOT auto-open the editor. */
|
|
88
|
+
function editableOptions(): GridOptions {
|
|
89
|
+
let capturedApi: UiGridApi | undefined;
|
|
90
|
+
const options: GridOptions = {
|
|
91
|
+
id: 'vanilla-keyboard-editable-grid',
|
|
92
|
+
enableCellEdit: true,
|
|
93
|
+
enableRowSelection: false,
|
|
94
|
+
data: [
|
|
95
|
+
{ id: 'r1', name: 'Alpha', status: 'Active' },
|
|
96
|
+
{ id: 'r2', name: 'Beta', status: 'Pilot' },
|
|
97
|
+
],
|
|
98
|
+
columnDefs: [
|
|
99
|
+
{ name: 'name', displayName: 'Name' },
|
|
100
|
+
{ name: 'status', displayName: 'Status' },
|
|
101
|
+
],
|
|
102
|
+
rowIdentity: (entity) => String((entity as { id: string }).id),
|
|
103
|
+
onRegisterApi: (api) => {
|
|
104
|
+
capturedApi = api as UiGridApi;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
Object.defineProperty(options, '__api', { enumerable: false, get: () => capturedApi });
|
|
108
|
+
return options;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function mountEditableGrid(): Promise<{
|
|
112
|
+
grid: VanillaUiGridElement;
|
|
113
|
+
shadow: ShadowRoot;
|
|
114
|
+
options: GridOptions;
|
|
115
|
+
}> {
|
|
116
|
+
const target = document.getElementById('app')!;
|
|
117
|
+
const options = editableOptions();
|
|
118
|
+
const grid = await mountVanillaUiGrid(target, options, undefined, TAG);
|
|
119
|
+
const shadow = await waitFor(() => grid.shadowRoot);
|
|
120
|
+
await waitFor(() => shadow.querySelector('.body-cell[data-row="r1"][data-column="name"]'));
|
|
121
|
+
return { grid, shadow, options };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Options with grouping enabled and a `status` groupBy. The data has two
|
|
125
|
+
* status buckets so a group header sits between row-2 (Pilot) and row-3
|
|
126
|
+
* (Draft) in visual order — Arrow navigation must skip the header. */
|
|
127
|
+
function groupedOptions(): GridOptions {
|
|
128
|
+
let capturedApi: UiGridApi | undefined;
|
|
129
|
+
const options: GridOptions = {
|
|
130
|
+
id: 'vanilla-keyboard-grouped-grid',
|
|
131
|
+
enableGrouping: true,
|
|
132
|
+
enableRowSelection: false,
|
|
133
|
+
grouping: { groupBy: ['status'] },
|
|
134
|
+
data: [
|
|
135
|
+
{ id: 'r1', name: 'Alpha', status: 'Active' },
|
|
136
|
+
{ id: 'r2', name: 'Beta', status: 'Active' },
|
|
137
|
+
{ id: 'r3', name: 'Gamma', status: 'Pilot' },
|
|
138
|
+
{ id: 'r4', name: 'Delta', status: 'Pilot' },
|
|
139
|
+
],
|
|
140
|
+
columnDefs: [
|
|
141
|
+
{ name: 'name', displayName: 'Name' },
|
|
142
|
+
{ name: 'status', displayName: 'Status' },
|
|
143
|
+
],
|
|
144
|
+
rowIdentity: (entity) => String((entity as { id: string }).id),
|
|
145
|
+
onRegisterApi: (api) => {
|
|
146
|
+
capturedApi = api as UiGridApi;
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
Object.defineProperty(options, '__api', { enumerable: false, get: () => capturedApi });
|
|
150
|
+
return options;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function mountGroupedGrid(): Promise<{
|
|
154
|
+
grid: VanillaUiGridElement;
|
|
155
|
+
shadow: ShadowRoot;
|
|
156
|
+
options: GridOptions;
|
|
157
|
+
}> {
|
|
158
|
+
const target = document.getElementById('app')!;
|
|
159
|
+
const options = groupedOptions();
|
|
160
|
+
const grid = await mountVanillaUiGrid(target, options, undefined, TAG);
|
|
161
|
+
const shadow = await waitFor(() => grid.shadowRoot);
|
|
162
|
+
await waitFor(() => shadow.querySelector('.body-cell[data-row="r2"][data-column="name"]'));
|
|
163
|
+
return { grid, shadow, options };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function mountGrid(): Promise<{
|
|
167
|
+
grid: VanillaUiGridElement;
|
|
168
|
+
shadow: ShadowRoot;
|
|
169
|
+
options: GridOptions;
|
|
170
|
+
}> {
|
|
171
|
+
const target = document.getElementById('app')!;
|
|
172
|
+
const options = sampleOptions();
|
|
173
|
+
const grid = await mountVanillaUiGrid(target, options, undefined, TAG);
|
|
174
|
+
const shadow = await waitFor(() => grid.shadowRoot);
|
|
175
|
+
await waitFor(() => shadow.querySelector('.body-cell[data-row="r1"][data-column="name"]'));
|
|
176
|
+
return { grid, shadow, options };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
describe('vanilla grid keyboard navigation', () => {
|
|
180
|
+
beforeEach(() => {
|
|
181
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
afterEach(() => {
|
|
185
|
+
clearRustWasmGridEngine();
|
|
186
|
+
document.body.innerHTML = '';
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('clicking a body cell focuses it', async () => {
|
|
190
|
+
const { shadow } = await mountGrid();
|
|
191
|
+
const target = cellIn(shadow, 'r2', 'name');
|
|
192
|
+
target.click();
|
|
193
|
+
expect(shadow.activeElement).toBe(target);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('ArrowDown after click calls preventDefault (scroll would be suppressed)', async () => {
|
|
197
|
+
// Pins the fix for the 100K-row demo bug: after click, pressing ArrowDown
|
|
198
|
+
// MUST preventDefault the keydown. Otherwise the scroll container
|
|
199
|
+
// (.grid-table) handles the arrow and scrolls the viewport instead of
|
|
200
|
+
// moving the selection. If this assertion fails, the demo will scroll.
|
|
201
|
+
const { shadow } = await mountGrid();
|
|
202
|
+
const cell = cellIn(shadow, 'r1', 'name');
|
|
203
|
+
cell.dispatchEvent(
|
|
204
|
+
new MouseEvent('click', { bubbles: true, cancelable: true, composed: true }),
|
|
205
|
+
);
|
|
206
|
+
const event = new KeyboardEvent('keydown', {
|
|
207
|
+
key: 'ArrowDown',
|
|
208
|
+
bubbles: true,
|
|
209
|
+
cancelable: true,
|
|
210
|
+
composed: true,
|
|
211
|
+
});
|
|
212
|
+
(shadow.activeElement as HTMLElement).dispatchEvent(event);
|
|
213
|
+
expect(event.defaultPrevented).toBe(true);
|
|
214
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r2', 'name'));
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('clicking a descendant of the body cell still focuses the cell', async () => {
|
|
218
|
+
// Real browsers deliver click events on the deepest DOM node under the
|
|
219
|
+
// pointer (often .cell-value inside .cell-content inside .cell-shell
|
|
220
|
+
// inside the body-cell). jsdom's cell.click() skips that — we simulate
|
|
221
|
+
// the descendant-target path explicitly so regressions are caught.
|
|
222
|
+
const { shadow } = await mountGrid();
|
|
223
|
+
const cell = cellIn(shadow, 'r2', 'name');
|
|
224
|
+
const descendant = cell.querySelector<HTMLElement>('.cell-content')
|
|
225
|
+
?? cell.querySelector<HTMLElement>('.cell-shell')
|
|
226
|
+
?? cell;
|
|
227
|
+
descendant.dispatchEvent(
|
|
228
|
+
new MouseEvent('click', { bubbles: true, cancelable: true, composed: true }),
|
|
229
|
+
);
|
|
230
|
+
expect(shadow.activeElement).toBe(cell);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('after clicking (no explicit focus()), ArrowRight moves focus to the next column', async () => {
|
|
234
|
+
// Regression: the 100K-row demo showed arrows scrolling instead of
|
|
235
|
+
// advancing focus. The bug is only reproducible if we rely on the click
|
|
236
|
+
// handler to focus the cell — NOT by calling .focus() directly like the
|
|
237
|
+
// other tests. Click (via descendant, like a real browser would deliver)
|
|
238
|
+
// then press arrow. Source/destination must both be queried against
|
|
239
|
+
// shadow.activeElement, not focused manually.
|
|
240
|
+
const { shadow } = await mountGrid();
|
|
241
|
+
const start = cellIn(shadow, 'r1', 'name');
|
|
242
|
+
const descendant = start.querySelector<HTMLElement>('.cell-content') ?? start;
|
|
243
|
+
descendant.dispatchEvent(
|
|
244
|
+
new MouseEvent('click', { bubbles: true, cancelable: true, composed: true }),
|
|
245
|
+
);
|
|
246
|
+
expect(shadow.activeElement).toBe(start);
|
|
247
|
+
pressKey(shadow.activeElement as HTMLElement, 'ArrowRight');
|
|
248
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r1', 'status'));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('ArrowRight moves the selection indicator (cell-focused class) to the next cell', async () => {
|
|
252
|
+
const { shadow } = await mountGrid();
|
|
253
|
+
const start = cellIn(shadow, 'r1', 'name');
|
|
254
|
+
start.click();
|
|
255
|
+
expect(start.classList.contains('cell-focused')).toBe(true);
|
|
256
|
+
expect(start.getAttribute('data-focused')).toBe('true');
|
|
257
|
+
pressKey(shadow.activeElement as HTMLElement, 'ArrowRight');
|
|
258
|
+
const next = cellIn(shadow, 'r1', 'status');
|
|
259
|
+
expect(next.classList.contains('cell-focused')).toBe(true);
|
|
260
|
+
expect(next.getAttribute('data-focused')).toBe('true');
|
|
261
|
+
expect(start.classList.contains('cell-focused')).toBe(false);
|
|
262
|
+
expect(start.getAttribute('data-focused')).toBe('false');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('ArrowDown moves the selection indicator to the row below', async () => {
|
|
266
|
+
const { shadow } = await mountGrid();
|
|
267
|
+
const start = cellIn(shadow, 'r1', 'name');
|
|
268
|
+
start.click();
|
|
269
|
+
pressKey(shadow.activeElement as HTMLElement, 'ArrowDown');
|
|
270
|
+
const next = cellIn(shadow, 'r2', 'name');
|
|
271
|
+
expect(next.classList.contains('cell-focused')).toBe(true);
|
|
272
|
+
expect(start.classList.contains('cell-focused')).toBe(false);
|
|
273
|
+
expect(shadow.activeElement).toBe(next);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('clicking a cell updates the selection indicator', async () => {
|
|
277
|
+
const { shadow } = await mountGrid();
|
|
278
|
+
const first = cellIn(shadow, 'r1', 'name');
|
|
279
|
+
first.click();
|
|
280
|
+
expect(first.classList.contains('cell-focused')).toBe(true);
|
|
281
|
+
const second = cellIn(shadow, 'r3', 'status');
|
|
282
|
+
second.click();
|
|
283
|
+
expect(second.classList.contains('cell-focused')).toBe(true);
|
|
284
|
+
expect(first.classList.contains('cell-focused')).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('ArrowRight moves focus to the next column', async () => {
|
|
288
|
+
const { shadow } = await mountGrid();
|
|
289
|
+
const start = cellIn(shadow, 'r1', 'name');
|
|
290
|
+
start.focus();
|
|
291
|
+
pressKey(start, 'ArrowRight');
|
|
292
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r1', 'status'));
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('ArrowLeft wraps at the start of a row to the end of the previous row', async () => {
|
|
296
|
+
const { shadow } = await mountGrid();
|
|
297
|
+
const start = cellIn(shadow, 'r2', 'name');
|
|
298
|
+
start.focus();
|
|
299
|
+
pressKey(start, 'ArrowLeft');
|
|
300
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r1', 'status'));
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('ArrowDown and ArrowUp move between rows in the same column', async () => {
|
|
304
|
+
const { shadow } = await mountGrid();
|
|
305
|
+
const start = cellIn(shadow, 'r1', 'status');
|
|
306
|
+
start.focus();
|
|
307
|
+
pressKey(start, 'ArrowDown');
|
|
308
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r2', 'status'));
|
|
309
|
+
pressKey(shadow.activeElement as HTMLElement, 'ArrowUp');
|
|
310
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r1', 'status'));
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('Tab and Shift+Tab behave like ArrowRight / ArrowLeft', async () => {
|
|
314
|
+
const { shadow } = await mountGrid();
|
|
315
|
+
const start = cellIn(shadow, 'r1', 'name');
|
|
316
|
+
start.focus();
|
|
317
|
+
pressKey(start, 'Tab');
|
|
318
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r1', 'status'));
|
|
319
|
+
pressKey(shadow.activeElement as HTMLElement, 'Tab', { shiftKey: true });
|
|
320
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r1', 'name'));
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('Home / End jump within a row, Ctrl+Home / Ctrl+End jump to grid edges', async () => {
|
|
324
|
+
const { shadow } = await mountGrid();
|
|
325
|
+
const start = cellIn(shadow, 'r2', 'status');
|
|
326
|
+
start.focus();
|
|
327
|
+
pressKey(start, 'Home');
|
|
328
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r2', 'name'));
|
|
329
|
+
pressKey(shadow.activeElement as HTMLElement, 'End');
|
|
330
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r2', 'status'));
|
|
331
|
+
pressKey(shadow.activeElement as HTMLElement, 'Home', { ctrlKey: true });
|
|
332
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r1', 'status'));
|
|
333
|
+
pressKey(shadow.activeElement as HTMLElement, 'End', { ctrlKey: true });
|
|
334
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r3', 'status'));
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('Arrow at a grid edge is a no-op (no throw, focus unchanged)', async () => {
|
|
338
|
+
const { shadow } = await mountGrid();
|
|
339
|
+
const topLeft = cellIn(shadow, 'r1', 'name');
|
|
340
|
+
topLeft.focus();
|
|
341
|
+
pressKey(topLeft, 'ArrowUp');
|
|
342
|
+
expect(shadow.activeElement).toBe(topLeft);
|
|
343
|
+
pressKey(topLeft, 'ArrowLeft');
|
|
344
|
+
expect(shadow.activeElement).toBe(topLeft);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('Enter on a cell enters edit mode for that cell', async () => {
|
|
348
|
+
const { shadow, options } = await mountGrid();
|
|
349
|
+
const cell = cellIn(shadow, 'r2', 'name');
|
|
350
|
+
cell.focus();
|
|
351
|
+
pressKey(cell, 'Enter');
|
|
352
|
+
const snapshot = getApi(options)!.core.getState
|
|
353
|
+
? null
|
|
354
|
+
: null;
|
|
355
|
+
// Assert via the controller — query it via the element's getState hook
|
|
356
|
+
// indirectly: the cell editor should be mounted in the shadow DOM.
|
|
357
|
+
void snapshot;
|
|
358
|
+
await waitFor(() => shadow.querySelector(`ui-grid-cell-editor[data-row="r2"][data-column="name"]`));
|
|
359
|
+
const editorInput = shadow.querySelector<HTMLInputElement>(
|
|
360
|
+
`ui-grid-cell-editor[data-row="r2"][data-column="name"] input`,
|
|
361
|
+
);
|
|
362
|
+
expect(editorInput).not.toBeNull();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('F2 enters edit mode identical to Enter', async () => {
|
|
366
|
+
const { shadow } = await mountGrid();
|
|
367
|
+
const cell = cellIn(shadow, 'r3', 'status');
|
|
368
|
+
cell.focus();
|
|
369
|
+
pressKey(cell, 'F2');
|
|
370
|
+
await waitFor(() =>
|
|
371
|
+
shadow.querySelector('ui-grid-cell-editor[data-row="r3"][data-column="status"]'),
|
|
372
|
+
);
|
|
373
|
+
expect(
|
|
374
|
+
shadow.querySelector('ui-grid-cell-editor[data-row="r3"][data-column="status"]'),
|
|
375
|
+
).not.toBeNull();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('printable key on a cell enters edit mode and seeds the typed character', async () => {
|
|
379
|
+
const { shadow } = await mountGrid();
|
|
380
|
+
const cell = cellIn(shadow, 'r1', 'name');
|
|
381
|
+
cell.focus();
|
|
382
|
+
pressKey(cell, 'x');
|
|
383
|
+
const editorInput = await waitFor(() =>
|
|
384
|
+
shadow.querySelector<HTMLInputElement>(
|
|
385
|
+
'ui-grid-cell-editor[data-row="r1"][data-column="name"] input',
|
|
386
|
+
),
|
|
387
|
+
);
|
|
388
|
+
// attributeChangedCallback seeds the value; dispatch a microtask to let it flush.
|
|
389
|
+
await new Promise((r) => window.setTimeout(r, 0));
|
|
390
|
+
expect(editorInput.value).toBe('x');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('Escape inside the editor cancels edit and returns focus to the cell', async () => {
|
|
394
|
+
const { shadow } = await mountGrid();
|
|
395
|
+
const cell = cellIn(shadow, 'r2', 'name');
|
|
396
|
+
cell.focus();
|
|
397
|
+
pressKey(cell, 'Enter');
|
|
398
|
+
const editorInput = await waitFor(() =>
|
|
399
|
+
shadow.querySelector<HTMLInputElement>(
|
|
400
|
+
'ui-grid-cell-editor[data-row="r2"][data-column="name"] input',
|
|
401
|
+
),
|
|
402
|
+
);
|
|
403
|
+
editorInput.focus();
|
|
404
|
+
pressKey(editorInput, 'Escape');
|
|
405
|
+
await waitFor(() => {
|
|
406
|
+
const stillEditing = shadow.querySelector(
|
|
407
|
+
'ui-grid-cell-editor[data-row="r2"][data-column="name"]',
|
|
408
|
+
);
|
|
409
|
+
return stillEditing === null ? {} : null;
|
|
410
|
+
});
|
|
411
|
+
expect(
|
|
412
|
+
shadow.querySelector('ui-grid-cell-editor[data-row="r2"][data-column="name"]'),
|
|
413
|
+
).toBeNull();
|
|
414
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r2', 'name'));
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('Enter inside the editor commits and moves focus down', async () => {
|
|
418
|
+
const { shadow } = await mountGrid();
|
|
419
|
+
const cell = cellIn(shadow, 'r1', 'name');
|
|
420
|
+
cell.focus();
|
|
421
|
+
pressKey(cell, 'Enter');
|
|
422
|
+
const editorInput = await waitFor(() =>
|
|
423
|
+
shadow.querySelector<HTMLInputElement>(
|
|
424
|
+
'ui-grid-cell-editor[data-row="r1"][data-column="name"] input',
|
|
425
|
+
),
|
|
426
|
+
);
|
|
427
|
+
editorInput.focus();
|
|
428
|
+
editorInput.value = 'Alpha Prime';
|
|
429
|
+
editorInput.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
|
430
|
+
pressKey(editorInput, 'Enter');
|
|
431
|
+
await waitFor(() =>
|
|
432
|
+
shadow.querySelector(
|
|
433
|
+
'.body-cell[data-row="r2"][data-column="name"]',
|
|
434
|
+
),
|
|
435
|
+
);
|
|
436
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r2', 'name'));
|
|
437
|
+
expect(cellIn(shadow, 'r1', 'name').textContent).toContain('Alpha Prime');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('Tab inside the editor does not re-enter commit via blur (no DOM crash)', async () => {
|
|
441
|
+
// Regression: `commitCellEdit` rebuilds the cell markup, which removes
|
|
442
|
+
// the focused <input>, firing blur on it. The blur handler also calls
|
|
443
|
+
// commitCellEdit — without the `editingCell === null` guard, the nested
|
|
444
|
+
// commit tries to mutate DOM the outer render hasn't unwound yet and
|
|
445
|
+
// throws "The node to be removed is no longer a child of this node".
|
|
446
|
+
const { shadow } = await mountGrid();
|
|
447
|
+
const cell = cellIn(shadow, 'r1', 'name');
|
|
448
|
+
cell.focus();
|
|
449
|
+
pressKey(cell, 'Enter');
|
|
450
|
+
const editorInput = await waitFor(() =>
|
|
451
|
+
shadow.querySelector<HTMLInputElement>(
|
|
452
|
+
'ui-grid-cell-editor[data-row="r1"][data-column="name"] input',
|
|
453
|
+
),
|
|
454
|
+
);
|
|
455
|
+
editorInput.focus();
|
|
456
|
+
editorInput.value = 'edited';
|
|
457
|
+
editorInput.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
|
458
|
+
// Pressing Tab dispatches keydown; the commit path removes the editor,
|
|
459
|
+
// which fires blur synchronously. Both handlers call commitCellEdit.
|
|
460
|
+
expect(() => pressKey(editorInput, 'Tab')).not.toThrow();
|
|
461
|
+
await waitFor(() => (shadow.activeElement === cellIn(shadow, 'r1', 'status') ? {} : null));
|
|
462
|
+
expect(cellIn(shadow, 'r1', 'name').textContent).toContain('edited');
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('Tab inside the editor commits and moves focus right', async () => {
|
|
466
|
+
const { shadow } = await mountGrid();
|
|
467
|
+
const cell = cellIn(shadow, 'r1', 'name');
|
|
468
|
+
cell.focus();
|
|
469
|
+
pressKey(cell, 'Enter');
|
|
470
|
+
const editorInput = await waitFor(() =>
|
|
471
|
+
shadow.querySelector<HTMLInputElement>(
|
|
472
|
+
'ui-grid-cell-editor[data-row="r1"][data-column="name"] input',
|
|
473
|
+
),
|
|
474
|
+
);
|
|
475
|
+
editorInput.focus();
|
|
476
|
+
editorInput.value = 'Alpha2';
|
|
477
|
+
editorInput.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
|
478
|
+
pressKey(editorInput, 'Tab');
|
|
479
|
+
await waitFor(() => shadow.activeElement === cellIn(shadow, 'r1', 'status') ? {} : null);
|
|
480
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r1', 'status'));
|
|
481
|
+
expect(cellIn(shadow, 'r1', 'name').textContent).toContain('Alpha2');
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
describe('with grouping', () => {
|
|
485
|
+
it('ArrowDown skips group headers to the next data row in display order', async () => {
|
|
486
|
+
// Display order: Active group header, r1, r2, Pilot group header, r3, r4.
|
|
487
|
+
// ArrowDown from r2 must land on r3 (skipping the Pilot header), NOT
|
|
488
|
+
// on whatever pipeline.visibleRows happens to have at index+1.
|
|
489
|
+
const { shadow } = await mountGroupedGrid();
|
|
490
|
+
const start = cellIn(shadow, 'r2', 'name');
|
|
491
|
+
start.click();
|
|
492
|
+
pressKey(shadow.activeElement as HTMLElement, 'ArrowDown');
|
|
493
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r3', 'name'));
|
|
494
|
+
expect(cellIn(shadow, 'r3', 'name').classList.contains('cell-focused')).toBe(true);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('ArrowUp across a group boundary lands on the last row of the prior group', async () => {
|
|
498
|
+
const { shadow } = await mountGroupedGrid();
|
|
499
|
+
const start = cellIn(shadow, 'r3', 'status');
|
|
500
|
+
start.click();
|
|
501
|
+
pressKey(shadow.activeElement as HTMLElement, 'ArrowUp');
|
|
502
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r2', 'status'));
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('ArrowRight at end-of-row wraps to first column of the next data row (not a group)', async () => {
|
|
506
|
+
const { shadow } = await mountGroupedGrid();
|
|
507
|
+
const start = cellIn(shadow, 'r2', 'status');
|
|
508
|
+
start.click();
|
|
509
|
+
pressKey(shadow.activeElement as HTMLElement, 'ArrowRight');
|
|
510
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r3', 'name'));
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
describe('edit mode follows navigation only when already editing', () => {
|
|
515
|
+
it('Tab inside the editor opens the editor on the next editable cell', async () => {
|
|
516
|
+
const { shadow } = await mountEditableGrid();
|
|
517
|
+
const cell = cellIn(shadow, 'r1', 'name');
|
|
518
|
+
cell.click();
|
|
519
|
+
pressKey(cell, 'Enter');
|
|
520
|
+
const editorInput = await waitFor(() =>
|
|
521
|
+
shadow.querySelector<HTMLInputElement>(
|
|
522
|
+
'ui-grid-cell-editor[data-row="r1"][data-column="name"] input',
|
|
523
|
+
),
|
|
524
|
+
);
|
|
525
|
+
editorInput.focus();
|
|
526
|
+
pressKey(editorInput, 'Tab');
|
|
527
|
+
// After Tab, the status column editor should mount automatically
|
|
528
|
+
// because we were editing and the destination is editable.
|
|
529
|
+
await waitFor(() =>
|
|
530
|
+
shadow.querySelector('ui-grid-cell-editor[data-row="r1"][data-column="status"] input'),
|
|
531
|
+
);
|
|
532
|
+
expect(
|
|
533
|
+
shadow.querySelector('ui-grid-cell-editor[data-row="r1"][data-column="status"] input'),
|
|
534
|
+
).not.toBeNull();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('Enter inside the editor opens the editor on the cell below', async () => {
|
|
538
|
+
const { shadow } = await mountEditableGrid();
|
|
539
|
+
const cell = cellIn(shadow, 'r1', 'name');
|
|
540
|
+
cell.click();
|
|
541
|
+
pressKey(cell, 'Enter');
|
|
542
|
+
const editorInput = await waitFor(() =>
|
|
543
|
+
shadow.querySelector<HTMLInputElement>(
|
|
544
|
+
'ui-grid-cell-editor[data-row="r1"][data-column="name"] input',
|
|
545
|
+
),
|
|
546
|
+
);
|
|
547
|
+
editorInput.focus();
|
|
548
|
+
pressKey(editorInput, 'Enter');
|
|
549
|
+
await waitFor(() =>
|
|
550
|
+
shadow.querySelector('ui-grid-cell-editor[data-row="r2"][data-column="name"] input'),
|
|
551
|
+
);
|
|
552
|
+
expect(
|
|
553
|
+
shadow.querySelector('ui-grid-cell-editor[data-row="r2"][data-column="name"] input'),
|
|
554
|
+
).not.toBeNull();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('ArrowRight on a non-editing cell does NOT open the editor on the next cell', async () => {
|
|
558
|
+
const { shadow } = await mountEditableGrid();
|
|
559
|
+
const cell = cellIn(shadow, 'r1', 'name');
|
|
560
|
+
cell.click();
|
|
561
|
+
pressKey(shadow.activeElement as HTMLElement, 'ArrowRight');
|
|
562
|
+
expect(
|
|
563
|
+
shadow.querySelector('ui-grid-cell-editor[data-row="r1"][data-column="status"]'),
|
|
564
|
+
).toBeNull();
|
|
565
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r1', 'status'));
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('ArrowDown on a non-editing cell does NOT open the editor', async () => {
|
|
569
|
+
const { shadow } = await mountEditableGrid();
|
|
570
|
+
const cell = cellIn(shadow, 'r1', 'name');
|
|
571
|
+
cell.click();
|
|
572
|
+
pressKey(shadow.activeElement as HTMLElement, 'ArrowDown');
|
|
573
|
+
expect(
|
|
574
|
+
shadow.querySelector('ui-grid-cell-editor[data-row="r2"][data-column="name"]'),
|
|
575
|
+
).toBeNull();
|
|
576
|
+
expect(shadow.activeElement).toBe(cellIn(shadow, 'r2', 'name'));
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
});
|