@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user