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

@@ -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);
}