Accessibility pass: ARIA roles, label associations, CSS class migrations

- Add role=dialog/aria-modal/aria-labelledby to all 12 modal overlays (JS + PHP)
- Add aria-label="Close" to all 14 modal close buttons
- Add full ARIA combobox pattern to @mention autocomplete (listbox, option, aria-selected, aria-expanded)
- Add for= attributes to admin filter form labels (AuditLog, UserActivity, ApiKeys)
- Remove dead closeOnAdvancedSearchBackdropClick() from advanced-search.js

CSS/JS style cleanup:
- Move .ascii-banner static styles from JS inline to CSS class; add .ascii-banner--glow
- Add .ascii-banner-cursor, .loading-overlay--hiding, .has-overlay, tr[data-clickable]
- Add .animate-fadein/.animate-fadeout/.comment--deleting to ticket.css
- Add .lt-toast--hiding to base.css; remove opacity/transition inline JS
- Remove redundant cursor:pointer JS (already in th{} CSS rule)
- Remove trailing space in lt-select class attributes

Bug fixes:
- base.js: boot overlay opacity inline style was overriding .fade-out class opacity via
  specificity (1000 vs 20), preventing the fade-out animation — removed
- ascii-banner.js: cursor used blink-caret (border-color only) instead of blink-cursor
  (opacity-based), so the █ cursor never actually blinked — fixed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 20:29:58 -04:00
parent 11f75fd823
commit 7695c6134c
21 changed files with 929 additions and 610 deletions

View File

