More ui accessibility improvements.

This commit is contained in:
Storm Dragon
2026-05-22 14:25:16 -04:00
parent aac197ef54
commit 486ce7fb4c
4 changed files with 256 additions and 153 deletions
+178 -127
View File
@@ -449,6 +449,7 @@ function setConnected(connected) {
connectionbox.classList.add('invisible');
closeNav();
setLeftPanelExpanded(true);
updateChatResizerValueFromLayout();
displayUsername();
window.onresize = function(e) {
scheduleReconsiderDownRate();
@@ -1632,55 +1633,115 @@ document.getElementById('invite-dialog').onclose = function(e) {
makeToken(template);
};
let currentActionMenu = null;
let currentActionMenuTrigger = null;
function closeActionMenu(restoreFocus) {
document.removeEventListener('click', closeActionMenuOnOutside);
if(currentActionMenu)
currentActionMenu.remove();
if(currentActionMenuTrigger)
currentActionMenuTrigger.setAttribute('aria-expanded', 'false');
if(restoreFocus && currentActionMenuTrigger)
currentActionMenuTrigger.focus();
currentActionMenu = null;
currentActionMenuTrigger = null;
}
function canModerateChat() {
return !!(serverConnection && serverConnection.permissions &&
serverConnection.permissions.indexOf('op') >= 0);
}
/**
* Adds keyboard navigation to Contextual menu
* @param {HTMLElement} menuElement
* @param {{label?: string, type?: string, onClick?: function(): void}[]} items
* @param {HTMLElement} trigger
* @param {string} label
*/
function makeContextualMenuAccessible(menuElement) {
// Find all menu items
let menuItems = menuElement.querySelectorAll('.contextualMenuItem:not(.disabled)');
if(menuItems.length === 0) return;
function openActionMenu(items, trigger, label) {
closeActionMenu(false);
let currentIndex = 0;
let menu = document.createElement('ul');
menu.classList.add('skald-action-menu');
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', label);
// Make menu items focusable and add ARIA
menuItems.forEach((item, index) => {
item.setAttribute('role', 'menuitem');
item.setAttribute('tabindex', index === 0 ? '0' : '-1');
});
// Focus first item
menuItems[0].focus();
// Add keyboard navigation
menuElement.addEventListener('keydown', function(e) {
if(e.key === 'Escape') {
e.preventDefault();
contextualCore.CloseMenu();
let buttons = [];
items.forEach(item => {
let li = document.createElement('li');
if(item.type === 'seperator') {
li.classList.add('skald-action-menu-separator');
li.setAttribute('role', 'separator');
menu.appendChild(li);
return;
}
if(e.key === 'ArrowDown') {
let button = document.createElement('button');
button.type = 'button';
button.classList.add('skald-action-menu-item');
button.setAttribute('role', 'menuitem');
button.textContent = item.label || 'Action';
button.addEventListener('click', function(e) {
e.stopPropagation();
closeActionMenu(true);
if(item.onClick)
item.onClick();
});
li.appendChild(button);
menu.appendChild(li);
buttons.push(button);
});
if(buttons.length === 0)
return;
menu.addEventListener('click', function(e) {
e.stopPropagation();
});
menu.addEventListener('keydown', function(e) {
let index = buttons.indexOf(document.activeElement);
if(e.key === 'Escape') {
e.preventDefault();
menuItems[currentIndex].setAttribute('tabindex', '-1');
currentIndex = (currentIndex + 1) % menuItems.length;
menuItems[currentIndex].setAttribute('tabindex', '0');
menuItems[currentIndex].focus();
closeActionMenu(true);
} else if(e.key === 'ArrowDown') {
e.preventDefault();
buttons[(index + 1 + buttons.length) % buttons.length].focus();
} else if(e.key === 'ArrowUp') {
e.preventDefault();
menuItems[currentIndex].setAttribute('tabindex', '-1');
currentIndex = currentIndex === 0 ? menuItems.length - 1 : currentIndex - 1;
menuItems[currentIndex].setAttribute('tabindex', '0');
menuItems[currentIndex].focus();
} else if(e.key === 'Enter' || e.key === ' ') {
buttons[(index - 1 + buttons.length) % buttons.length].focus();
} else if(e.key === 'Home') {
e.preventDefault();
// Click the parent li element which has the actual click handler
let parentLi = menuItems[currentIndex].parentElement;
if(parentLi) {
parentLi.click();
}
buttons[0].focus();
} else if(e.key === 'End') {
e.preventDefault();
buttons[buttons.length - 1].focus();
}
});
document.body.appendChild(menu);
currentActionMenu = menu;
currentActionMenuTrigger = trigger;
trigger.setAttribute('aria-haspopup', 'menu');
trigger.setAttribute('aria-expanded', 'true');
let rect = trigger.getBoundingClientRect();
let left = rect.left;
let top = rect.bottom;
if(left + menu.offsetWidth > window.innerWidth)
left = Math.max(0, window.innerWidth - menu.offsetWidth);
if(top + menu.offsetHeight > window.innerHeight)
top = Math.max(0, rect.top - menu.offsetHeight);
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
window.setTimeout(() => {
document.addEventListener('click', closeActionMenuOnOutside);
}, 0);
buttons[0].focus();
}
function closeActionMenuOnOutside() {
closeActionMenu(false);
}
/**
@@ -1740,19 +1801,7 @@ function userMenu(elt) {
}});
}
}
/** @ts-ignore */
new Contextual({
items: items,
});
// Add keyboard navigation after menu is created
setTimeout(() => {
let menu = document.querySelector('.contextualMenu');
if(menu) {
menu.setAttribute('role', 'menu');
makeContextualMenuAccessible(menu);
}
}, 0);
openActionMenu(items, elt, `Actions for ${user.username || 'participant'}`);
}
/**
@@ -1761,27 +1810,32 @@ function userMenu(elt) {
*/
function addUser(id, userinfo) {
let div = document.getElementById('users');
let user = document.createElement('div');
let user = document.createElement('button');
user.id = 'user-' + id;
user.type = 'button';
user.classList.add("user-p");
user.setAttribute('role', 'button');
user.setAttribute('tabindex', '0');
user.setAttribute('aria-haspopup', 'menu');
user.setAttribute('aria-expanded', 'false');
let statusIcon = document.createElement('span');
statusIcon.classList.add('user-status-icon', 'fas');
statusIcon.setAttribute('aria-hidden', 'true');
user.appendChild(statusIcon);
let name = document.createElement('span');
name.classList.add('user-name');
user.appendChild(name);
let microphoneIcon = document.createElement('span');
microphoneIcon.classList.add('user-microphone-icon', 'fas');
microphoneIcon.setAttribute('aria-hidden', 'true');
microphoneIcon.hidden = true;
user.appendChild(microphoneIcon);
setUserStatus(id, user, userinfo, false);
user.addEventListener('click', function(e) {
e.stopPropagation();
let elt = e.currentTarget;
if(!elt || !(elt instanceof HTMLElement))
throw new Error("Couldn't find user div");
throw new Error("Couldn't find user button");
userMenu(elt);
});
user.addEventListener('keydown', function(e) {
if(e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
let elt = e.target;
if(!elt || !(elt instanceof HTMLElement))
throw new Error("Couldn't find user div");
userMenu(elt);
}
});
let us = div.children;
@@ -1832,19 +1886,21 @@ function changeUser(id, userinfo) {
*/
function setUserStatus(id, elt, userinfo, notify) {
let username = userinfo.username ? userinfo.username : '(anon)';
elt.textContent = username;
let name = elt.querySelector('.user-name');
if(!name)
throw new Error("Couldn't find user name span");
if(name.textContent !== username)
name.textContent = username;
let wasRaised = elt.classList.contains('user-status-raisehand');
if(userinfo.data.raisehand) {
elt.classList.add('user-status-raisehand');
elt.setAttribute('aria-label', `${username} (hand raised)`);
if(notify && !wasRaised) {
announceUrgent(`${username}: hand raised`);
playNotificationSound('hand-raised');
}
} else {
elt.classList.remove('user-status-raisehand');
elt.setAttribute('aria-label', username);
if(notify && wasRaised) {
announceUrgent(`${username}: hand lowered`);
playNotificationSound('hand-lowered');
@@ -1866,6 +1922,11 @@ function setUserStatus(id, elt, userinfo, notify) {
if(microphone)
statusParts.push('microphone on');
elt.classList.toggle('user-status-microphone', microphone);
let microphoneIcon = elt.querySelector('.user-microphone-icon');
if(!microphoneIcon)
throw new Error("Couldn't find user microphone icon span");
microphoneIcon.hidden = !microphone;
elt.setAttribute('aria-label', statusParts.join(', '));
}
@@ -2559,14 +2620,6 @@ function addToChatbox(id, peerId, dest, nick, time, privileged, history, kind, m
if(peerId) {
container.dataset.peerId = peerId;
container.dataset.username = nick;
container.addEventListener('click', function(e) {
if(e.detail !== 2)
return;
let elt = e.currentTarget;
if(!elt || !(elt instanceof HTMLElement))
throw new Error("Couldn't find chat message div");
chatMessageMenu(elt);
});
}
/** @type{HTMLElement} */
@@ -2634,6 +2687,20 @@ function addToChatbox(id, peerId, dest, nick, time, privileged, history, kind, m
lastMessage = {};
}
container.appendChild(footer);
if(peerId && canModerateChat()) {
let actions = document.createElement('button');
actions.type = 'button';
actions.classList.add('message-actions');
actions.setAttribute('aria-haspopup', 'menu');
actions.setAttribute('aria-expanded', 'false');
actions.setAttribute('aria-label', `Actions for message from ${nick || '(anon)'}`);
actions.textContent = 'Actions';
actions.addEventListener('click', function(e) {
e.stopPropagation();
chatMessageMenu(container, actions);
});
footer.appendChild(actions);
}
let box = document.getElementById('box');
box.appendChild(row);
@@ -2657,10 +2724,10 @@ function addToChatbox(id, peerId, dest, nick, time, privileged, history, kind, m
/**
* @param {HTMLElement} elt
* @param {HTMLElement} trigger
*/
function chatMessageMenu(elt) {
if(!(serverConnection && serverConnection.permissions &&
serverConnection.permissions.indexOf('op') >= 0))
function chatMessageMenu(elt, trigger) {
if(!canModerateChat())
return;
let messageId = elt.dataset.id;
@@ -2691,19 +2758,7 @@ function chatMessageMenu(elt) {
serverConnection.userAction('kick', peerId);
}});
/** @ts-ignore */
new Contextual({
items: items,
});
// Add keyboard navigation after menu is created
setTimeout(() => {
let menu = document.querySelector('.contextualMenu');
if(menu) {
menu.setAttribute('role', 'menu');
makeContextualMenuAccessible(menu);
}
}, 0);
openActionMenu(items, trigger, `Actions for message from ${u}`);
}
/**
@@ -3480,24 +3535,49 @@ document.getElementById('input').onkeypress = function(e) {
}
};
function updateChatResizerValue(leftPercent) {
let resizer = document.getElementById('resizer');
if(!resizer)
return;
let rounded = Math.round(leftPercent);
resizer.setAttribute('aria-valuenow', rounded.toString());
resizer.setAttribute('aria-valuetext', `Chat width ${rounded} percent`);
}
function updateChatResizerValueFromLayout() {
let mainrow = document.getElementById("mainrow");
let left = document.getElementById("left");
if(!mainrow || !left || mainrow.offsetWidth === 0)
return;
updateChatResizerValue(left.offsetWidth * 100 / mainrow.offsetWidth);
}
function setChatWidth(leftPercent) {
let full_width = document.getElementById("mainrow").offsetWidth;
let left = document.getElementById("left");
let right = document.getElementById("right");
let min_left_width = 300 * 100 / full_width;
if(leftPercent < min_left_width)
return false;
left.style.flex = leftPercent.toString();
right.style.flex = (100 - leftPercent).toString();
updateChatResizerValue(leftPercent);
return true;
}
function chatResizer(e) {
e.preventDefault();
let full_width = document.getElementById("mainrow").offsetWidth;
let left = document.getElementById("left");
let right = document.getElementById("right");
let start_x = e.clientX;
let start_width = left.offsetWidth;
function start_drag(e) {
let left_width = (start_width + e.clientX - start_x) * 100 / full_width;
// set min chat width to 300px
let min_left_width = 300 * 100 / full_width;
if (left_width < min_left_width) {
return;
}
left.style.flex = left_width.toString();
right.style.flex = (100 - left_width).toString();
setChatWidth(left_width);
}
function stop_drag(e) {
document.documentElement.removeEventListener(
@@ -3524,21 +3604,12 @@ document.getElementById('resizer').addEventListener('keydown', function(e) {
e.preventDefault();
let full_width = document.getElementById("mainrow").offsetWidth;
let left = document.getElementById("left");
let right = document.getElementById("right");
let current_width = left.offsetWidth;
let step = 20; // pixels per keypress
let new_width = e.key === 'ArrowLeft' ? current_width - step : current_width + step;
let left_percent = new_width * 100 / full_width;
// set min chat width to 300px
let min_left_width = 300 * 100 / full_width;
if (left_percent < min_left_width) {
return;
}
left.style.flex = left_percent.toString();
right.style.flex = (100 - left_percent).toString();
setChatWidth(left_percent);
}
}, false);
@@ -3697,16 +3768,6 @@ document.getElementById('close-chat').onclick = function(e) {
document.getElementById('show-chat').focus();
};
document.getElementById('close-chat').onkeydown = function(e) {
if(e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setVisibility('left', false);
setVisibility('show-chat', true);
resizePeers();
document.getElementById('show-chat').focus();
}
};
document.getElementById('show-chat').onclick = function(e) {
e.preventDefault();
setVisibility('left', true);
@@ -3715,16 +3776,6 @@ document.getElementById('show-chat').onclick = function(e) {
document.getElementById('close-chat').focus();
};
document.getElementById('show-chat').onkeydown = function(e) {
if(e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setVisibility('left', true);
setVisibility('show-chat', false);
resizePeers();
document.getElementById('close-chat').focus();
}
};
async function serverConnect() {
if(serverConnection && serverConnection.socket)
serverConnection.close();