mirror of
https://github.com/smittix/intercept.git
synced 2026-05-22 07:44:49 -07:00
feat: UI/UX overhaul — CSS cleanup, accessibility, error handling, inline style extraction
Phase 0 — CSS-only fixes: - Fix --font-mono to use real monospace stack (JetBrains Mono, Fira Code, etc.) - Replace hardcoded hex colors with CSS variables across 16+ files - Merge global-nav.css (507 lines) into layout.css, delete original - Reduce !important in responsive.css from 71 to 8 via .app-shell specificity - Standardize breakpoints to 480/768/1024/1280px Phase 1 — Loading states & SSE connection feedback: - Add centralized SSEManager (sse-manager.js) with exponential backoff - Add SSE status indicator dot in nav bar - Add withLoadingButton() + .btn-loading CSS spinner - Add mode section crossfade transitions Phase 2 — Accessibility: - Add aria-labels to icon-only buttons across mode partials - Add for/id associations to 42 form labels in 5 mode partials - Add aria-live on toast stack, enableListKeyNav() utility Phase 3 — Destructive action guards & list overflow: - Add confirmAction() styled modal, replace all 25 native confirm() calls - Add toast cap at 5 simultaneous toasts - Add list overflow indicator CSS Phase 4 — Inline style extraction: - Refactor switchMode() in app.js and index.html to use classList.toggle() - Add CSS toggle rules for all switchMode-controlled elements - Remove inline style="display:none" from 7+ HTML elements - Add utility classes (.hidden, .d-flex, .d-grid, etc.) Phase 5 — Mobile UX polish: - pre/code overflow handling already in place - Touch target sizing via --touch-min variable Phase 6 — Error handling consistency: - Add reportActionableError() to user-facing catch blocks in 5 mode JS files - 28 error toast additions alongside existing console.error calls Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -485,7 +485,7 @@ async function syncLocalModeStates() {
|
||||
*/
|
||||
function showAgentModeWarnings(runningModes, modesDetail = {}) {
|
||||
// SDR modes that can't run simultaneously on same device
|
||||
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
||||
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
||||
const runningSdrModes = runningModes.filter(m => sdrModes.includes(m));
|
||||
|
||||
let warning = document.getElementById('agentModeWarning');
|
||||
@@ -613,7 +613,7 @@ function checkAgentAudioMode(modeToStart) {
|
||||
* @param {string} modeToStart - Mode to start
|
||||
* @param {number} deviceToUse - Device index to use (optional, for smarter conflict detection)
|
||||
*/
|
||||
function checkAgentModeConflict(modeToStart, deviceToUse = null) {
|
||||
async function checkAgentModeConflict(modeToStart, deviceToUse = null) {
|
||||
if (currentAgent === 'local') return true; // No conflict checking for local
|
||||
|
||||
// First check if this is an audio mode
|
||||
@@ -621,7 +621,7 @@ function checkAgentModeConflict(modeToStart, deviceToUse = null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
||||
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
||||
|
||||
// If we're trying to start an SDR mode
|
||||
if (sdrModes.includes(modeToStart)) {
|
||||
@@ -648,11 +648,12 @@ function checkAgentModeConflict(modeToStart, deviceToUse = null) {
|
||||
return detail ? `${m} (SDR ${detail.device})` : m;
|
||||
}).join(', ');
|
||||
|
||||
const proceed = confirm(
|
||||
`The agent's SDR device is currently running: ${modeList}\n\n` +
|
||||
`Starting ${modeToStart} on the same device will fail.\n\n` +
|
||||
`Do you want to stop the conflicting mode(s) first?`
|
||||
);
|
||||
const proceed = await AppFeedback.confirmAction({
|
||||
title: 'SDR Device Conflict',
|
||||
message: `The agent's SDR device is currently running: ${modeList}. Starting ${modeToStart} on the same device will fail. Do you want to stop the conflicting mode(s) first?`,
|
||||
confirmLabel: 'Stop & Continue',
|
||||
confirmClass: 'btn-danger'
|
||||
});
|
||||
|
||||
if (proceed) {
|
||||
// Stop conflicting modes
|
||||
|
||||
@@ -269,8 +269,14 @@ const AlertCenter = (function() {
|
||||
});
|
||||
}
|
||||
|
||||
function deleteRule(ruleId) {
|
||||
if (!confirm('Delete this alert rule?')) return;
|
||||
async function deleteRule(ruleId) {
|
||||
const confirmed = await AppFeedback.confirmAction({
|
||||
title: 'Delete Alert Rule',
|
||||
message: 'Delete this alert rule?',
|
||||
confirmLabel: 'Delete',
|
||||
confirmClass: 'btn-danger'
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
fetch(`/alerts/rules/${ruleId}`, { method: 'DELETE' })
|
||||
.then((r) => r.json())
|
||||
|
||||
@@ -120,19 +120,19 @@ function switchMode(mode) {
|
||||
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||||
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
||||
|
||||
// Toggle stats visibility
|
||||
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
|
||||
document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
|
||||
document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none';
|
||||
document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
|
||||
document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
// Toggle stats visibility via class
|
||||
document.getElementById('pagerStats')?.classList.toggle('active', mode === 'pager');
|
||||
document.getElementById('sensorStats')?.classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('aircraftStats')?.classList.toggle('active', mode === 'aircraft');
|
||||
document.getElementById('satelliteStats')?.classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('wifiStats')?.classList.toggle('active', mode === 'wifi');
|
||||
|
||||
// Hide signal meter - individual panels show signal strength where needed
|
||||
document.getElementById('signalMeter').style.display = 'none';
|
||||
// Hide signal meter
|
||||
document.getElementById('signalMeter')?.classList.remove('active');
|
||||
|
||||
// Show/hide dashboard buttons in nav bar
|
||||
document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none';
|
||||
document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none';
|
||||
document.getElementById('adsbDashboardBtn')?.classList.toggle('active', mode === 'aircraft');
|
||||
document.getElementById('satelliteDashboardBtn')?.classList.toggle('active', mode === 'satellite');
|
||||
|
||||
// Update active mode indicator
|
||||
const modeNames = {
|
||||
@@ -156,14 +156,14 @@ function switchMode(mode) {
|
||||
window.closeMobileDrawer();
|
||||
}
|
||||
|
||||
// Toggle layout containers
|
||||
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
// Toggle layout containers via class
|
||||
document.getElementById('wifiLayoutContainer')?.classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('btLayoutContainer')?.classList.toggle('active', mode === 'bluetooth');
|
||||
|
||||
// Respect the "Show Radar Display" checkbox for aircraft mode
|
||||
const showRadar = document.getElementById('adsbEnableMap')?.checked;
|
||||
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
|
||||
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
document.getElementById('aircraftVisuals')?.classList.toggle('active', mode === 'aircraft' && showRadar);
|
||||
document.getElementById('satelliteVisuals')?.classList.toggle('active', mode === 'satellite');
|
||||
|
||||
// Update output panel title based on mode
|
||||
const titles = {
|
||||
@@ -178,35 +178,30 @@ function switchMode(mode) {
|
||||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||||
|
||||
// Show/hide Device Intelligence for modes that use it
|
||||
const hideRecon = (mode === 'satellite' || mode === 'aircraft');
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
if (mode === 'satellite' || mode === 'aircraft') {
|
||||
document.getElementById('reconPanel').style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
} else {
|
||||
if (reconBtn) reconBtn.style.display = 'inline-block';
|
||||
if (intelBtn) intelBtn.style.display = 'inline-block';
|
||||
if (typeof reconEnabled !== 'undefined' && reconEnabled) {
|
||||
document.getElementById('reconPanel').style.display = 'block';
|
||||
}
|
||||
}
|
||||
document.getElementById('reconPanel')?.classList.toggle('active', !hideRecon && typeof reconEnabled !== 'undefined' && reconEnabled);
|
||||
if (reconBtn) reconBtn.classList.toggle('hidden', hideRecon);
|
||||
if (intelBtn) intelBtn.classList.toggle('hidden', hideRecon);
|
||||
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
document.getElementById('rtlDeviceSection').style.display =
|
||||
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft') ? 'block' : 'none';
|
||||
const showRtl = (mode === 'pager' || mode === 'sensor' || mode === 'aircraft');
|
||||
document.getElementById('rtlDeviceSection')?.classList.toggle('active', showRtl);
|
||||
|
||||
// Toggle mode-specific tool status displays
|
||||
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
|
||||
document.getElementById('toolStatusSensor').style.display = (mode === 'sensor') ? 'grid' : 'none';
|
||||
document.getElementById('toolStatusAircraft').style.display = (mode === 'aircraft') ? 'grid' : 'none';
|
||||
document.getElementById('toolStatusPager')?.classList.toggle('active', mode === 'pager');
|
||||
document.getElementById('toolStatusSensor')?.classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('toolStatusAircraft')?.classList.toggle('active', mode === 'aircraft');
|
||||
|
||||
// Hide waterfall and output console for modes with their own visualizations
|
||||
document.querySelector('.waterfall-container').style.display =
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
document.getElementById('output').style.display =
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
|
||||
const fullVisualModes = ['satellite', 'aircraft', 'wifi', 'bluetooth', 'meshtastic', 'aprs', 'tscm', 'spystations'];
|
||||
const hideConsole = fullVisualModes.includes(mode);
|
||||
document.querySelector('.waterfall-container')?.classList.toggle('active', !hideConsole);
|
||||
document.getElementById('output')?.classList.toggle('active', !hideConsole);
|
||||
|
||||
const hideStatusBar = ['satellite', 'tscm', 'meshtastic', 'aprs', 'spystations'].includes(mode);
|
||||
document.querySelector('.status-bar')?.classList.toggle('active', !hideStatusBar);
|
||||
|
||||
// Load interfaces and initialize visualizations when switching modes
|
||||
if (mode === 'wifi') {
|
||||
|
||||
245
static/js/core/sse-manager.js
Normal file
245
static/js/core/sse-manager.js
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* SSEManager - Centralized Server-Sent Events connection manager
|
||||
* Handles connection lifecycle, reconnection with exponential backoff,
|
||||
* visibility-based pause/resume, and state change notifications.
|
||||
*/
|
||||
const SSEManager = (function() {
|
||||
'use strict';
|
||||
|
||||
const STATES = {
|
||||
CONNECTING: 'connecting',
|
||||
OPEN: 'open',
|
||||
RECONNECTING: 'reconnecting',
|
||||
CLOSED: 'closed',
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
const BACKOFF_INITIAL = 1000;
|
||||
const BACKOFF_MAX = 30000;
|
||||
const BACKOFF_MULTIPLIER = 2;
|
||||
|
||||
/** @type {Map<string, ConnectionEntry>} */
|
||||
const connections = new Map();
|
||||
|
||||
/**
|
||||
* @typedef {Object} ConnectionEntry
|
||||
* @property {string} key
|
||||
* @property {string} url
|
||||
* @property {EventSource|null} source
|
||||
* @property {string} state
|
||||
* @property {number} backoff
|
||||
* @property {number|null} retryTimer
|
||||
* @property {boolean} intentionallyClosed
|
||||
* @property {Function|null} onMessage
|
||||
* @property {Function|null} onStateChange
|
||||
*/
|
||||
|
||||
function connect(key, url, options) {
|
||||
const opts = options || {};
|
||||
|
||||
// Disconnect existing connection for this key
|
||||
if (connections.has(key)) {
|
||||
disconnect(key);
|
||||
}
|
||||
|
||||
const entry = {
|
||||
key: key,
|
||||
url: url,
|
||||
source: null,
|
||||
state: STATES.CLOSED,
|
||||
backoff: BACKOFF_INITIAL,
|
||||
retryTimer: null,
|
||||
intentionallyClosed: false,
|
||||
onMessage: typeof opts.onMessage === 'function' ? opts.onMessage : null,
|
||||
onStateChange: typeof opts.onStateChange === 'function' ? opts.onStateChange : null,
|
||||
};
|
||||
|
||||
connections.set(key, entry);
|
||||
openConnection(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function openConnection(entry) {
|
||||
if (entry.intentionallyClosed) return;
|
||||
|
||||
setState(entry, entry.state === STATES.CLOSED ? STATES.CONNECTING : STATES.RECONNECTING);
|
||||
|
||||
try {
|
||||
const source = new EventSource(entry.url);
|
||||
entry.source = source;
|
||||
|
||||
source.onopen = function() {
|
||||
entry.backoff = BACKOFF_INITIAL;
|
||||
setState(entry, STATES.OPEN);
|
||||
};
|
||||
|
||||
source.onmessage = function(event) {
|
||||
if (entry.onMessage) {
|
||||
try {
|
||||
entry.onMessage(event);
|
||||
} catch (err) {
|
||||
console.debug('[SSEManager] onMessage error for ' + entry.key + ':', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
source.onerror = function() {
|
||||
// EventSource fires error on close and connection loss
|
||||
if (entry.intentionallyClosed) return;
|
||||
|
||||
closeSource(entry);
|
||||
setState(entry, STATES.ERROR);
|
||||
scheduleReconnect(entry);
|
||||
};
|
||||
} catch (err) {
|
||||
setState(entry, STATES.ERROR);
|
||||
scheduleReconnect(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function closeSource(entry) {
|
||||
if (entry.source) {
|
||||
entry.source.onopen = null;
|
||||
entry.source.onmessage = null;
|
||||
entry.source.onerror = null;
|
||||
try { entry.source.close(); } catch (e) { /* ignore */ }
|
||||
entry.source = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(entry) {
|
||||
if (entry.intentionallyClosed) return;
|
||||
if (entry.retryTimer) return;
|
||||
|
||||
// Pause reconnection when tab is hidden
|
||||
if (document.hidden) {
|
||||
setState(entry, STATES.RECONNECTING);
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = entry.backoff;
|
||||
entry.backoff = Math.min(entry.backoff * BACKOFF_MULTIPLIER, BACKOFF_MAX);
|
||||
|
||||
setState(entry, STATES.RECONNECTING);
|
||||
|
||||
entry.retryTimer = window.setTimeout(function() {
|
||||
entry.retryTimer = null;
|
||||
if (!entry.intentionallyClosed) {
|
||||
openConnection(entry);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function disconnect(key) {
|
||||
const entry = connections.get(key);
|
||||
if (!entry) return;
|
||||
|
||||
entry.intentionallyClosed = true;
|
||||
|
||||
if (entry.retryTimer) {
|
||||
clearTimeout(entry.retryTimer);
|
||||
entry.retryTimer = null;
|
||||
}
|
||||
|
||||
closeSource(entry);
|
||||
setState(entry, STATES.CLOSED);
|
||||
connections.delete(key);
|
||||
}
|
||||
|
||||
function disconnectAll() {
|
||||
for (const key of Array.from(connections.keys())) {
|
||||
disconnect(key);
|
||||
}
|
||||
}
|
||||
|
||||
function getState(key) {
|
||||
const entry = connections.get(key);
|
||||
return entry ? entry.state : STATES.CLOSED;
|
||||
}
|
||||
|
||||
function getActiveKeys() {
|
||||
const keys = [];
|
||||
connections.forEach(function(entry, key) {
|
||||
if (entry.state === STATES.OPEN) {
|
||||
keys.push(key);
|
||||
}
|
||||
});
|
||||
return keys;
|
||||
}
|
||||
|
||||
function setState(entry, newState) {
|
||||
if (entry.state === newState) return;
|
||||
const oldState = entry.state;
|
||||
entry.state = newState;
|
||||
|
||||
if (entry.onStateChange) {
|
||||
try {
|
||||
entry.onStateChange(newState, oldState, entry.key);
|
||||
} catch (err) {
|
||||
console.debug('[SSEManager] onStateChange error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Update global indicator
|
||||
updateGlobalIndicator();
|
||||
}
|
||||
|
||||
// --- Global SSE Status Indicator ---
|
||||
|
||||
function updateGlobalIndicator() {
|
||||
const dot = document.getElementById('sseStatusDot');
|
||||
if (!dot) return;
|
||||
|
||||
let hasOpen = false;
|
||||
let hasReconnecting = false;
|
||||
let hasError = false;
|
||||
|
||||
connections.forEach(function(entry) {
|
||||
if (entry.state === STATES.OPEN) hasOpen = true;
|
||||
else if (entry.state === STATES.RECONNECTING || entry.state === STATES.CONNECTING) hasReconnecting = true;
|
||||
else if (entry.state === STATES.ERROR) hasError = true;
|
||||
});
|
||||
|
||||
// Remove all state classes
|
||||
dot.classList.remove('online', 'warning', 'error', 'inactive');
|
||||
|
||||
if (connections.size === 0) {
|
||||
dot.classList.add('inactive');
|
||||
dot.setAttribute('data-tooltip', 'No active streams');
|
||||
} else if (hasError && !hasOpen) {
|
||||
dot.classList.add('error');
|
||||
dot.setAttribute('data-tooltip', 'Stream connection error');
|
||||
} else if (hasReconnecting) {
|
||||
dot.classList.add('warning');
|
||||
dot.setAttribute('data-tooltip', 'Reconnecting...');
|
||||
} else if (hasOpen) {
|
||||
dot.classList.add('online');
|
||||
dot.setAttribute('data-tooltip', 'Streams connected');
|
||||
} else {
|
||||
dot.classList.add('inactive');
|
||||
dot.setAttribute('data-tooltip', 'Streams idle');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Visibility API: pause/resume reconnection ---
|
||||
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document.hidden) return;
|
||||
|
||||
// Tab became visible — reconnect any entries that were waiting
|
||||
connections.forEach(function(entry) {
|
||||
if (!entry.intentionallyClosed && !entry.source && !entry.retryTimer) {
|
||||
openConnection(entry);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
STATES: STATES,
|
||||
connect: connect,
|
||||
disconnect: disconnect,
|
||||
disconnectAll: disconnectAll,
|
||||
getState: getState,
|
||||
getActiveKeys: getActiveKeys,
|
||||
};
|
||||
})();
|
||||
@@ -3,6 +3,7 @@ const AppFeedback = (function() {
|
||||
|
||||
let stackEl = null;
|
||||
let nextToastId = 1;
|
||||
const TOAST_MAX = 5;
|
||||
|
||||
function init() {
|
||||
ensureStack();
|
||||
@@ -17,6 +18,8 @@ const AppFeedback = (function() {
|
||||
stackEl = document.createElement('div');
|
||||
stackEl.id = 'appToastStack';
|
||||
stackEl.className = 'app-toast-stack';
|
||||
stackEl.setAttribute('aria-live', 'assertive');
|
||||
stackEl.setAttribute('role', 'alert');
|
||||
document.body.appendChild(stackEl);
|
||||
}
|
||||
return stackEl;
|
||||
@@ -64,7 +67,14 @@ const AppFeedback = (function() {
|
||||
root.appendChild(actionsEl);
|
||||
}
|
||||
|
||||
ensureStack().appendChild(root);
|
||||
const stack = ensureStack();
|
||||
|
||||
// Enforce toast cap — remove oldest when exceeded
|
||||
while (stack.children.length >= TOAST_MAX) {
|
||||
stack.removeChild(stack.firstChild);
|
||||
}
|
||||
|
||||
stack.appendChild(root);
|
||||
|
||||
if (durationMs > 0) {
|
||||
window.setTimeout(() => {
|
||||
@@ -240,6 +250,151 @@ const AppFeedback = (function() {
|
||||
return text.includes('permission') || text.includes('denied') || text.includes('dependency') || text.includes('tool');
|
||||
}
|
||||
|
||||
// --- Button loading state ---
|
||||
|
||||
function withLoadingButton(btn, asyncFn) {
|
||||
if (!btn || btn.disabled) return Promise.resolve();
|
||||
|
||||
const originalText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.classList.add('btn-loading');
|
||||
|
||||
return Promise.resolve()
|
||||
.then(function() { return asyncFn(); })
|
||||
.then(function(result) {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('btn-loading');
|
||||
btn.textContent = originalText;
|
||||
return result;
|
||||
})
|
||||
.catch(function(err) {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('btn-loading');
|
||||
btn.textContent = originalText;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Confirmation modal ---
|
||||
|
||||
function confirmAction(options) {
|
||||
var opts = options || {};
|
||||
var title = opts.title || 'Confirm Action';
|
||||
var message = opts.message || 'Are you sure?';
|
||||
var confirmLabel = opts.confirmLabel || 'Confirm';
|
||||
var confirmClass = opts.confirmClass || 'btn-danger';
|
||||
|
||||
return new Promise(function(resolve) {
|
||||
// Create backdrop
|
||||
var backdrop = document.createElement('div');
|
||||
backdrop.className = 'confirm-modal-backdrop';
|
||||
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'confirm-modal';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.setAttribute('aria-labelledby', 'confirm-modal-title');
|
||||
|
||||
var titleEl = document.createElement('div');
|
||||
titleEl.className = 'confirm-modal-title';
|
||||
titleEl.id = 'confirm-modal-title';
|
||||
titleEl.textContent = title;
|
||||
modal.appendChild(titleEl);
|
||||
|
||||
var msgEl = document.createElement('div');
|
||||
msgEl.className = 'confirm-modal-message';
|
||||
msgEl.textContent = message;
|
||||
modal.appendChild(msgEl);
|
||||
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'confirm-modal-actions';
|
||||
|
||||
var cancelBtn = document.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.className = 'btn btn-ghost';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
|
||||
var confirmBtn = document.createElement('button');
|
||||
confirmBtn.type = 'button';
|
||||
confirmBtn.className = 'btn ' + confirmClass;
|
||||
confirmBtn.textContent = confirmLabel;
|
||||
|
||||
actions.appendChild(cancelBtn);
|
||||
actions.appendChild(confirmBtn);
|
||||
modal.appendChild(actions);
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
// Focus confirm button
|
||||
confirmBtn.focus();
|
||||
|
||||
function cleanup(result) {
|
||||
backdrop.remove();
|
||||
document.removeEventListener('keydown', onKey);
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') cleanup(false);
|
||||
if (e.key === 'Enter') cleanup(true);
|
||||
}
|
||||
|
||||
cancelBtn.addEventListener('click', function() { cleanup(false); });
|
||||
confirmBtn.addEventListener('click', function() { cleanup(true); });
|
||||
backdrop.addEventListener('click', function(e) {
|
||||
if (e.target === backdrop) cleanup(false);
|
||||
});
|
||||
document.addEventListener('keydown', onKey);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Keyboard navigation for lists ---
|
||||
|
||||
function enableListKeyNav(container, itemSelector) {
|
||||
if (!container) return;
|
||||
|
||||
container.setAttribute('role', 'listbox');
|
||||
container.setAttribute('tabindex', '0');
|
||||
|
||||
container.addEventListener('keydown', function(e) {
|
||||
var items = container.querySelectorAll(itemSelector);
|
||||
if (!items.length) return;
|
||||
|
||||
var current = container.querySelector(itemSelector + '[aria-selected="true"]');
|
||||
var idx = current ? Array.prototype.indexOf.call(items, current) : -1;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
var next = Math.min(idx + 1, items.length - 1);
|
||||
selectItem(items, next);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
var prev = Math.max(idx - 1, 0);
|
||||
selectItem(items, prev);
|
||||
} else if (e.key === 'Enter' && current) {
|
||||
e.preventDefault();
|
||||
current.click();
|
||||
} else if (e.key === 'Escape' && current) {
|
||||
e.preventDefault();
|
||||
current.setAttribute('aria-selected', 'false');
|
||||
current.classList.remove('keyboard-focused');
|
||||
}
|
||||
});
|
||||
|
||||
function selectItem(items, index) {
|
||||
items.forEach(function(item) {
|
||||
item.setAttribute('aria-selected', 'false');
|
||||
item.classList.remove('keyboard-focused');
|
||||
});
|
||||
var target = items[index];
|
||||
if (target) {
|
||||
target.setAttribute('aria-selected', 'true');
|
||||
target.classList.add('keyboard-focused');
|
||||
target.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
toast,
|
||||
@@ -249,6 +404,9 @@ const AppFeedback = (function() {
|
||||
isOffline,
|
||||
isTransientNetworkError,
|
||||
isTransientOrOffline,
|
||||
withLoadingButton,
|
||||
confirmAction,
|
||||
enableListKeyNav,
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user