From f35468040e4104e832e457dcef7ab05f8b82ddad Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 17:38:34 -0700 Subject: [PATCH] refactor(ui): extract panel disclosure lifecycle controls --- CHANGELOG.md | 3 + docs/CODE-BOUNDARIES.md | 13 ++ docs/CURRENT-STATE.md | 10 ++ package.json | 3 +- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 5 + src/mapSourceFocus.test.mjs | 70 +++++++- src/mapStackChips.test.mjs | 5 +- src/panelEscape.test.mjs | 40 ++++- src/ui.js | 276 ++++++----------------------- src/ui/panelDisclosure.js | 303 ++++++++++++++++++++++++++++++++ 11 files changed, 497 insertions(+), 234 deletions(-) create mode 100644 src/ui/panelDisclosure.js diff --git a/CHANGELOG.md b/CHANGELOG.md index cd2c4ccdf..0c4ca0590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Fixed +- Extract panel disclosure and hover/focus controls into a reusable module; + cancel their listeners and pending work during replacement and teardown. + - Reuse cached military aircraft during adsb.lol rate limits and server errors, honor bounded retry delays, and preserve cached observation times and stale indicators. Show installation zoom guidance without a false LOAD FAILED. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 19a2f0fb8..d640df92c 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -182,3 +182,16 @@ keyless Photon fallback. The application passes this service to location controls, annotation resolution and voice/radio actions. Those consumers retain framing, landmark recovery, footprint matching and playback decisions. Existing reverse geocoding and nearby/text-search routes remain separate. + +## Panel controls + +`gods-eye-view/ui/panels` owns collapse-button binding, nearest-panel Escape +handling, hover delays and delayed content-focus handoff. It accepts existing +DOM elements and callbacks; importing it creates no browser state. `destroy()` +removes owned listeners and cancels pending work without changing saved state +or moving focus. Call it before removing or replacing the controls. + +`src/ui.js` retains panel layout, persistence, share restoration and application +reactions to state changes. Map Source selection and Location draft cleanup are +provided through callbacks. The component imports no globe, data, application +or server modules. Package checks and scoped formatting cover this entry. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 53ff64060..0ecc6d72d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,15 @@ # God's Eye View Current State +## Panel disclosure lifecycle + +Panel collapse buttons, nested Escape handling and dock hover/focus timing now +use `ui/panels`. The component receives existing DOM elements and callbacks for +state changes and content focus. Panel layout, saved state, share restoration, +Location draft cleanup and Map Source selection remain with their existing +callers. Listener and timer cleanup is synchronous when controls are replaced +or disposed, preventing old hover or focus work from changing a later view. + + ## Military feed cooldown and loading guidance The adsb.lol military proxy reuses its last response during upstream 429/5xx diff --git a/package.json b/package.json index f726046e3..5913db387 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "./server/standalone/key-setup": { "node": "./server/standalone/key-setup.js" }, - "./search": "./src/search/index.js" + "./search": "./src/search/index.js", + "./ui/panels": "./src/ui/panelDisclosure.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 97f2fa8d3..f2c1bd6c9 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -119,5 +119,6 @@ "src/search/placeSearch.js", "src/search/google.js", "src/keylessGeocoder.js", - "src/standalone/placeSearch.js" + "src/standalone/placeSearch.js", + "src/ui/panelDisclosure.js" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f9dd22c93..22e450e63 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -263,5 +263,10 @@ "src/keylessGeocoder.js" ], "external": [] + }, + "panel-disclosure": { + "exports": ["./ui/panels"], + "modules": ["src/ui/panelDisclosure.js"], + "external": [] } } diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs index 78e3e0fad..985bd5658 100644 --- a/src/mapSourceFocus.test.mjs +++ b/src/mapSourceFocus.test.mjs @@ -1,3 +1,4 @@ +import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -44,6 +45,10 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) { if (!listeners.has(type)) listeners.set(type, []); listeners.get(type).push(callback); }, + removeEventListener(type, callback) { + const values = listeners.get(type) || []; + listeners.set(type, values.filter((item) => item !== callback)); + }, matches(selector) { return selector === ':hover' ? this.hovered : true; }, closest() { return this.isDisclosure ? this : null; }, }; @@ -107,9 +112,12 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) { const window = { setTimeout(callback, delay) { timers.set(++nextTimer, { callback, at: now + delay }); return nextTimer; }, }; - const methods = new Function('document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame', + window.clearTimeout = (id) => timers.delete(id); + window.performance = { now: () => now }; + document.defaultView = window; + const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame', `return ({${source.slice(initStart, initEnd)},\n${source.slice(escapeStart, escapeEnd)},\n${source.slice(closeStart, closeEnd)},\n${source.slice(syncStart, syncEnd)}});`)( - document, window, (id) => timers.delete(id), { now: () => now }, () => {}, + createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {}, ); const saves = []; const claims = []; @@ -385,3 +393,61 @@ test('disposed UI cannot complete a pending handoff', () => { assert.equal(h.document.activeElement, h.disclosure); assert.equal(h.calls(), 1); assert.equal(h.timers.size, 0); }); + + +test('destroying hover controls cancels pending opens and removes their event routes', () => { + const h = harness(); + h.panel.hovered = true; + h.emit(h.panel, 'pointerenter', { pointerType: 'mouse' }); + assert.ok(h.timers.size > 0); + const control = h.manager._hoverPanelControls.get('control-panel'); + control.destroy(); + control.destroy(); + assert.equal(h.timers.size, 0); + for (const node of [h.panel, h.disclosure]) { + assert.equal([...node.listeners.values()].flat().length, 0); + } + h.key(); + h.drain(); + assert.equal(h.panel.classes.has('collapsed'), true); + assert.equal(h.calls(), 0); +}); + +test('destroying an open tray cancels close and focus work without changing saved state', () => { + const h = harness({ hidden: true }); + h.key(); + h.emit(h.panel, 'pointerleave', { pointerType: 'mouse' }); + const callbacks = [...h.timers.values()].map((timer) => timer.callback); + const saves = h.saves.length; + h.manager._hoverPanelControls.get('control-panel').destroy(); + assert.equal(h.timers.size, 0); + h.show(); + for (const callback of callbacks) callback(); + assert.equal(h.panel.classes.has('collapsed'), false); + assert.equal(h.saves.length, saves); + assert.equal(h.calls(), 0, 'even an already queued focus attempt is revoked'); +}); + +test('replacing hover controls removes the previous listeners before rebinding', () => { + const h = harness(); + h.manager._initAutoHoverPanel('control-panel'); + h.key(); + assert.equal(h.panel.classes.has('collapsed'), false, 'one activation toggles only once'); + h.tick(); + assert.equal(h.calls(), 1); +}); + + +test('teardown during an opening callback cannot enqueue a later focus handoff', () => { + const h = harness({ hidden: true }); + const change = h.manager.setPanelCollapsed.bind(h.manager); + h.manager.setPanelCollapsed = (...args) => { + change(...args); + h.manager._hoverPanelControls.get('control-panel').destroy(); + }; + h.key(); + assert.equal(h.timers.size, 0); + h.show(); + h.drain(); + assert.equal(h.calls(), 0); +}); diff --git a/src/mapStackChips.test.mjs b/src/mapStackChips.test.mjs index 7f425fd12..9cf2f6966 100644 --- a/src/mapStackChips.test.mjs +++ b/src/mapStackChips.test.mjs @@ -343,8 +343,9 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse /