@erickxavier/no-js 1.1.0 → 1.2.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/README.md +2 -2
- package/dist/cjs/no.js +44 -5
- package/dist/cjs/no.js.map +4 -4
- package/dist/esm/no.js +44 -5
- package/dist/esm/no.js.map +4 -4
- package/dist/iife/no.js +44 -5
- package/dist/iife/no.js.map +4 -4
- package/package.json +1 -1
- package/src/animations.js +12 -9
- package/src/context.js +14 -1
- package/src/directives/binding.js +1 -1
- package/src/directives/conditionals.js +4 -4
- package/src/directives/dnd.js +1150 -0
- package/src/directives/events.js +2 -1
- package/src/directives/http.js +14 -11
- package/src/directives/i18n.js +5 -1
- package/src/directives/loops.js +21 -3
- package/src/directives/refs.js +4 -0
- package/src/directives/state.js +3 -3
- package/src/directives/styling.js +10 -7
- package/src/evaluate.js +9 -3
- package/src/i18n.js +3 -2
- package/src/index.js +2 -1
- package/src/router.js +3 -0
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
2
|
+
// DIRECTIVES: drag, drop, drag-list, drag-multiple
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
import { evaluate, _execStatement, resolve } from "../evaluate.js";
|
|
6
|
+
import { findContext } from "../dom.js";
|
|
7
|
+
import { createContext } from "../context.js";
|
|
8
|
+
import { registerDirective, processTree } from "../registry.js";
|
|
9
|
+
import { _onDispose, _warn } from "../globals.js";
|
|
10
|
+
|
|
11
|
+
// ─── Module-scoped DnD coordination state ─────────────────────────────
|
|
12
|
+
const _dndState = {
|
|
13
|
+
dragging: null, // { item, type, effect, sourceEl, sourceCtx, sourceList, sourceIndex, listDirective }
|
|
14
|
+
selected: new Map(), // group → Set<{ item, el, ctx }>
|
|
15
|
+
placeholder: null, // current placeholder DOM element
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// ─── Inject built-in DnD styles (once) ────────────────────────────────
|
|
19
|
+
|
|
20
|
+
// Count visible children, recursing through display:contents wrappers
|
|
21
|
+
function _countVisibleChildren(el) {
|
|
22
|
+
let count = 0;
|
|
23
|
+
for (const child of el.children) {
|
|
24
|
+
if (child.classList.contains("nojs-drop-placeholder")) continue;
|
|
25
|
+
const style = child.style || {};
|
|
26
|
+
if (style.display === "contents") {
|
|
27
|
+
count += _countVisibleChildren(child);
|
|
28
|
+
} else {
|
|
29
|
+
count++;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return count;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function _injectDndStyles() {
|
|
36
|
+
if (typeof document === "undefined") return;
|
|
37
|
+
if (document.querySelector("style[data-nojs-dnd]")) return;
|
|
38
|
+
|
|
39
|
+
const css = `
|
|
40
|
+
.nojs-dragging {
|
|
41
|
+
opacity: 0.5;
|
|
42
|
+
cursor: grabbing !important;
|
|
43
|
+
}
|
|
44
|
+
.nojs-drag-over {
|
|
45
|
+
outline: 2px dashed #3b82f6;
|
|
46
|
+
outline-offset: -2px;
|
|
47
|
+
}
|
|
48
|
+
.nojs-drop-placeholder {
|
|
49
|
+
border: 2px dashed #3b82f6;
|
|
50
|
+
border-radius: 6px;
|
|
51
|
+
background: color-mix(in srgb, #3b82f6 6%, transparent);
|
|
52
|
+
box-sizing: border-box;
|
|
53
|
+
min-height: 2.5rem;
|
|
54
|
+
transition: all 0.15s ease;
|
|
55
|
+
pointer-events: none;
|
|
56
|
+
}
|
|
57
|
+
.nojs-drop-reject {
|
|
58
|
+
outline: 2px dashed #ef4444;
|
|
59
|
+
outline-offset: -2px;
|
|
60
|
+
background: color-mix(in srgb, #ef4444 4%, transparent);
|
|
61
|
+
}
|
|
62
|
+
.nojs-selected {
|
|
63
|
+
outline: 2px solid #3b82f6;
|
|
64
|
+
outline-offset: 1px;
|
|
65
|
+
}
|
|
66
|
+
[drag-axis="x"] { touch-action: pan-y; }
|
|
67
|
+
[drag-axis="y"] { touch-action: pan-x; }
|
|
68
|
+
@keyframes nojs-drop-settle {
|
|
69
|
+
from { transform: scale(1.05); opacity: 0.8; }
|
|
70
|
+
to { transform: scale(1); opacity: 1; }
|
|
71
|
+
}
|
|
72
|
+
.nojs-drop-settle {
|
|
73
|
+
animation: nojs-drop-settle 0.2s ease-out;
|
|
74
|
+
}
|
|
75
|
+
.nojs-drag-list-empty {
|
|
76
|
+
min-height: 3rem;
|
|
77
|
+
}
|
|
78
|
+
`.trim();
|
|
79
|
+
|
|
80
|
+
const style = document.createElement("style");
|
|
81
|
+
style.setAttribute("data-nojs-dnd", "");
|
|
82
|
+
style.textContent = css;
|
|
83
|
+
document.head.appendChild(style);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Helper: calculate drop index from mouse position ─────────────────
|
|
87
|
+
function _calcDropIndex(el, mouseX, mouseY, sortDir) {
|
|
88
|
+
const children = [...el.children].filter(
|
|
89
|
+
(c) => !c.classList.contains("nojs-drop-placeholder")
|
|
90
|
+
);
|
|
91
|
+
if (children.length === 0) return 0;
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < children.length; i++) {
|
|
94
|
+
// Use first visible child for display:contents wrappers
|
|
95
|
+
const measured = children[i].style && children[i].style.display === "contents"
|
|
96
|
+
? (children[i].firstElementChild || children[i])
|
|
97
|
+
: children[i];
|
|
98
|
+
const rect = measured.getBoundingClientRect();
|
|
99
|
+
if (sortDir === "horizontal") {
|
|
100
|
+
const midX = rect.left + rect.width / 2;
|
|
101
|
+
if (mouseX < midX) return i;
|
|
102
|
+
} else if (sortDir === "grid") {
|
|
103
|
+
const midX = rect.left + rect.width / 2;
|
|
104
|
+
const midY = rect.top + rect.height / 2;
|
|
105
|
+
if (mouseY < midY && mouseX < midX) return i;
|
|
106
|
+
if (mouseY < rect.top + rect.height && mouseX < midX) return i;
|
|
107
|
+
} else {
|
|
108
|
+
// vertical (default)
|
|
109
|
+
const midY = rect.top + rect.height / 2;
|
|
110
|
+
if (mouseY < midY) return i;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return children.length;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Helper: manage placeholder ───────────────────────────────────────
|
|
117
|
+
function _insertPlaceholder(el, index, placeholderAttr, placeholderClass) {
|
|
118
|
+
_removePlaceholder();
|
|
119
|
+
|
|
120
|
+
let ph;
|
|
121
|
+
if (placeholderAttr === "auto") {
|
|
122
|
+
ph = document.createElement("div");
|
|
123
|
+
ph.className = placeholderClass || "nojs-drop-placeholder";
|
|
124
|
+
// Size placeholder to match the dragged item
|
|
125
|
+
if (_dndState.dragging && _dndState.dragging.sourceEl) {
|
|
126
|
+
const srcEl = _dndState.dragging.sourceEl.firstElementChild || _dndState.dragging.sourceEl;
|
|
127
|
+
const rect = srcEl.getBoundingClientRect();
|
|
128
|
+
if (rect.height > 0) ph.style.height = rect.height + "px";
|
|
129
|
+
if (rect.width > 0) ph.style.width = rect.width + "px";
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
// Template ID
|
|
133
|
+
const tpl = document.getElementById(
|
|
134
|
+
placeholderAttr.startsWith("#") ? placeholderAttr.slice(1) : placeholderAttr
|
|
135
|
+
);
|
|
136
|
+
if (tpl && tpl.content) {
|
|
137
|
+
ph = document.createElement("div");
|
|
138
|
+
ph.style.display = "contents";
|
|
139
|
+
ph.className = placeholderClass || "nojs-drop-placeholder";
|
|
140
|
+
ph.appendChild(tpl.content.cloneNode(true));
|
|
141
|
+
} else {
|
|
142
|
+
ph = document.createElement("div");
|
|
143
|
+
ph.className = placeholderClass || "nojs-drop-placeholder";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
ph.classList.add("nojs-drop-placeholder");
|
|
148
|
+
const children = [...el.children].filter(
|
|
149
|
+
(c) => !c.classList.contains("nojs-drop-placeholder")
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (index >= children.length) {
|
|
153
|
+
el.appendChild(ph);
|
|
154
|
+
} else {
|
|
155
|
+
el.insertBefore(ph, children[index]);
|
|
156
|
+
}
|
|
157
|
+
_dndState.placeholder = ph;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function _removePlaceholder() {
|
|
161
|
+
if (_dndState.placeholder) {
|
|
162
|
+
_dndState.placeholder.remove();
|
|
163
|
+
_dndState.placeholder = null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── Helper: check if type is accepted ────────────────────────────────
|
|
168
|
+
function _isTypeAccepted(dragType, acceptAttr) {
|
|
169
|
+
if (!acceptAttr || acceptAttr === "*") return true;
|
|
170
|
+
const accepted = acceptAttr.split(",").map((s) => s.trim());
|
|
171
|
+
return accepted.includes(dragType);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Helper: build stacked-cards ghost for multi-drag ─────────────────
|
|
175
|
+
function _buildStackGhost(sourceEl, count) {
|
|
176
|
+
const ghost = document.createElement("div");
|
|
177
|
+
ghost.style.cssText = "position:fixed;top:-9999px;left:-9999px;pointer-events:none;z-index:99999;";
|
|
178
|
+
|
|
179
|
+
const measured = sourceEl.style && sourceEl.style.display === "contents"
|
|
180
|
+
? (sourceEl.firstElementChild || sourceEl) : sourceEl;
|
|
181
|
+
const rect = measured.getBoundingClientRect();
|
|
182
|
+
const w = rect.width;
|
|
183
|
+
const h = rect.height;
|
|
184
|
+
const cs = getComputedStyle(measured);
|
|
185
|
+
const maxStack = Math.min(count, 3);
|
|
186
|
+
|
|
187
|
+
// Stack layers (back to front)
|
|
188
|
+
for (let i = maxStack - 1; i >= 0; i--) {
|
|
189
|
+
const layer = document.createElement("div");
|
|
190
|
+
const offset = i * 4;
|
|
191
|
+
layer.style.cssText =
|
|
192
|
+
"position:absolute;" +
|
|
193
|
+
"top:" + offset + "px;left:" + offset + "px;" +
|
|
194
|
+
"width:" + w + "px;height:" + h + "px;" +
|
|
195
|
+
"border-radius:" + cs.borderRadius + ";" +
|
|
196
|
+
"box-shadow:0 1px 4px rgba(0,0,0,0.12);" +
|
|
197
|
+
"overflow:hidden;box-sizing:border-box;";
|
|
198
|
+
|
|
199
|
+
if (i === 0) {
|
|
200
|
+
// Top card: clone content
|
|
201
|
+
layer.innerHTML = measured.innerHTML;
|
|
202
|
+
layer.style.background = cs.backgroundColor || "#fff";
|
|
203
|
+
layer.style.border = cs.border;
|
|
204
|
+
layer.style.padding = cs.padding;
|
|
205
|
+
layer.style.fontSize = cs.fontSize;
|
|
206
|
+
layer.style.color = cs.color;
|
|
207
|
+
layer.style.fontFamily = cs.fontFamily;
|
|
208
|
+
} else {
|
|
209
|
+
// Back cards: solid fill
|
|
210
|
+
layer.style.background = cs.backgroundColor || "#fff";
|
|
211
|
+
layer.style.border = cs.border || "1px solid #ddd";
|
|
212
|
+
}
|
|
213
|
+
ghost.appendChild(layer);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Count badge
|
|
217
|
+
const badge = document.createElement("div");
|
|
218
|
+
badge.textContent = count;
|
|
219
|
+
badge.style.cssText =
|
|
220
|
+
"position:absolute;top:-6px;right:-6px;" +
|
|
221
|
+
"min-width:22px;height:22px;padding:0 5px;" +
|
|
222
|
+
"background:#3b82f6;color:#fff;border-radius:11px;" +
|
|
223
|
+
"display:flex;align-items:center;justify-content:center;" +
|
|
224
|
+
"font-size:11px;font-weight:700;border:2px solid #fff;" +
|
|
225
|
+
"box-shadow:0 1px 3px rgba(0,0,0,0.2);";
|
|
226
|
+
ghost.appendChild(badge);
|
|
227
|
+
|
|
228
|
+
// Container size
|
|
229
|
+
ghost.style.width = (w + (maxStack - 1) * 4) + "px";
|
|
230
|
+
ghost.style.height = (h + (maxStack - 1) * 4) + "px";
|
|
231
|
+
|
|
232
|
+
return ghost;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
236
|
+
// DRAG DIRECTIVE
|
|
237
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
238
|
+
|
|
239
|
+
registerDirective("drag", {
|
|
240
|
+
priority: 15,
|
|
241
|
+
init(el, name, expr) {
|
|
242
|
+
_injectDndStyles();
|
|
243
|
+
const ctx = findContext(el);
|
|
244
|
+
|
|
245
|
+
const type = el.getAttribute("drag-type") || "default";
|
|
246
|
+
const effect = el.getAttribute("drag-effect") || "move";
|
|
247
|
+
const handleSel = el.getAttribute("drag-handle");
|
|
248
|
+
const imageSel = el.getAttribute("drag-image");
|
|
249
|
+
const imageOffsetAttr = el.getAttribute("drag-image-offset") || "0,0";
|
|
250
|
+
const disabledExpr = el.getAttribute("drag-disabled");
|
|
251
|
+
const dragClass = el.getAttribute("drag-class") || "nojs-dragging";
|
|
252
|
+
const ghostClass = el.getAttribute("drag-ghost-class");
|
|
253
|
+
|
|
254
|
+
// Set draggable
|
|
255
|
+
el.draggable = true;
|
|
256
|
+
|
|
257
|
+
// Accessibility
|
|
258
|
+
el.setAttribute("aria-grabbed", "false");
|
|
259
|
+
if (!el.getAttribute("tabindex")) el.setAttribute("tabindex", "0");
|
|
260
|
+
|
|
261
|
+
// Handle restriction: prevent drag from non-handle areas
|
|
262
|
+
let _handleAllowed = true;
|
|
263
|
+
if (handleSel) {
|
|
264
|
+
const mousedownHandler = (e) => {
|
|
265
|
+
_handleAllowed = !!e.target.closest(handleSel);
|
|
266
|
+
};
|
|
267
|
+
el.addEventListener("mousedown", mousedownHandler);
|
|
268
|
+
_onDispose(() => el.removeEventListener("mousedown", mousedownHandler));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Drag start
|
|
272
|
+
const dragstartHandler = (e) => {
|
|
273
|
+
// Handle check
|
|
274
|
+
if (handleSel && !_handleAllowed) {
|
|
275
|
+
e.preventDefault();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Disabled check
|
|
280
|
+
if (disabledExpr && evaluate(disabledExpr, ctx)) {
|
|
281
|
+
e.preventDefault();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const item = evaluate(expr, ctx);
|
|
286
|
+
|
|
287
|
+
// Check for multi-select
|
|
288
|
+
const group = el.getAttribute("drag-group");
|
|
289
|
+
let dragItem = item;
|
|
290
|
+
if (group && _dndState.selected.has(group)) {
|
|
291
|
+
const selectedSet = _dndState.selected.get(group);
|
|
292
|
+
if (selectedSet.size > 0) {
|
|
293
|
+
// If this element is selected, drag all selected items
|
|
294
|
+
const hasThis = [...selectedSet].some((s) => s.el === el);
|
|
295
|
+
if (hasThis) {
|
|
296
|
+
dragItem = [...selectedSet].map((s) => s.item);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Store dragging state
|
|
302
|
+
_dndState.dragging = {
|
|
303
|
+
item: dragItem,
|
|
304
|
+
type,
|
|
305
|
+
effect,
|
|
306
|
+
sourceEl: el,
|
|
307
|
+
sourceCtx: ctx,
|
|
308
|
+
sourceList: null,
|
|
309
|
+
sourceIndex: null,
|
|
310
|
+
listDirective: null,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// Set dataTransfer
|
|
314
|
+
if (e.dataTransfer) {
|
|
315
|
+
e.dataTransfer.effectAllowed = effect;
|
|
316
|
+
e.dataTransfer.setData("text/plain", "");
|
|
317
|
+
|
|
318
|
+
// Multi-select: stacked cards drag image
|
|
319
|
+
if (Array.isArray(dragItem) && dragItem.length > 1 && e.dataTransfer.setDragImage) {
|
|
320
|
+
const ghost = _buildStackGhost(el, dragItem.length);
|
|
321
|
+
document.body.appendChild(ghost);
|
|
322
|
+
const rect = el.getBoundingClientRect();
|
|
323
|
+
e.dataTransfer.setDragImage(ghost, rect.width / 2, rect.height / 2);
|
|
324
|
+
requestAnimationFrame(() => ghost.remove());
|
|
325
|
+
} else if (imageSel && e.dataTransfer.setDragImage) {
|
|
326
|
+
// Custom drag image
|
|
327
|
+
if (imageSel === "none") {
|
|
328
|
+
const empty = document.createElement("div");
|
|
329
|
+
empty.style.cssText = "width:1px;height:1px;opacity:0;position:fixed;top:-999px";
|
|
330
|
+
document.body.appendChild(empty);
|
|
331
|
+
const [ox, oy] = imageOffsetAttr.split(",").map(Number);
|
|
332
|
+
e.dataTransfer.setDragImage(empty, ox || 0, oy || 0);
|
|
333
|
+
requestAnimationFrame(() => empty.remove());
|
|
334
|
+
} else {
|
|
335
|
+
const imgEl = el.querySelector(imageSel);
|
|
336
|
+
if (imgEl) {
|
|
337
|
+
const [ox, oy] = imageOffsetAttr.split(",").map(Number);
|
|
338
|
+
if (ghostClass) imgEl.classList.add(ghostClass);
|
|
339
|
+
e.dataTransfer.setDragImage(imgEl, ox || 0, oy || 0);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Apply drag class to dragged element (and all selected items if multi-select)
|
|
346
|
+
dragClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.add(c));
|
|
347
|
+
if (Array.isArray(dragItem) && group && _dndState.selected.has(group)) {
|
|
348
|
+
for (const s of _dndState.selected.get(group)) {
|
|
349
|
+
if (s.el !== el) dragClass.split(/\s+/).filter(Boolean).forEach((c) => s.el.classList.add(c));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ARIA
|
|
354
|
+
el.setAttribute("aria-grabbed", "true");
|
|
355
|
+
|
|
356
|
+
// Dispatch custom event
|
|
357
|
+
el.dispatchEvent(
|
|
358
|
+
new CustomEvent("drag-start", {
|
|
359
|
+
bubbles: true,
|
|
360
|
+
detail: { item: dragItem, index: _dndState.dragging.sourceIndex, el },
|
|
361
|
+
})
|
|
362
|
+
);
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// Drag end
|
|
366
|
+
const dragendHandler = () => {
|
|
367
|
+
// Remove drag class from this element and all selected items
|
|
368
|
+
dragClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.remove(c));
|
|
369
|
+
const group = el.getAttribute("drag-group");
|
|
370
|
+
if (group && _dndState.selected.has(group)) {
|
|
371
|
+
for (const s of _dndState.selected.get(group)) {
|
|
372
|
+
dragClass.split(/\s+/).filter(Boolean).forEach((c) => s.el.classList.remove(c));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ARIA
|
|
377
|
+
el.setAttribute("aria-grabbed", "false");
|
|
378
|
+
|
|
379
|
+
// Ghost class cleanup
|
|
380
|
+
if (ghostClass && imageSel && imageSel !== "none") {
|
|
381
|
+
const imgEl = el.querySelector(imageSel);
|
|
382
|
+
if (imgEl) imgEl.classList.remove(ghostClass);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Dispatch custom event
|
|
386
|
+
el.dispatchEvent(
|
|
387
|
+
new CustomEvent("drag-end", {
|
|
388
|
+
bubbles: true,
|
|
389
|
+
detail: {
|
|
390
|
+
item: _dndState.dragging?.item,
|
|
391
|
+
index: _dndState.dragging?.sourceIndex,
|
|
392
|
+
dropped: _dndState.dragging === null,
|
|
393
|
+
},
|
|
394
|
+
})
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
// Clear dragging state
|
|
398
|
+
_dndState.dragging = null;
|
|
399
|
+
_removePlaceholder();
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
el.addEventListener("dragstart", dragstartHandler);
|
|
403
|
+
el.addEventListener("dragend", dragendHandler);
|
|
404
|
+
_onDispose(() => {
|
|
405
|
+
el.removeEventListener("dragstart", dragstartHandler);
|
|
406
|
+
el.removeEventListener("dragend", dragendHandler);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// Reactive disabled toggle
|
|
410
|
+
if (disabledExpr) {
|
|
411
|
+
function updateDisabled() {
|
|
412
|
+
const disabled = !!evaluate(disabledExpr, ctx);
|
|
413
|
+
el.draggable = !disabled;
|
|
414
|
+
if (disabled) el.removeAttribute("aria-grabbed");
|
|
415
|
+
else el.setAttribute("aria-grabbed", "false");
|
|
416
|
+
}
|
|
417
|
+
ctx.$watch(updateDisabled);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Keyboard DnD support
|
|
421
|
+
const keydownHandler = (e) => {
|
|
422
|
+
// Clear stale state from elements removed from DOM
|
|
423
|
+
if (_dndState.dragging && !_dndState.dragging.sourceEl.isConnected) {
|
|
424
|
+
_dndState.dragging = null;
|
|
425
|
+
}
|
|
426
|
+
if (e.key === " " && !_dndState.dragging) {
|
|
427
|
+
e.preventDefault();
|
|
428
|
+
const item = evaluate(expr, ctx);
|
|
429
|
+
_dndState.dragging = {
|
|
430
|
+
item,
|
|
431
|
+
type,
|
|
432
|
+
effect,
|
|
433
|
+
sourceEl: el,
|
|
434
|
+
sourceCtx: ctx,
|
|
435
|
+
sourceList: null,
|
|
436
|
+
sourceIndex: null,
|
|
437
|
+
listDirective: null,
|
|
438
|
+
};
|
|
439
|
+
dragClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.add(c));
|
|
440
|
+
el.setAttribute("aria-grabbed", "true");
|
|
441
|
+
el.dispatchEvent(
|
|
442
|
+
new CustomEvent("drag-start", {
|
|
443
|
+
bubbles: true,
|
|
444
|
+
detail: { item, index: null, el },
|
|
445
|
+
})
|
|
446
|
+
);
|
|
447
|
+
} else if (e.key === "Escape" && _dndState.dragging && _dndState.dragging.sourceEl === el) {
|
|
448
|
+
e.preventDefault();
|
|
449
|
+
dragClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.remove(c));
|
|
450
|
+
el.setAttribute("aria-grabbed", "false");
|
|
451
|
+
_dndState.dragging = null;
|
|
452
|
+
_removePlaceholder();
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
el.addEventListener("keydown", keydownHandler);
|
|
456
|
+
_onDispose(() => el.removeEventListener("keydown", keydownHandler));
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
461
|
+
// DROP DIRECTIVE
|
|
462
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
463
|
+
|
|
464
|
+
registerDirective("drop", {
|
|
465
|
+
priority: 15,
|
|
466
|
+
init(el, name, expr) {
|
|
467
|
+
_injectDndStyles();
|
|
468
|
+
const ctx = findContext(el);
|
|
469
|
+
|
|
470
|
+
const acceptAttr = el.getAttribute("drop-accept") || "default";
|
|
471
|
+
const dropEffect = el.getAttribute("drop-effect") || "move";
|
|
472
|
+
const dropClass = el.getAttribute("drop-class") || "nojs-drag-over";
|
|
473
|
+
const rejectClass = el.getAttribute("drop-reject-class") || "nojs-drop-reject";
|
|
474
|
+
const disabledExpr = el.getAttribute("drop-disabled");
|
|
475
|
+
const maxExpr = el.getAttribute("drop-max");
|
|
476
|
+
const sortDir = el.getAttribute("drop-sort");
|
|
477
|
+
const placeholderAttr = el.getAttribute("drop-placeholder");
|
|
478
|
+
const placeholderClass = el.getAttribute("drop-placeholder-class");
|
|
479
|
+
|
|
480
|
+
// Accessibility
|
|
481
|
+
el.setAttribute("aria-dropeffect", dropEffect);
|
|
482
|
+
|
|
483
|
+
// Track dragenter/dragleave depth for nested elements
|
|
484
|
+
let _enterDepth = 0;
|
|
485
|
+
|
|
486
|
+
const dragoverHandler = (e) => {
|
|
487
|
+
if (!_dndState.dragging) return;
|
|
488
|
+
|
|
489
|
+
// Disabled check
|
|
490
|
+
if (disabledExpr && evaluate(disabledExpr, ctx)) return;
|
|
491
|
+
|
|
492
|
+
const typeOk = _isTypeAccepted(_dndState.dragging.type, acceptAttr);
|
|
493
|
+
let maxOk = true;
|
|
494
|
+
if (maxExpr) {
|
|
495
|
+
const max = evaluate(maxExpr, ctx);
|
|
496
|
+
const childCount = _countVisibleChildren(el);
|
|
497
|
+
if (typeof max === "number" && childCount >= max) maxOk = false;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (!typeOk || !maxOk) {
|
|
501
|
+
rejectClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.add(c));
|
|
502
|
+
dropClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.remove(c));
|
|
503
|
+
_removePlaceholder();
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
rejectClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.remove(c));
|
|
508
|
+
|
|
509
|
+
e.preventDefault();
|
|
510
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = dropEffect;
|
|
511
|
+
|
|
512
|
+
// Sortable: calculate index and show placeholder
|
|
513
|
+
if (sortDir) {
|
|
514
|
+
const idx = _calcDropIndex(el, e.clientX, e.clientY, sortDir);
|
|
515
|
+
if (placeholderAttr) {
|
|
516
|
+
_insertPlaceholder(el, idx, placeholderAttr, placeholderClass);
|
|
517
|
+
}
|
|
518
|
+
// Dispatch throttled drag-over event
|
|
519
|
+
el.dispatchEvent(
|
|
520
|
+
new CustomEvent("drag-over", {
|
|
521
|
+
bubbles: false,
|
|
522
|
+
detail: { item: _dndState.dragging.item, index: idx },
|
|
523
|
+
})
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const dragenterHandler = (e) => {
|
|
529
|
+
if (!_dndState.dragging) return;
|
|
530
|
+
if (disabledExpr && evaluate(disabledExpr, ctx)) return;
|
|
531
|
+
|
|
532
|
+
_enterDepth++;
|
|
533
|
+
if (_enterDepth === 1) {
|
|
534
|
+
const typeOk = _isTypeAccepted(_dndState.dragging.type, acceptAttr);
|
|
535
|
+
let maxOk = true;
|
|
536
|
+
if (maxExpr) {
|
|
537
|
+
const max = evaluate(maxExpr, ctx);
|
|
538
|
+
const childCount = _countVisibleChildren(el);
|
|
539
|
+
if (typeof max === "number" && childCount >= max) maxOk = false;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (typeOk && maxOk) {
|
|
543
|
+
dropClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.add(c));
|
|
544
|
+
el.dispatchEvent(
|
|
545
|
+
new CustomEvent("drag-enter", {
|
|
546
|
+
bubbles: false,
|
|
547
|
+
detail: { item: _dndState.dragging.item, type: _dndState.dragging.type },
|
|
548
|
+
})
|
|
549
|
+
);
|
|
550
|
+
} else {
|
|
551
|
+
rejectClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.add(c));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const dragleaveHandler = (e) => {
|
|
557
|
+
if (!_dndState.dragging) return;
|
|
558
|
+
|
|
559
|
+
_enterDepth--;
|
|
560
|
+
if (_enterDepth <= 0) {
|
|
561
|
+
_enterDepth = 0;
|
|
562
|
+
dropClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.remove(c));
|
|
563
|
+
rejectClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.remove(c));
|
|
564
|
+
_removePlaceholder();
|
|
565
|
+
|
|
566
|
+
el.dispatchEvent(
|
|
567
|
+
new CustomEvent("drag-leave", {
|
|
568
|
+
bubbles: false,
|
|
569
|
+
detail: { item: _dndState.dragging.item },
|
|
570
|
+
})
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const dropHandler = (e) => {
|
|
576
|
+
e.preventDefault();
|
|
577
|
+
e.stopPropagation();
|
|
578
|
+
_enterDepth = 0;
|
|
579
|
+
|
|
580
|
+
if (!_dndState.dragging) return;
|
|
581
|
+
if (disabledExpr && evaluate(disabledExpr, ctx)) return;
|
|
582
|
+
if (!_isTypeAccepted(_dndState.dragging.type, acceptAttr)) return;
|
|
583
|
+
|
|
584
|
+
// Max capacity check
|
|
585
|
+
if (maxExpr) {
|
|
586
|
+
const max = evaluate(maxExpr, ctx);
|
|
587
|
+
const childCount = _countVisibleChildren(el);
|
|
588
|
+
if (typeof max === "number" && childCount >= max) return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const dragItem = _dndState.dragging.item;
|
|
592
|
+
const dragType = _dndState.dragging.type;
|
|
593
|
+
const dragEffect = _dndState.dragging.effect;
|
|
594
|
+
|
|
595
|
+
// Calculate drop index
|
|
596
|
+
let dropIndex = 0;
|
|
597
|
+
if (sortDir) {
|
|
598
|
+
dropIndex = _calcDropIndex(el, e.clientX, e.clientY, sortDir);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Remove visual feedback
|
|
602
|
+
dropClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.remove(c));
|
|
603
|
+
rejectClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.remove(c));
|
|
604
|
+
_removePlaceholder();
|
|
605
|
+
|
|
606
|
+
// Execute drop expression with implicit variables
|
|
607
|
+
const extraVars = {
|
|
608
|
+
$drag: dragItem,
|
|
609
|
+
$dragType: dragType,
|
|
610
|
+
$dragEffect: dragEffect,
|
|
611
|
+
$dropIndex: dropIndex,
|
|
612
|
+
$source: {
|
|
613
|
+
list: _dndState.dragging.sourceList,
|
|
614
|
+
index: _dndState.dragging.sourceIndex,
|
|
615
|
+
el: _dndState.dragging.sourceEl,
|
|
616
|
+
},
|
|
617
|
+
$target: { list: null, index: dropIndex, el },
|
|
618
|
+
$el: el,
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
_execStatement(expr, ctx, extraVars);
|
|
622
|
+
|
|
623
|
+
// Clear dragging state BEFORE dispatch to prevent re-entry
|
|
624
|
+
_dndState.dragging = null;
|
|
625
|
+
|
|
626
|
+
// Dispatch custom event after expression runs
|
|
627
|
+
el.dispatchEvent(
|
|
628
|
+
new CustomEvent("drop", {
|
|
629
|
+
bubbles: false,
|
|
630
|
+
detail: {
|
|
631
|
+
item: dragItem,
|
|
632
|
+
index: dropIndex,
|
|
633
|
+
source: extraVars.$source,
|
|
634
|
+
target: extraVars.$target,
|
|
635
|
+
effect: dragEffect,
|
|
636
|
+
},
|
|
637
|
+
})
|
|
638
|
+
);
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
// Keyboard: Enter/Space to drop when item is grabbed
|
|
642
|
+
const keydownHandler = (e) => {
|
|
643
|
+
if (!_dndState.dragging) return;
|
|
644
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
645
|
+
e.preventDefault();
|
|
646
|
+
dropHandler(e);
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
el.addEventListener("dragover", dragoverHandler);
|
|
651
|
+
el.addEventListener("dragenter", dragenterHandler);
|
|
652
|
+
el.addEventListener("dragleave", dragleaveHandler);
|
|
653
|
+
el.addEventListener("drop", dropHandler);
|
|
654
|
+
el.addEventListener("keydown", keydownHandler);
|
|
655
|
+
_onDispose(() => {
|
|
656
|
+
el.removeEventListener("dragover", dragoverHandler);
|
|
657
|
+
el.removeEventListener("dragenter", dragenterHandler);
|
|
658
|
+
el.removeEventListener("dragleave", dragleaveHandler);
|
|
659
|
+
el.removeEventListener("drop", dropHandler);
|
|
660
|
+
el.removeEventListener("keydown", keydownHandler);
|
|
661
|
+
});
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
666
|
+
// DRAG-LIST DIRECTIVE (Sortable List Shorthand)
|
|
667
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
668
|
+
|
|
669
|
+
// Keep a registry of drag-list elements for cross-list communication
|
|
670
|
+
const _dragListRegistry = new Map(); // el → { listPath, ctx, el }
|
|
671
|
+
|
|
672
|
+
registerDirective("drag-list", {
|
|
673
|
+
priority: 10,
|
|
674
|
+
init(el, name, listPath) {
|
|
675
|
+
_injectDndStyles();
|
|
676
|
+
const ctx = findContext(el);
|
|
677
|
+
|
|
678
|
+
const tplId = el.getAttribute("template");
|
|
679
|
+
const keyProp = el.getAttribute("drag-list-key");
|
|
680
|
+
const itemName = el.getAttribute("drag-list-item") || "item";
|
|
681
|
+
const sortDir = el.getAttribute("drop-sort") || "vertical";
|
|
682
|
+
const type = el.getAttribute("drag-type") || ("__draglist_" + listPath);
|
|
683
|
+
const acceptAttr = el.getAttribute("drop-accept") || type;
|
|
684
|
+
const copyMode = el.hasAttribute("drag-list-copy");
|
|
685
|
+
const removeMode = el.hasAttribute("drag-list-remove");
|
|
686
|
+
const disabledDragExpr = el.getAttribute("drag-disabled");
|
|
687
|
+
const disabledDropExpr = el.getAttribute("drop-disabled");
|
|
688
|
+
const maxExpr = el.getAttribute("drop-max");
|
|
689
|
+
const placeholderAttr = el.getAttribute("drop-placeholder");
|
|
690
|
+
const placeholderClass = el.getAttribute("drop-placeholder-class");
|
|
691
|
+
const dragClass = el.getAttribute("drag-class") || "nojs-dragging";
|
|
692
|
+
const dropClass = el.getAttribute("drop-class") || "nojs-drag-over";
|
|
693
|
+
const rejectClass = el.getAttribute("drop-reject-class") || "nojs-drop-reject";
|
|
694
|
+
const settleClass = el.getAttribute("drop-settle-class") || "nojs-drop-settle";
|
|
695
|
+
const emptyClass = el.getAttribute("drop-empty-class") || "nojs-drag-list-empty";
|
|
696
|
+
|
|
697
|
+
// Accessibility
|
|
698
|
+
el.setAttribute("role", "listbox");
|
|
699
|
+
el.setAttribute("aria-dropeffect", copyMode ? "copy" : "move");
|
|
700
|
+
|
|
701
|
+
// Register for cross-list communication
|
|
702
|
+
const listInfo = { listPath, ctx, el };
|
|
703
|
+
_dragListRegistry.set(el, listInfo);
|
|
704
|
+
_onDispose(() => _dragListRegistry.delete(el));
|
|
705
|
+
|
|
706
|
+
let _enterDepth = 0;
|
|
707
|
+
let _prevList = null;
|
|
708
|
+
|
|
709
|
+
// ─── Render items ──────────────────────────────────────────────────
|
|
710
|
+
function renderItems() {
|
|
711
|
+
const list = resolve(listPath, ctx);
|
|
712
|
+
if (!Array.isArray(list)) return;
|
|
713
|
+
|
|
714
|
+
// Same reference & items already rendered — propagate to children
|
|
715
|
+
// without rebuilding the DOM (preserves focus, input state, etc.)
|
|
716
|
+
if (list === _prevList && list.length > 0 && el.children.length > 0) {
|
|
717
|
+
for (const child of el.children) {
|
|
718
|
+
if (child.__ctx && child.__ctx.$notify) child.__ctx.$notify();
|
|
719
|
+
}
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
_prevList = list;
|
|
723
|
+
|
|
724
|
+
const tpl = tplId ? document.getElementById(tplId) : null;
|
|
725
|
+
if (!tpl) return;
|
|
726
|
+
|
|
727
|
+
el.innerHTML = "";
|
|
728
|
+
const count = list.length;
|
|
729
|
+
|
|
730
|
+
list.forEach((item, i) => {
|
|
731
|
+
const childData = {
|
|
732
|
+
[itemName]: item,
|
|
733
|
+
$index: i,
|
|
734
|
+
$count: count,
|
|
735
|
+
$first: i === 0,
|
|
736
|
+
$last: i === count - 1,
|
|
737
|
+
$even: i % 2 === 0,
|
|
738
|
+
$odd: i % 2 !== 0,
|
|
739
|
+
};
|
|
740
|
+
const childCtx = createContext(childData, ctx);
|
|
741
|
+
|
|
742
|
+
const clone = tpl.content.cloneNode(true);
|
|
743
|
+
const wrapper = document.createElement("div");
|
|
744
|
+
wrapper.style.display = "contents";
|
|
745
|
+
wrapper.__ctx = childCtx;
|
|
746
|
+
wrapper.setAttribute("role", "option");
|
|
747
|
+
|
|
748
|
+
// Append clone first so we can access the visible child
|
|
749
|
+
wrapper.appendChild(clone);
|
|
750
|
+
el.appendChild(wrapper);
|
|
751
|
+
|
|
752
|
+
// The drag source is the first visible child
|
|
753
|
+
// (display:contents wrapper has no box, so draggable must be on a rendered element)
|
|
754
|
+
const dragEl = wrapper.firstElementChild || wrapper;
|
|
755
|
+
dragEl.draggable = true;
|
|
756
|
+
dragEl.setAttribute("aria-grabbed", "false");
|
|
757
|
+
if (!dragEl.getAttribute("tabindex")) dragEl.setAttribute("tabindex", "0");
|
|
758
|
+
|
|
759
|
+
// Per-item drag handlers (on wrapper so events from dragEl bubble up to them)
|
|
760
|
+
const itemDragstart = (e) => {
|
|
761
|
+
if (disabledDragExpr && evaluate(disabledDragExpr, ctx)) {
|
|
762
|
+
e.preventDefault();
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
_dndState.dragging = {
|
|
766
|
+
item,
|
|
767
|
+
type,
|
|
768
|
+
effect: copyMode ? "copy" : "move",
|
|
769
|
+
sourceEl: wrapper,
|
|
770
|
+
sourceCtx: childCtx,
|
|
771
|
+
sourceList: list,
|
|
772
|
+
sourceIndex: i,
|
|
773
|
+
listDirective: { el, listPath, ctx, keyProp, copyMode, removeMode },
|
|
774
|
+
};
|
|
775
|
+
if (e.dataTransfer) {
|
|
776
|
+
e.dataTransfer.effectAllowed = copyMode ? "copy" : "move";
|
|
777
|
+
e.dataTransfer.setData("text/plain", "");
|
|
778
|
+
}
|
|
779
|
+
dragClass.split(/\s+/).filter(Boolean).forEach((c) => dragEl.classList.add(c));
|
|
780
|
+
dragEl.setAttribute("aria-grabbed", "true");
|
|
781
|
+
|
|
782
|
+
el.dispatchEvent(
|
|
783
|
+
new CustomEvent("drag-start", {
|
|
784
|
+
bubbles: true,
|
|
785
|
+
detail: { item, index: i, el: dragEl },
|
|
786
|
+
})
|
|
787
|
+
);
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
const itemDragend = () => {
|
|
791
|
+
dragClass.split(/\s+/).filter(Boolean).forEach((c) => dragEl.classList.remove(c));
|
|
792
|
+
dragEl.setAttribute("aria-grabbed", "false");
|
|
793
|
+
|
|
794
|
+
// If drag-list-remove and item was NOT dropped in a target, no action
|
|
795
|
+
// If dragging state is still set, it wasn't dropped
|
|
796
|
+
if (_dndState.dragging && _dndState.dragging.sourceEl === wrapper) {
|
|
797
|
+
_dndState.dragging = null;
|
|
798
|
+
}
|
|
799
|
+
_removePlaceholder();
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
wrapper.addEventListener("dragstart", itemDragstart);
|
|
803
|
+
wrapper.addEventListener("dragend", itemDragend);
|
|
804
|
+
|
|
805
|
+
// Keyboard DnD on items
|
|
806
|
+
wrapper.addEventListener("keydown", (e) => {
|
|
807
|
+
if (e.key === " " && !_dndState.dragging) {
|
|
808
|
+
e.preventDefault();
|
|
809
|
+
_dndState.dragging = {
|
|
810
|
+
item,
|
|
811
|
+
type,
|
|
812
|
+
effect: copyMode ? "copy" : "move",
|
|
813
|
+
sourceEl: wrapper,
|
|
814
|
+
sourceCtx: childCtx,
|
|
815
|
+
sourceList: list,
|
|
816
|
+
sourceIndex: i,
|
|
817
|
+
listDirective: { el, listPath, ctx, keyProp, copyMode, removeMode },
|
|
818
|
+
};
|
|
819
|
+
dragClass.split(/\s+/).filter(Boolean).forEach((c) => dragEl.classList.add(c));
|
|
820
|
+
dragEl.setAttribute("aria-grabbed", "true");
|
|
821
|
+
} else if (e.key === "Escape" && _dndState.dragging && _dndState.dragging.sourceEl === wrapper) {
|
|
822
|
+
e.preventDefault();
|
|
823
|
+
dragClass.split(/\s+/).filter(Boolean).forEach((c) => dragEl.classList.remove(c));
|
|
824
|
+
dragEl.setAttribute("aria-grabbed", "false");
|
|
825
|
+
_dndState.dragging = null;
|
|
826
|
+
_removePlaceholder();
|
|
827
|
+
} else if ((e.key === "ArrowDown" || e.key === "ArrowRight") && _dndState.dragging && _dndState.dragging.sourceEl === wrapper) {
|
|
828
|
+
e.preventDefault();
|
|
829
|
+
// Navigate to next item via wrapper siblings
|
|
830
|
+
const nextWrapper = wrapper.nextElementSibling;
|
|
831
|
+
if (nextWrapper) {
|
|
832
|
+
const nextEl = nextWrapper.firstElementChild || nextWrapper;
|
|
833
|
+
nextEl.focus();
|
|
834
|
+
}
|
|
835
|
+
} else if ((e.key === "ArrowUp" || e.key === "ArrowLeft") && _dndState.dragging && _dndState.dragging.sourceEl === wrapper) {
|
|
836
|
+
e.preventDefault();
|
|
837
|
+
const prevWrapper = wrapper.previousElementSibling;
|
|
838
|
+
if (prevWrapper) {
|
|
839
|
+
const prevEl = prevWrapper.firstElementChild || prevWrapper;
|
|
840
|
+
prevEl.focus();
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
processTree(wrapper);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
// Toggle empty class so the container remains a viable drop target
|
|
849
|
+
const isEmpty = list.length === 0;
|
|
850
|
+
emptyClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.toggle(c, isEmpty));
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ─── Drop zone handlers on the list container ─────────────────────
|
|
854
|
+
const dragoverHandler = (e) => {
|
|
855
|
+
if (!_dndState.dragging) return;
|
|
856
|
+
if (disabledDropExpr && evaluate(disabledDropExpr, ctx)) return;
|
|
857
|
+
|
|
858
|
+
const typeOk = _isTypeAccepted(_dndState.dragging.type, acceptAttr);
|
|
859
|
+
let maxOk = true;
|
|
860
|
+
if (maxExpr) {
|
|
861
|
+
const max = evaluate(maxExpr, ctx);
|
|
862
|
+
const list = resolve(listPath, ctx);
|
|
863
|
+
if (typeof max === "number" && Array.isArray(list) && list.length >= max) maxOk = false;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (!typeOk || !maxOk) {
|
|
867
|
+
rejectClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.add(c));
|
|
868
|
+
dropClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.remove(c));
|
|
869
|
+
_removePlaceholder();
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
rejectClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.remove(c));
|
|
874
|
+
|
|
875
|
+
e.preventDefault();
|
|
876
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = copyMode ? "copy" : "move";
|
|
877
|
+
|
|
878
|
+
const idx = _calcDropIndex(el, e.clientX, e.clientY, sortDir);
|
|
879
|
+
if (placeholderAttr) {
|
|
880
|
+
_insertPlaceholder(el, idx, placeholderAttr, placeholderClass);
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
const dragenterHandler = (e) => {
|
|
885
|
+
if (!_dndState.dragging) return;
|
|
886
|
+
if (disabledDropExpr && evaluate(disabledDropExpr, ctx)) return;
|
|
887
|
+
|
|
888
|
+
_enterDepth++;
|
|
889
|
+
if (_enterDepth === 1) {
|
|
890
|
+
const typeOk = _isTypeAccepted(_dndState.dragging.type, acceptAttr);
|
|
891
|
+
let maxOk = true;
|
|
892
|
+
if (maxExpr) {
|
|
893
|
+
const max = evaluate(maxExpr, ctx);
|
|
894
|
+
const list = resolve(listPath, ctx);
|
|
895
|
+
if (typeof max === "number" && Array.isArray(list) && list.length >= max) maxOk = false;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (typeOk && maxOk) {
|
|
899
|
+
dropClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.add(c));
|
|
900
|
+
el.dispatchEvent(
|
|
901
|
+
new CustomEvent("drag-enter", {
|
|
902
|
+
bubbles: false,
|
|
903
|
+
detail: { item: _dndState.dragging.item, type: _dndState.dragging.type },
|
|
904
|
+
})
|
|
905
|
+
);
|
|
906
|
+
} else {
|
|
907
|
+
rejectClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.add(c));
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
const dragleaveHandler = () => {
|
|
913
|
+
if (!_dndState.dragging) return;
|
|
914
|
+
_enterDepth--;
|
|
915
|
+
if (_enterDepth <= 0) {
|
|
916
|
+
_enterDepth = 0;
|
|
917
|
+
dropClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.remove(c));
|
|
918
|
+
rejectClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.remove(c));
|
|
919
|
+
_removePlaceholder();
|
|
920
|
+
el.dispatchEvent(
|
|
921
|
+
new CustomEvent("drag-leave", {
|
|
922
|
+
bubbles: false,
|
|
923
|
+
detail: { item: _dndState.dragging?.item },
|
|
924
|
+
})
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
const dropHandler = (e) => {
|
|
930
|
+
e.preventDefault();
|
|
931
|
+
e.stopPropagation();
|
|
932
|
+
_enterDepth = 0;
|
|
933
|
+
|
|
934
|
+
if (!_dndState.dragging) return;
|
|
935
|
+
if (disabledDropExpr && evaluate(disabledDropExpr, ctx)) return;
|
|
936
|
+
if (!_isTypeAccepted(_dndState.dragging.type, acceptAttr)) return;
|
|
937
|
+
|
|
938
|
+
// Max check
|
|
939
|
+
if (maxExpr) {
|
|
940
|
+
const max = evaluate(maxExpr, ctx);
|
|
941
|
+
const list = resolve(listPath, ctx);
|
|
942
|
+
if (typeof max === "number" && Array.isArray(list) && list.length >= max) return;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const dragItem = _dndState.dragging.item;
|
|
946
|
+
const sourceInfo = _dndState.dragging.listDirective;
|
|
947
|
+
const sourceIndex = _dndState.dragging.sourceIndex;
|
|
948
|
+
|
|
949
|
+
const dropIndex = _calcDropIndex(el, e.clientX, e.clientY, sortDir);
|
|
950
|
+
|
|
951
|
+
// Remove visual feedback
|
|
952
|
+
dropClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.remove(c));
|
|
953
|
+
rejectClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.remove(c));
|
|
954
|
+
_removePlaceholder();
|
|
955
|
+
|
|
956
|
+
const targetList = resolve(listPath, ctx);
|
|
957
|
+
if (!Array.isArray(targetList)) return;
|
|
958
|
+
|
|
959
|
+
// Self-drop check: same list, same position
|
|
960
|
+
const isSameList = sourceInfo && sourceInfo.el === el;
|
|
961
|
+
if (isSameList && sourceIndex === dropIndex) {
|
|
962
|
+
_dndState.dragging = null;
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
// Same list, but adjacent position (effectively same position)
|
|
966
|
+
if (isSameList && sourceIndex + 1 === dropIndex) {
|
|
967
|
+
_dndState.dragging = null;
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Perform the list mutation
|
|
972
|
+
let newTargetList = [...targetList];
|
|
973
|
+
|
|
974
|
+
if (isSameList) {
|
|
975
|
+
// Same-list reorder
|
|
976
|
+
const [moved] = newTargetList.splice(sourceIndex, 1);
|
|
977
|
+
const insertAt = sourceIndex < dropIndex ? dropIndex - 1 : dropIndex;
|
|
978
|
+
newTargetList.splice(insertAt, 0, moved);
|
|
979
|
+
ctx.$set(listPath, newTargetList);
|
|
980
|
+
|
|
981
|
+
el.dispatchEvent(
|
|
982
|
+
new CustomEvent("reorder", {
|
|
983
|
+
bubbles: true,
|
|
984
|
+
detail: { list: newTargetList, item: dragItem, from: sourceIndex, to: insertAt },
|
|
985
|
+
})
|
|
986
|
+
);
|
|
987
|
+
} else {
|
|
988
|
+
// Cross-list transfer
|
|
989
|
+
const itemToInsert = copyMode ? (typeof dragItem === "object" ? { ...dragItem } : dragItem) : dragItem;
|
|
990
|
+
newTargetList.splice(dropIndex, 0, itemToInsert);
|
|
991
|
+
ctx.$set(listPath, newTargetList);
|
|
992
|
+
|
|
993
|
+
// Remove from source if move mode
|
|
994
|
+
if (sourceInfo && !sourceInfo.copyMode && (removeMode || sourceInfo.removeMode)) {
|
|
995
|
+
const sourceList = resolve(sourceInfo.listPath, sourceInfo.ctx);
|
|
996
|
+
if (Array.isArray(sourceList) && sourceIndex != null) {
|
|
997
|
+
const newSourceList = sourceList.filter((_, idx) => idx !== sourceIndex);
|
|
998
|
+
sourceInfo.ctx.$set(sourceInfo.listPath, newSourceList);
|
|
999
|
+
|
|
1000
|
+
sourceInfo.el.dispatchEvent(
|
|
1001
|
+
new CustomEvent("remove", {
|
|
1002
|
+
bubbles: true,
|
|
1003
|
+
detail: { list: newSourceList, item: dragItem, index: sourceIndex },
|
|
1004
|
+
})
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
el.dispatchEvent(
|
|
1010
|
+
new CustomEvent("receive", {
|
|
1011
|
+
bubbles: true,
|
|
1012
|
+
detail: {
|
|
1013
|
+
list: newTargetList,
|
|
1014
|
+
item: dragItem,
|
|
1015
|
+
from: sourceIndex,
|
|
1016
|
+
fromList: sourceInfo ? resolve(sourceInfo.listPath, sourceInfo.ctx) : null,
|
|
1017
|
+
},
|
|
1018
|
+
})
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Settle animation (apply to visible child, not display:contents wrapper)
|
|
1023
|
+
requestAnimationFrame(() => {
|
|
1024
|
+
const children = [...el.children];
|
|
1025
|
+
const targetChild = children[isSameList ? (sourceIndex < dropIndex ? dropIndex - 1 : dropIndex) : dropIndex];
|
|
1026
|
+
if (targetChild) {
|
|
1027
|
+
const animEl = targetChild.firstElementChild || targetChild;
|
|
1028
|
+
settleClass.split(/\s+/).filter(Boolean).forEach((c) => animEl.classList.add(c));
|
|
1029
|
+
animEl.addEventListener("animationend", () => {
|
|
1030
|
+
settleClass.split(/\s+/).filter(Boolean).forEach((c) => animEl.classList.remove(c));
|
|
1031
|
+
}, { once: true });
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
// Clear dragging state
|
|
1036
|
+
_dndState.dragging = null;
|
|
1037
|
+
};
|
|
1038
|
+
|
|
1039
|
+
// Keyboard: Enter/Space to drop
|
|
1040
|
+
const keydownHandler = (e) => {
|
|
1041
|
+
if (!_dndState.dragging) return;
|
|
1042
|
+
if (!_isTypeAccepted(_dndState.dragging.type, acceptAttr)) return;
|
|
1043
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
1044
|
+
e.preventDefault();
|
|
1045
|
+
// Simulate drop at the focused element's position
|
|
1046
|
+
const focused = el.querySelector(":focus");
|
|
1047
|
+
if (focused) {
|
|
1048
|
+
const measured = focused.style?.display === "contents"
|
|
1049
|
+
? (focused.firstElementChild || focused) : focused;
|
|
1050
|
+
const rect = measured.getBoundingClientRect();
|
|
1051
|
+
const fakeEvent = {
|
|
1052
|
+
preventDefault() {},
|
|
1053
|
+
stopPropagation() {},
|
|
1054
|
+
clientX: rect.left + rect.width / 2,
|
|
1055
|
+
clientY: rect.top + rect.height + 1,
|
|
1056
|
+
dataTransfer: null,
|
|
1057
|
+
};
|
|
1058
|
+
dropHandler(fakeEvent);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
|
|
1063
|
+
el.addEventListener("dragover", dragoverHandler);
|
|
1064
|
+
el.addEventListener("dragenter", dragenterHandler);
|
|
1065
|
+
el.addEventListener("dragleave", dragleaveHandler);
|
|
1066
|
+
el.addEventListener("drop", dropHandler);
|
|
1067
|
+
el.addEventListener("keydown", keydownHandler);
|
|
1068
|
+
_onDispose(() => {
|
|
1069
|
+
el.removeEventListener("dragover", dragoverHandler);
|
|
1070
|
+
el.removeEventListener("dragenter", dragenterHandler);
|
|
1071
|
+
el.removeEventListener("dragleave", dragleaveHandler);
|
|
1072
|
+
el.removeEventListener("drop", dropHandler);
|
|
1073
|
+
el.removeEventListener("keydown", keydownHandler);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
// ─── Reactive rendering ──────────────────────────────────────────
|
|
1077
|
+
ctx.$watch(renderItems);
|
|
1078
|
+
renderItems();
|
|
1079
|
+
},
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1083
|
+
// DRAG-MULTIPLE DIRECTIVE
|
|
1084
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1085
|
+
|
|
1086
|
+
registerDirective("drag-multiple", {
|
|
1087
|
+
priority: 16,
|
|
1088
|
+
init(el, name) {
|
|
1089
|
+
const ctx = findContext(el);
|
|
1090
|
+
const group = el.getAttribute("drag-group");
|
|
1091
|
+
const selectClass = el.getAttribute("drag-multiple-class") || "nojs-selected";
|
|
1092
|
+
|
|
1093
|
+
if (!group) {
|
|
1094
|
+
_warn("drag-multiple requires drag-group attribute");
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Initialize group set if needed
|
|
1099
|
+
if (!_dndState.selected.has(group)) {
|
|
1100
|
+
_dndState.selected.set(group, new Set());
|
|
1101
|
+
}
|
|
1102
|
+
const selectedSet = _dndState.selected.get(group);
|
|
1103
|
+
|
|
1104
|
+
const clickHandler = (e) => {
|
|
1105
|
+
const dragExpr = el.getAttribute("drag");
|
|
1106
|
+
const item = dragExpr ? evaluate(dragExpr, ctx) : null;
|
|
1107
|
+
const entry = { item, el, ctx };
|
|
1108
|
+
|
|
1109
|
+
if (e.ctrlKey || e.metaKey) {
|
|
1110
|
+
// Additive selection
|
|
1111
|
+
const existing = [...selectedSet].find((s) => s.el === el);
|
|
1112
|
+
if (existing) {
|
|
1113
|
+
selectedSet.delete(existing);
|
|
1114
|
+
selectClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.remove(c));
|
|
1115
|
+
} else {
|
|
1116
|
+
selectedSet.add(entry);
|
|
1117
|
+
selectClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.add(c));
|
|
1118
|
+
}
|
|
1119
|
+
} else {
|
|
1120
|
+
// Replace selection
|
|
1121
|
+
for (const s of selectedSet) {
|
|
1122
|
+
selectClass.split(/\s+/).filter(Boolean).forEach((c) => s.el.classList.remove(c));
|
|
1123
|
+
}
|
|
1124
|
+
selectedSet.clear();
|
|
1125
|
+
selectedSet.add(entry);
|
|
1126
|
+
selectClass.split(/\s+/).filter(Boolean).forEach((c) => el.classList.add(c));
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
el.addEventListener("click", clickHandler);
|
|
1131
|
+
_onDispose(() => {
|
|
1132
|
+
el.removeEventListener("click", clickHandler);
|
|
1133
|
+
// Remove this element from the selection set
|
|
1134
|
+
const existing = [...selectedSet].find((s) => s.el === el);
|
|
1135
|
+
if (existing) selectedSet.delete(existing);
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
// Escape clears selection for this group
|
|
1139
|
+
const escHandler = (e) => {
|
|
1140
|
+
if (e.key === "Escape") {
|
|
1141
|
+
for (const s of selectedSet) {
|
|
1142
|
+
selectClass.split(/\s+/).filter(Boolean).forEach((c) => s.el.classList.remove(c));
|
|
1143
|
+
}
|
|
1144
|
+
selectedSet.clear();
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
window.addEventListener("keydown", escHandler);
|
|
1148
|
+
_onDispose(() => window.removeEventListener("keydown", escHandler));
|
|
1149
|
+
},
|
|
1150
|
+
});
|