@star-ng/ui 0.1.0 → 0.2.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.
@@ -1,56 +1,237 @@
1
1
  import { _IdGenerator } from "@angular/cdk/a11y";
2
- import { NgTemplateOutlet } from "@angular/common";
2
+ import { DOCUMENT, NgTemplateOutlet } from "@angular/common";
3
3
  import * as i0 from "@angular/core";
4
- import { ChangeDetectionStrategy, Component, Directive, TemplateRef, booleanAttribute, computed, contentChild, inject, input, model, output, signal } from "@angular/core";
4
+ import { ChangeDetectionStrategy, Component, Directive, TemplateRef, afterEveryRender, booleanAttribute, computed, contentChild, effect, inject, input, isDevMode, linkedSignal, model, numberAttribute, output, signal, untracked, viewChild } from "@angular/core";
5
5
  import { STAR_UI_TEXT, optionText, readOption, starInlineKey } from "@star-ng/ui/core";
6
6
  import { StarPaginator } from "@star-ng/ui/paginator";
7
+ import { flattenVisible, navigationTarget, parentsOf, rowIndexOf, scrollOffsetToReveal, stillFocusable } from "@star-ng/ui/tree";
8
+ function rowWindow(count, itemSize, scrollTop, viewportSize, bufferRows) {
9
+ if (count <= 0 || itemSize <= 0) return {
10
+ start: 0,
11
+ end: 0,
12
+ before: 0,
13
+ after: 0
14
+ };
15
+ const visible = Math.ceil(Math.max(0, viewportSize) / itemSize) + 1;
16
+ const first = Math.min(Math.max(0, Math.floor(Math.max(0, scrollTop) / itemSize)), Math.max(0, count - visible));
17
+ const start = Math.max(0, first - bufferRows);
18
+ const end = Math.min(count, first + visible + bufferRows);
19
+ return {
20
+ start,
21
+ end,
22
+ before: start * itemSize,
23
+ after: (count - end) * itemSize
24
+ };
25
+ }
26
+ function revealRowOffset(index, itemSize, scrollTop, viewportSize, headerSize) {
27
+ return scrollOffsetToReveal(index, itemSize, scrollTop, Math.max(itemSize, viewportSize - headerSize));
28
+ }
29
+ function ariaRowIndex(index) {
30
+ return index + 2;
31
+ }
32
+ function ariaRowCount(rowCount) {
33
+ return rowCount + 1;
34
+ }
35
+ const TREE_TABLE_ROW_HEIGHT = 53;
36
+ const BUFFER_ROWS = 8;
37
+ const FALLBACK_VIEWPORT = 600;
7
38
  var StarTreeTable = class StarTreeTable {
8
39
  text = inject(STAR_UI_TEXT);
40
+ doc = inject(DOCUMENT);
9
41
  nodes = input([], ...ngDevMode ? [{ debugName: "nodes" }] : /* istanbul ignore next */ []);
10
42
  columns = input.required(...ngDevMode ? [{ debugName: "columns" }] : /* istanbul ignore next */ []);
11
43
  expanded = model([], ...ngDevMode ? [{ debugName: "expanded" }] : /* istanbul ignore next */ []);
12
44
  ariaLabel = input(null, ...ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []);
13
- rows = computed(() => {
14
- const rows = [];
15
- const open = new Set(this.expanded());
16
- const walk = (nodes, level) => nodes.forEach((node, i) => {
17
- rows.push({
18
- node,
19
- level,
20
- setSize: nodes.length,
21
- position: i + 1
45
+ scrollHeight = input(null, ...ngDevMode ? [{ debugName: "scrollHeight" }] : /* istanbul ignore next */ []);
46
+ virtualScroll = input(false, {
47
+ ...ngDevMode ? { debugName: "virtualScroll" } : /* istanbul ignore next */ {},
48
+ transform: booleanAttribute
49
+ });
50
+ virtualItemSize = input(TREE_TABLE_ROW_HEIGHT, {
51
+ ...ngDevMode ? { debugName: "virtualItemSize" } : /* istanbul ignore next */ {},
52
+ transform: numberAttribute
53
+ });
54
+ scroller = viewChild("scroller", ...ngDevMode ? [{ debugName: "scroller" }] : /* istanbul ignore next */ []);
55
+ header = viewChild("header", ...ngDevMode ? [{ debugName: "header" }] : /* istanbul ignore next */ []);
56
+ openKeys = computed(() => new Set(this.expanded()), ...ngDevMode ? [{ debugName: "openKeys" }] : /* istanbul ignore next */ []);
57
+ parents = computed(() => parentsOf(this.nodes()), ...ngDevMode ? [{ debugName: "parents" }] : /* istanbul ignore next */ []);
58
+ rows = computed(() => flattenVisible(this.nodes(), this.openKeys()), ...ngDevMode ? [{ debugName: "rows" }] : /* istanbul ignore next */ []);
59
+ rowIndex = computed(() => rowIndexOf(this.rows()), ...ngDevMode ? [{ debugName: "rowIndex" }] : /* istanbul ignore next */ []);
60
+ focusKey = linkedSignal({
61
+ ...ngDevMode ? { debugName: "focusKey" } : /* istanbul ignore next */ {},
62
+ source: this.rows,
63
+ computation: (rows, previous) => stillFocusable(previous?.value, this.rowIndex(), this.parents(), rows[0]?.node.key ?? null)
64
+ });
65
+ windowed = computed(() => this.virtualScroll() && !!this.scrollHeight() && this.virtualItemSize() > 0, ...ngDevMode ? [{ debugName: "windowed" }] : /* istanbul ignore next */ []);
66
+ scrollTop = signal(0, ...ngDevMode ? [{ debugName: "scrollTop" }] : /* istanbul ignore next */ []);
67
+ viewportHeight = signal(0, ...ngDevMode ? [{ debugName: "viewportHeight" }] : /* istanbul ignore next */ []);
68
+ window = computed(() => {
69
+ if (!this.windowed()) return {
70
+ start: 0,
71
+ end: this.rows().length,
72
+ before: 0,
73
+ after: 0
74
+ };
75
+ return rowWindow(this.rows().length, this.virtualItemSize(), this.scrollTop(), this.viewportHeight() || FALLBACK_VIEWPORT, BUFFER_ROWS);
76
+ }, {
77
+ ...ngDevMode ? { debugName: "window" } : /* istanbul ignore next */ {},
78
+ equal: (a, b) => a.start === b.start && a.end === b.end && a.before === b.before && a.after === b.after
79
+ });
80
+ entries = computed(() => {
81
+ const rows = this.rows();
82
+ if (!this.windowed()) return rows.map((row) => ({
83
+ row,
84
+ aria: null
85
+ }));
86
+ const { start, end } = this.window();
87
+ return rows.slice(start, end).map((row, i) => ({
88
+ row,
89
+ aria: ariaRowIndex(start + i)
90
+ }));
91
+ }, ...ngDevMode ? [{ debugName: "entries" }] : /* istanbul ignore next */ []);
92
+ activeRendered = computed(() => {
93
+ const index = this.rowIndex().get(this.focusKey() ?? "");
94
+ const { start, end } = this.window();
95
+ return index !== void 0 && index >= start && index < end;
96
+ }, ...ngDevMode ? [{ debugName: "activeRendered" }] : /* istanbul ignore next */ []);
97
+ ariaRowCount = ariaRowCount;
98
+ pendingFocus = null;
99
+ parked = false;
100
+ constructor() {
101
+ effect(() => {
102
+ const window = this.window();
103
+ this.rowIndex();
104
+ untracked(() => {
105
+ if (this.windowed()) this.parkFocus(window);
22
106
  });
23
- if (node.children?.length && open.has(node.key)) walk(node.children, level + 1);
24
107
  });
25
- walk(this.nodes(), 1);
26
- return rows;
27
- }, ...ngDevMode ? [{ debugName: "rows" }] : /* istanbul ignore next */ []);
28
- focusKey = signal(null, ...ngDevMode ? [{ debugName: "focusKey" }] : /* istanbul ignore next */ []);
108
+ effect((onCleanup) => {
109
+ const scroller = this.scroller()?.nativeElement;
110
+ if (!this.windowed() || !scroller || typeof ResizeObserver === "undefined") return;
111
+ const observer = new ResizeObserver(() => this.syncScroll());
112
+ observer.observe(scroller);
113
+ onCleanup(() => observer.disconnect());
114
+ });
115
+ afterEveryRender({ read: () => this.syncScroll() });
116
+ afterEveryRender(() => {
117
+ this.applyPendingFocus();
118
+ if (this.parked) this.restoreParkedFocus();
119
+ });
120
+ if (isDevMode()) effect(() => {
121
+ if (this.virtualScroll() && !this.scrollHeight()) console.warn("star-tree-table: virtualScroll needs scrollHeight. Without it the table renders all rows.");
122
+ });
123
+ }
29
124
  isOpen(key) {
30
- return this.expanded().includes(key);
125
+ return this.openKeys().has(key);
31
126
  }
32
127
  toggle(key, open = !this.isOpen(key)) {
33
- this.expanded.update((keys) => open ? [...keys, key] : keys.filter((k) => k !== key));
128
+ this.expanded.update((keys) => {
129
+ if (!open) return keys.filter((k) => k !== key);
130
+ return keys.includes(key) ? keys : [...keys, key];
131
+ });
132
+ }
133
+ syncScroll() {
134
+ const scroller = this.scroller()?.nativeElement;
135
+ if (!scroller || !this.windowed()) return;
136
+ this.scrollTop.set(scroller.scrollTop);
137
+ if (scroller.clientHeight) this.viewportHeight.set(scroller.clientHeight);
138
+ }
139
+ onRowFocus(event, key) {
140
+ this.focusKey.set(key);
141
+ this.pendingFocus = null;
142
+ this.parked = false;
143
+ if (event.target === event.currentTarget && this.windowed()) this.revealRow(key);
144
+ }
145
+ focusRow(key) {
146
+ if (!key) return;
147
+ this.focusKey.set(key);
148
+ this.pendingFocus = key;
149
+ if (this.windowed()) this.revealRow(key);
150
+ queueMicrotask(() => this.applyPendingFocus());
151
+ }
152
+ revealRow(key) {
153
+ const scroller = this.scroller()?.nativeElement;
154
+ const index = this.rowIndex().get(key);
155
+ if (!scroller || index === void 0) return;
156
+ const offset = revealRowOffset(index, this.virtualItemSize(), scroller.scrollTop, scroller.clientHeight, this.header()?.nativeElement.offsetHeight ?? 0);
157
+ if (offset === null) return;
158
+ scroller.scrollTop = offset;
159
+ this.syncScroll();
160
+ }
161
+ rowElement(key) {
162
+ return this.scroller()?.nativeElement.querySelector(`tbody > tr[data-key="${CSS.escape(key)}"]`) ?? null;
163
+ }
164
+ applyPendingFocus() {
165
+ const key = this.pendingFocus;
166
+ if (key === null) return;
167
+ if (!this.rowIndex().has(key)) {
168
+ this.pendingFocus = null;
169
+ return;
170
+ }
171
+ const row = this.rowElement(key);
172
+ if (!row) return;
173
+ this.pendingFocus = null;
174
+ row.focus({ preventScroll: this.windowed() });
175
+ }
176
+ parkFocus(window) {
177
+ const scroller = this.scroller()?.nativeElement;
178
+ const active = this.doc.activeElement;
179
+ if (!scroller || !active || active === scroller || !scroller.contains(active)) return;
180
+ const key = active.closest("tbody > tr[data-key]")?.dataset["key"];
181
+ const index = key === void 0 ? void 0 : this.rowIndex().get(key);
182
+ if (index !== void 0 && index >= window.start && index < window.end) return;
183
+ this.parked = true;
184
+ scroller.focus({ preventScroll: true });
185
+ }
186
+ restoreParkedFocus() {
187
+ const scroller = this.scroller()?.nativeElement;
188
+ if (!scroller || this.doc.activeElement !== scroller) {
189
+ this.parked = false;
190
+ return;
191
+ }
192
+ const key = this.focusKey();
193
+ if (key === null) return;
194
+ this.rowElement(key)?.focus({ preventScroll: true });
195
+ }
196
+ onScrollerFocus() {
197
+ if (this.windowed() && !this.parked) this.focusRow(this.focusKey());
198
+ }
199
+ onScrollerKeydown(event) {
200
+ if (!this.windowed() || event.target !== event.currentTarget) return;
201
+ const row = this.rows()[this.rowIndex().get(this.focusKey() ?? "") ?? -1];
202
+ if (row) this.onKeydown(event, row);
34
203
  }
35
204
  onKeydown(event, row) {
36
205
  const rows = this.rows();
37
- const index = rows.indexOf(row);
38
- const focus = (target) => {
39
- if (!target) return;
40
- event.preventDefault();
41
- this.focusKey.set(target.node.key);
42
- (event.currentTarget.closest("tbody")?.querySelector(`[data-key="${CSS.escape(target.node.key)}"]`))?.focus();
43
- };
206
+ const item = this.pendingFocus === null ? row : rows[this.rowIndex().get(this.pendingFocus) ?? -1] ?? row;
207
+ const index = this.rowIndex().get(item.node.key) ?? -1;
208
+ const hasChildren = !!item.node.children?.length;
209
+ const expanded = hasChildren && this.isOpen(item.node.key);
44
210
  const key = starInlineKey(event);
45
- if (key === "ArrowDown") focus(rows[index + 1]);
46
- else if (key === "ArrowUp") focus(rows[index - 1]);
47
- else if (key === "ArrowRight" && row.node.children?.length) {
211
+ const move = (target) => {
212
+ if (target === null) return;
48
213
  event.preventDefault();
49
- this.toggle(row.node.key, true);
50
- } else if (key === "ArrowLeft") {
51
- event.preventDefault();
52
- if (row.node.children?.length && this.isOpen(row.node.key)) this.toggle(row.node.key, false);
53
- else focus([...rows.slice(0, index)].reverse().find((r) => r.level < row.level));
214
+ this.focusRow(target);
215
+ };
216
+ switch (key) {
217
+ case "ArrowDown":
218
+ case "ArrowUp":
219
+ case "Home":
220
+ case "End": return move(navigationTarget(rows, index, key, {
221
+ hasChildren,
222
+ expanded
223
+ }));
224
+ case "ArrowRight":
225
+ if (hasChildren) {
226
+ event.preventDefault();
227
+ if (!expanded) this.toggle(item.node.key, true);
228
+ }
229
+ return;
230
+ case "ArrowLeft":
231
+ event.preventDefault();
232
+ if (expanded) this.toggle(item.node.key, false);
233
+ else move(item.parentKey);
234
+ return;
54
235
  }
55
236
  }
56
237
  static ɵfac = i0.ɵɵngDeclareFactory({
@@ -95,16 +276,72 @@ var StarTreeTable = class StarTreeTable {
95
276
  isSignal: true,
96
277
  isRequired: false,
97
278
  transformFunction: null
279
+ },
280
+ scrollHeight: {
281
+ classPropertyName: "scrollHeight",
282
+ publicName: "scrollHeight",
283
+ isSignal: true,
284
+ isRequired: false,
285
+ transformFunction: null
286
+ },
287
+ virtualScroll: {
288
+ classPropertyName: "virtualScroll",
289
+ publicName: "virtualScroll",
290
+ isSignal: true,
291
+ isRequired: false,
292
+ transformFunction: null
293
+ },
294
+ virtualItemSize: {
295
+ classPropertyName: "virtualItemSize",
296
+ publicName: "virtualItemSize",
297
+ isSignal: true,
298
+ isRequired: false,
299
+ transformFunction: null
98
300
  }
99
301
  },
100
302
  outputs: { expanded: "expandedChange" },
101
- host: { classAttribute: "star-table star-tree-table" },
303
+ host: {
304
+ properties: {
305
+ "class.star-table--sticky": "!!scrollHeight()",
306
+ "class.star-table--virtual": "windowed()",
307
+ "style.--star-table-row-height.px": "windowed() ? virtualItemSize() : null"
308
+ },
309
+ classAttribute: "star-table star-tree-table"
310
+ },
311
+ viewQueries: [{
312
+ propertyName: "scroller",
313
+ first: true,
314
+ predicate: ["scroller"],
315
+ descendants: true,
316
+ isSignal: true
317
+ }, {
318
+ propertyName: "header",
319
+ first: true,
320
+ predicate: ["header"],
321
+ descendants: true,
322
+ isSignal: true
323
+ }],
102
324
  ngImport: i0,
103
325
  template: `
104
- <div class="star-table__scroll">
105
- <table role="treegrid" [attr.aria-label]="ariaLabel()">
106
- <thead>
107
- <tr>
326
+ <div
327
+ #scroller
328
+ class="star-table__scroll"
329
+ [attr.role]="windowed() ? 'group' : null"
330
+ [attr.aria-label]="windowed() ? ariaLabel() : null"
331
+ [attr.tabindex]="windowed() ? (activeRendered() ? -1 : 0) : null"
332
+ [style.max-height]="scrollHeight()"
333
+ (scroll)="syncScroll()"
334
+ (focus)="onScrollerFocus()"
335
+ (blur)="parked = false"
336
+ (keydown)="onScrollerKeydown($event)"
337
+ >
338
+ <table
339
+ role="treegrid"
340
+ [attr.aria-label]="ariaLabel()"
341
+ [attr.aria-rowcount]="windowed() ? ariaRowCount(rows().length) : null"
342
+ >
343
+ <thead #header>
344
+ <tr [attr.aria-rowindex]="windowed() ? 1 : null">
108
345
  @for (column of columns(); track column.field) {
109
346
  <th
110
347
  scope="col"
@@ -117,9 +354,16 @@ var StarTreeTable = class StarTreeTable {
117
354
  </tr>
118
355
  </thead>
119
356
  <tbody>
120
- @for (row of rows(); track row.node.key) {
357
+ @if (window().before > 0) {
358
+ <tr class="star-table__spacer" aria-hidden="true">
359
+ <td [attr.colspan]="columns().length" [style.height.px]="window().before"></td>
360
+ </tr>
361
+ }
362
+ @for (entry of entries(); track entry.row.node.key) {
363
+ @let row = entry.row;
121
364
  <tr
122
365
  class="star-table__row"
366
+ [attr.aria-rowindex]="entry.aria"
123
367
  [attr.aria-level]="row.level"
124
368
  [attr.aria-setsize]="row.setSize"
125
369
  [attr.aria-posinset]="row.position"
@@ -127,7 +371,7 @@ var StarTreeTable = class StarTreeTable {
127
371
  [attr.tabindex]="focusKey() === row.node.key ? 0 : -1"
128
372
  [attr.data-key]="row.node.key"
129
373
  (keydown)="onKeydown($event, row)"
130
- (focus)="focusKey.set(row.node.key)"
374
+ (focusin)="onRowFocus($event, row.node.key)"
131
375
  >
132
376
  @for (column of columns(); track column.field; let first = $first) {
133
377
  <td [class]="'star-table__cell--' + (column.align ?? 'start')">
@@ -159,6 +403,11 @@ var StarTreeTable = class StarTreeTable {
159
403
  }
160
404
  </tr>
161
405
  }
406
+ @if (window().after > 0) {
407
+ <tr class="star-table__spacer" aria-hidden="true">
408
+ <td [attr.colspan]="columns().length" [style.height.px]="window().after"></td>
409
+ </tr>
410
+ }
162
411
  </tbody>
163
412
  </table>
164
413
  </div>
@@ -177,12 +426,32 @@ i0.ɵɵngDeclareClassMetadata({
177
426
  args: [{
178
427
  selector: "star-tree-table",
179
428
  changeDetection: ChangeDetectionStrategy.OnPush,
180
- host: { class: "star-table star-tree-table" },
429
+ host: {
430
+ class: "star-table star-tree-table",
431
+ "[class.star-table--sticky]": "!!scrollHeight()",
432
+ "[class.star-table--virtual]": "windowed()",
433
+ "[style.--star-table-row-height.px]": "windowed() ? virtualItemSize() : null"
434
+ },
181
435
  template: `
182
- <div class="star-table__scroll">
183
- <table role="treegrid" [attr.aria-label]="ariaLabel()">
184
- <thead>
185
- <tr>
436
+ <div
437
+ #scroller
438
+ class="star-table__scroll"
439
+ [attr.role]="windowed() ? 'group' : null"
440
+ [attr.aria-label]="windowed() ? ariaLabel() : null"
441
+ [attr.tabindex]="windowed() ? (activeRendered() ? -1 : 0) : null"
442
+ [style.max-height]="scrollHeight()"
443
+ (scroll)="syncScroll()"
444
+ (focus)="onScrollerFocus()"
445
+ (blur)="parked = false"
446
+ (keydown)="onScrollerKeydown($event)"
447
+ >
448
+ <table
449
+ role="treegrid"
450
+ [attr.aria-label]="ariaLabel()"
451
+ [attr.aria-rowcount]="windowed() ? ariaRowCount(rows().length) : null"
452
+ >
453
+ <thead #header>
454
+ <tr [attr.aria-rowindex]="windowed() ? 1 : null">
186
455
  @for (column of columns(); track column.field) {
187
456
  <th
188
457
  scope="col"
@@ -195,9 +464,16 @@ i0.ɵɵngDeclareClassMetadata({
195
464
  </tr>
196
465
  </thead>
197
466
  <tbody>
198
- @for (row of rows(); track row.node.key) {
467
+ @if (window().before > 0) {
468
+ <tr class="star-table__spacer" aria-hidden="true">
469
+ <td [attr.colspan]="columns().length" [style.height.px]="window().before"></td>
470
+ </tr>
471
+ }
472
+ @for (entry of entries(); track entry.row.node.key) {
473
+ @let row = entry.row;
199
474
  <tr
200
475
  class="star-table__row"
476
+ [attr.aria-rowindex]="entry.aria"
201
477
  [attr.aria-level]="row.level"
202
478
  [attr.aria-setsize]="row.setSize"
203
479
  [attr.aria-posinset]="row.position"
@@ -205,7 +481,7 @@ i0.ɵɵngDeclareClassMetadata({
205
481
  [attr.tabindex]="focusKey() === row.node.key ? 0 : -1"
206
482
  [attr.data-key]="row.node.key"
207
483
  (keydown)="onKeydown($event, row)"
208
- (focus)="focusKey.set(row.node.key)"
484
+ (focusin)="onRowFocus($event, row.node.key)"
209
485
  >
210
486
  @for (column of columns(); track column.field; let first = $first) {
211
487
  <td [class]="'star-table__cell--' + (column.align ?? 'start')">
@@ -237,12 +513,18 @@ i0.ɵɵngDeclareClassMetadata({
237
513
  }
238
514
  </tr>
239
515
  }
516
+ @if (window().after > 0) {
517
+ <tr class="star-table__spacer" aria-hidden="true">
518
+ <td [attr.colspan]="columns().length" [style.height.px]="window().after"></td>
519
+ </tr>
520
+ }
240
521
  </tbody>
241
522
  </table>
242
523
  </div>
243
524
  `
244
525
  }]
245
526
  }],
527
+ ctorParameters: () => [],
246
528
  propDecorators: {
247
529
  nodes: [{
248
530
  type: i0.Input,
@@ -278,6 +560,38 @@ i0.ɵɵngDeclareClassMetadata({
278
560
  alias: "ariaLabel",
279
561
  required: false
280
562
  }]
563
+ }],
564
+ scrollHeight: [{
565
+ type: i0.Input,
566
+ args: [{
567
+ isSignal: true,
568
+ alias: "scrollHeight",
569
+ required: false
570
+ }]
571
+ }],
572
+ virtualScroll: [{
573
+ type: i0.Input,
574
+ args: [{
575
+ isSignal: true,
576
+ alias: "virtualScroll",
577
+ required: false
578
+ }]
579
+ }],
580
+ virtualItemSize: [{
581
+ type: i0.Input,
582
+ args: [{
583
+ isSignal: true,
584
+ alias: "virtualItemSize",
585
+ required: false
586
+ }]
587
+ }],
588
+ scroller: [{
589
+ type: i0.ViewChild,
590
+ args: ["scroller", { isSignal: true }]
591
+ }],
592
+ header: [{
593
+ type: i0.ViewChild,
594
+ args: ["header", { isSignal: true }]
281
595
  }]
282
596
  }
283
597
  });