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,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.
|