@@ -19,14 +19,6 @@ function closeAdvancedSearch() {
lt.modal.close('advancedSearchModal');
}
// Close modal when clicking on backdrop
function closeOnAdvancedSearchBackdropClick(event) {
const modal = document.getElementById('advancedSearchModal');
if (event.target === modal) {
closeAdvancedSearch();
}
}
// Load users for dropdown
async function loadUsersForSearch() {
try {

View File

@@ -65,20 +65,8 @@ function renderASCIIBanner(bannerId, containerSelector, speed = 5, addGlow = tru
// Create pre element for ASCII art
const pre = document.createElement('pre');
pre.className = 'ascii-banner';
pre.style.margin = '0';
pre.style.fontFamily = 'var(--font-mono)';
pre.style.color = 'var(--terminal-green)';
if (addGlow) {
pre.style.textShadow = 'var(--glow-green)';
}
pre.className = addGlow ? 'ascii-banner ascii-banner--glow' : 'ascii-banner';
pre.style.fontSize = getBannerFontSize(bannerId);
pre.style.lineHeight = '1.2';
pre.style.whiteSpace = 'pre';
pre.style.overflow = 'visible';
pre.style.textAlign = 'center';
container.appendChild(pre);
@@ -178,8 +166,7 @@ function animatedWelcome(containerSelector) {
banner.addEventListener('bannerComplete', () => {
const cursor = document.createElement('span');
cursor.textContent = '█';
cursor.style.animation = 'blink-caret 0.75s step-end infinite';
cursor.style.marginLeft = '5px';
cursor.className = 'ascii-banner-cursor';
banner.appendChild(cursor);
});
}

View File

@@ -124,8 +124,7 @@
function _dismissToast(toast) {
if (!toast || !toast.parentNode) return;
clearTimeout(toast._lt_timer);
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s ease';
toast.classList.add('lt-toast--hiding');
setTimeout(() => {
if (toast.parentNode) toast.parentNode.removeChild(toast);
_toastActive = false;
@@ -176,11 +175,11 @@
lt.modal.closeAll();
HTML contract:
<div id="my-modal-id" class="lt-modal-overlay">
<div id="my-modal-id" class="lt-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="myModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title">Title</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<span class="lt-modal-title" id="myModalTitle">Title</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">…</div>
<div class="lt-modal-footer">…</div>
@@ -295,7 +294,6 @@
if (!overlay || !pre) return;
overlay.style.display = 'flex';
overlay.style.opacity = '1';
const name = (appName || 'TERMINAL').toUpperCase();
const titleStr = name + ' v1.0';
@@ -653,7 +651,6 @@
const ths = Array.from(table.querySelectorAll('th[data-sort-key]'));
ths.forEach((th, colIdx) => {
th.style.cursor = 'pointer';
let dir = 'asc';
th.addEventListener('click', () => {

View File

@@ -261,7 +261,6 @@ function clearAllFilters() {
function initTableSorting() {
const tableHeaders = document.querySelectorAll('th');
tableHeaders.forEach((header, index) => {
header.style.cursor = 'pointer';
header.addEventListener('click', () => {
const table = header.closest('table');
sortTable(table, index);
@@ -764,15 +763,15 @@ function showBulkAssignModal() {
// Create modal HTML
const modalHtml = `
<div class="lt-modal-overlay" id="bulkAssignModal" aria-hidden="true">
<div class="lt-modal-overlay" id="bulkAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkAssignModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title">Assign ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<span class="lt-modal-title" id="bulkAssignModalTitle">Assign ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<label for="bulkAssignUser">Assign to:</label>
<select id="bulkAssignUser" class="lt-select" style="width:100%;margin-top:0.5rem;">
<select id="bulkAssignUser" class="lt-select">
<option value="">Select User...</option>
</select>
</div>
@@ -852,15 +851,15 @@ function showBulkPriorityModal() {
}
const modalHtml = `
<div class="lt-modal-overlay" id="bulkPriorityModal" aria-hidden="true">
<div class="lt-modal-overlay" id="bulkPriorityModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkPriorityModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title">Change Priority for ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<span class="lt-modal-title" id="bulkPriorityModalTitle">Change Priority for ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<label for="bulkPriority">Priority:</label>
<select id="bulkPriority" class="lt-select" style="width:100%;margin-top:0.5rem;">
<select id="bulkPriority" class="lt-select">
<option value="">Select Priority...</option>
<option value="1">P1 - Critical Impact</option>
<option value="2">P2 - High Impact</option>
@@ -927,7 +926,6 @@ document.addEventListener('DOMContentLoaded', function() {
if (row.dataset.clickable) return;
row.dataset.clickable = 'true';
row.style.cursor = 'pointer';
row.addEventListener('click', function(e) {
// Don't navigate if clicking on a link, button, checkbox, or select
@@ -960,15 +958,15 @@ function showBulkStatusModal() {
}
const modalHtml = `
<div class="lt-modal-overlay" id="bulkStatusModal" aria-hidden="true">
<div class="lt-modal-overlay" id="bulkStatusModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkStatusModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title">Change Status for ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<span class="lt-modal-title" id="bulkStatusModalTitle">Change Status for ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<label for="bulkStatus">New Status:</label>
<select id="bulkStatus" class="lt-select" style="width:100%;margin-top:0.5rem;">
<select id="bulkStatus" class="lt-select">
<option value="">Select Status...</option>
<option value="Open">Open</option>
<option value="Pending">Pending</option>
@@ -1036,18 +1034,18 @@ function showBulkDeleteModal() {
}
const modalHtml = `
<div class="lt-modal-overlay" id="bulkDeleteModal" aria-hidden="true">
<div class="lt-modal">
<div class="lt-modal-header" style="color: var(--status-closed);">
<span class="lt-modal-title">[ ! ] DELETE ${ticketIds.length} TICKET(S)</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<div class="lt-modal-overlay" id="bulkDeleteModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkDeleteModalTitle">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header lt-modal-header--danger">
<span class="lt-modal-title" id="bulkDeleteModalTitle">[ ! ] DELETE ${ticketIds.length} TICKET(S)</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body" style="text-align:center;">
<p style="color: var(--terminal-amber); font-size: 1.1rem; margin-bottom: 1rem;">This action cannot be undone!</p>
<p style="color: var(--terminal-green);">You are about to permanently delete ${ticketIds.length} ticket(s).<br>All associated comments and history will be lost.</p>
<div class="lt-modal-body text-center">
<p class="modal-warning-text">This action cannot be undone!</p>
<p class="text-green">You are about to permanently delete ${ticketIds.length} ticket(s).<br>All associated comments and history will be lost.</p>
</div>
<div class="lt-modal-footer">
<button data-action="perform-bulk-delete" class="lt-btn lt-btn-primary" style="background: var(--status-closed); border-color: var(--status-closed);">DELETE PERMANENTLY</button>
<button data-action="perform-bulk-delete" class="lt-btn lt-btn-danger">DELETE PERMANENTLY</button>
<button data-action="close-bulk-delete-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
</div>
</div>
@@ -1121,14 +1119,14 @@ function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel
const safeMessage = lt.escHtml(message);
const modalHtml = `
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true">
<div class="lt-modal" style="max-width: 500px;">
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header" style="color: ${color};">
<span class="lt-modal-title">${icon} ${safeTitle}</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body" style="text-align: center;">
<p style="color: var(--terminal-green); white-space: pre-line;">${safeMessage}</p>
<div class="lt-modal-body text-center">
<p class="modal-message">${safeMessage}</p>
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
@@ -1172,15 +1170,15 @@ function showInputModal(title, label, placeholder = '', onSubmit, onCancel = nul
const safePlaceholder = lt.escHtml(placeholder);
const modalHtml = `
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true">
<div class="lt-modal" style="max-width: 500px;">
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header">
<span class="lt-modal-title">${safeTitle}</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<span class="lt-modal-title" id="${modalId}_title">${safeTitle}</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<label for="${inputId}" style="display: block; margin-bottom: 0.5rem; color: var(--terminal-green);">${safeLabel}</label>
<input type="text" id="${inputId}" class="lt-input" placeholder="${safePlaceholder}" style="width: 100%;" />
<label for="${inputId}">${safeLabel}</label>
<input type="text" id="${inputId}" class="lt-input" placeholder="${safePlaceholder}" />
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" id="${modalId}_submit">SAVE</button>
@@ -1224,17 +1222,17 @@ function quickStatusChange(ticketId, currentStatus) {
const otherStatuses = statuses.filter(s => s !== currentStatus);
const modalHtml = `
<div class="lt-modal-overlay" id="quickStatusModal" aria-hidden="true">
<div class="lt-modal" style="max-width:400px;">
<div class="lt-modal-overlay" id="quickStatusModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="quickStatusModalTitle">
<div class="lt-modal lt-modal-xs">
<div class="lt-modal-header">
<span class="lt-modal-title">Quick Status Change</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<span class="lt-modal-title" id="quickStatusModalTitle">Quick Status Change</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<p style="margin-bottom:0.5rem;">Ticket #${lt.escHtml(ticketId)}</p>
<p style="margin-bottom:0.5rem;color:var(--terminal-amber);">Current: ${lt.escHtml(currentStatus)}</p>
<p class="mb-half">Ticket #${lt.escHtml(ticketId)}</p>
<p class="text-amber mb-half">Current: ${lt.escHtml(currentStatus)}</p>
<label for="quickStatusSelect">New Status:</label>
<select id="quickStatusSelect" class="lt-select" style="width:100%;margin-top:0.5rem;">
<select id="quickStatusSelect" class="lt-select">
${otherStatuses.map(s => `<option value="${s}">${s}</option>`).join('')}
</select>
</div>
@@ -1280,16 +1278,16 @@ function performQuickStatusChange(ticketId) {
*/
function quickAssign(ticketId) {
const modalHtml = `
<div class="lt-modal-overlay" id="quickAssignModal" aria-hidden="true">
<div class="lt-modal" style="max-width:400px;">
<div class="lt-modal-overlay" id="quickAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="quickAssignModalTitle">
<div class="lt-modal lt-modal-xs">
<div class="lt-modal-header">
<span class="lt-modal-title">Quick Assign</span>
<button class="lt-modal-close" data-modal-close>✕</button>
<span class="lt-modal-title" id="quickAssignModalTitle">Quick Assign</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<p style="margin-bottom:0.5rem;">Ticket #${lt.escHtml(ticketId)}</p>
<p class="mb-half">Ticket #${lt.escHtml(ticketId)}</p>
<label for="quickAssignSelect">Assign to:</label>
<select id="quickAssignSelect" class="lt-select" style="width:100%;margin-top:0.5rem;">
<select id="quickAssignSelect" class="lt-select">
<option value="">Unassigned</option>
</select>
</div>
@@ -1697,7 +1695,7 @@ function showLoadingOverlay(element, message = 'Loading...') {
<div class="loading-spinner"></div>
<div class="loading-text">${message}</div>
`;
element.style.position = 'relative';
element.classList.add('has-overlay');
element.appendChild(overlay);
}
@@ -1707,9 +1705,11 @@ function showLoadingOverlay(element, message = 'Loading...') {
function hideLoadingOverlay(element) {
const overlay = element.querySelector('.loading-overlay');
if (overlay) {
overlay.style.opacity = '0';
overlay.style.transition = 'opacity 0.3s';
setTimeout(() => overlay.remove(), 300);
overlay.classList.add('loading-overlay--hiding');
setTimeout(() => {
overlay.remove();
element.classList.remove('has-overlay');
}, 300);
}
}

View File

@@ -33,43 +33,46 @@ function showKeyboardHelp() {
modal.id = 'keyboardHelpModal';
modal.className = 'lt-modal-overlay';
modal.setAttribute('aria-hidden', 'true');
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-labelledby', 'keyboardHelpModalTitle');
modal.innerHTML = `
<div class="lt-modal" style="max-width: 500px;">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header">
<span class="lt-modal-title">KEYBOARD SHORTCUTS</span>
<span class="lt-modal-title" id="keyboardHelpModalTitle">KEYBOARD SHORTCUTS</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<h4 style="color: var(--terminal-amber); margin: 0 0 0.5rem 0; font-size: 0.9rem;">Navigation</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>J</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Next ticket in list</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Previous ticket in list</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Enter</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Open selected ticket</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>G</kbd> then <kbd>D</kbd></td><td style="padding: 0.4rem;">Go to Dashboard</td></tr>
<h4 class="kb-section-heading">Navigation</h4>
<table class="kb-shortcuts-table">
<tr><td><kbd>J</kbd></td><td>Next ticket in list</td></tr>
<tr><td><kbd>K</kbd></td><td>Previous ticket in list</td></tr>
<tr><td><kbd>Enter</kbd></td><td>Open selected ticket</td></tr>
<tr><td><kbd>G</kbd> then <kbd>D</kbd></td><td>Go to Dashboard</td></tr>
</table>
<h4 style="color: var(--terminal-amber); margin: 0 0 0.5rem 0; font-size: 0.9rem;">Actions</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>N</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">New ticket</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>C</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus comment box</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd+E</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Toggle Edit Mode</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>Ctrl/Cmd+S</kbd></td><td style="padding: 0.4rem;">Save Changes</td></tr>
<h4 class="kb-section-heading">Actions</h4>
<table class="kb-shortcuts-table">
<tr><td><kbd>N</kbd></td><td>New ticket</td></tr>
<tr><td><kbd>C</kbd></td><td>Focus comment box</td></tr>
<tr><td><kbd>Ctrl/Cmd+E</kbd></td><td>Toggle Edit Mode</td></tr>
<tr><td><kbd>Ctrl/Cmd+S</kbd></td><td>Save Changes</td></tr>
</table>
<h4 style="color: var(--terminal-amber); margin: 0 0 0.5rem 0; font-size: 0.9rem;">Quick Status (Ticket Page)</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>1</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Open</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>2</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Pending</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>3</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set In Progress</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>4</kbd></td><td style="padding: 0.4rem;">Set Closed</td></tr>
<h4 class="kb-section-heading">Quick Status (Ticket Page)</h4>
<table class="kb-shortcuts-table">
<tr><td><kbd>1</kbd></td><td>Set Open</td></tr>
<tr><td><kbd>2</kbd></td><td>Set Pending</td></tr>
<tr><td><kbd>3</kbd></td><td>Set In Progress</td></tr>
<tr><td><kbd>4</kbd></td><td>Set Closed</td></tr>
</table>
<h4 style="color: var(--terminal-amber); margin: 0 0 0.5rem 0; font-size: 0.9rem;">Other</h4>
<table style="width: 100%; border-collapse: collapse;">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd+K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus Search</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>ESC</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Close Modal / Cancel</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>?</kbd></td><td style="padding: 0.4rem;">Show This Help</td></tr>
<h4 class="kb-section-heading">Other</h4>
<table class="kb-shortcuts-table no-margin">
<tr><td><kbd>Ctrl/Cmd+K</kbd></td><td>Focus Search</td></tr>
<tr><td><kbd>ESC</kbd></td><td>Close Modal / Cancel</td></tr>
<tr><td><kbd>?</kbd></td><td>Show This Help</td></tr>
</table>
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-ghost" data-modal-close>Close</button>
<button class="lt-btn lt-btn-ghost" data-modal-close>CLOSE</button>
</div>
</div>
`;

View File

@@ -50,7 +50,7 @@ function saveTicket() {
// Use the correct API path
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, ...data })
.then(data => {
if(data.success) {
if (data.success) {
const statusDisplay = document.getElementById('statusDisplay');
if (statusDisplay) {
statusDisplay.className = `status-${data.status}`;
@@ -58,9 +58,11 @@ function saveTicket() {
}
lt.toast.success('Ticket updated successfully');
} else {
lt.toast.error('Error saving ticket: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
lt.toast.error('Error saving ticket: ' + error.message);
});
}
@@ -511,10 +513,10 @@ function showDependencyError(message) {
const dependentsList = document.getElementById('dependentsList');
if (dependenciesList) {
dependenciesList.innerHTML = `<p style="color: var(--terminal-amber);">${lt.escHtml(message)}</p>`;
dependenciesList.innerHTML = `<p class="text-amber">${lt.escHtml(message)}</p>`;
}
if (dependentsList) {
dependentsList.innerHTML = `<p style="color: var(--terminal-amber);">${lt.escHtml(message)}</p>`;
dependentsList.innerHTML = `<p class="text-amber">${lt.escHtml(message)}</p>`;
}
}
@@ -557,7 +559,7 @@ function renderDependencies(dependencies) {
}
if (!hasAny) {
html = '<p style="color: var(--terminal-green-dim);">No dependencies configured.</p>';
html = '<p class="text-muted-green">No dependencies configured.</p>';
}
container.innerHTML = html;
@@ -568,21 +570,21 @@ function renderDependents(dependents) {
if (!container) return;
if (dependents.length === 0) {
container.innerHTML = '<p style="color: var(--terminal-green-dim);">No tickets depend on this one.</p>';
container.innerHTML = '<p class="text-muted-green">No tickets depend on this one.</p>';
return;
}
let html = '';
dependents.forEach(dep => {
const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-');
html += `<div class="dependency-item" style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px dashed var(--terminal-green-dim);">
html += `<div class="dependency-item">
<div>
<a href="/ticket/${lt.escHtml(dep.ticket_id)}" style="color: var(--terminal-green);">
<a href="/ticket/${lt.escHtml(dep.ticket_id)}">
#${lt.escHtml(dep.ticket_id)}
</a>
<span style="margin-left: 0.5rem;">${lt.escHtml(dep.title)}</span>
<span class="status-badge ${statusClass}" style="margin-left: 0.5rem; font-size: 0.8rem;">${lt.escHtml(dep.status)}</span>
<span style="margin-left: 0.5rem; color: var(--terminal-amber);">(${lt.escHtml(dep.dependency_type)})</span>
<span class="dependency-title">${lt.escHtml(dep.title)}</span>
<span class="status-badge ${statusClass}">${lt.escHtml(dep.status)}</span>
<span class="dependency-title text-amber">(${lt.escHtml(dep.dependency_type)})</span>
</div>
</div>`;
});
@@ -623,22 +625,25 @@ function addDependency() {
}
function removeDependency(dependencyId) {
if (!confirm('Are you sure you want to remove this dependency?')) {
return;
}
lt.api.delete('/api/ticket_dependencies.php', { dependency_id: dependencyId })
.then(data => {
if (data.success) {
lt.toast.success('Dependency removed', 3000);
loadDependencies();
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
showConfirmModal(
'Remove Dependency',
'Are you sure you want to remove this dependency?',
'warning',
function() {
lt.api.delete('/api/ticket_dependencies.php', { dependency_id: dependencyId })
.then(data => {
if (data.success) {
lt.toast.success('Dependency removed', 3000);
loadDependencies();
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
lt.toast.error('Error removing dependency', 4000);
});
}
})
.catch(error => {
lt.toast.error('Error removing dependency', 4000);
});
);
}
// ========================================
@@ -789,11 +794,11 @@ function loadAttachments() {
if (data.success) {
renderAttachments(data.attachments || []);
} else {
container.innerHTML = '<p style="color: var(--terminal-green-dim);">Error loading attachments.</p>';
container.innerHTML = '<p class="text-muted-green">Error loading attachments.</p>';
}
})
.catch(error => {
container.innerHTML = '<p style="color: var(--terminal-green-dim);">Error loading attachments.</p>';
container.innerHTML = '<p class="text-muted-green">Error loading attachments.</p>';
});
}
@@ -802,7 +807,7 @@ function renderAttachments(attachments) {
if (!container) return;
if (attachments.length === 0) {
container.innerHTML = '<p style="color: var(--terminal-green-dim);">No files attached to this ticket.</p>';
container.innerHTML = '<p class="text-muted-green">No files attached to this ticket.</p>';
return;
}
@@ -823,7 +828,7 @@ function renderAttachments(attachments) {
<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>
<div class="attachment-info">
<div class="attachment-name" title="${lt.escHtml(att.original_filename)}">
<a href="/api/download_attachment.php?id=${att.attachment_id}" target="_blank" style="color: var(--terminal-green);">
<a href="/api/download_attachment.php?id=${att.attachment_id}" target="_blank">
${lt.escHtml(att.original_filename)}
</a>
</div>
@@ -855,22 +860,25 @@ function formatFileSize(bytes) {
}
function deleteAttachment(attachmentId) {
if (!confirm('Are you sure you want to delete this attachment?')) {
return;
}
lt.api.post('/api/delete_attachment.php', { attachment_id: attachmentId })
.then(data => {
if (data.success) {
lt.toast.success('Attachment deleted', 3000);
loadAttachments();
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
showConfirmModal(
'Delete Attachment',
'Are you sure you want to delete this attachment?',
'warning',
function() {
lt.api.post('/api/delete_attachment.php', { attachment_id: attachmentId })
.then(data => {
if (data.success) {
lt.toast.success('Attachment deleted', 3000);
loadAttachments();
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
lt.toast.error('Error deleting attachment', 4000);
});
}
})
.catch(error => {
lt.toast.error('Error deleting attachment', 4000);
});
);
}
// ========================================
@@ -893,7 +901,12 @@ function initMentionAutocomplete() {
mentionAutocomplete = document.createElement('div');
mentionAutocomplete.className = 'mention-autocomplete';
mentionAutocomplete.id = 'mentionAutocomplete';
textarea.parentElement.style.position = 'relative';
mentionAutocomplete.setAttribute('role', 'listbox');
mentionAutocomplete.setAttribute('aria-label', 'User suggestions');
textarea.setAttribute('aria-autocomplete', 'list');
textarea.setAttribute('aria-controls', 'mentionAutocomplete');
textarea.setAttribute('aria-expanded', 'false');
textarea.parentElement.classList.add('has-overlay');
textarea.parentElement.appendChild(mentionAutocomplete);
// Fetch users list
@@ -990,7 +1003,9 @@ function handleMentionKeydown(e) {
*/
function updateMentionSelection(options) {
options.forEach((opt, i) => {
opt.classList.toggle('selected', i === selectedMentionIndex);
const isSelected = i === selectedMentionIndex;
opt.classList.toggle('selected', isSelected);
opt.setAttribute('aria-selected', isSelected ? 'true' : 'false');
});
}
@@ -1012,7 +1027,8 @@ function showMentionSuggestions(query, textarea) {
let html = '';
filtered.forEach((user, index) => {
const isSelected = index === 0 ? 'selected' : '';
html += `<div class="mention-option ${isSelected}" data-username="${lt.escHtml(user.username)}" data-action="select-mention">
const ariaSelected = index === 0 ? 'true' : 'false';
html += `<div class="mention-option ${isSelected}" role="option" aria-selected="${ariaSelected}" data-username="${lt.escHtml(user.username)}" data-action="select-mention">
<span class="mention-username">@${lt.escHtml(user.username)}</span>
${user.display_name ? `<span class="mention-displayname">${lt.escHtml(user.display_name)}</span>` : ''}
</div>`;
@@ -1020,6 +1036,8 @@ function showMentionSuggestions(query, textarea) {
mentionAutocomplete.innerHTML = html;
mentionAutocomplete.classList.add('active');
const textarea = document.getElementById('newComment');
if (textarea) textarea.setAttribute('aria-expanded', 'true');
selectedMentionIndex = 0;
// Position dropdown below cursor
@@ -1034,6 +1052,8 @@ function showMentionSuggestions(query, textarea) {
function hideMentionAutocomplete() {
if (mentionAutocomplete) {
mentionAutocomplete.classList.remove('active');
const textarea = document.getElementById('newComment');
if (textarea) textarea.setAttribute('aria-expanded', 'false');
}
mentionStartPos = -1;
}
@@ -1257,29 +1277,29 @@ function cancelEditComment(commentId) {
* Delete a comment
*/
function deleteComment(commentId) {
if (!confirm('Are you sure you want to delete this comment? This cannot be undone.')) {
return;
}
lt.api.post('/api/delete_comment.php', { comment_id: commentId })
.then(data => {
if (data.success) {
// Remove the comment from the DOM
const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`);
if (commentDiv) {
commentDiv.style.transition = 'opacity 0.3s, transform 0.3s';
commentDiv.style.opacity = '0';
commentDiv.style.transform = 'translateX(-20px)';
setTimeout(() => commentDiv.remove(), 300);
}
lt.toast.success('Comment deleted successfully');
} else {
lt.toast.error(data.error || 'Failed to delete comment');
showConfirmModal(
'Delete Comment',
'Are you sure you want to delete this comment? This cannot be undone.',
'warning',
function() {
lt.api.post('/api/delete_comment.php', { comment_id: commentId })
.then(data => {
if (data.success) {
const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`);
if (commentDiv) {
commentDiv.classList.add('comment--deleting');
setTimeout(() => commentDiv.remove(), 300);
}
lt.toast.success('Comment deleted successfully');
} else {
lt.toast.error(data.error || 'Failed to delete comment');
}
})
.catch(error => {
lt.toast.error('Failed to delete comment');
});
}
})
.catch(error => {
lt.toast.error('Failed to delete comment');
});
);
}
// ========================================
@@ -1331,7 +1351,7 @@ function showReplyForm(commentId, userName) {
*/
function closeReplyForm() {
document.querySelectorAll('.reply-form-container').forEach(form => {
form.style.animation = 'fadeIn 0.2s ease reverse';
form.classList.add('animate-fadeout');
setTimeout(() => form.remove(), 200);
});
}
@@ -1420,7 +1440,7 @@ function submitReply(parentCommentId) {
`;
// Add animation
replyDiv.style.animation = 'fadeIn 0.3s ease';
replyDiv.classList.add('animate-fadein');
repliesContainer.appendChild(replyDiv);
}

View File

@@ -10,3 +10,49 @@ function getTicketIdFromUrl() {
const params = new URLSearchParams(window.location.search);
return params.get('id');
}
/**
* Show a terminal-style confirmation modal using the lt.modal system.
* Falls back gracefully if dashboard.js has already defined this function.
* @param {string} title - Modal title
* @param {string} message - Confirmation message
* @param {string} type - 'warning' | 'error' | 'info'
* @param {Function} onConfirm - Called when user confirms
* @param {Function|null} onCancel - Called when user cancels
*/
if (typeof showConfirmModal === 'undefined') {
window.showConfirmModal = function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
const modalId = 'confirmModal' + Date.now();
const colors = { warning: 'var(--terminal-amber)', error: 'var(--status-closed)', info: 'var(--terminal-cyan)' };
const icons = { warning: '[ ! ]', error: '[ X ]', info: '[ i ]' };
const color = colors[type] || colors.warning;
const icon = icons[type] || icons.warning;
const safeTitle = lt.escHtml(title);
const safeMessage = lt.escHtml(message);
document.body.insertAdjacentHTML('beforeend', `
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header" style="color:${color};">
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body text-center">
<p class="modal-message">${safeMessage}</p>
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
</div>
</div>
</div>
`);
const modal = document.getElementById(modalId);
lt.modal.open(modalId);
const cleanup = (cb) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (cb) cb(); };
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm));
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
};
}