@jarenjs/collection 0.83.2
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/ARCHITECTURE.md +10 -0
- package/README.md +31 -0
- package/dist/types/collection.d.ts +474 -0
- package/dist/types/component/index.d.ts +1822 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/interaction.d.ts +92 -0
- package/docs/COLLECTION.md +110 -0
- package/docs/MEASUREMENTS.md +62 -0
- package/package.json +59 -0
- package/src/collection.js +134 -0
- package/src/component/index.js +243 -0
- package/src/index.js +4 -0
- package/src/interaction.js +112 -0
- package/styles/collection.css +10 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Identity-based interaction. Range intent stores endpoint keys in a declared query/snapshot. */
|
|
2
|
+
/** @param {any} options */
|
|
3
|
+
export declare function createCollectionInteraction(options: any): {
|
|
4
|
+
state: () => {
|
|
5
|
+
focus: any;
|
|
6
|
+
pending: any;
|
|
7
|
+
selection: {
|
|
8
|
+
mode: string;
|
|
9
|
+
keys: never[];
|
|
10
|
+
ranges: never[];
|
|
11
|
+
exclusions: never[];
|
|
12
|
+
query: any;
|
|
13
|
+
snapshot: any;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
selected: (key: any) => boolean;
|
|
17
|
+
focusIndex: (index: any, column?: number) => {
|
|
18
|
+
state: string;
|
|
19
|
+
reason: string;
|
|
20
|
+
focus?: undefined;
|
|
21
|
+
index?: undefined;
|
|
22
|
+
column?: undefined;
|
|
23
|
+
} | {
|
|
24
|
+
reason?: undefined;
|
|
25
|
+
state: string;
|
|
26
|
+
focus: null;
|
|
27
|
+
index?: undefined;
|
|
28
|
+
column?: undefined;
|
|
29
|
+
} | {
|
|
30
|
+
reason?: undefined;
|
|
31
|
+
focus?: undefined;
|
|
32
|
+
state: string;
|
|
33
|
+
index: any;
|
|
34
|
+
column: number;
|
|
35
|
+
} | {
|
|
36
|
+
reason?: undefined;
|
|
37
|
+
focus?: undefined;
|
|
38
|
+
key: string;
|
|
39
|
+
index: any;
|
|
40
|
+
column: number;
|
|
41
|
+
state: string;
|
|
42
|
+
};
|
|
43
|
+
cancelPending(): void;
|
|
44
|
+
update(next: any): {
|
|
45
|
+
state: string;
|
|
46
|
+
reason: string;
|
|
47
|
+
focus?: undefined;
|
|
48
|
+
index?: undefined;
|
|
49
|
+
column?: undefined;
|
|
50
|
+
} | {
|
|
51
|
+
reason?: undefined;
|
|
52
|
+
state: string;
|
|
53
|
+
focus: null;
|
|
54
|
+
index?: undefined;
|
|
55
|
+
column?: undefined;
|
|
56
|
+
} | {
|
|
57
|
+
reason?: undefined;
|
|
58
|
+
focus?: undefined;
|
|
59
|
+
state: string;
|
|
60
|
+
index: any;
|
|
61
|
+
column: number;
|
|
62
|
+
} | {
|
|
63
|
+
state: string;
|
|
64
|
+
};
|
|
65
|
+
toggle: (key: any) => {
|
|
66
|
+
state: string;
|
|
67
|
+
reason: string;
|
|
68
|
+
} | {
|
|
69
|
+
reason?: undefined;
|
|
70
|
+
state: string;
|
|
71
|
+
};
|
|
72
|
+
selectAll(): void;
|
|
73
|
+
clear(): void;
|
|
74
|
+
restore(intent: any): {
|
|
75
|
+
state: string;
|
|
76
|
+
reason: string;
|
|
77
|
+
} | {
|
|
78
|
+
reason?: undefined;
|
|
79
|
+
state: string;
|
|
80
|
+
};
|
|
81
|
+
key(event: any): {
|
|
82
|
+
state: string;
|
|
83
|
+
reason: string;
|
|
84
|
+
} | {
|
|
85
|
+
reason?: undefined;
|
|
86
|
+
state: string;
|
|
87
|
+
} | {
|
|
88
|
+
state: string;
|
|
89
|
+
key: any;
|
|
90
|
+
column: any;
|
|
91
|
+
};
|
|
92
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Collection contract
|
|
2
|
+
|
|
3
|
+
## Engine and rendering
|
|
4
|
+
|
|
5
|
+
`createCollection(options)` from `@jarenjs/collection` needs `count`, `keyAt(index)`,
|
|
6
|
+
`getItem(index)` and `renderCell(row,column,index)`. Keys are unique stable strings;
|
|
7
|
+
`null` denotes an unloaded placeholder. Duplicate loaded keys throw `TypeError`.
|
|
8
|
+
Accessors visit only the credited viewport and pins. The engine imports core/view
|
|
9
|
+
only and has no DOM, app, database or worker import.
|
|
10
|
+
|
|
11
|
+
Geometry options include `rowSize` (44), `columnSize` (160), `columnCount` (1),
|
|
12
|
+
`overscan` (2), `columnOverscan` (1), `maxRows` (128), `maxColumns` (32) and
|
|
13
|
+
`maxCells` (840). Row and column windows are independent. `rowPins`, `columnPins`,
|
|
14
|
+
`rowPinBudget` (2) and `columnPinBudget` (2) include focus pins in the same limits.
|
|
15
|
+
Pinned rows/columns remain at the leading viewport edge. With base windows R/C,
|
|
16
|
+
row/column mounts are at most R+rowPins and C+columnPins; all cells must also fit
|
|
17
|
+
`maxCells`. Credit exhaustion returns an empty explicit refusal, never a partially
|
|
18
|
+
rendered success. Hidden viewports have no rows, cells or focus pins.
|
|
19
|
+
|
|
20
|
+
The row and column axes independently accept `maxMeasurements`, `measurementBytes`,
|
|
21
|
+
`maxColumnMeasurements` and `columnMeasurementBytes`. Eviction reverts a size to
|
|
22
|
+
its estimate. `measure(index,key,size,axis='row')` preserves the stored keyed row
|
|
23
|
+
anchor, including compensation for evicted measurements. `update(next)` reindexes
|
|
24
|
+
retained measurements via `indexOf(key)`; it must run after the host changes source
|
|
25
|
+
order. Changing `rowSize` or `columnSize` invalidates corresponding measurements.
|
|
26
|
+
|
|
27
|
+
`viewport({top,left,width,height})`, `layout()`, `view(interaction,id)`, `stats()`,
|
|
28
|
+
`position()`, `options()`, `pin(rows,columns)`, `snapshot()`, `restore(anchor)` and
|
|
29
|
+
idempotent `dispose()` form the controller API. `scrollToOffset`, `scrollToIndex`
|
|
30
|
+
and `scrollToKey` return outcomes; missing key lookup returns `unsupported-seek`.
|
|
31
|
+
The optional column argument of `scrollToIndex` scrolls both axes. Indices locate
|
|
32
|
+
rows in the current query and are never persisted as entity identity. A missing
|
|
33
|
+
anchor key falls back to its clamped previous position; a changed query starts at
|
|
34
|
+
zero. A new snapshot re-resolves the stable key in the same query.
|
|
35
|
+
|
|
36
|
+
## DOM ownership and accessibility
|
|
37
|
+
|
|
38
|
+
Import `mountCollection`, `createCollectionWidget` and `mountProviderCollection`
|
|
39
|
+
from `@jarenjs/collection/component`, and link `@jarenjs/collection/styles/collection.css`.
|
|
40
|
+
`mountCollection(host, options)` owns its element, scroll listener, keyboard/click/
|
|
41
|
+
focus/composition handlers, resize observers and frames. `requestFrame`,
|
|
42
|
+
`cancelFrame` and `observe(element, callback) => unsubscribe` can be injected by the
|
|
43
|
+
host. `dispose` is adapted to the existing widget `unmount` hook, preserving the
|
|
44
|
+
renderer lifecycle. Failed mounts clean acquired resources too.
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
import { createCollectionWidget } from '@jarenjs/collection/component';
|
|
48
|
+
const widgets = { collection: createCollectionWidget({
|
|
49
|
+
count: rows.length, keyAt: (i) => rows[i].id, indexOf: (key) => index.get(key) ?? -1,
|
|
50
|
+
getItem: (i) => rows[i], renderCell: (row, column) => columns[column].render(row),
|
|
51
|
+
columnCount: columns.length, label: 'Catalog'
|
|
52
|
+
}) };
|
|
53
|
+
const vnode = ['jaren-widget', { name: 'collection', props: { height: 440 } }];
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The DOM adapter's conservative `maxExtent` defaults to 8,000,000 CSS pixels per
|
|
57
|
+
axis; larger extents return `error / scroll-extent` before reaching CSS layout.
|
|
58
|
+
The pure engine can compute larger ranges; this is an explicit browser refusal.
|
|
59
|
+
`direction: 'rtl'` normalizes negative browser scroll offsets and aligns columns
|
|
60
|
+
with logical inline positions. `height` controls the viewport; its host controls
|
|
61
|
+
width. `measured:true` observes content; `measureRow(element,index)` can supply
|
|
62
|
+
host-specific height measurement. The default measures the tallest cell content
|
|
63
|
+
plus cell padding. `maxMeasurementWork` (64) bounds measured rows per pass; resize
|
|
64
|
+
notifications coalesce into one frame. The same keyed renderer handles controlled
|
|
65
|
+
inputs, so scroll and measurement patches preserve node identity and selection.
|
|
66
|
+
|
|
67
|
+
`role` is `grid` by default or `listbox`. Grid cells carry logical row/column
|
|
68
|
+
indices; list options carry logical position/set size. `totalKnown:false` exposes
|
|
69
|
+
unknown total as -1 rather than loaded count. `label` names the collection.
|
|
70
|
+
`createCollectionInteraction(options)` is the headless counterpart: arrows,
|
|
71
|
+
Home/End (Ctrl for the whole grid), PageUp/Down, Space selection, Enter activation
|
|
72
|
+
and Escape return focus. Arrow direction follows RTL. Editing targets and
|
|
73
|
+
composition bypass collection shortcuts. Browser-native editing keys retain their
|
|
74
|
+
normal caret behavior.
|
|
75
|
+
|
|
76
|
+
`focusIndex` realizes a loaded key or records a pending transient index while
|
|
77
|
+
retaining existing focus. The DOM exposes `aria-activedescendant` only after the
|
|
78
|
+
referenced element exists. Loading exposes `aria-busy`; source refusal clears
|
|
79
|
+
pending realization. `removedKeys` tells interaction to resolve a removal fallback.
|
|
80
|
+
`onRealize`, `onActivate`, `returnFocus`, `onIntent` and `onChange` are host hooks;
|
|
81
|
+
only bounded observations and JSON intent need enter app state.
|
|
82
|
+
|
|
83
|
+
Selection contains stable `keys`, or query/snapshot-scoped `mode:'all'` with
|
|
84
|
+
`exclusions`, or one range with stable `fromKey`/`toKey` endpoints. Shift ranges
|
|
85
|
+
survive asynchronous endpoint realization. `maxSelectedKeys` (4096) bounds explicit
|
|
86
|
+
keys/exclusions. `state`, `selected`, `toggle`, `selectAll`, `clear`, `restore`,
|
|
87
|
+
`cancelPending` and `update` expose the model. Range membership for rendered rows
|
|
88
|
+
uses the host's current key lookup; exports resolve endpoints over the full ordered
|
|
89
|
+
snapshot. Key selection survives eviction and query changes; scoped all/range
|
|
90
|
+
selection cannot be exported against another identity. Authoritative writes must
|
|
91
|
+
still recheck membership/revision.
|
|
92
|
+
|
|
93
|
+
## Data and complete output
|
|
94
|
+
|
|
95
|
+
`mountProviderCollection(host,coordinator,options)` uses exactly the same controller
|
|
96
|
+
and interaction. The host injects `createCollectionCoordinator(provider)` from
|
|
97
|
+
`@jarenjs/app`. The component requests missing visible ranges, publishes loading/
|
|
98
|
+
refusal state, and preserves edit pages within the coordinator's existing credits.
|
|
99
|
+
Eviction cannot destroy an active editor; if its pinned page prevents admission,
|
|
100
|
+
the coordinator returns `pinned-page-credits` until the host releases the pin.
|
|
101
|
+
|
|
102
|
+
The mounted provider handle exposes `mounted`, `coordinator`, `next`, `snapshot`,
|
|
103
|
+
`output`, `print` and async idempotent `dispose`. `next` is the continuation
|
|
104
|
+
operation for unknown-total sequential sources. Printing and exporting use the same
|
|
105
|
+
complete snapshot stream, with the host supplying a transactional sink; neither
|
|
106
|
+
reads mounted DOM. The source must advertise `completeExport`. See the normative
|
|
107
|
+
[app provider contract](../../../packages/app/docs/COLLECTION-PROVIDER.md).
|
|
108
|
+
|
|
109
|
+
Actual assistive technology, physical touch and native OS IME qualification remain
|
|
110
|
+
separate from automated browser evidence; see [measurements](MEASUREMENTS.md).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Collection evidence
|
|
2
|
+
|
|
3
|
+
The retained reference fixtures and budgets remain unchanged. The installed
|
|
4
|
+
Node/Bun consumer runs both fixed and measured profiles using only public package
|
|
5
|
+
exports, without a retained virtualizer dependency. The reference remains in the
|
|
6
|
+
benchmark workspace for differential checks and future downstream qualification.
|
|
7
|
+
|
|
8
|
+
<!--fact:collection.measurements-->
|
|
9
|
+
|
|
10
|
+
Measured on v24.19.0, linux/x64, AMD Ryzen 9 5900HX with Radeon Graphics.
|
|
11
|
+
|
|
12
|
+
| Consumer | Rows | Reference range ms | Native range/view/interaction p95 ms | Cells | Cached rows / bytes | Measurements / accounted bytes | Heap MiB | Teardown ms |
|
|
13
|
+
|---|---:|---:|---:|---:|---|---|---:|---:|
|
|
14
|
+
| catalog | 10000 | 0.020 | 1.450 | 170 | 256 / 9732 | 256 / 8192 | 19.49 | 0.177 |
|
|
15
|
+
| archive-stock | 75000 | 8.080 | 0.307 | 160 | 256 / 10244 | 256 / 8448 | 92.43 | 0.047 |
|
|
16
|
+
|
|
17
|
+
The component and coordinator browser bundle is 16815 gzip bytes. Reference range calls do less work than native vnode and interaction calls; the comparison deliberately publishes that cost rather than claiming equal workloads.
|
|
18
|
+
|
|
19
|
+
<!--/fact-->
|
|
20
|
+
|
|
21
|
+
The Node measurements include range calculation, vnode projection and headless
|
|
22
|
+
interaction. The reference timing is range-only and is therefore cheaper work.
|
|
23
|
+
The historical adoption report also includes other engines; its heap and RSS
|
|
24
|
+
cannot be treated as isolated virtualizer memory. Measurement bytes account for
|
|
25
|
+
UTF-8 keys and numeric payloads; runtime heap separately includes Map/array overhead.
|
|
26
|
+
|
|
27
|
+
The browser matrix covers keyed anchor preservation, both axes, RTL, pinned rows,
|
|
28
|
+
resize, CSS zoom, synthetic touch events, keyboard selection, pending realization,
|
|
29
|
+
editing/composition events, extent limits, source examples and disposal. The
|
|
30
|
+
near-ceiling fixture uses lazy row access. It never allocates the entire logical
|
|
31
|
+
source in the browser. Browser script timings cover synchronous DOM reconciliation;
|
|
32
|
+
paint scheduling is separate. Physical touch devices, actual assistive technology,
|
|
33
|
+
native OS IME sessions and real downstream cutover remain unqualified.
|
|
34
|
+
|
|
35
|
+
<!--fact:collection.browser-->
|
|
36
|
+
|
|
37
|
+
Linux container, Node v24.20.0, 4 browser workers. Each sample set contains 40 synchronous viewport changes after the near-ceiling check.
|
|
38
|
+
|
|
39
|
+
| Engine / version | DOM interaction p95 ms | Earlier full-matrix p95 ms | Peak cells | CSS extent / final offset | Remaining resources |
|
|
40
|
+
|---|---:|---:|---:|---|---:|
|
|
41
|
+
| chromium 149.0.7827.55 | 10.30 | 8.60 | 170 | 7920000 / 7919560 | 0 |
|
|
42
|
+
| firefox 151.0 | 11.00 | 12.00 | 170 | 7920000 / 7919560 | 0 |
|
|
43
|
+
| webkit 26.5 | 15.00 | 17.00 | 170 | 7920000 / 7919560 | 0 |
|
|
44
|
+
|
|
45
|
+
The earlier loaded WebKit sample exceeded the fixed-profile 16 ms target. Browser latency varies with concurrent load; these measurements do not establish a universal frame-time guarantee.
|
|
46
|
+
|
|
47
|
+
<!--/fact-->
|
|
48
|
+
|
|
49
|
+
Reproduce the portable and Node measurements with `npm run test:packed` and
|
|
50
|
+
`npm run benchmark:collection`. After `npm run website:build`, run
|
|
51
|
+
`COLLECTION_MEASURE=1 npx playwright test -c packages/website/playwright.config.js packages/website/e2e/collection.spec.js packages/website/e2e/lifecycle.spec.js --workers=4`
|
|
52
|
+
on the qualified browser host, then `npm run docs:derive`. Ordinary gates omit the
|
|
53
|
+
measurement environment variable and leave committed timings unchanged. The earlier
|
|
54
|
+
full-matrix sample is retained separately so rerunning does not erase a measured loss.
|
|
55
|
+
True peak heap remains unmeasured; sampled heap and process peak RSS are reported.
|
|
56
|
+
|
|
57
|
+
The browser's conservative CSS extent ceiling refuses larger extents before they
|
|
58
|
+
reach layout. The pure range engine supports larger logical counts. Source-backed
|
|
59
|
+
sequential providers retain unknown totals and refuse index/key jumps and complete
|
|
60
|
+
export. Complete export is qualified for immutable resident array sources and
|
|
61
|
+
bounded complete SQLite resident snapshots. The browser download example uses a
|
|
62
|
+
finite output spool and refuses an oversized export before making a download.
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jarenjs/collection",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.83.2",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./dist/types/index.d.ts",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/types/index.d.ts",
|
|
12
|
+
"default": "./src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./component": {
|
|
15
|
+
"types": "./dist/types/component/index.d.ts",
|
|
16
|
+
"default": "./src/component/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json",
|
|
19
|
+
"./styles/collection.css": "./styles/collection.css"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src/",
|
|
23
|
+
"dist/types/",
|
|
24
|
+
"styles/",
|
|
25
|
+
"docs/",
|
|
26
|
+
"ARCHITECTURE.md"
|
|
27
|
+
],
|
|
28
|
+
"description": "Bounded virtual lists and grids with keyed interaction and injected data providers",
|
|
29
|
+
"author": "joham",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/jklarenbeek/jarenjs.git",
|
|
33
|
+
"directory": "components/collection"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=24"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"registry": "https://registry.npmjs.org/"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"jaren",
|
|
45
|
+
"collection",
|
|
46
|
+
"virtual",
|
|
47
|
+
"grid"
|
|
48
|
+
],
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "npm run build:types",
|
|
51
|
+
"build:types": "tsc -p tsconfig.json",
|
|
52
|
+
"prepack": "npm run build:types"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@jarenjs/core": "^0.83.2",
|
|
56
|
+
"@jarenjs/view": "^0.83.2",
|
|
57
|
+
"@jarenjs/app": "^0.83.2"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
import { createVirtualAxis, virtualIndices } from '@jarenjs/core/virtual';
|
|
3
|
+
|
|
4
|
+
/** Stable DOM identity; JSON escaping also supports lone surrogate code units in JSON keys.
|
|
5
|
+
* @param {string} id @param {string} key @param {number} [column] */
|
|
6
|
+
export function collectionItemId(id, key, column) {
|
|
7
|
+
return `${id}-r-${encodeURIComponent(JSON.stringify(key))}${column === undefined ? '' : `-c-${column}`}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a source-independent collection. Accessors run only for credited mounted rows.
|
|
12
|
+
* Measurements and pins have their own finite credits. Oversized DOM extents refuse explicitly.
|
|
13
|
+
* @param {any} options
|
|
14
|
+
*/
|
|
15
|
+
export function createCollection(options) {
|
|
16
|
+
let config = { rowSize: 44, overscan: 2, columnCount: 1, columnSize: 160, columnOverscan: 1,
|
|
17
|
+
maxRows: 128, maxColumns: 32, maxCells: 840, rowPinBudget: 2, columnPinBudget: 2,
|
|
18
|
+
maxExtent: Number.MAX_SAFE_INTEGER, query: '', snapshot: '', ...options };
|
|
19
|
+
for (const name of ['maxRows', 'maxColumns', 'maxCells', 'rowPinBudget', 'columnPinBudget'])
|
|
20
|
+
if (!Number.isSafeInteger(config[name]) || config[name] < 0) throw new RangeError(`Invalid ${name}`);
|
|
21
|
+
const rowAxis = createVirtualAxis({ count: config.count, estimateSize: config.rowSize,
|
|
22
|
+
maxMeasurements: config.maxMeasurements, maxBytes: config.measurementBytes });
|
|
23
|
+
const columnAxis = createVirtualAxis({ count: config.columnCount, estimateSize: config.columnSize,
|
|
24
|
+
maxMeasurements: config.maxColumnMeasurements, maxBytes: config.columnMeasurementBytes });
|
|
25
|
+
let viewport = { top: 0, left: 0, width: 0, height: 0 };
|
|
26
|
+
let anchor = null, disposed = false;
|
|
27
|
+
let focusPins = { rows: [], columns: [] };
|
|
28
|
+
const keyAt = (index) => config.keyAt(index);
|
|
29
|
+
const refusal = (state, reason) => ({ state, reason, rows: [], columns: [] });
|
|
30
|
+
function layout() {
|
|
31
|
+
if (disposed) return refusal('error', 'disposed');
|
|
32
|
+
const visible = viewport.height > 0 && viewport.width > 0;
|
|
33
|
+
const rows = rowAxis.range({ viewport: visible ? viewport.height : 0, offset: viewport.top, overscan: config.overscan });
|
|
34
|
+
const columns = columnAxis.range({ viewport: visible ? viewport.width : 0, offset: viewport.left, overscan: config.columnOverscan });
|
|
35
|
+
if (rows.extent > config.maxExtent || columns.extent > config.maxExtent) return refusal('error', 'scroll-extent');
|
|
36
|
+
if (rows.end - rows.start > config.maxRows || columns.end - columns.start > config.maxColumns)
|
|
37
|
+
return refusal('budget-exhausted', 'dom-credits');
|
|
38
|
+
const rowIndices = virtualIndices(rows, visible ? [...(config.rowPins ?? []), ...focusPins.rows] : [], config.count, config.rowPinBudget);
|
|
39
|
+
const columnIndices = virtualIndices(columns, visible ? [...(config.columnPins ?? []), ...focusPins.columns] : [], config.columnCount, config.columnPinBudget);
|
|
40
|
+
if (rowIndices.state !== 'ready' || columnIndices.state !== 'ready') return refusal('budget-exhausted', 'pin-credits');
|
|
41
|
+
if (rowIndices.indices.length * columnIndices.indices.length > config.maxCells) return refusal('budget-exhausted', 'dom-credits');
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const mounted = rowIndices.indices.map((index) => {
|
|
44
|
+
const key = keyAt(index);
|
|
45
|
+
if (key != null && (typeof key !== 'string' || seen.has(key))) throw new TypeError('Collection keys must be unique strings');
|
|
46
|
+
if (key != null) seen.add(key);
|
|
47
|
+
return { index, key, start: rowAxis.position(index), size: rowAxis.size(index), pinned: (config.rowPins ?? []).includes(index) };
|
|
48
|
+
});
|
|
49
|
+
return { state: 'ready', rowRange: rows, columnRange: columns, rows: mounted,
|
|
50
|
+
columns: columnIndices.indices.map((index) => ({ index, start: columnAxis.position(index), size: columnAxis.size(index),
|
|
51
|
+
pinned: (config.columnPins ?? []).includes(index) })) };
|
|
52
|
+
}
|
|
53
|
+
function saveAnchor() { anchor = rowAxis.anchor(viewport.top, keyAt, config.query); }
|
|
54
|
+
function restoreAnchor() {
|
|
55
|
+
const restored = rowAxis.restore(anchor, config.indexOf, config.query);
|
|
56
|
+
viewport.top = Math.min(restored.offset, Math.max(0, rowAxis.extent() - viewport.height));
|
|
57
|
+
return viewport.top;
|
|
58
|
+
}
|
|
59
|
+
function scrollToOffset(offset) {
|
|
60
|
+
if (disposed) return { state: 'error', reason: 'disposed' };
|
|
61
|
+
viewport.top = offset;
|
|
62
|
+
const result = layout();
|
|
63
|
+
if (result.state !== 'ready') return result;
|
|
64
|
+
viewport.top = result.rowRange.offset; saveAnchor();
|
|
65
|
+
return { state: 'ready', offset: viewport.top, left: viewport.left };
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
rowAxis, columnAxis, layout,
|
|
69
|
+
options() { return config; },
|
|
70
|
+
position() { return { ...viewport }; },
|
|
71
|
+
viewport(next) { viewport = { ...viewport, ...next }; const result = layout();
|
|
72
|
+
if (result.state === 'ready') { viewport.top = result.rowRange.offset; viewport.left = result.columnRange.offset; saveAnchor(); }
|
|
73
|
+
return result; },
|
|
74
|
+
update(next) {
|
|
75
|
+
config = { ...config, ...next };
|
|
76
|
+
rowAxis.update({ count: config.count, estimateSize: config.rowSize, indexOf: config.indexOf });
|
|
77
|
+
columnAxis.update({ count: config.columnCount, estimateSize: config.columnSize });
|
|
78
|
+
restoreAnchor(); saveAnchor(); return layout();
|
|
79
|
+
},
|
|
80
|
+
measure(index, key, size, axis = 'row') {
|
|
81
|
+
const engine = axis === 'column' ? columnAxis : rowAxis;
|
|
82
|
+
const result = engine.measure(index, key, size);
|
|
83
|
+
if (result.state === 'ready') { restoreAnchor(); saveAnchor(); }
|
|
84
|
+
return { ...result, offset: viewport.top };
|
|
85
|
+
},
|
|
86
|
+
pin(rows = [], columns = []) { focusPins = { rows, columns }; return layout(); },
|
|
87
|
+
restore(value) { anchor = value; const offset = restoreAnchor(); saveAnchor(); return scrollToOffset(offset); },
|
|
88
|
+
snapshot() { return anchor ? { ...anchor, snapshot: config.snapshot } : null; },
|
|
89
|
+
scrollToOffset,
|
|
90
|
+
/** @param {number} index @param {number} [column] */
|
|
91
|
+
scrollToIndex(index, column) {
|
|
92
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= config.count) return { state: 'error', reason: 'invalid-index' };
|
|
93
|
+
if (column !== undefined) viewport.left = columnAxis.position(Math.max(0, Math.min(config.columnCount - 1, column)));
|
|
94
|
+
return scrollToOffset(rowAxis.position(index));
|
|
95
|
+
},
|
|
96
|
+
scrollToKey(key) {
|
|
97
|
+
if (!config.indexOf) return { state: 'error', reason: 'unsupported-seek' };
|
|
98
|
+
return this.scrollToIndex(config.indexOf(key));
|
|
99
|
+
},
|
|
100
|
+
view(interaction = null, id = 'collection') {
|
|
101
|
+
const result = layout();
|
|
102
|
+
const grid = config.role !== 'listbox';
|
|
103
|
+
const selection = (key) => interaction?.selected(key) ?? false;
|
|
104
|
+
const active = interaction?.state().focus;
|
|
105
|
+
const rows = result.rows.map((row) => {
|
|
106
|
+
const rowKey = JSON.stringify(row.key == null ? [0, row.index] : [1, row.key]);
|
|
107
|
+
const rowId = row.key == null ? `${id}-p-${row.index}` : collectionItemId(id, row.key);
|
|
108
|
+
const rowTop = row.pinned ? Math.max(row.start, viewport.top) : row.start;
|
|
109
|
+
return ['div', { key: rowKey, 'data-row': row.index, 'data-key': row.key,
|
|
110
|
+
role: grid ? 'row' : 'option', id: grid ? undefined : rowId,
|
|
111
|
+
'aria-rowindex': grid ? row.index + 1 : undefined,
|
|
112
|
+
'aria-posinset': grid ? undefined : row.index + 1,
|
|
113
|
+
'aria-setsize': grid ? undefined : config.totalKnown === false ? -1 : config.count,
|
|
114
|
+
'aria-selected': grid ? undefined : selection(row.key), class: row.pinned ? 'jc-row jc-pin' : 'jc-row', style: {
|
|
115
|
+
position: 'absolute', top: `${rowTop}px`, height: `${row.size}px`, width: '100%', zIndex: row.pinned ? 2 : 0 } },
|
|
116
|
+
...result.columns.map((column) => ['div', { key: column.index, class: column.pinned ? 'jc-cell jc-pin' : 'jc-cell',
|
|
117
|
+
role: grid ? 'gridcell' : undefined,
|
|
118
|
+
id: grid ? `${rowId}-c-${column.index}` : undefined,
|
|
119
|
+
'data-column': column.index, 'aria-colindex': grid ? column.index + 1 : undefined,
|
|
120
|
+
'aria-selected': grid ? selection(row.key) : undefined,
|
|
121
|
+
'data-active': active?.key === row.key && active?.column === column.index,
|
|
122
|
+
style: { position: 'absolute', [config.direction === 'rtl' ? 'right' : 'left']:
|
|
123
|
+
`${column.pinned ? Math.max(column.start, viewport.left) : column.start}px`,
|
|
124
|
+
width: `${column.size}px`, height: '100%', zIndex: column.pinned ? 1 : 0 } },
|
|
125
|
+
['div', { class: 'jc-content' }, row.key == null ? (config.loadingLabel ?? 'Loading…') : config.renderCell(config.getItem(row.index), column.index, row.index)]])];
|
|
126
|
+
});
|
|
127
|
+
return ['div', { class: 'jc-surface', style: { position: 'relative', height: `${result.rowRange?.extent ?? 0}px`,
|
|
128
|
+
width: `${result.columnRange?.extent ?? 0}px` } }, ...rows];
|
|
129
|
+
},
|
|
130
|
+
stats() { const result = layout(); return { rows: result.rows.length, columns: result.columns.length,
|
|
131
|
+
cells: result.rows.length * result.columns.length, rowMeasurements: rowAxis.stats(), columnMeasurements: columnAxis.stats() }; },
|
|
132
|
+
dispose() { disposed = true; rowAxis.dispose(); columnAxis.dispose(); anchor = null; focusPins = { rows: [], columns: [] }; },
|
|
133
|
+
};
|
|
134
|
+
}
|