@ornery/ui-grid-vanilla 0.1.10 → 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.
Files changed (62) hide show
  1. package/README.md +196 -0
  2. package/demo/main.ts +0 -1
  3. package/dist/index.d.mts +575 -55
  4. package/dist/index.d.ts +575 -55
  5. package/dist/index.js +4894 -1503
  6. package/dist/index.mjs +4994 -1556
  7. package/package.json +11 -8
  8. package/scss-plugin.ts +65 -0
  9. package/src/adopt-styles.ts +39 -0
  10. package/src/attribute-bridge.ts +291 -0
  11. package/src/auto-height.test.ts +142 -0
  12. package/src/auto-resize.test.ts +206 -0
  13. package/src/cell-nav.test.ts +262 -0
  14. package/src/components/grid-body-cell/grid-body-cell.ts +23 -0
  15. package/src/components/grid-body-cell/index.ts +1 -0
  16. package/src/components/grid-cell-editor/grid-cell-editor.ts +104 -0
  17. package/src/components/grid-cell-editor/index.ts +1 -0
  18. package/src/components/grid-filter-cell/grid-filter-cell.scss +14 -0
  19. package/src/components/grid-filter-cell/grid-filter-cell.template.ts +20 -0
  20. package/src/components/grid-filter-cell/grid-filter-cell.ts +121 -0
  21. package/src/components/grid-filter-cell/index.ts +1 -0
  22. package/src/components/grid-group-row/grid-group-row.scss +24 -0
  23. package/src/components/grid-group-row/grid-group-row.template.ts +23 -0
  24. package/src/components/grid-group-row/grid-group-row.ts +94 -0
  25. package/src/components/grid-group-row/index.ts +1 -0
  26. package/src/components/grid-header-cell/grid-header-cell.ts +17 -0
  27. package/src/components/grid-header-cell/index.ts +1 -0
  28. package/src/components/grid-pagination/grid-pagination.scss +39 -0
  29. package/src/components/grid-pagination/grid-pagination.template.ts +40 -0
  30. package/src/components/grid-pagination/grid-pagination.ts +204 -0
  31. package/src/components/grid-pagination/index.ts +1 -0
  32. package/src/components/grid-template/grid-template.ts +166 -0
  33. package/src/components/grid-template/index.ts +1 -0
  34. package/src/events.ts +815 -0
  35. package/src/exporter.test.ts +186 -0
  36. package/src/focus.ts +353 -0
  37. package/src/framework-slots.ts +440 -0
  38. package/src/grid-controller.ts +1402 -18
  39. package/src/icons.ts +88 -0
  40. package/src/importer.test.ts +147 -0
  41. package/src/index.integration.test.ts +2 -2
  42. package/src/index.ts +9 -0
  43. package/src/infinite-scroll.test.ts +220 -0
  44. package/src/keyboard-navigation.test.ts +579 -0
  45. package/src/patch.ts +722 -0
  46. package/src/render.ts +465 -0
  47. package/src/row-edit.test.ts +191 -0
  48. package/src/row-selection.test.ts +550 -0
  49. package/src/save-state.test.ts +235 -0
  50. package/src/scss.d.ts +4 -0
  51. package/src/templates/index.ts +62 -0
  52. package/src/test-setup.ts +23 -0
  53. package/src/ui-grid-empty.template.ts +23 -0
  54. package/src/ui-grid-shell.scss +25 -0
  55. package/src/ui-grid-shell.template.ts +45 -0
  56. package/src/ui-grid-standalone.element.ts +741 -1112
  57. package/src/utils/cell-class.ts +70 -0
  58. package/src/utils/display-items.ts +15 -0
  59. package/src/utils/dom.ts +64 -0
  60. package/src/validate.test.ts +170 -0
  61. package/tsup.config.ts +10 -0
  62. package/vite.config.ts +10 -1
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Framework-rendered slot bridge for the vanilla grid element.
3
+ *
4
+ * The vanilla grid renders every cell / header / filter / group / expandable
5
+ * / empty-state via string-interpolated templates by default. When a
6
+ * framework wrapper (Angular, React, or any other consumer) wants to own
7
+ * rendering of a slot with its own templating engine, it calls
8
+ * `setFrameworkRenderedSlots()` on the element to flip slot kinds into
9
+ * "slot placeholder" mode.
10
+ *
11
+ * In that mode the element emits `<slot name="…">` placeholders at the
12
+ * usual render sites. The wrapper listens for the `*SlotsChanged` event
13
+ * this module dispatches and inserts light-DOM nodes with matching `slot`
14
+ * attributes carrying its framework-rendered content. Slot projection then
15
+ * composes the wrapper's view back into the shadow tree without the
16
+ * wrapper ever touching the shadow DOM directly.
17
+ *
18
+ * Slot naming conventions:
19
+ * - `cell-<columnName>-<rowId>` per-cell (one slot per rendered body cell)
20
+ * - `header-<columnName>` per-column, singleton
21
+ * - `filter-<columnName>` per-column, singleton
22
+ * - `group-row-<groupId>` per-group
23
+ * - `expandable-row-<rowId>` per-row
24
+ * - `empty` singleton
25
+ */
26
+
27
+ import {
28
+ buildGridHeaderContext,
29
+ getCellValue,
30
+ type GridCellTemplateContext,
31
+ type GridColumnDef,
32
+ type GridExpandableTemplateContext,
33
+ type GridHeaderTemplateContext,
34
+ type GridLabels,
35
+ type GridRecord,
36
+ type GridRow,
37
+ type GroupItem,
38
+ } from '@ornery/ui-grid-core';
39
+
40
+ /** Which slot kinds the element should render as `<slot>` placeholders. */
41
+ export interface FrameworkRenderedSlotsConfig {
42
+ /** Column names whose body cells are framework-rendered. Everything else
43
+ * continues to use the vanilla interpolation path. */
44
+ cells?: readonly string[];
45
+ /** Column names whose header cells are framework-rendered. */
46
+ headers?: readonly string[];
47
+ /** Column names whose filter cells are framework-rendered. */
48
+ filters?: readonly string[];
49
+ /** When true, group rows are emitted as slot placeholders. */
50
+ groupRow?: boolean;
51
+ /** When true, expandable detail rows are emitted as slot placeholders. */
52
+ expandableRow?: boolean;
53
+ /** When true, the empty-state panel is emitted as a slot placeholder. */
54
+ emptyState?: boolean;
55
+ }
56
+
57
+ export interface FrameworkCellSlot {
58
+ slotName: string;
59
+ columnName: string;
60
+ rowId: string;
61
+ rowIndex: number;
62
+ context: GridCellTemplateContext;
63
+ }
64
+
65
+ export interface FrameworkHeaderSlot {
66
+ slotName: string;
67
+ columnName: string;
68
+ context: GridHeaderTemplateContext;
69
+ }
70
+
71
+ export interface FrameworkFilterSlot {
72
+ slotName: string;
73
+ columnName: string;
74
+ value: string;
75
+ placeholder: string;
76
+ disabled: boolean;
77
+ column: GridColumnDef;
78
+ }
79
+
80
+ export interface FrameworkGroupRowSlot {
81
+ slotName: string;
82
+ groupId: string;
83
+ field: string;
84
+ label: string;
85
+ count: number;
86
+ depth: number;
87
+ collapsed: boolean;
88
+ }
89
+
90
+ export interface FrameworkExpandableRowSlot {
91
+ slotName: string;
92
+ rowId: string;
93
+ rowIndex: number;
94
+ context: GridExpandableTemplateContext;
95
+ }
96
+
97
+ export interface FrameworkEmptyStateSlot {
98
+ slotName: 'empty';
99
+ heading: string;
100
+ description: string;
101
+ labels: GridLabels;
102
+ }
103
+
104
+ export interface FrameworkSlotDelta<TSlot> {
105
+ added: TSlot[];
106
+ removed: TSlot[];
107
+ }
108
+
109
+ export function cellSlotName(column: GridColumnDef): string {
110
+ return `cell-${column.name}`;
111
+ }
112
+
113
+ export function cellSlotNameForRow(column: GridColumnDef, row: GridRow): string {
114
+ return `cell-${column.name}-${row.id}`;
115
+ }
116
+
117
+ export function headerSlotName(column: GridColumnDef): string {
118
+ return `header-${column.name}`;
119
+ }
120
+
121
+ export function filterSlotName(column: GridColumnDef): string {
122
+ return `filter-${column.name}`;
123
+ }
124
+
125
+ export function groupRowSlotName(group: GroupItem): string {
126
+ return `group-row-${group.id}`;
127
+ }
128
+
129
+ export function expandableRowSlotName(row: GridRow): string {
130
+ return `expandable-row-${row.id}`;
131
+ }
132
+
133
+ /**
134
+ * Bridge state + behavior. The element creates one instance, reads the
135
+ * `has*` methods during render to decide which path to take, calls the
136
+ * `stage*` methods as it emits slot placeholders, and calls `flush()` at
137
+ * the end of each render pass to emit the add/remove delta.
138
+ */
139
+ export class FrameworkSlotBridge {
140
+ private flaggedCells = new Set<string>();
141
+ private flaggedHeaders = new Set<string>();
142
+ private flaggedFilters = new Set<string>();
143
+ private flaggedGroupRow = false;
144
+ private flaggedExpandableRow = false;
145
+ private flaggedEmptyState = false;
146
+
147
+ private lastCellSlots = new Map<string, FrameworkCellSlot>();
148
+ private lastExpandableRowSlots = new Map<string, FrameworkExpandableRowSlot>();
149
+ private lastGroupRowSlots = new Map<string, FrameworkGroupRowSlot>();
150
+ private lastHeaderSlots = new Map<string, FrameworkHeaderSlot>();
151
+ private lastFilterSlots = new Map<string, FrameworkFilterSlot>();
152
+ private lastEmptyStateSlot: FrameworkEmptyStateSlot | null = null;
153
+
154
+ private pendingCellSlots = new Map<string, FrameworkCellSlot>();
155
+ private pendingExpandableRowSlots = new Map<string, FrameworkExpandableRowSlot>();
156
+ private pendingGroupRowSlots = new Map<string, FrameworkGroupRowSlot>();
157
+ private pendingHeaderSlots = new Map<string, FrameworkHeaderSlot>();
158
+ private pendingFilterSlots = new Map<string, FrameworkFilterSlot>();
159
+ private pendingEmptyStateSlot: FrameworkEmptyStateSlot | null = null;
160
+
161
+ constructor(private readonly host: EventTarget) {}
162
+
163
+ /**
164
+ * Apply a slots config. Returns true when the structural set changed (and
165
+ * therefore a re-render is required), false when the config is a no-op.
166
+ */
167
+ configure(config: FrameworkRenderedSlotsConfig): boolean {
168
+ let structureChanged = false;
169
+
170
+ if (config.cells !== undefined) {
171
+ const next = new Set(config.cells);
172
+ if (!setsEqual(next, this.flaggedCells)) {
173
+ this.flaggedCells = next;
174
+ structureChanged = true;
175
+ }
176
+ }
177
+
178
+ if (config.headers !== undefined) {
179
+ const next = new Set(config.headers);
180
+ if (!setsEqual(next, this.flaggedHeaders)) {
181
+ this.flaggedHeaders = next;
182
+ structureChanged = true;
183
+ }
184
+ }
185
+
186
+ if (config.filters !== undefined) {
187
+ const next = new Set(config.filters);
188
+ if (!setsEqual(next, this.flaggedFilters)) {
189
+ this.flaggedFilters = next;
190
+ structureChanged = true;
191
+ }
192
+ }
193
+
194
+ if (config.groupRow !== undefined && config.groupRow !== this.flaggedGroupRow) {
195
+ this.flaggedGroupRow = config.groupRow;
196
+ structureChanged = true;
197
+ }
198
+
199
+ if (
200
+ config.expandableRow !== undefined &&
201
+ config.expandableRow !== this.flaggedExpandableRow
202
+ ) {
203
+ this.flaggedExpandableRow = config.expandableRow;
204
+ structureChanged = true;
205
+ }
206
+
207
+ if (config.emptyState !== undefined && config.emptyState !== this.flaggedEmptyState) {
208
+ this.flaggedEmptyState = config.emptyState;
209
+ structureChanged = true;
210
+ }
211
+
212
+ return structureChanged;
213
+ }
214
+
215
+ // Flag predicates — used by the render path to decide whether to emit a
216
+ // `<slot>` placeholder or run the default string-interpolation template.
217
+
218
+ hasCell(columnName: string): boolean {
219
+ return this.flaggedCells.has(columnName);
220
+ }
221
+
222
+ hasHeader(columnName: string): boolean {
223
+ return this.flaggedHeaders.has(columnName);
224
+ }
225
+
226
+ hasFilter(columnName: string): boolean {
227
+ return this.flaggedFilters.has(columnName);
228
+ }
229
+
230
+ hasGroupRow(): boolean {
231
+ return this.flaggedGroupRow;
232
+ }
233
+
234
+ hasExpandableRow(): boolean {
235
+ return this.flaggedExpandableRow;
236
+ }
237
+
238
+ hasEmptyState(): boolean {
239
+ return this.flaggedEmptyState;
240
+ }
241
+
242
+ // Staging methods — called as the render path emits slot placeholders.
243
+ // Each call records one slot descriptor into `pending*`. At the end of
244
+ // the render pass, `flush()` diffs pending vs. last and emits events.
245
+
246
+ stageCell(column: GridColumnDef, row: GridRow, rowIndex: number): void {
247
+ const slotName = cellSlotNameForRow(column, row);
248
+ const value = getCellValue(row.entity as GridRecord, column);
249
+ this.pendingCellSlots.set(slotName, {
250
+ slotName,
251
+ columnName: column.name,
252
+ rowId: row.id,
253
+ rowIndex,
254
+ context: {
255
+ $implicit: value,
256
+ value,
257
+ row: row.entity as GridRecord,
258
+ column,
259
+ rowIndex,
260
+ },
261
+ });
262
+ }
263
+
264
+ stageExpandableRow(row: GridRow, rowIndex: number): void {
265
+ const slotName = expandableRowSlotName(row);
266
+ this.pendingExpandableRowSlots.set(slotName, {
267
+ slotName,
268
+ rowId: row.id,
269
+ rowIndex,
270
+ context: {
271
+ $implicit: row.entity as GridRecord,
272
+ row: row.entity as GridRecord,
273
+ expanded: row.expanded,
274
+ rowIndex,
275
+ },
276
+ });
277
+ }
278
+
279
+ stageGroupRow(group: GroupItem): void {
280
+ const slotName = groupRowSlotName(group);
281
+ this.pendingGroupRowSlots.set(slotName, {
282
+ slotName,
283
+ groupId: group.id,
284
+ field: group.field,
285
+ label: group.label,
286
+ count: group.count,
287
+ depth: group.depth,
288
+ collapsed: group.collapsed,
289
+ });
290
+ }
291
+
292
+ stageHeader(column: GridColumnDef): void {
293
+ const slotName = headerSlotName(column);
294
+ this.pendingHeaderSlots.set(slotName, {
295
+ slotName,
296
+ columnName: column.name,
297
+ context: buildGridHeaderContext(column),
298
+ });
299
+ }
300
+
301
+ stageFilter(
302
+ column: GridColumnDef,
303
+ value: string,
304
+ placeholder: string,
305
+ disabled: boolean,
306
+ ): void {
307
+ const slotName = filterSlotName(column);
308
+ this.pendingFilterSlots.set(slotName, {
309
+ slotName,
310
+ columnName: column.name,
311
+ value,
312
+ placeholder,
313
+ disabled,
314
+ column,
315
+ });
316
+ }
317
+
318
+ stageEmptyState(heading: string, description: string, labels: GridLabels): void {
319
+ this.pendingEmptyStateSlot = {
320
+ slotName: 'empty',
321
+ heading,
322
+ description,
323
+ labels,
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Diff pending vs. last, dispatch `*SlotsChanged` events for every kind
329
+ * that moved, then swap last = pending and reset pending. Called at the
330
+ * end of every render pass.
331
+ */
332
+ flush(): void {
333
+ this.diffAndEmit(this.lastCellSlots, this.pendingCellSlots, 'cellSlotsChanged');
334
+ this.lastCellSlots = this.pendingCellSlots;
335
+ this.pendingCellSlots = new Map();
336
+
337
+ this.diffAndEmit(
338
+ this.lastExpandableRowSlots,
339
+ this.pendingExpandableRowSlots,
340
+ 'expandableRowSlotsChanged',
341
+ );
342
+ this.lastExpandableRowSlots = this.pendingExpandableRowSlots;
343
+ this.pendingExpandableRowSlots = new Map();
344
+
345
+ this.diffAndEmit(
346
+ this.lastGroupRowSlots,
347
+ this.pendingGroupRowSlots,
348
+ 'groupRowSlotsChanged',
349
+ );
350
+ this.lastGroupRowSlots = this.pendingGroupRowSlots;
351
+ this.pendingGroupRowSlots = new Map();
352
+
353
+ this.diffAndEmit(this.lastHeaderSlots, this.pendingHeaderSlots, 'headerSlotsChanged');
354
+ this.lastHeaderSlots = this.pendingHeaderSlots;
355
+ this.pendingHeaderSlots = new Map();
356
+
357
+ this.diffAndEmit(this.lastFilterSlots, this.pendingFilterSlots, 'filterSlotsChanged');
358
+ this.lastFilterSlots = this.pendingFilterSlots;
359
+ this.pendingFilterSlots = new Map();
360
+
361
+ this.diffAndEmitSingleton(
362
+ this.lastEmptyStateSlot,
363
+ this.pendingEmptyStateSlot,
364
+ 'emptyStateSlotChanged',
365
+ );
366
+ this.lastEmptyStateSlot = this.pendingEmptyStateSlot;
367
+ this.pendingEmptyStateSlot = null;
368
+ }
369
+
370
+ /**
371
+ * Data-only patch variant: merge pending entries into last without firing
372
+ * events, since the slot structure hasn't changed (only cell contexts
373
+ * refreshed). Wrappers that need context updates subscribe to
374
+ * `rowsVisibleChanged`.
375
+ */
376
+ mergePendingIntoLast(): void {
377
+ for (const [key, slot] of this.pendingCellSlots) this.lastCellSlots.set(key, slot);
378
+ this.pendingCellSlots = new Map();
379
+ this.pendingExpandableRowSlots = new Map();
380
+ this.pendingGroupRowSlots = new Map();
381
+ this.pendingHeaderSlots = new Map();
382
+ this.pendingFilterSlots = new Map();
383
+ this.pendingEmptyStateSlot = null;
384
+ }
385
+
386
+ /**
387
+ * On element teardown, emit `removed` for every remaining slot so wrappers
388
+ * can destroy their views.
389
+ */
390
+ flushRemovals(): void {
391
+ this.flush(); // pending maps are empty → every `last` entry becomes a removal.
392
+ }
393
+
394
+ private diffAndEmit<TSlot extends { slotName: string }>(
395
+ previous: Map<string, TSlot>,
396
+ current: Map<string, TSlot>,
397
+ eventName: string,
398
+ ): void {
399
+ const added: TSlot[] = [];
400
+ const removed: TSlot[] = [];
401
+ for (const [key, slot] of current) {
402
+ if (!previous.has(key)) added.push(slot);
403
+ }
404
+ for (const [key, slot] of previous) {
405
+ if (!current.has(key)) removed.push(slot);
406
+ }
407
+ if (added.length === 0 && removed.length === 0) return;
408
+ this.host.dispatchEvent(
409
+ new CustomEvent<FrameworkSlotDelta<TSlot>>(eventName, {
410
+ detail: { added, removed },
411
+ bubbles: false,
412
+ composed: false,
413
+ }),
414
+ );
415
+ }
416
+
417
+ private diffAndEmitSingleton<TSlot>(
418
+ previous: TSlot | null,
419
+ current: TSlot | null,
420
+ eventName: string,
421
+ ): void {
422
+ if (previous === current) return;
423
+ if (!previous && !current) return;
424
+ const added: TSlot[] = current ? [current] : [];
425
+ const removed: TSlot[] = previous ? [previous] : [];
426
+ this.host.dispatchEvent(
427
+ new CustomEvent<FrameworkSlotDelta<TSlot>>(eventName, {
428
+ detail: { added, removed },
429
+ bubbles: false,
430
+ composed: false,
431
+ }),
432
+ );
433
+ }
434
+ }
435
+
436
+ function setsEqual(a: Set<string>, b: Set<string>): boolean {
437
+ if (a.size !== b.size) return false;
438
+ for (const value of a) if (!b.has(value)) return false;
439
+ return true;
440
+ }