diff --git a/public/drag-manager.js b/public/drag-manager.js new file mode 100644 index 00000000..9842f630 --- /dev/null +++ b/public/drag-manager.js @@ -0,0 +1,216 @@ +/* drag-manager.js — Free-form panel dragging (#608 M1) + * State machine: IDLE → PENDING → DRAGGING → IDLE + * Pointer events on .panel-header, transform: translate() during drag, + * snap-to-edge on release, z-index on focus, viewport % persistence. + */ +(function () { + 'use strict'; + + var DEAD_ZONE = 5; // px — disambiguate click vs drag + var SNAP_THRESHOLD = 20; // px — snap to edge on release + var SNAP_MARGIN = 12; // px — margin when snapped + + function DragManager() { + this.state = 'IDLE'; + this.activePanel = null; + this.startX = 0; + this.startY = 0; + this.panelStartX = 0; + this.panelStartY = 0; + this.preTransform = ''; + this.enabled = true; + this.zCounter = 1000; + this._panels = []; + this._onKeyDown = this._handleKeyDown.bind(this); + } + + DragManager.prototype.register = function (panel) { + if (!panel) return; + var header = panel.querySelector('.panel-header'); + if (!header) return; + this._panels.push(panel); + var self = this; + + header.addEventListener('pointerdown', function (e) { + if (!self.enabled) return; + if (e.button !== 0) return; + if (e.target.closest('button')) return; + e.preventDefault(); + header.setPointerCapture(e.pointerId); + + self.state = 'PENDING'; + self.activePanel = panel; + self.startX = e.clientX; + self.startY = e.clientY; + + var rect = panel.getBoundingClientRect(); + self.panelStartX = rect.left; + self.panelStartY = rect.top; + self.preTransform = panel.style.transform || ''; + document.addEventListener('keydown', self._onKeyDown); + }); + + header.addEventListener('pointermove', function (e) { + if (self.state === 'IDLE') return; + if (self.activePanel !== panel) return; + var dx = e.clientX - self.startX; + var dy = e.clientY - self.startY; + + if (self.state === 'PENDING') { + if (Math.hypot(dx, dy) < DEAD_ZONE) return; + self.state = 'DRAGGING'; + panel.classList.add('is-dragging'); + panel.style.zIndex = ++self.zCounter; + self._detachFromCorner(panel); + } + + panel.style.transform = 'translate(' + dx + 'px, ' + dy + 'px)'; + }); + + header.addEventListener('pointerup', function (e) { + if (self.activePanel !== panel) return; + header.releasePointerCapture(e.pointerId); + if (self.state === 'DRAGGING') { + panel.classList.remove('is-dragging'); + self._finalizePosition(panel); + } + self._reset(); + }); + + header.addEventListener('pointercancel', function () { + if (self.activePanel !== panel) return; + panel.classList.remove('is-dragging'); + if (self.state === 'DRAGGING') { + self._finalizePosition(panel); + } + self._reset(); + }); + }; + + DragManager.prototype._handleKeyDown = function (e) { + if (e.key === 'Escape' && this.state === 'DRAGGING' && this.activePanel) { + this.activePanel.classList.remove('is-dragging'); + this.activePanel.style.transform = this.preTransform; + // Revert: re-attach to corner if it was cornered before + var saved = localStorage.getItem('panel-drag-' + this.activePanel.id); + if (!saved) { + // Was in corner mode — restore corner CSS + delete this.activePanel.dataset.dragged; + this.activePanel.style.top = ''; + this.activePanel.style.left = ''; + this.activePanel.style.right = ''; + this.activePanel.style.bottom = ''; + this.activePanel.style.transform = ''; + // Re-apply corner position from M0 + var corner = localStorage.getItem('panel-corner-' + this.activePanel.id); + if (corner) this.activePanel.setAttribute('data-position', corner); + } else { + // Was already dragged — revert to pre-drag position + this.activePanel.style.transform = 'none'; + } + this._reset(); + } + }; + + DragManager.prototype._reset = function () { + document.removeEventListener('keydown', this._onKeyDown); + this.state = 'IDLE'; + this.activePanel = null; + }; + + DragManager.prototype._detachFromCorner = function (panel) { + var rect = panel.getBoundingClientRect(); + panel.removeAttribute('data-position'); + panel.dataset.dragged = 'true'; + panel.style.position = 'fixed'; + panel.style.top = rect.top + 'px'; + panel.style.left = rect.left + 'px'; + panel.style.right = 'auto'; + panel.style.bottom = 'auto'; + panel.style.transform = 'none'; + }; + + DragManager.prototype._finalizePosition = function (panel) { + var rect = panel.getBoundingClientRect(); + var vw = window.innerWidth; + var vh = window.innerHeight; + + var x = Math.max(0, Math.min(rect.left, vw - 40)); + var y = Math.max(0, Math.min(rect.top, vh - 40)); + + // Snap to edge + if (x < SNAP_THRESHOLD) x = SNAP_MARGIN; + if (y < SNAP_THRESHOLD) y = SNAP_MARGIN; + if (x + rect.width > vw - SNAP_THRESHOLD) x = vw - rect.width - SNAP_MARGIN; + if (y + rect.height > vh - SNAP_THRESHOLD) y = vh - rect.height - SNAP_MARGIN; + + panel.style.top = y + 'px'; + panel.style.left = x + 'px'; + panel.style.transform = 'none'; + + this._persist(panel.id, x / vw, y / vh); + }; + + DragManager.prototype._persist = function (id, xPct, yPct) { + try { + localStorage.setItem('panel-drag-' + id, + JSON.stringify({ xPct: xPct, yPct: yPct })); + } catch (_) { /* quota exceeded — silent */ } + }; + + DragManager.prototype.enable = function () { this.enabled = true; }; + DragManager.prototype.disable = function () { + this.enabled = false; + if (this.state !== 'IDLE' && this.activePanel) { + this.activePanel.classList.remove('is-dragging'); + this._reset(); + } + }; + + DragManager.prototype.restorePositions = function () { + var panels = this._panels; + for (var i = 0; i < panels.length; i++) { + var panel = panels[i]; + var raw = localStorage.getItem('panel-drag-' + panel.id); + if (!raw) continue; + try { + var pos = JSON.parse(raw); + var x = pos.xPct * window.innerWidth; + var y = pos.yPct * window.innerHeight; + panel.removeAttribute('data-position'); + panel.dataset.dragged = 'true'; + panel.style.position = 'fixed'; + panel.style.top = y + 'px'; + panel.style.left = x + 'px'; + panel.style.right = 'auto'; + panel.style.bottom = 'auto'; + panel.style.transform = 'none'; + } catch (_) { + localStorage.removeItem('panel-drag-' + panel.id); + } + } + }; + + DragManager.prototype.handleResize = function () { + var panels = document.querySelectorAll('.live-overlay[data-dragged="true"]'); + for (var i = 0; i < panels.length; i++) { + var panel = panels[i]; + var rect = panel.getBoundingClientRect(); + var vw = window.innerWidth; + var vh = window.innerHeight; + var x = rect.left, y = rect.top, moved = false; + if (rect.right > vw) { x = vw - rect.width - SNAP_MARGIN; moved = true; } + if (rect.bottom > vh) { y = vh - rect.height - SNAP_MARGIN; moved = true; } + if (x < 0) { x = SNAP_MARGIN; moved = true; } + if (y < 0) { y = SNAP_MARGIN; moved = true; } + if (moved) { + panel.style.left = x + 'px'; + panel.style.top = y + 'px'; + this._persist(panel.id, x / vw, y / vh); + } + } + }; + + // Export + window.DragManager = DragManager; +})(); diff --git a/public/index.html b/public/index.html index 381735ce..2debebb9 100644 --- a/public/index.html +++ b/public/index.html @@ -109,6 +109,7 @@ + diff --git a/public/live.css b/public/live.css index 3b73fcf8..7e570e6e 100644 --- a/public/live.css +++ b/public/live.css @@ -849,3 +849,40 @@ } } + +/* ── Drag Manager (#608 M1) ── */ + +/* Panel header as drag handle — desktop pointer devices only */ +@media (pointer: fine) { + .live-overlay .panel-header { + cursor: grab; + user-select: none; + touch-action: none; + } + .live-overlay.is-dragging .panel-header { + cursor: grabbing; + } + .live-overlay .panel-header:hover { + background: var(--bg-hover, rgba(255, 255, 255, 0.03)); + } +} + +/* Panel during drag */ +.live-overlay.is-dragging { + opacity: 0.92; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + will-change: transform; + transition: none !important; +} + +/* Freely placed panel — no corner transition animations */ +.live-overlay[data-dragged="true"] { + transition: none; +} + +@media (prefers-reduced-motion: reduce) { + .live-overlay.is-dragging, + .live-overlay[data-dragged="true"] { + transition: none; + } +} diff --git a/public/live.js b/public/live.js index 92903ab8..e79a3404 100644 --- a/public/live.js +++ b/public/live.js @@ -1161,6 +1161,48 @@ // Populate role legend from shared roles.js // Initialize panel corner positions (#608 M0) initPanelPositions(); + + // Initialize DragManager for free-form panel dragging (#608 M1) + if (window.DragManager) { + var dragMgr = new DragManager(); + var dragPanels = ['liveFeed', 'liveLegend', 'liveNodeDetail']; + for (var di = 0; di < dragPanels.length; di++) { + dragMgr.register(document.getElementById(dragPanels[di])); + } + dragMgr.restorePositions(); + + // Responsive gate: disable drag below medium breakpoint or on touch + var dragMql = window.matchMedia('(pointer: fine) and (min-width: 768px)'); + function onDragMediaChange(e) { + if (!e.matches) { + // Revert dragged panels to corner positions + document.querySelectorAll('.live-overlay[data-dragged="true"]').forEach(function (p) { + delete p.dataset.dragged; + p.style.transform = ''; + p.style.top = ''; + p.style.left = ''; + p.style.right = ''; + p.style.bottom = ''; + }); + initPanelPositions(); + dragMgr.disable(); + } else { + dragMgr.enable(); + dragMgr.restorePositions(); + } + } + dragMql.addEventListener('change', onDragMediaChange); + // Initial check + if (!dragMql.matches) dragMgr.disable(); + + // Resize clamping (debounced) + var resizeTimer = null; + window.addEventListener('resize', function () { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(function () { dragMgr.handleResize(); }, 200); + }); + } + const roleLegendList = document.getElementById('roleLegendList'); if (roleLegendList) { for (const role of (window.ROLE_SORT || ['repeater', 'companion', 'room', 'sensor', 'observer'])) { diff --git a/test-drag-manager.js b/test-drag-manager.js new file mode 100644 index 00000000..1bec20ba --- /dev/null +++ b/test-drag-manager.js @@ -0,0 +1,454 @@ +/* test-drag-manager.js — Unit tests for DragManager (#608 M1) */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +// Minimal DOM shim +function makePanel(id) { + const listeners = {}; + const style = {}; + const dataset = {}; + const classList = { + _set: new Set(), + add(c) { this._set.add(c); }, + remove(c) { this._set.delete(c); }, + contains(c) { return this._set.has(c); } + }; + let attrs = {}; + const header = { + _listeners: {}, + addEventListener(ev, fn) { + if (!this._listeners[ev]) this._listeners[ev] = []; + this._listeners[ev].push(fn); + }, + setPointerCapture() {}, + releasePointerCapture() {}, + _fire(ev, data) { + (this._listeners[ev] || []).forEach(fn => fn(data)); + } + }; + return { + id: id, + style: style, + dataset: dataset, + classList: classList, + querySelector(sel) { + if (sel === '.panel-header') return header; + return null; + }, + getAttribute(k) { return attrs[k] || null; }, + setAttribute(k, v) { attrs[k] = v; }, + removeAttribute(k) { delete attrs[k]; }, + getBoundingClientRect() { + return { + left: parseFloat(style.left) || 0, + top: parseFloat(style.top) || 0, + right: (parseFloat(style.left) || 0) + 300, + bottom: (parseFloat(style.top) || 0) + 200, + width: 300, + height: 200 + }; + }, + _header: header + }; +} + +// Mock globals +const storage = {}; +const mockWindow = { + innerWidth: 1920, + innerHeight: 1080, + DragManager: null, + matchMedia() { return { matches: true, addEventListener() {} }; }, + addEventListener() {} +}; +const mockDocument = { + addEventListener(ev, fn) { + if (!mockDocument._listeners) mockDocument._listeners = {}; + if (!mockDocument._listeners[ev]) mockDocument._listeners[ev] = []; + mockDocument._listeners[ev].push(fn); + }, + removeEventListener(ev, fn) { + if (mockDocument._listeners && mockDocument._listeners[ev]) { + mockDocument._listeners[ev] = mockDocument._listeners[ev].filter(f => f !== fn); + } + }, + querySelectorAll() { return []; } +}; +const mockLocalStorage = { + _data: {}, + getItem(k) { return this._data[k] || null; }, + setItem(k, v) { this._data[k] = v; }, + removeItem(k) { delete this._data[k]; }, + clear() { this._data = {}; } +}; + +// Load DragManager +const src = fs.readFileSync(path.join(__dirname, 'public', 'drag-manager.js'), 'utf8'); +const ctx = vm.createContext({ + window: mockWindow, + document: mockDocument, + localStorage: mockLocalStorage, + Math: Math, + JSON: JSON, + console: console, + setTimeout: setTimeout, + clearTimeout: clearTimeout, + parseFloat: parseFloat +}); +vm.runInContext(src, ctx); +const DragManager = ctx.window.DragManager; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + mockLocalStorage.clear(); + fn(); + passed++; + console.log(' ✓ ' + name); + } catch (e) { + failed++; + console.log(' ✗ ' + name + ': ' + e.message); + } +} + +console.log('DragManager tests:'); + +test('constructor initializes IDLE state', () => { + const dm = new DragManager(); + assert.strictEqual(dm.state, 'IDLE'); + assert.strictEqual(dm.enabled, true); +}); + +test('register adds panel', () => { + const dm = new DragManager(); + const panel = makePanel('testPanel'); + dm.register(panel); + assert.strictEqual(dm._panels.length, 1); +}); + +test('register ignores null panel', () => { + const dm = new DragManager(); + dm.register(null); + assert.strictEqual(dm._panels.length, 0); +}); + +test('pointerdown transitions to PENDING', () => { + const dm = new DragManager(); + const panel = makePanel('p1'); + dm.register(panel); + panel._header._fire('pointerdown', { + button: 0, clientX: 100, clientY: 100, + preventDefault() {}, + target: { closest() { return null; } } + }); + assert.strictEqual(dm.state, 'PENDING'); + assert.strictEqual(dm.activePanel, panel); +}); + +test('pointerdown ignores non-left button', () => { + const dm = new DragManager(); + const panel = makePanel('p1'); + dm.register(panel); + panel._header._fire('pointerdown', { + button: 2, clientX: 100, clientY: 100, + preventDefault() {}, + target: { closest() { return null; } } + }); + assert.strictEqual(dm.state, 'IDLE'); +}); + +test('pointerdown ignores button clicks', () => { + const dm = new DragManager(); + const panel = makePanel('p1'); + dm.register(panel); + panel._header._fire('pointerdown', { + button: 0, clientX: 100, clientY: 100, + preventDefault() {}, + target: { closest(sel) { return sel === 'button' ? {} : null; } } + }); + assert.strictEqual(dm.state, 'IDLE'); +}); + +test('pointermove within dead zone stays PENDING', () => { + const dm = new DragManager(); + const panel = makePanel('p1'); + dm.register(panel); + panel._header._fire('pointerdown', { + button: 0, clientX: 100, clientY: 100, + preventDefault() {}, + target: { closest() { return null; } } + }); + panel._header._fire('pointermove', { clientX: 103, clientY: 102 }); + assert.strictEqual(dm.state, 'PENDING'); + assert.ok(!panel.classList.contains('is-dragging')); +}); + +test('pointermove beyond dead zone transitions to DRAGGING', () => { + const dm = new DragManager(); + const panel = makePanel('p1'); + panel.setAttribute('data-position', 'bl'); + dm.register(panel); + panel._header._fire('pointerdown', { + button: 0, clientX: 100, clientY: 100, + preventDefault() {}, + target: { closest() { return null; } } + }); + panel._header._fire('pointermove', { clientX: 110, clientY: 110 }); + assert.strictEqual(dm.state, 'DRAGGING'); + assert.ok(panel.classList.contains('is-dragging')); + assert.strictEqual(panel.getAttribute('data-position'), null); // removed + assert.strictEqual(panel.dataset.dragged, 'true'); +}); + +test('pointerup after drag finalizes position', () => { + const dm = new DragManager(); + const panel = makePanel('p1'); + panel.setAttribute('data-position', 'bl'); + ctx.window.innerWidth = 1920; + ctx.window.innerHeight = 1080; + dm.register(panel); + + panel._header._fire('pointerdown', { + button: 0, clientX: 100, clientY: 100, + preventDefault() {}, + target: { closest() { return null; } } + }); + panel._header._fire('pointermove', { clientX: 200, clientY: 300 }); + panel._header._fire('pointerup', { pointerId: 1 }); + + assert.strictEqual(dm.state, 'IDLE'); + assert.ok(!panel.classList.contains('is-dragging')); + // Should have persisted + const saved = JSON.parse(mockLocalStorage.getItem('panel-drag-p1')); + assert.ok(saved.xPct >= 0); + assert.ok(saved.yPct >= 0); +}); + +test('pointerup from PENDING (click) does not finalize', () => { + const dm = new DragManager(); + const panel = makePanel('p1'); + dm.register(panel); + panel._header._fire('pointerdown', { + button: 0, clientX: 100, clientY: 100, + preventDefault() {}, + target: { closest() { return null; } } + }); + panel._header._fire('pointerup', { pointerId: 1 }); + assert.strictEqual(dm.state, 'IDLE'); + assert.strictEqual(mockLocalStorage.getItem('panel-drag-p1'), null); +}); + +test('disable prevents drag', () => { + const dm = new DragManager(); + dm.disable(); + const panel = makePanel('p1'); + dm.register(panel); + panel._header._fire('pointerdown', { + button: 0, clientX: 100, clientY: 100, + preventDefault() {}, + target: { closest() { return null; } } + }); + assert.strictEqual(dm.state, 'IDLE'); +}); + +test('snap-to-edge works within threshold', () => { + const dm = new DragManager(); + const panel = makePanel('p1'); + panel.setAttribute('data-position', 'tl'); + ctx.window.innerWidth = 1920; + ctx.window.innerHeight = 1080; + dm.register(panel); + + // Simulate drag to near top-left edge + panel._header._fire('pointerdown', { + button: 0, clientX: 500, clientY: 500, + preventDefault() {}, + target: { closest() { return null; } } + }); + panel._header._fire('pointermove', { clientX: 510, clientY: 510 }); // trigger DRAGGING + + // Panel is now detached; set its position near edge + panel.style.left = '5px'; + panel.style.top = '10px'; + + panel._header._fire('pointerup', { pointerId: 1 }); + + // Should have snapped to margin (12px) + assert.strictEqual(panel.style.left, '12px'); + assert.strictEqual(panel.style.top, '12px'); +}); + +test('restorePositions applies saved viewport percentages', () => { + const dm = new DragManager(); + const panel = makePanel('p2'); + panel.setAttribute('data-position', 'br'); + ctx.window.innerWidth = 1000; + ctx.window.innerHeight = 800; + dm.register(panel); + + mockLocalStorage.setItem('panel-drag-p2', JSON.stringify({ xPct: 0.5, yPct: 0.25 })); + dm.restorePositions(); + + assert.strictEqual(panel.style.left, '500px'); + assert.strictEqual(panel.style.top, '200px'); + assert.strictEqual(panel.dataset.dragged, 'true'); + assert.strictEqual(panel.getAttribute('data-position'), null); +}); + +test('handleResize clamps panels inside viewport', () => { + const dm = new DragManager(); + const panel = makePanel('p3'); + dm.register(panel); + + // Simulate a dragged panel that's now off-screen + panel.dataset.dragged = 'true'; + panel.style.left = '1800px'; + panel.style.top = '900px'; + ctx.window.innerWidth = 1000; + ctx.window.innerHeight = 600; + + // Need querySelectorAll to return this panel + const origQSA = mockDocument.querySelectorAll; + mockDocument.querySelectorAll = function (sel) { + if (sel === '.live-overlay[data-dragged="true"]') return [panel]; + return []; + }; + + dm.handleResize(); + mockDocument.querySelectorAll = origQSA; + + // Should be clamped + const left = parseFloat(panel.style.left); + const top = parseFloat(panel.style.top); + assert.ok(left + 300 <= 1000, 'left clamped: ' + left); + assert.ok(top + 200 <= 600, 'top clamped: ' + top); +}); + +test('Escape during drag reverts to corner position', () => { + const dm = new DragManager(); + const panel = makePanel('esc1'); + panel.setAttribute('data-position', 'bl'); + dm.register(panel); + + panel._header._fire('pointerdown', { + button: 0, clientX: 100, clientY: 100, + preventDefault() {}, + target: { closest() { return null; } } + }); + panel._header._fire('pointermove', { clientX: 120, clientY: 120 }); // trigger DRAGGING + assert.strictEqual(dm.state, 'DRAGGING'); + + // Simulate Escape + dm._handleKeyDown({ key: 'Escape' }); + + assert.strictEqual(dm.state, 'IDLE'); + assert.ok(!panel.classList.contains('is-dragging')); + assert.strictEqual(panel.dataset.dragged, undefined); + assert.strictEqual(panel.style.transform, ''); +}); + +test('Escape during drag reverts to saved position', () => { + const dm = new DragManager(); + const panel = makePanel('esc2'); + ctx.window.innerWidth = 1000; + ctx.window.innerHeight = 800; + dm.register(panel); + + // Pre-save a dragged position + mockLocalStorage.setItem('panel-drag-esc2', JSON.stringify({ xPct: 0.3, yPct: 0.4 })); + dm.restorePositions(); + assert.strictEqual(panel.dataset.dragged, 'true'); + + panel._header._fire('pointerdown', { + button: 0, clientX: 400, clientY: 400, + preventDefault() {}, + target: { closest() { return null; } } + }); + panel._header._fire('pointermove', { clientX: 500, clientY: 500 }); + assert.strictEqual(dm.state, 'DRAGGING'); + + dm._handleKeyDown({ key: 'Escape' }); + + assert.strictEqual(dm.state, 'IDLE'); + assert.strictEqual(panel.style.transform, 'none'); +}); + +test('pointercancel during drag finalizes position', () => { + const dm = new DragManager(); + const panel = makePanel('pc1'); + panel.setAttribute('data-position', 'tl'); + ctx.window.innerWidth = 1920; + ctx.window.innerHeight = 1080; + dm.register(panel); + + panel._header._fire('pointerdown', { + button: 0, clientX: 100, clientY: 100, + preventDefault() {}, + target: { closest() { return null; } } + }); + panel._header._fire('pointermove', { clientX: 200, clientY: 200 }); + assert.strictEqual(dm.state, 'DRAGGING'); + + panel._header._fire('pointercancel', {}); + assert.strictEqual(dm.state, 'IDLE'); + assert.ok(!panel.classList.contains('is-dragging')); +}); + +test('z-index increments on drag', () => { + const dm = new DragManager(); + const p1 = makePanel('z1'); + const p2 = makePanel('z2'); + dm.register(p1); + dm.register(p2); + + // Drag p1 + p1._header._fire('pointerdown', { + button: 0, clientX: 100, clientY: 100, + preventDefault() {}, + target: { closest() { return null; } } + }); + p1._header._fire('pointermove', { clientX: 110, clientY: 110 }); + const z1 = parseInt(p1.style.zIndex); + p1._header._fire('pointerup', { pointerId: 1 }); + + // Drag p2 + p2._header._fire('pointerdown', { + button: 0, clientX: 100, clientY: 100, + preventDefault() {}, + target: { closest() { return null; } } + }); + p2._header._fire('pointermove', { clientX: 110, clientY: 110 }); + const z2 = parseInt(p2.style.zIndex); + p2._header._fire('pointerup', { pointerId: 1 }); + + assert.ok(z2 > z1, 'z2 (' + z2 + ') should be greater than z1 (' + z1 + ')'); + assert.ok(z1 >= 1001, 'z1 should be >= 1001'); +}); + +test('disable mid-drag resets state', () => { + const dm = new DragManager(); + const panel = makePanel('dis1'); + dm.register(panel); + + panel._header._fire('pointerdown', { + button: 0, clientX: 100, clientY: 100, + preventDefault() {}, + target: { closest() { return null; } } + }); + panel._header._fire('pointermove', { clientX: 120, clientY: 120 }); + assert.strictEqual(dm.state, 'DRAGGING'); + + dm.disable(); + assert.strictEqual(dm.state, 'IDLE'); + assert.ok(!panel.classList.contains('is-dragging')); + assert.strictEqual(dm.enabled, false); +}); + +console.log('\n' + passed + ' passed, ' + failed + ' failed'); +if (failed > 0) process.exit(1);