@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/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@ornery/ui-grid-vanilla",
3
- "version": "1.0.5",
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.5"
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": "npm run build --prefix ../ui-grid-core && SASS_PATH=node_modules tsup",
33
- "watch": "npm run build --prefix ../ui-grid-core && SASS_PATH=node_modules tsup --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
  }
@@ -0,0 +1,9 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import './browser';
4
+
5
+ describe('browser entry', () => {
6
+ it('auto-registers the standalone grid element', () => {
7
+ expect(customElements.get('ui-grid-element')).toBeDefined();
8
+ });
9
+ });
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
+ });