Ensure node overlays appear above fullscreen map (#333)

* Increase overlay z-index to surface node info

* Ensure short info overlays attach to fullscreen host

* Ensure info overlay participates in fullscreen mode
This commit is contained in:
l5y
2025-10-14 15:52:26 +02:00
committed by GitHub
parent b7ef0bbfcd
commit 22a31b6c80
4 changed files with 222 additions and 12 deletions

View File

@@ -238,6 +238,7 @@ class StubElement {
function createStubDom() {
const body = new StubElement('body');
body.contains = body.contains.bind(body);
const listenerMap = new Map();
const document = {
body,
documentElement: { clientWidth: 640, clientHeight: 480 },
@@ -247,6 +248,26 @@ function createStubDom() {
getElementById() {
return null;
},
addEventListener(event, handler) {
if (!listenerMap.has(event)) {
listenerMap.set(event, new Set());
}
listenerMap.get(event).add(handler);
},
removeEventListener(event, handler) {
if (!listenerMap.has(event)) {
return;
}
listenerMap.get(event).delete(handler);
},
_dispatch(event) {
if (!listenerMap.has(event)) {
return;
}
for (const handler of Array.from(listenerMap.get(event))) {
handler();
}
},
};
const window = {
scrollX: 10,
@@ -282,6 +303,7 @@ test('render opens overlays and positions them relative to anchors', () => {
assert.equal(open.length, 1);
const overlay = open[0].element;
assert.equal(overlay.parentNode, body);
assert.equal(overlay.style.position, 'absolute');
const content = overlay.querySelector('.short-info-content');
assert.ok(content);
assert.equal(content.innerHTML, '<strong>Node</strong>');
@@ -340,6 +362,31 @@ test('containsNode recognises overlay descendants', () => {
assert.equal(stack.containsNode(stray), false);
});
test('overlays migrate into and out of fullscreen hosts', () => {
const { document, window, factory, anchor, body } = createStubDom();
const fullscreenRoot = document.createElement('div');
body.appendChild(fullscreenRoot);
const stack = createShortInfoOverlayStack({ document, window, factory });
stack.render(anchor, 'Fullscreen');
const [entry] = stack.getOpenOverlays();
assert.equal(entry.element.parentNode, body);
assert.equal(entry.element.style.position, 'absolute');
document.fullscreenElement = fullscreenRoot;
document._dispatch('fullscreenchange');
assert.equal(entry.element.parentNode, fullscreenRoot);
assert.equal(entry.element.style.position, 'fixed');
assert.equal(entry.element.style.left, '40px');
assert.equal(entry.element.style.top, '50px');
document.fullscreenElement = null;
document._dispatch('fullscreenchange');
assert.equal(entry.element.parentNode, body);
assert.equal(entry.element.style.position, 'absolute');
assert.equal(entry.element.style.left, '50px');
assert.equal(entry.element.style.top, '70px');
});
test('rendered overlays do not swallow click events by default', () => {
const { document, window, factory, anchor } = createStubDom();
const stack = createShortInfoOverlayStack({ document, window, factory });

View File

@@ -58,6 +58,9 @@ export function initializeApp(config) {
const baseTitle = document.title;
const nodesTable = document.getElementById('nodes');
const sortButtons = nodesTable ? Array.from(nodesTable.querySelectorAll('thead .sort-button[data-sort-key]')) : [];
const infoOverlayHome = infoOverlay
? { parent: infoOverlay.parentNode, nextSibling: infoOverlay.nextSibling }
: null;
/**
* Column sorter configuration for the node table.
*
@@ -498,6 +501,62 @@ export function initializeApp(config) {
});
});
/**
* Append the informational modal overlay to the fullscreen container when active.
*
* @returns {void}
*/
function attachInfoOverlayToFullscreenHost() {
if (!infoOverlay || !fullscreenContainer) return;
if (infoOverlay.parentNode !== fullscreenContainer) {
fullscreenContainer.appendChild(infoOverlay);
}
if (infoOverlay.classList) {
infoOverlay.classList.add('info-overlay--fullscreen');
}
}
/**
* Restore the informational overlay to its original DOM position.
*
* @returns {void}
*/
function restoreInfoOverlayToHome() {
if (!infoOverlay || !infoOverlayHome || !infoOverlayHome.parent) return;
if (infoOverlay.parentNode === infoOverlayHome.parent) {
if (infoOverlay.classList) {
infoOverlay.classList.remove('info-overlay--fullscreen');
}
return;
}
if (
infoOverlayHome.nextSibling &&
infoOverlayHome.nextSibling.parentNode === infoOverlayHome.parent &&
typeof infoOverlayHome.parent.insertBefore === 'function'
) {
infoOverlayHome.parent.insertBefore(infoOverlay, infoOverlayHome.nextSibling);
} else if (typeof infoOverlayHome.parent.appendChild === 'function') {
infoOverlayHome.parent.appendChild(infoOverlay);
}
if (infoOverlay.classList) {
infoOverlay.classList.remove('info-overlay--fullscreen');
}
}
/**
* Ensure the informational overlay participates in the active fullscreen subtree.
*
* @returns {void}
*/
function syncInfoOverlayHost() {
if (!infoOverlay) return;
if (isMapInFullscreen()) {
attachInfoOverlayToFullscreenHost();
} else {
restoreInfoOverlayToHome();
}
}
/**
* Respond to fullscreen change events originating from the browser.
*
@@ -528,6 +587,7 @@ export function initializeApp(config) {
mapContainer.style.minHeight = '';
}
}
syncInfoOverlayHost();
updateFullscreenToggleState();
refreshMapSize();
}
@@ -548,6 +608,8 @@ export function initializeApp(config) {
}
}
syncInfoOverlayHost();
// Firmware 2.7.10 / Android 2.7.0 roles and colors (see issue #177)
const roleColors = Object.freeze({
CLIENT_HIDDEN: '#A9CBE8',
@@ -1355,6 +1417,7 @@ export function initializeApp(config) {
*/
function openInfoOverlay() {
if (!infoOverlay || !infoDialog) return;
syncInfoOverlayHost();
lastFocusBeforeInfo = document.activeElement;
infoOverlay.hidden = false;
document.body.style.setProperty('overflow', 'hidden');

View File

@@ -15,6 +15,61 @@
*/
const DEFAULT_TEMPLATE_ID = 'shortInfoOverlayTemplate';
const FULLSCREEN_CHANGE_EVENTS = [
'fullscreenchange',
'webkitfullscreenchange',
'mozfullscreenchange',
'MSFullscreenChange',
];
/**
* Resolve the element currently presented in fullscreen mode.
*
* @param {Document} doc Host document reference.
* @returns {?Element} Fullscreen element or ``null`` when fullscreen is inactive.
*/
function getFullscreenElement(doc) {
if (!doc) return null;
return (
doc.fullscreenElement ||
doc.webkitFullscreenElement ||
doc.mozFullScreenElement ||
doc.msFullscreenElement ||
null
);
}
/**
* Determine the container that should host overlays.
*
* @param {Document} doc Host document reference.
* @returns {?Element} Preferred overlay host element.
*/
function resolveOverlayHost(doc) {
const fullscreenElement = getFullscreenElement(doc);
if (fullscreenElement && typeof fullscreenElement.appendChild === 'function') {
return fullscreenElement;
}
return doc && doc.body && typeof doc.body.appendChild === 'function' ? doc.body : null;
}
/**
* Update overlay positioning mode based on fullscreen state.
*
* @param {Element} element Overlay DOM node.
* @param {Document} doc Host document reference.
* @returns {void}
*/
function applyOverlayPositioning(element, doc) {
if (!element || !element.style) {
return;
}
const fullscreenElement = getFullscreenElement(doc);
const desired = fullscreenElement ? 'fixed' : 'absolute';
if (element.style.position !== desired) {
element.style.position = desired;
}
}
/**
* Determine whether a value behaves like a DOM element that can host overlays.
@@ -150,6 +205,49 @@ export function createShortInfoOverlayStack(options = {}) {
const overlayStates = new Map();
const overlayOrder = [];
/**
* Retrieve the active overlay host element.
*
* @returns {?Element} Host element capable of containing overlays.
*/
function getOverlayHost() {
return resolveOverlayHost(doc);
}
/**
* Append ``element`` to the preferred overlay host when necessary.
*
* @param {Element} element Overlay root element.
* @returns {void}
*/
function ensureOverlayAttached(element) {
if (!element) return;
const host = getOverlayHost();
if (!host) return;
if (element.parentNode !== host) {
host.appendChild(element);
}
applyOverlayPositioning(element, doc);
}
/**
* React to fullscreen transitions by reattaching overlays to the active host.
*
* @returns {void}
*/
function handleFullscreenChange() {
for (const state of overlayStates.values()) {
ensureOverlayAttached(state.element);
}
positionAll();
}
if (doc && typeof doc.addEventListener === 'function') {
for (const eventName of FULLSCREEN_CHANGE_EVENTS) {
doc.addEventListener(eventName, handleFullscreenChange);
}
}
/**
* Remove an overlay element from the DOM tree.
*
@@ -215,9 +313,7 @@ export function createShortInfoOverlayStack(options = {}) {
});
}
if (typeof doc.body.appendChild === 'function') {
doc.body.appendChild(overlayEl);
}
ensureOverlayAttached(overlayEl);
state = {
anchor,
@@ -276,24 +372,27 @@ export function createShortInfoOverlayStack(options = {}) {
(win && typeof win.innerHeight === 'number' ? win.innerHeight : 0);
const scrollX = (win && typeof win.scrollX === 'number' ? win.scrollX : 0) || 0;
const scrollY = (win && typeof win.scrollY === 'number' ? win.scrollY : 0) || 0;
const fullscreenElement = getFullscreenElement(doc);
const offsetX = fullscreenElement ? 0 : scrollX;
const offsetY = fullscreenElement ? 0 : scrollY;
let left = rect.left + scrollX;
let top = rect.top + scrollY;
let left = rect.left + offsetX;
let top = rect.top + offsetY;
if (viewportWidth > 0) {
const maxLeft = scrollX + viewportWidth - overlayRect.width - 8;
left = Math.max(scrollX + 8, Math.min(left, maxLeft));
const maxLeft = offsetX + viewportWidth - overlayRect.width - 8;
left = Math.max(offsetX + 8, Math.min(left, maxLeft));
}
if (viewportHeight > 0) {
const maxTop = scrollY + viewportHeight - overlayRect.height - 8;
top = Math.max(scrollY + 8, Math.min(top, maxTop));
const maxTop = offsetY + viewportHeight - overlayRect.height - 8;
top = Math.max(offsetY + 8, Math.min(top, maxTop));
}
if (state.element.style) {
applyOverlayPositioning(state.element, doc);
state.element.style.left = `${left}px`;
state.element.style.top = `${top}px`;
state.element.style.visibility = 'visible';
state.element.style.position = state.element.style.position || 'absolute';
}
}
@@ -328,6 +427,7 @@ export function createShortInfoOverlayStack(options = {}) {
if (!state) {
return;
}
ensureOverlayAttached(state.element);
if (state.content && typeof state.content.innerHTML === 'string') {
state.content.innerHTML = html;
}

View File

@@ -446,7 +446,7 @@ th {
line-height: 1.4;
min-width: 200px;
max-width: 240px;
z-index: 2000;
z-index: 12000;
}
.short-info-overlay[hidden] {
@@ -810,7 +810,7 @@ input[type="radio"] {
align-items: center;
justify-content: center;
padding: var(--pad);
z-index: 4000;
z-index: 13000;
}
.info-overlay[hidden] {