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
@@ -2,7 +2,7 @@
# shellcheck shell=bash disable=SC2034,SC2154 # shellcheck shell=bash disable=SC2034,SC2154
pkgname=skald-git pkgname=skald-git
pkgver=0.0.0.r1527.g47d1308 pkgver=0.0.0.r1528.gaac197e
pkgrel=1 pkgrel=1
pkgdesc='Audio-only hall-based conferencing server' pkgdesc='Audio-only hall-based conferencing server'
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
+76 -22
View File
@@ -1,6 +1,6 @@
/* Accessibility: Visible focus indicators for keyboard navigation */ /* Accessibility: Visible focus indicators for keyboard navigation */
button:focus, button:focus,
[role="button"]:focus, [role="separator"]:focus,
select:focus, select:focus,
input:focus, input:focus,
textarea:focus, textarea:focus,
@@ -1003,38 +1003,50 @@ header .collapse:hover {
} }
#users .user-p { #users .user-p {
display: block;
position: relative; position: relative;
padding: 10px !important; padding: 10px !important;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
border-left: 0;
border-right: 0;
border-top: 0;
height: 40px; height: 40px;
line-height: 18px; line-height: 18px;
margin: 0 !important; margin: 0 !important;
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
white-space: pre; white-space: pre;
width: 100%;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
} }
#left-sidebar.active #users > div { #left-sidebar.active #users > .user-p {
padding: 10px 5px !important; padding: 10px 5px !important;
} }
#users > div:hover { #users > .user-p:hover,
#users > .user-p:focus {
background-color: #f2f2f2; background-color: #f2f2f2;
} }
#users > div::before { #users .user-status-icon {
content: "\f111";
font-family: 'Font Awesome 6 Free';
color: #20b91e; color: #20b91e;
margin-right: 5px; margin-right: 5px;
font-weight: 900; font-weight: 900;
} }
#users > div.user-status-raisehand::before { #users .user-status-icon::before {
content: "\f111";
}
#users .user-status-raisehand .user-status-icon::before {
content: "\f256"; content: "\f256";
} }
#users > div::after { #users .user-microphone-icon {
font-family: 'Font Awesome 6 Free'; font-family: 'Font Awesome 6 Free';
color: #808080; color: #808080;
margin-left: 5px; margin-left: 5px;
@@ -1042,7 +1054,7 @@ header .collapse:hover {
float: right; float: right;
} }
#users > div.user-status-microphone::after { #users .user-microphone-icon::before {
content: "\f130"; content: "\f130";
} }
@@ -1186,22 +1198,64 @@ header .collapse:hover {
color: #000; color: #000;
} }
:root{ :root {
--contextualMenuBg: #eee; --skald-menu-bg: #eee;
--contextualMenuShadow: 1px 1px 1px #444; */ --skald-menu-shadow: 1px 1px 1px #444;
--contextualMenuRadius: 0px; */ --skald-menu-radius: 0;
--contextualMenuText: black; --skald-menu-text: black;
--skald-menu-hover: #ddd;
--contextualSubMenuBg: #eee; --skald-menu-separator: #999;
--contextualHover: #ddd;
--contextualOverflowIcon: #999;
--contextualSeperator: #999;
} }
.contextualMenu{ .skald-action-menu {
position: absolute;
z-index: 2999; z-index: 2999;
margin: 0;
padding: 8px 0;
min-width: 14em;
list-style: none;
background: var(--skald-menu-bg);
box-shadow: var(--skald-menu-shadow);
border-radius: var(--skald-menu-radius);
color: var(--skald-menu-text);
font-size: 13px;
}
.skald-action-menu li {
margin: 0;
padding: 0;
}
.skald-action-menu-item {
width: 100%;
border: 0;
padding: 5px 8px;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
text-align: left;
}
.skald-action-menu-item:focus,
.skald-action-menu-item:hover {
background: var(--skald-menu-hover);
}
.skald-action-menu-separator {
border-top: 1px solid var(--skald-menu-separator);
margin: 5px 0;
padding: 0;
}
.message-actions {
margin-left: 0.5em;
border: 0;
background: transparent;
color: #404064;
cursor: pointer;
font: inherit;
text-decoration: underline;
} }
.file-progress { .file-progress {
+1 -3
View File
@@ -12,7 +12,6 @@
<link rel="stylesheet" type="text/css" href="/third-party/fontawesome/css/solid.min.css"/> <link rel="stylesheet" type="text/css" href="/third-party/fontawesome/css/solid.min.css"/>
<link rel="stylesheet" type="text/css" href="/third-party/fontawesome/css/regular.min.css"/> <link rel="stylesheet" type="text/css" href="/third-party/fontawesome/css/regular.min.css"/>
<link rel="stylesheet" type="text/css" href="/third-party/toastify/toastify.css"/> <link rel="stylesheet" type="text/css" href="/third-party/toastify/toastify.css"/>
<link rel="stylesheet" type="text/css" href="/third-party/contextual/contextual.css"/>
</head> </head>
<body> <body>
@@ -82,7 +81,7 @@
</div> </div>
</div> </div>
</div> </div>
<div id="resizer" class="connected-only invisible" aria-hidden="true" tabindex="0" role="separator" aria-label="Chat resize handle" aria-orientation="vertical"></div> <div id="resizer" class="connected-only invisible" aria-hidden="true" tabindex="0" role="separator" aria-label="Chat resize handle" aria-orientation="vertical" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" aria-valuetext="Chat width 50 percent"></div>
<div class="coln-right" id="right"> <div class="coln-right" id="right">
<button class="chat-btn show-chat invisible" id="show-chat" aria-label="Show chat"> <button class="chat-btn show-chat invisible" id="show-chat" aria-label="Show chat">
<i class="far fa-comment-alt icon-chat" aria-hidden="true"></i> <i class="far fa-comment-alt icon-chat" aria-hidden="true"></i>
@@ -217,7 +216,6 @@
<script src="/protocol.js" defer></script> <script src="/protocol.js" defer></script>
<script src="/third-party/toastify/toastify.js" defer></script> <script src="/third-party/toastify/toastify.js" defer></script>
<script src="/third-party/contextual/contextual.js" defer></script>
<script src="/skald.js" defer></script> <script src="/skald.js" defer></script>
</body> </body>
</html> </html>
+178 -127
View File
@@ -449,6 +449,7 @@ function setConnected(connected) {
connectionbox.classList.add('invisible'); connectionbox.classList.add('invisible');
closeNav(); closeNav();
setLeftPanelExpanded(true); setLeftPanelExpanded(true);
updateChatResizerValueFromLayout();
displayUsername(); displayUsername();
window.onresize = function(e) { window.onresize = function(e) {
scheduleReconsiderDownRate(); scheduleReconsiderDownRate();
@@ -1632,55 +1633,115 @@ document.getElementById('invite-dialog').onclose = function(e) {
makeToken(template); 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 {{label?: string, type?: string, onClick?: function(): void}[]} items
* @param {HTMLElement} menuElement * @param {HTMLElement} trigger
* @param {string} label
*/ */
function makeContextualMenuAccessible(menuElement) { function openActionMenu(items, trigger, label) {
// Find all menu items closeActionMenu(false);
let menuItems = menuElement.querySelectorAll('.contextualMenuItem:not(.disabled)');
if(menuItems.length === 0) return;
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 let buttons = [];
menuItems.forEach((item, index) => { items.forEach(item => {
item.setAttribute('role', 'menuitem'); let li = document.createElement('li');
item.setAttribute('tabindex', index === 0 ? '0' : '-1'); if(item.type === 'seperator') {
}); li.classList.add('skald-action-menu-separator');
li.setAttribute('role', 'separator');
// Focus first item menu.appendChild(li);
menuItems[0].focus();
// Add keyboard navigation
menuElement.addEventListener('keydown', function(e) {
if(e.key === 'Escape') {
e.preventDefault();
contextualCore.CloseMenu();
return; 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(); e.preventDefault();
menuItems[currentIndex].setAttribute('tabindex', '-1'); closeActionMenu(true);
currentIndex = (currentIndex + 1) % menuItems.length; } else if(e.key === 'ArrowDown') {
menuItems[currentIndex].setAttribute('tabindex', '0'); e.preventDefault();
menuItems[currentIndex].focus(); buttons[(index + 1 + buttons.length) % buttons.length].focus();
} else if(e.key === 'ArrowUp') { } else if(e.key === 'ArrowUp') {
e.preventDefault(); e.preventDefault();
menuItems[currentIndex].setAttribute('tabindex', '-1'); buttons[(index - 1 + buttons.length) % buttons.length].focus();
currentIndex = currentIndex === 0 ? menuItems.length - 1 : currentIndex - 1; } else if(e.key === 'Home') {
menuItems[currentIndex].setAttribute('tabindex', '0');
menuItems[currentIndex].focus();
} else if(e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
// Click the parent li element which has the actual click handler buttons[0].focus();
let parentLi = menuItems[currentIndex].parentElement; } else if(e.key === 'End') {
if(parentLi) { e.preventDefault();
parentLi.click(); 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 */ openActionMenu(items, elt, `Actions for ${user.username || 'participant'}`);
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);
} }
/** /**
@@ -1761,27 +1810,32 @@ function userMenu(elt) {
*/ */
function addUser(id, userinfo) { function addUser(id, userinfo) {
let div = document.getElementById('users'); let div = document.getElementById('users');
let user = document.createElement('div'); let user = document.createElement('button');
user.id = 'user-' + id; user.id = 'user-' + id;
user.type = 'button';
user.classList.add("user-p"); user.classList.add("user-p");
user.setAttribute('role', 'button'); user.setAttribute('aria-haspopup', 'menu');
user.setAttribute('tabindex', '0'); 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); setUserStatus(id, user, userinfo, false);
user.addEventListener('click', function(e) { user.addEventListener('click', function(e) {
e.stopPropagation();
let elt = e.currentTarget; let elt = e.currentTarget;
if(!elt || !(elt instanceof HTMLElement)) if(!elt || !(elt instanceof HTMLElement))
throw new Error("Couldn't find user div"); throw new Error("Couldn't find user button");
userMenu(elt); 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; let us = div.children;
@@ -1832,19 +1886,21 @@ function changeUser(id, userinfo) {
*/ */
function setUserStatus(id, elt, userinfo, notify) { function setUserStatus(id, elt, userinfo, notify) {
let username = userinfo.username ? userinfo.username : '(anon)'; 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'); let wasRaised = elt.classList.contains('user-status-raisehand');
if(userinfo.data.raisehand) { if(userinfo.data.raisehand) {
elt.classList.add('user-status-raisehand'); elt.classList.add('user-status-raisehand');
elt.setAttribute('aria-label', `${username} (hand raised)`);
if(notify && !wasRaised) { if(notify && !wasRaised) {
announceUrgent(`${username}: hand raised`); announceUrgent(`${username}: hand raised`);
playNotificationSound('hand-raised'); playNotificationSound('hand-raised');
} }
} else { } else {
elt.classList.remove('user-status-raisehand'); elt.classList.remove('user-status-raisehand');
elt.setAttribute('aria-label', username);
if(notify && wasRaised) { if(notify && wasRaised) {
announceUrgent(`${username}: hand lowered`); announceUrgent(`${username}: hand lowered`);
playNotificationSound('hand-lowered'); playNotificationSound('hand-lowered');
@@ -1866,6 +1922,11 @@ function setUserStatus(id, elt, userinfo, notify) {
if(microphone) if(microphone)
statusParts.push('microphone on'); 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(', ')); elt.setAttribute('aria-label', statusParts.join(', '));
} }
@@ -2559,14 +2620,6 @@ function addToChatbox(id, peerId, dest, nick, time, privileged, history, kind, m
if(peerId) { if(peerId) {
container.dataset.peerId = peerId; container.dataset.peerId = peerId;
container.dataset.username = nick; 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} */ /** @type{HTMLElement} */
@@ -2634,6 +2687,20 @@ function addToChatbox(id, peerId, dest, nick, time, privileged, history, kind, m
lastMessage = {}; lastMessage = {};
} }
container.appendChild(footer); 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'); let box = document.getElementById('box');
box.appendChild(row); box.appendChild(row);
@@ -2657,10 +2724,10 @@ function addToChatbox(id, peerId, dest, nick, time, privileged, history, kind, m
/** /**
* @param {HTMLElement} elt * @param {HTMLElement} elt
* @param {HTMLElement} trigger
*/ */
function chatMessageMenu(elt) { function chatMessageMenu(elt, trigger) {
if(!(serverConnection && serverConnection.permissions && if(!canModerateChat())
serverConnection.permissions.indexOf('op') >= 0))
return; return;
let messageId = elt.dataset.id; let messageId = elt.dataset.id;
@@ -2691,19 +2758,7 @@ function chatMessageMenu(elt) {
serverConnection.userAction('kick', peerId); serverConnection.userAction('kick', peerId);
}}); }});
/** @ts-ignore */ openActionMenu(items, trigger, `Actions for message from ${u}`);
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);
} }
/** /**
@@ -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) { function chatResizer(e) {
e.preventDefault(); e.preventDefault();
let full_width = document.getElementById("mainrow").offsetWidth; let full_width = document.getElementById("mainrow").offsetWidth;
let left = document.getElementById("left"); let left = document.getElementById("left");
let right = document.getElementById("right");
let start_x = e.clientX; let start_x = e.clientX;
let start_width = left.offsetWidth; let start_width = left.offsetWidth;
function start_drag(e) { function start_drag(e) {
let left_width = (start_width + e.clientX - start_x) * 100 / full_width; let left_width = (start_width + e.clientX - start_x) * 100 / full_width;
// set min chat width to 300px setChatWidth(left_width);
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();
} }
function stop_drag(e) { function stop_drag(e) {
document.documentElement.removeEventListener( document.documentElement.removeEventListener(
@@ -3524,21 +3604,12 @@ document.getElementById('resizer').addEventListener('keydown', function(e) {
e.preventDefault(); e.preventDefault();
let full_width = document.getElementById("mainrow").offsetWidth; let full_width = document.getElementById("mainrow").offsetWidth;
let left = document.getElementById("left"); let left = document.getElementById("left");
let right = document.getElementById("right");
let current_width = left.offsetWidth; let current_width = left.offsetWidth;
let step = 20; // pixels per keypress let step = 20; // pixels per keypress
let new_width = e.key === 'ArrowLeft' ? current_width - step : current_width + step; let new_width = e.key === 'ArrowLeft' ? current_width - step : current_width + step;
let left_percent = new_width * 100 / full_width; let left_percent = new_width * 100 / full_width;
setChatWidth(left_percent);
// 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();
} }
}, false); }, false);
@@ -3697,16 +3768,6 @@ document.getElementById('close-chat').onclick = function(e) {
document.getElementById('show-chat').focus(); 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) { document.getElementById('show-chat').onclick = function(e) {
e.preventDefault(); e.preventDefault();
setVisibility('left', true); setVisibility('left', true);
@@ -3715,16 +3776,6 @@ document.getElementById('show-chat').onclick = function(e) {
document.getElementById('close-chat').focus(); 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() { async function serverConnect() {
if(serverConnection && serverConnection.socket) if(serverConnection && serverConnection.socket)
serverConnection.close(); serverConnection.close();