CI / Build & Test (push) Successful in 3m19s
- 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
102 lines
3.4 KiB
JavaScript
102 lines
3.4 KiB
JavaScript
/**
|
|
* Generic modal helpers for the dashboard.
|
|
* Handles open/close animations, error display, backdrop click, and Escape key.
|
|
* Depends on: core/utils.js ($), core/navigation.js (navigateTo)
|
|
*/
|
|
|
|
let _modalPreviousFocus = null;
|
|
|
|
/**
|
|
* 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 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Display an error message inside a modal's error element.
|
|
* @param {string} modalId - Unused; kept for API symmetry.
|
|
* @param {string} errorId - ID of the error display element.
|
|
* @param {string} msg - Error message text.
|
|
*/
|
|
function showModalError(modalId, errorId, msg) {
|
|
const el = $(errorId);
|
|
if (!el) return;
|
|
el.textContent = msg;
|
|
el.style.display = 'block';
|
|
}
|
|
|
|
// Close modals on backdrop click or close button
|
|
document.addEventListener('click', (e) => {
|
|
const closeBtn = e.target.closest('[data-close-modal]');
|
|
if (closeBtn) {
|
|
closeModal(closeBtn.dataset.closeModal);
|
|
return;
|
|
}
|
|
if (e.target.classList.contains('modal-backdrop')) {
|
|
closeModal(e.target.id);
|
|
}
|
|
const pageLink = e.target.closest('[data-page-link]');
|
|
if (pageLink) {
|
|
navigateTo(pageLink.dataset.pageLink);
|
|
}
|
|
});
|
|
|
|
// 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(); }
|
|
}
|
|
});
|