More ui accessibility improvements.
This commit is contained in:
+178
-127
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user