@jarenjs/collection 0.85.0 → 0.87.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/ARCHITECTURE.md +8 -0
- package/README.md +6 -0
- package/dist/types/component/drag.d.ts +1351 -0
- package/dist/types/component/index.d.ts +38 -0
- package/dist/types/drag.d.ts +91 -0
- package/dist/types/index.d.ts +11 -0
- package/docs/COLLECTION.md +102 -0
- package/package.json +4 -4
- package/src/component/drag.js +317 -0
- package/src/component/index.js +30 -2
- package/src/drag.js +107 -0
- package/src/index.js +6 -0
package/src/component/index.js
CHANGED
|
@@ -33,6 +33,7 @@ export function mountCollection(host, options) {
|
|
|
33
33
|
const render = createDomRenderer(element, { document, onEvent: config.onEvent });
|
|
34
34
|
let frame = null, disposed = false, composing = false, unobserve = null, measurePending = false, measurementCursor = 0;
|
|
35
35
|
const observed = new Map();
|
|
36
|
+
const subscribers = new Set(), retainers = new Set();
|
|
36
37
|
function schedule() { if (!disposed && frame === null) frame = requestFrame(refresh); }
|
|
37
38
|
function focusId() {
|
|
38
39
|
const focus = interaction.state().focus;
|
|
@@ -64,7 +65,10 @@ export function mountCollection(host, options) {
|
|
|
64
65
|
if (measurePending) { measurePending = false; measureVisible(); }
|
|
65
66
|
interaction.update({ ...controller.options(), pageRows: Math.max(1, Math.floor(element.clientHeight / controller.options().rowSize)) });
|
|
66
67
|
const focus = interaction.state().focus;
|
|
67
|
-
const
|
|
68
|
+
const pins = [...retainers].map((read) => read());
|
|
69
|
+
const focused = focus && config.keyAt(focus.index) === focus.key;
|
|
70
|
+
const result = controller.pin([...(focused ? [focus.index] : []), ...pins.flatMap((pin) => pin.rows)],
|
|
71
|
+
[...(focused ? [focus.column] : []), ...pins.flatMap((pin) => pin.columns)]);
|
|
68
72
|
// Remove the old ID before keyed removal; expose a new descendant only after realization.
|
|
69
73
|
element.removeAttribute('aria-activedescendant');
|
|
70
74
|
render(controller.view(interaction, id));
|
|
@@ -78,6 +82,7 @@ export function mountCollection(host, options) {
|
|
|
78
82
|
for (const row of rows) if (!observed.has(row)) observed.set(row, observe(row, scheduleMeasure));
|
|
79
83
|
}
|
|
80
84
|
config.onChange?.({ state: result.state, ...(result.reason ? {reason: result.reason} : {}), ...controller.stats(), focus: interaction.state().focus });
|
|
85
|
+
for (const fn of [...subscribers]) fn({ kind: 'layout', state: result.state, reason: result.reason });
|
|
81
86
|
}
|
|
82
87
|
function scheduleMeasure() { measurePending = true; schedule(); }
|
|
83
88
|
function measureVisible() {
|
|
@@ -150,6 +155,8 @@ export function mountCollection(host, options) {
|
|
|
150
155
|
frame = null;
|
|
151
156
|
let failure, failed = false;
|
|
152
157
|
const clean = (fn) => { try { fn(); } catch (error) { if (!failed) { failure = error; failed = true; } } };
|
|
158
|
+
for (const fn of [...subscribers]) clean(() => fn({ kind: 'dispose' }));
|
|
159
|
+
subscribers.clear(); retainers.clear();
|
|
153
160
|
if (unobserve) clean(unobserve);
|
|
154
161
|
for (const stop of observed.values()) clean(stop);
|
|
155
162
|
observed.clear(); clean(() => render.destroy()); clean(() => controller.dispose()); clean(() => element.remove());
|
|
@@ -163,8 +170,24 @@ export function mountCollection(host, options) {
|
|
|
163
170
|
catch (error) { dispose(); throw error; }
|
|
164
171
|
return {
|
|
165
172
|
controller, interaction, element, refresh, measureVisible,
|
|
173
|
+
/** Observe layout/disposal without placing resources in application state.
|
|
174
|
+
* @param {(event:any)=>void} fn */
|
|
175
|
+
subscribe(fn) {
|
|
176
|
+
if (disposed || subscribers.size >= 16) throw new RangeError('Collection lifecycle subscriber capacity exceeded');
|
|
177
|
+
subscribers.add(fn); return () => { subscribers.delete(fn); };
|
|
178
|
+
},
|
|
179
|
+
/** Add transient pins inside the existing shared row/column budgets.
|
|
180
|
+
* @param {()=>{rows:number[],columns:number[]}} read */
|
|
181
|
+
retain(read) {
|
|
182
|
+
if (disposed || retainers.size >= 8) throw new RangeError('Collection retainer capacity exceeded');
|
|
183
|
+
retainers.add(read);
|
|
184
|
+
return () => { if (retainers.delete(read)) schedule(); };
|
|
185
|
+
},
|
|
166
186
|
update(next) {
|
|
187
|
+
const reset = next.query !== undefined && next.query !== config.query
|
|
188
|
+
|| next.snapshot !== undefined && next.snapshot !== config.snapshot;
|
|
167
189
|
config = { ...config, ...next }; controller.update(next); interaction.update(next);
|
|
190
|
+
if (reset) for (const fn of [...subscribers]) fn({ kind: 'reset' });
|
|
168
191
|
element.scrollTop = controller.position().top; refresh();
|
|
169
192
|
},
|
|
170
193
|
scrollToOffset(offset) { return applyScroll(controller.scrollToOffset(offset)); },
|
|
@@ -177,11 +200,16 @@ export function mountCollection(host, options) {
|
|
|
177
200
|
},
|
|
178
201
|
snapshot() { return { anchor: controller.snapshot(), selection: interaction.state().selection }; },
|
|
179
202
|
stats() { return { ...controller.stats(), listeners: disposed ? 0 : Object.keys(listeners).length,
|
|
180
|
-
observers: disposed ? 0 : 1 + observed.size, frames: frame === null ? 0 : 1
|
|
203
|
+
observers: disposed ? 0 : 1 + observed.size, frames: frame === null ? 0 : 1,
|
|
204
|
+
subscribers: subscribers.size, retainers: retainers.size }; },
|
|
181
205
|
dispose,
|
|
182
206
|
};
|
|
183
207
|
}
|
|
184
208
|
|
|
209
|
+
export { mountCollectionDrag, createDraggableCollectionWidget } from './drag.js';
|
|
210
|
+
/** @typedef {import('./drag.js').DragContainer} DragContainer */
|
|
211
|
+
/** @typedef {import('./drag.js').CollectionDragOptions} CollectionDragOptions */
|
|
212
|
+
|
|
185
213
|
/** Adapt controller disposal to the existing WidgetDef unmount lifecycle.
|
|
186
214
|
* @param {any | ((props:any, emit:any)=>any)} options */
|
|
187
215
|
export function createCollectionWidget(options) {
|
package/src/drag.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
import { isJsonValue } from '@jarenjs/core/object';
|
|
3
|
+
|
|
4
|
+
/** @typedef {{key:string, revision:string|number}} DragSource */
|
|
5
|
+
/** @typedef {{container:string, key:string, column:string}} DragTarget */
|
|
6
|
+
/** @typedef {{source:DragSource, target:DragTarget, mode:'move'|'copy'}} DragIntent */
|
|
7
|
+
/** @typedef {{phase:'idle'|'armed'|'dragging'|'validating'|'committing'|'settled'|'cancelled'|'disposed',
|
|
8
|
+
* source:DragSource|null, target:DragTarget|null, mode:'move'|'copy', input:'pointer'|'touch'|'keyboard'|null,
|
|
9
|
+
* point:{x:number,y:number}|null, reason:string|null, generation:number, pending:boolean}} DragState */
|
|
10
|
+
/** @typedef {{resolveSource:(key:string)=>DragSource|null|undefined,
|
|
11
|
+
* validTarget:(target:DragTarget)=>boolean,
|
|
12
|
+
* validate?:(intent:DragIntent, context:{signal:AbortSignal})=>boolean|Promise<boolean>,
|
|
13
|
+
* commit:(intent:DragIntent, context:{signal:AbortSignal})=>unknown|Promise<unknown>,
|
|
14
|
+
* onChange?:(state:DragState)=>void, activationDistance?:number}} DragOptions */
|
|
15
|
+
|
|
16
|
+
/** Stable intent and one pending authority call; this engine never moves source data.
|
|
17
|
+
* @param {DragOptions} options */
|
|
18
|
+
export function createDragInteraction(options) {
|
|
19
|
+
const distance = options.activationDistance ?? 6;
|
|
20
|
+
if (!Number.isFinite(distance) || distance < 0) throw new RangeError('Invalid drag activation distance');
|
|
21
|
+
let generation = 0, pending = false, disposed = false, abort = null;
|
|
22
|
+
/** @type {Omit<DragState,'generation'|'pending'>} */
|
|
23
|
+
let value = { phase: 'idle', source: null, target: null, mode: 'move', input: null, point: null, reason: null };
|
|
24
|
+
let origin = null;
|
|
25
|
+
/** @returns {DragState} */
|
|
26
|
+
const state = () => structuredClone({ ...value, generation, pending });
|
|
27
|
+
const publish = () => { options.onChange?.(state()); return state(); };
|
|
28
|
+
const sameSource = () => {
|
|
29
|
+
const current = value.source && options.resolveSource(value.source.key);
|
|
30
|
+
return !!current && current.key === value.source.key && current.revision === value.source.revision;
|
|
31
|
+
};
|
|
32
|
+
const cancel = (reason = 'cancelled') => {
|
|
33
|
+
if (disposed || ['idle', 'cancelled', 'settled'].includes(value.phase)) return state();
|
|
34
|
+
generation++; abort?.abort();
|
|
35
|
+
value = { ...value, phase: 'cancelled', target: null, reason };
|
|
36
|
+
return publish();
|
|
37
|
+
};
|
|
38
|
+
const revalidate = () => {
|
|
39
|
+
if (!['armed', 'dragging', 'validating', 'committing'].includes(value.phase)) return state();
|
|
40
|
+
if (!sameSource()) return cancel('source-changed');
|
|
41
|
+
if (value.target && !options.validTarget(value.target)) return cancel('target-unavailable');
|
|
42
|
+
return state();
|
|
43
|
+
};
|
|
44
|
+
const pointOf = (point) => {
|
|
45
|
+
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) throw new TypeError('Drag coordinates must be finite');
|
|
46
|
+
return { x: point.x, y: point.y };
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
state, cancel, revalidate,
|
|
50
|
+
/** @param {string} key @param {{input?:'pointer'|'touch'|'keyboard', point?:{x:number,y:number}, copy?:boolean}} [request] */
|
|
51
|
+
begin(key, request = {}) {
|
|
52
|
+
if (disposed || pending || ['armed', 'dragging'].includes(value.phase)) return { phase: 'refused', reason: disposed ? 'disposed' : 'busy' };
|
|
53
|
+
const source = options.resolveSource(key);
|
|
54
|
+
if (!source || source.key !== key || typeof key !== 'string'
|
|
55
|
+
|| !['string', 'number'].includes(typeof source.revision) || !isJsonValue(source.revision))
|
|
56
|
+
return { phase: 'refused', reason: 'source-unavailable' };
|
|
57
|
+
const input = request.input ?? 'pointer';
|
|
58
|
+
if (!['pointer', 'touch', 'keyboard'].includes(input)) throw new TypeError('Unknown drag input');
|
|
59
|
+
origin = pointOf(request.point ?? { x: 0, y: 0 });
|
|
60
|
+
abort = new AbortController(); generation++;
|
|
61
|
+
value = { phase: input === 'keyboard' ? 'dragging' : 'armed', source: { key, revision: source.revision },
|
|
62
|
+
target: null, mode: request.copy ? 'copy' : 'move', input, point: origin, reason: null };
|
|
63
|
+
return publish();
|
|
64
|
+
},
|
|
65
|
+
/** @param {{x:number,y:number}} point @param {DragTarget|null} target @param {boolean} [copy] */
|
|
66
|
+
move(point, target, copy = value.mode === 'copy') {
|
|
67
|
+
if (!['armed', 'dragging'].includes(revalidate().phase)) return state();
|
|
68
|
+
point = pointOf(point);
|
|
69
|
+
if (target && (typeof target.container !== 'string' || typeof target.key !== 'string' || typeof target.column !== 'string'))
|
|
70
|
+
throw new TypeError('Drop targets require stable string identities');
|
|
71
|
+
if (target && !options.validTarget(target)) return cancel('target-unavailable');
|
|
72
|
+
value = { ...value, point, target: target ? { container: target.container, key: target.key, column: target.column } : null,
|
|
73
|
+
mode: copy ? 'copy' : 'move', phase: value.phase === 'dragging'
|
|
74
|
+
|| Math.hypot(point.x - origin.x, point.y - origin.y) >= distance ? 'dragging' : 'armed' };
|
|
75
|
+
return publish();
|
|
76
|
+
},
|
|
77
|
+
async drop() {
|
|
78
|
+
if (pending) return state();
|
|
79
|
+
if (revalidate().phase !== 'dragging' || !value.target) return cancel('no-target');
|
|
80
|
+
const token = generation;
|
|
81
|
+
const intent = /** @type {DragIntent} */ (structuredClone({ source: value.source, target: value.target, mode: value.mode }));
|
|
82
|
+
pending = true; value = { ...value, phase: 'validating' };
|
|
83
|
+
try {
|
|
84
|
+
publish();
|
|
85
|
+
const permitted = await (options.validate?.(intent, { signal: abort.signal }) ?? true);
|
|
86
|
+
if (generation !== token || disposed) return state();
|
|
87
|
+
if (!permitted) return cancel('permission-rejected');
|
|
88
|
+
if (revalidate().phase !== 'validating') return state();
|
|
89
|
+
value = { ...value, phase: 'committing' }; publish();
|
|
90
|
+
const result = await options.commit(intent, { signal: abort.signal });
|
|
91
|
+
if (generation !== token || disposed) return state();
|
|
92
|
+
if (result === false) return cancel('command-rejected');
|
|
93
|
+
value = { ...value, phase: 'settled', reason: null }; return publish();
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (generation === token && !disposed) cancel('command-rejected');
|
|
97
|
+
return { ...state(), error };
|
|
98
|
+
}
|
|
99
|
+
finally { pending = false; if (!disposed) publish(); }
|
|
100
|
+
},
|
|
101
|
+
dispose() {
|
|
102
|
+
if (disposed) return;
|
|
103
|
+
disposed = true; generation++; abort?.abort();
|
|
104
|
+
value = { ...value, phase: 'disposed', source: null, target: null, point: null }; publish();
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
package/src/index.js
CHANGED
|
@@ -2,3 +2,9 @@
|
|
|
2
2
|
/** Public headless collection geometry and vnode projection. */
|
|
3
3
|
export { createCollection } from './collection.js';
|
|
4
4
|
export { createCollectionInteraction } from './interaction.js';
|
|
5
|
+
export { createDragInteraction } from './drag.js';
|
|
6
|
+
/** @typedef {import('./drag.js').DragState} DragState */
|
|
7
|
+
/** @typedef {import('./drag.js').DragSource} DragSource */
|
|
8
|
+
/** @typedef {import('./drag.js').DragTarget} DragTarget */
|
|
9
|
+
/** @typedef {import('./drag.js').DragIntent} DragIntent */
|
|
10
|
+
/** @typedef {import('./drag.js').DragOptions} DragOptions */
|