@spectral_labs/ui 1.5.3 → 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.
Files changed (26) hide show
  1. package/dist/components/__tests__/overlay-scrim-source.test.js +134 -0
  2. package/dist/components/command/__tests__/CommandTest.svelte +24 -2
  3. package/dist/components/command/__tests__/command-item-passthrough.test.svelte.js +284 -0
  4. package/dist/components/command/components/command-item.svelte +49 -12
  5. package/dist/components/command/components/command-item.svelte.d.ts.map +1 -1
  6. package/dist/components/command/types.d.ts +27 -2
  7. package/dist/components/command/types.d.ts.map +1 -1
  8. package/dist/internal/drag-drop/__tests__/drag-session.test.svelte.js +256 -0
  9. package/dist/internal/drag-drop/drag-session.svelte.d.ts.map +1 -1
  10. package/dist/internal/drag-drop/drag-session.svelte.js +114 -14
  11. package/dist/internal/drag-drop/keyboard-dnd.svelte.d.ts +8 -0
  12. package/dist/internal/drag-drop/keyboard-dnd.svelte.d.ts.map +1 -1
  13. package/dist/internal/drag-drop/keyboard-dnd.svelte.js +14 -3
  14. package/dist/internal/escape-layer/escape-layer.svelte.d.ts +12 -0
  15. package/dist/internal/escape-layer/escape-layer.svelte.d.ts.map +1 -1
  16. package/dist/internal/escape-layer/escape-layer.svelte.js +20 -1
  17. package/package.json +1 -1
  18. package/src/themes/__tests__/generated-css-drift.test.ts +53 -0
  19. package/src/themes/__tests__/scrim-opaque.test.ts +52 -0
  20. package/src/themes/carbon.css +1 -2
  21. package/src/themes/day.css +1 -2
  22. package/src/themes/docs.css +1 -1
  23. package/src/themes/iris-ocr.css +1 -2
  24. package/src/themes/midnight.css +1 -2
  25. package/src/themes/spectral.css +1 -3
  26. package/src/tokens.css +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;AA4QD,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;gCA4G7B,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"}
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
- el.setPointerCapture(e.pointerId);
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
- try {
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.config.acceptedTypes && target.config.acceptedTypes.length > 0) {
604
- const accepts = target.config.acceptedTypes.some((t) => _types.has(t));
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;;;GAGG;AACH,wBAAgB,gBAAgB,IAAI,IAAI,CAIvC;AAED;;;GAGG;AACH,wBAAgB,eAAe,IAAI,IAAI,CAItC"}
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
- document.addEventListener('keydown', onKeyDown, true);
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
- document.removeEventListener('keydown', onKeyDown, true);
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
- e.stopPropagation();
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;;;;;GAKG;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;IAYb,OAAO,IAAI,IAAI;IAcf;;;;OAIG;IACH,cAAc,CAAC,QAAQ,EAAE,qBAAqB,GAAG,IAAI;CAGtD;AAED,gDAAgD;AAChD,wBAAgB,qBAAqB,IAAI,IAAI,CAK5C"}
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
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spectral_labs/ui",
3
3
  "type": "module",
4
- "version": "1.5.3",
4
+ "version": "1.6.1",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
7
7
  "access": "public"