fixes for z-index
CI / Build & Test (push) Successful in 3m27s

This commit is contained in:
Raven Scott
2026-07-26 02:26:46 -04:00
parent 775a9c44be
commit 2704dd8d4e
2 changed files with 142 additions and 15 deletions
+95 -15
View File
@@ -1,16 +1,72 @@
/**
* Generic modal helpers for the dashboard.
* Handles open/close animations, error display, backdrop click, and Escape key.
* Handles open/close animations, error display, backdrop click, Escape key,
* Enter-to-submit from text-like inputs, and Tab focus trap.
* Depends on: core/utils.js ($), core/navigation.js (navigateTo)
*/
let _modalPreviousFocus = null;
function topmostOpenModalBackdrop() {
const open = document.querySelectorAll('.modal-backdrop.open');
return open.length ? open[open.length - 1] : null;
}
/** Open backdrop that contains a node (e.g. focused field). Needed when two modals are open — e.g. VNC password over the viewer. */
function openModalBackdropContaining(node) {
if (!node || typeof node.closest !== 'function') return null;
return node.closest('.modal-backdrop.open');
}
/** Primary action in footer (or danger confirm if no primary). */
function getModalDefaultActionButton(modalBackdrop) {
const footer = modalBackdrop.querySelector('.modal-footer');
if (!footer) return null;
const primary = footer.querySelector('button.btn-primary:not([disabled])');
if (primary) return primary;
const danger = footer.querySelector('button.btn-danger:not([disabled])');
return danger || null;
}
const _modalEnterSkipInputTypes = new Set([
'checkbox',
'radio',
'button',
'submit',
'reset',
'file',
'hidden',
'image',
'color',
'range'
]);
/**
* Single-line text-like inputs in form modals — not xterm/RDP capture elements.
*/
function isModalEnterSubmitInput(el) {
if (!el || el.nodeName !== 'INPUT' || el.disabled) return false;
if (el.closest('#terminalContainer') || el.closest('#rdpViewerContainer') || el.closest('.xterm')) {
return false;
}
const t = (el.type || 'text').toLowerCase();
return !_modalEnterSkipInputTypes.has(t);
}
/**
* Open a modal by adding the `open` class and focusing the first focusable element.
* Stores the previously focused element for restore on close. Sets ARIA attributes.
* @param {string} id - The modal backdrop element ID.
*/
function syncModalOpenBodyClass() {
try {
document.body.classList.toggle(
'modal-open',
!!document.querySelector('.modal-backdrop.open')
);
} catch (_) {}
}
function openModal(id) {
const el = $(id);
if (!el) return;
@@ -19,6 +75,7 @@ function openModal(id) {
el.setAttribute('aria-hidden', 'false');
el.setAttribute('aria-modal', 'true');
el.setAttribute('role', 'dialog');
syncModalOpenBodyClass();
const modalTitle = el.querySelector('.modal-title');
if (modalTitle && modalTitle.id) el.setAttribute('aria-labelledby', modalTitle.id);
else if (modalTitle && !modalTitle.id) {
@@ -27,7 +84,17 @@ function openModal(id) {
el.setAttribute('aria-labelledby', tid);
}
setTimeout(() => {
const focusable = el.querySelector('input:not([type="checkbox"]):not([disabled]), button:not([disabled]), [tabindex="0"]');
// Prefer text fields in the body — not the header close button (first `button` in DOM order),
// or paste (⌘V) goes nowhere and feels "broken".
let focusable = el.querySelector(
'.modal-body input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]):not([disabled]), .modal-body textarea:not([disabled]), .modal-body select:not([disabled])'
);
if (!focusable) {
focusable = el.querySelector('.modal-footer button:not([disabled])');
}
if (!focusable) {
focusable = el.querySelector('button[data-close-modal]');
}
if (focusable) focusable.focus();
}, 50);
}
@@ -41,20 +108,16 @@ function closeModal(id) {
const el = $(id);
if (!el) return;
el.classList.remove('open');
// Restore or clear focus before aria-hidden: a focused descendant must not live under aria-hidden.
const focusTarget = _modalPreviousFocus;
_modalPreviousFocus = null;
if (focusTarget && typeof focusTarget.focus === 'function') {
focusTarget.focus();
}
if (el.contains(document.activeElement)) {
document.activeElement?.blur?.();
}
el.setAttribute('aria-hidden', 'true');
el.removeAttribute('aria-modal');
el.removeAttribute('role');
el.removeAttribute('aria-labelledby');
el.querySelectorAll('.modal-error').forEach(e => { e.style.display = 'none'; e.textContent = ''; });
syncModalOpenBodyClass();
if (_modalPreviousFocus && typeof _modalPreviousFocus.focus === 'function') {
_modalPreviousFocus.focus();
_modalPreviousFocus = null;
}
}
/**
@@ -87,17 +150,34 @@ document.addEventListener('click', (e) => {
}
});
// Escape key closes topmost open modal; Tab trap focus inside modal
// Escape closes topmost modal; Enter submits from text inputs; Tab traps focus
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const open = document.querySelector('.modal-backdrop.open');
const fromFocus = openModalBackdropContaining(document.activeElement);
const open = fromFocus || topmostOpenModalBackdrop();
if (open) closeModal(open.id);
return;
}
if (e.key === 'Enter' && !e.defaultPrevented) {
if (e.isComposing) return;
const open = openModalBackdropContaining(e.target);
if (!open) return;
if (!isModalEnterSubmitInput(e.target)) return;
const btn = getModalDefaultActionButton(open);
if (!btn) return;
e.preventDefault();
btn.click();
return;
}
if (e.key !== 'Tab') return;
const open = document.querySelector('.modal-backdrop.open');
const open =
openModalBackdropContaining(document.activeElement) || topmostOpenModalBackdrop();
if (!open) return;
const focusable = open.querySelectorAll('input:not([disabled]), button:not([disabled]), select:not([disabled]), [tabindex="0"]');
const focusable = open.querySelectorAll(
'input:not([disabled]), textarea:not([disabled]), button:not([disabled]), select:not([disabled]), [tabindex="0"]'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) return;