@erickxavier/no-js 1.0.2 → 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.
@@ -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
+ });