@jarenjs/collection 0.85.0 → 0.86.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.
@@ -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 result = focus && config.keyAt(focus.index) === focus.key ? controller.pin([focus.index], [focus.column]) : controller.pin();
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 */