forty-cdk 0.0.4 → 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.
@@ -0,0 +1,647 @@
1
+ import { isPlatformBrowser } from '@angular/common';
2
+ import * as i0 from '@angular/core';
3
+ import { inject, DestroyRef, afterNextRender, PLATFORM_ID, computed, signal, effect, linkedSignal, untracked, InjectionToken, ElementRef, Injector, input, output, runInInjectionContext, ChangeDetectionStrategy, Component, TemplateRef, ViewContainerRef, Directive, numberAttribute } from '@angular/core';
4
+ import { observeElementOffset, observeElementRect, elementScroll, Virtualizer } from '@tanstack/virtual-core';
5
+ import { FOR_TABLE_CONTEXT } from 'forty-cdk';
6
+
7
+ /**
8
+ * `afterNextRender` whose queued callback is cancelled on destroy, guarding the
9
+ * destroy-before-first-render hazard (a directive torn down between construction
10
+ * and the next render would otherwise still run the queued callback). It both
11
+ * `.destroy()`s the render ref from the destroy hook and re-checks a `destroyed`
12
+ * flag before invoking `fn`, covering the async and synchronous teardown orders.
13
+ *
14
+ * A standalone twin of the main entry point's
15
+ * `_internal/after-next-render-cancellable`: a secondary entry point cannot reach
16
+ * the main entry's internals across the package boundary, so the helper is
17
+ * mirrored here. Keep the two in sync; see that file for the full rationale.
18
+ *
19
+ * Must be called from an injection context. SSR-safe: `afterNextRender` never
20
+ * runs on the server, so the callback simply never fires.
21
+ */
22
+ function afterNextRenderCancellable(fn) {
23
+ const destroyRef = inject(DestroyRef);
24
+ let destroyed = false;
25
+ const ref = afterNextRender(() => {
26
+ if (destroyed)
27
+ return;
28
+ fn();
29
+ });
30
+ destroyRef.onDestroy(() => {
31
+ destroyed = true;
32
+ ref.destroy();
33
+ });
34
+ }
35
+
36
+ /** Default number of items rendered beyond the visible window on each side. */
37
+ const DEFAULT_OVERSCAN$1 = 5;
38
+ /**
39
+ * Sum of the estimated size of every item: `count` × the estimate when it's
40
+ * uniform, else the per-index estimator summed. Used as the SSR / pre-mount
41
+ * total before the core has measured any item, and shared with the ergonomic
42
+ * viewport layer so the estimate-total math lives in one place.
43
+ */
44
+ function estimateTotal(count, estimateSize) {
45
+ let total = 0;
46
+ for (let index = 0; index < count; index++) {
47
+ total += estimateSize(index);
48
+ }
49
+ return total;
50
+ }
51
+ function toVirtualItem(item) {
52
+ return {
53
+ index: item.index,
54
+ key: item.key,
55
+ start: item.start,
56
+ size: item.size,
57
+ };
58
+ }
59
+ /**
60
+ * Headless windowing core: given a reactive item count, a size estimator and a
61
+ * scroll container, returns the slice of items currently visible (plus
62
+ * overscan), the total scroll size, and imperative scroll/measure helpers. The
63
+ * consumer renders the items with their own `@for` and applies the position
64
+ * transform — this primitive owns no DOM.
65
+ *
66
+ * Backed by `@tanstack/virtual-core`. SSR-safe: off-browser it returns an empty
67
+ * window and the estimate-based total without touching `document`/`window`; the
68
+ * first real window is produced after the first browser render.
69
+ *
70
+ * Must be called from an injection context (a component/directive constructor
71
+ * or field initializer).
72
+ *
73
+ * @param options Reactive count, size estimator, scroll element and tuning.
74
+ * @returns A {@link ForVirtualizer} handle of signals + imperative methods.
75
+ */
76
+ function injectVirtualizer(options) {
77
+ if (!isPlatformBrowser(inject(PLATFORM_ID))) {
78
+ return {
79
+ virtualItems: signal([]).asReadonly(),
80
+ totalSize: computed(() => estimateTotal(options.count(), options.estimateSize)),
81
+ range: computed(() => [0, 0]),
82
+ scrollToIndex: () => undefined,
83
+ scrollToOffset: () => undefined,
84
+ measureElement: () => undefined,
85
+ };
86
+ }
87
+ const horizontal = (options.orientation ?? 'vertical') === 'horizontal';
88
+ const overscan = options.overscan ?? DEFAULT_OVERSCAN$1;
89
+ const notify = signal(0, { ...(ngDevMode ? { debugName: "notify" } : /* istanbul ignore next */ {}), equal: () => false });
90
+ const mounted = signal(false, /* @ts-ignore */
91
+ ...(ngDevMode ? [{ debugName: "mounted" }] : /* istanbul ignore next */ []));
92
+ const buildCoreOptions = (count, scrollElement) => ({
93
+ count,
94
+ getScrollElement: () => scrollElement,
95
+ estimateSize: options.estimateSize,
96
+ getItemKey: options.getItemKey,
97
+ overscan,
98
+ horizontal,
99
+ scrollToFn: elementScroll,
100
+ observeElementRect,
101
+ observeElementOffset,
102
+ onChange: () => notify.set(0),
103
+ });
104
+ const virtualizer = new Virtualizer(buildCoreOptions(options.count(), options.scrollElement()));
105
+ effect(() => {
106
+ virtualizer.setOptions(buildCoreOptions(options.count(), options.scrollElement()));
107
+ virtualizer._willUpdate();
108
+ notify.set(0);
109
+ });
110
+ let cleanup;
111
+ afterNextRenderCancellable(() => {
112
+ cleanup = virtualizer._didMount();
113
+ mounted.set(true);
114
+ });
115
+ inject(DestroyRef).onDestroy(() => cleanup?.());
116
+ const virtualItems = computed(() => {
117
+ notify();
118
+ if (!mounted())
119
+ return [];
120
+ return virtualizer.getVirtualItems().map(toVirtualItem);
121
+ }, /* @ts-ignore */
122
+ ...(ngDevMode ? [{ debugName: "virtualItems" }] : /* istanbul ignore next */ []));
123
+ const totalSize = computed(() => {
124
+ notify();
125
+ if (!mounted())
126
+ return estimateTotal(options.count(), options.estimateSize);
127
+ return virtualizer.getTotalSize();
128
+ }, /* @ts-ignore */
129
+ ...(ngDevMode ? [{ debugName: "totalSize" }] : /* istanbul ignore next */ []));
130
+ const range = computed(() => {
131
+ const items = virtualItems();
132
+ if (items.length === 0)
133
+ return [0, 0];
134
+ return [items[0].index, items[items.length - 1].index + 1];
135
+ }, /* @ts-ignore */
136
+ ...(ngDevMode ? [{ debugName: "range" }] : /* istanbul ignore next */ []));
137
+ return {
138
+ virtualItems,
139
+ totalSize,
140
+ range,
141
+ scrollToIndex: (index, scrollOptions) => virtualizer.scrollToIndex(index, scrollOptions),
142
+ scrollToOffset: (offset) => virtualizer.scrollToOffset(offset),
143
+ measureElement: (element) => virtualizer.measureElement(element),
144
+ };
145
+ }
146
+
147
+ const DEFAULT_THRESHOLD = 5;
148
+ /**
149
+ * Headless infinite-scroll detector: composes on top of any windowed list's
150
+ * `range` + `count` signals and fires `onLoadMore` once per threshold crossing,
151
+ * suppressing re-fire while a returned promise is pending and re-arming when
152
+ * `count` grows (a page was appended). It owns no DOM, adds no scroll listener —
153
+ * the trigger rides the existing reactive recompute — and is SSR-safe by
154
+ * construction: off-browser the window is `[0, 0]`, so it never fires.
155
+ *
156
+ * Must be called from an injection context (a component/directive constructor
157
+ * or field initializer).
158
+ *
159
+ * @param options Reactive `range` + `count`, an optional `threshold`/`disabled`,
160
+ * and the `onLoadMore` callback.
161
+ * @returns A {@link ForInfiniteScroll} handle exposing the `pending` signal.
162
+ */
163
+ function injectInfiniteScroll(options) {
164
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
165
+ const pending = signal(false, /* @ts-ignore */
166
+ ...(ngDevMode ? [{ debugName: "pending" }] : /* istanbul ignore next */ []));
167
+ const nearEnd = computed(() => {
168
+ if (options.disabled?.())
169
+ return false;
170
+ const [start, end] = options.range();
171
+ if (end <= start)
172
+ return false;
173
+ const total = options.count();
174
+ return total > 0 && end >= total - threshold;
175
+ }, /* @ts-ignore */
176
+ ...(ngDevMode ? [{ debugName: "nearEnd" }] : /* istanbul ignore next */ []));
177
+ const armed = linkedSignal({ ...(ngDevMode ? { debugName: "armed" } : /* istanbul ignore next */ {}), source: () => options.count(),
178
+ computation: () => true });
179
+ effect(() => {
180
+ options.count();
181
+ if (!nearEnd() || !untracked(armed))
182
+ return;
183
+ if (untracked(pending))
184
+ return;
185
+ armed.set(false);
186
+ const result = options.onLoadMore();
187
+ if (result instanceof Promise) {
188
+ pending.set(true);
189
+ void result.finally(() => pending.set(false));
190
+ }
191
+ });
192
+ return { pending: pending.asReadonly() };
193
+ }
194
+
195
+ /** DI token carrying the {@link ForVirtualViewportContext}. */
196
+ const FOR_VIRTUAL_VIEWPORT_CONTEXT = new InjectionToken('FOR_VIRTUAL_VIEWPORT_CONTEXT');
197
+ /**
198
+ * Resolve the enclosing viewport context, throwing a primitive-prefixed error
199
+ * when the piece is used outside a `[forVirtualViewport]`. Internal — never
200
+ * re-exported from the primitive barrel.
201
+ */
202
+ function injectVirtualViewportContext(consumer) {
203
+ const context = inject(FOR_VIRTUAL_VIEWPORT_CONTEXT, { optional: true });
204
+ if (!context) {
205
+ throw new Error(`[forty-cdk/virtualization] ${consumer} must be used inside a [forVirtualViewport].`);
206
+ }
207
+ return context;
208
+ }
209
+
210
+ /** Default estimated item size, in CSS pixels, when none is provided. */
211
+ const DEFAULT_ESTIMATE_SIZE = 50;
212
+ /** Default number of items rendered beyond the visible window on each side. */
213
+ const DEFAULT_OVERSCAN = 5;
214
+ /**
215
+ * Scroll viewport for the ergonomic virtualization layer. Decorate a fixed-size
216
+ * scroll container with `[forVirtualViewport]`, give it `[virtualCount]` and an
217
+ * `[estimateSize]`, and nest a single `*forVirtualFor` inside it — the viewport
218
+ * owns the scroll container, the total-size sizer, and the windowing core, so
219
+ * the consumer writes no manual spacer or position transform.
220
+ *
221
+ * Built on the headless {@link injectVirtualizer} core; for full manual control
222
+ * (custom DOM, dynamic measurement, window/document scroller) use that directly.
223
+ *
224
+ * The viewport forces `overflow: auto` on its host and renders a relatively
225
+ * positioned sizer whose main-axis size tracks `totalSize()`; `*forVirtualFor`
226
+ * projects its rows into that sizer and positions each one absolutely.
227
+ *
228
+ * `orientation` and `overscan` are read once when the viewport initializes;
229
+ * change them before first render, not at runtime.
230
+ */
231
+ class ForVirtualViewport {
232
+ #host = inject(ElementRef);
233
+ #injector = inject(Injector);
234
+ #scrollElement = signal(this.#host.nativeElement, /* @ts-ignore */
235
+ ...(ngDevMode ? [{ debugName: "#scrollElement" }] : /* istanbul ignore next */ []));
236
+ #virtualizer = signal(null, /* @ts-ignore */
237
+ ...(ngDevMode ? [{ debugName: "#virtualizer" }] : /* istanbul ignore next */ []));
238
+ /** Total number of items in the full list. */
239
+ virtualCount = input.required(/* @ts-ignore */
240
+ ...(ngDevMode ? [{ debugName: "virtualCount" }] : /* istanbul ignore next */ []));
241
+ /** Estimated item size in px along the scroll axis: a number or a per-index estimator. */
242
+ estimateSize = input(DEFAULT_ESTIMATE_SIZE, /* @ts-ignore */
243
+ ...(ngDevMode ? [{ debugName: "estimateSize" }] : /* istanbul ignore next */ []));
244
+ /** Scroll axis. Resolved once on init; runtime changes are not tracked by the core. */
245
+ orientation = input('vertical', /* @ts-ignore */
246
+ ...(ngDevMode ? [{ debugName: "orientation" }] : /* istanbul ignore next */ []));
247
+ /** Items rendered beyond the visible window on each side. Resolved once on init. */
248
+ overscan = input(DEFAULT_OVERSCAN, /* @ts-ignore */
249
+ ...(ngDevMode ? [{ debugName: "overscan" }] : /* istanbul ignore next */ []));
250
+ /** Stable key for the item at `index`. Defaults to the index. */
251
+ getItemKey = input(undefined, /* @ts-ignore */
252
+ ...(ngDevMode ? [{ debugName: "getItemKey" }] : /* istanbul ignore next */ []));
253
+ /**
254
+ * Emits when the rendered window comes within ~`overscan` items of the end of
255
+ * the list, signalling the consumer to load the next page. Built on
256
+ * {@link injectInfiniteScroll}; fires once per threshold crossing and re-arms
257
+ * when the bound count grows. The consumer owns the fetch (e.g. via `resource()`).
258
+ */
259
+ endReached = output();
260
+ /**
261
+ * The total number of items in the full (non-windowed) list — the
262
+ * {@link ForVirtualViewportContext.count} the nested `*forVirtualFor` reads.
263
+ * Aliases the `virtualCount` input signal directly (no wrapper node).
264
+ */
265
+ count = this.virtualCount;
266
+ #estimator = computed(() => {
267
+ const estimate = this.estimateSize();
268
+ return typeof estimate === 'function' ? estimate : () => estimate;
269
+ }, /* @ts-ignore */
270
+ ...(ngDevMode ? [{ debugName: "#estimator" }] : /* istanbul ignore next */ []));
271
+ #estimateTotal = computed(() => estimateTotal(this.count(), this.#estimator()), /* @ts-ignore */
272
+ ...(ngDevMode ? [{ debugName: "#estimateTotal" }] : /* istanbul ignore next */ []));
273
+ /** The items in the currently visible window plus overscan. */
274
+ virtualItems = computed(() => this.#virtualizer()?.virtualItems() ?? [], /* @ts-ignore */
275
+ ...(ngDevMode ? [{ debugName: "virtualItems" }] : /* istanbul ignore next */ []));
276
+ /** Total scroll size of all items, in pixels (drives the sizer). */
277
+ totalSize = computed(() => this.#virtualizer()?.totalSize() ?? this.#estimateTotal(), /* @ts-ignore */
278
+ ...(ngDevMode ? [{ debugName: "totalSize" }] : /* istanbul ignore next */ []));
279
+ #range = computed(() => this.#virtualizer()?.range() ?? [0, 0], /* @ts-ignore */
280
+ ...(ngDevMode ? [{ debugName: "#range" }] : /* istanbul ignore next */ []));
281
+ sizerWidth = computed(() => this.orientation() === 'horizontal' ? `${this.totalSize()}px` : '100%', /* @ts-ignore */
282
+ ...(ngDevMode ? [{ debugName: "sizerWidth" }] : /* istanbul ignore next */ []));
283
+ sizerHeight = computed(() => this.orientation() === 'horizontal' ? '100%' : `${this.totalSize()}px`, /* @ts-ignore */
284
+ ...(ngDevMode ? [{ debugName: "sizerHeight" }] : /* istanbul ignore next */ []));
285
+ ngOnInit() {
286
+ runInInjectionContext(this.#injector, () => {
287
+ this.#virtualizer.set(injectVirtualizer({
288
+ count: this.count,
289
+ estimateSize: (index) => this.#estimator()(index),
290
+ scrollElement: this.#scrollElement,
291
+ orientation: this.orientation(),
292
+ overscan: this.overscan(),
293
+ getItemKey: this.getItemKey(),
294
+ }));
295
+ injectInfiniteScroll({
296
+ range: this.#range,
297
+ count: this.count,
298
+ onLoadMore: () => this.endReached.emit(),
299
+ });
300
+ });
301
+ }
302
+ /** Scroll the container so the item at `index` is in view. No-op until initialized. */
303
+ scrollToIndex(index, options) {
304
+ this.#virtualizer()?.scrollToIndex(index, options);
305
+ }
306
+ /** Scroll the container to an absolute pixel offset. No-op until initialized. */
307
+ scrollToOffset(offset) {
308
+ this.#virtualizer()?.scrollToOffset(offset);
309
+ }
310
+ /** Record the measured size of a rendered item element. No-op until initialized. */
311
+ measureElement(element) {
312
+ this.#virtualizer()?.measureElement(element);
313
+ }
314
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForVirtualViewport, deps: [], target: i0.ɵɵFactoryTarget.Component });
315
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.2", type: ForVirtualViewport, isStandalone: true, selector: "for-virtual-viewport, [forVirtualViewport]", inputs: { virtualCount: { classPropertyName: "virtualCount", publicName: "virtualCount", isSignal: true, isRequired: true, transformFunction: null }, estimateSize: { classPropertyName: "estimateSize", publicName: "estimateSize", isSignal: true, isRequired: false, transformFunction: null }, orientation: { classPropertyName: "orientation", publicName: "orientation", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", isSignal: true, isRequired: false, transformFunction: null }, getItemKey: { classPropertyName: "getItemKey", publicName: "getItemKey", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { endReached: "endReached" }, host: { properties: { "style.overflow": "\"auto\"" } }, providers: [{ provide: FOR_VIRTUAL_VIEWPORT_CONTEXT, useExisting: ForVirtualViewport }], exportAs: ["forVirtualViewport"], ngImport: i0, template: `
316
+ <div style="position: relative" [style.width]="sizerWidth()" [style.height]="sizerHeight()">
317
+ <ng-content />
318
+ </div>
319
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
320
+ }
321
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForVirtualViewport, decorators: [{
322
+ type: Component,
323
+ args: [{
324
+ selector: 'for-virtual-viewport, [forVirtualViewport]',
325
+ exportAs: 'forVirtualViewport',
326
+ changeDetection: ChangeDetectionStrategy.OnPush,
327
+ providers: [{ provide: FOR_VIRTUAL_VIEWPORT_CONTEXT, useExisting: ForVirtualViewport }],
328
+ host: {
329
+ '[style.overflow]': '"auto"',
330
+ },
331
+ template: `
332
+ <div style="position: relative" [style.width]="sizerWidth()" [style.height]="sizerHeight()">
333
+ <ng-content />
334
+ </div>
335
+ `,
336
+ }]
337
+ }], propDecorators: { virtualCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "virtualCount", required: true }] }], estimateSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "estimateSize", required: false }] }], orientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "orientation", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], getItemKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "getItemKey", required: false }] }], endReached: [{ type: i0.Output, args: ["endReached"] }] } });
338
+
339
+ /**
340
+ * Structural directive that renders only the visible window of a list inside a
341
+ * `[forVirtualViewport]`. Pass the full data array; the directive iterates the
342
+ * viewport's `virtualItems()`, exposes each row plus its `virtualItem` to the
343
+ * template, positions it absolutely (so the consumer writes no transform), and
344
+ * binds `aria-setsize` (true total) / `aria-posinset` (`index + 1`) so screen
345
+ * readers announce the real list size.
346
+ *
347
+ * ```html
348
+ * <div forVirtualViewport [virtualCount]="rows().length" [estimateSize]="44">
349
+ * <div *forVirtualFor="let row of rows(); let item = virtualItem">{{ row.label }}</div>
350
+ * </div>
351
+ * ```
352
+ */
353
+ class ForVirtualFor {
354
+ #template = inject(TemplateRef);
355
+ #viewContainer = inject(ViewContainerRef);
356
+ #viewport = injectVirtualViewportContext('ForVirtualFor');
357
+ #views = new Map();
358
+ /** The full list of items to virtualize. */
359
+ forVirtualForOf = input.required({ ...(ngDevMode ? { debugName: "forVirtualForOf" } : /* istanbul ignore next */ {}), alias: 'forVirtualForOf' });
360
+ constructor() {
361
+ effect(() => this.#render());
362
+ }
363
+ /** Narrows the template context type for `let row of …` strict template checking. */
364
+ static ngTemplateContextGuard(_directive, _context) {
365
+ return true;
366
+ }
367
+ #render() {
368
+ const items = this.#viewport.virtualItems();
369
+ const data = this.forVirtualForOf();
370
+ const count = this.#viewport.count();
371
+ const horizontal = this.#viewport.orientation() === 'horizontal';
372
+ const seen = new Set();
373
+ items.forEach((item, position) => {
374
+ seen.add(item.key);
375
+ let view = this.#views.get(item.key);
376
+ if (!view) {
377
+ view = this.#viewContainer.createEmbeddedView(this.#template, { $implicit: data[item.index], virtualItem: item, index: item.index, count }, position);
378
+ this.#views.set(item.key, view);
379
+ }
380
+ else {
381
+ if (this.#viewContainer.indexOf(view) !== position) {
382
+ this.#viewContainer.move(view, position);
383
+ }
384
+ view.context.$implicit = data[item.index];
385
+ view.context.virtualItem = item;
386
+ view.context.index = item.index;
387
+ view.context.count = count;
388
+ view.markForCheck();
389
+ }
390
+ this.#applyLayout(view, item, count, horizontal);
391
+ });
392
+ for (const [key, view] of this.#views) {
393
+ if (seen.has(key)) {
394
+ continue;
395
+ }
396
+ const index = this.#viewContainer.indexOf(view);
397
+ if (index >= 0) {
398
+ this.#viewContainer.remove(index);
399
+ }
400
+ this.#views.delete(key);
401
+ }
402
+ }
403
+ #applyLayout(view, item, count, horizontal) {
404
+ const node = view.rootNodes[0];
405
+ if (!(node instanceof HTMLElement)) {
406
+ return;
407
+ }
408
+ node.style.position = 'absolute';
409
+ node.style.top = '0';
410
+ node.style.left = '0';
411
+ if (horizontal) {
412
+ node.style.height = '100%';
413
+ node.style.transform = `translateX(${item.start}px)`;
414
+ }
415
+ else {
416
+ node.style.width = '100%';
417
+ node.style.transform = `translateY(${item.start}px)`;
418
+ }
419
+ node.setAttribute('data-index', String(item.index));
420
+ node.setAttribute('aria-setsize', String(count));
421
+ node.setAttribute('aria-posinset', String(item.index + 1));
422
+ }
423
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForVirtualFor, deps: [], target: i0.ɵɵFactoryTarget.Directive });
424
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: ForVirtualFor, isStandalone: true, selector: "[forVirtualFor][forVirtualForOf]", inputs: { forVirtualForOf: { classPropertyName: "forVirtualForOf", publicName: "forVirtualForOf", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
425
+ }
426
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForVirtualFor, decorators: [{
427
+ type: Directive,
428
+ args: [{
429
+ selector: '[forVirtualFor][forVirtualForOf]',
430
+ }]
431
+ }], ctorParameters: () => [], propDecorators: { forVirtualForOf: [{ type: i0.Input, args: [{ isSignal: true, alias: "forVirtualForOf", required: true }] }] } });
432
+
433
+ /**
434
+ * Cross-window keyboard-navigation bridge for a virtualized `[forTable]` grid,
435
+ * owned by `[forTableVirtualized]`. The grid keeps roving-tabindex and renders
436
+ * only a window of rows, so a navigation target can land on a row that is not
437
+ * currently mounted. This bridge resolves that:
438
+ *
439
+ * - **Move** — `navigateTo(row, col)` focuses the cell at the absolute
440
+ * `(row, col)` when its row is already rendered; otherwise it stashes the
441
+ * target and calls `scrollToRow(row)` to mount it.
442
+ * - **Resolve** — once the freshly-mounted row registers, the companion's bridge
443
+ * effect calls `tryResolvePending`, which moves roving focus onto the target
444
+ * cell (its `(focus)` handler promotes it to the active roving cell) and clears
445
+ * the pending target.
446
+ *
447
+ * Mirrors the 1D `ListboxVirtualizedNavigator` precedent, adapted to the table's
448
+ * roving + focused-row-retention model. Internal — not re-exported from
449
+ * `table/index.ts` or `public-api.ts`.
450
+ */
451
+ class TableVirtualizedNavigator {
452
+ #deps;
453
+ #pending = signal(null, /* @ts-ignore */
454
+ ...(ngDevMode ? [{ debugName: "#pending" }] : /* istanbul ignore next */ []));
455
+ constructor(deps) {
456
+ this.#deps = deps;
457
+ }
458
+ /**
459
+ * Move roving focus to the data cell at the absolute `(row, col)`. When the
460
+ * target row is already rendered, focuses immediately. Otherwise stashes the
461
+ * target and scrolls it into the window; the bridge effect resolves it once
462
+ * the row mounts.
463
+ */
464
+ navigateTo(row, col) {
465
+ if (this.#focusCell(row, col)) {
466
+ this.#pending.set(null);
467
+ return;
468
+ }
469
+ this.#pending.set({ row, col });
470
+ this.#deps.scrollToRow(row);
471
+ }
472
+ /** Scroll the virtualizer so the row at the absolute `index` is in the window. */
473
+ scrollToRow(index) {
474
+ this.#deps.scrollToRow(index);
475
+ }
476
+ /**
477
+ * Resolve a pending cross-window navigation: once the row carrying the pending
478
+ * absolute index mounts, focus its cell and clear the pending target. Returns
479
+ * `true` when a pending request was resolved, `false` otherwise. Called from
480
+ * the companion's bridge effect whenever the rendered rows change.
481
+ */
482
+ tryResolvePending() {
483
+ const pending = this.#pending();
484
+ if (pending === null) {
485
+ return false;
486
+ }
487
+ if (this.#focusCell(pending.row, pending.col)) {
488
+ this.#pending.set(null);
489
+ return true;
490
+ }
491
+ return false;
492
+ }
493
+ #focusCell(row, col) {
494
+ const handle = this.#deps.rows().find((r) => r.virtualIndex() === row);
495
+ if (!handle) {
496
+ return false;
497
+ }
498
+ const cells = handle.cells();
499
+ const cell = cells[col] ?? cells[cells.length - 1];
500
+ if (!cell) {
501
+ return false;
502
+ }
503
+ cell.host.focus();
504
+ return true;
505
+ }
506
+ }
507
+
508
+ function injectTableContext() {
509
+ const ctx = inject(FOR_TABLE_CONTEXT, { optional: true });
510
+ if (!ctx) {
511
+ throw new Error('[forty-cdk/table] ForTableVirtualized must be used inside a [forTable] element.');
512
+ }
513
+ return ctx;
514
+ }
515
+ /**
516
+ * Opt-in row-virtualization companion for `[forTable]` in `<div role>` grid mode. Place it on the
517
+ * same element as `[forTable]`; it builds the windowing core from the table's `[rowCount]` and
518
+ * exposes the visible window for the consumer to render with their own `@for` + position transform.
519
+ *
520
+ * Tree-shakeable: `ForTable` never imports the virtualization core — only consumers that import
521
+ * `ForTableVirtualized` bundle `@tanstack/virtual-core`.
522
+ *
523
+ * The focused row is kept mounted even when scrolled out of the window so the roving-focused
524
+ * `gridcell` is never unmounted. SSR-safe: off-browser the window is empty and
525
+ * `totalSize` is the estimate-based total.
526
+ *
527
+ * Also drives cross-window keyboard navigation: when an Arrow / Page / Ctrl+Home / Ctrl+End grid
528
+ * action resolves a row outside the rendered window, it scrolls that row into view and moves roving
529
+ * focus onto the target cell once it mounts (preserving the current column). `ForTable` stays
530
+ * unaware of virtualization — it delegates the row-crossing move through the table context.
531
+ */
532
+ class ForTableVirtualized {
533
+ #ctx = injectTableContext();
534
+ #rootEl = inject(ElementRef).nativeElement;
535
+ /** Estimated row size in px along the scroll axis (the fixed-size fast path). Read for the size estimate. */
536
+ estimateRowSize = input(44, { ...(ngDevMode ? { debugName: "estimateRowSize" } : /* istanbul ignore next */ {}), transform: numberAttribute });
537
+ /**
538
+ * Scroll container. Defaults to the table root element (the scroll container in `<div>` grid
539
+ * mode). Bind it explicitly when the scroll container is an **ancestor** of the table — e.g. an
540
+ * app-shell viewport that scrolls projected content — since the table cannot inject an ancestor
541
+ * it does not own. A design-system wrapper can re-expose or rename this input through
542
+ * `hostDirectives` input aliasing (`inputs: ['scrollElement: scrollContainer']`) with no bridging
543
+ * effect required.
544
+ */
545
+ scrollElement = input(null, /* @ts-ignore */
546
+ ...(ngDevMode ? [{ debugName: "scrollElement" }] : /* istanbul ignore next */ []));
547
+ #scrollElement = computed(() => this.scrollElement() ?? this.#rootEl, /* @ts-ignore */
548
+ ...(ngDevMode ? [{ debugName: "#scrollElement" }] : /* istanbul ignore next */ []));
549
+ #virtualizer = injectVirtualizer({
550
+ count: computed(() => this.#ctx.rowCount() ?? 0),
551
+ estimateSize: () => this.estimateRowSize(),
552
+ scrollElement: this.#scrollElement,
553
+ });
554
+ #navigator = new TableVirtualizedNavigator({
555
+ rows: this.#ctx.rows,
556
+ scrollToRow: (index) => this.scrollToRow(index),
557
+ });
558
+ constructor() {
559
+ this.#ctx.registerVirtualNavigation(this.#navigator);
560
+ inject(DestroyRef).onDestroy(() => this.#ctx.registerVirtualNavigation(null));
561
+ effect(() => {
562
+ this.#ctx.rows();
563
+ this.#navigator.tryResolvePending();
564
+ });
565
+ }
566
+ /**
567
+ * The rows in the visible window plus overscan, augmented to always include the focused row and
568
+ * the row being reordered even when they are scrolled out of view (so the roving-focused cell
569
+ * stays mounted, and a pointer reorder drag never unmounts the lifted row). Render these with
570
+ * `@for (vrow of v.virtualRows(); track vrow.index)` and position each row absolutely with
571
+ * `transform: translateY(vrow.start + 'px')`. Bind each row's `[virtualIndex]="vrow.index"`.
572
+ */
573
+ virtualRows = computed(() => {
574
+ const items = this.#virtualizer.virtualItems();
575
+ const retain = new Set();
576
+ const focused = this.#ctx.focusedRowIndex();
577
+ if (focused !== null) {
578
+ retain.add(focused);
579
+ }
580
+ const reordering = this.#ctx.reorderingRowIndex();
581
+ if (reordering !== null) {
582
+ retain.add(reordering);
583
+ }
584
+ for (const it of items) {
585
+ retain.delete(it.index);
586
+ }
587
+ if (retain.size === 0) {
588
+ return items;
589
+ }
590
+ const size = this.estimateRowSize();
591
+ const retained = [...retain].map((index) => ({
592
+ index,
593
+ key: index,
594
+ start: index * size,
595
+ size,
596
+ }));
597
+ return [...items, ...retained].sort((a, b) => a.index - b.index);
598
+ }, /* @ts-ignore */
599
+ ...(ngDevMode ? [{ debugName: "virtualRows" }] : /* istanbul ignore next */ []));
600
+ /** Total scroll size of all rows in px. Bind to the body container's height to size the scroll range. */
601
+ totalSize = this.#virtualizer.totalSize;
602
+ /**
603
+ * The rendered window as the inclusive-exclusive `[firstIndex, lastIndex + 1)` index range,
604
+ * sourced from the underlying virtualizer (the true window) — not from {@link virtualRows},
605
+ * which is augmented with the focused / reordering rows. So a row retained out of the window
606
+ * never widens this range. Plugs straight into `injectInfiniteScroll({ range, count, onLoadMore })`.
607
+ */
608
+ range = this.#virtualizer.range;
609
+ /**
610
+ * Scroll the container so the row at `index` is in view. Cross-window keyboard
611
+ * navigation calls this internally; consumers may also call it to scroll
612
+ * programmatically.
613
+ */
614
+ scrollToRow(index, options) {
615
+ this.#virtualizer.scrollToIndex(index, options);
616
+ }
617
+ /** Record the measured size of a rendered row element (dynamic / measured row heights). */
618
+ measureRow(element) {
619
+ this.#virtualizer.measureElement(element);
620
+ }
621
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForTableVirtualized, deps: [], target: i0.ɵɵFactoryTarget.Directive });
622
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: ForTableVirtualized, isStandalone: true, selector: "[forTableVirtualized]", inputs: { estimateRowSize: { classPropertyName: "estimateRowSize", publicName: "estimateRowSize", isSignal: true, isRequired: false, transformFunction: null }, scrollElement: { classPropertyName: "scrollElement", publicName: "scrollElement", isSignal: true, isRequired: false, transformFunction: null } }, exportAs: ["forTableVirtualized"], ngImport: i0 });
623
+ }
624
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForTableVirtualized, decorators: [{
625
+ type: Directive,
626
+ args: [{
627
+ selector: '[forTableVirtualized]',
628
+ exportAs: 'forTableVirtualized',
629
+ }]
630
+ }], ctorParameters: () => [], propDecorators: { estimateRowSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "estimateRowSize", required: false }] }], scrollElement: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollElement", required: false }] }] } });
631
+
632
+ /*
633
+ * Public API surface of forty-cdk/virtualization.
634
+ *
635
+ * Virtualization (the windowing core, the Shape A viewport directives,
636
+ * infinite-scroll, and the `[forTableVirtualized]` companion) ships from this
637
+ * secondary entry point so that `@tanstack/virtual-core` is isolated to its own
638
+ * bundle chunk: only consumers importing from `forty-cdk/virtualization` pull
639
+ * it in, even when another lazy route in the same app virtualizes.
640
+ */
641
+
642
+ /**
643
+ * Generated bundle index. Do not edit.
644
+ */
645
+
646
+ export { FOR_VIRTUAL_VIEWPORT_CONTEXT, ForTableVirtualized, ForVirtualFor, ForVirtualViewport, injectInfiniteScroll, injectVirtualizer };
647
+ //# sourceMappingURL=forty-cdk-virtualization.mjs.map