@spectral_labs/ui 1.6.0 → 1.6.1
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/dist/internal/drag-drop/__tests__/drag-session.test.svelte.js +256 -0
- package/dist/internal/drag-drop/drag-session.svelte.d.ts.map +1 -1
- package/dist/internal/drag-drop/drag-session.svelte.js +114 -14
- package/dist/internal/drag-drop/keyboard-dnd.svelte.d.ts +8 -0
- package/dist/internal/drag-drop/keyboard-dnd.svelte.d.ts.map +1 -1
- package/dist/internal/drag-drop/keyboard-dnd.svelte.js +14 -3
- package/dist/internal/escape-layer/escape-layer.svelte.d.ts +12 -0
- package/dist/internal/escape-layer/escape-layer.svelte.d.ts.map +1 -1
- package/dist/internal/escape-layer/escape-layer.svelte.js +20 -1
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { EscapeLayerState, resetEscapeLayerStack } from '../../escape-layer/index.js';
|
|
2
3
|
import { dragSession } from '../drag-session.svelte.js';
|
|
3
4
|
function createSourceConfig(id, overrides = {}) {
|
|
4
5
|
return {
|
|
@@ -49,6 +50,28 @@ function ensurePointerEventPolyfill() {
|
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
52
|
}
|
|
53
|
+
function pointerEvent(type, x, y) {
|
|
54
|
+
return new PointerEvent(type, {
|
|
55
|
+
clientX: x,
|
|
56
|
+
clientY: y,
|
|
57
|
+
pointerId: 1,
|
|
58
|
+
pointerType: 'mouse',
|
|
59
|
+
bubbles: true,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function startPointerDrag(id) {
|
|
63
|
+
const config = createSourceConfig(id);
|
|
64
|
+
const el = createElement();
|
|
65
|
+
el.setPointerCapture = vi.fn();
|
|
66
|
+
el.releasePointerCapture = vi.fn();
|
|
67
|
+
dragSession.registerSource(config, el);
|
|
68
|
+
dragSession.beginPointerDrag(id, pointerEvent('pointerdown', 100, 100));
|
|
69
|
+
el.dispatchEvent(pointerEvent('pointermove', 110, 110)); // crosses the 5px threshold
|
|
70
|
+
return { config, el };
|
|
71
|
+
}
|
|
72
|
+
function pressEscape() {
|
|
73
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
74
|
+
}
|
|
52
75
|
describe('dragSession', () => {
|
|
53
76
|
beforeEach(() => {
|
|
54
77
|
dragSession._resetForTests();
|
|
@@ -544,4 +567,237 @@ describe('dragSession', () => {
|
|
|
544
567
|
tgtEl.remove();
|
|
545
568
|
});
|
|
546
569
|
});
|
|
570
|
+
describe('escape cancels a POINTER drag (PR #138)', () => {
|
|
571
|
+
beforeEach(() => {
|
|
572
|
+
// The session is a module singleton: a test that left one active would
|
|
573
|
+
// make `beginPointerDrag` refuse re-entry here, and the keystroke would
|
|
574
|
+
// then be handled by the INHERITED session — a green that proves nothing.
|
|
575
|
+
dragSession.cancel();
|
|
576
|
+
ensurePointerEventPolyfill();
|
|
577
|
+
});
|
|
578
|
+
it('cancels the session, releases the capture and drops no item', () => {
|
|
579
|
+
const { config, el } = startPointerDrag('esc-src');
|
|
580
|
+
const target = createTargetConfig('esc-tgt');
|
|
581
|
+
const tgtEl = createElement();
|
|
582
|
+
dragSession.registerTarget(target, tgtEl);
|
|
583
|
+
expect(dragSession.isDragging).toBe(true);
|
|
584
|
+
pressEscape();
|
|
585
|
+
expect(dragSession.isDragging).toBe(false);
|
|
586
|
+
expect(config.onDragEnd).toHaveBeenCalledWith('cancel');
|
|
587
|
+
expect(target.onDrop).not.toHaveBeenCalled();
|
|
588
|
+
expect(el.releasePointerCapture).toHaveBeenCalled();
|
|
589
|
+
expect(document.documentElement.classList.contains('sp-drag-active')).toBe(false);
|
|
590
|
+
expect(document.querySelector('.sp-drag-ghost')).toBeNull();
|
|
591
|
+
el.remove();
|
|
592
|
+
tgtEl.remove();
|
|
593
|
+
});
|
|
594
|
+
it('does not resurrect the drag on the next pointermove', () => {
|
|
595
|
+
const { config, el } = startPointerDrag('esc-resurrect');
|
|
596
|
+
pressEscape();
|
|
597
|
+
expect(dragSession.isDragging).toBe(false);
|
|
598
|
+
// The listeners must be gone: _startX/_startY survive, so a surviving
|
|
599
|
+
// onMove would re-activate the session past the 5px threshold.
|
|
600
|
+
el.dispatchEvent(pointerEvent('pointermove', 300, 300));
|
|
601
|
+
expect(dragSession.isDragging).toBe(false);
|
|
602
|
+
expect(dragSession.sourceId).toBeNull();
|
|
603
|
+
expect(config.onDragStart).toHaveBeenCalledTimes(1);
|
|
604
|
+
el.remove();
|
|
605
|
+
});
|
|
606
|
+
it('leaves Escape to the application while the claim is still PENDING', () => {
|
|
607
|
+
const config = createSourceConfig('esc-pending');
|
|
608
|
+
const el = createElement();
|
|
609
|
+
el.setPointerCapture = vi.fn();
|
|
610
|
+
el.releasePointerCapture = vi.fn();
|
|
611
|
+
dragSession.registerSource(config, el);
|
|
612
|
+
dragSession.beginPointerDrag('esc-pending', pointerEvent('pointerdown', 100, 100));
|
|
613
|
+
// no pointermove: under the threshold, the session is not dragging
|
|
614
|
+
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
|
|
615
|
+
document.dispatchEvent(event);
|
|
616
|
+
expect(event.defaultPrevented).toBe(false); // the keystroke belongs to the app
|
|
617
|
+
expect(config.onDragEnd).not.toHaveBeenCalled();
|
|
618
|
+
dragSession.cancel();
|
|
619
|
+
el.remove();
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
describe('escape priority over an already-mounted escape layer (see internal/escape-layer)', () => {
|
|
623
|
+
beforeEach(() => {
|
|
624
|
+
dragSession.cancel();
|
|
625
|
+
resetEscapeLayerStack();
|
|
626
|
+
ensurePointerEventPolyfill();
|
|
627
|
+
});
|
|
628
|
+
afterEach(() => {
|
|
629
|
+
resetEscapeLayerStack();
|
|
630
|
+
});
|
|
631
|
+
/**
|
|
632
|
+
* Mounts a layer BEFORE the drag starts — the order that exposes the bug.
|
|
633
|
+
* `EscapeLayer` registers its global handler on the first mount; a drag
|
|
634
|
+
* listener added later on the SAME node in the SAME phase runs after it,
|
|
635
|
+
* so `preventDefault()` arrives once the dialog has already closed.
|
|
636
|
+
*/
|
|
637
|
+
function mountLayerFirst() {
|
|
638
|
+
const onEscapeKeydown = vi.fn();
|
|
639
|
+
const layer = new EscapeLayerState({ enabled: true, onEscapeKeydown });
|
|
640
|
+
layer.mount();
|
|
641
|
+
return { layer, onEscapeKeydown };
|
|
642
|
+
}
|
|
643
|
+
it('gives the first escape to the pointer drag and the second to the dialog', () => {
|
|
644
|
+
const { layer, onEscapeKeydown } = mountLayerFirst();
|
|
645
|
+
const { config, el } = startPointerDrag('esc-layer-pointer');
|
|
646
|
+
expect(dragSession.isDragging).toBe(true);
|
|
647
|
+
pressEscape();
|
|
648
|
+
expect(dragSession.isDragging).toBe(false);
|
|
649
|
+
expect(config.onDragEnd).toHaveBeenCalledWith('cancel');
|
|
650
|
+
expect(onEscapeKeydown).not.toHaveBeenCalled(); // the dialog must survive
|
|
651
|
+
pressEscape();
|
|
652
|
+
expect(onEscapeKeydown).toHaveBeenCalledTimes(1); // now it closes normally
|
|
653
|
+
layer.unmount();
|
|
654
|
+
el.remove();
|
|
655
|
+
});
|
|
656
|
+
it('gives the first escape to the keyboard drag and the second to the dialog', () => {
|
|
657
|
+
const { layer, onEscapeKeydown } = mountLayerFirst();
|
|
658
|
+
const config = createSourceConfig('esc-layer-keyboard');
|
|
659
|
+
const el = createElement();
|
|
660
|
+
dragSession.registerSource(config, el);
|
|
661
|
+
dragSession.beginKeyboardDrag('esc-layer-keyboard');
|
|
662
|
+
expect(dragSession.isDragging).toBe(true);
|
|
663
|
+
pressEscape();
|
|
664
|
+
expect(dragSession.isDragging).toBe(false);
|
|
665
|
+
expect(config.onDragEnd).toHaveBeenCalledWith('cancel');
|
|
666
|
+
expect(onEscapeKeydown).not.toHaveBeenCalled();
|
|
667
|
+
pressEscape();
|
|
668
|
+
expect(onEscapeKeydown).toHaveBeenCalledTimes(1);
|
|
669
|
+
layer.unmount();
|
|
670
|
+
el.remove();
|
|
671
|
+
});
|
|
672
|
+
it('keeps that priority when the layer mounts AFTER the drag has started', () => {
|
|
673
|
+
// The mirror order. It was already green by accident — the drag listener
|
|
674
|
+
// happened to be registered first. The point of the pair is that the
|
|
675
|
+
// outcome no longer depends on which one registered first.
|
|
676
|
+
const { config, el } = startPointerDrag('esc-layer-after');
|
|
677
|
+
const { layer, onEscapeKeydown } = mountLayerFirst();
|
|
678
|
+
expect(dragSession.isDragging).toBe(true);
|
|
679
|
+
pressEscape();
|
|
680
|
+
expect(dragSession.isDragging).toBe(false);
|
|
681
|
+
expect(config.onDragEnd).toHaveBeenCalledWith('cancel');
|
|
682
|
+
expect(onEscapeKeydown).not.toHaveBeenCalled();
|
|
683
|
+
pressEscape();
|
|
684
|
+
expect(onEscapeKeydown).toHaveBeenCalledTimes(1);
|
|
685
|
+
layer.unmount();
|
|
686
|
+
el.remove();
|
|
687
|
+
});
|
|
688
|
+
it('leaves the dialog closable immediately while the claim is still pending', () => {
|
|
689
|
+
const { layer, onEscapeKeydown } = mountLayerFirst();
|
|
690
|
+
const config = createSourceConfig('esc-layer-pending');
|
|
691
|
+
const el = createElement();
|
|
692
|
+
el.setPointerCapture = vi.fn();
|
|
693
|
+
el.releasePointerCapture = vi.fn();
|
|
694
|
+
dragSession.registerSource(config, el);
|
|
695
|
+
dragSession.beginPointerDrag('esc-layer-pending', pointerEvent('pointerdown', 100, 100));
|
|
696
|
+
// no pointermove: under the 5px threshold the session is only PENDING
|
|
697
|
+
pressEscape();
|
|
698
|
+
expect(onEscapeKeydown).toHaveBeenCalledTimes(1); // closing stays immediate
|
|
699
|
+
expect(config.onDragEnd).not.toHaveBeenCalled();
|
|
700
|
+
dragSession.cancel();
|
|
701
|
+
layer.unmount();
|
|
702
|
+
el.remove();
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
describe('pointer drop target is computed at release', () => {
|
|
706
|
+
beforeEach(() => {
|
|
707
|
+
dragSession.cancel();
|
|
708
|
+
ensurePointerEventPolyfill();
|
|
709
|
+
});
|
|
710
|
+
it('drops on the target under the pointerup when no animation frame has run', async () => {
|
|
711
|
+
const { config, el } = startPointerDrag('release-src');
|
|
712
|
+
const target = createTargetConfig('release-tgt');
|
|
713
|
+
const onPointerUpdate = vi.fn();
|
|
714
|
+
const tgtEl = createElement(); // rect 0,0 → 200,100
|
|
715
|
+
dragSession.registerTarget(target, tgtEl, onPointerUpdate);
|
|
716
|
+
// move then up inside the SAME frame: no requestAnimationFrame callback
|
|
717
|
+
// has run, so the throttled hit-test never saw either position.
|
|
718
|
+
el.dispatchEvent(pointerEvent('pointermove', 150, 70));
|
|
719
|
+
el.dispatchEvent(pointerEvent('pointerup', 150, 70));
|
|
720
|
+
expect(onPointerUpdate).toHaveBeenCalledWith(150, 70);
|
|
721
|
+
expect(target.onDrop).toHaveBeenCalledTimes(1);
|
|
722
|
+
// completeDrop awaits onDrop, so the end-of-drag callback lands one
|
|
723
|
+
// microtask later even when onDrop is synchronous.
|
|
724
|
+
await Promise.resolve();
|
|
725
|
+
expect(config.onDragEnd).toHaveBeenCalledWith('move');
|
|
726
|
+
el.remove();
|
|
727
|
+
tgtEl.remove();
|
|
728
|
+
});
|
|
729
|
+
it('cancels the pending frame so a stale position cannot land during an async drop', async () => {
|
|
730
|
+
const { el } = startPointerDrag('release-async-src');
|
|
731
|
+
let resolveDrop = null;
|
|
732
|
+
const onDrop = vi.fn(() => new Promise((resolve) => {
|
|
733
|
+
resolveDrop = resolve;
|
|
734
|
+
}));
|
|
735
|
+
const target = createTargetConfig('release-async-tgt', { onDrop });
|
|
736
|
+
const onPointerUpdate = vi.fn();
|
|
737
|
+
const tgtEl = createElement();
|
|
738
|
+
dragSession.registerTarget(target, tgtEl, onPointerUpdate);
|
|
739
|
+
// 50,70 is INSIDE the target, so a surviving frame would call
|
|
740
|
+
// onPointerUpdate again with those stale coordinates.
|
|
741
|
+
el.dispatchEvent(pointerEvent('pointermove', 50, 70));
|
|
742
|
+
el.dispatchEvent(pointerEvent('pointerup', 150, 70));
|
|
743
|
+
expect(onPointerUpdate).toHaveBeenCalledTimes(1);
|
|
744
|
+
expect(onPointerUpdate).toHaveBeenLastCalledWith(150, 70);
|
|
745
|
+
// completeDrop is still awaiting onDrop — the session is alive, which is
|
|
746
|
+
// exactly the window a surviving frame would fire in.
|
|
747
|
+
await new Promise((resolve) => setTimeout(resolve, 48));
|
|
748
|
+
expect(onPointerUpdate).toHaveBeenCalledTimes(1);
|
|
749
|
+
expect(onPointerUpdate).toHaveBeenLastCalledWith(150, 70);
|
|
750
|
+
resolveDrop();
|
|
751
|
+
el.remove();
|
|
752
|
+
tgtEl.remove();
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
describe('pointer hit-test honours acceptedTypes', () => {
|
|
756
|
+
beforeEach(() => {
|
|
757
|
+
dragSession.cancel();
|
|
758
|
+
ensurePointerEventPolyfill();
|
|
759
|
+
});
|
|
760
|
+
it('never makes a target whose accepted types miss the drag the current one', async () => {
|
|
761
|
+
const { el } = startPointerDrag('types-src'); // carries 'test-type' + 'text/plain'
|
|
762
|
+
// Registered FIRST and covering the same rect: without the filter the
|
|
763
|
+
// hit-test stops here, because it breaks at the first rect containing
|
|
764
|
+
// the point.
|
|
765
|
+
const foreign = createTargetConfig('types-foreign', { acceptedTypes: ['other-type'] });
|
|
766
|
+
const foreignEl = createElement();
|
|
767
|
+
const foreignUpdate = vi.fn();
|
|
768
|
+
dragSession.registerTarget(foreign, foreignEl, foreignUpdate);
|
|
769
|
+
const own = createTargetConfig('types-own', { acceptedTypes: ['test-type'] });
|
|
770
|
+
const ownEl = createElement();
|
|
771
|
+
const ownUpdate = vi.fn();
|
|
772
|
+
dragSession.registerTarget(own, ownEl, ownUpdate);
|
|
773
|
+
el.dispatchEvent(pointerEvent('pointerup', 150, 70));
|
|
774
|
+
expect(foreignUpdate).not.toHaveBeenCalled();
|
|
775
|
+
expect(foreign.onDrop).not.toHaveBeenCalled();
|
|
776
|
+
expect(ownUpdate).toHaveBeenCalledWith(150, 70);
|
|
777
|
+
expect(own.onDrop).toHaveBeenCalledTimes(1);
|
|
778
|
+
await Promise.resolve();
|
|
779
|
+
el.remove();
|
|
780
|
+
foreignEl.remove();
|
|
781
|
+
ownEl.remove();
|
|
782
|
+
});
|
|
783
|
+
it('still accepts everything when acceptedTypes is absent or empty', async () => {
|
|
784
|
+
// Non-regression guard for the catch-all: green before and after the
|
|
785
|
+
// filter, and it must never turn red.
|
|
786
|
+
const { el } = startPointerDrag('types-catchall-src');
|
|
787
|
+
const catchAll = createTargetConfig('types-catchall');
|
|
788
|
+
const catchAllEl = createElement();
|
|
789
|
+
const catchAllUpdate = vi.fn();
|
|
790
|
+
dragSession.registerTarget(catchAll, catchAllEl, catchAllUpdate);
|
|
791
|
+
const empty = createTargetConfig('types-empty', { acceptedTypes: [] });
|
|
792
|
+
const emptyEl = createElement();
|
|
793
|
+
dragSession.registerTarget(empty, emptyEl, vi.fn());
|
|
794
|
+
el.dispatchEvent(pointerEvent('pointerup', 150, 70));
|
|
795
|
+
expect(catchAllUpdate).toHaveBeenCalledWith(150, 70);
|
|
796
|
+
expect(catchAll.onDrop).toHaveBeenCalledTimes(1);
|
|
797
|
+
await Promise.resolve();
|
|
798
|
+
el.remove();
|
|
799
|
+
catchAllEl.remove();
|
|
800
|
+
emptyEl.remove();
|
|
801
|
+
});
|
|
802
|
+
});
|
|
547
803
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"drag-session.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/drag-drop/drag-session.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,gBAAgB,EAGhB,UAAU,EACV,gBAAgB,EAEjB,MAAM,YAAY,CAAA;AAOnB,UAAU,gBAAgB;IACxB,MAAM,EAAE,gBAAgB,CAAA;IACxB,OAAO,EAAE,WAAW,CAAA;CACrB;AAED,UAAU,gBAAgB;IACxB,MAAM,EAAE,gBAAgB,CAAA;IACxB,OAAO,EAAE,WAAW,CAAA;IACpB,+EAA+E;IAC/E,eAAe,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CACjD;
|
|
1
|
+
{"version":3,"file":"drag-session.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/drag-drop/drag-session.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,gBAAgB,EAGhB,UAAU,EACV,gBAAgB,EAEjB,MAAM,YAAY,CAAA;AAOnB,UAAU,gBAAgB;IACxB,MAAM,EAAE,gBAAgB,CAAA;IACxB,OAAO,EAAE,WAAW,CAAA;CACrB;AAED,UAAU,gBAAgB;IACxB,MAAM,EAAE,gBAAgB,CAAA;IACxB,OAAO,EAAE,WAAW,CAAA;IACpB,+EAA+E;IAC/E,eAAe,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CACjD;AAsRD,eAAO,MAAM,WAAW;;;;;;;;;;;2BAmCC,gBAAgB,WAAW,WAAW,GAAG,MAAM,IAAI;2BAQhE,gBAAgB,WACf,WAAW,oBACF,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,GAC/C,MAAM,IAAI;kBASC,MAAM,IAAI,GAAG,MAAM,IAAI;+BASV,MAAM,KAAK,YAAY,GAAG,IAAI;gCAgK7B,MAAM,GAAG,IAAI;IAezC,gDAAgD;uBAC7B,IAAI;IAYvB,0DAA0D;uBACvC,IAAI;IAYvB,mFAAmF;0BAC7D,UAAU,GAAG,IAAI;YAQ/B,OAAO,CAAC,IAAI,CAAC;cAIX,IAAI;IAMd,uDAAuD;sCACrB,MAAM,QAAQ,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI;IAKlE,+FAA+F;iCAClE,MAAM,GAAG,OAAO;IAI7C,+FAA+F;6CACtD,MAAM,GAAG,OAAO;IAIzD,mCAAmC;kBACrB,MAAM,GAAG,gBAAgB,GAAG,SAAS;IAInD,mCAAmC;kBACrB,MAAM,GAAG,gBAAgB,GAAG,SAAS;IAInD,gBAAgB;sBACE,IAAI;CAMvB,CAAA"}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Inspired by React Aria's DragManager — adapted for Svelte 5 runes.
|
|
8
8
|
*/
|
|
9
9
|
import { AnnouncerState } from '../announcer/index.js';
|
|
10
|
-
import { isBrowser } from '../helpers/index.js';
|
|
10
|
+
import { isBrowser, kbd } from '../helpers/index.js';
|
|
11
11
|
import { startKeyboardDnD, stopKeyboardDnD } from './keyboard-dnd.svelte.js';
|
|
12
12
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- module-level registry, not reactive state
|
|
13
13
|
const registeredSources = new Map();
|
|
@@ -235,6 +235,16 @@ let _startX = 0;
|
|
|
235
235
|
let _startY = 0;
|
|
236
236
|
let _dragThresholdMet = false;
|
|
237
237
|
let _pointerUpdateScheduled = false;
|
|
238
|
+
/** Id of the throttling frame, so a release can drop it before it lands. */
|
|
239
|
+
let _pointerUpdateRafId = null;
|
|
240
|
+
/**
|
|
241
|
+
* Tears down the listeners a pointer drag installed — including the Escape
|
|
242
|
+
* listener on `window`, which would otherwise outlive its session: a drag
|
|
243
|
+
* ended by anything other than up/cancel/Escape (an external `cancel()`, a
|
|
244
|
+
* pagehide) never runs the closure's own cleanup, and the stale listener then
|
|
245
|
+
* answers the NEXT session's keystroke with the previous session's element.
|
|
246
|
+
*/
|
|
247
|
+
let _pointerCleanup = null;
|
|
238
248
|
let _pendingPointerX = 0;
|
|
239
249
|
let _pendingPointerY = 0;
|
|
240
250
|
const DRAG_THRESHOLD = 5;
|
|
@@ -320,14 +330,26 @@ export const dragSession = {
|
|
|
320
330
|
// Guard the pending claim against page navigation (see onPageHide)
|
|
321
331
|
startPageHideListener();
|
|
322
332
|
const el = source.element;
|
|
323
|
-
|
|
333
|
+
const pointerId = e.pointerId;
|
|
334
|
+
el.setPointerCapture(pointerId);
|
|
335
|
+
function releaseCapture(id) {
|
|
336
|
+
try {
|
|
337
|
+
el.releasePointerCapture(id);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
/* element may be disconnected */
|
|
341
|
+
}
|
|
342
|
+
}
|
|
324
343
|
function cleanup() {
|
|
344
|
+
_pointerCleanup = null;
|
|
325
345
|
// eslint-disable-next-line ts/no-use-before-define -- hoisted event handler references
|
|
326
346
|
el.removeEventListener('pointermove', onMove);
|
|
327
347
|
// eslint-disable-next-line ts/no-use-before-define -- hoisted event handler references
|
|
328
348
|
el.removeEventListener('pointerup', onUp);
|
|
329
349
|
// eslint-disable-next-line ts/no-use-before-define -- hoisted event handler references
|
|
330
350
|
el.removeEventListener('pointercancel', onCancel);
|
|
351
|
+
// eslint-disable-next-line ts/no-use-before-define -- hoisted event handler references
|
|
352
|
+
window.removeEventListener('keydown', onEscape, true);
|
|
331
353
|
clearLongPress();
|
|
332
354
|
}
|
|
333
355
|
function clearLongPress() {
|
|
@@ -377,14 +399,15 @@ export const dragSession = {
|
|
|
377
399
|
}
|
|
378
400
|
};
|
|
379
401
|
const onUp = (ev) => {
|
|
380
|
-
|
|
381
|
-
el.releasePointerCapture(ev.pointerId);
|
|
382
|
-
}
|
|
383
|
-
catch {
|
|
384
|
-
/* element may be disconnected */
|
|
385
|
-
}
|
|
402
|
+
releaseCapture(ev.pointerId);
|
|
386
403
|
cleanup();
|
|
387
404
|
if (_isDragging) {
|
|
405
|
+
// The target is whatever sits under the RELEASE point, not whatever
|
|
406
|
+
// the last frame that happened to run left behind. A fast gesture
|
|
407
|
+
// (move then up inside one frame) reached `completeDrop` with
|
|
408
|
+
// `_currentTargetId === null` and was cancelled in silence.
|
|
409
|
+
cancelScheduledPointerUpdate();
|
|
410
|
+
updatePointerPosition(ev.clientX, ev.clientY);
|
|
388
411
|
completeDrop();
|
|
389
412
|
}
|
|
390
413
|
else {
|
|
@@ -393,12 +416,50 @@ export const dragSession = {
|
|
|
393
416
|
}
|
|
394
417
|
};
|
|
395
418
|
const onCancel = () => {
|
|
419
|
+
releaseCapture(pointerId);
|
|
420
|
+
cleanup();
|
|
421
|
+
cancelDrag();
|
|
422
|
+
};
|
|
423
|
+
/**
|
|
424
|
+
* Escape cancels an ACTIVE pointer drag (PR #138: the e2e pin has documented
|
|
425
|
+
* this gap since 2026-04-13). Three rules make it correct:
|
|
426
|
+
*
|
|
427
|
+
* 1. the listeners are torn down from THIS closure — `_startX`/`_startY`
|
|
428
|
+
* survive a cancel, so a surviving `onMove` would re-activate the
|
|
429
|
+
* session at the next 5px move;
|
|
430
|
+
* 2. below the movement threshold the session is only PENDING: the
|
|
431
|
+
* keystroke belongs to the application (a dialog, a menu), and we
|
|
432
|
+
* neither consume nor prevent it;
|
|
433
|
+
* 3. the listener sits on `window`, not on `document`. `EscapeLayer`
|
|
434
|
+
* captures on `document`, and a drag that starts INSIDE an open dialog
|
|
435
|
+
* registers after it: on the same node in the same phase the layer
|
|
436
|
+
* would run first and close the dialog before `preventDefault()` could
|
|
437
|
+
* say anything. `window` in capture is strictly above EVERY
|
|
438
|
+
* `document`-level handler, `EscapeLayer` included, so mount order
|
|
439
|
+
* stops deciding, and `stopImmediatePropagation()` keeps the keystroke
|
|
440
|
+
* from travelling on. The next Escape finds no drag and closes the
|
|
441
|
+
* dialog normally.
|
|
442
|
+
*
|
|
443
|
+
* The tier is above `document`, not above everything: a handler already on
|
|
444
|
+
* `window` in capture when the drag starts still runs first, since this one
|
|
445
|
+
* registers last. Inside this package that cannot happen — the two
|
|
446
|
+
* window-capture handlers, here and in `keyboard-dnd`, are mutually
|
|
447
|
+
* exclusive because a session refuses re-entrance while one is live.
|
|
448
|
+
*/
|
|
449
|
+
const onEscape = (ev) => {
|
|
450
|
+
if (ev.key !== kbd.ESCAPE || !_isDragging)
|
|
451
|
+
return;
|
|
452
|
+
ev.preventDefault();
|
|
453
|
+
ev.stopImmediatePropagation();
|
|
454
|
+
releaseCapture(pointerId);
|
|
396
455
|
cleanup();
|
|
397
456
|
cancelDrag();
|
|
398
457
|
};
|
|
399
458
|
el.addEventListener('pointermove', onMove);
|
|
400
459
|
el.addEventListener('pointerup', onUp);
|
|
401
460
|
el.addEventListener('pointercancel', onCancel);
|
|
461
|
+
window.addEventListener('keydown', onEscape, true);
|
|
462
|
+
_pointerCleanup = cleanup;
|
|
402
463
|
},
|
|
403
464
|
// ── Start drag (keyboard) ───────────────────────────────────────────
|
|
404
465
|
beginKeyboardDrag(sourceId) {
|
|
@@ -544,20 +605,57 @@ function schedulePointerUpdate(x, y) {
|
|
|
544
605
|
moveGhost(x, y);
|
|
545
606
|
if (!_pointerUpdateScheduled) {
|
|
546
607
|
_pointerUpdateScheduled = true;
|
|
547
|
-
requestAnimationFrame(() => {
|
|
608
|
+
_pointerUpdateRafId = requestAnimationFrame(() => {
|
|
548
609
|
_pointerUpdateScheduled = false;
|
|
610
|
+
_pointerUpdateRafId = null;
|
|
549
611
|
if (_isDragging) {
|
|
550
612
|
updatePointerPosition(_pendingPointerX, _pendingPointerY);
|
|
551
613
|
}
|
|
552
614
|
});
|
|
553
615
|
}
|
|
554
616
|
}
|
|
617
|
+
/**
|
|
618
|
+
* Drops the throttling frame still in flight. Called when the session stops
|
|
619
|
+
* taking positions from it — at the release, and at every reset. `onDrop` may
|
|
620
|
+
* be async: while `completeDrop` awaits it the session is still dragging, so a
|
|
621
|
+
* surviving frame would push a STALE position over the one just released.
|
|
622
|
+
*/
|
|
623
|
+
function cancelScheduledPointerUpdate() {
|
|
624
|
+
if (_pointerUpdateRafId !== null) {
|
|
625
|
+
cancelAnimationFrame(_pointerUpdateRafId);
|
|
626
|
+
_pointerUpdateRafId = null;
|
|
627
|
+
}
|
|
628
|
+
_pointerUpdateScheduled = false;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Whether a target accepts what is being dragged. Answers for both the pointer
|
|
632
|
+
* hit-test and keyboard target navigation.
|
|
633
|
+
*
|
|
634
|
+
* No `acceptedTypes`, or an empty one, passes everything AT THIS LEVEL. That
|
|
635
|
+
* permissive branch is narrower than it looks: `SortableList` and `GridList`
|
|
636
|
+
* always hand down a non-empty list (their own `dragType` by default), so it
|
|
637
|
+
* is reached only when a consumer passes an empty array on purpose — and such
|
|
638
|
+
* a target is still refused at the drop by the component's own
|
|
639
|
+
* `getDropOperation`, which is pre-existing and unchanged here.
|
|
640
|
+
*/
|
|
641
|
+
function targetAcceptsDragTypes(target) {
|
|
642
|
+
const accepted = target.config.acceptedTypes;
|
|
643
|
+
if (!accepted || accepted.length === 0)
|
|
644
|
+
return true;
|
|
645
|
+
return accepted.some((t) => _types.has(t));
|
|
646
|
+
}
|
|
555
647
|
function updatePointerPosition(x, y) {
|
|
556
648
|
// Hit-test all registered targets to find the one under the pointer
|
|
557
649
|
let found = false;
|
|
558
650
|
for (const [id, target] of registeredTargets) {
|
|
559
651
|
if (target.config.isDisabled)
|
|
560
652
|
continue;
|
|
653
|
+
// A target that does not accept these types is not under the pointer for
|
|
654
|
+
// this drag: skipping it here is what keeps a foreign list from turning
|
|
655
|
+
// `drop-active` and drawing an indicator. Without it the loop breaks on
|
|
656
|
+
// the first rect containing the point, whatever that target accepts.
|
|
657
|
+
if (!targetAcceptsDragTypes(target))
|
|
658
|
+
continue;
|
|
561
659
|
const rect = target.element.getBoundingClientRect();
|
|
562
660
|
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
|
|
563
661
|
if (_currentTargetId !== id) {
|
|
@@ -600,11 +698,8 @@ function getValidTargets() {
|
|
|
600
698
|
if (target.config.isDisabled)
|
|
601
699
|
continue;
|
|
602
700
|
// Check if the target accepts the drag types
|
|
603
|
-
if (target
|
|
604
|
-
|
|
605
|
-
if (!accepts)
|
|
606
|
-
continue;
|
|
607
|
-
}
|
|
701
|
+
if (!targetAcceptsDragTypes(target))
|
|
702
|
+
continue;
|
|
608
703
|
// Group filtering: if target has a group, only accept sources from the same group
|
|
609
704
|
// (or sources whose dragType matches the target's acceptedTypes — already handled above)
|
|
610
705
|
if (target.config.group && _sourceCollectionId) {
|
|
@@ -702,6 +797,11 @@ function restoreFocus(targetEl, sourceEl) {
|
|
|
702
797
|
}, 150);
|
|
703
798
|
}
|
|
704
799
|
function resetSession() {
|
|
800
|
+
// A pointer session may end without its own handlers running (external
|
|
801
|
+
// `cancel()`, pagehide): tear its listeners down here too, or the Escape
|
|
802
|
+
// listener survives on `window` and answers the next session.
|
|
803
|
+
_pointerCleanup?.();
|
|
804
|
+
cancelScheduledPointerUpdate();
|
|
705
805
|
stopKeyboardDnD();
|
|
706
806
|
restoreHiddenContent();
|
|
707
807
|
stopObservingTargets();
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
/**
|
|
11
11
|
* Start listening for keyboard DnD events.
|
|
12
12
|
* Call when a keyboard drag begins.
|
|
13
|
+
*
|
|
14
|
+
* The handler sits on `window`, not on `document`: `EscapeLayer` captures on
|
|
15
|
+
* `document`, and a keyboard drag started INSIDE an open dialog registers
|
|
16
|
+
* after it — on the same node in the same phase the layer would answer the
|
|
17
|
+
* keystroke first and close the dialog. `window` in capture is strictly above
|
|
18
|
+
* every `document`-level handler, so mount order stops deciding. It is not
|
|
19
|
+
* above everything: a handler already on `window` in capture when the drag
|
|
20
|
+
* starts still runs first, since this one registers last.
|
|
13
21
|
*/
|
|
14
22
|
export declare function startKeyboardDnD(): void;
|
|
15
23
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"keyboard-dnd.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/drag-drop/keyboard-dnd.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAOH
|
|
1
|
+
{"version":3,"file":"keyboard-dnd.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/drag-drop/keyboard-dnd.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAOH;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,IAAI,IAAI,CAIvC;AAED;;;GAGG;AACH,wBAAgB,eAAe,IAAI,IAAI,CAItC"}
|
|
@@ -13,12 +13,20 @@ let isListening = false;
|
|
|
13
13
|
/**
|
|
14
14
|
* Start listening for keyboard DnD events.
|
|
15
15
|
* Call when a keyboard drag begins.
|
|
16
|
+
*
|
|
17
|
+
* The handler sits on `window`, not on `document`: `EscapeLayer` captures on
|
|
18
|
+
* `document`, and a keyboard drag started INSIDE an open dialog registers
|
|
19
|
+
* after it — on the same node in the same phase the layer would answer the
|
|
20
|
+
* keystroke first and close the dialog. `window` in capture is strictly above
|
|
21
|
+
* every `document`-level handler, so mount order stops deciding. It is not
|
|
22
|
+
* above everything: a handler already on `window` in capture when the drag
|
|
23
|
+
* starts still runs first, since this one registers last.
|
|
16
24
|
*/
|
|
17
25
|
export function startKeyboardDnD() {
|
|
18
26
|
if (!isBrowser || isListening)
|
|
19
27
|
return;
|
|
20
28
|
isListening = true;
|
|
21
|
-
|
|
29
|
+
window.addEventListener('keydown', onKeyDown, true);
|
|
22
30
|
}
|
|
23
31
|
/**
|
|
24
32
|
* Stop listening for keyboard DnD events.
|
|
@@ -28,7 +36,7 @@ export function stopKeyboardDnD() {
|
|
|
28
36
|
if (!isListening)
|
|
29
37
|
return;
|
|
30
38
|
isListening = false;
|
|
31
|
-
|
|
39
|
+
window.removeEventListener('keydown', onKeyDown, true);
|
|
32
40
|
}
|
|
33
41
|
function onKeyDown(e) {
|
|
34
42
|
if (!dragSession.isDragging) {
|
|
@@ -56,7 +64,10 @@ function onKeyDown(e) {
|
|
|
56
64
|
}
|
|
57
65
|
case kbd.ESCAPE: {
|
|
58
66
|
e.preventDefault();
|
|
59
|
-
|
|
67
|
+
// Immediate: the drag owns this keystroke entirely. Without it the
|
|
68
|
+
// containing dialog would close on the very Escape that cancelled the
|
|
69
|
+
// drag; the NEXT Escape closes it, which is what the user expects.
|
|
70
|
+
e.stopImmediatePropagation();
|
|
60
71
|
dragSession.cancel();
|
|
61
72
|
stopKeyboardDnD();
|
|
62
73
|
break;
|
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Multiple escape layers can be active simultaneously (e.g. nested dialogs).
|
|
5
5
|
* Only the topmost layer with behavior 'close' handles the keydown.
|
|
6
|
+
*
|
|
7
|
+
* TIERS. This stack listens on `document` in the CAPTURE phase (see `mount`).
|
|
8
|
+
* Exactly one tier sits above it: while a drag session is ACTIVE,
|
|
9
|
+
* `internal/drag-drop` listens on `window` in capture and calls
|
|
10
|
+
* `stopImmediatePropagation()`, so no layer here is offered that keystroke —
|
|
11
|
+
* the drag is cancelled, and the NEXT Escape reaches this stack normally.
|
|
12
|
+
* Below the drag's 5px pointer threshold the session is only PENDING and does
|
|
13
|
+
* not touch the event, so dismissing a surface stays immediately available.
|
|
14
|
+
*
|
|
15
|
+
* "Only the topmost layer handles the keydown" therefore reads: among the
|
|
16
|
+
* layers that are offered the keystroke at all. A dialog that will not close
|
|
17
|
+
* on Escape is very often a drag that has not ended.
|
|
6
18
|
*/
|
|
7
19
|
import type { EscapeKeydownBehavior, EscapeLayerProps } from './types.js';
|
|
8
20
|
export declare class EscapeLayerState {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"escape-layer.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/escape-layer/escape-layer.svelte.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"escape-layer.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/escape-layer/escape-layer.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AA4CzE,qBAAa,gBAAgB;IAC3B,KAAK,EAAE,gBAAgB,CAAA;gBAEX,KAAK,EAAE,gBAAgB;IAInC,KAAK,IAAI,IAAI;IAmBb,OAAO,IAAI,IAAI;IAcf;;;;OAIG;IACH,cAAc,CAAC,QAAQ,EAAE,qBAAqB,GAAG,IAAI;CAGtD;AAED,gDAAgD;AAChD,wBAAgB,qBAAqB,IAAI,IAAI,CAK5C"}
|
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Multiple escape layers can be active simultaneously (e.g. nested dialogs).
|
|
5
5
|
* Only the topmost layer with behavior 'close' handles the keydown.
|
|
6
|
+
*
|
|
7
|
+
* TIERS. This stack listens on `document` in the CAPTURE phase (see `mount`).
|
|
8
|
+
* Exactly one tier sits above it: while a drag session is ACTIVE,
|
|
9
|
+
* `internal/drag-drop` listens on `window` in capture and calls
|
|
10
|
+
* `stopImmediatePropagation()`, so no layer here is offered that keystroke —
|
|
11
|
+
* the drag is cancelled, and the NEXT Escape reaches this stack normally.
|
|
12
|
+
* Below the drag's 5px pointer threshold the session is only PENDING and does
|
|
13
|
+
* not touch the event, so dismissing a surface stays immediately available.
|
|
14
|
+
*
|
|
15
|
+
* "Only the topmost layer handles the keydown" therefore reads: among the
|
|
16
|
+
* layers that are offered the keystroke at all. A dialog that will not close
|
|
17
|
+
* on Escape is very often a drag that has not ended.
|
|
6
18
|
*/
|
|
7
19
|
import { isBrowser, kbd } from '../helpers/index.js';
|
|
8
20
|
const layerStack = [];
|
|
@@ -49,7 +61,14 @@ export class EscapeLayerState {
|
|
|
49
61
|
return;
|
|
50
62
|
if (!this.props.enabled)
|
|
51
63
|
return;
|
|
52
|
-
// Add global listener if this is the first layer
|
|
64
|
+
// Add global listener if this is the first layer.
|
|
65
|
+
//
|
|
66
|
+
// `document` + capture is contractual, not incidental: `internal/drag-drop`
|
|
67
|
+
// deliberately sits one tier above, on `window` in capture, so an active
|
|
68
|
+
// drag always answers Escape before any layer whatever the mount order.
|
|
69
|
+
// Hoisting this listener to `window` would put the two back on the same
|
|
70
|
+
// node in the same phase, where registration order decides — which is the
|
|
71
|
+
// bug that put the drag handler on `window` in the first place.
|
|
53
72
|
if (layerStack.length === 0) {
|
|
54
73
|
document.addEventListener('keydown', handleGlobalKeydown, true);
|
|
55
74
|
}
|