@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.
@@ -562,6 +562,15 @@ export declare function mountCollection(host: HTMLElement, options: any): {
562
562
  element: HTMLDivElement;
563
563
  refresh: () => void;
564
564
  measureVisible: () => void;
565
+ /** Observe layout/disposal without placing resources in application state.
566
+ * @param {(event:any)=>void} fn */
567
+ subscribe(fn: (event: any) => void): () => void;
568
+ /** Add transient pins inside the existing shared row/column budgets.
569
+ * @param {()=>{rows:number[],columns:number[]}} read */
570
+ retain(read: () => {
571
+ rows: number[];
572
+ columns: number[];
573
+ }): () => void;
565
574
  update(next: any): void;
566
575
  scrollToOffset(offset: any): any;
567
576
  /** @param {number} index @param {number} [column] */
@@ -596,9 +605,16 @@ export declare function mountCollection(host: HTMLElement, options: any): {
596
605
  listeners: number;
597
606
  observers: number;
598
607
  frames: number;
608
+ subscribers: number;
609
+ retainers: number;
599
610
  };
600
611
  dispose: () => void;
601
612
  };
613
+ export { mountCollectionDrag, createDraggableCollectionWidget } from './drag.js';
614
+ export type DragContainer = import('./drag.js').DragContainer;
615
+ export type CollectionDragOptions = import('./drag.js').CollectionDragOptions;
616
+ /** @typedef {import('./drag.js').DragContainer} DragContainer */
617
+ /** @typedef {import('./drag.js').CollectionDragOptions} CollectionDragOptions */
602
618
  /** Adapt controller disposal to the existing WidgetDef unmount lifecycle.
603
619
  * @param {any | ((props:any, emit:any)=>any)} options */
