Ship Track C UX polish: toasts, settings, lists, navigation
CI / test (push) Successful in 9m55s

Add a visible toast stack, fix presentError double-notify, wire density and auto-refresh settings, gate destructive confirms, improve empty/skeleton/search on resource lists, and enable browser history plus keyboard palette navigation.
This commit is contained in:
2026-07-10 22:10:40 -04:00
parent 3b3336fbde
commit 4063c83597
8 changed files with 733 additions and 235 deletions
+120 -25
View File
@@ -9,36 +9,29 @@ import notificationManager from './notifications.js';
* Close all open Bootstrap modals and clean up any lingering backdrops
*/
export function closeAllModals() {
// Find and hide all open modals
const modals = document.querySelectorAll('.modal.show, .modal[style*="display"]');
modals.forEach(modal => {
modals.forEach((modal) => {
const modalInstance = bootstrap.Modal.getInstance(modal);
if (modalInstance) {
modalInstance.hide();
} else {
// If no instance exists, create one and hide it
const newInstance = new bootstrap.Modal(modal);
newInstance.hide();
}
// Also directly hide the modal element as fallback
modal.classList.remove('show');
modal.style.display = 'none';
modal.setAttribute('aria-hidden', 'true');
modal.removeAttribute('aria-modal');
});
// Remove any lingering modal backdrops
const backdrops = document.querySelectorAll('.modal-backdrop');
backdrops.forEach(backdrop => {
document.querySelectorAll('.modal-backdrop').forEach((backdrop) => {
backdrop.remove();
});
// Clean up body classes and styles
document.body.classList.remove('modal-open');
document.body.style.paddingRight = '';
document.body.style.overflow = '';
// Also handle custom terminal modal if it exists
const terminalModal = document.getElementById('terminal-modal');
if (terminalModal && terminalModal.style.display !== 'none') {
terminalModal.style.display = 'none';
@@ -47,11 +40,9 @@ export function closeAllModals() {
/**
* Activity / progress indicator — routes to the job/event tray (no full-screen spinner).
* Falls back to a non-blocking notification if the ops shell is not ready yet.
* @param {string} message - Message to display
* @param {string} message
*/
export function showStatusIndicator(message = 'Processing...') {
// Defensive: never leave a legacy full-screen overlay around
removeLegacyStatusOverlay();
if (typeof window !== 'undefined' && window.peardockOps?.showActivity) {
@@ -67,8 +58,7 @@ export function showStatusIndicator(message = 'Processing...') {
}
/**
* Update status / activity message in the job tray
* @param {string} message - New message to display
* @param {string} message
*/
export function updateStatusIndicator(message) {
if (typeof window !== 'undefined' && window.peardockOps?.updateActivity) {
@@ -83,8 +73,7 @@ export function updateStatusIndicator(message) {
}
/**
* Complete / hide activity indicator in the job tray
* @param {boolean} [ok=true] - Whether the activity succeeded
* @param {boolean} [ok=true]
*/
export function hideStatusIndicator(ok = true) {
if (typeof window !== 'undefined' && window.peardockOps?.completeActivity) {
@@ -106,12 +95,118 @@ function removeLegacyStatusOverlay() {
}
/**
* Show alert message (now uses notification system)
* @param {string} type - Alert type (success, danger, warning, info)
* @param {string} message - Message to display
* @param {Object} options - Additional options (autoDismiss, duration)
* Ensure toast host exists (top-center stack).
* @returns {HTMLElement|null}
*/
function ensureToastStack() {
if (typeof document === 'undefined') return null;
let stack = document.getElementById('toast-stack');
if (stack) return stack;
// Prefer legacy alert-container if present
const legacy = document.getElementById('alert-container');
if (legacy) {
legacy.id = 'toast-stack';
legacy.className = 'toast-stack';
legacy.removeAttribute('style');
legacy.setAttribute('aria-live', 'polite');
legacy.setAttribute('aria-atomic', 'false');
return legacy;
}
stack = document.createElement('div');
stack.id = 'toast-stack';
stack.className = 'toast-stack';
stack.setAttribute('aria-live', 'polite');
stack.setAttribute('aria-atomic', 'false');
document.body.appendChild(stack);
return stack;
}
/**
* Floating toast (visible feedback) + notification tray history.
* @param {string} type - success | danger | warning | info
* @param {string} message
* @param {{ autoDismiss?: boolean, duration?: number, toast?: boolean, tray?: boolean }} [options]
*/
export function showAlert(type, message, options = {}) {
// Use the new notification system
notificationManager.add(type, message, options);
const {
autoDismiss = true,
duration = 4500,
toast = true,
tray = true,
} = options;
const text = String(message ?? '');
if (!text) return;
// Persist in notification tray (history)
if (tray !== false) {
try {
notificationManager.add(type, text, {
autoDismiss: false,
...options,
});
} catch {
// ignore
}
}
// Visible toast stack
if (toast === false) return;
const stack = ensureToastStack();
if (!stack) return;
const tone = ['success', 'danger', 'warning', 'info'].includes(type) ? type : 'info';
const iconMap = {
success: 'fa-circle-check',
danger: 'fa-circle-xmark',
warning: 'fa-triangle-exclamation',
info: 'fa-circle-info',
};
const el = document.createElement('div');
el.className = `pd-toast pd-toast--${tone}`;
el.setAttribute('role', 'status');
el.innerHTML = `
<i class="fas ${iconMap[tone]} pd-toast-icon" aria-hidden="true"></i>
<div class="pd-toast-body">${escapeToast(text)}</div>
<button type="button" class="pd-toast-close" aria-label="Dismiss">
<i class="fas fa-xmark"></i>
</button>`;
let timer = null;
const dismiss = () => {
if (timer) clearTimeout(timer);
el.classList.add('pd-toast--leaving');
setTimeout(() => el.remove(), 220);
};
el.querySelector('.pd-toast-close')?.addEventListener('click', dismiss);
el.addEventListener('mouseenter', () => {
if (timer) clearTimeout(timer);
});
el.addEventListener('mouseleave', () => {
if (autoDismiss !== false) {
timer = setTimeout(dismiss, 2000);
}
});
stack.prepend(el);
// Cap stack size
while (stack.children.length > 5) {
stack.lastElementChild?.remove();
}
if (autoDismiss !== false) {
timer = setTimeout(dismiss, duration);
}
}
function escapeToast(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}