@scion/components 22.5.0 → 22.7.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 (39) hide show
  1. package/_index.scss +32 -88
  2. package/design/components/_scion-component-dark-tokens.scss +15 -0
  3. package/design/components/_scion-component-light-tokens.scss +15 -0
  4. package/fesm2022/scion-components-common.mjs +156 -5
  5. package/fesm2022/scion-components-common.mjs.map +1 -1
  6. package/fesm2022/scion-components-dimension.mjs +3 -3
  7. package/fesm2022/scion-components-dimension.mjs.map +1 -1
  8. package/fesm2022/scion-components-icon.mjs +134 -98
  9. package/fesm2022/scion-components-icon.mjs.map +1 -1
  10. package/fesm2022/scion-components-menu.mjs +58 -51
  11. package/fesm2022/scion-components-menu.mjs.map +1 -1
  12. package/fesm2022/scion-components-sashbox.mjs +1 -1
  13. package/fesm2022/scion-components-sashbox.mjs.map +1 -1
  14. package/fesm2022/scion-components-splitter.mjs +1 -1
  15. package/fesm2022/scion-components-splitter.mjs.map +1 -1
  16. package/fesm2022/scion-components-table.mjs +1544 -1008
  17. package/fesm2022/scion-components-table.mjs.map +1 -1
  18. package/fesm2022/scion-components-text.mjs +83 -40
  19. package/fesm2022/scion-components-text.mjs.map +1 -1
  20. package/fesm2022/scion-components-throbber.mjs +1 -1
  21. package/fesm2022/scion-components-throbber.mjs.map +1 -1
  22. package/fesm2022/scion-components-tree.mjs +306 -0
  23. package/fesm2022/scion-components-tree.mjs.map +1 -0
  24. package/fesm2022/scion-components-viewport.mjs +31 -18
  25. package/fesm2022/scion-components-viewport.mjs.map +1 -1
  26. package/package.json +6 -2
  27. package/tree/package.json +4 -0
  28. package/types/scion-components-common.d.ts +111 -4
  29. package/types/scion-components-icon.d.ts +42 -15
  30. package/types/scion-components-menu.d.ts +7 -2
  31. package/types/scion-components-sashbox.d.ts +1 -1
  32. package/types/scion-components-splitter.d.ts +1 -1
  33. package/types/scion-components-table.d.ts +1112 -300
  34. package/types/scion-components-text.d.ts +6 -5
  35. package/types/scion-components-throbber.d.ts +1 -1
  36. package/types/scion-components-tree.d.ts +559 -0
  37. package/types/scion-components-viewport.d.ts +20 -11
  38. package/viewport/scrollbar/_scrollbar.scss +12 -0
  39. package/design/icons/_scion-icon-font.scss +0 -50
@@ -1,24 +1,328 @@
1
1
  import * as i0 from '@angular/core';
2
- import { computed, inject, DEFAULT_CURRENCY_CODE, signal, InjectionToken, makeEnvironmentProviders, Injector, DestroyRef, runInInjectionContext, onIdle, Injectable, untracked, linkedSignal, isSignal, NgZone, effect, resource, assertNotInReactiveContext, assertInInjectionContext, Pipe, input, Component, output, viewChild, Directive, afterNextRender, ElementRef, debounced, viewChildren, ViewEncapsulation } from '@angular/core';
3
- import { coerceSignal, SCI_LOCALE, createDestroyableInjector, toLazyObservable, SciComponentOutletDirective, SciAttributesDirective } from '@scion/components/common';
4
- import { formatDate, DATE_PIPE_DEFAULT_OPTIONS, NgTemplateOutlet } from '@angular/common';
5
- import { toObservable, rxResource, takeUntilDestroyed } from '@angular/core/rxjs-interop';
6
- import { Objects, clamp, runSafe, Observables, Arrays } from '@scion/toolkit/util';
2
+ import { signal, inject, Injector, computed, DestroyRef, untracked, runInInjectionContext, onIdle, Injectable, DEFAULT_CURRENCY_CODE, InjectionToken, linkedSignal, isSignal, NgZone, effect, resource, assertNotInReactiveContext, assertInInjectionContext, Pipe, input, Component, output, viewChild, Directive, afterNextRender, ElementRef, debounced, viewChildren, ViewEncapsulation } from '@angular/core';
3
+ import { toObservable, rxResource } from '@angular/core/rxjs-interop';
7
4
  import { LatestTaskExecutor } from '@scion/toolkit/common';
8
- import { fromEvent, switchMap, concat, of, timer, firstValueFrom, defaultIfEmpty, throwError, combineLatestWith, tap, skip } from 'rxjs';
9
- import { map, startWith, first } from 'rxjs/operators';
10
- import { subscribeIn } from '@scion/toolkit/operators';
5
+ import { Arrays, Observables, Objects, clamp } from '@scion/toolkit/util';
6
+ import * as i1 from '@scion/components/common';
7
+ import { ɵSingleActiveInjectorFactory as _SingleActiveInjectorFactory, coerceSignal, SCI_LOCALE, SCI_COMPONENTS_STORAGE, ɵcreateDestroyableInjector as _createDestroyableInjector, toLazyObservable, SciComponentOutletDirective, SciTooltipOnOverflowDirective, TOOLTIP_ON_OVERFLOW_DIRECTIVE_ADDITIONAL_TEXT, TOOLTIP_ON_OVERFLOW_DIRECTIVE_SOURCE_ELEMENT, SciAttributesDirective } from '@scion/components/common';
8
+ import { mapArray, subscribeIn } from '@scion/toolkit/operators';
9
+ import { formatDate, DATE_PIPE_DEFAULT_OPTIONS, NgTemplateOutlet } from '@angular/common';
10
+ import { text, SciTextPipe } from '@scion/components/text';
11
+ import { catchError, fromEvent, switchMap, concat, of, timer, combineLatestWith, finalize, firstValueFrom, skip } from 'rxjs';
12
+ import { map, startWith } from 'rxjs/operators';
11
13
  import { SciScrollbarComponent } from '@scion/components/viewport';
12
14
  import { dimension } from '@scion/components/dimension';
13
15
  import { SciIconComponent } from '@scion/components/icon';
14
16
  import { SciToolbarComponent, contributeMenu, ɵSciMenuService as _SciMenuService } from '@scion/components/menu';
15
17
  import { UUID } from '@scion/toolkit/uuid';
16
18
  import { SciSplitterComponent } from '@scion/components/splitter';
17
- import { text, SciTextPipe } from '@scion/components/text';
18
19
  import { SciThrobberComponent } from '@scion/components/throbber';
19
- import * as i1 from '@angular/forms';
20
+ import * as i2 from '@angular/forms';
20
21
  import { FormsModule } from '@angular/forms';
21
22
 
23
+ /** @inheritDoc */
24
+ class ɵSciTableCellValuePreloader {
25
+ _executor = new LatestTaskExecutor();
26
+ _queue = new Array(); // PERF: Do not change to signal to avoid array re-allocation on enqueue (expensive); queue size is tracked via a separate signal.
27
+ _queueSize = signal(0, /* @ts-ignore */
28
+ ...(ngDevMode ? [{ debugName: "_queueSize" }] : /* istanbul ignore next */ []));
29
+ _injector = inject(Injector);
30
+ /** @inheritDoc */
31
+ loading = computed(() => this._queueSize() > 0, /* @ts-ignore */
32
+ ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
33
+ constructor() {
34
+ inject(DestroyRef).onDestroy(() => {
35
+ this._executor.destroy();
36
+ this.clear();
37
+ });
38
+ }
39
+ /** @inheritDoc */
40
+ queue(cellValueFn) {
41
+ this._queue.push(cellValueFn);
42
+ this._queueSize.update(size => size + 1);
43
+ this._executor.submit(() => this.whenIdle().then(() => this.preloadQueuedBatch(PRELOAD_BATCH_SIZE)));
44
+ }
45
+ /** @inheritDoc */
46
+ clear() {
47
+ this._queue.length = 0;
48
+ untracked(() => this._queueSize.set(0));
49
+ }
50
+ /**
51
+ * Preloads the next batch of queued cell values, FIFO, then, if the queue is not empty, schedules the next batch.
52
+ */
53
+ async preloadQueuedBatch(batchSize) {
54
+ this._queue.splice(0, batchSize).forEach(fn => fn());
55
+ this._queueSize.set(this._queue.length);
56
+ // Preload next batch when idle again.
57
+ if (this._queue.length) {
58
+ this._executor.submit(() => this.whenIdle().then(() => this.preloadQueuedBatch(PRELOAD_BATCH_SIZE)));
59
+ }
60
+ }
61
+ /**
62
+ * Resolves when the browser becomes idle.
63
+ *
64
+ * @see onIdle
65
+ */
66
+ whenIdle() {
67
+ return runInInjectionContext(this._injector, onIdle);
68
+ }
69
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: ɵSciTableCellValuePreloader, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
70
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: ɵSciTableCellValuePreloader });
71
+ }
72
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: ɵSciTableCellValuePreloader, decorators: [{
73
+ type: Injectable
74
+ }], ctorParameters: () => [] });
75
+ const PRELOAD_BATCH_SIZE = 50_000;
76
+
77
+ /**
78
+ * Preloads cell values in the background when the browser is idle. This prevents lag with large datasets, in particular
79
+ * for expensive computations like formatting dates and generating ISO 8601 strings, providing faster sort and filter performance.
80
+ *
81
+ * If a cell's value is requested before background preloading finishes, it is computed synchronously.
82
+ */
83
+ class SciTableCellValuePreloader {
84
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciTableCellValuePreloader, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
85
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciTableCellValuePreloader, providedIn: 'root', useClass: ɵSciTableCellValuePreloader });
86
+ }
87
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciTableCellValuePreloader, decorators: [{
88
+ type: Injectable,
89
+ args: [{ providedIn: 'root', useClass: ɵSciTableCellValuePreloader }]
90
+ }] });
91
+
92
+ /*
93
+ * Copyright (c) 2018-2026 Swiss Federal Railways
94
+ *
95
+ * This program and the accompanying materials are made
96
+ * available under the terms of the Eclipse Public License 2.0
97
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
98
+ *
99
+ * SPDX-License-Identifier: EPL-2.0
100
+ */
101
+ class SciInMemoryTableDatasource {
102
+ _rows;
103
+ constructor(data, table, options) {
104
+ const tableCellValuePreloader = inject(SciTableCellValuePreloader);
105
+ const injectorFactory = new _SingleActiveInjectorFactory();
106
+ this._rows = computed(() => {
107
+ // Create new value function injector, releasing resources allocated in column value functions.
108
+ const injector = injectorFactory.create();
109
+ // Cancel preloading of previous data.
110
+ tableCellValuePreloader.clear();
111
+ return data()
112
+ .flatMap(item => createRow(item))
113
+ .reduce((map, row) => map.set(row.id, row), new Map());
114
+ function createRow(item, parent) {
115
+ const id = table.trackBy(item);
116
+ return [
117
+ {
118
+ id,
119
+ parentId: parent?.id,
120
+ item,
121
+ cells: table.columns().reduce((cells, column) => untracked(() => {
122
+ // PERF: Cell values are computed lazily (on idle or first use) to defer expensive computations like date formatting.
123
+ const tableCellValue = 'value' in column ? new eSciTableCellValue(tableCellValuePreloader, () => runInInjectionContext(injector, () => column.value(item)())) : undefined;
124
+ return cells.set(column.name, {
125
+ column: column,
126
+ value: () => tableCellValue?.value(),
127
+ });
128
+ }), new Map()),
129
+ },
130
+ // Child rows if a tree structure.
131
+ ...options?.children?.(item).flatMap(child => createRow(child, { id })) ?? [],
132
+ ];
133
+ }
134
+ }, /* @ts-ignore */
135
+ ...(ngDevMode ? [{ debugName: "_rows" }] : /* istanbul ignore next */ []));
136
+ }
137
+ getTreeTableNodes(query) {
138
+ return toObservable(computed(() => {
139
+ const rows = this._rows();
140
+ // PERF: Do not track signals inside `Array.sort` to avoid Angular signal tracking overhead as the comparator runs repeatedly, degrading performance otherwise.
141
+ return untracked(() => [...rows.values()]
142
+ .filter(row => matchesRow(row, query.columnFilters, { coerceEnumValueToArray: true }) && matchesGlobalFilter(row, query.tableFilter, { coerceEnumValueToArray: false }))
143
+ .sort((a, b) => compareRows(a, b, query.sortCriteria))
144
+ .map(row => ({ id: row.id, parentId: row.parentId })));
145
+ }));
146
+ }
147
+ getItems(ids) {
148
+ return toObservable(computed(() => ids.flatMap(id => this._rows().has(id) ? this._rows().get(id).item : [])));
149
+ }
150
+ }
151
+ /**
152
+ * Tests whether a row matches the given global filter.
153
+ */
154
+ function matchesGlobalFilter(row, filter, options) {
155
+ if (!filter?.trim()) {
156
+ return true;
157
+ }
158
+ for (const cell of row.cells.values()) {
159
+ const text = cell.column.type === 'number' ? toNumber(filter) : filter;
160
+ if (text === undefined) {
161
+ continue;
162
+ }
163
+ if (matchesRow(row, [{ text, columnName: cell.column.name }], options)) {
164
+ return true;
165
+ }
166
+ }
167
+ return false;
168
+ }
169
+ /**
170
+ * Tests whether a row matches the given column filters.
171
+ */
172
+ function matchesRow(row, columnFilters, options) {
173
+ if (!columnFilters.length) {
174
+ return true;
175
+ }
176
+ for (const columnFilter of columnFilters) {
177
+ const cell = row.cells.get(columnFilter.columnName);
178
+ if ('value' in cell.column && cell.value() === undefined) { // Component and template columns have no value.
179
+ return false;
180
+ }
181
+ const filter = cell.column.type === 'enum' && options.coerceEnumValueToArray ? Arrays.coerce(columnFilter.text) : columnFilter.text;
182
+ if (!cell.column.matches(filter, { item: row.item, value: cell.value() })) {
183
+ return false;
184
+ }
185
+ }
186
+ return true;
187
+ }
188
+ /**
189
+ * Compares two rows based on the given sort criteria.
190
+ */
191
+ function compareRows(row1, row2, sortCriteria) {
192
+ if (!sortCriteria.length) {
193
+ return 0;
194
+ }
195
+ for (const criterion of sortCriteria) {
196
+ const cell1 = row1.cells.get(criterion.columnName);
197
+ const cell2 = row2.cells.get(criterion.columnName);
198
+ const value1 = cell1.value();
199
+ const value2 = cell2.value();
200
+ // Always place `undefined` values at the end, independent of the sort direction. Note that component and template columns have no value.
201
+ if (value1 === undefined && value2 !== undefined) {
202
+ return 1;
203
+ }
204
+ if (value2 === undefined && value1 !== undefined) {
205
+ return -1;
206
+ }
207
+ // Prevent calling comparator for value columns where both values are undefined. Note that component and template columns have no value.
208
+ if (value1 === undefined && value2 === undefined && 'value' in cell1.column) {
209
+ return 0;
210
+ }
211
+ const comparison = cell1.column.compare({ item: row1.item, value: value1 }, { item: row2.item, value: value2 });
212
+ if (comparison !== 0) {
213
+ return criterion.direction === 'asc' ? comparison : -comparison;
214
+ }
215
+ }
216
+ return 0;
217
+ }
218
+ /**
219
+ * Converts the given string to a number, or `undefined` if it cannot be parsed.
220
+ */
221
+ function toNumber(numberLike) {
222
+ const number = Number.parseFloat(numberLike);
223
+ return !Number.isNaN(number) ? number : undefined;
224
+ }
225
+ /**
226
+ * Provides lazy loading for the cell's value, preloading it when idle for better sort/filter performance.
227
+ *
228
+ * The value is computed at most once, whether it gets preloaded during idle time or requested synchronously via {@link value()}.
229
+ */
230
+ class eSciTableCellValue {
231
+ _valueFn;
232
+ _value;
233
+ constructor(preloader, _valueFn) {
234
+ this._valueFn = _valueFn;
235
+ preloader.queue(() => {
236
+ this._value ??= this._valueFn();
237
+ });
238
+ }
239
+ value() {
240
+ return this._value ??= untracked(() => this._valueFn());
241
+ }
242
+ }
243
+
244
+ /*
245
+ * Copyright (c) 2018-2026 Swiss Federal Railways
246
+ *
247
+ * This program and the accompanying materials are made
248
+ * available under the terms of the Eclipse Public License 2.0
249
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
250
+ *
251
+ * SPDX-License-Identifier: EPL-2.0
252
+ */
253
+ /**
254
+ * Provides an in-memory datasource for {@link SciTable} with built-in filtering and sorting.
255
+ *
256
+ * A datasource provides data to {@link SciTable}, passing each item to columns for display. For a tree structure, additionally pass a child selector to the datasource.
257
+ *
258
+ * This datasource requires all data to be loaded upfront. The datasource filters and sorts data in memory.
259
+ *
260
+ * Custom columns must configure a filter matcher and sort comparator to be filterable and sortable.
261
+ *
262
+ * For large datasets requiring pagination or server-side filtering and sorting, prefer implementing a custom datasource using {@link ɵprovideTableDatasource}.
263
+ *
264
+ * @example - Usage
265
+ * ```ts
266
+ * import {table, provideTableDatasource} from '@scion/components/table';
267
+ * import {httpResource} from '@angular/common/http';
268
+ *
269
+ * const users = httpResource<User[]>(() => '/api/users', {defaultValue: []});
270
+ *
271
+ * table({
272
+ * datasource: provideTableDatasource(users.value),
273
+ * columns: table => table.addStringColumn('Name', user => user.name),
274
+ * });
275
+ * ```
276
+ *
277
+ * @example - Displaying hierarchical data
278
+ * ```ts
279
+ * import {table, provideTableDatasource} from '@scion/components/table';
280
+ * import {httpResource} from '@angular/common/http';
281
+ *
282
+ * const users = httpResource<User[]>(() => '/api/users', {defaultValue: []});
283
+ *
284
+ * table({
285
+ * datasource: provideTableDatasource(users.value, user => user.children),
286
+ * columns: table => table.addStringColumn('Name', user => user.name),
287
+ * });
288
+ * ```
289
+ *
290
+ * @param data - Provides items to display in the table, or root items if displaying a tree structure.
291
+ * @param children - Function required to display a tree structure, returning an item's children or an empty array if the item has no children or is a leaf.
292
+ * Called eagerly for each item when initializing the table, inside a reactive context that re-evaluates when tracked signals change.
293
+ *
294
+ * @see SciTableDescriptor.datasource
295
+ */
296
+ function provideTableDatasource(data, children) {
297
+ return {
298
+ [eSCI_TABLE_DATASOURCE_PROVIDER]: (table) => new SciInMemoryTableDatasource(data, table, { children }),
299
+ };
300
+ }
301
+ function ɵprovideTableDatasource(datasource) {
302
+ const isTreeTableDatesource = 'getTreeTableNodes' in datasource;
303
+ return {
304
+ [eSCI_TABLE_DATASOURCE_PROVIDER]: () => ({
305
+ getTreeTableNodes: (query) => isTreeTableDatesource ? datasource.getTreeTableNodes(query) : Observables.coerce(datasource.getIds(query)).pipe(mapArray(id => ({ id }))),
306
+ getItems: (ids) => datasource.getItems(ids),
307
+ }),
308
+ };
309
+ }
310
+ /**
311
+ * Symbol for providing a {@link SciTreeTableDatasource}.
312
+ */
313
+ const eSCI_TABLE_DATASOURCE_PROVIDER = Symbol('eSCI_TABLE_DATASOURCE_PROVIDER');
314
+
315
+ /*
316
+ * Copyright (c) 2018-2026 Swiss Federal Railways
317
+ *
318
+ * This program and the accompanying materials are made
319
+ * available under the terms of the Eclipse Public License 2.0
320
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
321
+ *
322
+ * SPDX-License-Identifier: EPL-2.0
323
+ */
324
+ const skeletonRowId = Symbol('SKELETON_ROW');
325
+
22
326
  /**
23
327
  * Creates a {@link SciDateValue} from the given date-like input.
24
328
  *
@@ -78,6 +382,39 @@ function ensureIso8601DatePrefix(isoDate) {
78
382
  }
79
383
  }
80
384
 
385
+ /*
386
+ * Copyright (c) 2018-2026 Swiss Federal Railways
387
+ *
388
+ * This program and the accompanying materials are made
389
+ * available under the terms of the Eclipse Public License 2.0
390
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
391
+ *
392
+ * SPDX-License-Identifier: EPL-2.0
393
+ */
394
+ /**
395
+ * Creates a {@link SciEnumValue} from the given enum-like input.
396
+ */
397
+ function coerceEnumValue(enumLike, options) {
398
+ const value = coerceSignal(enumLike, { coerceUndefined: true });
399
+ const enumeration = coerceSignal(options.enumeration);
400
+ const enumEntry = computed(() => value() !== undefined ? enumeration().find(entry => entry.value === value()) : undefined, /* @ts-ignore */
401
+ ...(ngDevMode ? [{ debugName: "enumEntry" }] : /* istanbul ignore next */ []));
402
+ const label = text(computed(() => enumEntry()?.label), { injector: options.injector ?? inject(Injector) });
403
+ const tooltip = text(computed(() => enumEntry()?.tooltip), { injector: options.injector ?? inject(Injector) });
404
+ return computed(() => {
405
+ if (!enumEntry()) {
406
+ return undefined;
407
+ }
408
+ return {
409
+ value: enumEntry().value,
410
+ label: label(),
411
+ icon: enumEntry().icon,
412
+ // Use tooltip if provided, or fall back to label, but only if it is not configured for display.
413
+ tooltip: tooltip() ?? (tooltip() === null || options.format.label ? undefined : label()),
414
+ };
415
+ });
416
+ }
417
+
81
418
  /*
82
419
  * Copyright (c) 2018-2026 Swiss Federal Railways
83
420
  *
@@ -101,7 +438,7 @@ class ɵSciTableColumnFactory {
101
438
  this.columns.push({
102
439
  ...column,
103
440
  type: 'string',
104
- value: item => coerceSignal(descriptor.value(item), { coerceUndefined: true }),
441
+ value: item => text(descriptor.value(item)),
105
442
  sortable: computed(() => this._table.sortable() && !!sortable),
106
443
  filterable: computed(() => this._table.filterable() && !!filterable),
107
444
  compare: typeof sortable === 'object' ? sortable.comparator : (a, b) => a.value.localeCompare(b.value),
@@ -128,15 +465,16 @@ class ɵSciTableColumnFactory {
128
465
  }
129
466
  addBooleanColumn(descriptorLike, value) {
130
467
  const descriptor = coerceColumnDescriptor(descriptorLike, value);
131
- const column = this.mapToColumn(descriptor);
132
- this.columns.push({
133
- ...column,
134
- type: 'boolean',
468
+ this.addEnumColumn({
469
+ ...descriptor,
135
470
  value: item => coerceSignal(descriptor.value(item), { coerceUndefined: true }),
136
- sortable: computed(() => this._table.sortable() && (descriptor.sortable ?? true)),
137
- filterable: computed(() => this._table.filterable() && (descriptor.filterable ?? true)),
138
- compare: (a, b) => a.value === b.value ? 0 : (a.value ? 1 : -1),
139
- matches: (text, context) => context.value === text,
471
+ values: [
472
+ { value: true, icon: 'scion:true', label: '%scion:components:yes.value', tooltip: null },
473
+ { value: false, icon: 'scion:false', label: '%scion:components:no.value', tooltip: null },
474
+ ],
475
+ format: { icon: true },
476
+ filterable: descriptor.filterable === false ? false : 'single',
477
+ referenceType: 'boolean',
140
478
  });
141
479
  return this;
142
480
  }
@@ -161,392 +499,141 @@ class ɵSciTableColumnFactory {
161
499
  });
162
500
  return this;
163
501
  }
164
- addComponentColumn(descriptor) {
165
- // Require a filter matcher if filterable on an array datasource.
166
- if (this._table.isArrayDatasource() && descriptor.filterable === true) {
167
- throw Error('[ColumnDefinitionError] Component column requires a filter matcher in order to be filterable.');
168
- }
169
- // Require a sort comparator if sortable on an array datasource.
170
- if (this._table.isArrayDatasource() && descriptor.sortable === true) {
171
- throw Error('[ColumnDefinitionError] Component column requires a sort comparator in order to be sortable.');
172
- }
502
+ addEnumColumn(descriptor) {
173
503
  const column = this.mapToColumn(descriptor);
504
+ const enumeration = coerceSignal(descriptor.values);
505
+ const positionByEnumValue = computed(() => enumeration().reduce((acc, entry, index) => acc.set(entry.value, index), new Map()), /* @ts-ignore */
506
+ ...(ngDevMode ? [{ debugName: "positionByEnumValue" }] : /* istanbul ignore next */ []));
507
+ const format = descriptor.format
508
+ ? { icon: descriptor.format.icon ?? false, label: descriptor.format.label ?? false, filter: { icon: descriptor.format.filter?.icon ?? false } } // eslint-disable-line @stylistic/operator-linebreak
509
+ : { icon: false, label: true, filter: { icon: true } }; // eslint-disable-line @stylistic/operator-linebreak
174
510
  this.columns.push({
175
511
  ...column,
176
- type: 'component',
177
- component: descriptor.component,
178
- sortable: computed(() => this._table.sortable() && !!descriptor.sortable),
179
- filterable: computed(() => this._table.filterable() && !!descriptor.filterable),
180
- compare: typeof descriptor.sortable === 'object' ? descriptor.sortable.comparator : () => 0,
181
- matches: typeof descriptor.filterable === 'object' ? descriptor.filterable.matcher : () => true,
182
- padding: descriptor.padding ?? true,
512
+ type: 'enum',
513
+ referenceType: descriptor.referenceType,
514
+ value: item => coerceEnumValue(descriptor.value(item), { enumeration, format }),
515
+ sortable: computed(() => this._table.sortable() && (descriptor.sortable ?? true)),
516
+ filterable: computed(() => this._table.filterable() && (descriptor.filterable ?? 'multi')),
517
+ enumeration,
518
+ compare: (a, b) => untracked(() => (positionByEnumValue().get(a.value.value) ?? 0) - (positionByEnumValue().get(b.value.value) ?? 0)),
519
+ matches: (filter, context) => {
520
+ if (typeof filter === 'string') { // table filter
521
+ return context.value.label.toLowerCase().includes(filter.toLowerCase());
522
+ }
523
+ else {
524
+ return filter.includes(context.value.value); // column filter
525
+ }
526
+ },
527
+ format,
183
528
  });
184
529
  return this;
185
530
  }
