@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
|
@@ -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
|
+
};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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 */
|
package/docs/COLLECTION.md
CHANGED
|
@@ -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.
|
|
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.
|
|
56
|
-
"@jarenjs/view": "^0.
|
|
57
|
-
"@jarenjs/app": "^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
|
+
}
|