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 { .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
View File
@@ -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
View File
@@ -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;
} }