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:
Storm Dragon
2025-11-27 00:03:41 -05:00
parent 4cf2c9e14c
commit e03c0be0f7
3 changed files with 88 additions and 18 deletions
+28 -3
View File
@@ -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 {
z-index: 1039;
}
@@ -312,7 +336,6 @@
resize: none;
overflow: hidden;
padding: 5px;
outline: none;
border: none;
text-indent: 5px;
box-shadow: none;
@@ -865,11 +888,13 @@ h1 {
}
#input:focus {
outline: none;
outline: 2px solid #0066cc;
outline-offset: 2px;
}
#inputbutton:focus {
outline: none;
outline: 2px solid #0066cc;
outline-offset: 2px;
}
#resizer {
+13 -12
View File
@@ -29,9 +29,9 @@
<header>
<nav class="topnav navbar navbar-expand navbar-light fixed-top">
<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>
</div>
</button>
<h1 id="title" class="header-title">Galène</h1>
</div>
@@ -47,21 +47,21 @@
</button>
</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>
<label>Mute</label>
</div>
<span class="nav-text">Mute</span>
</button>
</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>
<label>Share Screen</label>
</div>
<span class="nav-text">Share Screen</span>
</button>
</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>
</div>
</button>
</li>
</ul>
</nav>
@@ -70,10 +70,11 @@
<div class="coln-left" id="left">
<div id="chat">
<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>
</div>
</button>
<div id="box"></div>
<div id="chat-announcements" aria-live="polite" aria-atomic="true" class="sr-only"></div>
<div class="reply">
<form id="inputform">
<textarea id="input" class="form-reply"></textarea>
+47 -3
View File
@@ -626,10 +626,14 @@ function setLocalMute(mute, reflect) {
icon.classList.add('fa-microphone-slash');
icon.classList.remove('fa-microphone');
button.classList.add('muted');
button.setAttribute('aria-pressed', 'true');
button.setAttribute('aria-label', 'Unmute microphone');
} else {
icon.classList.remove('fa-microphone-slash');
icon.classList.add('fa-microphone');
button.classList.remove('muted');
button.setAttribute('aria-pressed', 'false');
button.setAttribute('aria-label', 'Mute microphone');
}
if(reflect)
updateSettings({localMute: mute});
@@ -2060,6 +2064,10 @@ async function setMedia(c, mirror, video) {
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)
let media = /** @type {HTMLVideoElement} */
@@ -2077,6 +2085,9 @@ async function setMedia(c, mirror, video) {
media.autoplay = true;
media.playsInline = true;
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);
addCustomControls(media, div, c, !!video);
}
@@ -2653,11 +2664,24 @@ function changeUser(id, userinfo) {
* @param {user} userinfo
*/
function setUserStatus(id, elt, userinfo) {
elt.textContent = userinfo.username ? userinfo.username : '(anon)';
if(userinfo.data.raisehand)
let username = userinfo.username ? userinfo.username : '(anon)';
elt.textContent = username;
let wasRaised = elt.classList.contains('user-status-raisehand');
if(userinfo.data.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.setAttribute('aria-label', username);
}
let microphone=false, camera = false;
for(let label in userinfo.streams) {
@@ -2668,6 +2692,17 @@ function setUserStatus(id, elt, userinfo) {
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) {
elt.classList.remove('user-status-microphone');
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;
}
// 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;
}