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,255 @@
1
+ # injectVirtualizer
2
+
3
+ Headless windowing core: given a reactive item count, a size estimator, and a
4
+ scroll container, returns the slice of items currently visible (plus overscan),
5
+ the total scroll size, and imperative scroll/measure helpers. The consumer renders
6
+ the items with their own `@for` and applies the position transform — this primitive
7
+ owns no DOM.
8
+
9
+ Backed internally by `@tanstack/virtual-core`. SSR-safe: off-browser it returns
10
+ an empty window and the estimate-based total without touching `document`/`window`.
11
+
12
+ > Ships from the **`forty-cdk/virtualization`** secondary entry point — import every
13
+ > symbol below (`injectVirtualizer`, `ForVirtualViewport`, `ForVirtualFor`,
14
+ > `injectInfiniteScroll`, `ForTableVirtualized`) from `forty-cdk/virtualization`, not
15
+ > `forty-cdk`. This keeps `@tanstack/virtual-core` out of the main `forty-cdk` bundle for
16
+ > apps and routes that don't virtualize.
17
+
18
+ ## Ergonomic layer (`[forVirtualViewport]` + `*forVirtualFor`)
19
+
20
+ For the common "just virtualize this list" case, the optional Shape A layer wraps the manual
21
+ wiring: `[forVirtualViewport]` owns the scroll container, the total-size sizer, and the windowing
22
+ core, and `*forVirtualFor` renders the visible window with the position transform and
23
+ `aria-setsize` / `aria-posinset` applied for you.
24
+
25
+ ```html
26
+ <div forVirtualViewport [virtualCount]="rows().length" [estimateSize]="44" style="height: 400px">
27
+ <div *forVirtualFor="let row of rows(); let item = virtualItem">{{ row.label }}</div>
28
+ </div>
29
+ ```
30
+
31
+ ```ts
32
+ readonly rows = signal(Array.from({ length: 10000 }, (_, i) => ({ label: `Row ${i}` })));
33
+ ```
34
+
35
+ The viewport forces `overflow: auto` on its host; give it a fixed size (e.g. `height: 400px`).
36
+ `orientation`, `overscan`, and `getItemKey` are optional inputs on `[forVirtualViewport]`; set
37
+ `orientation` / `overscan` before first render (they are read once when the viewport initializes).
38
+ The template context exposes `row` (`$implicit`), `virtualItem`, `index`, and `count`. Do not set
39
+ `position` / `transform` on the row yourself — the directive owns them.
40
+
41
+ For full control (custom DOM, dynamic per-item measurement, a window/document scroller) use the
42
+ headless `injectVirtualizer` core directly, documented below.
43
+
44
+ ## Vertical list (fixed item heights)
45
+
46
+ ```html
47
+ <div #scroll style="overflow: auto; height: 400px">
48
+ <div [style.height.px]="v.totalSize()" style="position: relative">
49
+ @for (item of v.virtualItems(); track item.key) {
50
+ <div
51
+ [attr.data-index]="item.index"
52
+ [attr.aria-setsize]="items().length"
53
+ [attr.aria-posinset]="item.index + 1"
54
+ [style.position]="'absolute'"
55
+ [style.top.px]="item.start"
56
+ [style.height.px]="item.size"
57
+ [style.width]="'100%'"
58
+ >
59
+ {{ items()[item.index] }}
60
+ </div>
61
+ }
62
+ </div>
63
+ </div>
64
+ ```
65
+
66
+ ```ts
67
+ readonly items = signal(Array.from({ length: 10000 }, (_, i) => `Row ${i}`));
68
+ readonly scrollRef = viewChild<ElementRef<HTMLElement>>('scroll');
69
+ readonly scrollElement = computed(() => this.scrollRef()?.nativeElement ?? null);
70
+
71
+ readonly v = injectVirtualizer({
72
+ count: computed(() => this.items().length),
73
+ estimateSize: () => 40,
74
+ scrollElement: this.scrollElement,
75
+ });
76
+ ```
77
+
78
+ The spacer `div` (the one bound to `totalSize()`) is `position: relative` so the
79
+ absolutely positioned item slices stay inside the scroll container. Each item is
80
+ positioned with `top: item.start` instead of `translateY` so jsdom-based tests can
81
+ read the value without CSS layout; prefer `transform: translateY(item.start + 'px')
82
+ translateZ(0)` in production for GPU compositing.
83
+
84
+ ## Dynamic item heights (with `measureElement`)
85
+
86
+ When items have variable heights, query the rendered elements and feed each one to
87
+ `measureElement` so the virtualizer refines its estimates. The item element **must**
88
+ carry `[attr.data-index]="item.index"` so the virtualizer can look up which row the
89
+ element belongs to:
90
+
91
+ ```html
92
+ @for (item of v.virtualItems(); track item.key) {
93
+ <div
94
+ #row
95
+ [attr.data-index]="item.index"
96
+ [attr.aria-setsize]="items().length"
97
+ [attr.aria-posinset]="item.index + 1"
98
+ [style.position]="'absolute'"
99
+ [style.top.px]="item.start"
100
+ [style.width]="'100%'"
101
+ >
102
+ {{ items()[item.index] }}
103
+ </div>
104
+ }
105
+ ```
106
+
107
+ ```ts
108
+ readonly rows = viewChildren<ElementRef<HTMLElement>>('row');
109
+
110
+ constructor() {
111
+ afterEveryRender(() => {
112
+ for (const row of this.rows()) {
113
+ this.v.measureElement(row.nativeElement);
114
+ }
115
+ });
116
+ }
117
+ ```
118
+
119
+ ## Horizontal list
120
+
121
+ Set `orientation: 'horizontal'` and apply `translateX` instead of `translateY`.
122
+ The `totalSize()` drives the spacer's `width` rather than `height`:
123
+
124
+ ```html
125
+ <div #scroll style="overflow: auto; display: flex; width: 600px">
126
+ <div [style.width.px]="v.totalSize()" style="position: relative; height: 100%">
127
+ @for (item of v.virtualItems(); track item.key) {
128
+ <div
129
+ [attr.data-index]="item.index"
130
+ [style.position]="'absolute'"
131
+ [style.left.px]="item.start"
132
+ [style.width.px]="item.size"
133
+ [style.height]="'100%'"
134
+ >
135
+ {{ items()[item.index] }}
136
+ </div>
137
+ }
138
+ </div>
139
+ </div>
140
+ ```
141
+
142
+ ```ts
143
+ readonly v = injectVirtualizer({
144
+ count: computed(() => this.items().length),
145
+ estimateSize: () => 80,
146
+ scrollElement: this.scrollElement,
147
+ orientation: 'horizontal',
148
+ });
149
+ ```
150
+
151
+ ## Jumping to an item
152
+
153
+ ```ts
154
+ this.v.scrollToIndex(500, { align: 'start' });
155
+ ```
156
+
157
+ `align` accepts `'start'` | `'center'` | `'end'` | `'auto'` (default). `'auto'`
158
+ scrolls the minimum amount needed to bring the item into view.
159
+
160
+ ## Accessibility
161
+
162
+ Virtual lists render only a window of items, so screen readers see a shorter list
163
+ than the true total. Bind the full list size so assistive technology announces
164
+ the real count:
165
+
166
+ - `aria-setsize` — the total number of items in the full (non-windowed) list.
167
+ - `aria-posinset` — the 1-based position of the item in that full list
168
+ (`item.index + 1`).
169
+
170
+ ```html
171
+ <div [attr.aria-setsize]="items().length" [attr.aria-posinset]="item.index + 1"></div>
172
+ ```
173
+
174
+ ## Options
175
+
176
+ | Option | Type | Default | Description |
177
+ | --------------- | ------------------------------------- | ------------ | ------------------------------------------------------------------- |
178
+ | `count` | `Signal<number>` | required | Reactive total number of items. |
179
+ | `estimateSize` | `(index: number) => number` | required | Estimated pixel size along the scroll axis for the item at `index`. |
180
+ | `scrollElement` | `Signal<HTMLElement \| null>` | required | Reactive scroll container. |
181
+ | `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Scroll axis. |
182
+ | `overscan` | `number` | `5` | Extra items to render beyond the visible window on each side. |
183
+ | `getItemKey` | `(index: number) => string \| number` | `(i) => i` | Stable key per item; used by `@for (track item.key)`. |
184
+
185
+ ## Returned handle
186
+
187
+ | Member | Type | Description |
188
+ | ---------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
189
+ | `virtualItems` | `Signal<readonly VirtualItem[]>` | Items in the current visible window plus overscan. |
190
+ | `totalSize` | `Signal<number>` | Total scroll size in pixels (drives the spacer element). |
191
+ | `range` | `Signal<readonly [number, number]>` | The `[firstIndex, lastIndex + 1)` rendered window, `[0, 0]` when empty. Feeds a list primitive's `[visibleRange]`. |
192
+ | `scrollToIndex` | method | Scroll the container so the item at `index` is in view. |
193
+ | `scrollToOffset` | method | Scroll to an absolute pixel offset. |
194
+ | `measureElement` | method | Record the measured size of a rendered item element. |
195
+
196
+ ## Infinite scroll
197
+
198
+ Two shapes are available: a turnkey output on `[forVirtualViewport]` (Shape A) and a
199
+ composable `injectInfiniteScroll` core for manual wiring (Shape B).
200
+
201
+ ### Shape A — `(endReached)` output
202
+
203
+ Wire directly onto `[forVirtualViewport]`; the viewport builds the detector internally:
204
+
205
+ ```html
206
+ <div
207
+ forVirtualViewport
208
+ [virtualCount]="rows().length"
209
+ [estimateSize]="44"
210
+ (endReached)="loadMore()"
211
+ style="height: 400px"
212
+ >
213
+ <div *forVirtualFor="let row of rows(); let item = virtualItem">{{ row.label }}</div>
214
+ </div>
215
+ ```
216
+
217
+ ### Shape B — `injectInfiniteScroll`
218
+
219
+ Compose with the headless core when you need `pending` state or custom `threshold`/`disabled`.
220
+ The consumer owns the fetch and the data accumulation; the library decides _when_ to ask:
221
+
222
+ ```ts
223
+ readonly v = injectVirtualizer({ count: this.count, estimateSize: () => 40, scrollElement: this.scrollEl });
224
+
225
+ readonly loader = injectInfiniteScroll({
226
+ range: this.v.range,
227
+ count: this.count,
228
+ disabled: computed(() => !this.hasMore()),
229
+ onLoadMore: () => this.fetchNextPage(),
230
+ });
231
+ ```
232
+
233
+ The detector fires once per threshold crossing, is suppressed while the `onLoadMore` promise is
234
+ pending (`loader.pending()` reflects the in-flight state), and re-arms when `count` grows (a page
235
+ was appended). An empty `[0, 0]` window (including SSR off-browser) never fires. The consumer owns
236
+ the fetch, deduplication, and retry — Angular `resource()` / `httpResource()` are a natural fit.
237
+
238
+ | Option | Type | Default | Description |
239
+ | ------------ | ----------------------------------- | -------- | ----------------------------------------------------------------------- |
240
+ | `range` | `Signal<readonly [number, number]>` | required | The rendered window from `injectVirtualizer(...).range`. |
241
+ | `count` | `Signal<number>` | required | Reactive total number of currently-loaded items. |
242
+ | `threshold` | `number` | `5` | Fire when the window's last index is within this many items of `count`. |
243
+ | `disabled` | `Signal<boolean>` | — | When `true` the detector never fires. |
244
+ | `onLoadMore` | `() => void \| Promise<unknown>` | required | Called once per threshold crossing; returning a promise arms `pending`. |
245
+
246
+ ## Composing into a list primitive
247
+
248
+ `range` lets the windowing core plug directly into a list primitive's `[visibleRange]` input without the consumer re-deriving the window from `virtualItems()`. The primitive uses `[visibleRange]` to keep `aria-setsize` / `aria-posinset` and `aria-activedescendant` correct across row recycling — it tracks option data by absolute index so options scrolled out of view are still reachable by keyboard.
249
+
250
+ ```html
251
+ [totalCount]="filtered().length" [visibleRange]="v.range()"
252
+ (scrollToIndex)="v.scrollToIndex($event)"
253
+ ```
254
+
255
+ See the [Combobox README](../combobox/README.md#virtualization) for the complete worked example wiring `[forCombobox]` with `injectVirtualizer` over a 100k-item list.