604
620
  export declare function createCollectionWidget(options: any | ((props: any, emit: any) => any)): {
@@ -1162,6 +1178,15 @@ export declare function createCollectionWidget(options: any | ((props: any, emit
1162
1178
  element: HTMLDivElement;
1163
1179
  refresh: () => void;
1164
1180
  measureVisible: () => void;
1181
+ /** Observe layout/disposal without placing resources in application state.
1182
+ * @param {(event:any)=>void} fn */
1183
+ subscribe(fn: (event: any) => void): () => void;
1184
+ /** Add transient pins inside the existing shared row/column budgets.
1185
+ * @param {()=>{rows:number[],columns:number[]}} read */
1186
+ retain(read: () => {
1187
+ rows: number[];
1188
+ columns: number[];
1189
+ }): () => void;
1165
1190
  update(next: any): void;
1166
1191
  scrollToOffset(offset: any): any;
1167
1192
  /** @param {number} index @param {number} [column] */
@@ -1196,6 +1221,8 @@ export declare function createCollectionWidget(options: any | ((props: any, emit
1196
1221
  listeners: number;
1197
1222
  observers: number;
1198
1223
  frames: number;
1224
+ subscribers: number;
1225
+ retainers: number;
1199
1226
  };
1200
1227
  dispose: () => void;
1201
1228
  };
@@ -1766,6 +1793,15 @@ export declare function mountProviderCollection(host: HTMLElement, coordinator:
1766
1793
  element: HTMLDivElement;
1767
1794
  refresh: () => void;
1768
1795
  measureVisible: () => void;
1796
+ /** Observe layout/disposal without placing resources in application state.
1797
+ * @param {(event:any)=>void} fn */
1798
+ subscribe(fn: (event: any) => void): () => void;
1799
+ /** Add transient pins inside the existing shared row/column budgets.
1800
+ * @param {()=>{rows:number[],columns:number[]}} read */
1801
+ retain(read: () => {
1802
+ rows: number[];
1803
+ columns: number[];
1804
+ }): () => void;
1769
1805
  update(next: any): void;
1770
1806
  scrollToOffset(offset: any): any;
1771
1807
  /** @param {number} index @param {number} [column] */
@@ -1800,6 +1836,8 @@ export declare function mountProviderCollection(host: HTMLElement, coordinator:
1800
1836
  listeners: number;
1801
1837
  observers: number;
1802
1838
  frames: number;
1839
+ subscribers: number;
1840
+ retainers: number;
1803
1841
  };
1804
1842
  dispose: () => void;
1805
1843
  };
@@ -0,0 +1,91 @@
1
+ export type DragSource = {
2
+ key: string;
3
+ revision: string | number;
4
+ };
5
+ export type DragTarget = {
6
+ container: string;
7
+ key: string;
8
+ column: string;
9
+ };
10
+ export type DragIntent = {
11
+ source: DragSource;
12
+ target: DragTarget;
13
+ mode: 'move' | 'copy';
14
+ };
15
+ export type DragState = {
16
+ phase: 'idle' | 'armed' | 'dragging' | 'validating' | 'committing' | 'settled' | 'cancelled' | 'disposed';
17
+ source: DragSource | null;
18
+ target: DragTarget | null;
19
+ mode: 'move' | 'copy';
20
+ input: 'pointer' | 'touch' | 'keyboard' | null;
21
+ point: {
22
+ x: number;
23
+ y: number;
24
+ } | null;
25
+ reason: string | null;
26
+ generation: number;
27
+ pending: boolean;
28
+ };
29
+ export type DragOptions = {
30
+ resolveSource: (key: string) => DragSource | null | undefined;
31
+ validTarget: (target: DragTarget) => boolean;
32
+ validate?: (intent: DragIntent, context: {
33
+ signal: AbortSignal;
34
+ }) => boolean | Promise<boolean>;
35
+ commit: (intent: DragIntent, context: {
36
+ signal: AbortSignal;
37
+ }) => unknown | Promise<unknown>;
38
+ onChange?: (state: DragState) => void;
39
+ activationDistance?: number;
40
+ };
41
+ /** @typedef {{key:string, revision:string|number}} DragSource */
42
+ /** @typedef {{container:string, key:string, column:string}} DragTarget */
43
+ /** @typedef {{source:DragSource, target:DragTarget, mode:'move'|'copy'}} DragIntent */
44
+ /** @typedef {{phase:'idle'|'armed'|'dragging'|'validating'|'committing'|'settled'|'cancelled'|'disposed',
45
+ * source:DragSource|null, target:DragTarget|null, mode:'move'|'copy', input:'pointer'|'touch'|'keyboard'|null,
46
+ * point:{x:number,y:number}|null, reason:string|null, generation:number, pending:boolean}} DragState */
47
+ /** @typedef {{resolveSource:(key:string)=>DragSource|null|undefined,
48
+ * validTarget:(target:DragTarget)=>boolean,
49
+ * validate?:(intent:DragIntent, context:{signal:AbortSignal})=>boolean|Promise<boolean>,
50
+ * commit:(intent:DragIntent, context:{signal:AbortSignal})=>unknown|Promise<unknown>,
51
+ * onChange?:(state:DragState)=>void, activationDistance?:number}} DragOptions */
52
+ /** Stable intent and one pending authority call; this engine never moves source data.
53
+ * @param {DragOptions} options */
54
+ export declare function createDragInteraction(options: DragOptions): {
55
+ state: () => DragState;
56
+ cancel: (reason?: string) => DragState;
57
+ revalidate: () => DragState;
58
+ /** @param {string} key @param {{input?:'pointer'|'touch'|'keyboard', point?:{x:number,y:number}, copy?:boolean}} [request] */
59
+ begin(key: string, request?: {
60
+ input?: 'pointer' | 'touch' | 'keyboard';
61
+ point?: {
62
+ x: number;
63
+ y: number;
64
+ };
65
+ copy?: boolean;
66
+ }): DragState | {
67
+ phase: string;
68
+ reason: string;
69
+ };
70
+ /** @param {{x:number,y:number}} point @param {DragTarget|null} target @param {boolean} [copy] */
71
+ move(point: {
72
+ x: number;
73
+ y: number;
74
+ }, target: DragTarget | null, copy?: boolean): DragState;
75
+ drop(): Promise<DragState | {
76
+ phase: 'idle' | 'armed' | 'dragging' | 'validating' | 'committing' | 'settled' | 'cancelled' | 'disposed';
77
+ source: DragSource | null;
78
+ target: DragTarget | null;
79
+ mode: 'move' | 'copy';
80
+ input: 'pointer' | 'touch' | 'keyboard' | null;
81
+ point: {
82
+ x: number;
83
+ y: number;
84
+ } | null;
85
+ reason: string | null;
86
+ generation: number;
87
+ pending: boolean;
88
+ error: unknown;
89
+ }>;
90
+ dispose(): void;
91
+ };
@@ -1,3 +1,14 @@
1
1
  /** Public headless collection geometry and vnode projection. */
2
2
  export { createCollection } from './collection.js';
3
3
  export { createCollectionInteraction } from './interaction.js';
4
+ export { createDragInteraction } from './drag.js';
5
+ export type DragState = import('./drag.js').DragState;
6
+ export type DragSource = import('./drag.js').DragSource;
7
+ export type DragTarget = import('./drag.js').DragTarget;
8
+ export type DragIntent = import('./drag.js').DragIntent;
9
+ export type DragOptions = import('./drag.js').DragOptions;
10
+ /** @typedef {import('./drag.js').DragState} DragState */
11
+ /** @typedef {import('./drag.js').DragSource} DragSource */
12
+ /** @typedef {import('./drag.js').DragTarget} DragTarget */
13
+ /** @typedef {import('./drag.js').DragIntent} DragIntent */
14
+ /** @typedef {import('./drag.js').DragOptions} DragOptions */
@@ -108,3 +108,105 @@ reads mounted DOM. The source must advertise `completeExport`. See the normative
108
108
 
109
109
  Actual assistive technology, physical touch and native OS IME qualification remain
110
110
  separate from automated browser evidence; see [measurements](MEASUREMENTS.md).
111
+
112
+ ## Drag intent and authoritative commands
113
+
114
+ `createDragInteraction` from `@jarenjs/collection` owns a single drag with a stable
115
+ `source:{key,revision}`, `target:{container,key,column}` and `mode:'move'|'copy'`.
116
+ Source revisions are finite numbers or strings; every target identity is a string.
117
+ Indices and DOM objects never enter this intent. The engine requires
118
+ `resolveSource(key)`, `validTarget(target)` and `commit(intent,{signal})`; an optional
119
+ `validate(intent,{signal})` can check asynchronous permission before dispatch.
120
+
121
+ `begin(key,{input,point,copy})` arms pointer/touch input and immediately activates
122
+ keyboard input. Pointer movement must meet `activationDistance` before dragging.
123
+ `move(point,target,copy)` changes only transient intent. `revalidate()` resolves
124
+ current source revision and target availability; source changes or invalid targets
125
+ cancel. `drop()` validates and then dispatches one command. Repeated drops do not
126
+ dispatch again. Permission denial, a false command result or a rejected promise
127
+ cancels without any optimistic source mutation. The authoritative host owns all
128
+ policy, revision checks and actual movement.
129
+
130
+ `state()` returns a detached serializable snapshot with phase, identity, point,
131
+ generation and pending status. `cancel(reason)` and idempotent `dispose()` fence
132
+ late replies and abort their signals. A cancelled asynchronous callback retains
133
+ the single pending credit until it settles, so repeated cancellation cannot
134
+ accumulate new authority calls on that engine. Cancellation after dispatch does
135
+ not undo a server command: the host must honour the signal or reconcile its
136
+ authoritative result. The engine never applies a late reply to source data.
137
+
138
+ ## Owned collection drag adapter
139
+
140
+ `mountCollectionDrag(containers,options)` from `@jarenjs/collection/component`
141
+ attaches to already-mounted collections. Each container supplies a unique `id`,
142
+ its `mounted` handle, `columnKey(index)` and `indexOfColumn(key)`. Options supply
143
+ the engine policy above and `locateSource(key) => {container,key,column}` for its
144
+ current cell. `disabled(target)` may additionally reject a cell. Resolvers are
145
+ synchronous and must be bounded; unloaded or unmounted targets are unavailable.
146
+
147
+ ```js
148
+ import { mountCollectionDrag } from '@jarenjs/collection/component';
149
+
150
+ const drag = mountCollectionDrag([{
151
+ id: 'catalog', mounted: grid,
152
+ columnKey: (index) => columns[index].id,
153
+ indexOfColumn: (key) => columnIndex.get(key) ?? -1
154
+ }], {
155
+ resolveSource: (key) => records.get(key), // { key, revision }
156
+ locateSource: (key) => positions.get(key), // stable container/row/column keys
157
+ validTarget: (target) => permittedCells.has(target.key),
158
+ commit: (intent, { signal }) => commands.moveOrCopy(intent, { signal })
159
+ });
160
+ // A cell renderer supplies a dedicated, focusable handle:
161
+ const handle = ['button', {
162
+ 'data-jc-drag': record.key, style: { touchAction: 'none' }
163
+ }, 'Move item'];
164
+ // The owning widget or route disposes the adapter with its collections.
165
+ drag.dispose();
166
+ ```
167
+
168
+ Pointer input uses capture and client-coordinate hit testing. Touch activation
169
+ requires a dedicated `data-jc-drag` handle whose computed `touch-action` is `none`;
170
+ the remainder of the collection keeps ordinary browser scrolling. No global
171
+ touch-scroll suppression or long-press heuristic is installed. Pointer coordinates
172
+ come from actual cell rectangles, so nested scrolling, pinned headers, transforms
173
+ and CSS zoom do not require storing logical indices as positions. A keyed DOM move
174
+ can reacquire capture for the same connected source; actual capture loss cancels.
175
+
176
+ Space or Enter on a focused handle starts keyboard dragging. Arrows resolve the
177
+ next current row/column key; horizontal motion follows the collection direction.
178
+ Tab switches containers, Alt selects copy, and Enter/Space drops. Escape cancels.
179
+ Text controls and composition retain their native editing keys; Escape can cancel
180
+ an active pointer drag when a text control still has focus, while composing input
181
+ remains untouched. A polite status region announces target, result and cancellation.
182
+ Focus returns to the original connected element, or a surviving collection if it
183
+ was removed. Native OS IME and assistive-technology behaviour still need manual
184
+ qualification; synthetic events do not establish those results.
185
+
186
+ The overlay is text in an owned portal, outside the grid's clipped containers.
187
+ It uses the browser top layer when popovers are available and otherwise a fixed
188
+ positioned portal. A custom `portal` owns its own CSS coordinate context. Only
189
+ one overlay and one animation frame belong to an adapter. Auto-scroll visits a
190
+ bounded chain of scrollable ancestors, stops at limits and on cancellation, and
191
+ re-resolves targets after movement. Existing row/column pin budgets include the
192
+ drag source and focus together. A pin or DOM-credit refusal cancels the drag.
193
+
194
+ `mounted.subscribe(listener)` observes layout, reset and disposal;
195
+ `mounted.retain(() => ({rows,columns}))` contributes transient indices resolved
196
+ from stable keys inside the same pin budget. Both return idempotent unsubscribe
197
+ functions. These host resources stay outside app state. Their own finite admission
198
+ limits refuse excess rather than silently keeping more listeners or pins.
199
+
200
+ The drag handle exposes `interaction`, `cancel`, `update`, `stats` and idempotent
201
+ `dispose`. `update` replaces authority callbacks; geometry, portal and scheduling
202
+ ownership are fixed for a mount. Window blur, pointer cancellation, lost capture,
203
+ source reset, removed source and collection disposal clear the gesture. Disposal
204
+ attempts every acquired cleanup even if an observer throws. Stats count owned
205
+ listeners, subscriptions, frames, overlays, status nodes and pending authority.
206
+
207
+ `createDraggableCollectionWidget(options)` combines one collection and its drag
208
+ adapter with the existing WidgetDef lifetime. Supply `id`, `collection`,
209
+ `columnKey`, `indexOfColumn` and `drag`; a factory may derive them from props and
210
+ the host emit callback. Updating props refreshes policy and collection state.
211
+ A changed container identity requires a new widget key. Multi-container widgets
212
+ own one `mountCollectionDrag` handle and dispose it before their mounted collections.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/collection",
3
3
  "private": false,
4
- "version": "0.85.0",
4
+ "version": "0.87.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -52,8 +52,8 @@
52
52
  "prepack": "npm run build:types"
53
53
  },
54
54
  "dependencies": {
55
- "@jarenjs/core": "^0.85.0",
56
- "@jarenjs/view": "^0.85.0",
57
- "@jarenjs/app": "^0.85.0"
55
+ "@jarenjs/core": "^0.87.0",
56
+ "@jarenjs/view": "^0.87.0",
57
+ "@jarenjs/app": "^0.87.0"
58
58
  }
59
59
  }
@@ -0,0 +1,317 @@
1
+ //@ts-check
2
+ import { createDragInteraction } from '../drag.js';
3
+ import { mountCollection } from './index.js';
4
+
5
+ /** @typedef {import('../drag.js').DragTarget} DragTarget */
6
+ /** @typedef {{id:string, mounted:ReturnType<typeof mountCollection>,
7
+ * columnKey:(index:number)=>string, indexOfColumn:(key:string)=>number}} DragContainer */
8
+ /** @typedef {import('../drag.js').DragOptions & {
9
+ * locateSource:(key:string)=>DragTarget|null, disabled?:(target:DragTarget)=>boolean,
10
+ * label?:(key:string)=>string, portal?:HTMLElement, maxContainers?:number,
11
+ * edgeSize?:number, maxScrollPerFrame?:number,
12
+ * requestFrame?:(fn:FrameRequestCallback)=>number, cancelFrame?:(id:number)=>void
13
+ * }} CollectionDragOptions */
14
+
15
+ /** Own sensors and an overlay across bounded, already-mounted collections.
16
+ * Touch starts only on handles marked data-jc-drag and styled touch-action:none.
17
+ * @param {DragContainer[]} containers @param {CollectionDragOptions} options */
18
+ export function mountCollectionDrag(containers, options) {
19
+ const maxContainers = options.maxContainers ?? 8;
20
+ const edge = options.edgeSize ?? 32, speed = options.maxScrollPerFrame ?? 16;
21
+ if (!Number.isSafeInteger(maxContainers) || maxContainers < 1 || !containers.length || containers.length > maxContainers
22
+ || !Number.isFinite(edge) || edge <= 0 || !Number.isFinite(speed) || speed <= 0)
23
+ throw new RangeError('Invalid drag container or scroll credits');
24
+ const byId = new Map(containers.map((container) => [container.id, container]));
25
+ if (byId.size !== containers.length || containers.some((c) => typeof c.id !== 'string'
26
+ || typeof c.columnKey !== 'function' || typeof c.indexOfColumn !== 'function')) throw new TypeError('Drag containers need unique stable identities and column lookups');
27
+ const document = containers[0].mounted.element.ownerDocument, window = document.defaultView;
28
+ if (containers.some((c) => c.mounted.element.ownerDocument !== document)) throw new TypeError('Drag containers must share a document');
29
+ const portal = options.portal ?? document.documentElement;
30
+ const requestFrame = options.requestFrame ?? ((fn) => window.requestAnimationFrame(fn));
31
+ const cancelFrame = options.cancelFrame ?? ((id) => window.cancelAnimationFrame(id));
32
+ let disposed = false, frame = null, overlay = null, pointer = null, retained = null, focusBefore = null;
33
+ let lastPoint = null, copy = false, suppressedClick = null, announcement = '';
34
+ const stops = [];
35
+ const status = document.createElement('div');
36
+ status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite'); status.setAttribute('aria-atomic', 'true');
37
+ Object.assign(status.style, { position: 'fixed', width: '1px', height: '1px', overflow: 'hidden', clipPath: 'inset(50%)' });
38
+ function location(target) {
39
+ const container = byId.get(target?.container);
40
+ if (!container || !container.mounted.element.isConnected) return null;
41
+ const config = container.mounted.controller.options();
42
+ const row = config.indexOf?.(target.key) ?? container.mounted.controller.layout().rows.find((r) => r.key === target.key)?.index;
43
+ const column = container.indexOfColumn(target.column);
44
+ if (!Number.isSafeInteger(row) || row < 0 || config.keyAt(row) !== target.key
45
+ || !Number.isSafeInteger(column) || column < 0 || column >= config.columnCount || container.columnKey(column) !== target.column) return null;
46
+ const cell = container.mounted.element.querySelector(`[data-row="${row}"] [data-column="${column}"]`);
47
+ return { container, row, column, cell };
48
+ }
49
+ const available = (target) => {
50
+ const found = location(target);
51
+ return !!found?.cell && !options.disabled?.(target) && (options.validTarget?.(target) ?? true);
52
+ };
53
+ const editing = (target) => !!target?.closest?.('input,textarea,select,[contenteditable]:not([contenteditable="false"])');
54
+ function cleanGesture() {
55
+ let failure;
56
+ const clean = (fn) => { try { fn(); } catch (error) { failure ??= error; } };
57
+ if (frame !== null) clean(() => cancelFrame(frame));
58
+ frame = null;
59
+ const capture = pointer; pointer = null;
60
+ clean(() => { if (capture?.element.hasPointerCapture?.(capture.id)) capture.element.releasePointerCapture(capture.id); });
61
+ const release = retained; retained = null; if (release) clean(release);
62
+ const priorOverlay = overlay; overlay = null; clean(() => priorOverlay?.remove());
63
+ if (focusBefore) {
64
+ const target = focusBefore; focusBefore = null;
65
+ clean(() => {
66
+ if (target.isConnected) target.focus({ preventScroll: true });
67
+ else containers.find((c) => c.mounted.element.isConnected)?.mounted.element.focus({ preventScroll: true });
68
+ });
69
+ }
70
+ if (failure) throw failure;
71
+ }
72
+ function changed(state) {
73
+ const active = ['dragging', 'validating', 'committing'].includes(state.phase);
74
+ if (active) {
75
+ if (!overlay) {
76
+ overlay = document.createElement('div'); overlay.setAttribute('data-jc-overlay', '');
77
+ overlay.setAttribute('aria-hidden', 'true');
78
+ Object.assign(overlay.style, { position: 'fixed', margin: '0', inset: 'auto', pointerEvents: 'none',
79
+ zIndex: '2147483647', padding: '8px', background: 'Canvas', color: 'CanvasText', border: '1px solid currentColor' });
80
+ portal.appendChild(overlay);
81
+ if (typeof overlay.showPopover === 'function') { overlay.setAttribute('popover', 'manual'); overlay.showPopover(); }
82
+ }
83
+ overlay.textContent = `${state.mode === 'copy' ? 'Copy' : 'Move'} ${options.label?.(state.source.key) ?? state.source.key}`;
84
+ overlay.style.left = `${state.point.x + 12}px`; overlay.style.top = `${state.point.y + 12}px`;
85
+ }
86
+ if (!['armed', 'dragging', 'validating', 'committing'].includes(state.phase)) cleanGesture();
87
+ if (!['armed', 'dragging'].includes(state.phase) && frame !== null) { cancelFrame(frame); frame = null; }
88
+ const message = state.phase === 'dragging' ? `${state.mode === 'copy' ? 'Copying' : 'Moving'} ${state.source.key}${state.target ? ` to ${state.target.key}, ${state.target.column}` : ''}. Enter to drop, Escape to cancel.`
89
+ : state.phase === 'settled' ? 'Drop accepted.' : state.phase === 'cancelled' ? `Drag cancelled: ${state.reason}.`
90
+ : state.phase === 'validating' || state.phase === 'committing' ? 'Checking drop.' : '';
91
+ if (message !== announcement) { announcement = message; status.textContent = message; }
92
+ options.onChange?.(state);
93
+ }
94
+ const interaction = createDragInteraction({ activationDistance: options.activationDistance,
95
+ resolveSource: (key) => options.resolveSource(key), validTarget: available,
96
+ validate: (intent, context) => options.validate?.(intent, context) ?? true,
97
+ commit: (intent, context) => options.commit(intent, context), onChange: changed });
98
+ function hit(point) {
99
+ const elements = document.elementsFromPoint(point.x, point.y).slice(0, 64);
100
+ for (const element of elements) {
101
+ const cell = element.closest?.('.jc-cell'), row = cell?.closest('.jc-row');
102
+ if (!cell || !row) continue;
103
+ const container = containers.find((c) => c.mounted.element.contains(cell));
104
+ if (!container) continue;
105
+ const key = row.getAttribute('data-key'), column = container.columnKey(Number(cell.getAttribute('data-column')));
106
+ if (typeof key !== 'string' || typeof column !== 'string') continue;
107
+ const target = { container: container.id, key, column };
108
+ return available(target) ? target : null;
109
+ }
110
+ return null;
111
+ }
112
+ function scroll(point) {
113
+ const container = containers.find((c) => {
114
+ const r = c.mounted.element.getBoundingClientRect();
115
+ return point.x >= r.left && point.x <= r.right && point.y >= r.top && point.y <= r.bottom;
116
+ });
117
+ let node = container?.mounted.element, xDone = false, yDone = false, moved = false;
118
+ for (let i = 0; node && i < 8; i++, node = node.parentElement) {
119
+ const r = node.getBoundingClientRect();
120
+ if (r.width <= 0 || r.height <= 0) continue;
121
+ const delta = (v, low, high) => v < low + edge ? -speed : v > high - edge ? speed : 0;
122
+ const left = node.scrollLeft, top = node.scrollTop;
123
+ const maxX = Math.max(0, node.scrollWidth - node.clientWidth), maxY = Math.max(0, node.scrollHeight - node.clientHeight);
124
+ const style = window.getComputedStyle(node), rtl = style.direction === 'rtl';
125
+ const root = node === document.scrollingElement;
126
+ if (!xDone && maxX && (root || /auto|scroll/.test(style.overflowX))) node.scrollLeft = Math.max(rtl ? -maxX : 0, Math.min(rtl ? 0 : maxX,
127
+ left + delta(point.x, r.left, r.right) / Math.max(0.01, r.width / node.offsetWidth)));
128
+ if (!yDone && maxY && (root || /auto|scroll/.test(style.overflowY))) node.scrollTop = Math.max(0, Math.min(maxY,
129
+ top + delta(point.y, r.top, r.bottom) / Math.max(0.01, r.height / node.offsetHeight)));
130
+ xDone ||= node.scrollLeft !== left; yDone ||= node.scrollTop !== top;
131
+ moved ||= xDone || yDone;
132
+ }
133
+ if (moved) container?.mounted.refresh();
134
+ }
135
+ function tick() {
136
+ frame = null;
137
+ if (disposed || !['armed', 'dragging'].includes(interaction.revalidate().phase)) return;
138
+ if (!location(options.locateSource(interaction.state().source.key))?.cell) { interaction.cancel('source-unavailable'); return; }
139
+ if (lastPoint && interaction.state().input !== 'keyboard') {
140
+ if (interaction.state().phase === 'dragging') {
141
+ interaction.move(lastPoint, null, copy);
142
+ scroll(lastPoint);
143
+ }
144
+ interaction.move(lastPoint, hit(lastPoint), copy);
145
+ }
146
+ if (['armed', 'dragging'].includes(interaction.state().phase)) frame = requestFrame(tick);
147
+ }
148
+ function start(key, input, point, copyMode) {
149
+ const current = interaction.state();
150
+ if (current.pending || ['armed', 'dragging'].includes(current.phase)) return false;
151
+ const origin = options.locateSource(key), place = location(origin);
152
+ if (!place?.cell) return false;
153
+ focusBefore = document.activeElement; lastPoint = point; copy = copyMode;
154
+ try {
155
+ const started = interaction.begin(key, { input, point, copy: copyMode });
156
+ if (!['armed', 'dragging'].includes(started.phase)) { focusBefore = null; return false; }
157
+ retained = place.container.mounted.retain(() => {
158
+ const current = location(options.locateSource(key));
159
+ return current ? { rows: [current.row], columns: [current.column] } : { rows: [], columns: [] };
160
+ });
161
+ place.container.mounted.refresh();
162
+ if (!['armed', 'dragging'].includes(interaction.state().phase)) return false;
163
+ if (input === 'keyboard') interaction.move(point, origin, copyMode);
164
+ frame = requestFrame(tick); return true;
165
+ }
166
+ catch (error) {
167
+ try { interaction.cancel('activation-failed'); } finally { cleanGesture(); }
168
+ throw error;
169
+ }
170
+ }
171
+ function pointerdown(event) {
172
+ suppressedClick = null;
173
+ if (disposed || pointer || event.button !== 0 || !event.isPrimary || editing(event.target)) return;
174
+ const handle = event.target.closest?.('[data-jc-drag]');
175
+ if (!handle || !containers.some((c) => c.mounted.element.contains(handle))) return;
176
+ if (event.pointerType === 'touch' && window.getComputedStyle(handle).touchAction !== 'none') return;
177
+ if (start(handle.getAttribute('data-jc-drag'), event.pointerType === 'touch' ? 'touch' : 'pointer', { x: event.clientX, y: event.clientY }, event.altKey)) {
178
+ pointer = { id: event.pointerId, element: handle };
179
+ try { handle.setPointerCapture(event.pointerId); }
180
+ catch (error) { interaction.cancel('capture-failed'); throw error; }
181
+ if (event.pointerType === 'touch') event.preventDefault();
182
+ }
183
+ }
184
+ function pointermove(event) {
185
+ if (!pointer || pointer.id !== event.pointerId) return;
186
+ lastPoint = { x: event.clientX, y: event.clientY }; copy = event.altKey;
187
+ const next = interaction.move(lastPoint, hit(lastPoint), copy);
188
+ if (next.phase === 'dragging') event.preventDefault();
189
+ }
190
+ function pointerup(event) {
191
+ if (!pointer || pointer.id !== event.pointerId) return;
192
+ if (interaction.state().phase === 'dragging') {
193
+ event.preventDefault(); suppressedClick = { id: pointer.id, until: Date.now() + 300 };
194
+ const held = pointer; pointer = null;
195
+ if (held.element.hasPointerCapture(held.id)) held.element.releasePointerCapture(held.id);
196
+ void interaction.drop();
197
+ }
198
+ else interaction.cancel('not-activated');
199
+ }
200
+ function keydown(event) {
201
+ if (event.isComposing) return;
202
+ const current = interaction.state();
203
+ if (['armed', 'dragging', 'validating', 'committing'].includes(current.phase) && event.key === 'Escape') {
204
+ event.preventDefault(); event.stopImmediatePropagation(); interaction.cancel('escape'); return;
205
+ }
206
+ if (editing(event.target)) return;
207
+ if (['armed', 'dragging', 'validating', 'committing'].includes(current.phase)) {
208
+ if (event.key === 'Alt' && current.phase === 'dragging') {
209
+ copy = event.altKey; interaction.move(current.point, current.target, copy); return;
210
+ }
211
+ if (current.input !== 'keyboard' || current.phase !== 'dragging') return;
212
+ if (event.key === 'Enter' || event.key === ' ') {
213
+ event.preventDefault(); event.stopImmediatePropagation(); void interaction.drop(); return;
214
+ }
215
+ const place = location(current.target ?? options.locateSource(current.source.key));
216
+ if (!place || !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab', 'Alt'].includes(event.key)) return;
217
+ event.preventDefault(); event.stopImmediatePropagation();
218
+ let container = place.container, row = place.row, column = place.column;
219
+ if (event.key === 'ArrowUp') row--;
220
+ if (event.key === 'ArrowDown') row++;
221
+ const direction = container.mounted.controller.options().direction === 'rtl' ? -1 : 1;
222
+ if (event.key === 'ArrowLeft') column -= direction;
223
+ if (event.key === 'ArrowRight') column += direction;
224
+ if (event.key === 'Tab') container = containers[(containers.indexOf(container) + (event.shiftKey ? containers.length - 1 : 1)) % containers.length];
225
+ const config = container.mounted.controller.options();
226
+ row = Math.max(0, Math.min(config.count - 1, row)); column = Math.max(0, Math.min(config.columnCount - 1, column));
227
+ container.mounted.scrollToIndex(row, column);
228
+ const target = { container: container.id, key: config.keyAt(row), column: container.columnKey(column) };
229
+ const rect = location(target)?.cell?.getBoundingClientRect();
230
+ if (rect) interaction.move({ x: rect.left, y: rect.top }, target, event.altKey);
231
+ return;
232
+ }
233
+ if (![' ', 'Enter'].includes(event.key)) return;
234
+ const handle = event.target.closest?.('[data-jc-drag]');
235
+ if (!handle || !containers.some((c) => c.mounted.element.contains(handle))) return;
236
+ const rect = handle.getBoundingClientRect();
237
+ if (start(handle.getAttribute('data-jc-drag'), 'keyboard', { x: rect.left, y: rect.top }, event.altKey)) {
238
+ event.preventDefault(); event.stopImmediatePropagation();
239
+ }
240
+ }
241
+ const listen = (node, name, fn, config = true) => {
242
+ node.addEventListener(name, fn, config); stops.push(() => node.removeEventListener(name, fn, config));
243
+ };
244
+ function dispose() {
245
+ if (disposed) return;
246
+ disposed = true;
247
+ let failure;
248
+ const clean = (fn) => { try { fn(); } catch (error) { failure ??= error; } };
249
+ clean(() => interaction.dispose()); clean(cleanGesture);
250
+ for (const stop of stops.splice(0)) clean(stop);
251
+ clean(() => status.remove());
252
+ if (failure) throw failure;
253
+ }
254
+ try {
255
+ portal.appendChild(status);
256
+ listen(window, 'pointerdown', pointerdown);
257
+ listen(window, 'pointermove', pointermove, { capture: true, passive: false });
258
+ listen(window, 'pointerup', pointerup);
259
+ listen(window, 'pointercancel', (event) => { if (pointer?.id === event.pointerId) interaction.cancel('pointer-cancel'); });
260
+ listen(window, 'lostpointercapture', (event) => {
261
+ if (pointer?.id === event.pointerId && !pointer.element.hasPointerCapture(pointer.id)) interaction.cancel('capture-lost');
262
+ });
263
+ listen(window, 'blur', (event) => { if (event.target === window) interaction.cancel('blur'); });
264
+ listen(window, 'keydown', keydown);
265
+ listen(window, 'keyup', (event) => { if (event.key === 'Alt' && interaction.state().phase === 'dragging') {
266
+ const current = interaction.state(); copy = false; interaction.move(current.point, current.target, false);
267
+ } });
268
+ listen(window, 'click', (event) => {
269
+ if (suppressedClick && event.pointerId === suppressedClick.id && Date.now() < suppressedClick.until) {
270
+ suppressedClick = null; event.preventDefault(); event.stopImmediatePropagation();
271
+ }
272
+ });
273
+ for (const container of containers) stops.push(container.mounted.subscribe((event) => {
274
+ if (event.kind === 'dispose') { dispose(); return; }
275
+ if (event.kind === 'reset') interaction.cancel('source-reset');
276
+ else if (event.state !== 'ready') interaction.cancel(event.reason ?? 'collection-unavailable');
277
+ else if (interaction.state().phase !== 'committing') {
278
+ interaction.revalidate();
279
+ // A keyed DOM move can release capture while preserving the source.
280
+ // Reacquire only for the same connected handle and live pointer.
281
+ if (pointer?.element.isConnected && !pointer.element.hasPointerCapture(pointer.id)) pointer.element.setPointerCapture(pointer.id);
282
+ }
283
+ }));
284
+ }
285
+ catch (error) { dispose(); throw error; }
286
+ return { interaction, dispose, cancel: (reason = 'cancelled') => interaction.cancel(reason),
287
+ /** Update authority callbacks; geometry and resource owners belong to this mount.
288
+ * @param {Partial<CollectionDragOptions>} next */
289
+ update(next) { options = { ...options, ...next }; return interaction.revalidate(); },
290
+ stats: () => ({ listeners: disposed ? 0 : 9, subscriptions: disposed ? 0 : containers.length,
291
+ frames: frame === null ? 0 : 1, overlays: overlay ? 1 : 0, statusNodes: disposed ? 0 : 1,
292
+ pending: interaction.state().pending ? 1 : 0 }) };
293
+ }
294
+
295
+ /** One collection and its interaction share the existing WidgetDef lifetime.
296
+ * @param {any|((props:any,emit:any)=>any)} options */
297
+ export function createDraggableCollectionWidget(options) {
298
+ const config = (props, emit) => typeof options === 'function' ? options(props, emit) : { ...options, ...props };
299
+ return {
300
+ mount(host, props, emit) {
301
+ const settings = config(props, emit), mounted = mountCollection(host, settings.collection);
302
+ try {
303
+ const container = { id: settings.id, mounted, columnKey: settings.columnKey, indexOfColumn: settings.indexOfColumn };
304
+ const drag = mountCollectionDrag([container], settings.drag);
305
+ return { mounted, drag, container, emit };
306
+ }
307
+ catch (error) { mounted.dispose(); throw error; }
308
+ },
309
+ update(handle, props) {
310
+ const settings = config(props, handle.emit);
311
+ if (settings.id !== handle.container.id) throw new TypeError('A different drag container requires a new widget key');
312
+ handle.container.columnKey = settings.columnKey; handle.container.indexOfColumn = settings.indexOfColumn;
313
+ handle.drag.update(settings.drag); handle.mounted.update(settings.collection);
314
+ },
315
+ unmount(handle) { try { handle.drag.dispose(); } finally { handle.mounted.dispose(); } },
316
+ };
317
+ }