186
- addTemplateColumn(descriptor) {
187
- // Require a filter matcher if filterable on an array datasource.
188
- if (this._table.isArrayDatasource() && descriptor.filterable === true) {
189
- throw Error('[ColumnDefinitionError] Template column requires a filter matcher in order to be filterable.');
190
- }
191
- // Require a sort comparator if sortable on an array datasource.
192
- if (this._table.isArrayDatasource() && descriptor.sortable === true) {
193
- throw Error('[ColumnDefinitionError] Template column requires a sort comparator in order to be sortable.');
194
- }
195
- const column = this.mapToColumn(descriptor);
196
- this.columns.push({
197
- ...column,
198
- type: 'template',
199
- template: descriptor.template,
200
- sortable: computed(() => this._table.sortable() && !!descriptor.sortable),
201
- filterable: computed(() => this._table.filterable() && !!descriptor.filterable),
202
- compare: typeof descriptor.sortable === 'object' ? descriptor.sortable.comparator : () => 0,
203
- matches: typeof descriptor.filterable === 'object' ? descriptor.filterable.matcher : () => true,
204
- padding: descriptor.padding ?? true,
531
+ addIconColumn(descriptor) {
532
+ const resizable = typeof descriptor.header === 'string' || !!descriptor.header?.label?.length;
533
+ this.addEnumColumn({
534
+ ...descriptor,
535
+ value: item => coerceSignal(descriptor.value(item), { coerceUndefined: true }),
536
+ format: { icon: true, filter: { icon: true } },
537
+ resizable: descriptor.resizable ?? resizable,
538
+ width: descriptor.width ?? (resizable ? undefined : 'max-content'), // adapt width to intrinsic size
539
+ minWidth: descriptor.minWidth ?? (resizable ? undefined : 32),
540
+ referenceType: 'icon',
205
541
  });
206
542
  return this;
207
543
  }
208
- /**
209
- * Maps given column descriptor to a {@link SciTableColumn}.
210
- */
211
- mapToColumn(descriptor) {
212
- // Require a unique column name.
213
- const name = descriptor.name ?? `column:${this.columns.length}`;
214
- if (this.columns.find(column => column.name === name)) {
215
- throw Error(`[ColumnDefinitionError] Column names must be unique. "${name}" is defined more than once.`);
216
- }
217
- // Disallow a filter matcher if using a custom datasource.
218
- if (!this._table.isArrayDatasource() && typeof descriptor.filterable === 'object') {
219
- throw Error('[ColumnDefinitionError] Configuring a filter matcher is not supported for tables using a datasource. Filtering must be done by the datasource.');
220
- }
221
- // Disallow a sort comparator if using a custom datasource.
222
- if (!this._table.isArrayDatasource() && typeof descriptor.sortable === 'object') {
223
- throw Error('[ColumnDefinitionError] Configuring a sort comparator is not supported for tables using a datasource. Sorting must be done by the datasource.');
224
- }
225
- return {
226
- name: name,
227
- header: coerceSignal(descriptor.header ?? ''),
228
- resizable: computed(() => this._table.resizable() && (descriptor.resizable ?? true)),
229
- width: computed(() => {
230
- const userSettings = this._table.userSettings().columns?.find(column => column.name === name);
231
- return userSettings?.width ? `${userSettings.width}px` : descriptor.width ?? '1fr';
232
- }),
233
- minWidth: descriptor.minWidth ?? 100,
234
- resizing: signal(false),
235
- padding: true,
236
- location: { x: 0, width: 0 }, // set in `SciTableColumnComponent`
237
- };
238
- }
239
- }
240
- function coerceColumnDescriptor(argument1, argument2) {
241
- switch (typeof argument1) {
242
- case 'string':
243
- return { header: argument1, value: argument2 };
244
- case 'function':
245
- return { value: argument1 };
246
- default:
247
- return argument1;
248
- }
249
- }
250
-
251
- /*
252
- * Copyright (c) 2018-2026 Swiss Federal Railways
253
- *
254
- * This program and the accompanying materials are made
255
- * available under the terms of the Eclipse Public License 2.0
256
- * which is available at https://www.eclipse.org/legal/epl-2.0/
257
- *
258
- * SPDX-License-Identifier: EPL-2.0
259
- */
260
- /**
261
- * Creates a range of integers starting at `start` and ending at `end` (including `end`).
262
- */
263
- function rangeInclusive(start, end) {
264
- const range = [];
265
- for (let i = start; i <= end; i++) {
266
- range.push(i);
267
- }
268
- return range;
269
- }
270
-
271
- /*
272
- * Copyright (c) 2018-2026 Swiss Federal Railways
273
- *
274
- * This program and the accompanying materials are made
275
- * available under the terms of the Eclipse Public License 2.0
276
- * which is available at https://www.eclipse.org/legal/epl-2.0/
277
- *
278
- * SPDX-License-Identifier: EPL-2.0
279
- */
280
- const SCI_TABLE_STORAGE = new InjectionToken('SCI_TABLE_STORAGE', { factory: () => ({
281
- load: (name) => localStorage.getItem(name),
282
- store: (name, value) => localStorage.setItem(name, value),
283
- }) });
284
- /**
285
- * Defaults to localStorage persistence.
286
- * @param storage
287
- */
288
- function provideTableStorage(storage) {
289
- return makeEnvironmentProviders([
290
- { provide: SCI_TABLE_STORAGE, useClass: storage },
291
- ]);
292
- }
293
-
294
- /** @inheritDoc */
295
- class ɵSciTableCellValuePreloader {
296
- _executor = new LatestTaskExecutor();
297
- _queue = new Array(); // PERF: Do not change to signal to avoid array re-allocation on enqueue (expensive); queue size is tracked via a separate signal.
298
- _queueSize = signal(0, /* @ts-ignore */
299
- ...(ngDevMode ? [{ debugName: "_queueSize" }] : /* istanbul ignore next */ []));
300
- _injector = inject(Injector);
301
- /** @inheritDoc */
302
- loading = computed(() => this._queueSize() > 0, /* @ts-ignore */
303
- ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
304
- constructor() {
305
- inject(DestroyRef).onDestroy(() => {
306
- this._executor.destroy();
307
- this._queue.length = 0;
308
- this._queueSize.set(0);
309
- });
310
- }
311
- /** @inheritDoc */
312
- queue(cellValueFn) {
313
- this._queue.push(cellValueFn);
314
- this._queueSize.update(size => size + 1);
315
- this._executor.submit(() => this.whenIdle().then(() => this.preloadQueuedBatch(PRELOAD_BATCH_SIZE)));
316
- }
317
- /**
318
- * Preloads the next batch of queued cell values, FIFO, then, if the queue is not empty, schedules the next batch.
319
- */
320
- async preloadQueuedBatch(batchSize) {
321
- this._queue.splice(0, batchSize).forEach(fn => fn());
322
- this._queueSize.set(this._queue.length);
323
- // Preload next batch when idle again.
324
- if (this._queue.length) {
325
- this._executor.submit(() => this.whenIdle().then(() => this.preloadQueuedBatch(PRELOAD_BATCH_SIZE)));
326
- }
327
- }
328
- /**
329
- * Resolves when the browser becomes idle.
330
- *
331
- * @see onIdle
332
- */
333
- whenIdle() {
334
- return runInInjectionContext(this._injector, onIdle);
335
- }
336
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: ɵSciTableCellValuePreloader, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
337
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: ɵSciTableCellValuePreloader });
338
- }
339
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: ɵSciTableCellValuePreloader, decorators: [{
340
- type: Injectable
341
- }], ctorParameters: () => [] });
342
- const PRELOAD_BATCH_SIZE = 50_000;
343
-
344
- /**
345
- * Preloads cell values in the background when the browser is idle. This prevents lag with large datasets, in particular
346
- * for expensive computations like formatting dates and generating ISO 8601 strings, providing faster sort and filter performance.
347
- *
348
- * If a cell's value is requested before background preloading finishes, it is computed synchronously.
349
- */
350
- class SciTableCellValuePreloader {
351
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciTableCellValuePreloader, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
352
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciTableCellValuePreloader, providedIn: 'root', useClass: ɵSciTableCellValuePreloader });
353
- }
354
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciTableCellValuePreloader, decorators: [{
355
- type: Injectable,
356
- args: [{ providedIn: 'root', useClass: ɵSciTableCellValuePreloader }]
357
- }] });
358
-
359
- /*
360
- * Copyright (c) 2018-2026 Swiss Federal Railways
361
- *
362
- * This program and the accompanying materials are made
363
- * available under the terms of the Eclipse Public License 2.0
364
- * which is available at https://www.eclipse.org/legal/epl-2.0/
365
- *
366
- * SPDX-License-Identifier: EPL-2.0
367
- */
368
- function arrayDatasource(data, columns) {
369
- const dataset = new Dataset(data, columns);
370
- return markAsArrayDatasource((request) => {
371
- dataset.columnFilters.set(request.columnFilters);
372
- dataset.tableFilter.set(request.tableFilter);
373
- dataset.sortCriteria.set(request.sortCriteria);
374
- const totalCount = dataset.count;
375
- const items = dataset.slice(request.start, request.end);
376
- return toObservable(computed(() => ({
377
- totalCount: totalCount(),
378
- items: items(),
379
- })));
380
- });
381
- }
382
- /**
383
- * Provides a filtered and sorted view on given data.
384
- */
385
- class Dataset {
386
- _dataview;
387
- columnFilters = signal([], { ...(ngDevMode ? { debugName: "columnFilters" } : /* istanbul ignore next */ {}), equal: Objects.isEqual });
388
- sortCriteria = signal([], { ...(ngDevMode ? { debugName: "sortCriteria" } : /* istanbul ignore next */ {}), equal: Objects.isEqual });
389
- tableFilter = signal(undefined, /* @ts-ignore */
390
- ...(ngDevMode ? [{ debugName: "tableFilter" }] : /* istanbul ignore next */ []));
391
- /**
392
- * Returns the total count of items matching the current filters and search criteria.
393
- */
394
- count = computed(() => this._dataview().length, /* @ts-ignore */
395
- ...(ngDevMode ? [{ debugName: "count" }] : /* istanbul ignore next */ []));
396
- constructor(data, columns) {
397
- const tableCellValuePreloader = inject(SciTableCellValuePreloader);
398
- const dataset = computed(() => data().map(item => ({
399
- item,
400
- cells: columns().reduce((cells, column) => untracked(() => {
401
- // PERF: Cell values are computed lazily (on idle or first use) to defer expensive computations like date formatting.
402
- const tableCellValue = 'value' in column ? new eSciTableCellValue(tableCellValuePreloader, () => column.value(item)()) : undefined;
403
- return cells.set(column.name, {
404
- column: column,
405
- value: () => tableCellValue?.value(),
406
- });
407
- }), new Map()),
408
- })), /* @ts-ignore */
409
- ...(ngDevMode ? [{ debugName: "dataset" }] : /* istanbul ignore next */ []));
410
- // Memoize filtered/sorted view; recomputes only when dataset or criteria change, not when scrolling through the view, as sorting is an expensive operation.
411
- this._dataview = computed(() => {
412
- const rows = dataset();
413
- const columnFilters = this.columnFilters();
414
- const tableFilter = this.tableFilter();
415
- const sortCriteria = this.sortCriteria();
416
- // PERF: Do not track signals inside `Array.sort` to avoid Angular signal tracking overhead as the comparator runs repeatedly, degrading performance otherwise.
417
- return untracked(() => rows
418
- .filter(row => matchesRow(row, columnFilters) && matchesGlobalFilter(row, tableFilter))
419
- .sort((a, b) => compareRows(a, b, sortCriteria))
420
- .map(row => row.item));
421
- }, /* @ts-ignore */
422
- ...(ngDevMode ? [{ debugName: "_dataview" }] : /* istanbul ignore next */ []));
423
- }
424
- /**
425
- * Returns a slice of the filtered and sorted data within the given range (start inclusive, end exclusive).
426
- */
427
- slice(start, end) {
428
- return computed(() => this._dataview().slice(start, end));
429
- }
430
- }
431
- /**
432
- * Tests whether a row matches the given global filter.
433
- */
434
- function matchesGlobalFilter(row, filter) {
435
- if (!filter?.trim()) {
436
- return true;
437
- }
438
- for (const cell of row.cells.values()) {
439
- const text = coerceFilterText(filter, { to: cell.column.type });
440
- if (text === undefined) {
441
- continue;
442
- }
443
- if (matchesRow(row, [{ text, columnName: cell.column.name }])) {
444
- return true;
445
- }
446
- }
447
- return false;
448
- }
449
- /**
450
- * Tests whether a row matches the given column filters.
451
- */
452
- function matchesRow(row, columnFilters) {
453
- if (!columnFilters.length) {
454
- return true;
455
- }
456
- for (const columnFilter of columnFilters) {
457
- const cell = row.cells.get(columnFilter.columnName);
458
- if ('value' in cell.column && cell.value() === undefined) { // Component and template columns have no value.
459
- return false;
544
+ addComponentColumn(descriptor) {
545
+ // Require a filter matcher if filterable using the built-in in-memory datasource.
546
+ if (this._table.isInMemoryDatasource() && descriptor.filterable === true) {
547
+ throw Error('[ColumnDefinitionError] Component column requires a filter matcher in order to be filterable.');
460
548
  }
461
- if (!cell.column.matches(columnFilter.text, { item: row.item, value: cell.value() })) {
462
- return false;
549
+ // Require a sort comparator if sortable using the built-in in-memory datasource.
550
+ if (this._table.isInMemoryDatasource() && descriptor.sortable === true) {
551
+ throw Error('[ColumnDefinitionError] Component column requires a sort comparator in order to be sortable.');
463
552
  }
553
+ const column = this.mapToColumn(descriptor);
554
+ this.columns.push({
555
+ ...column,
556
+ type: 'component',
557
+ component: descriptor.component,
558
+ sortable: computed(() => this._table.sortable() && !!descriptor.sortable),
559
+ filterable: computed(() => this._table.filterable() && !!descriptor.filterable),
560
+ compare: typeof descriptor.sortable === 'object' ? descriptor.sortable.comparator : () => 0,
561
+ matches: typeof descriptor.filterable === 'object' ? descriptor.filterable.matcher : () => false,
562
+ padding: descriptor.padding ?? true,
563
+ });
564
+ return this;
464
565
  }
465
- return true;
466
- }
467
- /**
468
- * Compares two rows based on the given sort criteria.
469
- */
470
- function compareRows(row1, row2, sortCriteria) {
471
- if (!sortCriteria.length) {
472
- return 0;
473
- }
474
- for (const criterion of sortCriteria) {
475
- const cell1 = row1.cells.get(criterion.columnName);
476
- const cell2 = row2.cells.get(criterion.columnName);
477
- const value1 = cell1.value();
478
- const value2 = cell2.value();
479
- // Always place `undefined` values at the end, independent of the sort direction. Note that component and template columns have no value.
480
- if (value1 === undefined && value2 !== undefined) {
481
- return 1;
482
- }
483
- if (value2 === undefined && value1 !== undefined) {
484
- return -1;
566
+ addTemplateColumn(descriptor) {
567
+ // Require a filter matcher if filterable using the built-in in-memory datasource.
568
+ if (this._table.isInMemoryDatasource() && descriptor.filterable === true) {
569
+ throw Error('[ColumnDefinitionError] Template column requires a filter matcher in order to be filterable.');
485
570
  }
486
- const comparison = cell1.column.compare({ item: row1.item, value: value1 }, { item: row2.item, value: value2 });
487
- if (comparison !== 0) {
488
- return criterion.direction === 'asc' ? comparison : -comparison;
571
+ // Require a sort comparator if sortable using the built-in in-memory datasource.
572
+ if (this._table.isInMemoryDatasource() && descriptor.sortable === true) {
573
+ throw Error('[ColumnDefinitionError] Template column requires a sort comparator in order to be sortable.');
489
574
  }
575
+ const column = this.mapToColumn(descriptor);
576
+ this.columns.push({
577
+ ...column,
578
+ type: 'template',
579
+ template: descriptor.template,
580
+ sortable: computed(() => this._table.sortable() && !!descriptor.sortable),
581
+ filterable: computed(() => this._table.filterable() && !!descriptor.filterable),
582
+ compare: typeof descriptor.sortable === 'object' ? descriptor.sortable.comparator : () => 0,
583
+ matches: typeof descriptor.filterable === 'object' ? descriptor.filterable.matcher : () => false,
584
+ padding: descriptor.padding ?? true,
585
+ });
586
+ return this;
490
587
  }
491
- return 0;
492
- }
493
- /**
494
- * Checks whether given {@link SciTableDataLoaderFn} represents an {@link arrayDatasource}.
495
- */
496
- function isArrayDatasource(loader) {
497
- return ARRAY_DATASOURCE_MARKER in loader;
498
- }
499
- /**
500
- * Marks given {@link SciTableDataLoaderFn} as an array data source.
501
- */
502
- function markAsArrayDatasource(loader) {
503
- Object.defineProperty(loader, ARRAY_DATASOURCE_MARKER, { value: true, writable: false, enumerable: false, configurable: false });
504
- return loader;
505
- }
506
- /**
507
- * Identifies a {@link SciTableDataLoaderFn} as an {@link arrayDatasource}.
508
- */
509
- const ARRAY_DATASOURCE_MARKER = Symbol('ARRAY_DATASOURCE_HINT');
510
- /**
511
- * Coerces the given filter text into the specified column data type.
512
- */
513
- function coerceFilterText(text, options) {
514
- switch (options.to) {
515
- case 'number': {
516
- const number = Number.parseFloat(text);
517
- return !Number.isNaN(number) ? number : undefined;
588
+ /**
589
+ * Maps given column descriptor to a {@link SciTableColumn}.
590
+ */
591
+ mapToColumn(descriptor) {
592
+ // Require a unique column name.
593
+ const name = descriptor.name ?? `column:${this.columns.length}`;
594
+ if (this.columns.find(column => column.name === name)) {
595
+ throw Error(`[ColumnDefinitionError] Column names must be unique. "${name}" is defined more than once.`);
518
596
  }
519
- case 'boolean': {
520
- if (text === 'true' || text === '1') {
521
- return true;
522
- }
523
- if (text === 'false' || text === '0') {
524
- return false;
525
- }
526
- return undefined;
597
+ // Ensure filter matcher is only configured if using the built-in in-memory datasource.
598
+ if (!this._table.isInMemoryDatasource() && typeof descriptor.filterable === 'object') {
599
+ throw Error('[ColumnDefinitionError] Configuring a filter matcher is not supported for tables using a datasource. Filtering must be done by the datasource.');
527
600
  }
528
- default: {
529
- return text;
601
+ // Ensure sort comparator is only configured if using the built-in in-memory datasource.
602
+ if (!this._table.isInMemoryDatasource() && typeof descriptor.sortable === 'object') {
603
+ throw Error('[ColumnDefinitionError] Configuring a sort comparator is not supported for tables using a datasource. Sorting must be done by the datasource.');
530
604
  }
605
+ return {
606
+ name: name,
607
+ header: untracked(() => {
608
+ if (typeof descriptor.header === 'string') {
609
+ return { label: text(descriptor.header), icon: undefined, tooltip: undefined };
610
+ }
611
+ return {
612
+ label: descriptor.header?.label !== undefined ? text(descriptor.header.label) : undefined,
613
+ icon: descriptor.header?.icon,
614
+ tooltip: descriptor.header?.tooltip !== undefined ? text(descriptor.header.tooltip) : undefined,
615
+ };
616
+ }),
617
+ resizable: computed(() => this._table.resizable() && (descriptor.resizable ?? true)),
618
+ width: computed(() => {
619
+ const userSettings = this._table.userSettings().columns?.find(column => column.name === name);
620
+ return userSettings?.width ? `${userSettings.width}px` : descriptor.width ?? '1fr';
621
+ }),
622
+ minWidth: descriptor.minWidth ?? 100,
623
+ resizing: signal(false),
624
+ padding: true,
625
+ location: { x: 0, width: 0 }, // set in `SciTableColumnComponent`
626
+ };
531
627
  }
532
628
  }
533
- /**
534
- * Provides lazy loading for the cell's value, preloading it when idle for better sort/filter performance.
535
- *
536
- * The value is computed at most once, whether it gets preloaded during idle time or requested synchronously via {@link value()}.
537
- */
538
- class eSciTableCellValue {
539
- _valueFn;
540
- _value;
541
- constructor(preloader, _valueFn) {
542
- this._valueFn = _valueFn;
543
- preloader.queue(() => {
544
- this._value ??= this._valueFn();
545
- });
546
- }
547
- value() {
548
- return this._value ??= untracked(() => this._valueFn());
629
+ function coerceColumnDescriptor(argument1, argument2) {
630
+ if (argument2 === undefined) {
631
+ return typeof argument1 === 'function' ? { value: argument1 } : argument1;
549
632
  }
633
+ return {
634
+ header: argument1,
635
+ value: argument2,
636
+ };
550
637
  }
551
638
 
552
639
  /*
@@ -559,57 +646,68 @@ class eSciTableCellValue {
559
646
  * SPDX-License-Identifier: EPL-2.0
560
647
  */
561
648
  class SciTableCache {
562
- _cache = signal(new Map(), /* @ts-ignore */
563
- ...(ngDevMode ? [{ debugName: "_cache" }] : /* istanbul ignore next */ []));
564
- loading = computed(() => this.values().some(entry => entry.rows.isLoading()), /* @ts-ignore */
649
+ _entriesById = signal(new Map(), /* @ts-ignore */
650
+ ...(ngDevMode ? [{ debugName: "_entriesById" }] : /* istanbul ignore next */ []));
651
+ _idsByEntry = computed(() => [...this._entriesById().entries()].reduce((map, [id, entry]) => {
652
+ const ids = map.get(entry) ?? new Set();
653
+ ids.add(id);
654
+ return map.set(entry, ids);
655
+ }, new Map()), /* @ts-ignore */
656
+ ...(ngDevMode ? [{ debugName: "_idsByEntry" }] : /* istanbul ignore next */ []));
657
+ rowsById = computed(() => this._entries()
658
+ .flatMap(entry => entry.status() === 'error' ? [] : entry.value())
659
+ .reduce((acc, row) => row.id !== skeletonRowId ? acc.set(row.id, row) : acc, new Map()), { ...(ngDevMode ? { debugName: "rowsById" } : /* istanbul ignore next */ {}), equal: Objects.isEqual });
660
+ _entries = computed(() => [...new Set(this._entriesById().values())], /* @ts-ignore */
661
+ ...(ngDevMode ? [{ debugName: "_entries" }] : /* istanbul ignore next */ []));
662
+ loading = computed(() => this._entries().some(entry => entry.isLoading()), /* @ts-ignore */
565
663
  ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
566
- error = computed(() => this.values().find(entry => entry.rows.error())?.rows.error(), /* @ts-ignore */
664
+ error = computed(() => this._entries().some(entry => entry.status() === 'error'), /* @ts-ignore */
567
665
  ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
568
- values = computed(() => [...this._cache().values()], /* @ts-ignore */
569
- ...(ngDevMode ? [{ debugName: "values" }] : /* istanbul ignore next */ []));
570
- rowsByIndex = computed(() => this.values()
571
- .flatMap(page => page.rows.value())
572
- .reduce((acc, row) => acc.set(row.index, row), new Map()), { ...(ngDevMode ? { debugName: "rowsByIndex" } : /* istanbul ignore next */ {}), equal: Objects.isEqual });
573
- rowsById = computed(() => this.values()
574
- .flatMap(page => page.rows.value())
575
- .reduce((acc, row) => row.id !== undefined ? acc.set(row.id, row) : acc, new Map()), { ...(ngDevMode ? { debugName: "rowsById" } : /* istanbul ignore next */ {}), equal: Objects.isEqual });
576
666
  has(key) {
577
- return this._cache().has(key);
667
+ return this._entriesById().has(key);
578
668
  }
579
- get(key) {
580
- return this._cache().get(key);
669
+ set(ids, entry) {
670
+ this._entriesById.update(cache => {
671
+ const newCache = new Map(cache);
672
+ for (const id of ids) {
673
+ newCache.set(id, entry);
674
+ }
675
+ return newCache;
676
+ });
581
677
  }
582
- set(key, entry) {
583
- this._cache.update(cache => {
678
+ deleteIfLoading(ids) {
679
+ this._entriesById.update(cache => {
584
680
  const cacheCopy = new Map(cache);
585
- const existing = cacheCopy.get(key);
586
- if (existing) {
587
- cacheCopy.delete(key);
588
- existing.dispose();
681
+ const entries = new Set(ids.map(id => cacheCopy.get(id)));
682
+ for (const id of ids) {
683
+ if (cacheCopy.get(id)?.isLoading()) {
684
+ cacheCopy.delete(id);
685
+ }
686
+ }
687
+ // Dispose entries, which have no ids left pointing to them.
688
+ for (const entry of entries) {
689
+ if (!entry) {
690
+ continue;
691
+ }
692
+ const idsByEntry = this._idsByEntry().get(entry);
693
+ if (entry.isLoading() && [...(idsByEntry ?? [])].every(id => ids.includes(id))) {
694
+ entry.destroy();
695
+ }
589
696
  }
590
- cacheCopy.set(key, entry);
591
697
  return cacheCopy;
592
698
  });
593
699
  }
594
- /**
595
- * Deletes the specified page from the cache, but only if still loading.
596
- */
597
- deleteIfLoading(key) {
598
- const cacheEntry = this._cache().get(key);
599
- if (!cacheEntry?.rows.isLoading()) {
600
- return;
700
+ reloadFailed() {
701
+ for (const entry of this._entries()) {
702
+ if (entry.status() === 'error') {
703
+ entry.reload();
704
+ }
601
705
  }
602
- this._cache.update(cache => {
603
- const cacheCopy = new Map(cache);
604
- cacheCopy.delete(key);
605
- cacheEntry.dispose();
606
- return cacheCopy;
607
- });
608
706
  }
609
707
  clear() {
610
- this._cache.update(cache => {
611
- for (const entry of cache.values()) {
612
- entry.dispose();
708
+ this._entriesById.update(cache => {
709
+ for (const entry of new Set(cache.values())) {
710
+ entry.destroy();
613
711
  }
614
712
  return new Map();
615
713
  });
@@ -625,32 +723,57 @@ class SciTableCache {
625
723
  *
626
724
  * SPDX-License-Identifier: EPL-2.0
627
725
  */
726
+ /**
727
+ * Provides row bindings to tables in the scope of the injector providing the bindings.
728
+ *
729
+ * Multiple providers can be registered. Providers at different injector tree levels form a hierarchy.
730
+ */
628
731
  function provideTableRowBinding(bindings) {
629
- return {
630
- provide: SCI_TABLE_ROW_BINDING,
631
- useValue: bindings,
632
- multi: true,
633
- };
732
+ return [
733
+ {
734
+ provide: SCI_TABLE_ROW_BINDING,
735
+ useValue: bindings,
736
+ multi: true,
737
+ },
738
+ {
739
+ provide: SCI_TABLE_ROW_BINDINGS,
740
+ useFactory: () => {
741
+ return [
742
+ // Collect bindings registered in parent injectors.
743
+ ...inject(SCI_TABLE_ROW_BINDINGS, { skipSelf: true, optional: true }) ?? [],
744
+ // Collect bindings registered in this injector.
745
+ ...inject(SCI_TABLE_ROW_BINDING),
746
+ ];
747
+ },
748
+ },
749
+ ];
634
750
  }
635
751
  class ɵSciTableRowBindingFactory {
636
- attributeBindings = new Array();
637
- classBindings = new Array();
638
- partBindings = new Array();
639
- addAttributeBinding(name, value) {
640
- if (value !== undefined) {
641
- this.attributeBindings.push([name, value]);
752
+ icons = new Array();
753
+ parts = new Array();
754
+ attributes = new Array();
755
+ classes = new Array();
756
+ addIconBinding(icon) {
757
+ if (icon) {
758
+ this.icons.push(icon);
642
759
  }
643
760
  return this;
644
761
  }
645
- addClassBinding(name) {
762
+ addPartBinding(name) {
646
763
  if (name) {
647
- this.classBindings.push(name);
764
+ this.parts.push(name);
648
765
  }
649
766
  return this;
650
767
  }
651
- addPartBinding(name) {
768
+ addAttributeBinding(name, value) {
769
+ if (value !== undefined) {
770
+ this.attributes.push([name, value]);
771
+ }
772
+ return this;
773
+ }
774
+ addClassBinding(name) {
652
775
  if (name) {
653
- this.partBindings.push(name);
776
+ this.classes.push(name);
654
777
  }
655
778
  return this;
656
779
  }
@@ -663,12 +786,341 @@ function coerceTableRowBindings(bindingsFn, item, index) {
663
786
  }, /* @ts-ignore */
664
787
  ...(ngDevMode ? [{ debugName: "factory" }] : /* istanbul ignore next */ []));
665
788
  return {
666
- attributes: computed(() => Object.fromEntries(factory().attributeBindings), { equal: Objects.isEqual }),
667
- cssClass: computed(() => factory().classBindings, { equal: Objects.isEqual }),
668
- part: computed(() => factory().partBindings, { equal: Objects.isEqual }),
789
+ icons: computed(() => factory().icons, { equal: Objects.isEqual }),
790
+ parts: computed(() => factory().parts, { equal: Objects.isEqual }),
791
+ attributes: computed(() => Object.fromEntries(factory().attributes), { equal: Objects.isEqual }),
792
+ classes: computed(() => factory().classes, { equal: Objects.isEqual }),
669
793
  };
670
794
  }
795
+ /**
796
+ * Multi-DI token for registering a {@link SciTableRowBindingFactory}.
797
+ */
671
798
  const SCI_TABLE_ROW_BINDING = new InjectionToken('SCI_TABLE_ROW_BINDING');
799
+ /**
800
+ * DI token to inject {@link SciTableRowBindingFactory} functions provided in the current and parent injectors.
801
+ */
802
+ const SCI_TABLE_ROW_BINDINGS = new InjectionToken('SCI_TABLE_ROW_BINDINGS');
803
+
804
+ /**
805
+ * Loads and builds the table's hierarchy using item IDs and parent relationships, without loading full items.
806
+ * Only operates on IDs, never full objects and regardless of the viewport / virtual scrolling state.
807
+ * For non-hierarchical datasources, treats each item as a root node and preserves the order from the unfiltered response.
808
+ *
809
+ * Uses two independent requests:
810
+ * - An unfiltered request loads all nodes initially and whenever the sort criteria change.
811
+ * - A filtered request loads matching nodes when a filter is present and whenever the filters change.
812
+ *
813
+ * Combines both responses to include matching nodes and their parents and children, preserving the order from the unfiltered response.
814
+ * When no filter is present, uses the unfiltered response directly.
815
+ *
816
+ * @example
817
+ * ```ts
818
+ * const unfilteredNodes = [
819
+ * {item: 'Downloads'},
820
+ * {item: 'Desktop'},
821
+ * {item: 'New Folder', parent: 'Desktop'},
822
+ * {item: 'notes.txt', parent: 'New Folder'},
823
+ * ];
824
+ *
825
+ * // Filtered by 'notes'
826
+ * const filteredNodes = [{item: 'notes.txt', parent: 'New Folder'}];
827
+ *
828
+ * // Result includes the matching node and its ancestors.
829
+ * const tree = [
830
+ * {item: 'Desktop'},
831
+ * {item: 'New Folder', parent: 'Desktop'},
832
+ * {item: 'notes.txt', parent: 'New Folder'},
833
+ * ];
834
+ * ```
835
+ */
836
+ class SciTableTree {
837
+ _injector = inject(Injector);
838
+ _datasource;
839
+ _sortCriteria;
840
+ _columnFilters;
841
+ _tableFilter;
842
+ _activeNode = this.computeActiveNode();
843
+ _selectedNodes = this.computeSelectedNodes();
844
+ _expandedNodes = this.computeExpandedNodes();
845
+ _unfilteredNodes = this.computeUnfilteredNodes();
846
+ _filteredNodes = this.computeFilteredNodes();
847
+ _tree = this.computeTree();
848
+ expandedNodes = this._expandedNodes.asReadonly();
849
+ selectedNodes = this._selectedNodes.asReadonly();
850
+ activeNode = this._activeNode.asReadonly();
851
+ error = computed(() => this._unfilteredNodes.status() === 'error' || this._filteredNodes.status() === 'error', /* @ts-ignore */
852
+ ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
853
+ loading = computed(() => this._unfilteredNodes.isLoading() || this._filteredNodes.isLoading(), /* @ts-ignore */
854
+ ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
855
+ unfilteredNodesById = computed(() => this._unfilteredNodes.status() === 'error' ? new Map() : this._unfilteredNodes.value().reduce((acc, node) => acc.set(node.id, node), new Map()), /* @ts-ignore */
856
+ ...(ngDevMode ? [{ debugName: "unfilteredNodesById" }] : /* istanbul ignore next */ []));
857
+ nodes = computed(() => this._tree().nodes, /* @ts-ignore */
858
+ ...(ngDevMode ? [{ debugName: "nodes" }] : /* istanbul ignore next */ []));
859
+ nodesById = computed(() => this._tree().nodesById, /* @ts-ignore */
860
+ ...(ngDevMode ? [{ debugName: "nodesById" }] : /* istanbul ignore next */ []));
861
+ childrenIdsByParent = computed(() => this._tree().childrenIdsByParent, /* @ts-ignore */
862
+ ...(ngDevMode ? [{ debugName: "childrenIdsByParent" }] : /* istanbul ignore next */ []));
863
+ visibleNodes = this.computeVisibleNodes();
864
+ nodeIndexById = computed(() => this.visibleNodes().reduce((map, node, index) => map.set(node.id, index), new Map()), /* @ts-ignore */
865
+ ...(ngDevMode ? [{ debugName: "nodeIndexById" }] : /* istanbul ignore next */ []));
866
+ visibleNodeCount = computed(() => this.visibleNodes().length, /* @ts-ignore */
867
+ ...(ngDevMode ? [{ debugName: "visibleNodeCount" }] : /* istanbul ignore next */ []));
868
+ constructor(options) {
869
+ this._datasource = options.datasource;
870
+ this._columnFilters = options.columnFilters;
871
+ this._tableFilter = options.tableFilter;
872
+ this._sortCriteria = options.sortCriteria;
873
+ }
874
+ reload() {
875
+ this._unfilteredNodes.reload();
876
+ }
877
+ reloadFailed() {
878
+ if (this._unfilteredNodes.status() === 'error') {
879
+ this._unfilteredNodes.reload();
880
+ }
881
+ if (this._filteredNodes.status() === 'error') {
882
+ this._filteredNodes.reload();
883
+ }
884
+ }
885
+ expand(id) {
886
+ const ids = Arrays.coerce(id);
887
+ this._expandedNodes.update(toggledRows => new Set([...toggledRows, ...ids]));
888
+ }
889
+ expandAll() {
890
+ this._expandedNodes.set(new Set([...this.childrenIdsByParent().keys()].filter((id) => id !== undefined)));
891
+ }
892
+ collapse(id) {
893
+ const ids = Arrays.coerce(id);
894
+ this._expandedNodes.update(toggledRows => {
895
+ const newToggledRows = new Set(toggledRows);
896
+ ids.forEach(id => newToggledRows.delete(id));
897
+ return newToggledRows;
898
+ });
899
+ }
900
+ collapseAll() {
901
+ this._expandedNodes.set(new Set());
902
+ }
903
+ toggleRow(id) {
904
+ this._expandedNodes.update(toggledRows => {
905
+ if (toggledRows.has(id)) {
906
+ const newToggledRows = new Set(toggledRows);
907
+ newToggledRows.delete(id);
908
+ return newToggledRows;
909
+ }
910
+ else {
911
+ return new Set(toggledRows).add(id);
912
+ }
913
+ });
914
+ }
915
+ setActiveNode(id) {
916
+ this._activeNode.set(id);
917
+ }
918
+ updateSelectedNodes(updateFn) {
919
+ this._selectedNodes.update(updateFn);
920
+ }
921
+ select(ids) {
922
+ // Prevents selecting nodes which do not exist.
923
+ // Check against unfiltered nodes, to allow selecting nodes, which are filtered out.
924
+ const nodesById = this.unfilteredNodesById();
925
+ const nodeIds = Arrays.coerce(ids).filter(id => nodesById.has(id));
926
+ // Expand nodes.
927
+ nodeIds.forEach(id => {
928
+ const node = nodesById.get(id);
929
+ if (node?.parentId !== undefined && nodesById.has(node.parentId)) {
930
+ // Expand all nodes before the selected ones.
931
+ this.expand(getPathFromRoot(nodesById.get(node.parentId), nodesById));
932
+ }
933
+ });
934
+ // Wait for viewport height to update after expanding nodes.
935
+ // Else scrollActiveRowIntoViewport operates on an old viewport height.
936
+ requestAnimationFrame(() => {
937
+ this._selectedNodes.set(new Set(nodeIds));
938
+ // Find and activate last item based on the visual index.
939
+ const lastItemIndex = Math.max(...nodeIds.map(id => this.nodeIndexById().get(id) ?? -1));
940
+ const lastItem = this.visibleNodes()[lastItemIndex]?.id;
941
+ this._activeNode.set(lastItem);
942
+ });
943
+ }
944
+ getIds$(query, options) {
945
+ return runInInjectionContext(options.injector, () => Observables.coerce(this._datasource.getTreeTableNodes({
946
+ columnFilters: query.columnFilters ?? [],
947
+ sortCriteria: query.sortCriteria ?? [],
948
+ tableFilter: query.tableFilter,
949
+ })).pipe(catchError(error => {
950
+ console.error(error);
951
+ throw error;
952
+ })));
953
+ }
954
+ computeUnfilteredNodes() {
955
+ // Create separate injection context per resource invocation to clean up allocated resources.
956
+ const injectorFactory = new _SingleActiveInjectorFactory({ parent: this._injector });
957
+ return rxResource({
958
+ params: () => this._sortCriteria(),
959
+ stream: ({ params: sortCriteria }) => this.getIds$({ sortCriteria }, { injector: injectorFactory.create() }),
960
+ defaultValue: [],
961
+ });
962
+ }
963
+ computeFilteredNodes() {
964
+ // Create separate injection context per resource invocation to clean up allocated resources.
965
+ const injectorFactory = new _SingleActiveInjectorFactory({ parent: this._injector });
966
+ return rxResource({
967
+ params: () => ({
968
+ columnFilters: this._columnFilters(),
969
+ tableFilter: this._tableFilter() ?? undefined,
970
+ }),
971
+ stream: ({ params }) => {
972
+ const injector = injectorFactory.create();
973
+ if (params.columnFilters.length === 0 && params.tableFilter === undefined) {
974
+ return runInInjectionContext(injector, () => toObservable(this._unfilteredNodes.value));
975
+ }
976
+ return this.getIds$({ columnFilters: params.columnFilters, tableFilter: params.tableFilter }, { injector });
977
+ },
978
+ defaultValue: [],
979
+ });
980
+ }
981
+ computeTree() {
982
+ return computed(() => {
983
+ if (this.error()) {
984
+ return {
985
+ nodes: [],
986
+ nodesById: new Map(),
987
+ childrenIdsByParent: new Map(),
988
+ };
989
+ }
990
+ const filteredNodes = this._filteredNodes.value();
991
+ const unfilteredNodes = this._unfilteredNodes.value();
992
+ return untracked(() => createTree(filteredNodes, unfilteredNodes));
993
+ });
994
+ }
995
+ computeExpandedNodes() {
996
+ return linkedSignal({
997
+ source: () => ({ unfilteredNodesById: this.unfilteredNodesById(), loading: this._unfilteredNodes.isLoading(), columnFilters: this._columnFilters(), tableFilter: this._tableFilter() }),
998
+ computation: ({ unfilteredNodesById, loading, tableFilter, columnFilters }, previous) => {
999
+ // Wait until resource finished loading before changing the expanded nodes.
1000
+ if (loading) {
1001
+ return previous?.value ?? new Set();
1002
+ }
1003
+ // Expand all items if filter is set.
1004
+ if (columnFilters.length > 0 || tableFilter?.trim()) {
1005
+ return new Set(unfilteredNodesById.keys());
1006
+ }
1007
+ const expandedNodeIds = previous?.value ?? new Set();
1008
+ return new Set([...expandedNodeIds].filter(id => unfilteredNodesById.has(id)));
1009
+ },
1010
+ });
1011
+ }
1012
+ computeSelectedNodes() {
1013
+ return linkedSignal({
1014
+ source: () => ({ unfilteredNodesById: this.unfilteredNodesById(), loading: this._unfilteredNodes.isLoading() }),
1015
+ computation: ({ unfilteredNodesById, loading }, previous) => {
1016
+ // Wait until resource finished loading before changing the selected nodes.
1017
+ if (loading) {
1018
+ return previous?.value ?? new Set();
1019
+ }
1020
+ const selectedNodeIds = previous?.value ?? new Set();
1021
+ return new Set([...selectedNodeIds].filter(id => unfilteredNodesById.has(id)));
1022
+ },
1023
+ });
1024
+ }
1025
+ computeActiveNode() {
1026
+ return linkedSignal({
1027
+ source: () => ({ unfilteredNodesById: this.unfilteredNodesById(), loading: this._unfilteredNodes.isLoading() }),
1028
+ computation: ({ unfilteredNodesById, loading }, previous) => {
1029
+ // Wait until resource finished loading before changing the active nodes.
1030
+ if (loading) {
1031
+ return previous?.value;
1032
+ }
1033
+ return previous?.value !== undefined && unfilteredNodesById.has(previous.value) ? previous.value : undefined;
1034
+ },
1035
+ });
1036
+ }
1037
+ computeVisibleNodes() {
1038
+ return computed(() => {
1039
+ if (this.error()) {
1040
+ return [];
1041
+ }
1042
+ const expandedNodes = this._expandedNodes();
1043
+ const nodesById = this.nodesById();
1044
+ const nodes = this.nodes();
1045
+ const allAncestorsExpanded = (node) => {
1046
+ if (node?.parentId === undefined) {
1047
+ return true;
1048
+ }
1049
+ if (!expandedNodes.has(node.parentId)) {
1050
+ return false;
1051
+ }
1052
+ return allAncestorsExpanded(nodesById.get(node.parentId));
1053
+ };
1054
+ return untracked(() => nodes.filter(allAncestorsExpanded));
1055
+ });
1056
+ }
1057
+ }
1058
+ function createTree(filteredNodes, unfilteredNodes) {
1059
+ const nodesById = includeParentsAndChildren(unfilteredNodes, filteredNodes);
1060
+ const sortedNodes = unfilteredNodes.filter(node => nodesById.has(node.id));
1061
+ const childrenIdsByParent = groupChildrenByParent(sortedNodes);
1062
+ const nodes = flattenTree(nodesById, childrenIdsByParent);
1063
+ return ({ nodes, nodesById, childrenIdsByParent });
1064
+ }
1065
+ function includeParentsAndChildren(unfilteredNodes, filteredNodes) {
1066
+ const nodesById = new Map(unfilteredNodes.map(node => [node.id, node]));
1067
+ const filteredIds = new Set(filteredNodes.map(node => node.id));
1068
+ return unfilteredNodes.reduce((acc, node) => {
1069
+ const path = getPathFromRoot(node, nodesById);
1070
+ // Include the full path (parents and children) of any filtered node.
1071
+ // This ensures that all parents and children are displayed, even if they don't match the filter.
1072
+ if (path.some(id => filteredIds.has(id))) {
1073
+ path.forEach(id => acc.set(id, nodesById.get(id)));
1074
+ }
1075
+ return acc;
1076
+ }, new Map());
1077
+ }
1078
+ function groupChildrenByParent(nodes) {
1079
+ return nodes.reduce((acc, node) => {
1080
+ const values = acc.get(node.parentId) ?? [];
1081
+ values.push(node.id);
1082
+ return acc.set(node.parentId, values);
1083
+ }, new Map());
1084
+ }
1085
+ /**
1086
+ * Flattens the tree in depth-first order, placing each parent before its descendants and preserving sibling order.
1087
+ * Includes all nodes regardless of expansion state.
1088
+ *
1089
+ * Example:
1090
+ * ```
1091
+ * [
1092
+ * {id: '1'},
1093
+ * {id: '1.1', parentId: '1'},
1094
+ * {id: '1.2', parentId: '1'},
1095
+ * {id: '1.2.1', parentId: '1.2'},
1096
+ * {id: '1.3', parentId: '1'},
1097
+ * {id: '2'},
1098
+ * ]
1099
+ * ```
1100
+ */
1101
+ function flattenTree(nodesById, childrenIdsByParent) {
1102
+ const nodes = [];
1103
+ const roots = childrenIdsByParent.get(undefined) ?? [];
1104
+ const stack = roots.slice().reverse();
1105
+ // Don't use recursion, to avoid stack overflow on large amount of root nodes (e.g. flat table).
1106
+ while (stack.length > 0) {
1107
+ const id = stack.pop();
1108
+ nodes.push(nodesById.get(id));
1109
+ // Push children in reverse order so the first child is visited next.
1110
+ const children = childrenIdsByParent.get(id) ?? [];
1111
+ for (let i = children.length - 1; i >= 0; i--) {
1112
+ if (children[i] !== undefined) {
1113
+ stack.push(children[i]);
1114
+ }
1115
+ }
1116
+ }
1117
+ return nodes;
1118
+ }
1119
+ function getPathFromRoot(node, nodesById) {
1120
+ const parent = node.parentId !== undefined ? nodesById.get(node.parentId) : undefined;
1121
+ const pathFromRoot = parent ? getPathFromRoot(parent, nodesById) : [];
1122
+ return pathFromRoot.concat(node.id);
1123
+ }
672
1124
 
673
1125
  /*
674
1126
  * Copyright (c) 2018-2026 Swiss Federal Railways
@@ -679,19 +1131,23 @@ const SCI_TABLE_ROW_BINDING = new InjectionToken('SCI_TABLE_ROW_BINDING');
679
1131
  *
680
1132
  * SPDX-License-Identifier: EPL-2.0
681
1133
  */
1134
+ /**
1135
+ * @docs-private Not public API. For internal use only.
1136
+ */
682
1137
  class ɵSciTable {
683
- _tableStorage = inject(SCI_TABLE_STORAGE);
1138
+ _tableStorage = inject(SCI_COMPONENTS_STORAGE);
684
1139
  _injector = inject(Injector);
685
1140
  name = signal(undefined, /* @ts-ignore */
686
1141
  ...(ngDevMode ? [{ debugName: "name" }] : /* istanbul ignore next */ []));
687
1142
  columns;
688
1143
  rowActions;
689
1144
  _rowBindings;
690
- _dataLoaderFn;
1145
+ _datasource;
691
1146
  _trackBy;
692
1147
  tableViewRef = signal(undefined, /* @ts-ignore */
693
1148
  ...(ngDevMode ? [{ debugName: "tableViewRef" }] : /* istanbul ignore next */ []));
694
1149
  userSettings;
1150
+ type;
695
1151
  bufferSize;
696
1152
  pageSize;
697
1153
  filterable;
@@ -710,42 +1166,38 @@ class ɵSciTable {
710
1166
  sortCriteria = linkedSignal({ ...(ngDevMode ? { debugName: "sortCriteria" } : /* istanbul ignore next */ {}), source: () => this.columns(), // Discard stale criteria on column change.
711
1167
  computation: (columns, previous) => previous?.value.filter(criteria => columns.some(column => column.name === criteria.columnName)) ?? [],
712
1168
  equal: Objects.isEqual });
713
- filterCriteria = linkedSignal({ ...(ngDevMode ? { debugName: "filterCriteria" } : /* istanbul ignore next */ {}), source: () => this.columns(), // Discard stale criteria on column change.
1169
+ columnFilters = linkedSignal({ ...(ngDevMode ? { debugName: "columnFilters" } : /* istanbul ignore next */ {}), source: () => this.columns(), // Discard stale criteria on column change.
714
1170
  computation: (columns, previous) => previous?.value.filter(criteria => columns.some(column => column.name === criteria.columnName)) ?? [],
715
1171
  equal: Objects.isEqual });
716
1172
  _cache = new SciTableCache();
717
1173
  _tableCellValuePreloader = inject(SciTableCellValuePreloader);
718
1174
  _tableFilter = signal(null, /* @ts-ignore */
719
1175
  ...(ngDevMode ? [{ debugName: "_tableFilter" }] : /* istanbul ignore next */ []));
720
- _selectedItems = signal(new Map(), /* @ts-ignore */
721
- ...(ngDevMode ? [{ debugName: "_selectedItems" }] : /* istanbul ignore next */ []));
722
- // Reset total count on criteria change to show skeletons instead of stale data while loading.
723
- totalCount = linkedSignal({ ...(ngDevMode ? { debugName: "totalCount" } : /* istanbul ignore next */ {}), source: () => this.criteria(),
724
- computation: () => undefined });
725
- activeItem = linkedSignal({ ...(ngDevMode ? { debugName: "activeItem" } : /* istanbul ignore next */ {}), source: () => this.criteria(),
726
- computation: () => undefined });
1176
+ _tableTree;
727
1177
  hoveredIndex = linkedSignal({ ...(ngDevMode ? { debugName: "hoveredIndex" } : /* istanbul ignore next */ {}), source: () => {
728
1178
  this.criteria(); // reset on criteria change
729
1179
  this.scrolling(); // reset when start scrolling
730
1180
  },
731
1181
  computation: () => -1 });
732
- criteria = computed(() => ({ sort: this.sortCriteria(), filter: this.filterCriteria(), tableFilter: this._tableFilter() }), /* @ts-ignore */
1182
+ criteria = computed(() => ({ sort: this.sortCriteria(), filter: this.columnFilters(), tableFilter: this._tableFilter() }), /* @ts-ignore */
733
1183
  ...(ngDevMode ? [{ debugName: "criteria" }] : /* istanbul ignore next */ []));
734
- loading = computed(() => this._cache.loading() || this._tableCellValuePreloader.loading(), /* @ts-ignore */
1184
+ loading = computed(() => this._tableTree.loading() || this._cache.loading() || this._tableCellValuePreloader.loading(), /* @ts-ignore */
735
1185
  ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
736
- error = this._cache.error;
1186
+ error = computed(() => this._cache.error() || this._tableTree.error(), /* @ts-ignore */
1187
+ ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
737
1188
  activeRow;
738
- hoveredRow = computed(() => this.rowsByIndex().get(this.hoveredIndex()), /* @ts-ignore */
739
- ...(ngDevMode ? [{ debugName: "hoveredRow" }] : /* istanbul ignore next */ []));
740
- selectedItems = computed(() => [...this._selectedItems().values()], /* @ts-ignore */
1189
+ selectedItems = computed(() => [...this._tableTree.selectedNodes().values()], /* @ts-ignore */
741
1190
  ...(ngDevMode ? [{ debugName: "selectedItems" }] : /* istanbul ignore next */ []));
742
- selectedIds = computed(() => new Set([...this._selectedItems().keys()]), /* @ts-ignore */
743
- ...(ngDevMode ? [{ debugName: "selectedIds" }] : /* istanbul ignore next */ []));
744
- rowsByIndex = this._cache.rowsByIndex;
1191
+ rowsById = this._cache.rowsById;
745
1192
  rows = this.computeRows();
1193
+ visibleRowCount;
1194
+ visibleRows;
1195
+ rowIndexById;
1196
+ activeItem;
1197
+ activeIndex = computed(() => this.activeItem() !== undefined ? (this.rowIndexById().get(this.activeItem()) ?? -1) : -1, /* @ts-ignore */
1198
+ ...(ngDevMode ? [{ debugName: "activeIndex" }] : /* istanbul ignore next */ []));
746
1199
  constructor(descriptor) {
747
- // TODO [table] Remove after datasource is final. See ɵillegaldatasource.
748
- descriptor = { ...descriptor, ɵdatasource: descriptor.ɵdatasource ?? descriptor.datasource };
1200
+ this.type = descriptor.type ?? 'table';
749
1201
  this.bufferSize = descriptor.bufferSize ?? 10;
750
1202
  this.pageSize = descriptor.pageSize ?? 50;
751
1203
  this.sortable = signal(descriptor.sortable ?? true, /* @ts-ignore */
@@ -771,11 +1223,21 @@ class ɵSciTable {
771
1223
  this.rowActions = descriptor.rowActions;
772
1224
  this._rowBindings = [
773
1225
  ...descriptor.rowBindings ? [descriptor.rowBindings] : [],
774
- ...inject(SCI_TABLE_ROW_BINDING, { optional: true }) ?? [],
1226
+ ...inject(SCI_TABLE_ROW_BINDINGS, { optional: true }) ?? [],
775
1227
  ];
776
1228
  this._trackBy = descriptor.trackBy;
777
- this._dataLoaderFn = isSignal(descriptor.ɵdatasource) ? arrayDatasource(descriptor.ɵdatasource, this.columns) : descriptor.ɵdatasource;
778
- this.installCriteriaWatcher();
1229
+ const datasourceProvider = isSignal(descriptor.datasource) ? provideTableDatasource(descriptor.datasource) : descriptor.datasource;
1230
+ this._datasource = datasourceProvider[eSCI_TABLE_DATASOURCE_PROVIDER](this);
1231
+ this._tableTree = new SciTableTree({
1232
+ datasource: this._datasource,
1233
+ sortCriteria: this.sortCriteria,
1234
+ columnFilters: this.columnFilters,
1235
+ tableFilter: this._tableFilter,
1236
+ });
1237
+ this.visibleRowCount = this._tableTree.visibleNodeCount;
1238
+ this.rowIndexById = this._tableTree.nodeIndexById;
1239
+ this.visibleRows = this._tableTree.visibleNodes;
1240
+ this.activeItem = this._tableTree.activeNode;
779
1241
  this.installPageLoader();
780
1242
  }
781
1243
  /**
@@ -790,17 +1252,13 @@ class ɵSciTable {
790
1252
  this.tableViewRef.set(undefined);
791
1253
  }
792
1254
  computeColumns(descriptor) {
793
- // Create separate injection context per factory invocation to clean up allocated resources, like RxJS subscriptions.
794
- let injector;
795
- return computed(() => {
796
- injector?.destroy();
797
- injector = createDestroyableInjector({ parent: this._injector });
798
- return runInInjectionContext(injector, () => {
799
- const tableColumnFactory = new ɵSciTableColumnFactory(this);
800
- descriptor.columns(tableColumnFactory);
801
- return tableColumnFactory.columns;
802
- });
803
- });
1255
+ // Create a factory to instantiate a new injector per column factory execution, releasing resources allocated in the previous execution.
1256
+ const injectorFactory = new _SingleActiveInjectorFactory();
1257
+ return computed(() => runInInjectionContext(injectorFactory.create(), () => {
1258
+ const tableColumnFactory = new ɵSciTableColumnFactory(this);
1259
+ descriptor.columns(tableColumnFactory);
1260
+ return tableColumnFactory.columns;
1261
+ }));
804
1262
  }
805
1263
  /**
806
1264
  * Computes the rows currently visible in the viewport (+buffer).
@@ -811,15 +1269,29 @@ class ɵSciTable {
811
1269
  if (!scrollRange) {
812
1270
  return [];
813
1271
  }
814
- const rowsByIndex = this.rowsByIndex();
815
- const rowCount = scrollRange.end - scrollRange.start;
1272
+ const visibleNodes = this._tableTree.visibleNodes();
1273
+ const rowsById = this.rowsById();
1274
+ const rangeCount = scrollRange.end - scrollRange.start;
1275
+ const rowCount = Math.min(rangeCount, this._tableTree.loading() ? rangeCount : visibleNodes.length);
816
1276
  // Populate rows with cached rows in the range window, fallback to row shell to show skeleton.
817
- return untracked(() => Array.from({ length: rowCount }, (_, i) => rowsByIndex.get(scrollRange.start + i) ?? createSyntheticRow(i)));
1277
+ return untracked(() => Array.from({ length: rowCount }, (_, i) => {
1278
+ const node = visibleNodes[scrollRange.start + i];
1279
+ if (node && rowsById.has(node.id)) {
1280
+ return rowsById.get(node.id);
1281
+ }
1282
+ return createSkeletonRow(i);
1283
+ }));
818
1284
  });
819
- function createSyntheticRow(index) {
1285
+ function createSkeletonRow(index) {
820
1286
  return {
821
1287
  index,
822
- loading: true,
1288
+ id: skeletonRowId,
1289
+ item: undefined,
1290
+ cells: [],
1291
+ bindings: coerceTableRowBindings([], undefined, index),
1292
+ level: 0,
1293
+ hasChildren: signal(false).asReadonly(),
1294
+ expanded: signal(false).asReadonly(),
823
1295
  active: signal(false).asReadonly(),
824
1296
  selected: signal(false).asReadonly(),
825
1297
  hovered: signal(false).asReadonly(),
@@ -841,7 +1313,8 @@ class ɵSciTable {
841
1313
  const start = Math.floor(scrollTop / itemHeight);
842
1314
  const viewportRowCount = Math.ceil(viewportHeight / itemHeight);
843
1315
  const end = Math.min(start + viewportRowCount);
844
- const totalCount = this.totalCount() ?? viewportRowCount; // fill viewport if no data loaded yet
1316
+ // fill viewport if no data loaded yet
1317
+ const totalCount = this._tableTree.loading() ? viewportRowCount : this.visibleRowCount();
845
1318
  return {
846
1319
  start: clamp(start - this.bufferSize, { min: 0, max: Math.max(0, totalCount - viewportRowCount) }),
847
1320
  end: clamp(end + this.bufferSize, { max: totalCount }),
@@ -850,9 +1323,13 @@ class ɵSciTable {
850
1323
  }
851
1324
  computeVirtualScrollOffset() {
852
1325
  return computed(() => {
1326
+ // Remove virtual-scroll padding so the error message and retry button are in view.
1327
+ if (this.error()) {
1328
+ return { top: 0, bottom: 0 };
1329
+ }
853
1330
  const itemHeight = this.tableViewRef()?.itemHeight() ?? 0;
854
- const totalCount = this.totalCount() ?? 0;
855
- const rangeEnd = Math.min(this.scrollRange()?.end ?? 0, this.totalCount() ?? 0);
1331
+ const totalCount = this.visibleRowCount();
1332
+ const rangeEnd = Math.min(this.scrollRange()?.end ?? 0, totalCount);
856
1333
  return {
857
1334
  top: (this.scrollRange()?.start ?? 0) * itemHeight,
858
1335
  bottom: (totalCount - rangeEnd) * itemHeight,
@@ -890,74 +1367,44 @@ class ɵSciTable {
890
1367
  .pipe(switchMap(() => concat(of(true), timer(150).pipe(map(() => false)))), startWith(false), subscribeIn(fn => zone.runOutsideAngular(fn))),
891
1368
  }).value;
892
1369
  }
893
- /**
894
- * Loads a range of rows, based on the current sort and filter criteria, into the cache.
895
- */
896
- async loadRange(start, end) {
897
- const sortCriteria = this.sortCriteria();
898
- const columnFilters = this.filterCriteria();
899
- const tableFilter = this._tableFilter() ?? undefined;
900
- // Calculate pages based on row indices and page size.
901
- const pages = pagesByRange(start, end, this.pageSize);
902
- if (!pages) {
903
- return;
904
- }
905
- // Load pages and wait until completed loading.
906
- await Promise.all(pages
907
- .map(page => this.loadPage({ page, pageSize: this.pageSize, columnFilters, tableFilter, sortCriteria }))
908
- .map(page => firstValueFrom(toLazyObservable(page.loading, { injector: this._injector }).pipe(first(loading => !loading), defaultIfEmpty(true)))));
909
- }
910
1370
  /**
911
1371
  * Loads the requested page from the cache or datasource and returns its loading state with a cancelation handler.
912
1372
  */
913
- loadPage({ page, pageSize, sortCriteria, columnFilters, tableFilter }) {
914
- const pageStart = page * pageSize;
915
- const pageEnd = pageStart + pageSize;
916
- const cacheKey = `${pageStart}-${pageEnd}`;
917
- if (this._cache.has(cacheKey)) {
1373
+ loadPage(nodeIds) {
1374
+ const nodeIdsToLoad = nodeIds.filter(id => !this._cache.has(id));
1375
+ if (nodeIdsToLoad.length === 0) {
918
1376
  return {
919
- loading: this._cache.get(cacheKey).rows.isLoading,
920
- cancel: () => this._cache.deleteIfLoading(cacheKey),
1377
+ cancel: () => this._cache.deleteIfLoading(nodeIds),
921
1378
  };
922
1379
  }
923
- const cacheEntryInjector = createDestroyableInjector({ parent: this._injector });
924
- const cacheEntry = {
925
- rows: runInInjectionContext(cacheEntryInjector, () => {
926
- // Fetch data.
927
- const tableResponse$ = runSafe(() => Observables.coerce(this._dataLoaderFn({
928
- start: pageStart,
929
- end: pageEnd,
930
- pageSize,
931
- page,
932
- sortCriteria,
933
- tableFilter,
934
- columnFilters,
935
- })), error => throwError(() => error));
936
- const columns$ = toObservable(this.columns);
937
- // Create a resource to track loading and error states.
938
- return rxResource({
939
- stream: () => tableResponse$.pipe(combineLatestWith(columns$), map(([response, columns]) => ({
940
- rows: this.mapItemsToRow(response.items, columns, pageStart),
941
- totalCount: response.totalCount,
942
- })), tap({
943
- next: ({ totalCount }) => this.totalCount.set(totalCount),
944
- error: error => {
945
- console.error(error);
946
- // Reset count to scroll to the top so the user sees the error.
947
- this.totalCount.set(0);
948
- },
949
- }), map(({ rows }) => rows)),
950
- defaultValue: [],
951
- });
952
- }),
953
- dispose: () => cacheEntryInjector.destroy(),
954
- start: pageStart,
955
- end: pageEnd,
956
- };
957
- this._cache.set(cacheKey, cacheEntry);
1380
+ // Track consistent snapshot of columns and node Ids.
1381
+ // If the access happens directly in `mapItemsToRow` they can be out of sync because `nodesById` depends on `columns`.
1382
+ // Don't use the rowMapping data as resource params, since it should not refetch from the datasource on change.
1383
+ const snapshot = computed(() => {
1384
+ const columns = this.columns();
1385
+ const nodesById = this._tableTree.nodesById();
1386
+ const nodeIndexById = this._tableTree.nodeIndexById();
1387
+ return { columns, nodesById, nodeIndexById };
1388
+ }, /* @ts-ignore */
1389
+ ...(ngDevMode ? [{ debugName: "snapshot" }] : /* istanbul ignore next */ []));
1390
+ // Create a resource to track loading and error states.
1391
+ this._cache.set(nodeIdsToLoad, rxResource({
1392
+ stream: () => {
1393
+ // Injector for the datasource call; destroyed on stream completion to clean up allocated resources.
1394
+ const datasourceInjector = _createDestroyableInjector({ parent: this._injector });
1395
+ // Create separate injection context per items emission to clean up allocated resources.
1396
+ const itemsInjectorFactory = new _SingleActiveInjectorFactory({ parent: datasourceInjector });
1397
+ return runInInjectionContext(datasourceInjector, () => Observables.coerce(this._datasource.getItems(nodeIdsToLoad)))
1398
+ .pipe(combineLatestWith(toLazyObservable(snapshot, { injector: datasourceInjector })), map(([response, { columns, nodesById, nodeIndexById }]) => runInInjectionContext(itemsInjectorFactory.create(), () => this.mapItemsToRow(response, columns, nodesById, nodeIndexById))), catchError(error => {
1399
+ console.error(error);
1400
+ throw error;
1401
+ }), finalize(() => datasourceInjector.destroy()));
1402
+ },
1403
+ injector: this._injector,
1404
+ defaultValue: [],
1405
+ }));
958
1406
  return {
959
- loading: cacheEntry.rows.isLoading,
960
- cancel: () => this._cache.deleteIfLoading(cacheKey),
1407
+ cancel: () => this._cache.deleteIfLoading(nodeIdsToLoad),
961
1408
  };
962
1409
  }
963
1410
  /**
@@ -996,28 +1443,30 @@ class ɵSciTable {
996
1443
  if (!column) {
997
1444
  throw Error(`[NullColumnError] Column '${options.columnName}' not found in table '${this.name()}'.`);
998
1445
  }
999
- this.filterCriteria.update(filterCriterion => {
1000
- const otherFilterCriteria = filterCriterion.filter(filterCriterion => filterCriterion.columnName !== options.columnName);
1001
- if (text === null) {
1002
- return otherFilterCriteria;
1003
- }
1004
- return otherFilterCriteria.concat({ columnName: column.name, text });
1446
+ this.columnFilters.update(filterCriteria => {
1447
+ const newFilterCriteria = filterCriteria.filter(filterCriterion => filterCriterion.columnName !== options.columnName);
1448
+ return text === null ? newFilterCriteria : newFilterCriteria.concat({ columnName: column.name, text });
1005
1449
  });
1006
1450
  }
1451
+ setActiveItem(id) {
1452
+ this._tableTree.setActiveNode(id);
1453
+ }
1007
1454
  updateSelectedItems(updateFn) {
1008
- this._selectedItems.update(updateFn);
1455
+ this._tableTree.updateSelectedNodes(updateFn);
1009
1456
  }
1010
1457
  /**
1011
- * Indicates whether the built-in array datasource is used.
1012
- *
1013
- * Supports out-of-the-box filtering and sorting for built-in table columns.
1458
+ * Indicates whether the built-in in-memory datasource is used.
1014
1459
  */
1015
- isArrayDatasource() {
1016
- return isArrayDatasource(this._dataLoaderFn);
1460
+ isInMemoryDatasource() {
1461
+ return this._datasource instanceof SciInMemoryTableDatasource;
1017
1462
  }
1018
- reset() {
1463
+ reload() {
1019
1464
  this._cache.clear();
1020
- this.totalCount.set(undefined);
1465
+ this._tableTree.reload();
1466
+ }
1467
+ reloadFailed() {
1468
+ this._cache.reloadFailed();
1469
+ this._tableTree.reloadFailed();
1021
1470
  }
1022
1471
  dispose() {
1023
1472
  this._cache.clear();
@@ -1029,51 +1478,45 @@ class ɵSciTable {
1029
1478
  installPageLoader() {
1030
1479
  effect(onCleanup => {
1031
1480
  const scrollRange = this.scrollRange();
1032
- const sortCriteria = this.sortCriteria();
1033
- const columnFilters = this.filterCriteria();
1034
- const tableFilter = this._tableFilter() ?? undefined;
1481
+ const visibleNodes = this._tableTree.visibleNodes();
1035
1482
  if (!scrollRange) {
1036
1483
  return;
1037
1484
  }
1038
1485
  untracked(() => {
1039
- // Fall back to load the initial page if range is empty. Otherwise, if the table grows with its content,
1040
- // no data would ever load because the scroll range remains 0.
1041
- const pages = pagesByRange(scrollRange.start, scrollRange.end, this.pageSize) ?? [0];
1042
- pages.forEach(page => {
1043
- const pageRef = this.loadPage({
1044
- pageSize: this.pageSize,
1045
- page,
1046
- sortCriteria,
1047
- tableFilter,
1048
- columnFilters,
1049
- });
1486
+ // Calculate start and end pages that contain the range.
1487
+ const startPage = Math.floor(scrollRange.start / this.pageSize);
1488
+ const endPage = Math.floor((scrollRange.end - 1) / this.pageSize);
1489
+ for (let page = startPage; page <= endPage; page++) {
1490
+ const index = page * this.pageSize;
1491
+ const nodes = visibleNodes.slice(index, index + this.pageSize).map(node => node.id);
1492
+ const pageRef = this.loadPage(nodes);
1050
1493
  onCleanup(() => pageRef.cancel());
1051
- });
1494
+ }
1052
1495
  });
1053
1496
  });
1054
1497
  }
1055
- /**
1056
- * Resets cache on criteria changes.
1057
- */
1058
- installCriteriaWatcher() {
1059
- toObservable(this.criteria).pipe(skip(1), // skip first emission to avoid race condition with loader on initialization.
1060
- takeUntilDestroyed()).subscribe(() => {
1061
- this._cache.clear();
1062
- });
1063
- }
1064
- mapItemsToRow(items, columns, pageStart) {
1065
- return items.map((item, i) => {
1498
+ mapItemsToRow(items, columns, nodesById, nodeIndexById) {
1499
+ // Indicates whether there are nodes with children.
1500
+ const isTree = Array.from(nodesById.values()).some(node => node.parentId !== undefined);
1501
+ return items.map((item) => {
1066
1502
  const id = this.trackBy(item);
1067
- const index = pageStart + i;
1503
+ const index = nodeIndexById.get(id);
1504
+ const node = nodesById.get(id);
1505
+ if (node === undefined || index === undefined) {
1506
+ return undefined;
1507
+ }
1068
1508
  return {
1069
1509
  id,
1070
1510
  index,
1071
1511
  item,
1072
- loading: false,
1073
- active: computed(() => item === this.activeItem()),
1074
- selected: computed(() => this.selectedIds().has(id)),
1075
- hovered: computed(() => this.hoveredRow()?.index === index),
1076
- bindings: coerceTableRowBindings(this._rowBindings, item, pageStart + i),
1512
+ hovered: computed(() => this.hoveredIndex() === index),
1513
+ active: computed(() => this.activeItem() === id),
1514
+ selected: computed(() => this._tableTree.selectedNodes().has(id)),
1515
+ expanded: computed(() => this._tableTree.expandedNodes().has(id)),
1516
+ level: isTree ? calculateLevel(node) : undefined,
1517
+ parentId: node.parentId,
1518
+ hasChildren: computed(() => (this._tableTree.childrenIdsByParent().get(id)?.length ?? 0) > 0),
1519
+ bindings: coerceTableRowBindings(this._rowBindings, item, index),
1077
1520
  cells: columns.map(column => ({
1078
1521
  value: 'value' in column ? column.value(item) : undefined,
1079
1522
  component: column.type === 'component' ? column.component(item) : undefined,
@@ -1082,7 +1525,13 @@ class ɵSciTable {
1082
1525
  column,
1083
1526
  })),
1084
1527
  };
1085
- });
1528
+ }).filter((node) => !!node);
1529
+ function calculateLevel(node) {
1530
+ if (node.parentId === undefined) {
1531
+ return 0;
1532
+ }
1533
+ return calculateLevel(nodesById.get(node.parentId)) + 1;
1534
+ }
1086
1535
  }
1087
1536
  /**
1088
1537
  * Creates a signal to read and write table user settings.
@@ -1092,7 +1541,7 @@ class ɵSciTable {
1092
1541
  const userSettings = resource({ ...(ngDevMode ? { debugName: "userSettings" } : /* istanbul ignore next */ {}), params: () => this.name(),
1093
1542
  loader: async ({ params: tableName }) => {
1094
1543
  try {
1095
- const serialized = await this._tableStorage.load(`scion.components.${tableName}`);
1544
+ const serialized = await this._tableStorage.load(`scion.components:${tableName}`);
1096
1545
  return serialized ? JSON.parse(serialized) : { columns: [] };
1097
1546
  }
1098
1547
  catch (error) {
@@ -1104,7 +1553,7 @@ class ɵSciTable {
1104
1553
  // Persist settings to storage.
1105
1554
  effect(() => {
1106
1555
  if (userSettings.status() === 'local' && this.name()) {
1107
- untracked(() => void this._tableStorage.store(`scion.components.${this.name()}`, JSON.stringify(userSettings.value())));
1556
+ untracked(() => void this._tableStorage.store(`scion.components:${this.name()}`, JSON.stringify(userSettings.value())));
1108
1557
  }
1109
1558
  });
1110
1559
  return userSettings.value;
@@ -1113,30 +1562,39 @@ class ɵSciTable {
1113
1562
  * Call trackBy function or fallback to track by object reference.
1114
1563
  */
1115
1564
  trackBy(item) {
1116
- return this._trackBy?.(item) ?? item;
1565
+ if (this._trackBy !== undefined) {
1566
+ return this._trackBy(item);
1567
+ }
1568
+ return item;
1117
1569
  }
1118
1570
  computeActiveRow() {
1119
1571
  return computed(() => {
1120
1572
  const activeItem = this.activeItem();
1121
- if (activeItem === undefined) {
1122
- return undefined;
1123
- }
1124
- const id = this.trackBy(activeItem);
1125
- if (id === undefined) {
1126
- return undefined;
1127
- }
1128
- return this._cache.rowsById().get(id);
1573
+ const rowsById = this._cache.rowsById();
1574
+ return rowsById.has(activeItem) ? rowsById.get(activeItem) : undefined;
1129
1575
  });
1130
1576
  }
1131
- }
1132
- /**
1133
- * Gets pages by range and page size, or `null` if empty. End is exclusive.
1134
- */
1135
- function pagesByRange(start, end, pageSize) {
1136
- const startPage = Math.floor(start / pageSize);
1137
- const endPage = Math.floor((end - 1) / pageSize); // `end` is exclusive, so use the last included index (`end - 1`) for page calculation.
1138
- const pages = rangeInclusive(startPage, endPage);
1139
- return pages.length ? pages : null;
1577
+ select(ids) {
1578
+ this._tableTree.select(ids);
1579
+ }
1580
+ expand(ids) {
1581
+ this._tableTree.expand(ids);
1582
+ }
1583
+ isExpanded(id) {
1584
+ return this._tableTree.expandedNodes().has(id);
1585
+ }
1586
+ expandAll() {
1587
+ this._tableTree.expandAll();
1588
+ }
1589
+ collapse(ids) {
1590
+ this._tableTree.collapse(ids);
1591
+ }
1592
+ collapseAll() {
1593
+ this._tableTree.collapseAll();
1594
+ }
1595
+ toggleRow(id) {
1596
+ this._tableTree.toggleRow(id);
1597
+ }
1140
1598
  }
1141
1599
  const ɵSCI_TABLE = new InjectionToken('ɵSciTable');
1142
1600
 
@@ -1149,16 +1607,16 @@ const ɵSCI_TABLE = new InjectionToken('ɵSciTable');
1149
1607
  *
1150
1608
  * SPDX-License-Identifier: EPL-2.0
1151
1609
  */
1152
- /**
1153
- * TODO [table] Remove after datasource is final.
1154
- *
1155
- * @docs-private Not public API. For internal use only.
1156
- * @experimental since 22.3.0; API and behavior may change in any version without notice.
1157
- */
1158
- function ɵillegaldatasource() {
1159
- return computed(() => {
1160
- throw Error('[SciTableError] Illegal state. ɵillegaldatasource should not be used.');
1161
- });
1610
+ function table(dataOrDescriptor, columnFactoryFn, options) {
1611
+ assertNotInReactiveContext(table, 'Call table in a non-reactive (non-tracking) context, such as within the untracked() function.');
1612
+ const descriptor = isSignal(dataOrDescriptor) ? ({ datasource: dataOrDescriptor, columns: columnFactoryFn, injector: options?.injector }) : dataOrDescriptor;
1613
+ if (!descriptor.injector) {
1614
+ assertInInjectionContext(table);
1615
+ }
1616
+ const injector = descriptor.injector ?? inject(Injector);
1617
+ const sciTable = runInInjectionContext(injector, () => new ɵSciTable(descriptor));
1618
+ injector.get(DestroyRef).onDestroy(() => sciTable.dispose());
1619
+ return sciTable;
1162
1620
  }
1163
1621
 
1164
1622
  /*
@@ -1170,16 +1628,33 @@ function ɵillegaldatasource() {
1170
1628
  *
1171
1629
  * SPDX-License-Identifier: EPL-2.0
1172
1630
  */
1173
- function table(dataOrDescriptor, columnFactoryFn, options) {
1174
- assertNotInReactiveContext(table, 'Call table in a non-reactive (non-tracking) context, such as within the untracked() function.');
1175
- const descriptor = isSignal(dataOrDescriptor) ? ({ ɵdatasource: dataOrDescriptor, datasource: ɵillegaldatasource(), columns: columnFactoryFn, injector: options?.injector }) : dataOrDescriptor;
1176
- if (!descriptor.injector) {
1177
- assertInInjectionContext(table);
1631
+ /**
1632
+ * Creates a range of integers starting at `start` and ending at `end` (including `end`).
1633
+ */
1634
+ function rangeInclusive(start, end) {
1635
+ const range = [];
1636
+ for (let i = start; i <= end; i++) {
1637
+ range.push(i);
1178
1638
  }
1179
- const injector = descriptor.injector ?? inject(Injector);
1180
- const sciTable = runInInjectionContext(injector, () => new ɵSciTable(descriptor));
1181
- injector.get(DestroyRef).onDestroy(() => sciTable.dispose());
1182
- return sciTable;
1639
+ return range;
1640
+ }
1641
+ /**
1642
+ * Indicates whether the given event was triggered from an input element.
1643
+ */
1644
+ function isFromInput(event) {
1645
+ if (event.target instanceof Element && event.target.closest('input, textarea, select') !== null) {
1646
+ return true;
1647
+ }
1648
+ if (event.target instanceof HTMLElement && event.target.isContentEditable) {
1649
+ return true;
1650
+ }
1651
+ return false;
1652
+ }
1653
+ /**
1654
+ * Indicates whether the given keyboard event was triggered from a button.
1655
+ */
1656
+ function isFromButton(event) {
1657
+ return event.target instanceof Element && event.target.closest('button') !== null;
1183
1658
  }
1184
1659
 
1185
1660
  /*
@@ -1195,218 +1670,114 @@ class SciTableSelectionService {
1195
1670
  _table = inject(ɵSCI_TABLE);
1196
1671
  async onRowClick(index, event) {
1197
1672
  const table = this._table();
1198
- const rowsByIndex = table.rowsByIndex();
1199
- const row = rowsByIndex.get(index);
1200
- const item = rowsByIndex.get(index)?.item;
1201
- const previousFocusedIndex = table.activeRow()?.index ?? -1;
1202
- table.activeItem.set(item);
1203
- if (item === undefined || !table.selectable()) {
1673
+ const rowId = table.visibleRows()?.[index]?.id;
1674
+ const previousActiveIndex = table.activeIndex();
1675
+ table.setActiveItem(rowId);
1676
+ if (rowId === undefined || !table.selectable()) {
1204
1677
  return;
1205
1678
  }
1206
1679
  if (table.selectable() === 'single') {
1207
- table.updateSelectedItems(() => new Map().set(row.id, item));
1680
+ table.updateSelectedItems(() => new Set().add(rowId));
1208
1681
  return;
1209
1682
  }
1210
- if (event.shiftKey && previousFocusedIndex >= 0) {
1211
- const start = Math.min(previousFocusedIndex, index);
1212
- const end = Math.max(previousFocusedIndex, index);
1683
+ if (event.shiftKey && previousActiveIndex >= 0) {
1684
+ const start = Math.min(previousActiveIndex, index);
1685
+ const end = Math.max(previousActiveIndex, index);
1213
1686
  await this.selectItems(start, end, { add: true });
1214
1687
  }
1215
1688
  else if (event.ctrlKey || event.metaKey) {
1216
- this.toggleSelectedItem(item);
1689
+ this.toggleSelectedItem(rowId);
1217
1690
  }
1218
1691
  else {
1219
1692
  // If no modifier is pressed set the selection to the clicked row.
1220
- table.updateSelectedItems(() => new Map().set(row.id, item));
1693
+ table.updateSelectedItems(() => new Set().add(rowId));
1221
1694
  }
1222
1695
  }
1223
1696
  async onArrowUp(event) {
1224
1697
  event.preventDefault();
1225
1698
  const table = this._table();
1226
- const activeIndex = this._table().activeRow()?.index ?? -1;
1227
- if (activeIndex <= 0) {
1228
- return;
1229
- }
1230
- // Set active item.
1231
- const startIndex = activeIndex - 1;
1232
- await this.loadMissingItems(startIndex, startIndex);
1233
- const startItem = table.rowsByIndex().get(startIndex)?.item;
1234
- table.activeItem.set(startItem);
1235
- if (!table.selectable()) {
1236
- return;
1237
- }
1238
- const keyboardEvent = event;
1239
- const shift = keyboardEvent.shiftKey;
1240
- const ctrlOrMeta = keyboardEvent.ctrlKey || keyboardEvent.metaKey;
1241
- if (shift && ctrlOrMeta) {
1242
- await this.ctrlShiftUp({ startIndex, activeIndex });
1243
- }
1244
- else if (shift) {
1245
- await this.shiftUp({ startIndex, activeIndex });
1246
- }
1247
- else if (ctrlOrMeta) {
1248
- // Selection remains unchanged.
1249
- }
1250
- else {
1251
- await this.selectItems(startIndex, startIndex);
1252
- }
1699
+ const activeIndex = table.activeIndex();
1700
+ const targetIndex = Math.max(activeIndex - 1, 0);
1701
+ await this.selectOnKeyboardNavigation(targetIndex, event);
1253
1702
  }
1254
1703
  async onArrowDown(event) {
1255
1704
  event.preventDefault();
1256
1705
  const table = this._table();
1257
- const activeIndex = this._table().activeRow()?.index ?? -1;
1258
- const endIndex = Math.min(activeIndex + 1, table.totalCount() - 1);
1259
- if (endIndex === activeIndex) {
1260
- return;
1261
- }
1262
- // Set active item.
1263
- await this.loadMissingItems(endIndex, endIndex);
1264
- const endItem = table.rowsByIndex().get(endIndex)?.item;
1265
- table.activeItem.set(endItem);
1266
- if (!table.selectable()) {
1706
+ const activeIndex = table.activeIndex();
1707
+ const targetIndex = Math.min(activeIndex + 1, table.visibleRowCount() - 1);
1708
+ await this.selectOnKeyboardNavigation(targetIndex, event);
1709
+ }
1710
+ async onArrowLeft(event) {
1711
+ event.preventDefault();
1712
+ const table = this._table();
1713
+ const activeRow = table.activeRow();
1714
+ if (activeRow?.item === undefined || activeRow.id === skeletonRowId) {
1267
1715
  return;
1268
1716
  }
1269
- const keyboardEvent = event;
1270
- const shift = keyboardEvent.shiftKey;
1271
- const ctrlOrMeta = keyboardEvent.ctrlKey || keyboardEvent.metaKey;
1272
- if (shift && ctrlOrMeta) {
1273
- await this.ctrlShiftDown({ endIndex, activeIndex });
1274
- }
1275
- else if (shift) {
1276
- await this.shiftDown({ endIndex, activeIndex });
1717
+ if (activeRow.hasChildren() && activeRow.expanded()) {
1718
+ table.collapse(activeRow.id);
1277
1719
  }
1278
- else if (ctrlOrMeta) {
1279
- // Selection remains unchanged.
1720
+ else if (activeRow.parentId !== undefined) {
1721
+ const parentIndex = table.rowIndexById().get(activeRow.parentId) ?? -1;
1722
+ await this.selectOnKeyboardNavigation(parentIndex, { shiftKey: false, ctrlKey: false, metaKey: false });
1280
1723
  }
1724
+ // Root case
1281
1725
  else {
1282
- await this.selectItems(endIndex, endIndex);
1726
+ const rows = table.visibleRows();
1727
+ const row = findPreviousExpandedRow(activeRow.index - 1);
1728
+ if (row !== undefined) {
1729
+ const index = table.rowIndexById().get(row.id) ?? -1;
1730
+ await this.selectOnKeyboardNavigation(index, { shiftKey: false, ctrlKey: false, metaKey: false });
1731
+ }
1732
+ function findPreviousExpandedRow(index) {
1733
+ while (index > 0) {
1734
+ const row = rows?.[index];
1735
+ // Get the row expansion state whether the row is already loaded or not.
1736
+ if (row !== undefined && table.isExpanded(row.id)) {
1737
+ return row;
1738
+ }
1739
+ index--;
1740
+ }
1741
+ // Return the first row if no previous row is expanded.
1742
+ return rows?.[index];
1743
+ }
1283
1744
  }
1284
1745
  }
1285
- async onPageUp(event) {
1746
+ async onArrowRight(event) {
1286
1747
  event.preventDefault();
1287
1748
  const table = this._table();
1288
- const activeIndex = this._table().activeRow()?.index ?? -1;
1289
- if (activeIndex === 0) {
1290
- return;
1291
- }
1292
- // Set active item.
1293
- const startIndex = Math.max(activeIndex - table.viewportPageSize() + 1, 0);
1294
- await this.loadMissingItems(startIndex, startIndex);
1295
- const startItem = table.rowsByIndex().get(startIndex)?.item;
1296
- table.activeItem.set(startItem);
1297
- if (!table.selectable()) {
1749
+ const activeRow = table.activeRow();
1750
+ const activeIndex = table.activeIndex();
1751
+ const endIndex = Math.min(activeIndex + 1, table.visibleRowCount() - 1);
1752
+ if (activeRow?.id !== skeletonRowId && activeRow?.hasChildren() && !activeRow.expanded()) {
1753
+ table.expand(activeRow.id);
1298
1754
  return;
1299
1755
  }
1300
- const keyboardEvent = event;
1301
- const shift = keyboardEvent.shiftKey;
1302
- const ctrlOrMeta = keyboardEvent.ctrlKey || keyboardEvent.metaKey;
1303
- if (shift && ctrlOrMeta) {
1304
- await this.ctrlShiftUp({ startIndex, activeIndex });
1305
- }
1306
- else if (shift) {
1307
- await this.shiftUp({ startIndex, activeIndex });
1308
- }
1309
- else if (ctrlOrMeta) {
1310
- // Selection remains unchanged.
1311
- }
1312
- else {
1313
- await this.selectItems(startIndex, startIndex);
1314
- }
1756
+ await this.selectOnKeyboardNavigation(endIndex, { shiftKey: false, ctrlKey: false, metaKey: false });
1757
+ }
1758
+ async onPageUp(event) {
1759
+ event.preventDefault();
1760
+ const table = this._table();
1761
+ const activeIndex = table.activeIndex();
1762
+ const targetIndex = Math.max(activeIndex - table.viewportPageSize() + 1, 0);
1763
+ await this.selectOnKeyboardNavigation(targetIndex, event);
1315
1764
  }
1316
1765
  async onPageDown(event) {
1317
1766
  event.preventDefault();
1318
1767
  const table = this._table();
1319
- const activeIndex = this._table().activeRow()?.index ?? -1;
1320
- const endIndex = Math.min(activeIndex + table.viewportPageSize() - 1, table.totalCount() - 1);
1321
- if (endIndex === activeIndex) {
1322
- return;
1323
- }
1324
- // Set active item.
1325
- await this.loadMissingItems(endIndex, endIndex);
1326
- const endItem = table.rowsByIndex().get(endIndex)?.item;
1327
- table.activeItem.set(endItem);
1328
- if (!table.selectable()) {
1329
- return;
1330
- }
1331
- const keyboardEvent = event;
1332
- const shift = keyboardEvent.shiftKey;
1333
- const ctrlOrMeta = keyboardEvent.ctrlKey || keyboardEvent.metaKey;
1334
- if (shift && ctrlOrMeta) {
1335
- await this.ctrlShiftDown({ endIndex, activeIndex });
1336
- }
1337
- else if (shift) {
1338
- await this.shiftDown({ endIndex, activeIndex });
1339
- }
1340
- else if (ctrlOrMeta) {
1341
- // Selection remains unchanged.
1342
- }
1343
- else {
1344
- await this.selectItems(endIndex, endIndex);
1345
- }
1768
+ const activeIndex = table.activeIndex();
1769
+ const targetIndex = Math.min(activeIndex + table.viewportPageSize() - 1, table.visibleRowCount() - 1);
1770
+ await this.selectOnKeyboardNavigation(targetIndex, event);
1346
1771
  }
1347
1772
  async onHome(event) {
1348
1773
  event.preventDefault();
1349
- const table = this._table();
1350
- const activeIndex = this._table().activeRow()?.index ?? -1;
1351
- if (activeIndex <= 0) {
1352
- return;
1353
- }
1354
- // Set active item.
1355
- const startIndex = 0;
1356
- await this.loadMissingItems(startIndex, startIndex);
1357
- const startItem = table.rowsByIndex().get(startIndex)?.item;
1358
- table.activeItem.set(startItem);
1359
- if (!table.selectable()) {
1360
- return;
1361
- }
1362
- const keyboardEvent = event;
1363
- const shift = keyboardEvent.shiftKey;
1364
- const ctrlOrMeta = keyboardEvent.ctrlKey || keyboardEvent.metaKey;
1365
- if (shift && ctrlOrMeta) {
1366
- await this.ctrlShiftUp({ startIndex, activeIndex });
1367
- }
1368
- else if (shift) {
1369
- await this.shiftUp({ startIndex, activeIndex });
1370
- }
1371
- else if (ctrlOrMeta) {
1372
- // Selection remains unchanged.
1373
- }
1374
- else {
1375
- await this.selectItems(startIndex, startIndex);
1376
- }
1774
+ await this.selectOnKeyboardNavigation(0, event);
1377
1775
  }
1378
1776
  async onEnd(event) {
1379
1777
  event.preventDefault();
1380
1778
  const table = this._table();
1381
- const activeIndex = this._table().activeRow()?.index ?? -1;
1382
- const endIndex = table.totalCount() - 1;
1383
- if (endIndex === activeIndex) {
1384
- return;
1385
- }
1386
- // TODO [mmu] should active item be set before selection? this way, further methods cant rely on table.activeItem.
1387
- // But otherwise, loading and setting the active item needs to be done for each case, or once at the end.
1388
- // Set active item.
1389
- await this.loadMissingItems(endIndex, endIndex);
1390
- const endItem = table.rowsByIndex().get(endIndex)?.item;
1391
- table.activeItem.set(endItem);
1392
- if (!table.selectable()) {
1393
- return;
1394
- }
1395
- const keyboardEvent = event;
1396
- const shift = keyboardEvent.shiftKey;
1397
- const ctrlOrMeta = keyboardEvent.ctrlKey || keyboardEvent.metaKey;
1398
- if (shift && ctrlOrMeta) {
1399
- await this.ctrlShiftDown({ endIndex, activeIndex });
1400
- }
1401
- else if (shift) {
1402
- await this.shiftDown({ endIndex, activeIndex });
1403
- }
1404
- else if (ctrlOrMeta) {
1405
- // Selection remains unchanged.
1406
- }
1407
- else {
1408
- await this.selectItems(endIndex, endIndex);
1409
- }
1779
+ const targetIndex = table.visibleRowCount() - 1;
1780
+ await this.selectOnKeyboardNavigation(targetIndex, event);
1410
1781
  }
1411
1782
  onSpace(event) {
1412
1783
  event.preventDefault();
@@ -1414,168 +1785,126 @@ class SciTableSelectionService {
1414
1785
  if (!table.selectable()) {
1415
1786
  return;
1416
1787
  }
1417
- const activeItem = table.activeItem();
1418
- if (!activeItem) {
1788
+ const activeItemId = table.activeItem();
1789
+ if (activeItemId === undefined) {
1419
1790
  return;
1420
1791
  }
1421
1792
  const keyboardEvent = event;
1422
1793
  const ctrlOrMeta = keyboardEvent.ctrlKey || keyboardEvent.metaKey;
1423
- const activeItemId = table.trackBy(activeItem);
1424
1794
  if (ctrlOrMeta) {
1425
- this.toggleSelectedItem(activeItem);
1795
+ this.toggleSelectedItem(activeItemId);
1426
1796
  }
1427
1797
  else {
1428
- table.updateSelectedItems(() => new Map().set(activeItemId, activeItem));
1798
+ table.updateSelectedItems(() => new Set().add(activeItemId));
1429
1799
  }
1430
1800
  }
1431
1801
  async onControlA(event) {
1432
1802
  event.preventDefault();
1433
1803
  const table = this._table();
1434
- const totalCount = table.totalCount() ?? 0;
1804
+ const totalCount = table.visibleRowCount();
1435
1805
  if (table.selectable() === 'multi' && totalCount >= 0) {
1436
1806
  await this.selectItems(0, totalCount - 1);
1437
1807
  }
1438
1808
  }
1439
- // TODO [mmu] Should we add overloads for all variants for better readability, but same logic?
1440
- // shiftArrowDown
1441
- // shiftPageDown
1442
- // ShiftEnd
1443
- async shiftDown(options) {
1809
+ async selectOnKeyboardNavigation(targetIndex, event) {
1444
1810
  const table = this._table();
1445
- const selectedIds = table.selectedIds();
1446
- const activeIndex = options.activeIndex;
1447
- const startIndex = activeIndex;
1448
- const endIndex = options.endIndex;
1449
- const activeItem = table.rowsByIndex().get(activeIndex).item;
1450
- const activeItemId = table.trackBy(activeItem);
1451
- if (table.selectable() === 'single') {
1452
- await this.selectItems(endIndex, endIndex);
1811
+ const targetItem = table.visibleRows()?.[targetIndex]?.id;
1812
+ const activeIndex = table.activeIndex();
1813
+ const direction = activeIndex > targetIndex ? 'up' : 'down';
1814
+ const [startIndex, endIndex] = direction === 'up' ? [targetIndex, activeIndex] : [activeIndex, targetIndex];
1815
+ const shift = event.shiftKey;
1816
+ const ctrlOrMeta = event.ctrlKey || event.metaKey;
1817
+ if (table.selectable() !== 'multi') {
1818
+ // Don't modify selection when only ctrlOrMeta is pressed.
1819
+ if (table.selectable() === 'single' && (shift || !ctrlOrMeta)) {
1820
+ await this.selectItems(targetIndex, targetIndex);
1821
+ }
1822
+ table.setActiveItem(targetItem);
1453
1823
  return;
1454
1824
  }
1455
- if (!selectedIds.has(activeItemId)) {
1456
- await this.selectItems(startIndex, endIndex);
1457
- return;
1825
+ if (shift && ctrlOrMeta) {
1826
+ await this.selectItems(startIndex, endIndex, { add: true });
1458
1827
  }
1459
- if (this.isSelectionAtBlockStart(activeIndex)) {
1460
- await this.selectFromBlockStart(startIndex, endIndex);
1461
- return;
1828
+ else if (shift) {
1829
+ await this.selectOnShiftNavigation(direction, startIndex, endIndex);
1462
1830
  }
1463
- await this.selectItems(startIndex, endIndex, { add: true });
1464
- }
1465
- async ctrlShiftDown(options) {
1466
- const table = this._table();
1467
- const startIndex = options.activeIndex;
1468
- const endIndex = options.endIndex;
1469
- if (table.selectable() === 'single') {
1470
- await this.selectItems(endIndex, endIndex);
1831
+ else if (ctrlOrMeta) {
1832
+ // Selection remains unchanged.
1471
1833
  }
1472
1834
  else {
1473
- await this.selectItems(startIndex, endIndex, { add: true });
1835
+ await this.selectItems(targetIndex, targetIndex);
1474
1836
  }
1837
+ table.setActiveItem(targetItem);
1475
1838
  }
1476
- async shiftUp(options) {
1839
+ selectOnShiftNavigation(direction, startIndex, endIndex) {
1477
1840
  const table = this._table();
1478
- const selectedIds = table.selectedIds();
1479
- const activeIndex = options.activeIndex;
1480
- const startIndex = options.startIndex;
1481
- const endIndex = activeIndex;
1482
- const activeItem = table.rowsByIndex().get(activeIndex).item;
1483
- const activeItemId = table.trackBy(activeItem);
1484
- if (table.selectable() === 'single') {
1485
- await this.selectItems(startIndex, startIndex);
1486
- return;
1487
- }
1488
- if (!selectedIds.has(activeItemId)) {
1489
- await this.selectItems(startIndex, endIndex);
1490
- return;
1491
- }
1492
- if (this.isSelectionAtBlockEnd(activeIndex)) {
1493
- await this.selectFromBlockEnd(startIndex, endIndex);
1494
- return;
1841
+ const activeIndex = table.activeIndex();
1842
+ const selectedIds = table.selectedItems();
1843
+ // If the active item is outside the selection, start a new selection.
1844
+ if (!selectedIds.includes(table.activeItem())) {
1845
+ return this.selectItems(startIndex, endIndex);
1495
1846
  }
1496
- await this.selectItems(startIndex, endIndex, { add: true });
1497
- }
1498
- async ctrlShiftUp(options) {
1499
- const table = this._table();
1500
- const startIndex = options.startIndex;
1501
- const endIndex = options.activeIndex;
1502
- if (table.selectable() === 'single') {
1503
- await this.selectItems(startIndex, startIndex);
1847
+ // Moving down from the block start shrinks the selection until the block end, then extends it.
1848
+ if (direction === 'down' && this.isSelectionAtBlockStart(activeIndex)) {
1849
+ return this.selectFromBlockStart(startIndex, endIndex);
1504
1850
  }
1505
- else {
1506
- await this.selectItems(startIndex, endIndex, { add: true });
1851
+ // Moving up from the block end shrinks the selection until the block start, then extends it.
1852
+ if (direction === 'up' && this.isSelectionAtBlockEnd(activeIndex)) {
1853
+ return this.selectFromBlockEnd(startIndex, endIndex);
1507
1854
  }
1855
+ // In other cases, e.g. going down at the end of a selection block, select all items in the range.
1856
+ return this.selectItems(startIndex, endIndex, { add: true });
1508
1857
  }
1509
1858
  isSelectionAtBlockStart(activeIndex) {
1510
1859
  const table = this._table();
1511
- const selectedIds = table.selectedIds();
1512
- const previousItem = table.rowsByIndex().get(activeIndex - 1)?.item;
1513
- const previousItemId = previousItem ? table.trackBy(previousItem) : undefined;
1514
- const nextItem = table.rowsByIndex().get(activeIndex + 1)?.item;
1515
- const nextItemId = nextItem ? table.trackBy(nextItem) : undefined;
1516
- return !selectedIds.has(previousItemId) && selectedIds.has(nextItemId);
1860
+ const selectedIds = table.selectedItems();
1861
+ const previousItemId = table.visibleRows()?.[activeIndex - 1]?.id;
1862
+ const nextItemId = table.visibleRows()?.[activeIndex + 1]?.id;
1863
+ return (previousItemId === undefined || !selectedIds.includes(previousItemId)) && nextItemId !== undefined && selectedIds.includes(nextItemId);
1517
1864
  }
1518
1865
  isSelectionAtBlockEnd(activeIndex) {
1519
1866
  const table = this._table();
1520
- const selectedIds = table.selectedIds();
1521
- const previousItem = table.rowsByIndex().get(activeIndex - 1)?.item;
1522
- const previousItemId = previousItem ? table.trackBy(previousItem) : undefined;
1523
- const nextItem = table.rowsByIndex().get(activeIndex + 1)?.item;
1524
- const nextItemId = nextItem ? table.trackBy(nextItem) : undefined;
1525
- return selectedIds.has(previousItemId) && !selectedIds.has(nextItemId);
1526
- }
1527
- async loadMissingItems(startIndex, endIndex) {
1528
- const table = this._table();
1529
- const indices = rangeInclusive(startIndex, endIndex + 1);
1530
- const rows = indices.map(i => table.rowsByIndex().get(i));
1531
- if (rows.some(row => row?.id === undefined)) {
1532
- await Promise.race([
1533
- table.loadRange(startIndex, endIndex + 1),
1534
- firstValueFrom(timer(5_000)),
1535
- ]);
1536
- }
1867
+ const selectedIds = table.selectedItems();
1868
+ const previousItemId = table.visibleRows()?.[activeIndex - 1]?.id;
1869
+ const nextItemId = table.visibleRows()?.[activeIndex + 1]?.id;
1870
+ return previousItemId !== undefined && selectedIds.includes(previousItemId) && (nextItemId === undefined || !selectedIds.includes(nextItemId));
1537
1871
  }
1538
1872
  async selectItems(startIndex, endIndex, options) {
1539
1873
  const table = this._table();
1874
+ if (!table.selectable()) {
1875
+ return;
1876
+ }
1540
1877
  const indices = rangeInclusive(startIndex, endIndex);
1541
1878
  const add = options?.add ?? false;
1542
- let rows = indices.map(i => table.rowsByIndex().get(i));
1543
- if (rows.some(row => row?.id === undefined)) {
1544
- // If not all id's could be found load the missing items.
1545
- await Promise.race([
1546
- table.loadRange(startIndex, endIndex),
1547
- firstValueFrom(timer(5_000)),
1548
- ]);
1549
- rows = indices.map(i => table.rowsByIndex().get(i));
1550
- }
1551
- table.updateSelectedItems(existing => new Map([
1879
+ const rows = indices.map(i => table.visibleRows()?.[i]);
1880
+ table.updateSelectedItems(existing => new Set([
1552
1881
  ...(add ? existing : []),
1553
1882
  ...rows
1554
- .filter((row) => row?.id !== undefined && row.item !== undefined)
1555
- .map((row) => [row.id, row.item]),
1883
+ .filter((row) => !!row)
1884
+ .map(row => row.id),
1556
1885
  ]));
1557
1886
  }
1558
1887
  async selectFromBlockStart(startIndex, endIndex) {
1559
1888
  const table = this._table();
1560
- await this.loadMissingItems(startIndex, endIndex);
1561
1889
  const indices = rangeInclusive(startIndex, endIndex);
1562
1890
  table.updateSelectedItems(selection => {
1563
- const newSelection = new Map(selection);
1891
+ const newSelection = new Set(selection);
1892
+ let shrinking = true;
1564
1893
  for (let i = 0; i < indices.length; i++) {
1565
1894
  const currentIndex = indices[i];
1566
1895
  const nextIndex = indices[i + 1];
1567
- const item = table.rowsByIndex().get(currentIndex)?.item;
1568
- if (!item) {
1896
+ const itemId = table.visibleRows()?.[currentIndex]?.id;
1897
+ if (itemId === undefined) {
1569
1898
  continue;
1570
1899
  }
1571
- const itemId = table.trackBy(item);
1572
- const nextItem = table.rowsByIndex().get(nextIndex)?.item;
1573
- const nextItemId = nextItem ? table.trackBy(nextItem) : undefined;
1574
- if (selection.has(nextItemId)) {
1900
+ const nextItemId = table.visibleRows()?.[nextIndex]?.id;
1901
+ if (shrinking && nextItemId !== undefined && selection.has(nextItemId)) {
1575
1902
  newSelection.delete(itemId);
1576
1903
  }
1577
1904
  else {
1578
- newSelection.set(itemId, item);
1905
+ // Once outside the active block, only add items, even when crossing another selected block.
1906
+ shrinking = false;
1907
+ newSelection.add(itemId);
1579
1908
  }
1580
1909
  }
1581
1910
  return newSelection;
@@ -1583,35 +1912,34 @@ class SciTableSelectionService {
1583
1912
  }
1584
1913
  async selectFromBlockEnd(startIndex, endIndex) {
1585
1914
  const table = this._table();
1586
- await this.loadMissingItems(startIndex, endIndex);
1587
1915
  const indices = rangeInclusive(startIndex, endIndex);
1588
1916
  table.updateSelectedItems(selection => {
1589
- const newSelection = new Map(selection);
1917
+ const newSelection = new Set(selection);
1918
+ let shrinking = true;
1590
1919
  for (let i = indices.length - 1; i >= 0; i--) {
1591
1920
  const currentIndex = indices[i];
1592
1921
  const previousIndex = indices[i - 1];
1593
- const item = table.rowsByIndex().get(currentIndex)?.item;
1594
- if (!item) {
1922
+ const itemId = table.visibleRows()?.[currentIndex]?.id;
1923
+ if (itemId === undefined) {
1595
1924
  continue;
1596
1925
  }
1597
- const itemId = table.trackBy(item);
1598
- const previousItem = table.rowsByIndex().get(previousIndex)?.item;
1599
- const previousItemId = previousItem ? table.trackBy(previousItem) : undefined;
1600
- if (selection.has(previousItemId)) {
1926
+ const previousItemId = table.visibleRows()?.[previousIndex]?.id;
1927
+ if (shrinking && previousItemId !== undefined && selection.has(previousItemId)) {
1601
1928
  newSelection.delete(itemId);
1602
1929
  }
1603
1930
  else {
1604
- newSelection.set(itemId, item);
1931
+ // Once outside the active block, only add items, even when crossing another selected block.
1932
+ shrinking = false;
1933
+ newSelection.add(itemId);
1605
1934
  }
1606
1935
  }
1607
1936
  return newSelection;
1608
1937
  });
1609
1938
  }
1610
- toggleSelectedItem(item) {
1939
+ toggleSelectedItem(id) {
1611
1940
  const table = this._table();
1612
- const id = table.trackBy(item);
1613
1941
  table.updateSelectedItems(selection => {
1614
- const next = new Map(selection);
1942
+ const next = new Set(selection);
1615
1943
  if (next.has(id)) {
1616
1944
  next.delete(id);
1617
1945
  }
@@ -1619,7 +1947,7 @@ class SciTableSelectionService {
1619
1947
  if (table.selectable() === 'single') {
1620
1948
  next.clear();
1621
1949
  }
1622
- next.set(id, item);
1950
+ next.add(id);
1623
1951
  }
1624
1952
  return next;
1625
1953
  });
@@ -1669,9 +1997,11 @@ class SciTableCellComponent {
1669
1997
  ...(ngDevMode ? [{ debugName: "cell" }] : /* istanbul ignore next */ []));
1670
1998
  row = input.required(/* @ts-ignore */
1671
1999
  ...(ngDevMode ? [{ debugName: "row" }] : /* istanbul ignore next */ []));
2000
+ _table = inject(ɵSCI_TABLE);
1672
2001
  template = this.computeTemplate();
1673
2002
  templateContext = this.computeTemplateContext();
1674
2003
  partAttribute = this.computePartAttribute();
2004
+ value = this.computeValueAttribute();
1675
2005
  computeTemplate() {
1676
2006
  return computed(() => {
1677
2007
  const cell = this.cell();
@@ -1699,28 +2029,51 @@ class SciTableCellComponent {
1699
2029
  }
1700
2030
  computePartAttribute() {
1701
2031
  return computed(() => {
2032
+ const cell = this.cell();
2033
+ const partType = this._table().type === 'tree' ? 'node' : 'row';
2034
+ const referenceType = cell.type === 'enum' ? cell.column.referenceType : undefined;
1702
2035
  return Arrays.distinct([
1703
- // Enable column styling based on column type.
1704
- `column:${this.cell().column.type}`,
2036
+ // Enable column styling based on column type. For enum columns, resolves to the reference type, if any, i.e., boolean or icon.
2037
+ `column:${referenceType ?? cell.column.type}`,
1705
2038
  // Enable column styling based on column name.
1706
- this.cell().column.name,
2039
+ cell.column.name,
1707
2040
  // Enable row styling based on selection state.
1708
- this.row().selected() ? 'row:selected' : 'row:not:selected',
2041
+ this.row().selected() ? `${partType}:selected` : `${partType}:not:selected`,
1709
2042
  // Enable row styling based on active state.
1710
- this.row().active() ? 'row:active' : 'row:not:active',
2043
+ this.row().active() ? `${partType}:active` : `${partType}:not:active`,
1711
2044
  // Enable row styling based on row bindings.
1712
- ...Arrays.coerce(this.row().bindings?.part()),
2045
+ ...Arrays.coerce(this.row().bindings.parts()),
1713
2046
  ]).join(' ');
1714
2047
  });
1715
2048
  }
2049
+ computeValueAttribute() {
2050
+ return computed(() => {
2051
+ const cell = this.cell();
2052
+ const value = (() => {
2053
+ switch (cell.type) {
2054
+ case 'string':
2055
+ case 'number':
2056
+ return cell.value();
2057
+ case 'enum':
2058
+ return cell.value()?.value;
2059
+ case 'date':
2060
+ return cell.value()?.isoString;
2061
+ default:
2062
+ return undefined;
2063
+ }
2064
+ })();
2065
+ return value !== undefined ? `${value}` : undefined;
2066
+ });
2067
+ }
1716
2068
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciTableCellComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1717
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: SciTableCellComponent, isStandalone: true, selector: "sci-table-cell", inputs: { cell: { classPropertyName: "cell", publicName: "cell", isSignal: true, isRequired: true, transformFunction: null }, row: { classPropertyName: "row", publicName: "row", isSignal: true, isRequired: true, transformFunction: null } }, host: { properties: { "attr.data-type": "cell().column.type", "attr.data-column": "cell().column.name", "attr.data-padding": "!cell().column.padding ? false : null", "attr.part": "partAttribute()" } }, ngImport: i0, template: "@let cell = this.cell();\n\n@switch (cell.type) {\n @case ('template') {\n <ng-template *ngTemplateOutlet=\"template(); context: templateContext()\"/>\n }\n @case ('component') {\n <ng-container *sciComponentOutlet=\"cell.component\"/>\n }\n @case ('boolean') {\n @if (cell.value() !== undefined) {\n <!-- TODO [dwi] Use another ligature for icon scion.clear -->\n <sci-icon class=\"checkbox\">{{cell.value() ? 'scion.checkmark' : 'scion.clear'}}</sci-icon>\n }\n }\n @case ('date') {\n {{cell.value()?.formatted}}\n }\n @case ('number') {\n {{cell.value() | sciNumber:cell.column.format()}}\n }\n @default {\n {{cell.value()}}\n }\n}\n", styles: ["@charset \"UTF-8\";:host{display:inline-grid;position:relative;border-inline:var(--\\275sci-table-cell-padding-inline) solid transparent;align-items:center;overflow:hidden}:host:is([data-type=string],[data-type=number],[data-type=date]){display:inline;white-space:nowrap;text-overflow:ellipsis;line-height:var(--\\275sci-table-row-height)}:host[data-type=number]{text-align:end}:host:is([data-type=number],[data-type=date]){font-variant-numeric:tabular-nums}:host[data-padding=false]{border-inline:none;align-items:stretch}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: SciIconComponent, selector: "sci-icon" }, { kind: "directive", type: SciComponentOutletDirective, selector: "ng-template[sciComponentOutlet]", inputs: ["sciComponentOutlet"] }, { kind: "pipe", type: SciNumberPipe, name: "sciNumber" }] });
2069
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: SciTableCellComponent, isStandalone: true, selector: "sci-table-cell", inputs: { cell: { classPropertyName: "cell", publicName: "cell", isSignal: true, isRequired: true, transformFunction: null }, row: { classPropertyName: "row", publicName: "row", isSignal: true, isRequired: true, transformFunction: null } }, host: { properties: { "attr.data-type": "cell().column.type", "attr.data-column": "cell().column.name", "attr.data-value": "value()", "attr.data-padding": "!cell().column.padding ? false : null", "attr.part": "partAttribute()" } }, hostDirectives: [{ directive: i1.SciTooltipOnOverflowDirective }], ngImport: i0, template: "@let cell = this.cell();\n\n@switch (cell.type) {\n @case ('template') {\n <ng-template *ngTemplateOutlet=\"template(); context: templateContext()\"/>\n }\n @case ('component') {\n <ng-container *sciComponentOutlet=\"cell.component\"/>\n }\n @case ('date') {\n {{cell.value()?.formatted}}\n }\n @case ('number') {\n {{cell.value() | sciNumber:cell.column.format()}}\n }\n @case ('enum') {\n @let value = cell.value();\n @if (value !== undefined) {\n @if (cell.column.format.icon && value.icon !== undefined) {\n <sci-icon [attr.title]=\"value.tooltip\">{{value.icon}}</sci-icon>\n }\n @if (cell.column.format.label) {\n <span>{{value.label}}</span>\n }\n }\n }\n @default {\n {{cell.value()}}\n }\n}\n", styles: ["@charset \"UTF-8\";:host{display:inline-grid;position:relative;border-inline:var(--\\275sci-table-cell-padding-inline) solid transparent;align-items:center;overflow:hidden}:host:is([data-type=string],[data-type=number],[data-type=date],[data-type=enum]){display:inline;white-space:nowrap;text-overflow:ellipsis;line-height:var(--\\275sci-table-row-height)}:host[data-type=number]{text-align:end}:host:is([data-type=number],[data-type=date]){font-variant-numeric:tabular-nums}:host[data-padding=false]{border-inline:none;align-items:stretch}:host>sci-icon:not(:last-child){margin-right:.75em}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: SciIconComponent, selector: "sci-icon" }, { kind: "directive", type: SciComponentOutletDirective, selector: "ng-template[sciComponentOutlet]", inputs: ["sciComponentOutlet"] }, { kind: "pipe", type: SciNumberPipe, name: "sciNumber" }] });
1718
2070
  }
1719
2071
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciTableCellComponent, decorators: [{
1720
2072
  type: Component,
1721
2073
  args: [{ selector: 'sci-table-cell', host: {
1722
2074
  '[attr.data-type]': 'cell().column.type',
1723
2075
  '[attr.data-column]': 'cell().column.name',
2076
+ '[attr.data-value]': 'value()',
1724
2077
  '[attr.data-padding]': '!cell().column.padding ? false : null',
1725
2078
  '[attr.part]': 'partAttribute()',
1726
2079
  }, imports: [
@@ -1728,7 +2081,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
1728
2081
  SciIconComponent,
1729
2082
  SciComponentOutletDirective,
1730
2083
  SciNumberPipe,
1731
- ], template: "@let cell = this.cell();\n\n@switch (cell.type) {\n @case ('template') {\n <ng-template *ngTemplateOutlet=\"template(); context: templateContext()\"/>\n }\n @case ('component') {\n <ng-container *sciComponentOutlet=\"cell.component\"/>\n }\n @case ('boolean') {\n @if (cell.value() !== undefined) {\n <!-- TODO [dwi] Use another ligature for icon scion.clear -->\n <sci-icon class=\"checkbox\">{{cell.value() ? 'scion.checkmark' : 'scion.clear'}}</sci-icon>\n }\n }\n @case ('date') {\n {{cell.value()?.formatted}}\n }\n @case ('number') {\n {{cell.value() | sciNumber:cell.column.format()}}\n }\n @default {\n {{cell.value()}}\n }\n}\n", styles: ["@charset \"UTF-8\";:host{display:inline-grid;position:relative;border-inline:var(--\\275sci-table-cell-padding-inline) solid transparent;align-items:center;overflow:hidden}:host:is([data-type=string],[data-type=number],[data-type=date]){display:inline;white-space:nowrap;text-overflow:ellipsis;line-height:var(--\\275sci-table-row-height)}:host[data-type=number]{text-align:end}:host:is([data-type=number],[data-type=date]){font-variant-numeric:tabular-nums}:host[data-padding=false]{border-inline:none;align-items:stretch}\n"] }]
2084
+ ], hostDirectives: [
2085
+ SciTooltipOnOverflowDirective,
2086
+ ], template: "@let cell = this.cell();\n\n@switch (cell.type) {\n @case ('template') {\n <ng-template *ngTemplateOutlet=\"template(); context: templateContext()\"/>\n }\n @case ('component') {\n <ng-container *sciComponentOutlet=\"cell.component\"/>\n }\n @case ('date') {\n {{cell.value()?.formatted}}\n }\n @case ('number') {\n {{cell.value() | sciNumber:cell.column.format()}}\n }\n @case ('enum') {\n @let value = cell.value();\n @if (value !== undefined) {\n @if (cell.column.format.icon && value.icon !== undefined) {\n <sci-icon [attr.title]=\"value.tooltip\">{{value.icon}}</sci-icon>\n }\n @if (cell.column.format.label) {\n <span>{{value.label}}</span>\n }\n }\n }\n @default {\n {{cell.value()}}\n }\n}\n", styles: ["@charset \"UTF-8\";:host{display:inline-grid;position:relative;border-inline:var(--\\275sci-table-cell-padding-inline) solid transparent;align-items:center;overflow:hidden}:host:is([data-type=string],[data-type=number],[data-type=date],[data-type=enum]){display:inline;white-space:nowrap;text-overflow:ellipsis;line-height:var(--\\275sci-table-row-height)}:host[data-type=number]{text-align:end}:host:is([data-type=number],[data-type=date]){font-variant-numeric:tabular-nums}:host[data-padding=false]{border-inline:none;align-items:stretch}:host>sci-icon:not(:last-child){margin-right:.75em}\n"] }]
1732
2087
  }], propDecorators: { cell: [{ type: i0.Input, args: [{ isSignal: true, alias: "cell", required: true }] }], row: [{ type: i0.Input, args: [{ isSignal: true, alias: "row", required: true }] }] } });
1733
2088
 
1734
2089
  /*
@@ -1745,27 +2100,35 @@ class SciTableRowComponent {
1745
2100
  ...(ngDevMode ? [{ debugName: "row" }] : /* istanbul ignore next */ []));
1746
2101
  primaryAction = output();
1747
2102
  table = inject(ɵSCI_TABLE);
2103
+ _selectionService = inject(SciTableSelectionService);
1748
2104
  rowActionToolbar = viewChild(SciToolbarComponent, /* @ts-ignore */
1749
2105
  ...(ngDevMode ? [{ debugName: "rowActionToolbar" }] : /* istanbul ignore next */ []));
1750
2106
  rowActionsToolbarName = `toolbar:${UUID.randomUUID()}`;
1751
- _selectionService = inject(SciTableSelectionService);
1752
2107
  constructor() {
1753
2108
  this.contributeRowActions();
1754
2109
  }
1755
2110
  onRowClick(event) {
1756
- if (this.row().loading) {
2111
+ const row = this.row();
2112
+ if (row.id === skeletonRowId) {
1757
2113
  return;
1758
2114
  }
1759
- void this._selectionService.onRowClick(this.row().index, event);
2115
+ const index = this.table().rowIndexById().get(row.id) ?? -1;
2116
+ void this._selectionService.onRowClick(index, event);
1760
2117
  }
1761
2118
  onRowDblClick(event) {
1762
- if (this.row().loading) {
2119
+ const row = this.row();
2120
+ if (row.id === skeletonRowId || isFromInput(event) || isFromButton(event)) {
1763
2121
  return;
1764
2122
  }
1765
- this.primaryAction.emit({ sourceEvent: event, items: [this.row().item] });
2123
+ this.primaryAction.emit({ sourceEvent: event, items: [row.id] });
1766
2124
  }
1767
2125
  onRowMouseEnter() {
1768
- this.table().hoveredIndex.set(this.row().index);
2126
+ const row = this.row();
2127
+ if (row.id === skeletonRowId) {
2128
+ return;
2129
+ }
2130
+ const index = this.table().rowIndexById().get(row.id) ?? -1;
2131
+ this.table().hoveredIndex.set(index);
1769
2132
  }
1770
2133
  onRowMouseLeave(event) {
1771
2134
  const next = event.relatedTarget;
@@ -1776,35 +2139,49 @@ class SciTableRowComponent {
1776
2139
  this.table().hoveredIndex.set(-1);
1777
2140
  }
1778
2141
  onActionToolbarClick(event) {
2142
+ const row = this.row();
2143
+ if (row.id === skeletonRowId) {
2144
+ return;
2145
+ }
2146
+ event.stopPropagation(); // prevent selecting the row
2147
+ this.table().setActiveItem(row.id);
2148
+ }
2149
+ onTreeNodeHandle(event) {
2150
+ const row = this.row();
2151
+ if (row.id === skeletonRowId) {
2152
+ return;
2153
+ }
1779
2154
  event.stopPropagation(); // prevent selecting the row
1780
- this.table().activeItem.set(this.row().item);
2155
+ this.table().toggleRow(row.id);
1781
2156
  }
1782
2157
  contributeRowActions() {
1783
2158
  contributeMenu(this.rowActionsToolbarName, toolbar => {
1784
- const rowActionsFactoryFn = this.table().rowActions;
1785
- if (!this.row().loading && rowActionsFactoryFn) {
1786
- rowActionsFactoryFn(toolbar, this.row().item, this.row().index);
2159
+ if (this.row().id !== skeletonRowId) {
2160
+ this.table().rowActions?.(toolbar, this.row().item, this.row().index);
1787
2161
  }
1788
2162
  });
1789
2163
  }
1790
2164
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciTableRowComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1791
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: SciTableRowComponent, isStandalone: true, selector: "sci-table-row", inputs: { row: { classPropertyName: "row", publicName: "row", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { primaryAction: "primaryAction" }, host: { listeners: { "click": "onRowClick($event)", "dblclick": "onRowDblClick($event)", "mouseenter": "onRowMouseEnter()", "mouseleave": "onRowMouseLeave($event)" }, properties: { "attr.data-active": "row().active() ? '' : null", "attr.data-selected": "row().selected() ? '' : null", "attr.data-hovered": "row().hovered() ? '' : null", "class": "row().bindings?.cssClass?.()" } }, viewQueries: [{ propertyName: "rowActionToolbar", first: true, predicate: SciToolbarComponent, descendants: true, isSignal: true }], ngImport: i0, template: "@let row = this.row();\n@for (cell of row.cells; track cell.column.name) {\n <sci-table-cell [cell]=\"cell\" [row]=\"row\"/>\n} @empty {\n @for (column of table().columns(); track column.name) {\n <div class=\"skeleton e2e-skeleton\" [attr.data-column]=\"column.name\"></div>\n }\n}\n\n@let hasRowActions = !!table().rowActions;\n@let isRowHovered = table().hoveredRow() === row;\n@let isRowActionMenuOpen = rowActionToolbar()?.menuOpen();\n\n@if (hasRowActions && (isRowHovered || isRowActionMenuOpen)) {\n <sci-toolbar [name]=\"rowActionsToolbarName\" (click)=\"onActionToolbarClick($event)\" class=\"row-actions\"/>\n}\n", styles: ["@charset \"UTF-8\";:host{display:inline-grid;grid-template-columns:subgrid;grid-auto-rows:var(--sci-table-row-height);align-items:stretch;-webkit-user-select:none;user-select:none;border-bottom:var(--\\275sci-table-border-size) solid var(--sci-table-gridline-color)}:host[data-hovered]{background-color:var(--sci-table-row-background-color-hover)}:host[data-selected]{background-color:var(--sci-table-row-background-color-selected);--\\275sci-table-row-selected: true}:host[data-hovered]:not([data-selected]){border-radius:var(--sci-table-row-border-radius)}:host:is(:first-child,:not([data-selected])+sci-table-row)[data-selected]{border-top-left-radius:var(--sci-table-row-border-radius);border-top-right-radius:var(--sci-table-row-border-radius)}:host[data-selected]:not(:has(+sci-table-row[data-selected])){border-bottom-left-radius:var(--sci-table-row-border-radius);border-bottom-right-radius:var(--sci-table-row-border-radius)}@container (style(--\\275sci-table-scrolling: true)) or (style(--\\275sci-table-resizing: true)){:host{pointer-events:none}}:host:has(>div.skeleton){pointer-events:none;align-items:center}:host:has(>div.skeleton)>div.skeleton{display:inline-block;position:relative;overflow:hidden;border-radius:var(--sci-corner-small);background-color:var(--sci-color-skeleton-subtle);opacity:0;animation-name:visiblity;animation-duration:.5s;animation-timing-function:ease-in;animation-fill-mode:forwards}:host:has(>div.skeleton)>div.skeleton:after{position:absolute;inset:0;transform:translate(-100%);background-image:linear-gradient(to right,transparent 0,color-mix(in srgb,light-dark(var(--sci-static-color-white),var(--sci-static-color-black)) 30%,transparent) 50%,transparent);animation:shimmer 2s infinite;content:\"\"}@keyframes shimmer{to{transform:translate(100%)}}@keyframes visiblity{to{opacity:1}}:host:has(>div.skeleton)>div.skeleton{height:1em;margin-inline:var(--\\275sci-table-cell-padding-inline)}:host>sci-toolbar.row-actions{position:sticky;right:0;transform:translate(calc(-1 * var(--\\275sci-table-cell-padding-inline)));justify-self:end;align-self:center;border-radius:var(--sci-corner);padding:1px;background-color:var(--\\275sci-table-row-action-background-color);z-index:2;--sci-toolbar-item-background-color-hover: color-mix(in srgb, var(--\\275sci-table-row-action-background-color) 85%, light-dark(var(--sci-static-color-black), var(--sci-static-color-white)));--sci-toolbar-item-background-color-active: color-mix(in srgb, var(--\\275sci-table-row-action-background-color) 75%, light-dark(var(--sci-static-color-black), var(--sci-static-color-white)))}@container not style(--\\275sci-table-row-selected: true){:host>sci-toolbar.row-actions{--\\275sci-table-row-action-background-color: var(--sci-table-row-action-background-color)}}@container style(--\\275sci-table-row-selected: true){:host>sci-toolbar.row-actions{--\\275sci-table-row-action-background-color: var(--sci-table-row-action-background-color-selected)}}:host>sci-toolbar.row-actions:empty{display:none}\n"], dependencies: [{ kind: "component", type: SciTableCellComponent, selector: "sci-table-cell", inputs: ["cell", "row"] }, { kind: "component", type: SciToolbarComponent, selector: "sci-toolbar", inputs: ["name", "orientation", "context", "acceleratorTarget", "popoverViewContainerRef"] }] });
2165
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: SciTableRowComponent, isStandalone: true, selector: "sci-table-row", inputs: { row: { classPropertyName: "row", publicName: "row", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { primaryAction: "primaryAction" }, host: { listeners: { "click": "onRowClick($event)", "dblclick": "onRowDblClick($event)", "mouseenter": "onRowMouseEnter()", "mouseleave": "onRowMouseLeave($event)" }, properties: { "attr.data-active": "row().active() ? '' : null", "attr.data-selected": "row().selected() ? '' : null", "attr.data-hovered": "row().hovered() ? '' : null", "attr.data-level": "row().level", "attr.data-state": "row().level !== undefined && row().hasChildren() ? (row().expanded() ? 'expanded' : 'collapsed') : null", "style.--\u0275sci-table-row-level": "row().level" } }, viewQueries: [{ propertyName: "rowActionToolbar", first: true, predicate: SciToolbarComponent, descendants: true, isSignal: true }], ngImport: i0, template: "@let row = this.row();\n\n<!-- Handle to expand or collapse a tree node -->\n@if (row.level !== undefined) {\n <button class=\"tree-node-handle e2e-tree-node-handle\" (click)=\"onTreeNodeHandle($event)\" [attr.disabled]=\"!row.hasChildren() || null\">\n @if (row.expanded()) {\n <sci-icon class=\"e2e-expanded\">scion:chevron_down</sci-icon>\n } @else {\n <sci-icon class=\"e2e-collapsed\">scion:chevron_right</sci-icon>\n }\n </button>\n}\n\n<!-- Row Icons -->\n@if (row.cells.length) {\n @for (icon of row.bindings.icons(); track $index) {\n <sci-icon>{{icon}}</sci-icon>\n }\n}\n\n<!-- Table Cells -->\n@for (cell of row.cells; track cell.column.name) {\n <sci-table-cell [cell]=\"cell\" [row]=\"row\"/>\n} @empty {\n @for (column of table().columns(); track column.name) {\n <div class=\"skeleton e2e-skeleton\" [attr.data-column]=\"column.name\"></div>\n }\n}\n\n<!--Row Actions -->\n@let hasRowActions = !!table().rowActions;\n@let isRowHovered = table().hoveredIndex() === row.index;\n@let isRowActionMenuOpen = rowActionToolbar()?.menuOpen();\n\n@if (hasRowActions && (isRowHovered || isRowActionMenuOpen)) {\n <sci-toolbar [name]=\"rowActionsToolbarName\" (click)=\"onActionToolbarClick($event)\" class=\"row-actions\"/>\n}\n", styles: ["@charset \"UTF-8\";:host{display:inline-grid;grid-template-columns:subgrid;grid-auto-rows:var(--sci-table-row-height);align-items:stretch;-webkit-user-select:none;user-select:none;border-bottom:var(--\\275sci-table-border-size) solid var(--sci-table-gridline-color)}:host[data-hovered]{background-color:var(--sci-table-row-background-color-hover)}:host[data-selected]{background-color:var(--sci-table-row-background-color-selected);--\\275sci-table-row-selected: true}:host[data-hovered]:not([data-selected]){border-radius:var(--sci-table-row-border-radius)}:host:is(:first-child,:not([data-selected])+sci-table-row)[data-selected]{border-top-left-radius:var(--sci-table-row-border-radius);border-top-right-radius:var(--sci-table-row-border-radius)}:host[data-selected]:not(:has(+sci-table-row[data-selected])){border-bottom-left-radius:var(--sci-table-row-border-radius);border-bottom-right-radius:var(--sci-table-row-border-radius)}@container (style(--\\275sci-table-scrolling: true)) or (style(--\\275sci-table-resizing: true)){:host{pointer-events:none}}:host:has(>div.skeleton){pointer-events:none;align-items:center}:host:has(>div.skeleton)>div.skeleton{display:inline-block;position:relative;overflow:hidden;border-radius:var(--sci-corner-small);background-color:var(--sci-color-skeleton-subtle);opacity:0;animation-name:visiblity;animation-duration:.5s;animation-timing-function:ease-in;animation-fill-mode:forwards}:host:has(>div.skeleton)>div.skeleton:after{position:absolute;inset:0;transform:translate(-100%);background-image:linear-gradient(to right,transparent 0,color-mix(in srgb,light-dark(var(--sci-static-color-white),var(--sci-static-color-black)) 30%,transparent) 50%,transparent);animation:shimmer 2s infinite;content:\"\"}@keyframes shimmer{to{transform:translate(100%)}}@keyframes visiblity{to{opacity:1}}:host:has(>div.skeleton)>div.skeleton{height:1em;margin-inline:var(--\\275sci-table-cell-padding-inline)}:host>button.tree-node-handle{all:unset;display:inline-grid;position:relative;order:1}:host>button.tree-node-handle:disabled{display:none}:host>:is(sci-icon,button.tree-node-handle):has(~sci-table-cell:first-of-type){grid-column:1;grid-row:1;align-self:center;margin-left:calc(var(--\\275sci-table-cell-padding-inline) + (sibling-index() - 1) * 24px + var(--\\275sci-table-row-level, 0) * var(--sci-table-row-indent));--sci-icon-size: 16px;width:auto;max-width:var(--sci-icon-size)}:host>:is(sci-icon,button.tree-node-handle):has(~sci-table-cell:first-of-type)+sci-table-cell{grid-column:1;grid-row:1;border-left-width:calc(var(--\\275sci-table-cell-padding-inline) + (sibling-index() - 1) * 24px + var(--\\275sci-table-row-level, 0) * var(--sci-table-row-indent))}:host>:is(sci-icon,button.tree-node-handle):has(~sci-table-cell:first-of-type)+sci-table-cell[data-padding=false]{border-left-width:calc(var(--\\275sci-table-cell-padding-inline) + (sibling-index() - 1) * 24px + var(--\\275sci-table-row-level, 0) * var(--sci-table-row-indent) - var(--\\275sci-table-cell-padding-inline))}:host>sci-toolbar.row-actions{position:sticky;right:0;transform:translate(calc(-1 * var(--\\275sci-table-cell-padding-inline)));justify-self:end;align-self:center;border-radius:var(--sci-corner);padding:1px;background-color:var(--\\275sci-table-row-action-background-color);z-index:2;--sci-toolbar-item-background-color-hover: color-mix(in srgb, var(--\\275sci-table-row-action-background-color) 85%, light-dark(var(--sci-static-color-black), var(--sci-static-color-white)));--sci-toolbar-item-background-color-active: color-mix(in srgb, var(--\\275sci-table-row-action-background-color) 75%, light-dark(var(--sci-static-color-black), var(--sci-static-color-white)))}@container not style(--\\275sci-table-row-selected: true){:host>sci-toolbar.row-actions{--\\275sci-table-row-action-background-color: var(--sci-table-row-action-background-color)}}@container style(--\\275sci-table-row-selected: true){:host>sci-toolbar.row-actions{--\\275sci-table-row-action-background-color: var(--sci-table-row-action-background-color-selected)}}:host>sci-toolbar.row-actions:empty{display:none}\n"], dependencies: [{ kind: "component", type: SciTableCellComponent, selector: "sci-table-cell", inputs: ["cell", "row"] }, { kind: "component", type: SciToolbarComponent, selector: "sci-toolbar", inputs: ["name", "orientation", "context", "acceleratorTarget", "popoverViewContainerRef"] }, { kind: "component", type: SciIconComponent, selector: "sci-icon" }] });
1792
2166
  }
1793
2167
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciTableRowComponent, decorators: [{
1794
2168
  type: Component,
1795
2169
  args: [{ selector: 'sci-table-row', imports: [
1796
2170
  SciTableCellComponent,
1797
2171
  SciToolbarComponent,
2172
+ SciIconComponent,
1798
2173
  ], host: {
1799
2174
  '[attr.data-active]': `row().active() ? '' : null`,
1800
2175
  '[attr.data-selected]': `row().selected() ? '' : null`,
1801
2176
  '[attr.data-hovered]': `row().hovered() ? '' : null`,
1802
- '[class]': 'row().bindings?.cssClass?.()',
2177
+ '[attr.data-level]': 'row().level',
2178
+ '[attr.data-state]': `row().level !== undefined && row().hasChildren() ? (row().expanded() ? 'expanded' : 'collapsed') : null`,
2179
+ '[style.--ɵsci-table-row-level]': 'row().level', // only set if hierarchical
1803
2180
  '(click)': 'onRowClick($event)',
1804
2181
  '(dblclick)': 'onRowDblClick($event)',
1805
2182
  '(mouseenter)': 'onRowMouseEnter()',
1806
2183
  '(mouseleave)': 'onRowMouseLeave($event)',
1807
- }, template: "@let row = this.row();\n@for (cell of row.cells; track cell.column.name) {\n <sci-table-cell [cell]=\"cell\" [row]=\"row\"/>\n} @empty {\n @for (column of table().columns(); track column.name) {\n <div class=\"skeleton e2e-skeleton\" [attr.data-column]=\"column.name\"></div>\n }\n}\n\n@let hasRowActions = !!table().rowActions;\n@let isRowHovered = table().hoveredRow() === row;\n@let isRowActionMenuOpen = rowActionToolbar()?.menuOpen();\n\n@if (hasRowActions && (isRowHovered || isRowActionMenuOpen)) {\n <sci-toolbar [name]=\"rowActionsToolbarName\" (click)=\"onActionToolbarClick($event)\" class=\"row-actions\"/>\n}\n", styles: ["@charset \"UTF-8\";:host{display:inline-grid;grid-template-columns:subgrid;grid-auto-rows:var(--sci-table-row-height);align-items:stretch;-webkit-user-select:none;user-select:none;border-bottom:var(--\\275sci-table-border-size) solid var(--sci-table-gridline-color)}:host[data-hovered]{background-color:var(--sci-table-row-background-color-hover)}:host[data-selected]{background-color:var(--sci-table-row-background-color-selected);--\\275sci-table-row-selected: true}:host[data-hovered]:not([data-selected]){border-radius:var(--sci-table-row-border-radius)}:host:is(:first-child,:not([data-selected])+sci-table-row)[data-selected]{border-top-left-radius:var(--sci-table-row-border-radius);border-top-right-radius:var(--sci-table-row-border-radius)}:host[data-selected]:not(:has(+sci-table-row[data-selected])){border-bottom-left-radius:var(--sci-table-row-border-radius);border-bottom-right-radius:var(--sci-table-row-border-radius)}@container (style(--\\275sci-table-scrolling: true)) or (style(--\\275sci-table-resizing: true)){:host{pointer-events:none}}:host:has(>div.skeleton){pointer-events:none;align-items:center}:host:has(>div.skeleton)>div.skeleton{display:inline-block;position:relative;overflow:hidden;border-radius:var(--sci-corner-small);background-color:var(--sci-color-skeleton-subtle);opacity:0;animation-name:visiblity;animation-duration:.5s;animation-timing-function:ease-in;animation-fill-mode:forwards}:host:has(>div.skeleton)>div.skeleton:after{position:absolute;inset:0;transform:translate(-100%);background-image:linear-gradient(to right,transparent 0,color-mix(in srgb,light-dark(var(--sci-static-color-white),var(--sci-static-color-black)) 30%,transparent) 50%,transparent);animation:shimmer 2s infinite;content:\"\"}@keyframes shimmer{to{transform:translate(100%)}}@keyframes visiblity{to{opacity:1}}:host:has(>div.skeleton)>div.skeleton{height:1em;margin-inline:var(--\\275sci-table-cell-padding-inline)}:host>sci-toolbar.row-actions{position:sticky;right:0;transform:translate(calc(-1 * var(--\\275sci-table-cell-padding-inline)));justify-self:end;align-self:center;border-radius:var(--sci-corner);padding:1px;background-color:var(--\\275sci-table-row-action-background-color);z-index:2;--sci-toolbar-item-background-color-hover: color-mix(in srgb, var(--\\275sci-table-row-action-background-color) 85%, light-dark(var(--sci-static-color-black), var(--sci-static-color-white)));--sci-toolbar-item-background-color-active: color-mix(in srgb, var(--\\275sci-table-row-action-background-color) 75%, light-dark(var(--sci-static-color-black), var(--sci-static-color-white)))}@container not style(--\\275sci-table-row-selected: true){:host>sci-toolbar.row-actions{--\\275sci-table-row-action-background-color: var(--sci-table-row-action-background-color)}}@container style(--\\275sci-table-row-selected: true){:host>sci-toolbar.row-actions{--\\275sci-table-row-action-background-color: var(--sci-table-row-action-background-color-selected)}}:host>sci-toolbar.row-actions:empty{display:none}\n"] }]
2184
+ }, template: "@let row = this.row();\n\n<!-- Handle to expand or collapse a tree node -->\n@if (row.level !== undefined) {\n <button class=\"tree-node-handle e2e-tree-node-handle\" (click)=\"onTreeNodeHandle($event)\" [attr.disabled]=\"!row.hasChildren() || null\">\n @if (row.expanded()) {\n <sci-icon class=\"e2e-expanded\">scion:chevron_down</sci-icon>\n } @else {\n <sci-icon class=\"e2e-collapsed\">scion:chevron_right</sci-icon>\n }\n </button>\n}\n\n<!-- Row Icons -->\n@if (row.cells.length) {\n @for (icon of row.bindings.icons(); track $index) {\n <sci-icon>{{icon}}</sci-icon>\n }\n}\n\n<!-- Table Cells -->\n@for (cell of row.cells; track cell.column.name) {\n <sci-table-cell [cell]=\"cell\" [row]=\"row\"/>\n} @empty {\n @for (column of table().columns(); track column.name) {\n <div class=\"skeleton e2e-skeleton\" [attr.data-column]=\"column.name\"></div>\n }\n}\n\n<!--Row Actions -->\n@let hasRowActions = !!table().rowActions;\n@let isRowHovered = table().hoveredIndex() === row.index;\n@let isRowActionMenuOpen = rowActionToolbar()?.menuOpen();\n\n@if (hasRowActions && (isRowHovered || isRowActionMenuOpen)) {\n <sci-toolbar [name]=\"rowActionsToolbarName\" (click)=\"onActionToolbarClick($event)\" class=\"row-actions\"/>\n}\n", styles: ["@charset \"UTF-8\";:host{display:inline-grid;grid-template-columns:subgrid;grid-auto-rows:var(--sci-table-row-height);align-items:stretch;-webkit-user-select:none;user-select:none;border-bottom:var(--\\275sci-table-border-size) solid var(--sci-table-gridline-color)}:host[data-hovered]{background-color:var(--sci-table-row-background-color-hover)}:host[data-selected]{background-color:var(--sci-table-row-background-color-selected);--\\275sci-table-row-selected: true}:host[data-hovered]:not([data-selected]){border-radius:var(--sci-table-row-border-radius)}:host:is(:first-child,:not([data-selected])+sci-table-row)[data-selected]{border-top-left-radius:var(--sci-table-row-border-radius);border-top-right-radius:var(--sci-table-row-border-radius)}:host[data-selected]:not(:has(+sci-table-row[data-selected])){border-bottom-left-radius:var(--sci-table-row-border-radius);border-bottom-right-radius:var(--sci-table-row-border-radius)}@container (style(--\\275sci-table-scrolling: true)) or (style(--\\275sci-table-resizing: true)){:host{pointer-events:none}}:host:has(>div.skeleton){pointer-events:none;align-items:center}:host:has(>div.skeleton)>div.skeleton{display:inline-block;position:relative;overflow:hidden;border-radius:var(--sci-corner-small);background-color:var(--sci-color-skeleton-subtle);opacity:0;animation-name:visiblity;animation-duration:.5s;animation-timing-function:ease-in;animation-fill-mode:forwards}:host:has(>div.skeleton)>div.skeleton:after{position:absolute;inset:0;transform:translate(-100%);background-image:linear-gradient(to right,transparent 0,color-mix(in srgb,light-dark(var(--sci-static-color-white),var(--sci-static-color-black)) 30%,transparent) 50%,transparent);animation:shimmer 2s infinite;content:\"\"}@keyframes shimmer{to{transform:translate(100%)}}@keyframes visiblity{to{opacity:1}}:host:has(>div.skeleton)>div.skeleton{height:1em;margin-inline:var(--\\275sci-table-cell-padding-inline)}:host>button.tree-node-handle{all:unset;display:inline-grid;position:relative;order:1}:host>button.tree-node-handle:disabled{display:none}:host>:is(sci-icon,button.tree-node-handle):has(~sci-table-cell:first-of-type){grid-column:1;grid-row:1;align-self:center;margin-left:calc(var(--\\275sci-table-cell-padding-inline) + (sibling-index() - 1) * 24px + var(--\\275sci-table-row-level, 0) * var(--sci-table-row-indent));--sci-icon-size: 16px;width:auto;max-width:var(--sci-icon-size)}:host>:is(sci-icon,button.tree-node-handle):has(~sci-table-cell:first-of-type)+sci-table-cell{grid-column:1;grid-row:1;border-left-width:calc(var(--\\275sci-table-cell-padding-inline) + (sibling-index() - 1) * 24px + var(--\\275sci-table-row-level, 0) * var(--sci-table-row-indent))}:host>:is(sci-icon,button.tree-node-handle):has(~sci-table-cell:first-of-type)+sci-table-cell[data-padding=false]{border-left-width:calc(var(--\\275sci-table-cell-padding-inline) + (sibling-index() - 1) * 24px + var(--\\275sci-table-row-level, 0) * var(--sci-table-row-indent) - var(--\\275sci-table-cell-padding-inline))}:host>sci-toolbar.row-actions{position:sticky;right:0;transform:translate(calc(-1 * var(--\\275sci-table-cell-padding-inline)));justify-self:end;align-self:center;border-radius:var(--sci-corner);padding:1px;background-color:var(--\\275sci-table-row-action-background-color);z-index:2;--sci-toolbar-item-background-color-hover: color-mix(in srgb, var(--\\275sci-table-row-action-background-color) 85%, light-dark(var(--sci-static-color-black), var(--sci-static-color-white)));--sci-toolbar-item-background-color-active: color-mix(in srgb, var(--\\275sci-table-row-action-background-color) 75%, light-dark(var(--sci-static-color-black), var(--sci-static-color-white)))}@container not style(--\\275sci-table-row-selected: true){:host>sci-toolbar.row-actions{--\\275sci-table-row-action-background-color: var(--sci-table-row-action-background-color)}}@container style(--\\275sci-table-row-selected: true){:host>sci-toolbar.row-actions{--\\275sci-table-row-action-background-color: var(--sci-table-row-action-background-color-selected)}}:host>sci-toolbar.row-actions:empty{display:none}\n"] }]
1808
2185
  }], ctorParameters: () => [], propDecorators: { row: [{ type: i0.Input, args: [{ isSignal: true, alias: "row", required: true }] }], primaryAction: [{ type: i0.Output, args: ["primaryAction"] }], rowActionToolbar: [{ type: i0.ViewChild, args: [i0.forwardRef(() => SciToolbarComponent), { isSignal: true }] }] } });
1809
2186
 
1810
2187
  /*
@@ -1817,27 +2194,44 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
1817
2194
  * SPDX-License-Identifier: EPL-2.0
1818
2195
  */
1819
2196
  class SciTableKeyboardNavigatorDirective {
2197
+ _table = inject(ɵSCI_TABLE);
1820
2198
  selectionService = inject(SciTableSelectionService);
1821
- /**
1822
- * Indicates whether the given keyboard event was triggered from an input element.
1823
- */
1824
- isFromInput(event) {
1825
- if (event.target instanceof Element && event.target.closest('input, textarea, select') !== null) {
1826
- return true;
2199
+ isFromInput = isFromInput;
2200
+ isFromButton = isFromButton;
2201
+ async onKeydown(event) {
2202
+ if (event.key === '+' && event.location === 3) { // NumpadAdd
2203
+ void this.onNumpadAdd(event);
1827
2204
  }
1828
- if (event.target instanceof HTMLElement && event.target.isContentEditable) {
1829
- return true;
2205
+ else if (event.key === '-' && event.location === 3) { // NumpadSubtract
2206
+ void this.onNumpadSubtract(event);
1830
2207
  }
1831
- return false;
1832
2208
  }
1833
- /**
1834
- * Indicates whether the given keyboard event was triggered from a button.
1835
- */
1836
- isFromButton(event) {
1837
- return event.target instanceof Element && event.target.closest('button') !== null;
2209
+ async onNumpadAdd(event) {
2210
+ event.preventDefault();
2211
+ const table = this._table();
2212
+ if (event.ctrlKey || event.metaKey) {
2213
+ table.expandAll();
2214
+ return;
2215
+ }
2216
+ const activeRow = table.activeRow();
2217
+ if (activeRow?.id !== skeletonRowId && activeRow?.hasChildren()) {
2218
+ table.expand(activeRow.id);
2219
+ }
2220
+ }
2221
+ async onNumpadSubtract(event) {
2222
+ event.preventDefault();
2223
+ const table = this._table();
2224
+ if (event.ctrlKey || event.metaKey) {
2225
+ table.collapseAll();
2226
+ return;
2227
+ }
2228
+ const activeRow = table.activeRow();
2229
+ if (activeRow?.id !== skeletonRowId && activeRow?.hasChildren()) {
2230
+ table.collapse(activeRow.id);
2231
+ }
1838
2232
  }
1839
2233
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciTableKeyboardNavigatorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1840
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.7", type: SciTableKeyboardNavigatorDirective, isStandalone: true, selector: "[sciTableKeyboardNavigator]", host: { listeners: { "keydown.arrowUp": "isFromInput($event) || selectionService.onArrowUp($event)", "keydown.shift.arrowUp": "isFromInput($event) || selectionService.onArrowUp($event)", "keydown.control.arrowUp": "isFromInput($event) || selectionService.onArrowUp($event)", "keydown.meta.arrowUp": "isFromInput($event) || selectionService.onArrowUp($event)", "keydown.shift.control.arrowUp": "isFromInput($event) || selectionService.onArrowUp($event)", "keydown.shift.meta.arrowUp": "isFromInput($event) || selectionService.onArrowUp($event)", "keydown.arrowDown": "isFromInput($event) || selectionService.onArrowDown($event)", "keydown.shift.arrowDown": "isFromInput($event) || selectionService.onArrowDown($event)", "keydown.control.arrowDown": "isFromInput($event) || selectionService.onArrowDown($event)", "keydown.meta.arrowDown": "isFromInput($event) || selectionService.onArrowDown($event)", "keydown.shift.control.arrowDown": "isFromInput($event) || selectionService.onArrowDown($event)", "keydown.shift.meta.arrowDown": "isFromInput($event) || selectionService.onArrowDown($event)", "keydown.space": "isFromInput($event) || isFromButton($event) || selectionService.onSpace($event)", "keydown.control.space": "isFromInput($event) || isFromButton($event) || selectionService.onSpace($event)", "keydown.meta.space": "isFromInput($event) || isFromButton($event) || selectionService.onSpace($event)", "keydown.control.a": "isFromInput($event) || selectionService.onControlA($event)", "keydown.meta.a": "isFromInput($event) || selectionService.onControlA($event)", "keydown.pageUp": "isFromInput($event) || selectionService.onPageUp($event)", "keydown.shift.pageUp": "isFromInput($event) || selectionService.onPageUp($event)", "keydown.control.pageUp": "isFromInput($event) || selectionService.onPageUp($event)", "keydown.shift.control.pageUp": "isFromInput($event) || selectionService.onPageUp($event)", "keydown.pageDown": "isFromInput($event) || selectionService.onPageDown($event)", "keydown.shift.pageDown": "isFromInput($event) || selectionService.onPageDown($event)", "keydown.control.pageDown": "isFromInput($event) || selectionService.onPageDown($event)", "keydown.shift.control.pageDown": "isFromInput($event) || selectionService.onPageDown($event)", "keydown.home": "isFromInput($event) || selectionService.onHome($event)", "keydown.shift.home": "isFromInput($event) || selectionService.onHome($event)", "keydown.control.home": "isFromInput($event) || selectionService.onHome($event)", "keydown.shift.control.home": "isFromInput($event) || selectionService.onHome($event)", "keydown.end": "isFromInput($event) || selectionService.onEnd($event)", "keydown.shift.end": "isFromInput($event) || selectionService.onEnd($event)", "keydown.control.end": "isFromInput($event) || selectionService.onEnd($event)", "keydown.shift.control.end": "isFromInput($event) || selectionService.onEnd($event)" }, properties: { "attr.tabindex": "0" } }, ngImport: i0 });
2234
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.7", type: SciTableKeyboardNavigatorDirective, isStandalone: true, selector: "[sciTableKeyboardNavigator]", host: { listeners: { "keydown.arrowUp": "isFromInput($event) || selectionService.onArrowUp($event)", "keydown.shift.arrowUp": "isFromInput($event) || selectionService.onArrowUp($event)", "keydown.control.arrowUp": "isFromInput($event) || selectionService.onArrowUp($event)", "keydown.meta.arrowUp": "isFromInput($event) || selectionService.onArrowUp($event)", "keydown.shift.control.arrowUp": "isFromInput($event) || selectionService.onArrowUp($event)", "keydown.shift.meta.arrowUp": "isFromInput($event) || selectionService.onArrowUp($event)", "keydown.arrowDown": "isFromInput($event) || selectionService.onArrowDown($event)", "keydown.shift.arrowDown": "isFromInput($event) || selectionService.onArrowDown($event)", "keydown.control.arrowDown": "isFromInput($event) || selectionService.onArrowDown($event)", "keydown.meta.arrowDown": "isFromInput($event) || selectionService.onArrowDown($event)", "keydown.shift.control.arrowDown": "isFromInput($event) || selectionService.onArrowDown($event)", "keydown.shift.meta.arrowDown": "isFromInput($event) || selectionService.onArrowDown($event)", "keydown.arrowLeft": "isFromInput($event) || selectionService.onArrowLeft($event)", "keydown.arrowRight": "isFromInput($event) || selectionService.onArrowRight($event)", "keydown.space": "isFromInput($event) || isFromButton($event) || selectionService.onSpace($event)", "keydown.control.space": "isFromInput($event) || isFromButton($event) || selectionService.onSpace($event)", "keydown.meta.space": "isFromInput($event) || isFromButton($event) || selectionService.onSpace($event)", "keydown.control.a": "isFromInput($event) || selectionService.onControlA($event)", "keydown.meta.a": "isFromInput($event) || selectionService.onControlA($event)", "keydown.pageUp": "isFromInput($event) || selectionService.onPageUp($event)", "keydown.shift.pageUp": "isFromInput($event) || selectionService.onPageUp($event)", "keydown.control.pageUp": "isFromInput($event) || selectionService.onPageUp($event)", "keydown.meta.pageUp": "isFromInput($event) || selectionService.onPageUp($event)", "keydown.shift.control.pageUp": "isFromInput($event) || selectionService.onPageUp($event)", "keydown.shift.meta.pageUp": "isFromInput($event) || selectionService.onPageUp($event)", "keydown.pageDown": "isFromInput($event) || selectionService.onPageDown($event)", "keydown.shift.pageDown": "isFromInput($event) || selectionService.onPageDown($event)", "keydown.control.pageDown": "isFromInput($event) || selectionService.onPageDown($event)", "keydown.meta.pageDown": "isFromInput($event) || selectionService.onPageDown($event)", "keydown.shift.control.pageDown": "isFromInput($event) || selectionService.onPageDown($event)", "keydown.shift.meta.pageDown": "isFromInput($event) || selectionService.onPageDown($event)", "keydown.home": "isFromInput($event) || selectionService.onHome($event)", "keydown.shift.home": "isFromInput($event) || selectionService.onHome($event)", "keydown.control.home": "isFromInput($event) || selectionService.onHome($event)", "keydown.meta.home": "isFromInput($event) || selectionService.onHome($event)", "keydown.shift.control.home": "isFromInput($event) || selectionService.onHome($event)", "keydown.shift.meta.home": "isFromInput($event) || selectionService.onHome($event)", "keydown.end": "isFromInput($event) || selectionService.onEnd($event)", "keydown.shift.end": "isFromInput($event) || selectionService.onEnd($event)", "keydown.control.end": "isFromInput($event) || selectionService.onEnd($event)", "keydown.meta.end": "isFromInput($event) || selectionService.onEnd($event)", "keydown.shift.control.end": "isFromInput($event) || selectionService.onEnd($event)", "keydown.shift.meta.end": "isFromInput($event) || selectionService.onEnd($event)", "keydown": "isFromInput($event) || onKeydown($event)" }, properties: { "attr.tabindex": "0" } }, ngImport: i0 });
1841
2235
  }
1842
2236
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciTableKeyboardNavigatorDirective, decorators: [{
1843
2237
  type: Directive,
@@ -1857,6 +2251,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
1857
2251
  '(keydown.meta.arrowDown)': 'isFromInput($event) || selectionService.onArrowDown($event)',
1858
2252
  '(keydown.shift.control.arrowDown)': 'isFromInput($event) || selectionService.onArrowDown($event)',
1859
2253
  '(keydown.shift.meta.arrowDown)': 'isFromInput($event) || selectionService.onArrowDown($event)',
2254
+ '(keydown.arrowLeft)': 'isFromInput($event) || selectionService.onArrowLeft($event)',
2255
+ '(keydown.arrowRight)': 'isFromInput($event) || selectionService.onArrowRight($event)',
1860
2256
  '(keydown.space)': 'isFromInput($event) || isFromButton($event) || selectionService.onSpace($event)',
1861
2257
  '(keydown.control.space)': 'isFromInput($event) || isFromButton($event) || selectionService.onSpace($event)',
1862
2258
  '(keydown.meta.space)': 'isFromInput($event) || isFromButton($event) || selectionService.onSpace($event)',
@@ -1865,19 +2261,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
1865
2261
  '(keydown.pageUp)': 'isFromInput($event) || selectionService.onPageUp($event)',
1866
2262
  '(keydown.shift.pageUp)': 'isFromInput($event) || selectionService.onPageUp($event)',
1867
2263
  '(keydown.control.pageUp)': 'isFromInput($event) || selectionService.onPageUp($event)',
2264
+ '(keydown.meta.pageUp)': 'isFromInput($event) || selectionService.onPageUp($event)',
1868
2265
  '(keydown.shift.control.pageUp)': 'isFromInput($event) || selectionService.onPageUp($event)',
2266
+ '(keydown.shift.meta.pageUp)': 'isFromInput($event) || selectionService.onPageUp($event)',
1869
2267
  '(keydown.pageDown)': 'isFromInput($event) || selectionService.onPageDown($event)',
1870
2268
  '(keydown.shift.pageDown)': 'isFromInput($event) || selectionService.onPageDown($event)',
1871
2269
  '(keydown.control.pageDown)': 'isFromInput($event) || selectionService.onPageDown($event)',
2270
+ '(keydown.meta.pageDown)': 'isFromInput($event) || selectionService.onPageDown($event)',
1872
2271
  '(keydown.shift.control.pageDown)': 'isFromInput($event) || selectionService.onPageDown($event)',
2272
+ '(keydown.shift.meta.pageDown)': 'isFromInput($event) || selectionService.onPageDown($event)',
1873
2273
  '(keydown.home)': 'isFromInput($event) || selectionService.onHome($event)',
1874
2274
  '(keydown.shift.home)': 'isFromInput($event) || selectionService.onHome($event)',
1875
2275
  '(keydown.control.home)': 'isFromInput($event) || selectionService.onHome($event)',
2276
+ '(keydown.meta.home)': 'isFromInput($event) || selectionService.onHome($event)',
1876
2277
  '(keydown.shift.control.home)': 'isFromInput($event) || selectionService.onHome($event)',
2278
+ '(keydown.shift.meta.home)': 'isFromInput($event) || selectionService.onHome($event)',
1877
2279
  '(keydown.end)': 'isFromInput($event) || selectionService.onEnd($event)',
1878
2280
  '(keydown.shift.end)': 'isFromInput($event) || selectionService.onEnd($event)',
1879
2281
  '(keydown.control.end)': 'isFromInput($event) || selectionService.onEnd($event)',
2282
+ '(keydown.meta.end)': 'isFromInput($event) || selectionService.onEnd($event)',
1880
2283
  '(keydown.shift.control.end)': 'isFromInput($event) || selectionService.onEnd($event)',
2284
+ '(keydown.shift.meta.end)': 'isFromInput($event) || selectionService.onEnd($event)',
2285
+ '(keydown)': 'isFromInput($event) || onKeydown($event)',
1881
2286
  },
1882
2287
  }]
1883
2288
  }] });
@@ -2092,14 +2497,14 @@ class SciTableColumnsComponent {
2092
2497
  }
2093
2498
  }
2094
2499
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciTableColumnsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2095
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: SciTableColumnsComponent, isStandalone: true, selector: "sci-table-columns", inputs: { rows: { classPropertyName: "rows", publicName: "rows", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: "<!-- Render a resize splitter per resizable column. -->\n@for (column of table().columns(); track column.name; let i = $index) {\n @if (column.resizable()) {\n <sci-splitter orientation=\"vertical\"\n [class.resizing]=\"column.resizing()\"\n [attr.data-column]=\"column.name\"\n [style.grid-column]=\"i + 1\"\n (start)=\"onResizeStart(column)\"\n (move)=\"onResize(column, $event)\"\n (end)=\"onResizeEnd()\"\n (reset)=\"onPack(column)\"\n (mouseenter)=\"onMouseEnter()\"\n (mouseleave)=\"onMouseLeave()\"/>\n }\n}\n\n<!-- Render an invisible synthetic column per column to read current widths and set minimum widths. -->\n@for (column of table().columns(); track column.name; let i = $index) {\n <sci-table-column [column]=\"column\" [style.grid-column]=\"i + 1\"/>\n}\n", styles: ["@charset \"UTF-8\";:host{display:grid;grid-template-columns:subgrid;overflow-x:clip;pointer-events:none}@container (style(--sci-table-header-column-divider: visible)) and (style(--\\275sci-table-header-visible: true)){:host{--\\275sci-table-header-column-divider-visible: true}}:host:has(>sci-splitter:hover){--\\275sci-column-splitter-hover: true}:host>sci-splitter,:host>sci-table-column{grid-row:1}:host>sci-splitter{--sci-splitter-size: var(--\\275sci-table-border-size);--sci-splitter-size-hover: var(--\\275sci-table-border-size);--sci-splitter-cross-axis-size: 0;--sci-splitter-background-color: var(--\\275sci-table-border-color);justify-self:end;pointer-events:auto}@container style(--\\275sci-table-header-column-divider-visible: true){:host>sci-splitter{--sci-splitter-cross-axis-start: .5em;--sci-splitter-cross-axis-size: calc(var(--\\275sci-table-header-height) - 2 * var(--sci-splitter-cross-axis-start))}@container style(--\\275sci-table-viewport-client-width >= --\\275sci-table-viewport-width){:host>sci-splitter:last-of-type:not(.resizing):not(:hover){--sci-splitter-cross-axis-size: 0}}}:host>sci-splitter:is(:hover,.resizing){--sci-splitter-background-color: rgb(from var(--\\275sci-table-border-color) r g b / .5);--sci-splitter-cross-axis-size: 100%}@container style(--\\275sci-table-header-column-divider-visible: true){:host>sci-splitter:is(:hover,.resizing){--sci-splitter-cross-axis-size: auto;--sci-splitter-cross-axis-end: 0}}@container style(--\\275sci-table-resizing: true){:host>sci-splitter:not(.resizing){pointer-events:none}}@container (style(--\\275sci-table-scrolling: true)) and (not style(--\\275sci-table-resizing: true)) and (not style(--\\275sci-column-splitter-hover: true)){:host>sci-splitter{pointer-events:none}}:host>sci-table-column{height:0}\n"], dependencies: [{ kind: "component", type: SciSplitterComponent, selector: "sci-splitter", inputs: ["orientation"], outputs: ["start", "move", "end", "reset"] }, { kind: "component", type: SciTableColumnComponent, selector: "sci-table-column", inputs: ["column"] }] });
2500
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: SciTableColumnsComponent, isStandalone: true, selector: "sci-table-columns", inputs: { rows: { classPropertyName: "rows", publicName: "rows", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: "<!-- Render splitters, also for non-resizable columns, as non-interactive column header dividers. -->\n@for (column of table().columns(); track column.name; let i = $index) {\n <sci-splitter orientation=\"vertical\"\n [class.resizing]=\"column.resizing()\"\n [attr.data-column]=\"column.name\"\n [attr.data-non-interactive]=\"!column.resizable() ? '' : null\"\n [style.grid-column]=\"i + 1\"\n (start)=\"onResizeStart(column)\"\n (move)=\"onResize(column, $event)\"\n (end)=\"onResizeEnd()\"\n (reset)=\"onPack(column)\"\n (mouseenter)=\"onMouseEnter()\"\n (mouseleave)=\"onMouseLeave()\"/>\n}\n\n<!-- Render an invisible synthetic column per column to read current widths and set minimum widths. -->\n@for (column of table().columns(); track column.name; let i = $index) {\n <sci-table-column [column]=\"column\" [style.grid-column]=\"i + 1\"/>\n}\n", styles: ["@charset \"UTF-8\";:host{display:grid;grid-template-columns:subgrid;overflow-x:clip;pointer-events:none}@container (style(--sci-table-header-column-divider: visible)) and (style(--\\275sci-table-header-visible: true)){:host{--\\275sci-table-header-column-divider-visible: true}}:host:has(>sci-splitter:hover){--\\275sci-column-splitter-hover: true}:host>sci-splitter,:host>sci-table-column{grid-row:1}:host>sci-splitter{--sci-splitter-size: var(--\\275sci-table-border-size);--sci-splitter-size-hover: var(--\\275sci-table-border-size);--sci-splitter-cross-axis-size: 0;--sci-splitter-background-color: var(--\\275sci-table-border-color);justify-self:end}:host>sci-splitter:where(:not([data-non-interactive])){pointer-events:auto}@container style(--\\275sci-table-header-column-divider-visible: true){:host>sci-splitter{--sci-splitter-cross-axis-start: .5em;--sci-splitter-cross-axis-size: calc(var(--\\275sci-table-header-height) - 2 * var(--sci-splitter-cross-axis-start))}@container style(--\\275sci-table-viewport-client-width >= --\\275sci-table-viewport-width){:host>sci-splitter:last-of-type:not(.resizing):not(:hover){--sci-splitter-cross-axis-size: 0}}}:host>sci-splitter:is(:hover,.resizing){--sci-splitter-background-color: rgb(from var(--\\275sci-table-border-color) r g b / .5);--sci-splitter-cross-axis-start: .5em;--sci-splitter-cross-axis-size: auto;--sci-splitter-cross-axis-end: 0}@container not style(--\\275sci-table-header-column-divider-visible: true){:host>sci-splitter:is(:hover,.resizing){--sci-splitter-cross-axis-start: 0}}@container style(--\\275sci-table-resizing: true){:host>sci-splitter:not(.resizing){pointer-events:none}}@container (style(--\\275sci-table-scrolling: true)) and (not style(--\\275sci-table-resizing: true)) and (not style(--\\275sci-column-splitter-hover: true)){:host>sci-splitter{pointer-events:none}}:host>sci-table-column{height:0}\n"], dependencies: [{ kind: "component", type: SciSplitterComponent, selector: "sci-splitter", inputs: ["orientation"], outputs: ["start", "move", "end", "reset"] }, { kind: "component", type: SciTableColumnComponent, selector: "sci-table-column", inputs: ["column"] }] });
2096
2501
  }
2097
2502
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciTableColumnsComponent, decorators: [{
2098
2503
  type: Component,
2099
2504
  args: [{ selector: 'sci-table-columns', imports: [
2100
2505
  SciSplitterComponent,
2101
2506
  SciTableColumnComponent,
2102
- ], template: "<!-- Render a resize splitter per resizable column. -->\n@for (column of table().columns(); track column.name; let i = $index) {\n @if (column.resizable()) {\n <sci-splitter orientation=\"vertical\"\n [class.resizing]=\"column.resizing()\"\n [attr.data-column]=\"column.name\"\n [style.grid-column]=\"i + 1\"\n (start)=\"onResizeStart(column)\"\n (move)=\"onResize(column, $event)\"\n (end)=\"onResizeEnd()\"\n (reset)=\"onPack(column)\"\n (mouseenter)=\"onMouseEnter()\"\n (mouseleave)=\"onMouseLeave()\"/>\n }\n}\n\n<!-- Render an invisible synthetic column per column to read current widths and set minimum widths. -->\n@for (column of table().columns(); track column.name; let i = $index) {\n <sci-table-column [column]=\"column\" [style.grid-column]=\"i + 1\"/>\n}\n", styles: ["@charset \"UTF-8\";:host{display:grid;grid-template-columns:subgrid;overflow-x:clip;pointer-events:none}@container (style(--sci-table-header-column-divider: visible)) and (style(--\\275sci-table-header-visible: true)){:host{--\\275sci-table-header-column-divider-visible: true}}:host:has(>sci-splitter:hover){--\\275sci-column-splitter-hover: true}:host>sci-splitter,:host>sci-table-column{grid-row:1}:host>sci-splitter{--sci-splitter-size: var(--\\275sci-table-border-size);--sci-splitter-size-hover: var(--\\275sci-table-border-size);--sci-splitter-cross-axis-size: 0;--sci-splitter-background-color: var(--\\275sci-table-border-color);justify-self:end;pointer-events:auto}@container style(--\\275sci-table-header-column-divider-visible: true){:host>sci-splitter{--sci-splitter-cross-axis-start: .5em;--sci-splitter-cross-axis-size: calc(var(--\\275sci-table-header-height) - 2 * var(--sci-splitter-cross-axis-start))}@container style(--\\275sci-table-viewport-client-width >= --\\275sci-table-viewport-width){:host>sci-splitter:last-of-type:not(.resizing):not(:hover){--sci-splitter-cross-axis-size: 0}}}:host>sci-splitter:is(:hover,.resizing){--sci-splitter-background-color: rgb(from var(--\\275sci-table-border-color) r g b / .5);--sci-splitter-cross-axis-size: 100%}@container style(--\\275sci-table-header-column-divider-visible: true){:host>sci-splitter:is(:hover,.resizing){--sci-splitter-cross-axis-size: auto;--sci-splitter-cross-axis-end: 0}}@container style(--\\275sci-table-resizing: true){:host>sci-splitter:not(.resizing){pointer-events:none}}@container (style(--\\275sci-table-scrolling: true)) and (not style(--\\275sci-table-resizing: true)) and (not style(--\\275sci-column-splitter-hover: true)){:host>sci-splitter{pointer-events:none}}:host>sci-table-column{height:0}\n"] }]
2507
+ ], template: "<!-- Render splitters, also for non-resizable columns, as non-interactive column header dividers. -->\n@for (column of table().columns(); track column.name; let i = $index) {\n <sci-splitter orientation=\"vertical\"\n [class.resizing]=\"column.resizing()\"\n [attr.data-column]=\"column.name\"\n [attr.data-non-interactive]=\"!column.resizable() ? '' : null\"\n [style.grid-column]=\"i + 1\"\n (start)=\"onResizeStart(column)\"\n (move)=\"onResize(column, $event)\"\n (end)=\"onResizeEnd()\"\n (reset)=\"onPack(column)\"\n (mouseenter)=\"onMouseEnter()\"\n (mouseleave)=\"onMouseLeave()\"/>\n}\n\n<!-- Render an invisible synthetic column per column to read current widths and set minimum widths. -->\n@for (column of table().columns(); track column.name; let i = $index) {\n <sci-table-column [column]=\"column\" [style.grid-column]=\"i + 1\"/>\n}\n", styles: ["@charset \"UTF-8\";:host{display:grid;grid-template-columns:subgrid;overflow-x:clip;pointer-events:none}@container (style(--sci-table-header-column-divider: visible)) and (style(--\\275sci-table-header-visible: true)){:host{--\\275sci-table-header-column-divider-visible: true}}:host:has(>sci-splitter:hover){--\\275sci-column-splitter-hover: true}:host>sci-splitter,:host>sci-table-column{grid-row:1}:host>sci-splitter{--sci-splitter-size: var(--\\275sci-table-border-size);--sci-splitter-size-hover: var(--\\275sci-table-border-size);--sci-splitter-cross-axis-size: 0;--sci-splitter-background-color: var(--\\275sci-table-border-color);justify-self:end}:host>sci-splitter:where(:not([data-non-interactive])){pointer-events:auto}@container style(--\\275sci-table-header-column-divider-visible: true){:host>sci-splitter{--sci-splitter-cross-axis-start: .5em;--sci-splitter-cross-axis-size: calc(var(--\\275sci-table-header-height) - 2 * var(--sci-splitter-cross-axis-start))}@container style(--\\275sci-table-viewport-client-width >= --\\275sci-table-viewport-width){:host>sci-splitter:last-of-type:not(.resizing):not(:hover){--sci-splitter-cross-axis-size: 0}}}:host>sci-splitter:is(:hover,.resizing){--sci-splitter-background-color: rgb(from var(--\\275sci-table-border-color) r g b / .5);--sci-splitter-cross-axis-start: .5em;--sci-splitter-cross-axis-size: auto;--sci-splitter-cross-axis-end: 0}@container not style(--\\275sci-table-header-column-divider-visible: true){:host>sci-splitter:is(:hover,.resizing){--sci-splitter-cross-axis-start: 0}}@container style(--\\275sci-table-resizing: true){:host>sci-splitter:not(.resizing){pointer-events:none}}@container (style(--\\275sci-table-scrolling: true)) and (not style(--\\275sci-table-resizing: true)) and (not style(--\\275sci-column-splitter-hover: true)){:host>sci-splitter{pointer-events:none}}:host>sci-table-column{height:0}\n"] }]
2103
2508
  }], propDecorators: { rows: [{ type: i0.Input, args: [{ isSignal: true, alias: "rows", required: true }] }] } });
2104
2509
 
2105
2510
  /*
@@ -2215,15 +2620,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
2215
2620
  class SciColumnHeaderComponent {
2216
2621
  column = input.required(/* @ts-ignore */
2217
2622
  ...(ngDevMode ? [{ debugName: "column" }] : /* istanbul ignore next */ []));
2623
+ labelElement = viewChild('label', /* @ts-ignore */
2624
+ ...(ngDevMode ? [{ debugName: "labelElement" }] : /* istanbul ignore next */ [])); // public visibility required to be accessible in `configureTooltip` function
2218
2625
  table = inject(ɵSCI_TABLE);
2219
- header = text(computed(() => this.column().header()));
2220
2626
  sortDirection = computed(() => this.table().sortCriteria().find(criteria => criteria.columnName === this.column().name)?.direction, /* @ts-ignore */
2221
2627
  ...(ngDevMode ? [{ debugName: "sortDirection" }] : /* istanbul ignore next */ []));
2222
2628
  onSort(event) {
2223
2629
  this.table().sort(this.column().name, event.ctrlKey || event.metaKey);
2224
2630
  }
2225
2631
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciColumnHeaderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2226
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: SciColumnHeaderComponent, isStandalone: true, selector: "sci-column-header", inputs: { column: { classPropertyName: "column", publicName: "column", isSignal: true, isRequired: true, transformFunction: null } }, host: { properties: { "attr.data-column": "column().name", "attr.title": "header()" } }, ngImport: i0, template: "@if (column().sortable()) {\n <button class=\"sort e2e-sort\"\n [attr.data-sort-direction]=\"sortDirection()\"\n (click)=\"onSort($event)\">\n <!-- Header -->\n <ng-container *ngTemplateOutlet=\"header_template\"/>\n\n <!-- Sort Indicator -->\n @switch (sortDirection()) {\n @case ('asc') {\n <sci-icon>scion.sort_ascending</sci-icon>\n }\n @case ('desc') {\n <sci-icon>scion.sort_descending</sci-icon>\n }\n @default {\n <sci-icon class=\"no-sort\">scion.sort_indicator</sci-icon>\n }\n }\n </button>\n} @else {\n <ng-container *ngTemplateOutlet=\"header_template\"/>\n}\n\n<ng-template #header_template>\n <span class=\"header e2e-header\" [class.wrap]=\"table().wrapHeader()\">{{header()}}</span>\n</ng-template>\n", styles: ["@charset \"UTF-8\";:host{display:grid;align-items:center}:host:not(:has(button.sort)){padding-inline:var(--\\275sci-table-cell-padding-inline)}:host:is(:focus-visible,:hover){--\\275sci-table-column-header-hover: true}:host>button.sort{all:unset;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.5em;padding-inline:var(--\\275sci-table-cell-padding-inline);cursor:var(--sci-table-header-cursor);align-self:stretch}@container not style(--\\275sci-table-column-header-hover: true){:host>button.sort>sci-icon.no-sort{visibility:hidden}}@container style(--\\275sci-table-column-header-hover: true){:host>button.sort{background-color:var(--sci-table-header-background-color-hover)}}:host span.header{display:inline;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--sci-table-header-text-color);font-family:var(--sci-table-header-font-family),sans-serif;font-weight:var(--sci-table-header-font-weight);font-size:var(--sci-table-header-font-size);padding-block:.25em}:host span.header.wrap{white-space:wrap}\n"], dependencies: [{ kind: "component", type: SciIconComponent, selector: "sci-icon" }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
2632
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: SciColumnHeaderComponent, isStandalone: true, selector: "sci-column-header", inputs: { column: { classPropertyName: "column", publicName: "column", isSignal: true, isRequired: true, transformFunction: null } }, host: { properties: { "attr.data-column": "column().name" } }, providers: [
2633
+ configureTooltip$1(),
2634
+ ], viewQueries: [{ propertyName: "labelElement", first: true, predicate: ["label"], descendants: true, isSignal: true }], hostDirectives: [{ directive: i1.SciTooltipOnOverflowDirective }], ngImport: i0, template: "@if (column().sortable()) {\n <button class=\"sort e2e-sort\"\n [attr.data-sort-direction]=\"sortDirection()\"\n (click)=\"onSort($event)\">\n <!-- Header -->\n <ng-container *ngTemplateOutlet=\"header_template\"/>\n\n <!-- Sort Indicator -->\n @switch (sortDirection()) {\n @case ('asc') {\n <sci-icon>scion:sort_ascending</sci-icon>\n }\n @case ('desc') {\n <sci-icon>scion:sort_descending</sci-icon>\n }\n @default {\n <sci-icon class=\"no-sort\">scion:sort_indicator</sci-icon>\n }\n }\n </button>\n} @else {\n <ng-container *ngTemplateOutlet=\"header_template\"/>\n}\n\n<ng-template #header_template>\n @if (column().header.icon) {\n <sci-icon class=\"icon e2e-icon\">{{column().header.icon}}</sci-icon>\n }\n\n @if (column().header.label) {\n <span [class.wrap]=\"table().wrapHeader()\" class=\"label\" #label>\n {{column().header.label!()}}\n </span>\n }\n</ng-template>\n", styles: ["@charset \"UTF-8\";:host{display:grid;align-items:center;gap:.25em}:host:not(:has(button.sort)){padding-inline:var(--\\275sci-table-cell-padding-inline)}:host:is(:focus-visible,:hover){--\\275sci-table-column-header-hover: true}:host:has(>sci-icon.icon):has(>span.label){grid-template-columns:auto 1fr}:host>button.sort{all:unset;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.25em;padding-inline:var(--\\275sci-table-cell-padding-inline);cursor:var(--sci-table-header-cursor);align-self:stretch}:host>button.sort:has(>sci-icon.icon):has(>span.label){grid-template-columns:auto minmax(0,1fr) auto}:host>button.sort:not(:has(>:is(sci-icon.icon,span.label))){grid-template-columns:1fr;justify-items:end}@container not style(--\\275sci-table-column-header-hover: true){:host>button.sort>sci-icon.no-sort{visibility:hidden}}@container style(--\\275sci-table-column-header-hover: true){:host>button.sort{background-color:var(--sci-table-header-background-color-hover)}}:host span.label{display:inline;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--sci-table-header-text-color);font-family:var(--sci-table-header-font-family),sans-serif;font-weight:var(--sci-table-header-font-weight);font-size:var(--sci-table-header-font-size);padding-block:.25em}:host span.label.wrap{white-space:wrap}\n"], dependencies: [{ kind: "component", type: SciIconComponent, selector: "sci-icon" }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
2227
2635
  }
2228
2636
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciColumnHeaderComponent, decorators: [{
2229
2637
  type: Component,
@@ -2232,9 +2640,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
2232
2640
  NgTemplateOutlet,
2233
2641
  ], host: {
2234
2642
  '[attr.data-column]': 'column().name',
2235
- '[attr.title]': 'header()',
2236
- }, template: "@if (column().sortable()) {\n <button class=\"sort e2e-sort\"\n [attr.data-sort-direction]=\"sortDirection()\"\n (click)=\"onSort($event)\">\n <!-- Header -->\n <ng-container *ngTemplateOutlet=\"header_template\"/>\n\n <!-- Sort Indicator -->\n @switch (sortDirection()) {\n @case ('asc') {\n <sci-icon>scion.sort_ascending</sci-icon>\n }\n @case ('desc') {\n <sci-icon>scion.sort_descending</sci-icon>\n }\n @default {\n <sci-icon class=\"no-sort\">scion.sort_indicator</sci-icon>\n }\n }\n </button>\n} @else {\n <ng-container *ngTemplateOutlet=\"header_template\"/>\n}\n\n<ng-template #header_template>\n <span class=\"header e2e-header\" [class.wrap]=\"table().wrapHeader()\">{{header()}}</span>\n</ng-template>\n", styles: ["@charset \"UTF-8\";:host{display:grid;align-items:center}:host:not(:has(button.sort)){padding-inline:var(--\\275sci-table-cell-padding-inline)}:host:is(:focus-visible,:hover){--\\275sci-table-column-header-hover: true}:host>button.sort{all:unset;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.5em;padding-inline:var(--\\275sci-table-cell-padding-inline);cursor:var(--sci-table-header-cursor);align-self:stretch}@container not style(--\\275sci-table-column-header-hover: true){:host>button.sort>sci-icon.no-sort{visibility:hidden}}@container style(--\\275sci-table-column-header-hover: true){:host>button.sort{background-color:var(--sci-table-header-background-color-hover)}}:host span.header{display:inline;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--sci-table-header-text-color);font-family:var(--sci-table-header-font-family),sans-serif;font-weight:var(--sci-table-header-font-weight);font-size:var(--sci-table-header-font-size);padding-block:.25em}:host span.header.wrap{white-space:wrap}\n"] }]
2237
- }], propDecorators: { column: [{ type: i0.Input, args: [{ isSignal: true, alias: "column", required: true }] }] } });
2643
+ }, hostDirectives: [
2644
+ SciTooltipOnOverflowDirective,
2645
+ ], providers: [
2646
+ configureTooltip$1(),
2647
+ ], template: "@if (column().sortable()) {\n <button class=\"sort e2e-sort\"\n [attr.data-sort-direction]=\"sortDirection()\"\n (click)=\"onSort($event)\">\n <!-- Header -->\n <ng-container *ngTemplateOutlet=\"header_template\"/>\n\n <!-- Sort Indicator -->\n @switch (sortDirection()) {\n @case ('asc') {\n <sci-icon>scion:sort_ascending</sci-icon>\n }\n @case ('desc') {\n <sci-icon>scion:sort_descending</sci-icon>\n }\n @default {\n <sci-icon class=\"no-sort\">scion:sort_indicator</sci-icon>\n }\n }\n </button>\n} @else {\n <ng-container *ngTemplateOutlet=\"header_template\"/>\n}\n\n<ng-template #header_template>\n @if (column().header.icon) {\n <sci-icon class=\"icon e2e-icon\">{{column().header.icon}}</sci-icon>\n }\n\n @if (column().header.label) {\n <span [class.wrap]=\"table().wrapHeader()\" class=\"label\" #label>\n {{column().header.label!()}}\n </span>\n }\n</ng-template>\n", styles: ["@charset \"UTF-8\";:host{display:grid;align-items:center;gap:.25em}:host:not(:has(button.sort)){padding-inline:var(--\\275sci-table-cell-padding-inline)}:host:is(:focus-visible,:hover){--\\275sci-table-column-header-hover: true}:host:has(>sci-icon.icon):has(>span.label){grid-template-columns:auto 1fr}:host>button.sort{all:unset;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.25em;padding-inline:var(--\\275sci-table-cell-padding-inline);cursor:var(--sci-table-header-cursor);align-self:stretch}:host>button.sort:has(>sci-icon.icon):has(>span.label){grid-template-columns:auto minmax(0,1fr) auto}:host>button.sort:not(:has(>:is(sci-icon.icon,span.label))){grid-template-columns:1fr;justify-items:end}@container not style(--\\275sci-table-column-header-hover: true){:host>button.sort>sci-icon.no-sort{visibility:hidden}}@container style(--\\275sci-table-column-header-hover: true){:host>button.sort{background-color:var(--sci-table-header-background-color-hover)}}:host span.label{display:inline;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--sci-table-header-text-color);font-family:var(--sci-table-header-font-family),sans-serif;font-weight:var(--sci-table-header-font-weight);font-size:var(--sci-table-header-font-size);padding-block:.25em}:host span.label.wrap{white-space:wrap}\n"] }]
2648
+ }], propDecorators: { column: [{ type: i0.Input, args: [{ isSignal: true, alias: "column", required: true }] }], labelElement: [{ type: i0.ViewChild, args: ['label', { isSignal: true }] }] } });
2649
+ function configureTooltip$1() {
2650
+ return [
2651
+ {
2652
+ provide: TOOLTIP_ON_OVERFLOW_DIRECTIVE_ADDITIONAL_TEXT,
2653
+ useFactory: () => {
2654
+ const host = inject(SciColumnHeaderComponent);
2655
+ return computed(() => host.column().header.tooltip?.());
2656
+ },
2657
+ },
2658
+ {
2659
+ provide: TOOLTIP_ON_OVERFLOW_DIRECTIVE_SOURCE_ELEMENT,
2660
+ useFactory: () => inject(SciColumnHeaderComponent).labelElement,
2661
+ },
2662
+ ];
2663
+ }
2238
2664
 
2239
2665
  /*
2240
2666
  * Copyright (c) 2018-2026 Swiss Federal Railways
@@ -2245,22 +2671,24 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
2245
2671
  *
2246
2672
  * SPDX-License-Identifier: EPL-2.0
2247
2673
  */
2248
- class SciColumnFilterBooleanLabelPipe {
2249
- transform(value) {
2250
- if (value === true) {
2251
- return '%scion.components.yes.value';
2252
- }
2253
- if (value === false) {
2254
- return '%scion.components.no.value';
2255
- }
2256
- return null;
2257
- }
2258
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciColumnFilterBooleanLabelPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
2259
- static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.1.7", ngImport: i0, type: SciColumnFilterBooleanLabelPipe, isStandalone: true, name: "sciColumnFilterBooleanLabel" });
2674
+ /**
2675
+ * Creates a comma-separated string with localized labels of the given enum values.
2676
+ */
2677
+ class SciEnumLabelPipe {
2678
+ _injector = inject(Injector);
2679
+ transform(values, enumeration) {
2680
+ return computed(() => Arrays.coerce(values)
2681
+ .map(value => enumeration.find(entry => entry.value === value)?.label)
2682
+ .map(label => untracked(() => text(label, { injector: this._injector }))())
2683
+ .filter(Boolean)
2684
+ .join(', ') || undefined);
2685
+ }
2686
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciEnumLabelPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
2687
+ static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.1.7", ngImport: i0, type: SciEnumLabelPipe, isStandalone: true, name: "sciEnumLabel" });
2260
2688
  }
2261
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciColumnFilterBooleanLabelPipe, decorators: [{
2689
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciEnumLabelPipe, decorators: [{
2262
2690
  type: Pipe,
2263
- args: [{ name: 'sciColumnFilterBooleanLabel' }]
2691
+ args: [{ name: 'sciEnumLabel' }]
2264
2692
  }] });
2265
2693
 
2266
2694
  /*
@@ -2275,33 +2703,36 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
2275
2703
  class SciColumnFilterComponent {
2276
2704
  column = input.required(/* @ts-ignore */
2277
2705
  ...(ngDevMode ? [{ debugName: "column" }] : /* istanbul ignore next */ []));
2706
+ inputElement = viewChild('input', /* @ts-ignore */
2707
+ ...(ngDevMode ? [{ debugName: "inputElement" }] : /* istanbul ignore next */ [])); // public visibility required to be accessible in `configureTooltip` function
2278
2708
  filter = signal(null, /* @ts-ignore */
2279
2709
  ...(ngDevMode ? [{ debugName: "filter" }] : /* istanbul ignore next */ []));
2280
- input = viewChild('input', /* @ts-ignore */
2281
- ...(ngDevMode ? [{ debugName: "input" }] : /* istanbul ignore next */ []));
2710
+ dimension = dimension(inject(ElementRef));
2282
2711
  _table = inject(ɵSCI_TABLE);
2283
2712
  _menuService = inject(_SciMenuService);
2284
2713
  _injector = inject(Injector);
2285
2714
  _host = inject(ElementRef).nativeElement;
2286
- _menu;
2715
+ menu;
2287
2716
  constructor() {
2288
2717
  this.bindToModel();
2289
2718
  }
2290
2719
  onFilterClick() {
2291
- if (this.column().type === 'boolean') {
2292
- this.toggleBooleanMenu();
2720
+ if (this.column().type === 'enum') {
2721
+ this.toggleEnumChooser();
2722
+ }
2723
+ else {
2724
+ this.inputElement().nativeElement.focus();
2293
2725
  }
2294
- this.input().nativeElement.focus();
2295
2726
  }
2296
2727
  onReset(event) {
2297
2728
  this.filter.set(null);
2298
- this.input().nativeElement.focus();
2729
+ this.inputElement().nativeElement.focus();
2299
2730
  event.stopPropagation(); // prevent opening menu
2300
2731
  }
2301
2732
  bindToModel() {
2302
2733
  const filterDebounced = debounced(computed(() => trim(this.filter())), async (value) => {
2303
- // Debounce only if not empty.
2304
- if (value !== null && typeof value !== 'boolean') {
2734
+ // Debounce only if not empty and not an enum.
2735
+ if (value !== null && this.column().type !== 'enum') {
2305
2736
  await firstValueFrom(timer(200));
2306
2737
  }
2307
2738
  });
@@ -2314,56 +2745,93 @@ class SciColumnFilterComponent {
2314
2745
  });
2315
2746
  });
2316
2747
  }
2317
- toggleBooleanMenu() {
2748
+ toggleEnumChooser() {
2318
2749
  // Close menu if already opened.
2319
- if (this._menu) {
2320
- this._menu.close();
2750
+ if (this.menu) {
2751
+ this.menu.close();
2321
2752
  return;
2322
2753
  }
2323
- const injector = createDestroyableInjector({ parent: this._injector });
2754
+ const column = this.column();
2755
+ const injector = _createDestroyableInjector({ parent: this._injector });
2324
2756
  runInInjectionContext(injector, () => {
2757
+ const menuId = `menu:column-filter-${UUID.randomUUID()}`;
2325
2758
  // Contribute menu items.
2326
- contributeMenu('menu:column-filter', menu => menu
2327
- .addMenuItem({ label: '%scion.components.yes.value', attributes: { 'data-option': 'true' }, onSelect: () => this.filter.set(true) })
2328
- .addMenuItem({ label: '%scion.components.no.value', attributes: { 'data-option': 'false' }, onSelect: () => this.filter.set(false) }));
2759
+ contributeMenu(menuId, menu => {
2760
+ const multi = column.filterable() === 'multi';
2761
+ column.enumeration().forEach(entry => menu.addMenuItem({
2762
+ label: entry.label,
2763
+ checked: multi ? computed(() => this.filter()?.includes(entry.value) ?? false) : undefined,
2764
+ icon: column.format.filter.icon ? entry.icon : undefined,
2765
+ attributes: { 'data-option': `${entry.value}` },
2766
+ onSelect: () => this.filter.update(filter => {
2767
+ if (multi) {
2768
+ const values = new Set(filter ?? []);
2769
+ values.has(entry.value) ? values.delete(entry.value) : values.add(entry.value);
2770
+ return values.size ? Array.from(values) : null;
2771
+ }
2772
+ else {
2773
+ return entry.value;
2774
+ }
2775
+ }),
2776
+ }));
2777
+ });
2329
2778
  // Open menu.
2330
- this._menu = this._menuService.open('menu:column-filter', {
2779
+ this.menu = this._menuService.open(menuId, {
2331
2780
  anchor: this._host,
2332
2781
  minWidth: `${this._host.offsetWidth}px`,
2782
+ maxHeight: '300px',
2783
+ cssClass: 'e2e-filter',
2784
+ filter: column.enumeration().length >= 8 ? { focus: true } : false,
2785
+ maxWidth: `max(300px, ${this._host.offsetWidth}px)`, // overflow long enumeration labels
2333
2786
  offset: { y: -1 },
2334
2787
  });
2335
2788
  // Close menu when start resizing a column. By default, closes the menu only on mouse up.
2336
2789
  toObservable(this._table().resizing)
2337
2790
  .pipe(skip(1))
2338
- .subscribe(() => this._menu?.close());
2339
- this._menu.onClose(() => {
2791
+ .subscribe(() => this.menu?.close());
2792
+ this.menu.onClose(() => {
2340
2793
  injector.destroy();
2341
2794
  // Restore focus manually as it is not restored automatically since opening the popover inside Shadow DOM.
2342
2795
  // TODO [menu] Consider restore focus manually in Menu API due to restriction in shadow DOM.
2343
- this.input().nativeElement.focus();
2344
- this._menu = undefined;
2796
+ this.inputElement().nativeElement.focus();
2797
+ this.menu = undefined;
2345
2798
  });
2346
2799
  });
2347
2800
  }
2348
2801
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciColumnFilterComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2349
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: SciColumnFilterComponent, isStandalone: true, selector: "sci-column-filter", inputs: { column: { classPropertyName: "column", publicName: "column", isSignal: true, isRequired: true, transformFunction: null } }, host: { listeners: { "click": "column().filterable() && onFilterClick()" }, properties: { "attr.data-column": "column().name", "attr.data-disabled": "!column().filterable() ? '' : null" } }, viewQueries: [{ propertyName: "input", first: true, predicate: ["input"], descendants: true, isSignal: true }], ngImport: i0, template: "@if (column().filterable()) {\n @let active = filter() !== null && filter() !== '';\n\n <sci-icon class=\"filter e2e-filter\" [class.active]=\"active\">scion.filter</sci-icon>\n\n @switch (column().type) {\n @case ('number') {\n <input [(ngModel)]=\"filter\" type=\"number\" #input/>\n }\n @case ('boolean') {\n <input [ngModel]=\"(filter() | sciColumnFilterBooleanLabel | sciText)()\" type=\"button\" #input>\n <sci-icon class=\"chevron\">scion.chevron_down</sci-icon>\n }\n @default {\n <input [(ngModel)]=\"filter\" type=\"text\" #input/>\n }\n }\n\n @if (active) {\n <button class=\"clear e2e-clear\" (click)=\"onReset($event)\">\n <sci-icon>scion.clear</sci-icon>\n </button>\n }\n}\n", styles: ["@charset \"UTF-8\";:host{display:flex;gap:.5em;align-items:center;padding-inline:var(--\\275sci-table-cell-padding-inline);border-bottom:var(--\\275sci-table-border-size) solid transparent;margin-bottom:-1px;transition:border-bottom-color ease-in-out .15s,color ease-in-out .15s}:host:focus-within:not(:disabled):not(:has(input:disabled)){border-bottom-color:var(--sci-color-accent)}:host>sci-icon.filter.active{color:var(--sci-color-accent)}:host>sci-icon.chevron{--sci-icon-size: var(--sci-button-icon-size)}:host:has(input[type=button]){cursor:var(--sci-button-cursor)}:host:has(input[type=button]):has(button.clear)>sci-icon.chevron{display:none}:host>input{all:unset;flex:auto;align-self:stretch;width:0;min-width:0;color:var(--sci-color-text);appearance:auto;box-sizing:border-box}:host>input[type=number]::-webkit-outer-spin-button,:host>input[type=number]::-webkit-inner-spin-button{appearance:none;margin:0}:host>button.clear{all:unset;display:inline-grid;place-content:center;place-items:center;padding:.25em;border-radius:var(--sci-button-border-radius);-webkit-user-select:none;user-select:none;overflow:hidden;cursor:var(--sci-button-cursor);--sci-icon-size: var(--sci-button-icon-size)}:host>button.clear:hover:where(:not(:disabled)){background-color:var(--sci-button-background-color-hover)}:host>button.clear:active:where(:not(:disabled)){background-color:var(--sci-button-background-color-active)}:host>button.clear:focus:not(:focus-visible){outline:none}:host>button.clear:focus-visible{outline:var(--sci-button-outline-width) solid var(--sci-color-accent)}:host>button.clear:disabled{color:var(--sci-button-text-color-disabled)}:host>button.clear{padding:1px}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number]:not([ngNoCva])[formControlName],input[type=number]:not([ngNoCva])[formControl],input[type=number]:not([ngNoCva])[ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: SciIconComponent, selector: "sci-icon" }, { kind: "pipe", type: SciTextPipe, name: "sciText" }, { kind: "pipe", type: SciColumnFilterBooleanLabelPipe, name: "sciColumnFilterBooleanLabel" }] });
2802
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: SciColumnFilterComponent, isStandalone: true, selector: "sci-column-filter", inputs: { column: { classPropertyName: "column", publicName: "column", isSignal: true, isRequired: true, transformFunction: null } }, host: { listeners: { "click": "column().filterable() && onFilterClick()" }, properties: { "attr.data-column": "column().name", "attr.data-disabled": "!column().filterable() ? '' : null", "class.collapsed": "dimension().clientWidth < 50" } }, providers: [
2803
+ configureTooltip(),
2804
+ ], viewQueries: [{ propertyName: "inputElement", first: true, predicate: ["input"], descendants: true, isSignal: true }], hostDirectives: [{ directive: i1.SciTooltipOnOverflowDirective }], ngImport: i0, template: "@let column = this.column();\n@if (column.filterable()) {\n @let active = filter() !== null && filter() !== '';\n\n <sci-icon class=\"filter e2e-filter\" [class.active]=\"active\">scion:filter</sci-icon>\n\n @switch (column.type) {\n @case ('number') {\n <input [(ngModel)]=\"filter\" type=\"number\" #input/>\n }\n @case ('enum') {\n <input [ngModel]=\"(filter() | sciEnumLabel:column.enumeration())()\" type=\"button\" #input>\n <sci-icon class=\"chevron\">{{menu ? 'scion:chevron_up' : 'scion:chevron_down'}}</sci-icon>\n }\n @default {\n <input [(ngModel)]=\"filter\" type=\"text\" #input/>\n }\n }\n\n @if (active) {\n <button class=\"clear e2e-clear\" (click)=\"onReset($event)\" [attr.title]=\"('%scion:components:clear.tooltip' | sciText)()\">\n <sci-icon>scion:clear</sci-icon>\n </button>\n }\n}\n", styles: ["@charset \"UTF-8\";:host{display:flex;gap:.25em;align-items:center;padding-inline:var(--\\275sci-table-cell-padding-inline);border-bottom:var(--\\275sci-table-border-size) solid transparent;margin-bottom:-1px;transition:border-bottom-color ease-in-out .15s,color ease-in-out .15s}:host:focus-within:not(:disabled):not(:has(input:disabled)){border-bottom-color:var(--sci-color-accent)}:host>sci-icon.filter.active{color:var(--sci-color-accent)}:host:has(input[type=button]){cursor:var(--sci-button-cursor)}:host:has(input[type=button]):has(button.clear)>sci-icon.chevron{display:none}:host.collapsed{gap:0}:host>input{all:unset;flex:auto;align-self:stretch;width:0;min-width:0;color:var(--sci-color-text);appearance:auto;box-sizing:border-box}:host>input[type=number]::-webkit-outer-spin-button,:host>input[type=number]::-webkit-inner-spin-button{appearance:none;margin:0}:host>input[type=button]{white-space:nowrap;text-overflow:ellipsis}:host>button.clear{all:unset;display:inline-grid;place-content:center;place-items:center;padding:.25em;border-radius:var(--sci-button-border-radius);-webkit-user-select:none;user-select:none;overflow:hidden;cursor:var(--sci-button-cursor);--sci-icon-size: var(--sci-button-icon-size)}:host>button.clear:hover:where(:not(:disabled)){background-color:var(--sci-button-background-color-hover)}:host>button.clear:active:where(:not(:disabled)){background-color:var(--sci-button-background-color-active)}:host>button.clear:focus:not(:focus-visible){outline:none}:host>button.clear:focus-visible{outline:var(--sci-button-outline-width) solid var(--sci-color-accent)}:host>button.clear:disabled{color:var(--sci-button-text-color-disabled)}:host>button.clear{padding:1px}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i2.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2.NumberValueAccessor, selector: "input[type=number]:not([ngNoCva])[formControlName],input[type=number]:not([ngNoCva])[formControl],input[type=number]:not([ngNoCva])[ngModel]" }, { kind: "directive", type: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: SciIconComponent, selector: "sci-icon" }, { kind: "pipe", type: SciEnumLabelPipe, name: "sciEnumLabel" }, { kind: "pipe", type: SciTextPipe, name: "sciText" }] });
2350
2805
  }
2351
2806
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciColumnFilterComponent, decorators: [{
2352
2807
  type: Component,
2353
2808
  args: [{ selector: 'sci-column-filter', imports: [
2354
2809
  FormsModule,
2355
2810
  SciIconComponent,
2811
+ SciEnumLabelPipe,
2356
2812
  SciTextPipe,
2357
- SciColumnFilterBooleanLabelPipe,
2813
+ ], hostDirectives: [
2814
+ SciTooltipOnOverflowDirective,
2358
2815
  ], host: {
2359
2816
  '[attr.data-column]': 'column().name',
2360
2817
  '[attr.data-disabled]': `!column().filterable() ? '' : null`,
2818
+ '[class.collapsed]': 'dimension().clientWidth < 50',
2361
2819
  '(click)': 'column().filterable() && onFilterClick()',
2362
- }, template: "@if (column().filterable()) {\n @let active = filter() !== null && filter() !== '';\n\n <sci-icon class=\"filter e2e-filter\" [class.active]=\"active\">scion.filter</sci-icon>\n\n @switch (column().type) {\n @case ('number') {\n <input [(ngModel)]=\"filter\" type=\"number\" #input/>\n }\n @case ('boolean') {\n <input [ngModel]=\"(filter() | sciColumnFilterBooleanLabel | sciText)()\" type=\"button\" #input>\n <sci-icon class=\"chevron\">scion.chevron_down</sci-icon>\n }\n @default {\n <input [(ngModel)]=\"filter\" type=\"text\" #input/>\n }\n }\n\n @if (active) {\n <button class=\"clear e2e-clear\" (click)=\"onReset($event)\">\n <sci-icon>scion.clear</sci-icon>\n </button>\n }\n}\n", styles: ["@charset \"UTF-8\";:host{display:flex;gap:.5em;align-items:center;padding-inline:var(--\\275sci-table-cell-padding-inline);border-bottom:var(--\\275sci-table-border-size) solid transparent;margin-bottom:-1px;transition:border-bottom-color ease-in-out .15s,color ease-in-out .15s}:host:focus-within:not(:disabled):not(:has(input:disabled)){border-bottom-color:var(--sci-color-accent)}:host>sci-icon.filter.active{color:var(--sci-color-accent)}:host>sci-icon.chevron{--sci-icon-size: var(--sci-button-icon-size)}:host:has(input[type=button]){cursor:var(--sci-button-cursor)}:host:has(input[type=button]):has(button.clear)>sci-icon.chevron{display:none}:host>input{all:unset;flex:auto;align-self:stretch;width:0;min-width:0;color:var(--sci-color-text);appearance:auto;box-sizing:border-box}:host>input[type=number]::-webkit-outer-spin-button,:host>input[type=number]::-webkit-inner-spin-button{appearance:none;margin:0}:host>button.clear{all:unset;display:inline-grid;place-content:center;place-items:center;padding:.25em;border-radius:var(--sci-button-border-radius);-webkit-user-select:none;user-select:none;overflow:hidden;cursor:var(--sci-button-cursor);--sci-icon-size: var(--sci-button-icon-size)}:host>button.clear:hover:where(:not(:disabled)){background-color:var(--sci-button-background-color-hover)}:host>button.clear:active:where(:not(:disabled)){background-color:var(--sci-button-background-color-active)}:host>button.clear:focus:not(:focus-visible){outline:none}:host>button.clear:focus-visible{outline:var(--sci-button-outline-width) solid var(--sci-color-accent)}:host>button.clear:disabled{color:var(--sci-button-text-color-disabled)}:host>button.clear{padding:1px}\n"] }]
2363
- }], ctorParameters: () => [], propDecorators: { column: [{ type: i0.Input, args: [{ isSignal: true, alias: "column", required: true }] }], input: [{ type: i0.ViewChild, args: ['input', { isSignal: true }] }] } });
2820
+ }, providers: [
2821
+ configureTooltip(),
2822
+ ], template: "@let column = this.column();\n@if (column.filterable()) {\n @let active = filter() !== null && filter() !== '';\n\n <sci-icon class=\"filter e2e-filter\" [class.active]=\"active\">scion:filter</sci-icon>\n\n @switch (column.type) {\n @case ('number') {\n <input [(ngModel)]=\"filter\" type=\"number\" #input/>\n }\n @case ('enum') {\n <input [ngModel]=\"(filter() | sciEnumLabel:column.enumeration())()\" type=\"button\" #input>\n <sci-icon class=\"chevron\">{{menu ? 'scion:chevron_up' : 'scion:chevron_down'}}</sci-icon>\n }\n @default {\n <input [(ngModel)]=\"filter\" type=\"text\" #input/>\n }\n }\n\n @if (active) {\n <button class=\"clear e2e-clear\" (click)=\"onReset($event)\" [attr.title]=\"('%scion:components:clear.tooltip' | sciText)()\">\n <sci-icon>scion:clear</sci-icon>\n </button>\n }\n}\n", styles: ["@charset \"UTF-8\";:host{display:flex;gap:.25em;align-items:center;padding-inline:var(--\\275sci-table-cell-padding-inline);border-bottom:var(--\\275sci-table-border-size) solid transparent;margin-bottom:-1px;transition:border-bottom-color ease-in-out .15s,color ease-in-out .15s}:host:focus-within:not(:disabled):not(:has(input:disabled)){border-bottom-color:var(--sci-color-accent)}:host>sci-icon.filter.active{color:var(--sci-color-accent)}:host:has(input[type=button]){cursor:var(--sci-button-cursor)}:host:has(input[type=button]):has(button.clear)>sci-icon.chevron{display:none}:host.collapsed{gap:0}:host>input{all:unset;flex:auto;align-self:stretch;width:0;min-width:0;color:var(--sci-color-text);appearance:auto;box-sizing:border-box}:host>input[type=number]::-webkit-outer-spin-button,:host>input[type=number]::-webkit-inner-spin-button{appearance:none;margin:0}:host>input[type=button]{white-space:nowrap;text-overflow:ellipsis}:host>button.clear{all:unset;display:inline-grid;place-content:center;place-items:center;padding:.25em;border-radius:var(--sci-button-border-radius);-webkit-user-select:none;user-select:none;overflow:hidden;cursor:var(--sci-button-cursor);--sci-icon-size: var(--sci-button-icon-size)}:host>button.clear:hover:where(:not(:disabled)){background-color:var(--sci-button-background-color-hover)}:host>button.clear:active:where(:not(:disabled)){background-color:var(--sci-button-background-color-active)}:host>button.clear:focus:not(:focus-visible){outline:none}:host>button.clear:focus-visible{outline:var(--sci-button-outline-width) solid var(--sci-color-accent)}:host>button.clear:disabled{color:var(--sci-button-text-color-disabled)}:host>button.clear{padding:1px}\n"] }]
2823
+ }], ctorParameters: () => [], propDecorators: { column: [{ type: i0.Input, args: [{ isSignal: true, alias: "column", required: true }] }], inputElement: [{ type: i0.ViewChild, args: ['input', { isSignal: true }] }] } });
2364
2824
  function trim(value) {
2365
2825
  return typeof value === 'string' ? value.trim() || null : value;
2366
2826
  }
2827
+ function configureTooltip() {
2828
+ return [
2829
+ {
2830
+ provide: TOOLTIP_ON_OVERFLOW_DIRECTIVE_SOURCE_ELEMENT,
2831
+ useFactory: () => inject(SciColumnFilterComponent).inputElement,
2832
+ },
2833
+ ];
2834
+ }
2367
2835
 
2368
2836
  /*
2369
2837
  * Copyright (c) 2018-2026 Swiss Federal Railways
@@ -2405,6 +2873,72 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
2405
2873
  * SPDX-License-Identifier: EPL-2.0
2406
2874
  */
2407
2875
  /**
2876
+ * Provides a table to display tabular or hierarchical data.
2877
+ *
2878
+ * ### Usage
2879
+ *
2880
+ * Create a table using the `table()` function, passing a signal containing the items to display in the table and a factory function to define the columns.
2881
+ * Alternatively, pass a descriptor to display a tree structure, configure a custom datasource, or set additional options.
2882
+ *
2883
+ * ```ts
2884
+ * import {SciTableComponent, table} from '@scion/components/table';
2885
+ * import {httpResource} from '@angular/common/http';
2886
+ *
2887
+ * // Load data to display in the table.
2888
+ * const users = httpResource<User[]>(() => '/api/users', {defaultValue: []});
2889
+ *
2890
+ * // Create the table and pass data.
2891
+ * const userTable = table(users.value, table => table
2892
+ * .addNumberColumn('ID', user => user.id)
2893
+ * .addStringColumn('Name', user => user.name)
2894
+ * .addBooleanColumn('Inactive', user => user.inactive)
2895
+ * .addDateColumn('Expiration', user => user.expiration),
2896
+ * );
2897
+ * ```
2898
+ *
2899
+ * In the HTML, bind the created table instance to `<sci-table>` and assign it a name.
2900
+ * The name must start with the `table:` prefix and is used to store user settings.
2901
+ *
2902
+ * ```html
2903
+ * <sci-table name="table:users" [table]="userTable"/>
2904
+ * ```
2905
+ *
2906
+ * ### Fixed Row Height
2907
+ *
2908
+ * All rows in the table must have equal height, which is `2em` by default. The height can be changed per table using the CSS variable `--sci-table-row-height`.
2909
+ *
2910
+ * ```scss
2911
+ * sci-table {
2912
+ * --sci-table-row-height: 30px;
2913
+ * }
2914
+ * ```
2915
+ *
2916
+ * ### Styling
2917
+ *
2918
+ * The appearance of the table can be customized using the following CSS variables, either globally in the `:root` or at the table level.
2919
+ *
2920
+ * - `--sci-table-gridline-color`
2921
+ * - `--sci-table-header-height`
2922
+ * - `--sci-table-header-cursor`
2923
+ * - `--sci-table-header-background-color`
2924
+ * - `--sci-table-header-background-color-hover`
2925
+ * - `--sci-table-header-font-family`
2926
+ * - `--sci-table-header-font-size`
2927
+ * - `--sci-table-header-font-weight`
2928
+ * - `--sci-table-header-text-color`
2929
+ * - `--sci-table-header-column-divider`
2930
+ * - `--sci-table-row-height`
2931
+ * - `--sci-table-row-indent`
2932
+ * - `--sci-table-row-background-color-hover`
2933
+ * - `--sci-table-row-background-color-selected`
2934
+ * - `--sci-table-row-border-radius`
2935
+ * - `--sci-table-row-outline-color`
2936
+ * - `--sci-table-row-outline-width`
2937
+ * - `--sci-table-row-outline-style`
2938
+ * - `--sci-table-row-action-background-color`
2939
+ * - `--sci-table-row-action-background-color-selected`
2940
+ * - `--sci-table-cell-padding-inline`
2941
+ *
2408
2942
  * @experimental since 22.3.0; API and behavior may change in any version without notice.
2409
2943
  */
2410
2944
  class SciTableComponent {
@@ -2437,9 +2971,10 @@ class SciTableComponent {
2437
2971
  }
2438
2972
  onRowPrimaryAction(event) {
2439
2973
  this.primaryAction.emit(event);
2974
+ event.items.forEach(item => this.table().toggleRow(item));
2440
2975
  }
2441
2976
  onRetry() {
2442
- this.table().reset();
2977
+ this.table().reloadFailed();
2443
2978
  }
2444
2979
  connectToModel() {
2445
2980
  const viewportDimension = dimension(this._viewport);
@@ -2470,15 +3005,16 @@ class SciTableComponent {
2470
3005
  }
2471
3006
  scrollActiveRowIntoViewport() {
2472
3007
  effect(() => {
2473
- const activeRow = this.table().activeRow();
2474
- if (!activeRow) {
3008
+ const activeItem = this.table().activeItem();
3009
+ if (activeItem === undefined) {
2475
3010
  return;
2476
3011
  }
2477
3012
  untracked(() => {
2478
3013
  const viewport = this._viewport().nativeElement;
2479
3014
  const viewportHeight = this.table().tableViewRef()?.viewportHeight() ?? 0;
2480
3015
  const itemHeight = this.table().tableViewRef()?.itemHeight() ?? 0;
2481
- const activeRowTop = activeRow.index * itemHeight;
3016
+ const activeIndex = this.table().rowIndexById().get(activeItem) ?? -1;
3017
+ const activeRowTop = activeIndex * itemHeight;
2482
3018
  const activeRowBottom = activeRowTop + itemHeight;
2483
3019
  const scrollTop = viewport.scrollTop;
2484
3020
  const scrollBottom = scrollTop + viewportHeight;
@@ -2495,7 +3031,7 @@ class SciTableComponent {
2495
3031
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: SciTableComponent, isStandalone: true, selector: "sci-table", inputs: { name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: true, transformFunction: null }, table: { classPropertyName: "table", publicName: "table", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { primaryAction: "primaryAction" }, host: { properties: { "attr.name": "name()", "style.--\u0275sci-table-scrolling": "table().scrolling() ? `true` : null", "style.--\u0275sci-table-resizing": "table().resizing() ? `true` : null" } }, providers: [
2496
3032
  provideSciTable(),
2497
3033
  SciTableSelectionService,
2498
- ], viewQueries: [{ propertyName: "_viewport", first: true, predicate: SciTableViewportComponent, descendants: true, read: ElementRef, isSignal: true }, { propertyName: "_viewportClient", first: true, predicate: SciTableGridComponent, descendants: true, read: ElementRef, isSignal: true }, { propertyName: "_tableHeader", first: true, predicate: SciTableHeaderComponent, descendants: true, read: ElementRef, isSignal: true }, { propertyName: "_tableBody", first: true, predicate: SciTableBodyComponent, descendants: true, read: ElementRef, isSignal: true }, { propertyName: "_itemSizeSyntheticElement", first: true, predicate: ["item_size_synthetic_element"], descendants: true, isSignal: true }, { propertyName: "_cellPaddingSyntheticElement", first: true, predicate: ["cell_padding_synthetic_element"], descendants: true, isSignal: true }, { propertyName: "rows", predicate: SciTableRowComponent, descendants: true, isSignal: true }], ngImport: i0, template: "@let table = this.table();\n\n<sci-table-viewport #viewport>\n <sci-table-grid>\n <!-- Table header -->\n @if (table.showHeader() || table.filterable()) {\n <sci-table-header/>\n }\n\n <!-- Table body -->\n <sci-table-body sciTableKeyboardNavigator>\n @if (!table.error()) {\n <!-- Track index to reuse the table row elements -->\n @for (row of table.rows(); track $index) {\n <sci-table-row [row]=\"row\" (primaryAction)=\"onRowPrimaryAction($event)\" [sciAttributes]=\"row.bindings?.attributes()\"/>\n } @empty {\n <span class=\"message e2e-no-data\">{{('%scion.components.no_items.message' | sciText)()}}</span>\n }\n } @else {\n <span class=\"message e2e-datasource\">\n {{('%scion.components.datasource_error.message' | sciText)()}}\n\n <button (click)=\"onRetry()\" class=\"action e2e-datasource-retry\">\n <!-- TODO [dwi] Replace with SCION icon -->\n <sci-icon>refresh</sci-icon>\n <span>{{('%scion.components.retry.label' | sciText)()}}</span>\n </button>\n </span>\n }\n\n <!-- Render active row outline separately to preserve row border-radius within block selection. -->\n @if (table.activeRow()) {\n <div class=\"active-row-outline e2e-active-row-outline\"></div>\n }\n </sci-table-body>\n\n <!-- Render column splitters. -->\n <sci-table-columns [rows]=\"rows()\"/>\n </sci-table-grid>\n</sci-table-viewport>\n\n<!-- Scrollbars -->\n<sci-scrollbar direction=\"hscroll\" [viewport]=\"viewport.host\"/>\n<sci-scrollbar direction=\"vscroll\" [viewport]=\"viewport.host\"/>\n\n@if (table.loading()) {\n <sci-throbber type=\"spinner\" class=\"loading-indicator e2e-loading-indicator\"/>\n}\n\n<!-- Synthetic element to compute the absolute pixel value of the `--sci-table-row-height`, relative to the table's font size if em-based. -->\n<div class=\"synthetic item-size\" #item_size_synthetic_element></div>\n\n<!-- Synthetic element to compute the absolute pixel value of the `--sci-table-cell-padding-inline`, relative to the table's font size if em-based. -->\n<div class=\"synthetic cell-padding\" #cell_padding_synthetic_element></div>\n", styles: ["@charset \"UTF-8\";:host{display:block;overflow:hidden;--\\275sci-table-border-size: 1px;--\\275sci-table-border-color: var(--sci-color-border)}:host:is(:not(:hover):not(:active),:host(:not(:hover):not(:active))) sci-scrollbar:not(.scrolling){opacity:0}:host>sci-table-viewport{display:block;position:relative;height:100%;overflow:auto;scrollbar-width:none;isolation:isolate;box-sizing:border-box;anchor-name:--sci-table-viewport}:host>sci-table-viewport:has(sci-table-header){--\\275sci-table-header-visible: true}:host>sci-table-viewport:has(sci-table-header>sci-column-header){--\\275sci-column-header-visible: true}:host>sci-table-viewport:has(~sci-scrollbar.scrolling){--\\275sci-table-scrolling: true}:host>sci-table-viewport>sci-table-grid{display:grid;grid-template-columns:var(--\\275sci-table-columns, repeat(auto-fit, 100px)) 0;grid-auto-rows:min-content;grid-template-rows:min-content 1fr;width:min-content;min-width:100%;min-height:100%}@container not style(--\\275sci-table-header-visible: true){:host>sci-table-viewport>sci-table-grid{grid-template-rows:1fr}}:host>sci-table-viewport>sci-table-grid>sci-table-header{z-index:1;grid-row:1;grid-column:1/-1;position:sticky;top:0}:host>sci-table-viewport>sci-table-grid>sci-table-body{anchor-name:--sci-table-body;grid-row:2}@container not style(--\\275sci-table-header-visible: true){:host>sci-table-viewport>sci-table-grid>sci-table-body{grid-row:1}}:host>sci-table-viewport>sci-table-grid>sci-table-body{display:grid;grid-template-columns:subgrid;grid-column:1/-1;grid-auto-rows:var(--sci-table-row-height);outline:none;padding-top:var(--\\275sci-table-virtual-scroll-offset-top);padding-bottom:var(--\\275sci-table-virtual-scroll-offset-bottom)}:host>sci-table-viewport>sci-table-grid>sci-table-body>sci-table-row{grid-column:1/-1}:host>sci-table-viewport>sci-table-grid>sci-table-body>sci-table-row[data-active]{anchor-name:--sci-table-active-row}:host>sci-table-viewport>sci-table-grid>sci-table-body>div.active-row-outline{position:absolute;top:anchor(--sci-table-active-row top);right:anchor(--sci-table-active-row right);bottom:anchor(--sci-table-active-row bottom);left:anchor(--sci-table-active-row left);border:var(--sci-table-row-outline-width) var(--sci-table-row-outline-style) var(--sci-table-row-outline-color);border-radius:var(--sci-table-row-border-radius);pointer-events:none}:host>sci-table-viewport>sci-table-grid>sci-table-body:not(:has(sci-table-row[data-active]))>div.active-row-outline{display:none}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message{grid-column:1/-1;grid-row:auto/span 2;justify-self:center;align-self:center;display:flex;flex-direction:column;align-items:center;gap:1em;-webkit-user-select:none;user-select:none;white-space:nowrap;color:var(--sci-color-text-subtle)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message:has(button.action){grid-row:auto/span 4}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action{all:unset;display:inline-grid;place-content:center;place-items:center;padding:.25em;border-radius:var(--sci-button-border-radius);-webkit-user-select:none;user-select:none;overflow:hidden;cursor:var(--sci-button-cursor);--sci-icon-size: var(--sci-button-icon-size)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:hover:where(:not(:disabled)){background-color:var(--sci-button-background-color-hover)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:active:where(:not(:disabled)){background-color:var(--sci-button-background-color-active)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:focus:not(:focus-visible){outline:none}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:focus-visible{outline:var(--sci-button-outline-width) solid var(--sci-color-accent)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:disabled{color:var(--sci-button-text-color-disabled)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action{border:1px solid var(--sci-color-border);padding:.25em 1em}@container style(--\\275sci-table-viewport-client-width > --\\275sci-table-viewport-width){:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message{position:sticky;left:50%;right:50%;width:0}}:host>sci-table-viewport>sci-table-grid>sci-table-body:has(>span.message){z-index:2}:host>sci-table-viewport>sci-table-grid>sci-table-columns{grid-area:1/1/-1/-1;z-index:1;position:sticky;top:0;max-height:var(--\\275sci-table-viewport-height)}:host>sci-scrollbar{position:absolute}:host>sci-scrollbar[direction=vscroll]{top:calc(anchor(--sci-table-body top) + 2px);right:calc(anchor(--sci-table-viewport right) + 2px);bottom:calc(anchor(--sci-table-viewport bottom) + 2px)}:host>sci-scrollbar[direction=hscroll]{left:calc(anchor(--sci-table-viewport left) + 2px);bottom:calc(anchor(--sci-table-viewport bottom) + 1px);right:calc(anchor(--sci-table-viewport right) + 2px)}@container style(--\\275sci-table-resizing: true){:host>sci-scrollbar{pointer-events:none}}:host>sci-throbber.loading-indicator{--sci-throbber-size: 16px;position:absolute;top:calc(anchor(--sci-table-viewport top) + .5em);right:calc(anchor(--sci-table-viewport right) + .5em)}:host>div.synthetic{position:absolute;pointer-events:none;visibility:hidden}:host>div.synthetic.item-size{height:var(--sci-table-row-height)}:host>div.synthetic.cell-padding{width:var(--sci-table-cell-padding-inline)}\n"], dependencies: [{ kind: "component", type: SciTableViewportComponent, selector: "sci-table-viewport" }, { kind: "component", type: SciTableHeaderComponent, selector: "sci-table-header" }, { kind: "component", type: SciTableGridComponent, selector: "sci-table-grid" }, { kind: "component", type: SciTableBodyComponent, selector: "sci-table-body" }, { kind: "component", type: SciTableRowComponent, selector: "sci-table-row", inputs: ["row"], outputs: ["primaryAction"] }, { kind: "component", type: SciTableColumnsComponent, selector: "sci-table-columns", inputs: ["rows"] }, { kind: "directive", type: SciTableKeyboardNavigatorDirective, selector: "[sciTableKeyboardNavigator]" }, { kind: "directive", type: SciAttributesDirective, selector: "[sciAttributes]", inputs: ["sciAttributes"] }, { kind: "component", type: SciScrollbarComponent, selector: "sci-scrollbar", inputs: ["direction", "viewport"] }, { kind: "component", type: SciThrobberComponent, selector: "sci-throbber", inputs: ["type"] }, { kind: "component", type: SciIconComponent, selector: "sci-icon" }, { kind: "pipe", type: SciTextPipe, name: "sciText" }], encapsulation: i0.ViewEncapsulation.ShadowDom });
3034
+ ], viewQueries: [{ propertyName: "_viewport", first: true, predicate: SciTableViewportComponent, descendants: true, read: ElementRef, isSignal: true }, { propertyName: "_viewportClient", first: true, predicate: SciTableGridComponent, descendants: true, read: ElementRef, isSignal: true }, { propertyName: "_tableHeader", first: true, predicate: SciTableHeaderComponent, descendants: true, read: ElementRef, isSignal: true }, { propertyName: "_tableBody", first: true, predicate: SciTableBodyComponent, descendants: true, read: ElementRef, isSignal: true }, { propertyName: "_itemSizeSyntheticElement", first: true, predicate: ["item_size_synthetic_element"], descendants: true, isSignal: true }, { propertyName: "_cellPaddingSyntheticElement", first: true, predicate: ["cell_padding_synthetic_element"], descendants: true, isSignal: true }, { propertyName: "rows", predicate: SciTableRowComponent, descendants: true, isSignal: true }], ngImport: i0, template: "@let table = this.table();\n\n<!-- TODO [#384] Replace with sci-viewport -->\n<sci-table-viewport #viewport>\n <sci-table-grid>\n <!-- Table header -->\n @if (table.showHeader() || table.filterable()) {\n <sci-table-header/>\n }\n\n <!-- Table body -->\n <sci-table-body sciTableKeyboardNavigator>\n @if (!table.error()) {\n <!-- Track index to reuse the table row elements -->\n @for (row of table.rows(); track $index) {\n <sci-table-row [row]=\"row\" [sciAttributes]=\"row.bindings.attributes()\" [class]=\"row.bindings.classes()\" (primaryAction)=\"onRowPrimaryAction($event)\"/>\n } @empty {\n <span class=\"message e2e-no-data\">{{('%scion:components:no_items.message' | sciText)()}}</span>\n }\n } @else {\n <span class=\"message e2e-datasource\">\n {{('%scion:components:datasource_error.message' | sciText)()}}\n\n <button (click)=\"onRetry()\" class=\"action e2e-datasource-retry\">\n <sci-icon>scion:reload</sci-icon>\n <span>{{('%scion:components:retry.action' | sciText)()}}</span>\n </button>\n </span>\n }\n\n <!-- Render active row outline separately to preserve row border-radius within block selection. -->\n @if (table.activeRow()) {\n <div class=\"active-row-outline e2e-active-row-outline\"></div>\n }\n </sci-table-body>\n\n <!-- Render column splitters. -->\n <sci-table-columns [rows]=\"rows()\"/>\n </sci-table-grid>\n</sci-table-viewport>\n\n<!-- Scrollbars -->\n<sci-scrollbar direction=\"hscroll\" [viewport]=\"viewport.host\"/>\n<sci-scrollbar direction=\"vscroll\" [viewport]=\"viewport.host\"/>\n\n@if (table.loading()) {\n <sci-throbber type=\"spinner\" class=\"loading-indicator e2e-loading-indicator\"/>\n}\n\n<!-- Synthetic element to compute the absolute pixel value of the `--sci-table-row-height`, relative to the table's font size if em-based. -->\n<div class=\"synthetic item-size\" #item_size_synthetic_element></div>\n\n<!-- Synthetic element to compute the absolute pixel value of the `--sci-table-cell-padding-inline`, relative to the table's font size if em-based. -->\n<div class=\"synthetic cell-padding\" #cell_padding_synthetic_element></div>\n", styles: ["@charset \"UTF-8\";:host{display:block;overflow:hidden;--\\275sci-table-border-size: 1px;--\\275sci-table-border-color: var(--sci-color-border)}:host>sci-table-viewport{display:block;position:relative;height:100%;overflow:auto;scrollbar-width:none;isolation:isolate;box-sizing:border-box;anchor-name:--sci-table-viewport}:host>sci-table-viewport:is(:not(:hover),:has(sci-table-header:hover))~sci-scrollbar{opacity:0}:host>sci-table-viewport:has(sci-table-header){--\\275sci-table-header-visible: true}:host>sci-table-viewport:has(sci-table-header>sci-column-header){--\\275sci-column-header-visible: true}:host>sci-table-viewport:has(~sci-scrollbar.scrolling){--\\275sci-table-scrolling: true}:host>sci-table-viewport>sci-table-grid{display:grid;grid-template-columns:var(--\\275sci-table-columns, repeat(auto-fit, 100px)) 0;grid-auto-rows:min-content;grid-template-rows:min-content 1fr;width:min-content;min-width:100%;min-height:100%}@container not style(--\\275sci-table-header-visible: true){:host>sci-table-viewport>sci-table-grid{grid-template-rows:1fr}}:host>sci-table-viewport>sci-table-grid>sci-table-header{z-index:1;grid-row:1;grid-column:1/-1;position:sticky;top:0}:host>sci-table-viewport>sci-table-grid>sci-table-body{anchor-name:--sci-table-body;grid-row:2}@container not style(--\\275sci-table-header-visible: true){:host>sci-table-viewport>sci-table-grid>sci-table-body{grid-row:1}}:host>sci-table-viewport>sci-table-grid>sci-table-body{display:grid;grid-template-columns:subgrid;grid-column:1/-1;grid-auto-rows:var(--sci-table-row-height);outline:none;padding-top:var(--\\275sci-table-virtual-scroll-offset-top);padding-bottom:var(--\\275sci-table-virtual-scroll-offset-bottom)}:host>sci-table-viewport>sci-table-grid>sci-table-body>sci-table-row{grid-column:1/-1}:host>sci-table-viewport>sci-table-grid>sci-table-body>sci-table-row[data-active]{anchor-name:--sci-table-active-row}:host>sci-table-viewport>sci-table-grid>sci-table-body>div.active-row-outline{position:absolute;top:anchor(--sci-table-active-row top);right:anchor(--sci-table-active-row right);bottom:anchor(--sci-table-active-row bottom);left:anchor(--sci-table-active-row left);border:var(--sci-table-row-outline-width) var(--sci-table-row-outline-style) var(--sci-table-row-outline-color);border-radius:var(--sci-table-row-border-radius);pointer-events:none}:host>sci-table-viewport>sci-table-grid>sci-table-body:not(:has(sci-table-row[data-active]))>div.active-row-outline{display:none}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message{grid-column:1/-1;grid-row:auto/span 2;justify-self:center;align-self:center;display:flex;flex-direction:column;align-items:center;gap:1em;-webkit-user-select:none;user-select:none;white-space:nowrap;color:var(--sci-color-text-subtle)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message:has(button.action){grid-row:auto/span 4}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action{all:unset;display:inline-grid;place-content:center;place-items:center;padding:.25em;border-radius:var(--sci-button-border-radius);-webkit-user-select:none;user-select:none;overflow:hidden;cursor:var(--sci-button-cursor);--sci-icon-size: var(--sci-button-icon-size)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:hover:where(:not(:disabled)){background-color:var(--sci-button-background-color-hover)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:active:where(:not(:disabled)){background-color:var(--sci-button-background-color-active)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:focus:not(:focus-visible){outline:none}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:focus-visible{outline:var(--sci-button-outline-width) solid var(--sci-color-accent)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:disabled{color:var(--sci-button-text-color-disabled)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action{display:flex;gap:.5em;border:1px solid var(--sci-color-border);padding:.35em 1em}@container style(--\\275sci-table-viewport-client-width > --\\275sci-table-viewport-width){:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message{position:sticky;left:50%;right:50%;width:0}}:host>sci-table-viewport>sci-table-grid>sci-table-body:has(>span.message){z-index:2}:host>sci-table-viewport>sci-table-grid>sci-table-columns{grid-area:1/1/-1/-1;z-index:1;position:sticky;top:0;max-height:var(--\\275sci-table-viewport-height)}:host>sci-scrollbar{position:absolute}:host>sci-scrollbar[direction=vscroll]{top:calc(anchor(--sci-table-body top) + 2px);right:calc(anchor(--sci-table-viewport right) + 2px);bottom:calc(anchor(--sci-table-viewport bottom) + 2px)}:host>sci-scrollbar[direction=hscroll]{left:calc(anchor(--sci-table-viewport left) + 2px);bottom:calc(anchor(--sci-table-viewport bottom) + 1px);right:calc(anchor(--sci-table-viewport right) + 2px)}@container style(--\\275sci-table-resizing: true){:host>sci-scrollbar{pointer-events:none}}:host>sci-throbber.loading-indicator{--sci-throbber-size: 16px;position:absolute;top:calc(anchor(--sci-table-viewport top) + .5em);right:calc(anchor(--sci-table-viewport right) + .5em)}:host>div.synthetic{position:absolute;pointer-events:none;visibility:hidden}:host>div.synthetic.item-size{height:var(--sci-table-row-height)}:host>div.synthetic.cell-padding{width:var(--sci-table-cell-padding-inline)}\n"], dependencies: [{ kind: "component", type: SciTableViewportComponent, selector: "sci-table-viewport" }, { kind: "component", type: SciTableHeaderComponent, selector: "sci-table-header" }, { kind: "component", type: SciTableGridComponent, selector: "sci-table-grid" }, { kind: "component", type: SciTableBodyComponent, selector: "sci-table-body" }, { kind: "component", type: SciTableRowComponent, selector: "sci-table-row", inputs: ["row"], outputs: ["primaryAction"] }, { kind: "component", type: SciTableColumnsComponent, selector: "sci-table-columns", inputs: ["rows"] }, { kind: "directive", type: SciTableKeyboardNavigatorDirective, selector: "[sciTableKeyboardNavigator]" }, { kind: "directive", type: SciAttributesDirective, selector: "[sciAttributes]", inputs: ["sciAttributes"] }, { kind: "component", type: SciScrollbarComponent, selector: "sci-scrollbar", inputs: ["direction", "viewport"] }, { kind: "component", type: SciThrobberComponent, selector: "sci-throbber", inputs: ["type"] }, { kind: "component", type: SciIconComponent, selector: "sci-icon" }, { kind: "pipe", type: SciTextPipe, name: "sciText" }], encapsulation: i0.ViewEncapsulation.ShadowDom });
2499
3035
  }
2500
3036
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: SciTableComponent, decorators: [{
2501
3037
  type: Component,
@@ -2519,7 +3055,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
2519
3055
  ], providers: [
2520
3056
  provideSciTable(),
2521
3057
  SciTableSelectionService,
2522
- ], template: "@let table = this.table();\n\n<sci-table-viewport #viewport>\n <sci-table-grid>\n <!-- Table header -->\n @if (table.showHeader() || table.filterable()) {\n <sci-table-header/>\n }\n\n <!-- Table body -->\n <sci-table-body sciTableKeyboardNavigator>\n @if (!table.error()) {\n <!-- Track index to reuse the table row elements -->\n @for (row of table.rows(); track $index) {\n <sci-table-row [row]=\"row\" (primaryAction)=\"onRowPrimaryAction($event)\" [sciAttributes]=\"row.bindings?.attributes()\"/>\n } @empty {\n <span class=\"message e2e-no-data\">{{('%scion.components.no_items.message' | sciText)()}}</span>\n }\n } @else {\n <span class=\"message e2e-datasource\">\n {{('%scion.components.datasource_error.message' | sciText)()}}\n\n <button (click)=\"onRetry()\" class=\"action e2e-datasource-retry\">\n <!-- TODO [dwi] Replace with SCION icon -->\n <sci-icon>refresh</sci-icon>\n <span>{{('%scion.components.retry.label' | sciText)()}}</span>\n </button>\n </span>\n }\n\n <!-- Render active row outline separately to preserve row border-radius within block selection. -->\n @if (table.activeRow()) {\n <div class=\"active-row-outline e2e-active-row-outline\"></div>\n }\n </sci-table-body>\n\n <!-- Render column splitters. -->\n <sci-table-columns [rows]=\"rows()\"/>\n </sci-table-grid>\n</sci-table-viewport>\n\n<!-- Scrollbars -->\n<sci-scrollbar direction=\"hscroll\" [viewport]=\"viewport.host\"/>\n<sci-scrollbar direction=\"vscroll\" [viewport]=\"viewport.host\"/>\n\n@if (table.loading()) {\n <sci-throbber type=\"spinner\" class=\"loading-indicator e2e-loading-indicator\"/>\n}\n\n<!-- Synthetic element to compute the absolute pixel value of the `--sci-table-row-height`, relative to the table's font size if em-based. -->\n<div class=\"synthetic item-size\" #item_size_synthetic_element></div>\n\n<!-- Synthetic element to compute the absolute pixel value of the `--sci-table-cell-padding-inline`, relative to the table's font size if em-based. -->\n<div class=\"synthetic cell-padding\" #cell_padding_synthetic_element></div>\n", styles: ["@charset \"UTF-8\";:host{display:block;overflow:hidden;--\\275sci-table-border-size: 1px;--\\275sci-table-border-color: var(--sci-color-border)}:host:is(:not(:hover):not(:active),:host(:not(:hover):not(:active))) sci-scrollbar:not(.scrolling){opacity:0}:host>sci-table-viewport{display:block;position:relative;height:100%;overflow:auto;scrollbar-width:none;isolation:isolate;box-sizing:border-box;anchor-name:--sci-table-viewport}:host>sci-table-viewport:has(sci-table-header){--\\275sci-table-header-visible: true}:host>sci-table-viewport:has(sci-table-header>sci-column-header){--\\275sci-column-header-visible: true}:host>sci-table-viewport:has(~sci-scrollbar.scrolling){--\\275sci-table-scrolling: true}:host>sci-table-viewport>sci-table-grid{display:grid;grid-template-columns:var(--\\275sci-table-columns, repeat(auto-fit, 100px)) 0;grid-auto-rows:min-content;grid-template-rows:min-content 1fr;width:min-content;min-width:100%;min-height:100%}@container not style(--\\275sci-table-header-visible: true){:host>sci-table-viewport>sci-table-grid{grid-template-rows:1fr}}:host>sci-table-viewport>sci-table-grid>sci-table-header{z-index:1;grid-row:1;grid-column:1/-1;position:sticky;top:0}:host>sci-table-viewport>sci-table-grid>sci-table-body{anchor-name:--sci-table-body;grid-row:2}@container not style(--\\275sci-table-header-visible: true){:host>sci-table-viewport>sci-table-grid>sci-table-body{grid-row:1}}:host>sci-table-viewport>sci-table-grid>sci-table-body{display:grid;grid-template-columns:subgrid;grid-column:1/-1;grid-auto-rows:var(--sci-table-row-height);outline:none;padding-top:var(--\\275sci-table-virtual-scroll-offset-top);padding-bottom:var(--\\275sci-table-virtual-scroll-offset-bottom)}:host>sci-table-viewport>sci-table-grid>sci-table-body>sci-table-row{grid-column:1/-1}:host>sci-table-viewport>sci-table-grid>sci-table-body>sci-table-row[data-active]{anchor-name:--sci-table-active-row}:host>sci-table-viewport>sci-table-grid>sci-table-body>div.active-row-outline{position:absolute;top:anchor(--sci-table-active-row top);right:anchor(--sci-table-active-row right);bottom:anchor(--sci-table-active-row bottom);left:anchor(--sci-table-active-row left);border:var(--sci-table-row-outline-width) var(--sci-table-row-outline-style) var(--sci-table-row-outline-color);border-radius:var(--sci-table-row-border-radius);pointer-events:none}:host>sci-table-viewport>sci-table-grid>sci-table-body:not(:has(sci-table-row[data-active]))>div.active-row-outline{display:none}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message{grid-column:1/-1;grid-row:auto/span 2;justify-self:center;align-self:center;display:flex;flex-direction:column;align-items:center;gap:1em;-webkit-user-select:none;user-select:none;white-space:nowrap;color:var(--sci-color-text-subtle)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message:has(button.action){grid-row:auto/span 4}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action{all:unset;display:inline-grid;place-content:center;place-items:center;padding:.25em;border-radius:var(--sci-button-border-radius);-webkit-user-select:none;user-select:none;overflow:hidden;cursor:var(--sci-button-cursor);--sci-icon-size: var(--sci-button-icon-size)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:hover:where(:not(:disabled)){background-color:var(--sci-button-background-color-hover)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:active:where(:not(:disabled)){background-color:var(--sci-button-background-color-active)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:focus:not(:focus-visible){outline:none}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:focus-visible{outline:var(--sci-button-outline-width) solid var(--sci-color-accent)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:disabled{color:var(--sci-button-text-color-disabled)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action{border:1px solid var(--sci-color-border);padding:.25em 1em}@container style(--\\275sci-table-viewport-client-width > --\\275sci-table-viewport-width){:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message{position:sticky;left:50%;right:50%;width:0}}:host>sci-table-viewport>sci-table-grid>sci-table-body:has(>span.message){z-index:2}:host>sci-table-viewport>sci-table-grid>sci-table-columns{grid-area:1/1/-1/-1;z-index:1;position:sticky;top:0;max-height:var(--\\275sci-table-viewport-height)}:host>sci-scrollbar{position:absolute}:host>sci-scrollbar[direction=vscroll]{top:calc(anchor(--sci-table-body top) + 2px);right:calc(anchor(--sci-table-viewport right) + 2px);bottom:calc(anchor(--sci-table-viewport bottom) + 2px)}:host>sci-scrollbar[direction=hscroll]{left:calc(anchor(--sci-table-viewport left) + 2px);bottom:calc(anchor(--sci-table-viewport bottom) + 1px);right:calc(anchor(--sci-table-viewport right) + 2px)}@container style(--\\275sci-table-resizing: true){:host>sci-scrollbar{pointer-events:none}}:host>sci-throbber.loading-indicator{--sci-throbber-size: 16px;position:absolute;top:calc(anchor(--sci-table-viewport top) + .5em);right:calc(anchor(--sci-table-viewport right) + .5em)}:host>div.synthetic{position:absolute;pointer-events:none;visibility:hidden}:host>div.synthetic.item-size{height:var(--sci-table-row-height)}:host>div.synthetic.cell-padding{width:var(--sci-table-cell-padding-inline)}\n"] }]
3058
+ ], template: "@let table = this.table();\n\n<!-- TODO [#384] Replace with sci-viewport -->\n<sci-table-viewport #viewport>\n <sci-table-grid>\n <!-- Table header -->\n @if (table.showHeader() || table.filterable()) {\n <sci-table-header/>\n }\n\n <!-- Table body -->\n <sci-table-body sciTableKeyboardNavigator>\n @if (!table.error()) {\n <!-- Track index to reuse the table row elements -->\n @for (row of table.rows(); track $index) {\n <sci-table-row [row]=\"row\" [sciAttributes]=\"row.bindings.attributes()\" [class]=\"row.bindings.classes()\" (primaryAction)=\"onRowPrimaryAction($event)\"/>\n } @empty {\n <span class=\"message e2e-no-data\">{{('%scion:components:no_items.message' | sciText)()}}</span>\n }\n } @else {\n <span class=\"message e2e-datasource\">\n {{('%scion:components:datasource_error.message' | sciText)()}}\n\n <button (click)=\"onRetry()\" class=\"action e2e-datasource-retry\">\n <sci-icon>scion:reload</sci-icon>\n <span>{{('%scion:components:retry.action' | sciText)()}}</span>\n </button>\n </span>\n }\n\n <!-- Render active row outline separately to preserve row border-radius within block selection. -->\n @if (table.activeRow()) {\n <div class=\"active-row-outline e2e-active-row-outline\"></div>\n }\n </sci-table-body>\n\n <!-- Render column splitters. -->\n <sci-table-columns [rows]=\"rows()\"/>\n </sci-table-grid>\n</sci-table-viewport>\n\n<!-- Scrollbars -->\n<sci-scrollbar direction=\"hscroll\" [viewport]=\"viewport.host\"/>\n<sci-scrollbar direction=\"vscroll\" [viewport]=\"viewport.host\"/>\n\n@if (table.loading()) {\n <sci-throbber type=\"spinner\" class=\"loading-indicator e2e-loading-indicator\"/>\n}\n\n<!-- Synthetic element to compute the absolute pixel value of the `--sci-table-row-height`, relative to the table's font size if em-based. -->\n<div class=\"synthetic item-size\" #item_size_synthetic_element></div>\n\n<!-- Synthetic element to compute the absolute pixel value of the `--sci-table-cell-padding-inline`, relative to the table's font size if em-based. -->\n<div class=\"synthetic cell-padding\" #cell_padding_synthetic_element></div>\n", styles: ["@charset \"UTF-8\";:host{display:block;overflow:hidden;--\\275sci-table-border-size: 1px;--\\275sci-table-border-color: var(--sci-color-border)}:host>sci-table-viewport{display:block;position:relative;height:100%;overflow:auto;scrollbar-width:none;isolation:isolate;box-sizing:border-box;anchor-name:--sci-table-viewport}:host>sci-table-viewport:is(:not(:hover),:has(sci-table-header:hover))~sci-scrollbar{opacity:0}:host>sci-table-viewport:has(sci-table-header){--\\275sci-table-header-visible: true}:host>sci-table-viewport:has(sci-table-header>sci-column-header){--\\275sci-column-header-visible: true}:host>sci-table-viewport:has(~sci-scrollbar.scrolling){--\\275sci-table-scrolling: true}:host>sci-table-viewport>sci-table-grid{display:grid;grid-template-columns:var(--\\275sci-table-columns, repeat(auto-fit, 100px)) 0;grid-auto-rows:min-content;grid-template-rows:min-content 1fr;width:min-content;min-width:100%;min-height:100%}@container not style(--\\275sci-table-header-visible: true){:host>sci-table-viewport>sci-table-grid{grid-template-rows:1fr}}:host>sci-table-viewport>sci-table-grid>sci-table-header{z-index:1;grid-row:1;grid-column:1/-1;position:sticky;top:0}:host>sci-table-viewport>sci-table-grid>sci-table-body{anchor-name:--sci-table-body;grid-row:2}@container not style(--\\275sci-table-header-visible: true){:host>sci-table-viewport>sci-table-grid>sci-table-body{grid-row:1}}:host>sci-table-viewport>sci-table-grid>sci-table-body{display:grid;grid-template-columns:subgrid;grid-column:1/-1;grid-auto-rows:var(--sci-table-row-height);outline:none;padding-top:var(--\\275sci-table-virtual-scroll-offset-top);padding-bottom:var(--\\275sci-table-virtual-scroll-offset-bottom)}:host>sci-table-viewport>sci-table-grid>sci-table-body>sci-table-row{grid-column:1/-1}:host>sci-table-viewport>sci-table-grid>sci-table-body>sci-table-row[data-active]{anchor-name:--sci-table-active-row}:host>sci-table-viewport>sci-table-grid>sci-table-body>div.active-row-outline{position:absolute;top:anchor(--sci-table-active-row top);right:anchor(--sci-table-active-row right);bottom:anchor(--sci-table-active-row bottom);left:anchor(--sci-table-active-row left);border:var(--sci-table-row-outline-width) var(--sci-table-row-outline-style) var(--sci-table-row-outline-color);border-radius:var(--sci-table-row-border-radius);pointer-events:none}:host>sci-table-viewport>sci-table-grid>sci-table-body:not(:has(sci-table-row[data-active]))>div.active-row-outline{display:none}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message{grid-column:1/-1;grid-row:auto/span 2;justify-self:center;align-self:center;display:flex;flex-direction:column;align-items:center;gap:1em;-webkit-user-select:none;user-select:none;white-space:nowrap;color:var(--sci-color-text-subtle)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message:has(button.action){grid-row:auto/span 4}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action{all:unset;display:inline-grid;place-content:center;place-items:center;padding:.25em;border-radius:var(--sci-button-border-radius);-webkit-user-select:none;user-select:none;overflow:hidden;cursor:var(--sci-button-cursor);--sci-icon-size: var(--sci-button-icon-size)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:hover:where(:not(:disabled)){background-color:var(--sci-button-background-color-hover)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:active:where(:not(:disabled)){background-color:var(--sci-button-background-color-active)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:focus:not(:focus-visible){outline:none}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:focus-visible{outline:var(--sci-button-outline-width) solid var(--sci-color-accent)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action:disabled{color:var(--sci-button-text-color-disabled)}:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message>button.action{display:flex;gap:.5em;border:1px solid var(--sci-color-border);padding:.35em 1em}@container style(--\\275sci-table-viewport-client-width > --\\275sci-table-viewport-width){:host>sci-table-viewport>sci-table-grid>sci-table-body>span.message{position:sticky;left:50%;right:50%;width:0}}:host>sci-table-viewport>sci-table-grid>sci-table-body:has(>span.message){z-index:2}:host>sci-table-viewport>sci-table-grid>sci-table-columns{grid-area:1/1/-1/-1;z-index:1;position:sticky;top:0;max-height:var(--\\275sci-table-viewport-height)}:host>sci-scrollbar{position:absolute}:host>sci-scrollbar[direction=vscroll]{top:calc(anchor(--sci-table-body top) + 2px);right:calc(anchor(--sci-table-viewport right) + 2px);bottom:calc(anchor(--sci-table-viewport bottom) + 2px)}:host>sci-scrollbar[direction=hscroll]{left:calc(anchor(--sci-table-viewport left) + 2px);bottom:calc(anchor(--sci-table-viewport bottom) + 1px);right:calc(anchor(--sci-table-viewport right) + 2px)}@container style(--\\275sci-table-resizing: true){:host>sci-scrollbar{pointer-events:none}}:host>sci-throbber.loading-indicator{--sci-throbber-size: 16px;position:absolute;top:calc(anchor(--sci-table-viewport top) + .5em);right:calc(anchor(--sci-table-viewport right) + .5em)}:host>div.synthetic{position:absolute;pointer-events:none;visibility:hidden}:host>div.synthetic.item-size{height:var(--sci-table-row-height)}:host>div.synthetic.cell-padding{width:var(--sci-table-cell-padding-inline)}\n"] }]
2523
3059
  }], ctorParameters: () => [], propDecorators: { name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: true }] }], table: [{ type: i0.Input, args: [{ isSignal: true, alias: "table", required: true }] }], primaryAction: [{ type: i0.Output, args: ["primaryAction"] }], _viewport: [{ type: i0.ViewChild, args: [i0.forwardRef(() => SciTableViewportComponent), { ...{ read: ElementRef }, isSignal: true }] }], _viewportClient: [{ type: i0.ViewChild, args: [i0.forwardRef(() => SciTableGridComponent), { ...{ read: ElementRef }, isSignal: true }] }], _tableHeader: [{ type: i0.ViewChild, args: [i0.forwardRef(() => SciTableHeaderComponent), { ...{ read: ElementRef }, isSignal: true }] }], _tableBody: [{ type: i0.ViewChild, args: [i0.forwardRef(() => SciTableBodyComponent), { ...{ read: ElementRef }, isSignal: true }] }], _itemSizeSyntheticElement: [{ type: i0.ViewChild, args: ['item_size_synthetic_element', { isSignal: true }] }], _cellPaddingSyntheticElement: [{ type: i0.ViewChild, args: ['cell_padding_synthetic_element', { isSignal: true }] }], rows: [{ type: i0.ViewChildren, args: [i0.forwardRef(() => SciTableRowComponent), { isSignal: true }] }] } });
2524
3060
  function provideSciTable() {
2525
3061
  return {
@@ -2542,5 +3078,5 @@ function provideSciTable() {
2542
3078
  * Generated bundle index. Do not edit.
2543
3079
  */
2544
3080
 
2545
- export { SciTableComponent, provideTableRowBinding, provideTableStorage, table, ɵillegaldatasource };
3081
+ export { SciTableComponent, provideTableDatasource, provideTableRowBinding, table, ɵSciTable, ɵSciTableRowBindingFactory, ɵprovideTableDatasource };
2546
3082
  //# sourceMappingURL=scion-components-table.mjs.map