feat: Enhance dark mode styling and improve dropdown menu behavior for better user experience

This commit is contained in:
Matt Batchelder
2026-02-06 23:57:16 -05:00
parent 87a444b8de
commit edd112fec3
7 changed files with 1094 additions and 32 deletions

View File

@@ -335,8 +335,21 @@
dropdown.addEventListener('click', function(e) {
if (e.target.closest('.user-btn') || e.target.closest('[aria-label="User menu"]') || e.target.closest('#navbarUserMenu')) {
e.preventDefault();
const nowActive = !dropdown.classList.contains('active');
dropdown.classList.toggle('active');
// If the dropdown has a menu, float it out of any overflowed container
try {
const ddMenu = dropdown.querySelector('.dropdown-menu');
if (ddMenu) {
if (nowActive) {
floatMenu(ddMenu, dropdown);
} else {
unfloatMenu(ddMenu);
}
}
} catch (err) { /* ignore */ }
// If this dropdown contains the user menu, compute placement to avoid going off-screen
const menu = dropdown.querySelector('.dropdown-menu.ots-user-menu');
const trigger = dropdown.querySelector('#navbarUserMenu');
@@ -368,12 +381,318 @@
// Close menu when clicking outside
document.addEventListener('click', function(e) {
if (!dropdown.contains(e.target)) {
const hasActive = dropdown.classList.contains('active');
dropdown.classList.remove('active');
if (hasActive) {
try { const ddMenu = dropdown.querySelector('.dropdown-menu'); if (ddMenu) unfloatMenu(ddMenu); } catch (err) {}
}
}
});
});
}
/**
* Float a menu element into document.body so it can escape overflowed parents.
* Adds `.ots-floating-menu` and positions absolutely based on the trigger rect.
*/
function floatMenu(menuEl, triggerEl) {
if (!menuEl || !triggerEl || menuEl.getAttribute('data-ots-floating') === '1') return;
try {
// Remember original parent and next sibling so we can restore later
menuEl._otsOriginalParent = menuEl.parentNode || null;
menuEl._otsOriginalNext = menuEl.nextSibling || null;
menuEl.setAttribute('data-ots-floating', '1');
menuEl.classList.add('ots-floating-menu');
// Append to body
document.body.appendChild(menuEl);
const rect = triggerEl.getBoundingClientRect();
// Default placement below trigger, align to left edge
const top = Math.max(8, Math.round(rect.bottom + window.scrollY + 6));
const left = Math.max(8, Math.round(rect.left + window.scrollX));
// Use fixed positioning so the menu floats above all stacking contexts
// Use fixed positioning so the menu floats above all stacking contexts
try {
menuEl.style.setProperty('position', 'fixed', 'important');
menuEl.style.setProperty('top', Math.max(6, Math.round(rect.bottom + 6)) + 'px', 'important');
menuEl.style.setProperty('left', left + 'px', 'important');
// Use the maximum reasonable z-index to ensure it appears on top
menuEl.style.setProperty('z-index', '2147483647', 'important');
// Ensure transforms won't clip rendering
menuEl.style.setProperty('transform', 'none', 'important');
menuEl.style.setProperty('min-width', (rect.width) + 'px', 'important');
menuEl.style.setProperty('pointer-events', 'auto', 'important');
} catch (err) {
// fallback to non-important inline style
menuEl.style.position = 'fixed';
menuEl.style.top = Math.max(6, Math.round(rect.bottom + 6)) + 'px';
menuEl.style.left = left + 'px';
menuEl.style.zIndex = '2147483647';
menuEl.style.transform = 'none';
menuEl.style.minWidth = (rect.width) + 'px';
menuEl.style.pointerEvents = 'auto';
}
// Reposition on scroll/resize while open
const reposition = function() {
if (menuEl.getAttribute('data-ots-floating') !== '1') return;
const r = triggerEl.getBoundingClientRect();
// For fixed positioning we only need viewport coords
menuEl.style.top = Math.max(6, Math.round(r.bottom + 6)) + 'px';
menuEl.style.left = Math.max(6, Math.round(r.left)) + 'px';
};
menuEl._otsReposition = reposition;
window.addEventListener('scroll', reposition, true);
window.addEventListener('resize', reposition);
// Guard: some libraries move/drop menus. Keep a short-lived guard that
// re-attaches the menu to body and re-applies important styles while open.
let guardCount = 0;
const guard = setInterval(function() {
try {
if (menuEl.getAttribute('data-ots-floating') !== '1') {
clearInterval(guard);
return;
}
// If parent moved, re-append to body
if (menuEl.parentNode !== document.body) document.body.appendChild(menuEl);
// Re-ensure important styles
menuEl.style.setProperty('z-index', '2147483647', 'important');
menuEl.style.setProperty('position', 'fixed', 'important');
} catch (err) {}
guardCount += 1;
if (guardCount > 120) {
clearInterval(guard);
}
}, 100);
menuEl._otsGuard = guard;
} catch (err) {
console.warn('[OTS] floatMenu failed', err);
}
}
function unfloatMenu(menuEl) {
if (!menuEl || menuEl.getAttribute('data-ots-floating') !== '1') return;
try {
menuEl.removeAttribute('data-ots-floating');
menuEl.classList.remove('ots-floating-menu');
menuEl.style.position = '';
menuEl.style.top = '';
menuEl.style.left = '';
menuEl.style.zIndex = '';
menuEl.style.minWidth = '';
menuEl.style.pointerEvents = '';
// Remove reposition listeners
if (menuEl._otsReposition) {
window.removeEventListener('scroll', menuEl._otsReposition, true);
window.removeEventListener('resize', menuEl._otsReposition);
delete menuEl._otsReposition;
}
// Attempt to restore the original parent and insertion point
try {
if (menuEl._otsOriginalParent) {
if (menuEl._otsOriginalNext && menuEl._otsOriginalNext.parentNode === menuEl._otsOriginalParent) {
menuEl._otsOriginalParent.insertBefore(menuEl, menuEl._otsOriginalNext);
} else {
menuEl._otsOriginalParent.appendChild(menuEl);
}
delete menuEl._otsOriginalParent;
delete menuEl._otsOriginalNext;
} else {
// fallback: append to body (leave it there)
document.body.appendChild(menuEl);
}
} catch (err) {
document.body.appendChild(menuEl);
}
} catch (err) {
console.warn('[OTS] unfloatMenu failed', err);
}
}
/**
* Observe document for dynamically added dropdown menus and float them when necessary.
*/
function observeAndFloatMenus() {
try {
const mo = new MutationObserver(function(muts) {
muts.forEach(function(m) {
(m.addedNodes || []).forEach(function(node) {
try {
if (!node || node.nodeType !== 1) return;
// If the node itself is a dropdown menu
if (node.classList && node.classList.contains('dropdown-menu')) {
attachIfNeeded(node);
}
// Or contains dropdown menus
const menus = node.querySelectorAll && node.querySelectorAll('.dropdown-menu');
if (menus && menus.length) {
menus.forEach(attachIfNeeded);
}
} catch (err) {}
});
});
});
mo.observe(document.body, { childList: true, subtree: true });
// keep alive for the lifetime of the page
} catch (err) {
// ignore
}
function attachIfNeeded(menu) {
try {
if (!menu || menu.getAttribute('data-ots-floating-obs') === '1') return;
menu.setAttribute('data-ots-floating-obs', '1');
// find a reasonable trigger element: aria-labelledby or previous element
let trigger = null;
const labelled = menu.getAttribute('aria-labelledby');
if (labelled) trigger = document.getElementById(labelled);
if (!trigger) trigger = menu._otsOriginalParent ? menu._otsOriginalParent.querySelector('[data-toggle="dropdown"]') : null;
if (!trigger) trigger = menu.previousElementSibling || null;
// If the menu is visible and inside an overflowed ancestor, float it
const rect = menu.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return; // not rendered yet
if (isClippedByOverflow(menu) && trigger) {
floatMenu(menu, trigger);
}
// Also watch for when dropdown gets toggled active via class
const obs = new MutationObserver(function(ms) {
ms.forEach(function(mm) {
if (mm.type === 'attributes' && mm.attributeName === 'class') {
const isActive = menu.classList.contains('show') || menu.parentNode && menu.parentNode.classList.contains('active');
if (isActive && trigger) floatMenu(menu, trigger);
if (!isActive) unfloatMenu(menu);
}
});
});
obs.observe(menu, { attributes: true, attributeFilter: ['class'] });
} catch (err) {}
}
function isClippedByOverflow(el) {
let p = el.parentElement;
while (p && p !== document.body) {
const s = window.getComputedStyle(p);
if (/(hidden|auto|scroll)/.test(s.overflow + s.overflowY + s.overflowX)) {
const r = el.getBoundingClientRect();
const pr = p.getBoundingClientRect();
// if element overflows parent's rect then it's clipped
if (r.bottom > pr.bottom || r.top < pr.top || r.left < pr.left || r.right > pr.right) return true;
}
p = p.parentElement;
}
return false;
}
}
/**
* Force common menu classes to the top by moving them to body and keeping them there.
* This is the most aggressive approach to ensure menus are never clipped.
*/
function forceTopMenus() {
const selectors = ['.dropdown-menu', '.ots-notif-menu', '.ots-user-menu', '.context-menu', '.row-menu', '.rowMenu', '.menu-popover'];
function moveToBody(el) {
try {
if (!el || el.getAttribute('data-ots-moved-to-body') === '1') return;
// Store original parent info
el._otsOriginalParent = el.parentElement;
el._otsOriginalNextSibling = el.nextElementSibling;
el.setAttribute('data-ots-moved-to-body', '1');
// Force fixed positioning with maximum z-index
el.style.setProperty('position', 'fixed', 'important');
el.style.setProperty('z-index', '2147483647', 'important');
el.style.setProperty('transform', 'none', 'important');
el.style.setProperty('pointer-events', 'auto', 'important');
el.style.setProperty('visibility', 'visible', 'important');
el.style.setProperty('display', 'block', 'important');
el.style.setProperty('opacity', '1', 'important');
el.style.setProperty('clip-path', 'none', 'important');
// Move to body if not already there
if (el.parentElement !== document.body) {
document.body.appendChild(el);
}
} catch (err) {
console.warn('[OTS] moveToBody failed', err);
}
}
function applyMenuStyles(el) {
try {
if (!el) return;
el.style.setProperty('position', 'fixed', 'important');
el.style.setProperty('z-index', '2147483647', 'important');
el.style.setProperty('transform', 'none', 'important');
el.style.setProperty('pointer-events', 'auto', 'important');
} catch (err) {}
}
// Apply to existing menus immediately
selectors.forEach(sel => {
document.querySelectorAll(sel).forEach(el => {
moveToBody(el);
applyMenuStyles(el);
});
});
// Continuously guard: check that menus stay in body and have correct styles
let guardInterval = setInterval(function() {
try {
selectors.forEach(sel => {
document.querySelectorAll(sel).forEach(el => {
// If menu got moved back, move it to body again
if (el.parentElement !== document.body && el.parentElement !== null) {
document.body.appendChild(el);
}
// Reapply critical styles in case they got overridden
applyMenuStyles(el);
});
});
} catch (err) {}
}, 200);
// Keep guard alive for the page lifetime, but stop if no menus found after 30s
let noMenuCount = 0;
const checkGuard = setInterval(function() {
const hasMenus = selectors.some(sel => document.querySelector(sel));
if (!hasMenus) {
noMenuCount++;
if (noMenuCount > 150) {
clearInterval(guardInterval);
clearInterval(checkGuard);
}
} else {
noMenuCount = 0;
}
}, 200);
// Observe for dynamically added menus
try {
const mo = new MutationObserver(function(muts) {
muts.forEach(m => {
(m.addedNodes || []).forEach(node => {
try {
if (!node || node.nodeType !== 1) return;
selectors.forEach(sel => {
if (node.matches && node.matches(sel)) {
moveToBody(node);
}
const found = node.querySelectorAll && node.querySelectorAll(sel);
found && found.forEach(moveToBody);
});
} catch (err) {}
});
});
});
mo.observe(document.body, { childList: true, subtree: true });
} catch (err) {}
}
/**
* Initialize search functionality
*/
@@ -805,6 +1124,7 @@
updateSidebarWidth();
updateSidebarNavOffset();
// updateSidebarGap() disabled - use CSS variables instead
initUserProfileQrFix();
var debouncedUpdate = debounce(function() {
updateSidebarNavOffset();
updateSidebarWidth();
@@ -820,3 +1140,82 @@
init();
}
})();
// Replace broken QR images in user profile modals with a friendly placeholder
function initUserProfileQrFix() {
function replaceIfEmptyDataUri(el) {
try {
if (!el || el.tagName !== 'IMG') return false;
if (!el.closest || !el.closest('.modal, .modal-dialog')) return false;
var src = el.getAttribute('src') || '';
// matches empty/invalid data uri like "data:image/png;base64," or very short payloads
if (/^data:image\/[a-zA-Z0-9.+-]+;base64,\s*$/.test(src) || (src.indexOf('data:image') === 0 && src.split(',')[1] && src.split(',')[1].length < 10)) {
console.warn('[OTS] Replacing empty data URI for QR image inside modal:', src);
var svg = 'data:image/svg+xml;utf8,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160">'
+ '<rect width="100%" height="100%" fill="#213041"/>'
+ '<text x="50%" y="50%" fill="#9fb1c8" font-family="Arial,Helvetica,sans-serif" font-size="14" text-anchor="middle" dy=".3em">QR unavailable</text>'
+ '</svg>'
);
if (el.getAttribute('data-ots-replaced') === '1') return true;
el.setAttribute('data-ots-replaced', '1');
el.src = svg;
el.alt = 'QR code unavailable';
var parent = el.parentNode;
if (parent && !parent.querySelector('.ots-qr-note')) {
var p = document.createElement('p');
p.className = 'ots-qr-note text-muted';
p.style.marginTop = '6px';
p.textContent = 'QR failed to load. Close and re-open the Edit Profile dialog to retry.';
parent.appendChild(p);
}
return true;
}
} catch (err) {
console.error('[OTS] replaceIfEmptyDataUri error', err);
}
return false;
}
// Initial quick scan for any modal images already present
try {
var imgs = document.querySelectorAll('.modal img, .modal-dialog img');
imgs.forEach(function(i) { replaceIfEmptyDataUri(i); });
} catch (e) {}
// Observe DOM for modals being added (some UIs load modal content via AJAX)
try {
var mo = new MutationObserver(function(muts) {
muts.forEach(function(m) {
m.addedNodes && m.addedNodes.forEach(function(node) {
try {
if (!node) return;
if (node.nodeType === 1) {
if (node.matches && node.matches('.modal, .modal-dialog')) {
var imgs = node.querySelectorAll('img');
imgs.forEach(function(i) { replaceIfEmptyDataUri(i); });
} else {
var imgs = node.querySelectorAll && node.querySelectorAll('img');
imgs && imgs.forEach(function(i) { replaceIfEmptyDataUri(i); });
}
}
} catch (err) {}
});
});
});
mo.observe(document.body, { childList: true, subtree: true });
// stop observing after 20s to avoid long-lived observers in older pages
setTimeout(function() { try { mo.disconnect(); } catch (e) {} }, 20000);
} catch (err) {}
// Also a short polling fallback for dynamic UIs for the first 6s
var checks = 0;
var interval = setInterval(function() {
try {
var imgs = document.querySelectorAll('.modal img, .modal-dialog img');
imgs.forEach(function(i) { replaceIfEmptyDataUri(i); });
} catch (e) {}
checks += 1;
if (checks > 12) clearInterval(interval);
}, 500);
}