- Add unit tests (hostname-validator, TLDs, payload-schemas) and integration tests for message handler registry - Refactor native host message router into handler registry (handlers/state, tunnels, ssh, rdp, backup, ca, connections) - Add ESLint config and npm test + lint steps in CI - Dashboard: visibility-based refresh pause, configurable refresh interval (2s/5s/10s/paused) - Accessibility: ARIA on nav and modals, focus trap and restore, prefers-reduced-motion - Empty states: primary action buttons for virtual hosts, servers, service tunnels - Native host rate limiting for backup and CA operations; update SECURITY.md - CONTRIBUTING: "Adding a new dashboard page", dev workflow; add npm run dev script
This commit is contained in:
@@ -4,29 +4,50 @@
|
||||
* Depends on: core/utils.js ($), core/navigation.js (navigateTo)
|
||||
*/
|
||||
|
||||
let _modalPreviousFocus = null;
|
||||
|
||||
/**
|
||||
* Open a modal by adding the `open` class and focusing the first input.
|
||||
* 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 openModal(id) {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
_modalPreviousFocus = document.activeElement;
|
||||
el.classList.add('open');
|
||||
el.setAttribute('aria-modal', 'true');
|
||||
el.setAttribute('role', 'dialog');
|
||||
const modalTitle = el.querySelector('.modal-title');
|
||||
if (modalTitle && modalTitle.id) el.setAttribute('aria-labelledby', modalTitle.id);
|
||||
else if (modalTitle && !modalTitle.id) {
|
||||
const tid = 'modal-title-' + id;
|
||||
modalTitle.id = tid;
|
||||
el.setAttribute('aria-labelledby', tid);
|
||||
}
|
||||
setTimeout(() => {
|
||||
const input = el.querySelector('input:not([type="checkbox"])');
|
||||
if (input) input.focus();
|
||||
const focusable = el.querySelector('input:not([type="checkbox"]):not([disabled]), button:not([disabled]), [tabindex="0"]');
|
||||
if (focusable) focusable.focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a modal by removing the `open` class and clearing any error messages.
|
||||
* Restores focus to the element that had focus before the modal opened.
|
||||
* @param {string} id - The modal backdrop element ID.
|
||||
*/
|
||||
function closeModal(id) {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
el.classList.remove('open');
|
||||
el.removeAttribute('aria-modal');
|
||||
el.removeAttribute('role');
|
||||
el.removeAttribute('aria-labelledby');
|
||||
el.querySelectorAll('.modal-error').forEach(e => { e.style.display = 'none'; e.textContent = ''; });
|
||||
if (_modalPreviousFocus && typeof _modalPreviousFocus.focus === 'function') {
|
||||
_modalPreviousFocus.focus();
|
||||
_modalPreviousFocus = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,10 +79,23 @@ document.addEventListener('click', (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Escape key closes topmost open modal
|
||||
// Escape key closes topmost open modal; Tab trap focus inside modal
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const open = document.querySelector('.modal-backdrop.open');
|
||||
if (open) closeModal(open.id);
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Tab') return;
|
||||
const open = document.querySelector('.modal-backdrop.open');
|
||||
if (!open) return;
|
||||
const focusable = open.querySelectorAll('input:not([disabled]), button:not([disabled]), select:not([disabled]), [tabindex="0"]');
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (!first || !last) return;
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
} else {
|
||||
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user