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.
- package/LICENSE +21 -21
- package/README.md +2 -1
- package/fesm2022/forty-cdk-internationalized-date.mjs +2 -2
- package/fesm2022/forty-cdk-internationalized-date.mjs.map +1 -1
- package/fesm2022/forty-cdk-virtualization.mjs +647 -0
- package/fesm2022/forty-cdk-virtualization.mjs.map +1 -0
- package/fesm2022/forty-cdk.mjs +8312 -6096
- package/fesm2022/forty-cdk.mjs.map +1 -1
- package/internationalized-date/README.md +23 -23
- package/package.json +5 -1
- package/types/forty-cdk-internationalized-date.d.ts +2 -2
- package/types/forty-cdk-virtualization.d.ts +305 -0
- package/types/forty-cdk.d.ts +1792 -771
- package/virtualization/README.md +255 -0
|
@@ -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
|