@spectral_labs/ui 1.7.0 → 1.8.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/THIRD_PARTY_NOTICES.md +201 -0
- package/package.json +8 -2
- package/dist/components/__tests__/overlay-scrim-source.test.js +0 -134
- package/dist/components/accordion/__tests__/AccordionTest.svelte +0 -31
- package/dist/components/accordion/__tests__/accordion.test.svelte.js +0 -139
- package/dist/components/alert-dialog/__tests__/AlertDialogTest.svelte +0 -28
- package/dist/components/alert-dialog/__tests__/alert-dialog.a11y.test.js +0 -42
- package/dist/components/alert-dialog/__tests__/alert-dialog.integration.test.svelte.js +0 -51
- package/dist/components/alert-dialog/__tests__/alert-dialog.test.svelte.js +0 -113
- package/dist/components/app-bar/__tests__/app-bar.test.svelte.js +0 -30
- package/dist/components/aspect-ratio/__tests__/aspect-ratio.test.svelte.js +0 -22
- package/dist/components/avatar/__tests__/avatar.test.svelte.js +0 -26
- package/dist/components/badge/__tests__/badge.test.svelte.js +0 -30
- package/dist/components/bottom-sheet/__tests__/BottomSheetTest.svelte +0 -22
- package/dist/components/bottom-sheet/__tests__/bottom-sheet.test.svelte.js +0 -83
- package/dist/components/button/__tests__/button.test.svelte.js +0 -63
- package/dist/components/calendar/__tests__/CalendarTest.svelte +0 -56
- package/dist/components/calendar/__tests__/calendar.test.svelte.js +0 -190
- package/dist/components/card/__tests__/card.test.svelte.js +0 -30
- package/dist/components/checkbox/__tests__/checkbox.test.svelte.js +0 -44
- package/dist/components/chips/__tests__/chips.test.svelte.js +0 -26
- package/dist/components/collapsible/__tests__/CollapsibleTest.svelte +0 -18
- package/dist/components/collapsible/__tests__/collapsible.test.svelte.js +0 -125
- package/dist/components/combobox/__tests__/ComboboxTest.svelte +0 -40
- package/dist/components/combobox/__tests__/combobox.test.svelte.js +0 -255
- package/dist/components/command/__tests__/CommandInlineDirectTest.svelte +0 -29
- package/dist/components/command/__tests__/CommandInlineTest.svelte +0 -89
- package/dist/components/command/__tests__/CommandTest.svelte +0 -77
- package/dist/components/command/__tests__/command-inline.test.svelte.js +0 -392
- package/dist/components/command/__tests__/command-item-passthrough.test.svelte.js +0 -284
- package/dist/components/command/__tests__/command.a11y.test.js +0 -30
- package/dist/components/command/__tests__/command.integration.test.svelte.js +0 -68
- package/dist/components/command/__tests__/command.test.svelte.js +0 -230
- package/dist/components/context-menu/__tests__/ContextMenuNonInteractiveTest.svelte +0 -32
- package/dist/components/context-menu/__tests__/ContextMenuTest.svelte +0 -25
- package/dist/components/context-menu/__tests__/context-menu.test.svelte.js +0 -141
- package/dist/components/date-field/__tests__/DateFieldTest.svelte +0 -20
- package/dist/components/date-field/__tests__/date-field.test.svelte.js +0 -139
- package/dist/components/date-picker/__tests__/DatePickerTest.svelte +0 -20
- package/dist/components/date-picker/__tests__/date-picker.test.svelte.js +0 -190
- package/dist/components/date-range-field/__tests__/DateRangeFieldTest.svelte +0 -20
- package/dist/components/date-range-field/__tests__/date-range-field.test.svelte.js +0 -130
- package/dist/components/date-range-picker/__tests__/DateRangePickerTest.svelte +0 -18
- package/dist/components/date-range-picker/__tests__/date-range-picker.test.svelte.js +0 -156
- package/dist/components/dialog/__tests__/DialogTest.svelte +0 -34
- package/dist/components/dialog/__tests__/dialog.test.svelte.js +0 -184
- package/dist/components/drop-zone/__tests__/drop-zone.test.svelte.js +0 -26
- package/dist/components/dropdown-menu/__tests__/DropdownMenuRadioTest.svelte +0 -90
- package/dist/components/dropdown-menu/__tests__/DropdownMenuTest.svelte +0 -71
- package/dist/components/dropdown-menu/__tests__/dropdown-menu-radio-group.test.svelte.js +0 -306
- package/dist/components/dropdown-menu/__tests__/dropdown-menu.test.svelte.js +0 -567
- package/dist/components/empty-state/__tests__/empty-state.test.svelte.js +0 -26
- package/dist/components/fab/__tests__/fab.test.svelte.js +0 -30
- package/dist/components/field/__tests__/FieldTest.svelte +0 -26
- package/dist/components/field/__tests__/field.test.svelte.js +0 -74
- package/dist/components/grid-list/__tests__/GridListTest.svelte +0 -46
- package/dist/components/grid-list/__tests__/grid-list.test.svelte.js +0 -231
- package/dist/components/hover-card/__tests__/HoverCardTest.svelte +0 -30
- package/dist/components/hover-card/__tests__/hover-card.test.svelte.js +0 -236
- package/dist/components/indicator/__tests__/indicator.test.svelte.js +0 -30
- package/dist/components/input/__tests__/input.test.svelte.js +0 -67
- package/dist/components/label/__tests__/label.test.svelte.js +0 -22
- package/dist/components/language-select/__tests__/language-select.test.svelte.js +0 -199
- package/dist/components/language-select/__tests__/languages.test.js +0 -87
- package/dist/components/link-preview/__tests__/LinkPreviewTest.svelte +0 -37
- package/dist/components/link-preview/__tests__/link-preview.a11y.test.js +0 -28
- package/dist/components/link-preview/__tests__/link-preview.integration.test.svelte.js +0 -54
- package/dist/components/link-preview/__tests__/link-preview.test.svelte.js +0 -215
- package/dist/components/list/__tests__/list.test.svelte.js +0 -22
- package/dist/components/menubar/__tests__/MenubarTest.svelte +0 -52
- package/dist/components/menubar/__tests__/menubar.a11y.test.js +0 -28
- package/dist/components/menubar/__tests__/menubar.integration.test.svelte.js +0 -57
- package/dist/components/menubar/__tests__/menubar.test.svelte.js +0 -328
- package/dist/components/meter/__tests__/meter.test.svelte.js +0 -26
- package/dist/components/nav-bar/__tests__/nav-bar.test.svelte.js +0 -23
- package/dist/components/nav-drawer/__tests__/nav-drawer.test.svelte.js +0 -28
- package/dist/components/nav-rail/__tests__/nav-rail.test.svelte.js +0 -28
- package/dist/components/pagination/__tests__/pagination.test.svelte.js +0 -26
- package/dist/components/pin-input/__tests__/pin-input.test.svelte.js +0 -71
- package/dist/components/popover/__tests__/PopoverTest.svelte +0 -27
- package/dist/components/popover/__tests__/popover.test.svelte.js +0 -196
- package/dist/components/progress/__tests__/progress.test.svelte.js +0 -32
- package/dist/components/radio-group/__tests__/radio-group.test.svelte.js +0 -22
- package/dist/components/range-calendar/__tests__/RangeCalendarTest.svelte +0 -23
- package/dist/components/range-calendar/__tests__/range-calendar.test.svelte.js +0 -105
- package/dist/components/rating-group/__tests__/rating-group.test.svelte.js +0 -26
- package/dist/components/scroll-area/__tests__/scroll-area.test.svelte.js +0 -34
- package/dist/components/search-field/__tests__/SearchFieldTest.svelte +0 -34
- package/dist/components/search-field/__tests__/search-field.test.svelte.js +0 -118
- package/dist/components/select/__tests__/SelectLabelPersistenceTest.svelte +0 -31
- package/dist/components/select/__tests__/SelectTest.svelte +0 -33
- package/dist/components/select/__tests__/select.test.svelte.js +0 -221
- package/dist/components/separator/__tests__/separator.test.svelte.js +0 -39
- package/dist/components/sheet/__tests__/SheetTest.svelte +0 -34
- package/dist/components/sheet/__tests__/sheet.test.svelte.js +0 -193
- package/dist/components/side-sheet/__tests__/side-sheet.test.svelte.js +0 -138
- package/dist/components/sidebar/__tests__/SidebarFullTest.svelte +0 -45
- package/dist/components/sidebar/__tests__/SidebarTest.svelte +0 -9
- package/dist/components/sidebar/__tests__/sidebar.test.svelte.js +0 -186
- package/dist/components/simple-dialog/__tests__/SimpleDialogTest.svelte +0 -40
- package/dist/components/simple-dialog/__tests__/simple-dialog.test.svelte.js +0 -50
- package/dist/components/simple-tooltip/__tests__/SimpleTooltipTest.svelte +0 -19
- package/dist/components/simple-tooltip/__tests__/simple-tooltip.test.svelte.js +0 -61
- package/dist/components/slider/__tests__/slider.test.svelte.js +0 -42
- package/dist/components/sortable-list/__tests__/NestedSortableListTest.svelte +0 -49
- package/dist/components/sortable-list/__tests__/SortableListTest.svelte +0 -42
- package/dist/components/sortable-list/__tests__/sortable-list.nested.test.svelte.js +0 -112
- package/dist/components/sortable-list/__tests__/sortable-list.test.svelte.js +0 -175
- package/dist/components/splitter/__tests__/splitter.test.svelte.js +0 -26
- package/dist/components/switch/__tests__/switch.test.svelte.js +0 -44
- package/dist/components/table/__tests__/table.test.svelte.js +0 -22
- package/dist/components/tabs/__tests__/TabsTest.svelte +0 -32
- package/dist/components/tabs/__tests__/tabs.test.svelte.js +0 -227
- package/dist/components/textarea/__tests__/textarea.test.svelte.js +0 -55
- package/dist/components/time-field/__tests__/TimeFieldTest.svelte +0 -20
- package/dist/components/time-field/__tests__/time-field.test.svelte.js +0 -100
- package/dist/components/time-range-field/__tests__/TimeRangeFieldTest.svelte +0 -20
- package/dist/components/time-range-field/__tests__/time-range-field.test.svelte.js +0 -124
- package/dist/components/toast/__tests__/toast.test.svelte.js +0 -46
- package/dist/components/toggle/__tests__/toggle.test.svelte.js +0 -38
- package/dist/components/toggle-group/__tests__/toggle-group.test.svelte.js +0 -22
- package/dist/components/toolbar/__tests__/ToolbarCompositionTest.svelte +0 -18
- package/dist/components/toolbar/__tests__/ToolbarTabstopTest.svelte +0 -10
- package/dist/components/toolbar/__tests__/toolbar.test.svelte.js +0 -63
- package/dist/components/tooltip/__tests__/TooltipTest.svelte +0 -22
- package/dist/components/tooltip/__tests__/tooltip.test.svelte.js +0 -225
- package/dist/components/tree/__tests__/TreeLabelContentTest.svelte +0 -21
- package/dist/components/tree/__tests__/TreeTest.svelte +0 -24
- package/dist/components/tree/__tests__/tree.test.svelte.js +0 -229
- package/dist/components/virtualizer/__tests__/waterfall-layout.test.js +0 -82
- package/dist/internal/announcer/__tests__/announcer.test.js +0 -42
- package/dist/internal/dismissible-layer/__tests__/dismissible-layer.test.js +0 -196
- package/dist/internal/drag-drop/__tests__/auto-scroll.test.js +0 -104
- package/dist/internal/drag-drop/__tests__/drag-session.test.svelte.js +0 -803
- package/dist/internal/drag-drop/__tests__/drag-source.test.svelte.js +0 -130
- package/dist/internal/drag-drop/__tests__/drop-delegate.test.js +0 -223
- package/dist/internal/drag-drop/__tests__/types.test.js +0 -20
- package/dist/internal/escape-layer/__tests__/escape-layer.test.js +0 -113
- package/dist/internal/focus-scope/__tests__/focus-scope.test.js +0 -225
- package/dist/internal/grace-area/__tests__/grace-area.test.js +0 -67
- package/dist/internal/helpers/__tests__/helpers.test.js +0 -123
- package/dist/internal/portal/__tests__/PortalTest.svelte +0 -21
- package/dist/internal/portal/__tests__/portal.test.js +0 -29
- package/dist/internal/portal/__tests__/portal.test.svelte.js +0 -57
- package/dist/internal/scroll-lock/__tests__/scroll-lock.test.js +0 -160
- package/dist/internal/typeahead/__tests__/typeahead.test.js +0 -61
- package/src/themes/__tests__/carbon-dark-contrast.test.ts +0 -170
- package/src/themes/__tests__/generated-css-drift.test.ts +0 -53
- package/src/themes/__tests__/scrim-opaque.test.ts +0 -52
|
@@ -1,803 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { EscapeLayerState, resetEscapeLayerStack } from '../../escape-layer/index.js';
|
|
3
|
-
import { dragSession } from '../drag-session.svelte.js';
|
|
4
|
-
function createSourceConfig(id, overrides = {}) {
|
|
5
|
-
return {
|
|
6
|
-
id,
|
|
7
|
-
getItems: () => [{ 'test-type': id, 'text/plain': `Item ${id}` }],
|
|
8
|
-
onDragStart: vi.fn(),
|
|
9
|
-
onDragEnd: vi.fn(),
|
|
10
|
-
...overrides,
|
|
11
|
-
};
|
|
12
|
-
}
|
|
13
|
-
function createTargetConfig(id, overrides = {}) {
|
|
14
|
-
return {
|
|
15
|
-
id,
|
|
16
|
-
onDrop: vi.fn(),
|
|
17
|
-
...overrides,
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
function createElement() {
|
|
21
|
-
const el = document.createElement('div');
|
|
22
|
-
Object.defineProperty(el, 'getBoundingClientRect', {
|
|
23
|
-
value: () => ({
|
|
24
|
-
top: 0,
|
|
25
|
-
left: 0,
|
|
26
|
-
bottom: 100,
|
|
27
|
-
right: 200,
|
|
28
|
-
width: 200,
|
|
29
|
-
height: 100,
|
|
30
|
-
x: 0,
|
|
31
|
-
y: 0,
|
|
32
|
-
toJSON() { },
|
|
33
|
-
}),
|
|
34
|
-
});
|
|
35
|
-
document.body.appendChild(el);
|
|
36
|
-
return el;
|
|
37
|
-
}
|
|
38
|
-
/** Polyfill PointerEvent for jsdom. */
|
|
39
|
-
function ensurePointerEventPolyfill() {
|
|
40
|
-
if (typeof globalThis.PointerEvent === 'undefined') {
|
|
41
|
-
;
|
|
42
|
-
globalThis.PointerEvent = class PointerEvent extends MouseEvent {
|
|
43
|
-
pointerId;
|
|
44
|
-
pointerType;
|
|
45
|
-
constructor(type, init = {}) {
|
|
46
|
-
super(type, init);
|
|
47
|
-
this.pointerId = init.pointerId ?? 0;
|
|
48
|
-
this.pointerType = init.pointerType ?? '';
|
|
49
|
-
}
|
|
50
|
-
};
|
|
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
|
-
}
|
|
75
|
-
describe('dragSession', () => {
|
|
76
|
-
beforeEach(() => {
|
|
77
|
-
dragSession._resetForTests();
|
|
78
|
-
});
|
|
79
|
-
describe('initial state', () => {
|
|
80
|
-
it('is not dragging initially', () => {
|
|
81
|
-
expect(dragSession.isDragging).toBe(false);
|
|
82
|
-
});
|
|
83
|
-
it('has no source', () => {
|
|
84
|
-
expect(dragSession.sourceId).toBeNull();
|
|
85
|
-
});
|
|
86
|
-
it('has no current target', () => {
|
|
87
|
-
expect(dragSession.currentTarget).toBeNull();
|
|
88
|
-
});
|
|
89
|
-
it('has empty items', () => {
|
|
90
|
-
expect(dragSession.items).toEqual([]);
|
|
91
|
-
});
|
|
92
|
-
it('has empty types', () => {
|
|
93
|
-
expect(dragSession.types.size).toBe(0);
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
describe('source registration', () => {
|
|
97
|
-
it('registers and unregisters a source', () => {
|
|
98
|
-
const config = createSourceConfig('source-1');
|
|
99
|
-
const el = createElement();
|
|
100
|
-
const unregister = dragSession.registerSource(config, el);
|
|
101
|
-
expect(dragSession.getSource('source-1')).toBeDefined();
|
|
102
|
-
expect(dragSession.getSource('source-1').config).toBe(config);
|
|
103
|
-
unregister();
|
|
104
|
-
expect(dragSession.getSource('source-1')).toBeUndefined();
|
|
105
|
-
el.remove();
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
describe('target registration', () => {
|
|
109
|
-
it('registers and unregisters a target', () => {
|
|
110
|
-
const config = createTargetConfig('target-1');
|
|
111
|
-
const el = createElement();
|
|
112
|
-
const unregister = dragSession.registerTarget(config, el);
|
|
113
|
-
expect(dragSession.getTarget('target-1')).toBeDefined();
|
|
114
|
-
expect(dragSession.getTarget('target-1').config).toBe(config);
|
|
115
|
-
unregister();
|
|
116
|
-
expect(dragSession.getTarget('target-1')).toBeUndefined();
|
|
117
|
-
el.remove();
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
describe('keyboard drag', () => {
|
|
121
|
-
it('starts a keyboard drag session', () => {
|
|
122
|
-
const config = createSourceConfig('kb-source');
|
|
123
|
-
const el = createElement();
|
|
124
|
-
dragSession.registerSource(config, el);
|
|
125
|
-
dragSession.beginKeyboardDrag('kb-source');
|
|
126
|
-
expect(dragSession.isDragging).toBe(true);
|
|
127
|
-
expect(dragSession.sourceId).toBe('kb-source');
|
|
128
|
-
expect(dragSession.modality).toBe('keyboard');
|
|
129
|
-
expect(dragSession.items).toHaveLength(1);
|
|
130
|
-
expect(dragSession.types.has('test-type')).toBe(true);
|
|
131
|
-
expect(config.onDragStart).toHaveBeenCalled();
|
|
132
|
-
dragSession.cancel();
|
|
133
|
-
el.remove();
|
|
134
|
-
});
|
|
135
|
-
it('does not start if source is disabled', () => {
|
|
136
|
-
const config = createSourceConfig('disabled-source', { isDisabled: true });
|
|
137
|
-
const el = createElement();
|
|
138
|
-
dragSession.registerSource(config, el);
|
|
139
|
-
dragSession.beginKeyboardDrag('disabled-source');
|
|
140
|
-
expect(dragSession.isDragging).toBe(false);
|
|
141
|
-
el.remove();
|
|
142
|
-
});
|
|
143
|
-
it('does not start if already dragging', () => {
|
|
144
|
-
const config1 = createSourceConfig('src-1');
|
|
145
|
-
const config2 = createSourceConfig('src-2');
|
|
146
|
-
const el1 = createElement();
|
|
147
|
-
const el2 = createElement();
|
|
148
|
-
dragSession.registerSource(config1, el1);
|
|
149
|
-
dragSession.registerSource(config2, el2);
|
|
150
|
-
dragSession.beginKeyboardDrag('src-1');
|
|
151
|
-
dragSession.beginKeyboardDrag('src-2');
|
|
152
|
-
expect(dragSession.sourceId).toBe('src-1'); // first one wins
|
|
153
|
-
dragSession.cancel();
|
|
154
|
-
el1.remove();
|
|
155
|
-
el2.remove();
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
describe('cancel', () => {
|
|
159
|
-
it('resets state and calls onDragEnd with cancel', () => {
|
|
160
|
-
const config = createSourceConfig('cancel-src');
|
|
161
|
-
const el = createElement();
|
|
162
|
-
dragSession.registerSource(config, el);
|
|
163
|
-
dragSession.beginKeyboardDrag('cancel-src');
|
|
164
|
-
expect(dragSession.isDragging).toBe(true);
|
|
165
|
-
dragSession.cancel();
|
|
166
|
-
expect(dragSession.isDragging).toBe(false);
|
|
167
|
-
expect(dragSession.sourceId).toBeNull();
|
|
168
|
-
expect(dragSession.items).toEqual([]);
|
|
169
|
-
expect(config.onDragEnd).toHaveBeenCalledWith('cancel');
|
|
170
|
-
el.remove();
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
describe('drop', () => {
|
|
174
|
-
it('calls onDrop on the target and onDragEnd on the source', async () => {
|
|
175
|
-
const sourceConfig = createSourceConfig('drop-src');
|
|
176
|
-
const targetConfig = createTargetConfig('drop-tgt', {
|
|
177
|
-
getDropOperation: () => 'move',
|
|
178
|
-
onDrop: vi.fn(),
|
|
179
|
-
});
|
|
180
|
-
const srcEl = createElement();
|
|
181
|
-
const tgtEl = createElement();
|
|
182
|
-
dragSession.registerSource(sourceConfig, srcEl);
|
|
183
|
-
dragSession.registerTarget(targetConfig, tgtEl);
|
|
184
|
-
dragSession.beginKeyboardDrag('drop-src');
|
|
185
|
-
dragSession.focusNextTarget(); // navigate to the target
|
|
186
|
-
// Verify we're on a target
|
|
187
|
-
expect(dragSession.currentTargetId).toBe('drop-tgt');
|
|
188
|
-
await dragSession.drop();
|
|
189
|
-
expect(targetConfig.onDrop).toHaveBeenCalled();
|
|
190
|
-
expect(sourceConfig.onDragEnd).toHaveBeenCalledWith('move');
|
|
191
|
-
expect(dragSession.isDragging).toBe(false);
|
|
192
|
-
srcEl.remove();
|
|
193
|
-
tgtEl.remove();
|
|
194
|
-
});
|
|
195
|
-
it('cancels if no target is selected', async () => {
|
|
196
|
-
const config = createSourceConfig('no-tgt-src');
|
|
197
|
-
const el = createElement();
|
|
198
|
-
dragSession.registerSource(config, el);
|
|
199
|
-
dragSession.beginKeyboardDrag('no-tgt-src');
|
|
200
|
-
await dragSession.drop();
|
|
201
|
-
expect(config.onDragEnd).toHaveBeenCalledWith('cancel');
|
|
202
|
-
expect(dragSession.isDragging).toBe(false);
|
|
203
|
-
el.remove();
|
|
204
|
-
});
|
|
205
|
-
});
|
|
206
|
-
describe('target navigation', () => {
|
|
207
|
-
it('cycles through targets with focusNextTarget', () => {
|
|
208
|
-
const sourceConfig = createSourceConfig('nav-src');
|
|
209
|
-
const tgt1Config = createTargetConfig('nav-tgt-1');
|
|
210
|
-
const tgt2Config = createTargetConfig('nav-tgt-2');
|
|
211
|
-
const srcEl = createElement();
|
|
212
|
-
const tgt1El = createElement();
|
|
213
|
-
const tgt2El = createElement();
|
|
214
|
-
dragSession.registerSource(sourceConfig, srcEl);
|
|
215
|
-
dragSession.registerTarget(tgt1Config, tgt1El);
|
|
216
|
-
dragSession.registerTarget(tgt2Config, tgt2El);
|
|
217
|
-
dragSession.beginKeyboardDrag('nav-src');
|
|
218
|
-
dragSession.focusNextTarget();
|
|
219
|
-
const first = dragSession.currentTargetId;
|
|
220
|
-
expect(first).toBeTruthy();
|
|
221
|
-
dragSession.focusNextTarget();
|
|
222
|
-
const second = dragSession.currentTargetId;
|
|
223
|
-
expect(second).toBeTruthy();
|
|
224
|
-
expect(second).not.toBe(first);
|
|
225
|
-
// Cycle back
|
|
226
|
-
dragSession.focusNextTarget();
|
|
227
|
-
expect(dragSession.currentTargetId).toBe(first);
|
|
228
|
-
dragSession.cancel();
|
|
229
|
-
srcEl.remove();
|
|
230
|
-
tgt1El.remove();
|
|
231
|
-
tgt2El.remove();
|
|
232
|
-
});
|
|
233
|
-
});
|
|
234
|
-
describe('subscriptions', () => {
|
|
235
|
-
it('notifies subscribers on state change', () => {
|
|
236
|
-
const callback = vi.fn();
|
|
237
|
-
const unsub = dragSession.subscribe(callback);
|
|
238
|
-
const config = createSourceConfig('sub-src');
|
|
239
|
-
const el = createElement();
|
|
240
|
-
dragSession.registerSource(config, el);
|
|
241
|
-
dragSession.beginKeyboardDrag('sub-src');
|
|
242
|
-
expect(callback).toHaveBeenCalled();
|
|
243
|
-
dragSession.cancel();
|
|
244
|
-
unsub();
|
|
245
|
-
el.remove();
|
|
246
|
-
});
|
|
247
|
-
it('unsubscribes correctly', () => {
|
|
248
|
-
const callback = vi.fn();
|
|
249
|
-
const unsub = dragSession.subscribe(callback);
|
|
250
|
-
unsub();
|
|
251
|
-
const config = createSourceConfig('unsub-src');
|
|
252
|
-
const el = createElement();
|
|
253
|
-
dragSession.registerSource(config, el);
|
|
254
|
-
dragSession.beginKeyboardDrag('unsub-src');
|
|
255
|
-
expect(callback).not.toHaveBeenCalled();
|
|
256
|
-
dragSession.cancel();
|
|
257
|
-
el.remove();
|
|
258
|
-
});
|
|
259
|
-
});
|
|
260
|
-
describe('collection context', () => {
|
|
261
|
-
it('sets and checks source collection', () => {
|
|
262
|
-
const config = createSourceConfig('coll-src');
|
|
263
|
-
const el = createElement();
|
|
264
|
-
dragSession.registerSource(config, el);
|
|
265
|
-
dragSession.beginKeyboardDrag('coll-src');
|
|
266
|
-
dragSession.setSourceCollection('list-A', new Set(['item-1', 'item-2']));
|
|
267
|
-
expect(dragSession.sourceCollectionId).toBe('list-A');
|
|
268
|
-
expect(dragSession.draggingKeys.has('item-1')).toBe(true);
|
|
269
|
-
expect(dragSession.draggingKeys.has('item-2')).toBe(true);
|
|
270
|
-
expect(dragSession.isInternalDrop('list-A')).toBe(true);
|
|
271
|
-
expect(dragSession.isInternalDrop('list-B')).toBe(false);
|
|
272
|
-
// wasDroppedExternally — no drop yet
|
|
273
|
-
expect(dragSession.wasDroppedExternally('list-A')).toBe(false);
|
|
274
|
-
dragSession.cancel();
|
|
275
|
-
el.remove();
|
|
276
|
-
});
|
|
277
|
-
it('wasDroppedExternally detects cross-list drops correctly', async () => {
|
|
278
|
-
const sourceConfig = createSourceConfig('cross-src');
|
|
279
|
-
const targetConfig = createTargetConfig('cross-tgt', {
|
|
280
|
-
getDropOperation: () => 'move',
|
|
281
|
-
onDrop: vi.fn(),
|
|
282
|
-
});
|
|
283
|
-
const srcEl = createElement();
|
|
284
|
-
const tgtEl = createElement();
|
|
285
|
-
dragSession.registerSource(sourceConfig, srcEl);
|
|
286
|
-
dragSession.registerTarget(targetConfig, tgtEl);
|
|
287
|
-
dragSession.beginKeyboardDrag('cross-src');
|
|
288
|
-
dragSession.setSourceCollection('list-A', new Set(['item-1']));
|
|
289
|
-
dragSession.focusNextTarget();
|
|
290
|
-
// Before drop, dropCollectionId is null
|
|
291
|
-
expect(dragSession.wasDroppedExternally('list-A')).toBe(false);
|
|
292
|
-
await dragSession.drop();
|
|
293
|
-
// After drop, source's onDragEnd was called — check that the session
|
|
294
|
-
// correctly tracked that the drop went to a DIFFERENT collection
|
|
295
|
-
// (cross-tgt is NOT list-A, so wasDroppedExternally should be true)
|
|
296
|
-
// Note: by this point resetSession was called, so we test indirectly
|
|
297
|
-
// via the onDragEnd callback behavior
|
|
298
|
-
expect(sourceConfig.onDragEnd).toHaveBeenCalledWith('move');
|
|
299
|
-
srcEl.remove();
|
|
300
|
-
tgtEl.remove();
|
|
301
|
-
});
|
|
302
|
-
});
|
|
303
|
-
describe('touch long-press', () => {
|
|
304
|
-
beforeEach(() => {
|
|
305
|
-
vi.useFakeTimers();
|
|
306
|
-
ensurePointerEventPolyfill();
|
|
307
|
-
});
|
|
308
|
-
afterEach(() => {
|
|
309
|
-
vi.useRealTimers();
|
|
310
|
-
});
|
|
311
|
-
function createTouchPointerEvent(type, x, y, pointerId = 1) {
|
|
312
|
-
return new PointerEvent(type, {
|
|
313
|
-
clientX: x,
|
|
314
|
-
clientY: y,
|
|
315
|
-
pointerId,
|
|
316
|
-
pointerType: 'touch',
|
|
317
|
-
bubbles: true,
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
it('does not start drag immediately on touch pointerdown', () => {
|
|
321
|
-
const config = createSourceConfig('touch-src');
|
|
322
|
-
const el = createElement();
|
|
323
|
-
// Stub setPointerCapture / releasePointerCapture
|
|
324
|
-
el.setPointerCapture = vi.fn();
|
|
325
|
-
el.releasePointerCapture = vi.fn();
|
|
326
|
-
dragSession.registerSource(config, el);
|
|
327
|
-
const event = createTouchPointerEvent('pointerdown', 100, 100);
|
|
328
|
-
dragSession.beginPointerDrag('touch-src', event);
|
|
329
|
-
// Should NOT be dragging yet — waiting for long-press timer
|
|
330
|
-
expect(dragSession.isDragging).toBe(false);
|
|
331
|
-
dragSession.cancel();
|
|
332
|
-
el.remove();
|
|
333
|
-
});
|
|
334
|
-
it('starts drag after 300ms long-press without movement', () => {
|
|
335
|
-
const config = createSourceConfig('touch-lp');
|
|
336
|
-
const el = createElement();
|
|
337
|
-
el.setPointerCapture = vi.fn();
|
|
338
|
-
el.releasePointerCapture = vi.fn();
|
|
339
|
-
dragSession.registerSource(config, el);
|
|
340
|
-
const event = createTouchPointerEvent('pointerdown', 100, 100);
|
|
341
|
-
dragSession.beginPointerDrag('touch-lp', event);
|
|
342
|
-
// Advance past the long-press delay
|
|
343
|
-
vi.advanceTimersByTime(300);
|
|
344
|
-
expect(dragSession.isDragging).toBe(true);
|
|
345
|
-
expect(dragSession.sourceId).toBe('touch-lp');
|
|
346
|
-
expect(config.onDragStart).toHaveBeenCalled();
|
|
347
|
-
dragSession.cancel();
|
|
348
|
-
el.remove();
|
|
349
|
-
});
|
|
350
|
-
it('cancels if touch moves too far before long-press fires', () => {
|
|
351
|
-
const config = createSourceConfig('touch-scroll');
|
|
352
|
-
const el = createElement();
|
|
353
|
-
el.setPointerCapture = vi.fn();
|
|
354
|
-
el.releasePointerCapture = vi.fn();
|
|
355
|
-
dragSession.registerSource(config, el);
|
|
356
|
-
const downEvent = createTouchPointerEvent('pointerdown', 100, 100);
|
|
357
|
-
dragSession.beginPointerDrag('touch-scroll', downEvent);
|
|
358
|
-
// Move more than 10px (LONG_PRESS_TOLERANCE) before timer fires
|
|
359
|
-
const moveEvent = createTouchPointerEvent('pointermove', 100, 120);
|
|
360
|
-
el.dispatchEvent(moveEvent);
|
|
361
|
-
// Even after timer would fire, drag should not start
|
|
362
|
-
vi.advanceTimersByTime(300);
|
|
363
|
-
expect(dragSession.isDragging).toBe(false);
|
|
364
|
-
expect(config.onDragStart).not.toHaveBeenCalled();
|
|
365
|
-
el.remove();
|
|
366
|
-
});
|
|
367
|
-
it('allows small touch jitter within tolerance', () => {
|
|
368
|
-
const config = createSourceConfig('touch-jitter');
|
|
369
|
-
const el = createElement();
|
|
370
|
-
el.setPointerCapture = vi.fn();
|
|
371
|
-
el.releasePointerCapture = vi.fn();
|
|
372
|
-
dragSession.registerSource(config, el);
|
|
373
|
-
const downEvent = createTouchPointerEvent('pointerdown', 100, 100);
|
|
374
|
-
dragSession.beginPointerDrag('touch-jitter', downEvent);
|
|
375
|
-
// Small movement within tolerance (< 10px)
|
|
376
|
-
const moveEvent = createTouchPointerEvent('pointermove', 103, 105);
|
|
377
|
-
el.dispatchEvent(moveEvent);
|
|
378
|
-
// Timer fires → drag should start despite small jitter
|
|
379
|
-
vi.advanceTimersByTime(300);
|
|
380
|
-
expect(dragSession.isDragging).toBe(true);
|
|
381
|
-
dragSession.cancel();
|
|
382
|
-
el.remove();
|
|
383
|
-
});
|
|
384
|
-
});
|
|
385
|
-
describe('exception safety (F-013)', () => {
|
|
386
|
-
it('resets session even if onDrop throws', async () => {
|
|
387
|
-
const sourceConfig = createSourceConfig('exc-src');
|
|
388
|
-
const targetConfig = createTargetConfig('exc-tgt', {
|
|
389
|
-
getDropOperation: () => 'move',
|
|
390
|
-
onDrop: vi.fn(() => {
|
|
391
|
-
throw new Error('callback error');
|
|
392
|
-
}),
|
|
393
|
-
});
|
|
394
|
-
const srcEl = createElement();
|
|
395
|
-
const tgtEl = createElement();
|
|
396
|
-
dragSession.registerSource(sourceConfig, srcEl);
|
|
397
|
-
dragSession.registerTarget(targetConfig, tgtEl);
|
|
398
|
-
dragSession.beginKeyboardDrag('exc-src');
|
|
399
|
-
dragSession.focusNextTarget();
|
|
400
|
-
await expect(dragSession.drop()).rejects.toThrow('callback error');
|
|
401
|
-
// Session MUST be cleaned up despite the exception
|
|
402
|
-
expect(dragSession.isDragging).toBe(false);
|
|
403
|
-
expect(dragSession.sourceId).toBeNull();
|
|
404
|
-
expect(dragSession.currentTarget).toBeNull();
|
|
405
|
-
srcEl.remove();
|
|
406
|
-
tgtEl.remove();
|
|
407
|
-
});
|
|
408
|
-
it('resets session even if onDragEnd throws on cancel', () => {
|
|
409
|
-
const config = createSourceConfig('exc-cancel', {
|
|
410
|
-
onDragEnd: vi.fn(() => {
|
|
411
|
-
throw new Error('cancel callback error');
|
|
412
|
-
}),
|
|
413
|
-
});
|
|
414
|
-
const el = createElement();
|
|
415
|
-
dragSession.registerSource(config, el);
|
|
416
|
-
dragSession.beginKeyboardDrag('exc-cancel');
|
|
417
|
-
expect(() => dragSession.cancel()).toThrow('cancel callback error');
|
|
418
|
-
// Session MUST be cleaned up
|
|
419
|
-
expect(dragSession.isDragging).toBe(false);
|
|
420
|
-
expect(dragSession.sourceId).toBeNull();
|
|
421
|
-
el.remove();
|
|
422
|
-
});
|
|
423
|
-
});
|
|
424
|
-
describe('keyboard focus on targets (F-014)', () => {
|
|
425
|
-
it('focuses target element on Tab navigation', () => {
|
|
426
|
-
const sourceConfig = createSourceConfig('focus-src');
|
|
427
|
-
const targetConfig = createTargetConfig('focus-tgt');
|
|
428
|
-
const srcEl = createElement();
|
|
429
|
-
const tgtEl = createElement();
|
|
430
|
-
tgtEl.tabIndex = 0;
|
|
431
|
-
tgtEl.focus = vi.fn();
|
|
432
|
-
dragSession.registerSource(sourceConfig, srcEl);
|
|
433
|
-
dragSession.registerTarget(targetConfig, tgtEl);
|
|
434
|
-
dragSession.beginKeyboardDrag('focus-src');
|
|
435
|
-
dragSession.focusNextTarget();
|
|
436
|
-
expect(tgtEl.focus).toHaveBeenCalled();
|
|
437
|
-
dragSession.cancel();
|
|
438
|
-
srcEl.remove();
|
|
439
|
-
tgtEl.remove();
|
|
440
|
-
});
|
|
441
|
-
});
|
|
442
|
-
describe('pagehide cleanup (F-005)', () => {
|
|
443
|
-
it('resets session on pagehide event during active drag', () => {
|
|
444
|
-
const config = createSourceConfig('ph-src');
|
|
445
|
-
const el = createElement();
|
|
446
|
-
dragSession.registerSource(config, el);
|
|
447
|
-
dragSession.beginKeyboardDrag('ph-src');
|
|
448
|
-
expect(dragSession.isDragging).toBe(true);
|
|
449
|
-
// Simulate page navigation
|
|
450
|
-
window.dispatchEvent(new Event('pagehide'));
|
|
451
|
-
expect(dragSession.isDragging).toBe(false);
|
|
452
|
-
expect(dragSession.sourceId).toBeNull();
|
|
453
|
-
el.remove();
|
|
454
|
-
});
|
|
455
|
-
it('does nothing on pagehide when not dragging', () => {
|
|
456
|
-
// Just verify no error is thrown
|
|
457
|
-
window.dispatchEvent(new Event('pagehide'));
|
|
458
|
-
expect(dragSession.isDragging).toBe(false);
|
|
459
|
-
});
|
|
460
|
-
});
|
|
461
|
-
describe('pointer drag re-entrance (#190)', () => {
|
|
462
|
-
beforeEach(() => {
|
|
463
|
-
ensurePointerEventPolyfill();
|
|
464
|
-
});
|
|
465
|
-
function createMousePointerEvent(type, x, y) {
|
|
466
|
-
return new PointerEvent(type, {
|
|
467
|
-
clientX: x,
|
|
468
|
-
clientY: y,
|
|
469
|
-
pointerId: 1,
|
|
470
|
-
pointerType: 'mouse',
|
|
471
|
-
bubbles: true,
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
function registerSource(id) {
|
|
475
|
-
const config = createSourceConfig(id);
|
|
476
|
-
const el = createElement();
|
|
477
|
-
el.setPointerCapture = vi.fn();
|
|
478
|
-
el.releasePointerCapture = vi.fn();
|
|
479
|
-
dragSession.registerSource(config, el);
|
|
480
|
-
return { config, el };
|
|
481
|
-
}
|
|
482
|
-
it('a second beginPointerDrag while one is pending does not clobber the first', () => {
|
|
483
|
-
const inner = registerSource('inner-src');
|
|
484
|
-
const outer = registerSource('outer-src');
|
|
485
|
-
// Same pointerdown delivered to both sources (bubbling in a nested DOM)
|
|
486
|
-
const down = createMousePointerEvent('pointerdown', 100, 100);
|
|
487
|
-
dragSession.beginPointerDrag('inner-src', down);
|
|
488
|
-
expect(dragSession.sourceId).toBe('inner-src');
|
|
489
|
-
dragSession.beginPointerDrag('outer-src', down);
|
|
490
|
-
expect(dragSession.sourceId).toBe('inner-src'); // first claim wins
|
|
491
|
-
// Crossing the threshold activates the drag for the FIRST source
|
|
492
|
-
inner.el.dispatchEvent(createMousePointerEvent('pointermove', 110, 110));
|
|
493
|
-
expect(dragSession.isDragging).toBe(true);
|
|
494
|
-
expect(dragSession.sourceId).toBe('inner-src');
|
|
495
|
-
expect(inner.config.onDragStart).toHaveBeenCalled();
|
|
496
|
-
expect(outer.config.onDragStart).not.toHaveBeenCalled();
|
|
497
|
-
// And the session ends cleanly — no stuck ghost
|
|
498
|
-
inner.el.dispatchEvent(createMousePointerEvent('pointerup', 110, 110));
|
|
499
|
-
expect(dragSession.isDragging).toBe(false);
|
|
500
|
-
expect(dragSession.sourceId).toBeNull();
|
|
501
|
-
expect(document.querySelector('.sp-drag-ghost')).toBeNull();
|
|
502
|
-
inner.el.remove();
|
|
503
|
-
outer.el.remove();
|
|
504
|
-
});
|
|
505
|
-
it('releasing before the threshold clears the pending claim for the next drag', () => {
|
|
506
|
-
const a = registerSource('pending-a');
|
|
507
|
-
const b = registerSource('pending-b');
|
|
508
|
-
dragSession.beginPointerDrag('pending-a', createMousePointerEvent('pointerdown', 100, 100));
|
|
509
|
-
a.el.dispatchEvent(createMousePointerEvent('pointerup', 100, 100));
|
|
510
|
-
expect(dragSession.sourceId).toBeNull();
|
|
511
|
-
dragSession.beginPointerDrag('pending-b', createMousePointerEvent('pointerdown', 100, 100));
|
|
512
|
-
expect(dragSession.sourceId).toBe('pending-b');
|
|
513
|
-
dragSession.cancel();
|
|
514
|
-
a.el.remove();
|
|
515
|
-
b.el.remove();
|
|
516
|
-
});
|
|
517
|
-
it('keyboard drag cannot start while a pointer drag is pending', () => {
|
|
518
|
-
const a = registerSource('kb-block-a');
|
|
519
|
-
const b = registerSource('kb-block-b');
|
|
520
|
-
dragSession.beginPointerDrag('kb-block-a', createMousePointerEvent('pointerdown', 100, 100));
|
|
521
|
-
dragSession.beginKeyboardDrag('kb-block-b');
|
|
522
|
-
expect(dragSession.isDragging).toBe(false);
|
|
523
|
-
expect(dragSession.sourceId).toBe('kb-block-a');
|
|
524
|
-
dragSession.cancel();
|
|
525
|
-
a.el.remove();
|
|
526
|
-
b.el.remove();
|
|
527
|
-
});
|
|
528
|
-
it('pagehide during a pending claim resets it so future drags are not blocked', () => {
|
|
529
|
-
const src = registerSource('ph-pending');
|
|
530
|
-
// Pointer down, threshold NOT crossed — claim is pending
|
|
531
|
-
dragSession.beginPointerDrag('ph-pending', createMousePointerEvent('pointerdown', 100, 100));
|
|
532
|
-
expect(dragSession.isDragging).toBe(false);
|
|
533
|
-
expect(dragSession.sourceId).toBe('ph-pending');
|
|
534
|
-
// Page navigates during the pending claim
|
|
535
|
-
window.dispatchEvent(new Event('pagehide'));
|
|
536
|
-
expect(dragSession.sourceId).toBeNull();
|
|
537
|
-
// The re-entrance guard must not block the next drag
|
|
538
|
-
dragSession.beginPointerDrag('ph-pending', createMousePointerEvent('pointerdown', 100, 100));
|
|
539
|
-
expect(dragSession.sourceId).toBe('ph-pending');
|
|
540
|
-
dragSession.cancel();
|
|
541
|
-
src.el.remove();
|
|
542
|
-
});
|
|
543
|
-
});
|
|
544
|
-
describe('async onDrop (F-001)', () => {
|
|
545
|
-
it('awaits async onDrop callback before cleanup', async () => {
|
|
546
|
-
let dropResolved = false;
|
|
547
|
-
const sourceConfig = createSourceConfig('async-src');
|
|
548
|
-
const targetConfig = createTargetConfig('async-tgt', {
|
|
549
|
-
getDropOperation: () => 'move',
|
|
550
|
-
onDrop: vi.fn(async () => {
|
|
551
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
552
|
-
dropResolved = true;
|
|
553
|
-
}),
|
|
554
|
-
});
|
|
555
|
-
const srcEl = createElement();
|
|
556
|
-
const tgtEl = createElement();
|
|
557
|
-
dragSession.registerSource(sourceConfig, srcEl);
|
|
558
|
-
dragSession.registerTarget(targetConfig, tgtEl);
|
|
559
|
-
dragSession.beginKeyboardDrag('async-src');
|
|
560
|
-
dragSession.focusNextTarget();
|
|
561
|
-
await dragSession.drop();
|
|
562
|
-
// The async callback should have resolved before we get here
|
|
563
|
-
expect(dropResolved).toBe(true);
|
|
564
|
-
expect(targetConfig.onDrop).toHaveBeenCalled();
|
|
565
|
-
expect(dragSession.isDragging).toBe(false);
|
|
566
|
-
srcEl.remove();
|
|
567
|
-
tgtEl.remove();
|
|
568
|
-
});
|
|
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
|
-
});
|
|
803
|
-
});
|