@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/src/patch.ts
CHANGED
|
@@ -450,6 +450,22 @@ export function patchBodyRegion(
|
|
|
450
450
|
bodyContainer.innerHTML = bodyContent;
|
|
451
451
|
}
|
|
452
452
|
el.lastItemsFingerprint = itemsFingerprint;
|
|
453
|
+
// Body just rebuilt — old per-row fingerprints reference cells that no
|
|
454
|
+
// longer exist in the DOM. Re-seed from the freshly-rendered rows so the
|
|
455
|
+
// next patch pass sees them as already-up-to-date.
|
|
456
|
+
el.lastRowStateFingerprints.clear();
|
|
457
|
+
for (let i = 0; i < itemsToRender.length; i++) {
|
|
458
|
+
const item = itemsToRender[i]!;
|
|
459
|
+
if (!isRowItem(item)) continue;
|
|
460
|
+
el.lastRowStateFingerprints.set(item.row.id, {
|
|
461
|
+
fingerprint: buildRowFingerprint(item.row, startIndex + i, snapshot.visibleColumns),
|
|
462
|
+
entity: item.row.entity,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
el.lastEditingCellKey = el.controller?.getEditingCellKey() ?? null;
|
|
466
|
+
el.lastFocusedCellKey = el.focusedCell
|
|
467
|
+
? `${el.focusedCell.rowId} ${el.focusedCell.columnName}`
|
|
468
|
+
: null;
|
|
453
469
|
}
|
|
454
470
|
|
|
455
471
|
/**
|
|
@@ -486,7 +502,6 @@ export function patchExistingRows(
|
|
|
486
502
|
startIndex: number,
|
|
487
503
|
): void {
|
|
488
504
|
const controller = el.controller!;
|
|
489
|
-
void controller;
|
|
490
505
|
const columns = snapshot.visibleColumns;
|
|
491
506
|
|
|
492
507
|
// Build O(1) lookup maps from a single pass over bodyContainer.children.
|
|
@@ -521,6 +536,18 @@ export function patchExistingRows(
|
|
|
521
536
|
templateMarkupMap.set(col.name, getTemplateMarkup(el, `cell-${col.name}`));
|
|
522
537
|
}
|
|
523
538
|
|
|
539
|
+
// Track which row IDs we visited so we can prune stale fingerprint entries
|
|
540
|
+
// (rows that scrolled out of the rendered window keep their last fingerprint
|
|
541
|
+
// around so when they scroll back in we still detect changes — but rows
|
|
542
|
+
// that are gone for good shouldn't grow the map indefinitely).
|
|
543
|
+
const visitedRowIds = new Set<string>();
|
|
544
|
+
const editingCellKey = controller.getEditingCellKey();
|
|
545
|
+
const focusedCellKey = el.focusedCell
|
|
546
|
+
? `${el.focusedCell.rowId} ${el.focusedCell.columnName}`
|
|
547
|
+
: null;
|
|
548
|
+
const editingMoved = editingCellKey !== el.lastEditingCellKey;
|
|
549
|
+
const focusedMoved = focusedCellKey !== el.lastFocusedCellKey;
|
|
550
|
+
|
|
524
551
|
let expandableIndex = 0;
|
|
525
552
|
for (let i = 0; i < itemsToRender.length; i++) {
|
|
526
553
|
const item = itemsToRender[i]!;
|
|
@@ -552,14 +579,104 @@ export function patchExistingRows(
|
|
|
552
579
|
if (!isRowItem(item)) continue;
|
|
553
580
|
|
|
554
581
|
const row = item.row;
|
|
582
|
+
visitedRowIds.add(row.id);
|
|
555
583
|
const rowCells = cellEls.get(row.id);
|
|
556
584
|
if (!rowCells) continue;
|
|
585
|
+
|
|
586
|
+
// Per-row visual fingerprint — captures every input that drives
|
|
587
|
+
// patchBodyCell's class / attrs / shell content. When this matches the
|
|
588
|
+
// previous render's fingerprint AND the focused/editing cell hasn't
|
|
589
|
+
// crossed in or out of this row, we can skip the per-cell patch entirely.
|
|
590
|
+
// This is the hot path for selection / focus / expand toggles, where
|
|
591
|
+
// most rows are visually unchanged but still walk the entire columns
|
|
592
|
+
// loop in the previous implementation.
|
|
593
|
+
const rowFingerprint = buildRowFingerprint(row, displayIndex, columns);
|
|
594
|
+
const lastEntry = el.lastRowStateFingerprints.get(row.id);
|
|
595
|
+
const editingTouchesRow =
|
|
596
|
+
(editingCellKey !== null && editingCellKey.startsWith(`${row.id} `)) ||
|
|
597
|
+
(el.lastEditingCellKey !== null && el.lastEditingCellKey.startsWith(`${row.id} `));
|
|
598
|
+
const focusedTouchesRow =
|
|
599
|
+
(focusedCellKey !== null && focusedCellKey.startsWith(`${row.id} `)) ||
|
|
600
|
+
(el.lastFocusedCellKey !== null && el.lastFocusedCellKey.startsWith(`${row.id} `));
|
|
601
|
+
|
|
602
|
+
// Skip when row identity (entity reference), visual fingerprint, and
|
|
603
|
+
// the focused/editing cell positions are all unchanged. The entity
|
|
604
|
+
// reference guard catches data-array swaps that produce a fresh row
|
|
605
|
+
// instance with potentially new field values.
|
|
606
|
+
const canSkip =
|
|
607
|
+
lastEntry !== undefined &&
|
|
608
|
+
lastEntry.fingerprint === rowFingerprint &&
|
|
609
|
+
lastEntry.entity === row.entity &&
|
|
610
|
+
!(editingMoved && editingTouchesRow) &&
|
|
611
|
+
!(focusedMoved && focusedTouchesRow);
|
|
612
|
+
|
|
613
|
+
if (canSkip) {
|
|
614
|
+
// Even though we're skipping the per-cell DOM patch, the row's
|
|
615
|
+
// framework-rendered slots must persist past the upcoming `flush()`.
|
|
616
|
+
// `flush()` diffs `pendingCellSlots` against `lastCellSlots`; without
|
|
617
|
+
// carrying the row's slots forward, every unchanged row in a structural
|
|
618
|
+
// patch pass would emit a spurious `cellSlotsChanged removed` event,
|
|
619
|
+
// tearing down framework wrappers' projected views (Angular embedded
|
|
620
|
+
// views, React portals) that already match the DOM.
|
|
621
|
+
el.frameworkSlots.carryRowCells(row.id);
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
el.lastRowStateFingerprints.set(row.id, { fingerprint: rowFingerprint, entity: row.entity });
|
|
626
|
+
|
|
557
627
|
for (const column of columns) {
|
|
558
628
|
const target = rowCells.get(column.name);
|
|
559
629
|
if (!target) continue;
|
|
560
630
|
patchBodyCell(el, target, row, column, displayIndex, templateMarkupMap);
|
|
561
631
|
}
|
|
562
632
|
}
|
|
633
|
+
|
|
634
|
+
// Prune fingerprint entries for rows that are no longer in the rendered
|
|
635
|
+
// window. The map otherwise grows without bound across scroll passes.
|
|
636
|
+
if (el.lastRowStateFingerprints.size > visitedRowIds.size) {
|
|
637
|
+
for (const id of el.lastRowStateFingerprints.keys()) {
|
|
638
|
+
if (!visitedRowIds.has(id)) el.lastRowStateFingerprints.delete(id);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
el.lastEditingCellKey = editingCellKey;
|
|
643
|
+
el.lastFocusedCellKey = focusedCellKey;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Per-row fingerprint of every input patchBodyCell reads off the row.
|
|
648
|
+
* Stable across renders when nothing visual has changed; flips when
|
|
649
|
+
* selection, focus, expand, dirty/saving/error, tree-expand, indent, or
|
|
650
|
+
* the entity reference moves. Used as the skip key in patchExistingRows.
|
|
651
|
+
*
|
|
652
|
+
* Also samples per-column validation state — `validate.runValidators`
|
|
653
|
+
* mutates fields directly on the row entity (`$$invalid<col>`), so we
|
|
654
|
+
* read those keys here to detect validity flips even though the entity
|
|
655
|
+
* reference is unchanged.
|
|
656
|
+
*/
|
|
657
|
+
function buildRowFingerprint(
|
|
658
|
+
row: GridRow,
|
|
659
|
+
displayIndex: number,
|
|
660
|
+
visibleColumns: readonly GridColumnDef[],
|
|
661
|
+
): string {
|
|
662
|
+
// Bit-packing flags into a single number keeps the fingerprint short.
|
|
663
|
+
let flags = 0;
|
|
664
|
+
if (row.isSelected) flags |= 1;
|
|
665
|
+
if (row.isFocused) flags |= 2;
|
|
666
|
+
if (row.expanded) flags |= 4;
|
|
667
|
+
if (row.isDirty) flags |= 8;
|
|
668
|
+
if (row.isSaving) flags |= 16;
|
|
669
|
+
if (row.isError) flags |= 32;
|
|
670
|
+
if (row.visible) flags |= 64;
|
|
671
|
+
// Per-column validity bitmap. The validate module flags cells via
|
|
672
|
+
// `$$invalid<col>` keys on the entity; without sampling them here a
|
|
673
|
+
// selection-only refresh would skip cells whose validity just flipped.
|
|
674
|
+
let invalidBits = '';
|
|
675
|
+
const entity = row.entity as Record<string, unknown>;
|
|
676
|
+
for (const col of visibleColumns) {
|
|
677
|
+
invalidBits += entity[`$$invalid${col.name}`] ? '1' : '0';
|
|
678
|
+
}
|
|
679
|
+
return `${displayIndex}|${flags}|${row.treeLevel}|${row.height}|${row.expandedRowHeight}|${invalidBits}`;
|
|
563
680
|
}
|
|
564
681
|
|
|
565
682
|
/** Patch a group-row element's data-attrs. */
|
package/src/row-edit.test.ts
CHANGED
|
@@ -105,7 +105,7 @@ describe('vanilla grid row-edit', () => {
|
|
|
105
105
|
const options = baseOptions();
|
|
106
106
|
await mountGrid(options);
|
|
107
107
|
const api = getApi(options);
|
|
108
|
-
const handler = vi.fn<
|
|
108
|
+
const handler = vi.fn<(row: Record<string, unknown>) => void>();
|
|
109
109
|
api.rowEdit.on.saveRow(handler);
|
|
110
110
|
const row = options.data[0]! as unknown as Record<string, unknown>;
|
|
111
111
|
api.edit.raise.afterCellEdit(row, options.columnDefs[0]!, 'Alpha!', 'Alpha');
|
|
@@ -111,6 +111,12 @@ export type VanillaUiGridElement = HTMLElement & {
|
|
|
111
111
|
* remains completely smooth while data ticks in the background.
|
|
112
112
|
*/
|
|
113
113
|
setData(rows: GridRecord[]): void;
|
|
114
|
+
/**
|
|
115
|
+
* Declare which slots the grid should emit as `<slot>` placeholders for a
|
|
116
|
+
* framework wrapper to project into. See `FrameworkSlotBridge` for the
|
|
117
|
+
* full contract.
|
|
118
|
+
*/
|
|
119
|
+
setFrameworkRenderedSlots(config: FrameworkRenderedSlotsConfig): void;
|
|
114
120
|
};
|
|
115
121
|
|
|
116
122
|
type VanillaGridOptions = GridOptions & {
|
|
@@ -170,6 +176,15 @@ export class UiGridStandaloneElement extends HTMLElement {
|
|
|
170
176
|
/** @internal */ lastItemsFingerprint: string | null = null;
|
|
171
177
|
/** @internal */ lastVirtualOffset = 0;
|
|
172
178
|
/** @internal */ lastTotalVirtualHeight = 0;
|
|
179
|
+
/**
|
|
180
|
+
* @internal Per-row visual-state fingerprint from the previous patch pass.
|
|
181
|
+
* The patch path uses this to skip cells whose row state hasn't changed
|
|
182
|
+
* (selection, expand, dirty/saving/error flags) when the structure
|
|
183
|
+
* fingerprint matches. Cleared on full re-render.
|
|
184
|
+
*/
|
|
185
|
+
lastRowStateFingerprints = new Map<string, { fingerprint: string; entity: unknown }>();
|
|
186
|
+
/** @internal */ lastEditingCellKey: string | null = null;
|
|
187
|
+
/** @internal */ lastFocusedCellKey: string | null = null;
|
|
173
188
|
|
|
174
189
|
/**
|
|
175
190
|
* @internal Framework-rendered slot bridge. See `./framework-slots.ts`.
|
|
@@ -220,7 +235,11 @@ export class UiGridStandaloneElement extends HTMLElement {
|
|
|
220
235
|
if (this.frameworkSlots.configure(config)) {
|
|
221
236
|
// The set of slot placeholders changed — force a structural re-render
|
|
222
237
|
// so framework-rendered slots transition to `<slot>` nodes (or the
|
|
223
|
-
// reverse, when a slot is revoked).
|
|
238
|
+
// reverse, when a slot is revoked). Also bust the per-row patch
|
|
239
|
+
// fingerprint cache, because the cell-shell content depends on
|
|
240
|
+
// hasCell() / hasHeader() / etc. — values not captured by the
|
|
241
|
+
// row-level fingerprint.
|
|
242
|
+
this.lastRowStateFingerprints.clear();
|
|
224
243
|
this.render();
|
|
225
244
|
}
|
|
226
245
|
}
|
|
@@ -692,11 +711,58 @@ export class UiGridStandaloneElement extends HTMLElement {
|
|
|
692
711
|
cancelAnimationFrame(this.scrollFrame);
|
|
693
712
|
this.scrollFrame = null;
|
|
694
713
|
}
|
|
714
|
+
this.controller?.setBenchmarkHandler(null);
|
|
695
715
|
// Emit `removed` events for every framework-rendered slot so wrappers
|
|
696
716
|
// can destroy their views.
|
|
697
717
|
this.frameworkSlots.flushRemovals();
|
|
698
718
|
}
|
|
699
719
|
|
|
720
|
+
private benchmarkRender(iterations?: number) {
|
|
721
|
+
const controller = this.controller;
|
|
722
|
+
const snapshot = this.snapshot;
|
|
723
|
+
if (!controller || !snapshot) {
|
|
724
|
+
return Promise.resolve({
|
|
725
|
+
iterations: 0,
|
|
726
|
+
totalMs: 0,
|
|
727
|
+
averageMs: 0,
|
|
728
|
+
visibleRows: 0,
|
|
729
|
+
renderedItems: 0,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const now = () => (typeof performance === 'undefined' ? Date.now() : performance.now());
|
|
734
|
+
const nextFrame = () =>
|
|
735
|
+
new Promise<void>((resolve) => {
|
|
736
|
+
if (typeof requestAnimationFrame === 'undefined') {
|
|
737
|
+
setTimeout(resolve, 0);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
requestAnimationFrame(() => resolve());
|
|
741
|
+
});
|
|
742
|
+
const loops = Math.max(1, iterations ?? controller.getOptions().benchmark?.iterations ?? 25);
|
|
743
|
+
return (async () => {
|
|
744
|
+
const started = now();
|
|
745
|
+
|
|
746
|
+
for (let index = 0; index < loops; index += 1) {
|
|
747
|
+
this.render();
|
|
748
|
+
await nextFrame();
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const currentSnapshot = this.snapshot ?? snapshot;
|
|
752
|
+
const totalMs = now() - started;
|
|
753
|
+
const result = {
|
|
754
|
+
iterations: loops,
|
|
755
|
+
totalMs,
|
|
756
|
+
averageMs: totalMs / loops,
|
|
757
|
+
visibleRows: currentSnapshot.pipeline.visibleRows.length,
|
|
758
|
+
renderedItems: currentSnapshot.pipeline.displayItems.length,
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
controller.gridApi.core.raise.benchmarkComplete(result);
|
|
762
|
+
return result;
|
|
763
|
+
})();
|
|
764
|
+
}
|
|
765
|
+
|
|
700
766
|
/** @internal */
|
|
701
767
|
ensureShadowRoot(): ShadowRoot {
|
|
702
768
|
if (!this.shadowRoot) {
|
|
@@ -709,6 +775,7 @@ export class UiGridStandaloneElement extends HTMLElement {
|
|
|
709
775
|
/** @internal */
|
|
710
776
|
ensureController(options: VanillaGridOptions): void {
|
|
711
777
|
if (this.controller) {
|
|
778
|
+
this.controller.setBenchmarkHandler((iterations) => this.benchmarkRender(iterations));
|
|
712
779
|
if (this.clientWidth > 0) {
|
|
713
780
|
this.controller.setViewportWidth(this.clientWidth);
|
|
714
781
|
}
|
|
@@ -717,6 +784,7 @@ export class UiGridStandaloneElement extends HTMLElement {
|
|
|
717
784
|
}
|
|
718
785
|
|
|
719
786
|
this.controller = createVanillaGridController(options);
|
|
787
|
+
this.controller.setBenchmarkHandler((iterations) => this.benchmarkRender(iterations));
|
|
720
788
|
if (this.clientWidth > 0) {
|
|
721
789
|
this.controller.setViewportWidth(this.clientWidth);
|
|
722
790
|
}
|
|
@@ -919,6 +987,9 @@ export class UiGridStandaloneElement extends HTMLElement {
|
|
|
919
987
|
if (!snapshot) {
|
|
920
988
|
this.lastStructureKey = null;
|
|
921
989
|
this.lastItemsFingerprint = null;
|
|
990
|
+
this.lastRowStateFingerprints.clear();
|
|
991
|
+
this.lastEditingCellKey = null;
|
|
992
|
+
this.lastFocusedCellKey = null;
|
|
922
993
|
emptyTemplate({ message: 'No grid options provided.' }).connect(root);
|
|
923
994
|
return;
|
|
924
995
|
}
|
|
@@ -1223,6 +1294,13 @@ export class UiGridStandaloneElement extends HTMLElement {
|
|
|
1223
1294
|
this.lastItemsFingerprint = this.fingerprintItems(plan.itemsToRender);
|
|
1224
1295
|
this.lastVirtualOffset = plan.virtualOffset;
|
|
1225
1296
|
this.lastTotalVirtualHeight = plan.totalVirtualHeight;
|
|
1297
|
+
// The row-state cache is keyed against the patched DOM. A full remount
|
|
1298
|
+
// wipes it; the next patch pass will repopulate as it walks rows.
|
|
1299
|
+
this.lastRowStateFingerprints.clear();
|
|
1300
|
+
this.lastEditingCellKey = this.controller?.getEditingCellKey() ?? null;
|
|
1301
|
+
this.lastFocusedCellKey = this.focusedCell
|
|
1302
|
+
? `${this.focusedCell.rowId} ${this.focusedCell.columnName}`
|
|
1303
|
+
: null;
|
|
1226
1304
|
}
|
|
1227
1305
|
|
|
1228
1306
|
private renderPatch(
|
package/tsup.config.ts
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import { defineConfig } from 'tsup';
|
|
2
2
|
import { scssInlineEsbuild } from './scss-plugin';
|
|
3
3
|
|
|
4
|
-
export default defineConfig(
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
export default defineConfig([
|
|
5
|
+
{
|
|
6
|
+
entry: ['src/index.ts'],
|
|
7
|
+
format: ['esm', 'cjs'],
|
|
8
|
+
dts: true,
|
|
9
|
+
tsconfig: './tsconfig.json',
|
|
10
|
+
esbuildPlugins: [scssInlineEsbuild()],
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
entry: { 'ui-grid-element': 'src/browser.ts' },
|
|
14
|
+
format: ['esm'],
|
|
15
|
+
dts: false,
|
|
16
|
+
splitting: false,
|
|
17
|
+
platform: 'browser',
|
|
18
|
+
target: 'es2022',
|
|
19
|
+
outDir: 'dist/browser',
|
|
20
|
+
outExtension: () => ({ js: '.js' }),
|
|
21
|
+
tsconfig: './tsconfig.json',
|
|
22
|
+
noExternal: ['@ornery/ui-grid-core'],
|
|
23
|
+
esbuildPlugins: [scssInlineEsbuild()],
|
|
24
|
+
},
|
|
25
|
+
]);
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { defineConfig } from 'vitest/config';
|
|
3
|
+
import { scssInlinePlugin } from './scss-plugin';
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [scssInlinePlugin()],
|
|
7
|
+
resolve: {
|
|
8
|
+
alias: {
|
|
9
|
+
'@ornery/ui-grid-core': path.resolve(__dirname, '../ui-grid-core/src/index.ts'),
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
css: {
|
|
13
|
+
preprocessorOptions: {
|
|
14
|
+
scss: {
|
|
15
|
+
loadPaths: [path.resolve(__dirname, 'node_modules')],
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
test: {
|
|
20
|
+
dir: path.resolve(__dirname, 'src'),
|
|
21
|
+
environment: 'happy-dom',
|
|
22
|
+
coverage: {
|
|
23
|
+
provider: 'v8',
|
|
24
|
+
include: ['src/**/*.ts'],
|
|
25
|
+
exclude: ['src/**/*.test.ts', 'src/**/*.spec.ts', 'src/test-setup.ts'],
|
|
26
|
+
reporter: ['text', 'lcov', 'html'],
|
|
27
|
+
reportsDirectory: './coverage',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
});
|