@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.
@@ -0,0 +1,243 @@
1
+ //@ts-check
2
+ import { createDomRenderer } from '@jarenjs/view';
3
+ import { logicalScrollOffset } from '@jarenjs/core/virtual';
4
+ import { createCollection, collectionItemId } from '../collection.js';
5
+ import { createCollectionInteraction } from '../interaction.js';
6
+ let nextId = 0;
7
+
8
+ /**
9
+ * Mount a collection with injected source access, optional frame and resize ownership.
10
+ * CSS extents above the qualified ceiling refuse before they reach layout.
11
+ * @param {HTMLElement} host @param {any} options
12
+ */
13
+ export function mountCollection(host, options) {
14
+ let config = { maxExtent: 8000000, maxMeasurementWork: 64, ...options };
15
+ if (!Number.isSafeInteger(config.maxMeasurementWork) || config.maxMeasurementWork < 1
16
+ || !Number.isFinite(config.maxExtent) || config.maxExtent <= 0) throw new RangeError('Invalid measurement/extent credits');
17
+ const document = host.ownerDocument, window = document.defaultView;
18
+ const requestFrame = config.requestFrame ?? ((fn) => window.requestAnimationFrame(fn));
19
+ const cancelFrame = config.cancelFrame ?? ((id) => window.cancelAnimationFrame(id));
20
+ const observe = config.observe ?? ((element, fn) => {
21
+ const observer = new window.ResizeObserver(fn); observer.observe(element);
22
+ return () => observer.disconnect();
23
+ });
24
+ const element = document.createElement('div');
25
+ element.className = 'jc-viewport';
26
+ element.style.overflow = 'auto'; element.style.position = 'relative';
27
+ element.style.height = `${config.height ?? 440}px`;
28
+ element.style.overflowAnchor = 'none';
29
+ element.tabIndex = 0;
30
+ const id = config.id ?? `jc-${++nextId}`;
31
+ const controller = createCollection(config);
32
+ const interaction = createCollectionInteraction(controller.options());
33
+ const render = createDomRenderer(element, { document, onEvent: config.onEvent });
34
+ let frame = null, disposed = false, composing = false, unobserve = null, measurePending = false, measurementCursor = 0;
35
+ const observed = new Map();
36
+ function schedule() { if (!disposed && frame === null) frame = requestFrame(refresh); }
37
+ function focusId() {
38
+ const focus = interaction.state().focus;
39
+ if (!focus) return null;
40
+ return collectionItemId(id, focus.key, config.role === 'listbox' ? undefined : focus.column);
41
+ }
42
+ function aria() {
43
+ const target = focusId();
44
+ if (target && document.getElementById(target) && element.contains(document.getElementById(target)))
45
+ element.setAttribute('aria-activedescendant', target);
46
+ else element.removeAttribute('aria-activedescendant');
47
+ element.setAttribute('aria-busy', String(!!interaction.state().pending || config.loading === true));
48
+ }
49
+ function refresh() {
50
+ if (disposed) return;
51
+ if (frame !== null) { cancelFrame(frame); frame = null; }
52
+ element.setAttribute('role', config.role ?? 'grid');
53
+ element.setAttribute('aria-label', config.label ?? 'Collection');
54
+ element.setAttribute('aria-multiselectable', 'true');
55
+ element.setAttribute('dir', config.direction ?? 'ltr');
56
+ if (config.role !== 'listbox') {
57
+ element.setAttribute('aria-rowcount', String(config.totalKnown === false ? -1 : config.count));
58
+ element.setAttribute('aria-colcount', String(config.columnCount ?? 1));
59
+ }
60
+ else { element.removeAttribute('aria-rowcount'); element.removeAttribute('aria-colcount'); }
61
+ const left = logicalScrollOffset(element.scrollLeft, Math.max(0, controller.columnAxis.extent() - element.clientWidth),
62
+ config.direction === 'rtl' ? 'negative' : 'ltr');
63
+ controller.viewport({ top: element.scrollTop, left, width: element.clientWidth, height: element.clientHeight });
64
+ if (measurePending) { measurePending = false; measureVisible(); }
65
+ interaction.update({ ...controller.options(), pageRows: Math.max(1, Math.floor(element.clientHeight / controller.options().rowSize)) });
66
+ const focus = interaction.state().focus;
67
+ const result = focus && config.keyAt(focus.index) === focus.key ? controller.pin([focus.index], [focus.column]) : controller.pin();
68
+ // Remove the old ID before keyed removal; expose a new descendant only after realization.
69
+ element.removeAttribute('aria-activedescendant');
70
+ render(controller.view(interaction, id));
71
+ element.setAttribute('data-state', result.state);
72
+ if (result.reason) element.setAttribute('data-reason', result.reason); else element.removeAttribute('data-reason');
73
+ aria();
74
+ if (config.measured) {
75
+ const rows = [...element.querySelectorAll('.jc-content')];
76
+ const live = new Set(rows);
77
+ for (const [node, stop] of observed) if (!live.has(node)) { stop(); observed.delete(node); }
78
+ for (const row of rows) if (!observed.has(row)) observed.set(row, observe(row, scheduleMeasure));
79
+ }
80
+ config.onChange?.({ state: result.state, ...(result.reason ? {reason: result.reason} : {}), ...controller.stats(), focus: interaction.state().focus });
81
+ }
82
+ function scheduleMeasure() { measurePending = true; schedule(); }
83
+ function measureVisible() {
84
+ if (disposed || !config.measured) return;
85
+ let changed = false;
86
+ const rows = [...element.querySelectorAll('.jc-row')];
87
+ if (measurementCursor >= rows.length) measurementCursor = 0;
88
+ const end = Math.min(rows.length, measurementCursor + config.maxMeasurementWork);
89
+ for (let cursor = measurementCursor; cursor < end; cursor++) {
90
+ const row = rows[cursor];
91
+ const index = Number(row.getAttribute('data-row')), key = config.keyAt(index);
92
+ if (key == null) continue;
93
+ const height = config.measureRow ? config.measureRow(row, index) : Math.max(1,
94
+ ...[...row.querySelectorAll('.jc-content')].map((content) => content.getBoundingClientRect().height + 16));
95
+ if (Number.isFinite(height) && height > 0 && Math.abs(height - controller.rowAxis.size(index)) > 0.5) {
96
+ const result = controller.measure(index, key, height);
97
+ if (result.state === 'ready') { element.scrollTop = result.offset; changed = true; }
98
+ }
99
+ }
100
+ measurementCursor = end < rows.length ? end : 0;
101
+ if (measurementCursor) { measurePending = true; schedule(); }
102
+ else if (changed) schedule();
103
+ }
104
+ function applyScroll(result) {
105
+ if (result.state === 'ready') {
106
+ element.scrollTop = result.offset;
107
+ element.scrollLeft = (config.direction === 'rtl' ? -1 : 1) * (result.left ?? controller.position().left);
108
+ refresh();
109
+ }
110
+ return result;
111
+ }
112
+ function editing(target) { return target !== element && !!target?.closest?.('input,textarea,select,[contenteditable="true"]'); }
113
+ function keydown(event) {
114
+ const result = interaction.key({ key: event.key, shiftKey: event.shiftKey, ctrlKey: event.ctrlKey,
115
+ altKey: event.altKey, metaKey: event.metaKey, isComposing: composing || event.isComposing, editing: editing(event.target) });
116
+ if (result.state === 'ignored') return;
117
+ event.preventDefault();
118
+ if (result.state === 'loading') { config.onRealize?.(result.index, result.column); aria(); }
119
+ else if (result.state === 'activate') config.onActivate?.(result);
120
+ else if (result.state === 'return-focus') config.returnFocus?.focus();
121
+ else if (result.index !== undefined) applyScroll(controller.scrollToIndex(result.index, result.column));
122
+ else refresh();
123
+ config.onIntent?.(interaction.state().selection);
124
+ }
125
+ function focusin(event) {
126
+ const row = event.target.closest?.('.jc-row');
127
+ if (row) {
128
+ const column = event.target.closest?.('.jc-cell');
129
+ interaction.focusIndex(Number(row.getAttribute('data-row')), Number(column?.getAttribute('data-column') ?? 0));
130
+ aria();
131
+ }
132
+ else if (!interaction.state().focus) { interaction.focusIndex(controller.rowAxis.indexAt(element.scrollTop)); refresh(); }
133
+ }
134
+ function click(event) {
135
+ if (editing(event.target)) return;
136
+ const row = event.target.closest?.('.jc-row');
137
+ if (!row) return;
138
+ const column = event.target.closest?.('.jc-cell');
139
+ const result = interaction.focusIndex(Number(row.getAttribute('data-row')), Number(column?.getAttribute('data-column') ?? 0));
140
+ if (result.state === 'ready') interaction.toggle(result.key);
141
+ element.focus(); refresh(); config.onIntent?.(interaction.state().selection);
142
+ }
143
+ const listeners = { scroll: schedule, keydown, focusin, click,
144
+ compositionstart: () => { composing = true; }, compositionend: () => { composing = false; } };
145
+ function dispose() {
146
+ if (disposed) return;
147
+ disposed = true;
148
+ for (const [name, listener] of Object.entries(listeners)) element.removeEventListener(name, listener);
149
+ if (frame !== null) cancelFrame(frame);
150
+ frame = null;
151
+ let failure, failed = false;
152
+ const clean = (fn) => { try { fn(); } catch (error) { if (!failed) { failure = error; failed = true; } } };
153
+ if (unobserve) clean(unobserve);
154
+ for (const stop of observed.values()) clean(stop);
155
+ observed.clear(); clean(() => render.destroy()); clean(() => controller.dispose()); clean(() => element.remove());
156
+ if (failed) throw failure;
157
+ }
158
+ try {
159
+ host.appendChild(element);
160
+ for (const [name, listener] of Object.entries(listeners)) element.addEventListener(name, listener, { passive: name === 'scroll' });
161
+ unobserve = observe(element, schedule); refresh();
162
+ }
163
+ catch (error) { dispose(); throw error; }
164
+ return {
165
+ controller, interaction, element, refresh, measureVisible,
166
+ update(next) {
167
+ config = { ...config, ...next }; controller.update(next); interaction.update(next);
168
+ element.scrollTop = controller.position().top; refresh();
169
+ },
170
+ scrollToOffset(offset) { return applyScroll(controller.scrollToOffset(offset)); },
171
+ /** @param {number} index @param {number} [column] */
172
+ scrollToIndex(index, column) { return applyScroll(controller.scrollToIndex(index, column)); },
173
+ scrollToKey(key) { return applyScroll(controller.scrollToKey(key)); },
174
+ restore(intent) {
175
+ if (intent?.selection) interaction.restore(intent.selection);
176
+ return applyScroll(controller.restore(intent?.anchor));
177
+ },
178
+ snapshot() { return { anchor: controller.snapshot(), selection: interaction.state().selection }; },
179
+ stats() { return { ...controller.stats(), listeners: disposed ? 0 : Object.keys(listeners).length,
180
+ observers: disposed ? 0 : 1 + observed.size, frames: frame === null ? 0 : 1 }; },
181
+ dispose,
182
+ };
183
+ }
184
+
185
+ /** Adapt controller disposal to the existing WidgetDef unmount lifecycle.
186
+ * @param {any | ((props:any, emit:any)=>any)} options */
187
+ export function createCollectionWidget(options) {
188
+ const config = (props, emit) => typeof options === 'function' ? options(props, emit) : { ...options, ...props };
189
+ return {
190
+ mount(host, props, emit) { return { mounted: mountCollection(host, config(props, emit)), emit }; },
191
+ update(handle, props) { handle.mounted.update(config(props, handle.emit)); },
192
+ unmount(handle) { handle.mounted.dispose(); },
193
+ };
194
+ }
195
+
196
+ /** Mount the same controller over an injected app coordinator. It owns the coordinator lifetime.
197
+ * @param {HTMLElement} host @param {any} coordinator @param {any} options */
198
+ export function mountProviderCollection(host, coordinator, options) {
199
+ let disposed = false, loading = false, mounted, stop;
200
+ const config = () => {
201
+ const state = coordinator.observation();
202
+ return { count: coordinator.logicalCount(), totalKnown: state.total.kind === 'known', loading: state.state === 'loading',
203
+ query: state.query, snapshot: state.snapshot, keyAt: (index) => coordinator.keyAt(index),
204
+ getItem: (index) => coordinator.rowAt(index), indexOf: (key) => coordinator.indexOf(key) };
205
+ };
206
+ async function loadVisible() {
207
+ if (disposed || loading || !mounted) return;
208
+ const range = mounted.controller.layout().rowRange;
209
+ if (!range || range.start === range.end) return;
210
+ let start = range.start;
211
+ while (start < range.end && coordinator.keyAt(start) != null) start++;
212
+ if (start === range.end) return;
213
+ loading = true;
214
+ try {
215
+ const result = await coordinator.requestRange({ start, end: range.end });
216
+ if (!disposed) { mounted.element.setAttribute('data-provider-state', result.state);
217
+ if (result.reason) mounted.element.setAttribute('data-provider-reason', result.reason); }
218
+ }
219
+ finally { loading = false; }
220
+ }
221
+ mounted = mountCollection(host, { ...options, ...config(),
222
+ onChange: (state) => {
223
+ const focused = mounted?.element.ownerDocument.activeElement;
224
+ const editing = focused && focused !== mounted?.element && mounted?.element.contains(focused);
225
+ coordinator.pinKeys(editing && state.focus ? [state.focus.key] : []);
226
+ options.onChange?.(state); queueMicrotask(loadVisible);
227
+ },
228
+ onRealize: async (index) => {
229
+ const result = await coordinator.requestRange({start:index,end:index+1});
230
+ if (!disposed && result.state === 'ready') { mounted.update(config()); mounted.scrollToIndex(index); }
231
+ else if (!disposed) { mounted.interaction.cancelPending(); mounted.element.setAttribute('data-provider-reason', result.reason ?? result.state); mounted.refresh(); }
232
+ } });
233
+ stop = coordinator.subscribe(() => { if (!disposed) mounted.update(config()); });
234
+ queueMicrotask(loadVisible);
235
+ return {
236
+ mounted, coordinator,
237
+ async next() { const result = await coordinator.next(); if (!disposed && result.state === 'ready') mounted.scrollToIndex(result.start); return result; },
238
+ output(sink, options = {}) { return coordinator.output(sink, { selection: mounted.interaction.state().selection, ...options }); },
239
+ print(sink, options = {}) { return coordinator.output(sink, { selection: mounted.interaction.state().selection, ...options }); },
240
+ snapshot() { return mounted.snapshot(); },
241
+ async dispose() { if (!disposed) { disposed = true; stop(); mounted.dispose(); } await coordinator.dispose(); },
242
+ };
243
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ //@ts-check
2
+ /** Public headless collection geometry and vnode projection. */
3
+ export { createCollection } from './collection.js';
4
+ export { createCollectionInteraction } from './interaction.js';
@@ -0,0 +1,112 @@
1
+ //@ts-check
2
+ import { isJsonValue } from '@jarenjs/core/object';
3
+ /** Identity-based interaction. Range intent stores endpoint keys in a declared query/snapshot. */
4
+
5
+ /** @param {any} options */
6
+ export function createCollectionInteraction(options) {
7
+ let config = { columnCount: 1, pageRows: 10, query: '', snapshot: '', maxSelectedKeys: 4096, ...options };
8
+ let focus = null, pending = null, rangeAnchor = null;
9
+ let selection = { mode: 'keys', keys: [], ranges: [], exclusions: [], query: config.query, snapshot: config.snapshot };
10
+ function state() { return structuredClone({ focus, pending, selection }); }
11
+ function focusIndex(index, column = 0) {
12
+ if (!Number.isSafeInteger(index) || !Number.isSafeInteger(column)) return {state:'error',reason:'invalid-index'};
13
+ if (!config.count) { focus = null; pending = null; return { state: 'ready', focus: null }; }
14
+ index = Math.max(0, Math.min(config.count - 1, index));
15
+ column = Math.max(0, Math.min(config.columnCount - 1, column));
16
+ const key = config.keyAt(index);
17
+ if (typeof key !== 'string') { pending = { index, column }; return { state: 'loading', index, column }; }
18
+ focus = { key, index, column }; pending = null;
19
+ return { state: 'ready', ...focus };
20
+ }
21
+ function scoped() { return selection.query === config.query && selection.snapshot === config.snapshot; }
22
+ function selected(key) {
23
+ if (key == null) return false;
24
+ if (selection.mode === 'all' && scoped()) return !selection.exclusions.includes(key);
25
+ if (selection.keys.includes(key)) return true;
26
+ if (!scoped()) return false;
27
+ return selection.ranges.some((range) => {
28
+ if (key === range.fromKey || key === range.toKey) return true;
29
+ const index = config.indexOf?.(key), start = config.indexOf?.(range.fromKey), end = config.indexOf?.(range.toKey);
30
+ return index >= 0 && start >= 0 && end >= 0 && index >= Math.min(start, end) && index <= Math.max(start, end);
31
+ });
32
+ }
33
+ function toggle(key) {
34
+ if (typeof key !== 'string') return {state:'error',reason:'invalid-selection'};
35
+ if (selection.mode === 'all' && scoped()) {
36
+ const excluded = selection.exclusions.includes(key);
37
+ if (!excluded && selection.exclusions.length >= config.maxSelectedKeys) return { state: 'budget-exhausted', reason: 'selection-credits' };
38
+ selection.exclusions = excluded ? selection.exclusions.filter((item) => item !== key) : [...selection.exclusions, key];
39
+ }
40
+ else {
41
+ const exists = selection.keys.includes(key);
42
+ if (!exists && selection.keys.length >= config.maxSelectedKeys) return { state: 'budget-exhausted', reason: 'selection-credits' };
43
+ selection.keys = exists ? selection.keys.filter((item) => item !== key) : [...selection.keys, key];
44
+ }
45
+ return { state: 'ready' };
46
+ }
47
+ return {
48
+ state, selected, focusIndex,
49
+ cancelPending() { pending = null; },
50
+ update(next) {
51
+ config = { ...config, ...next };
52
+ if (pending) {
53
+ const intent = pending;
54
+ if (intent.query !== undefined && (intent.query !== config.query || intent.snapshot !== config.snapshot)) { pending = null; return {state:'invalidated'}; }
55
+ const result = focusIndex(intent.index, intent.column);
56
+ if (result.state === 'ready' && intent.fromKey) selection = {mode:'keys',keys:[],exclusions:[],
57
+ ranges:[{fromKey:intent.fromKey,toKey:focus.key}],query:config.query,snapshot:config.snapshot};
58
+ else if (pending) pending = {...pending,...intent};
59
+ return result;
60
+ }
61
+ if (focus) {
62
+ const index = config.indexOf?.(focus.key);
63
+ if (index >= 0) focus = { ...focus, index };
64
+ else if (next.removedKeys?.includes(focus.key) || config.count === 0) return focusIndex(focus.index, focus.column);
65
+ }
66
+ return { state: 'ready' };
67
+ },
68
+ toggle,
69
+ selectAll() { selection = { mode: 'all', keys: [], ranges: [], exclusions: [], query: config.query, snapshot: config.snapshot }; },
70
+ clear() { selection = { mode: 'keys', keys: [], ranges: [], exclusions: [], query: config.query, snapshot: config.snapshot }; },
71
+ restore(intent) {
72
+ // Only JSON intent returns from navigation; loaded resources never travel here.
73
+ if (!intent || !isJsonValue(intent) || typeof intent.query !== 'string' || typeof intent.snapshot !== 'string' || !['keys', 'all'].includes(intent.mode) || !Array.isArray(intent.keys) || !Array.isArray(intent.exclusions)
74
+ || !Array.isArray(intent.ranges) || intent.keys.length + intent.exclusions.length > config.maxSelectedKeys
75
+ || intent.ranges.length > 1 || ![...intent.keys, ...intent.exclusions].every((key) => typeof key === 'string')
76
+ || intent.ranges.some((range) => typeof range.fromKey !== 'string' || typeof range.toKey !== 'string'))
77
+ return { state: 'error', reason: 'invalid-selection' };
78
+ selection = structuredClone(intent); return { state: 'ready' };
79
+ },
80
+ key(event) {
81
+ if (event.editing || event.isComposing || event.altKey || event.metaKey) return { state: 'ignored' };
82
+ if (!config.count) return { state: 'ignored' };
83
+ const old = focus ?? { key: config.keyAt(0), index: 0, column: 0 };
84
+ let index = pending?.index ?? old.index, column = pending?.column ?? old.column;
85
+ switch (event.key) {
86
+ case 'ArrowDown': index++; break;
87
+ case 'ArrowUp': index--; break;
88
+ case 'ArrowRight': column += config.direction === 'rtl' ? -1 : 1; break;
89
+ case 'ArrowLeft': column += config.direction === 'rtl' ? 1 : -1; break;
90
+ case 'Home': if (event.ctrlKey || config.role === 'listbox') index = 0; column = 0; break;
91
+ case 'End': if (event.ctrlKey || config.role === 'listbox') index = config.count - 1; column = config.columnCount - 1; break;
92
+ case 'PageDown': index += config.pageRows; break;
93
+ case 'PageUp': index -= config.pageRows; break;
94
+ case ' ': return focus ? toggle(focus.key) : { state: 'ignored' };
95
+ case 'Enter': return focus ? { state: 'activate', key: focus.key, column: focus.column } : { state: 'ignored' };
96
+ case 'Escape': return { state: 'return-focus' };
97
+ default: return { state: 'ignored' };
98
+ }
99
+ const result = focusIndex(index, column);
100
+ if (event.shiftKey && typeof old.key === 'string' && result.state === 'ready') {
101
+ rangeAnchor ??= old.key;
102
+ selection = { mode: 'keys', keys: [], exclusions: [], ranges: [{ fromKey: rangeAnchor, toKey: focus.key }],
103
+ query: config.query, snapshot: config.snapshot };
104
+ }
105
+ else if (event.shiftKey && result.state === 'loading') {
106
+ rangeAnchor ??= old.key; pending = {...pending,fromKey:rangeAnchor,query:config.query,snapshot:config.snapshot};
107
+ }
108
+ else if (!event.shiftKey) rangeAnchor = null;
109
+ return result;
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,10 @@
1
+ .jc-viewport { overflow: auto; position: relative; overscroll-behavior: contain; overflow-anchor: none; touch-action: pan-x pan-y; border: 1px solid var(--border, #999); color: var(--fg, var(--text, #222)); background: var(--surface, #fff); }
2
+ .jc-viewport:focus-visible { outline: 2px solid var(--accent, #555); outline-offset: 2px; }
3
+ .jc-surface { position: relative; }
4
+ .jc-row { box-sizing: border-box; border-bottom: 1px solid var(--border, #ddd); }
5
+ .jc-cell { box-sizing: border-box; padding: var(--space-2, 8px); overflow: hidden; }
6
+ .jc-cell[aria-selected='true'], .jc-row[aria-selected='true'] { background: var(--accent-soft, var(--surface, #eee)); }
7
+ .jc-cell[data-active='true'] { outline: 2px solid var(--accent, #555); outline-offset: -2px; }
8
+ .jc-cell input { max-width: 100%; box-sizing: border-box; }
9
+
10
+ .jc-pin { background: var(--surface, Canvas); }