Add accessibility improvements for screen readers and keyboard navigation
This commit addresses critical accessibility issues that prevent blind users and keyboard-only users from using Galene effectively. Changes: - Convert navigation buttons from divs to proper button elements with ARIA labels * Mute button now has aria-pressed state and updates label (Mute/Unmute) * Share screen button has proper aria-label * More options button has aria-label and aria-haspopup * Collapse sidebar button converted to proper button element * Close chat button converted to proper button element - Add visible focus indicators for keyboard navigation * Restore focus outlines for all interactive elements * Add consistent 2px blue outline for buttons, inputs, selects, and links * Remove outline: none that was blocking keyboard users - Add aria-live region for chat messages * New messages announced to screen readers in real-time * History messages not announced to avoid spam on page load * Includes .sr-only CSS class for screen-reader-only content - Add aria-labels to video stream elements * Video containers labeled with username * Video elements labeled to identify stream owner * Helps screen reader users identify which video belongs to whom - Add raised hand notifications for screen readers * Announces when users raise their hand * User list items show status (hand raised, camera on, microphone on) * Does not announce when you raise your own hand * Updates aria-label on user elements with full status These changes make Galene significantly more accessible for users who rely on screen readers or keyboard navigation.
This commit is contained in:
+28
-3
@@ -1,3 +1,27 @@
|
|||||||
|
/* Accessibility: Visible focus indicators for keyboard navigation */
|
||||||
|
button:focus,
|
||||||
|
[role="button"]:focus,
|
||||||
|
select:focus,
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
a:focus {
|
||||||
|
outline: 2px solid #0066cc;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accessibility: Screen reader only content */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-fixed .topnav {
|
.nav-fixed .topnav {
|
||||||
z-index: 1039;
|
z-index: 1039;
|
||||||
}
|
}
|
||||||
@@ -312,7 +336,6 @@
|
|||||||
resize: none;
|
resize: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
outline: none;
|
|
||||||
border: none;
|
border: none;
|
||||||
text-indent: 5px;
|
text-indent: 5px;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -865,11 +888,13 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#input:focus {
|
#input:focus {
|
||||||
outline: none;
|
outline: 2px solid #0066cc;
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#inputbutton:focus {
|
#inputbutton:focus {
|
||||||
outline: none;
|
outline: 2px solid #0066cc;
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#resizer {
|
#resizer {
|
||||||
|
|||||||
+13
-12
@@ -29,9 +29,9 @@
|
|||||||
<header>
|
<header>
|
||||||
<nav class="topnav navbar navbar-expand navbar-light fixed-top">
|
<nav class="topnav navbar navbar-expand navbar-light fixed-top">
|
||||||
<div id="header">
|
<div id="header">
|
||||||
<div class="collapse" title="Collapse left panel" id="sidebarCollapse">
|
<button class="collapse" aria-label="Collapse left panel" id="sidebarCollapse">
|
||||||
<i class="fas fa-align-left" aria-hidden="true"></i>
|
<i class="fas fa-align-left" aria-hidden="true"></i>
|
||||||
</div>
|
</button>
|
||||||
<h1 id="title" class="header-title">Galène</h1>
|
<h1 id="title" class="header-title">Galène</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -47,21 +47,21 @@
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<div id="mutebutton" class="nav-link nav-button">
|
<button id="mutebutton" class="nav-link nav-button" aria-pressed="false" aria-label="Mute microphone">
|
||||||
<span><i class="fas fa-microphone-slash" aria-hidden="true"></i></span>
|
<span><i class="fas fa-microphone-slash" aria-hidden="true"></i></span>
|
||||||
<label>Mute</label>
|
<span class="nav-text">Mute</span>
|
||||||
</div>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<div id="sharebutton" class="invisible nav-link nav-button">
|
<button id="sharebutton" class="invisible nav-link nav-button" aria-label="Share screen">
|
||||||
<span><i class="fas fa-share-square" aria-hidden="true"></i></span>
|
<span><i class="fas fa-share-square" aria-hidden="true"></i></span>
|
||||||
<label>Share Screen</label>
|
<span class="nav-text">Share Screen</span>
|
||||||
</div>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<div class="nav-button nav-link nav-more" id="openside">
|
<button class="nav-button nav-link nav-more" id="openside" aria-label="More options" aria-haspopup="true">
|
||||||
<span><i class="fas fa-ellipsis-v" aria-hidden="true"></i></span>
|
<span><i class="fas fa-ellipsis-v" aria-hidden="true"></i></span>
|
||||||
</div>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -70,10 +70,11 @@
|
|||||||
<div class="coln-left" id="left">
|
<div class="coln-left" id="left">
|
||||||
<div id="chat">
|
<div id="chat">
|
||||||
<div id="chatbox">
|
<div id="chatbox">
|
||||||
<div class="close-chat" id="close-chat" title="Hide chat">
|
<button class="close-chat" id="close-chat" aria-label="Hide chat">
|
||||||
<span class="close-icon"></span>
|
<span class="close-icon"></span>
|
||||||
</div>
|
</button>
|
||||||
<div id="box"></div>
|
<div id="box"></div>
|
||||||
|
<div id="chat-announcements" aria-live="polite" aria-atomic="true" class="sr-only"></div>
|
||||||
<div class="reply">
|
<div class="reply">
|
||||||
<form id="inputform">
|
<form id="inputform">
|
||||||
<textarea id="input" class="form-reply"></textarea>
|
<textarea id="input" class="form-reply"></textarea>
|
||||||
|
|||||||
+47
-3
@@ -626,10 +626,14 @@ function setLocalMute(mute, reflect) {
|
|||||||
icon.classList.add('fa-microphone-slash');
|
icon.classList.add('fa-microphone-slash');
|
||||||
icon.classList.remove('fa-microphone');
|
icon.classList.remove('fa-microphone');
|
||||||
button.classList.add('muted');
|
button.classList.add('muted');
|
||||||
|
button.setAttribute('aria-pressed', 'true');
|
||||||
|
button.setAttribute('aria-label', 'Unmute microphone');
|
||||||
} else {
|
} else {
|
||||||
icon.classList.remove('fa-microphone-slash');
|
icon.classList.remove('fa-microphone-slash');
|
||||||
icon.classList.add('fa-microphone');
|
icon.classList.add('fa-microphone');
|
||||||
button.classList.remove('muted');
|
button.classList.remove('muted');
|
||||||
|
button.setAttribute('aria-pressed', 'false');
|
||||||
|
button.setAttribute('aria-label', 'Mute microphone');
|
||||||
}
|
}
|
||||||
if(reflect)
|
if(reflect)
|
||||||
updateSettings({localMute: mute});
|
updateSettings({localMute: mute});
|
||||||
@@ -2060,6 +2064,10 @@ async function setMedia(c, mirror, video) {
|
|||||||
peersdiv.appendChild(div);
|
peersdiv.appendChild(div);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add aria-label to identify video stream owner
|
||||||
|
let username = c.username || 'Participant';
|
||||||
|
div.setAttribute('aria-label', `Video stream from ${username}`);
|
||||||
|
|
||||||
showHideMedia(c, div)
|
showHideMedia(c, div)
|
||||||
|
|
||||||
let media = /** @type {HTMLVideoElement} */
|
let media = /** @type {HTMLVideoElement} */
|
||||||
@@ -2077,6 +2085,9 @@ async function setMedia(c, mirror, video) {
|
|||||||
media.autoplay = true;
|
media.autoplay = true;
|
||||||
media.playsInline = true;
|
media.playsInline = true;
|
||||||
media.id = 'media-' + c.localId;
|
media.id = 'media-' + c.localId;
|
||||||
|
// Add aria-label for screen readers
|
||||||
|
let username = c.username || 'Participant';
|
||||||
|
media.setAttribute('aria-label', `Video from ${username}`);
|
||||||
div.appendChild(media);
|
div.appendChild(media);
|
||||||
addCustomControls(media, div, c, !!video);
|
addCustomControls(media, div, c, !!video);
|
||||||
}
|
}
|
||||||
@@ -2653,11 +2664,24 @@ function changeUser(id, userinfo) {
|
|||||||
* @param {user} userinfo
|
* @param {user} userinfo
|
||||||
*/
|
*/
|
||||||
function setUserStatus(id, elt, userinfo) {
|
function setUserStatus(id, elt, userinfo) {
|
||||||
elt.textContent = userinfo.username ? userinfo.username : '(anon)';
|
let username = userinfo.username ? userinfo.username : '(anon)';
|
||||||
if(userinfo.data.raisehand)
|
elt.textContent = username;
|
||||||
|
|
||||||
|
let wasRaised = elt.classList.contains('user-status-raisehand');
|
||||||
|
if(userinfo.data.raisehand) {
|
||||||
elt.classList.add('user-status-raisehand');
|
elt.classList.add('user-status-raisehand');
|
||||||
else
|
elt.setAttribute('aria-label', `${username} (hand raised)`);
|
||||||
|
// Announce when hand is newly raised (not on initial load)
|
||||||
|
if(!wasRaised && id !== (serverConnection && serverConnection.id)) {
|
||||||
|
let announcement = document.getElementById('chat-announcements');
|
||||||
|
if(announcement) {
|
||||||
|
announcement.textContent = `${username} raised their hand`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
elt.classList.remove('user-status-raisehand');
|
elt.classList.remove('user-status-raisehand');
|
||||||
|
elt.setAttribute('aria-label', username);
|
||||||
|
}
|
||||||
|
|
||||||
let microphone=false, camera = false;
|
let microphone=false, camera = false;
|
||||||
for(let label in userinfo.streams) {
|
for(let label in userinfo.streams) {
|
||||||
@@ -2668,6 +2692,17 @@ function setUserStatus(id, elt, userinfo) {
|
|||||||
camera = true;
|
camera = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build descriptive aria-label with media status
|
||||||
|
let statusParts = [username];
|
||||||
|
if(userinfo.data.raisehand)
|
||||||
|
statusParts.push('hand raised');
|
||||||
|
if(camera)
|
||||||
|
statusParts.push('camera on');
|
||||||
|
if(microphone)
|
||||||
|
statusParts.push('microphone on');
|
||||||
|
elt.setAttribute('aria-label', statusParts.join(', '));
|
||||||
|
|
||||||
if(camera) {
|
if(camera) {
|
||||||
elt.classList.remove('user-status-microphone');
|
elt.classList.remove('user-status-microphone');
|
||||||
elt.classList.add('user-status-camera');
|
elt.classList.add('user-status-camera');
|
||||||
@@ -3384,6 +3419,15 @@ function addToChatbox(id, peerId, dest, nick, time, privileged, history, kind, m
|
|||||||
box.scrollTop = box.scrollHeight - box.clientHeight;
|
box.scrollTop = box.scrollHeight - box.clientHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Announce new messages to screen readers (but not history)
|
||||||
|
if(!history) {
|
||||||
|
let announcement = document.getElementById('chat-announcements');
|
||||||
|
if(announcement) {
|
||||||
|
let messageText = typeof message === 'string' ? message : body.textContent;
|
||||||
|
announcement.textContent = nick ? `${nick}: ${messageText}` : messageText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user