diff --git a/GitHubA11yFixes.user.js b/GitHubA11yFixes.user.js index 4775409..4eeb1f0 100644 --- a/GitHubA11yFixes.user.js +++ b/GitHubA11yFixes.user.js @@ -2,10 +2,10 @@ // @name GitHub Accessibility Fixes // @namespace http://axSgrease.nvaccess.org/ // @description Improves the accessibility of GitHub. -// @author James Teh -// @copyright 2019-2024 Mozilla Corporation, Derek Riemer +// @author James Teh , Sascha Cowley +// @copyright 2019-2025 Mozilla Corporation, Derek Riemer, Sascha Cowley // @license Mozilla Public License version 2.0 -// @version 2024.2 +// @version 2025.3 // @include https://github.com/* // ==/UserScript== @@ -136,12 +136,17 @@ const DYNAMIC_TWEAKS = [ if (code && comment) { makeElementOwn(cell, [code, comment]); } + if (comment) { + // These buttons only appear on hover, which sucks for screen reader users. + // Make them always visible. + comment.style.display = "block"; + } }}, // Make non-comment events into headings; e.g. closing/referencing an issue, // approving/requesting changes to a PR, merging a PR. Exclude commits and // commit references because these contain too much detail and there's no // way to separate the header from the body. - {selector: '.TimelineItem:not(.js-commit) .TimelineItem-body:not(.my-0):not(.discussion-comment):not([id^="ref-commit-"])', + {selector: '.TimelineItem:not(.js-commit, .pt-0) .TimelineItem-body:not(.my-0):not(.discussion-comment):not([id^="ref-commit-"])', tweak: [makeHeading, 3]}, // Issue listing tables. {selector: '.js-navigation-container:not(.commits-listing)', @@ -163,6 +168,27 @@ const DYNAMIC_TWEAKS = [ // Remove headings from folder and file lists. {selector: 'table[aria-labelledby=folders-and-files] :is(h2, h3)', tweak: makePresentational}, + // Make file viewer filenames headings, and the first item in the file viewer. + {selector: '.file-header .file-info .Truncate:has(.Link--primary)', + tweak: el => { + makeHeading(el, 2); + const headerRow = el.parentElement; + const children = Array.from(headerRow.children); + // Filename is the last child of .file-info, make it the first + children.unshift(children.pop()); + if (headerRow) { + makeElementOwn(headerRow, children); + } + }}, + // Label diffs and the like with their filename. + {selector: '.file', + tweak: el => { + const label = el.querySelector(".Link--primary"); + const file = el.querySelector(".js-file-content"); + if (label && file) { + makeRegion(file, label.textContent); + } + }}, ]; /*** Lights, camera, action! ***/ diff --git a/MessengerA11yFixes.user.js b/MessengerA11yFixes.user.js new file mode 100644 index 0000000..ec97d60 --- /dev/null +++ b/MessengerA11yFixes.user.js @@ -0,0 +1,237 @@ +// ==UserScript== +// @name Messenger Accessibility Fixes +// @namespace http://axSgrease.nvaccess.org/ +// @description Improves the accessibility of Facebook Messenger. +// @author James Teh +// @copyright 2019-2025 James Teh, Mozilla Corporation, Derek Riemer +// @license Mozilla Public License version 2.0 +// @version 2025.1 +// @include https://www.messenger.com/* +// @include https://www.facebook.com/messages/* +// ==/UserScript== + +/*** Functions for common tweaks. ***/ + +/** + * Adds text to the given live region, and clears it a second later so it's no + * longer perceivable. + * @param {string} regionid an id of a region. + */ +function announce(text, regionId) { + getLiveRegion(regionId) + .then((region) => { + region.innerText = text; + setTimeout(() => { + region.innerText = ''; + }, 1000); + }); +} + +/** + * create or fetch a live region that can be used with announce(). Returns a promise with the region. + * @param {string} id the name of the new live region. This is an html id. + * @return {!Promise} a div that contains the live region. This can typically be ignored, this exists to aid in chaining creation of non-existant regions. + */ +function getLiveRegion(id) { + const updatePromise = new Promise((resolve, reject) => { + if (!id) { + reject('Need a valid id!'); + return; + } + const existingRegion = document.getElementById(id); + if (existingRegion) { + resolve(existingRegion); + return; + } + const region = document.createElement('div'); + region.id = id; + region.setAttribute('aria-live', 'polite'); + region.setAttribute('aria-atomic', 'true'); + region.style.position = 'absolute'; + region.style.width = '50px'; + region.style.height = '50px'; + region.style.opasity = 0; + document.body.appendChild(region); + // we need to delay a little to get the new region to actually read contents. + // A11y APIs probably don't treat the relevant changes as "additions" until + //an annimation frame has passed. It may, in reality be more like 2-4 + // annimation frames, so delay 134 ms to be safe. + setTimeout(() => { + resolve(region); + }, 134); + }); + return updatePromise; +} + +function makeHeading(el, level) { + el.setAttribute("role", "heading"); + el.setAttribute("aria-level", level); +} + +function makeRegion(el, label) { + el.setAttribute("role", "region"); + el.setAttribute("aria-label", label); +} + +function makeButton(el, label) { + el.setAttribute("role", "button"); + if (label) { + el.setAttribute("aria-label", label); + } +} + +function makePresentational(el) { + el.setAttribute("role", "presentation"); +} + +function setLabel(el, label) { + el.setAttribute("aria-label", label); +} + +function makeHidden(el) { + el.setAttribute("aria-hidden", "true"); +} + +function setExpanded(el, expanded) { + el.setAttribute("aria-expanded", expanded ? "true" : "false"); +} + +var idCounter = 0; +// Get a node's id. If it doesn't have one, make and set one first. +function setAriaIdIfNecessary(elem) { + if (!elem.id) { + elem.setAttribute("id", "axsg-" + idCounter++); + } + return elem.id; +} + +function makeElementOwn(parentElement, listOfNodes) { + ids = []; + for (let node of listOfNodes) { + ids.push(setAriaIdIfNecessary(node)); + } + parentElement.setAttribute("aria-owns", ids.join(" ")); +} + +// Focus something even if it wasn't made focusable by the author. +function forceFocus(el) { + let focusable = el.hasAttribute("tabindex"); + if (focusable) { + el.focus(); + return; + } + el.setAttribute("tabindex", "-1"); + el.focus(); +} + +/*** Code to apply the tweaks when appropriate. ***/ + +function applyTweak(el, tweak) { + if (Array.isArray(tweak.tweak)) { + let [func, ...args] = tweak.tweak; + func(el, ...args); + } else { + tweak.tweak(el); + } +} + +function applyTweaks(root, tweaks, checkRoot, forAttrChange = false) { + for (let tweak of tweaks) { + if (!forAttrChange || tweak.whenAttrChangedOnAncestor !== false) { + for (let el of root.querySelectorAll(tweak.selector)) { + try { + applyTweak(el, tweak); + } catch (e) { + console.log("Exception while applying tweak for '" + tweak.selector + "': " + e); + } + } + } + if (checkRoot && root.matches(tweak.selector)) { + try { + applyTweak(root, tweak); + } catch (e) { + console.log("Exception while applying tweak for '" + tweak.selector + "': " + e); + } + } + } +} + +let observer = new MutationObserver(function (mutations) { + for (let mutation of mutations) { + try { + if (mutation.type === "childList") { + for (let node of mutation.addedNodes) { + if (node.nodeType != Node.ELEMENT_NODE) { + continue; + } + applyTweaks(node, DYNAMIC_TWEAKS, true); + } + } else if (mutation.type === "attributes") { + applyTweaks(mutation.target, DYNAMIC_TWEAKS, true, true); + } + } catch (e) { + // Catch exceptions for individual mutations so other mutations are still handled. + console.log("Exception while handling mutation: " + e); + } + } +}); + +function init() { + applyTweaks(document, LOAD_TWEAKS, false); + applyTweaks(document, DYNAMIC_TWEAKS, false); + options = { childList: true, subtree: true }; + if (DYNAMIC_TWEAK_ATTRIBS.length > 0) { + options.attributes = true; + options.attributeFilter = DYNAMIC_TWEAK_ATTRIBS; + } + observer.observe(document, options); +} + +/*** Define the actual tweaks. ***/ + +// Tweaks that only need to be applied on load. +const LOAD_TWEAKS = [ +]; + +// Attributes that should be watched for changes and cause dynamic tweaks to be +// applied. +const DYNAMIC_TWEAK_ATTRIBS = []; + +// Tweaks that must be applied whenever an element is added/changed. +const DYNAMIC_TWEAKS = [ +]; + +/** add your specific initialization here, so that if you ever update the framework from new skeleton your inits are not overridden. */ +function userInit(){ + document.addEventListener("keydown", event => { + // Make alt+1 focus the first chat in the Chats list. + if (event.altKey && event.key == "1") { + const firstChat = document.querySelector('[role=navigation] [role=gridcell] a'); + if (firstChat) { + firstChat.focus(); + } + return; + } + // Make alt+2 focus the last message in the active chat. + if (event.altKey && event.key == "2") { + const messages = document.querySelectorAll('[role=main] [role=row] > div > [role=gridcell]'); + if (messages.length > 0) { + const lastMessage = messages[messages.length - 1]; + lastMessage.focus(); + } + return; + } + // Make alt+3 focus the message composer. + if (event.altKey && event.key == "3") { + const composer = document.querySelector('[role=main] [role=textbox]'); + if (composer) { + composer.focus(); + } + return; + } + }); +} + +/*** Lights, camera, action! ***/ +init(); +userInit(); diff --git a/PhabricatorA11yFixes.user.js b/PhabricatorA11yFixes.user.js index 1377201..652055e 100644 --- a/PhabricatorA11yFixes.user.js +++ b/PhabricatorA11yFixes.user.js @@ -3,9 +3,9 @@ // @namespace http://axSgrease.nvaccess.org/ // @description Improves the accessibility of Phabricator. // @author James Teh -// @copyright 2018 Mozilla Corporation +// @copyright 2018-2025 Mozilla Corporation // @license Mozilla Public License version 2.0 -// @version 2018.2 +// @version 2025.1 // @grant GM_log // @include https://phabricator.services.mozilla.com/D* // ==/UserScript== @@ -35,6 +35,12 @@ function setLabel(el, label) { el.setAttribute("aria-label", label); } +function labelHiddenStatusIcon(el, label) { + el.removeAttribute("aria-hidden"); + el.setAttribute("role", "image"); + setLabel(el, label); +} + /*** Code to apply the tweaks when appropriate. ***/ function applyTweaks(root, tweaks) { @@ -90,6 +96,21 @@ const LOAD_TWEAKS = [ // The diff is an h1, so the files inside the diff should be an h2, not an h1. {selector: '.differential-file-icon-header', tweak: [makeHeading, 2]}, + // Reviewer status icons. + {selector: '.phui-status-item-target .fa-circle-o', + tweak: [labelHiddenStatusIcon, "pending"]}, + {selector: '.phui-status-item-target .fa-minus-circle', + tweak: [labelHiddenStatusIcon, "pending blocking"]}, + {selector: '.phui-status-item-target .fa-check-circle', + tweak: [labelHiddenStatusIcon, "accepted"]}, + {selector: '.phui-status-item-target .fa-check-circle-o', + tweak: [labelHiddenStatusIcon, "accepted prior"]}, + {selector: '.phui-status-item-target .fa-times-circle', + tweak: [labelHiddenStatusIcon, "requested changes"]}, + {selector: '.phui-status-item-target .fa-times-circle-o', + tweak: [labelHiddenStatusIcon, "requested changes to prior"]}, + {selector: '.phui-status-item-target .fa-comment', + tweak: [labelHiddenStatusIcon, "comment"]}, ] // Tweaks that must be applied whenever a node is added. diff --git a/WhatsAppA11yFixes.user.js b/WhatsAppA11yFixes.user.js new file mode 100644 index 0000000..6b24389 --- /dev/null +++ b/WhatsAppA11yFixes.user.js @@ -0,0 +1,281 @@ +// ==UserScript== +// @name WhatsApp Web Accessibility Fixes +// @namespace http://axSgrease.nvaccess.org/ +// @description Improves the accessibility of WhatsApp Web. +// @author James Teh +// @copyright 2019-2026 Mozilla Corporation, Derek Riemer, James Teh +// @license Mozilla Public License version 2.0 +// @version 2026.1 +// @include https://web.whatsapp.com/ +// ==/UserScript== + +/*** Functions for common tweaks. ***/ + +/** + * Adds text to the given live region, and clears it a second later so it's no + * longer perceivable. + * @param {string} regionid an id of a region. + */ +function announce(text, regionId) { + getLiveRegion(regionId) + .then((region) => { + region.innerText = text; + setTimeout(() => { + region.innerText = ''; + }, 1000); + }); +} + +/** + * create or fetch a live region that can be used with announce(). Returns a promise with the region. + * @param {string} id the name of the new live region. This is an html id. + * @return {!Promise} a div that contains the live region. This can typically be ignored, this exists to aid in chaining creation of non-existant regions. + */ +function getLiveRegion(id) { + const updatePromise = new Promise((resolve, reject) => { + if (!id) { + reject('Need a valid id!'); + return; + } + const existingRegion = document.getElementById(id); + if (existingRegion) { + resolve(existingRegion); + return; + } + const region = document.createElement('div'); + region.id = id; + region.setAttribute('aria-live', 'polite'); + region.setAttribute('aria-atomic', 'true'); + region.style.position = 'absolute'; + region.style.width = '50px'; + region.style.height = '50px'; + region.style.opasity = 0; + document.body.appendChild(region); + // we need to delay a little to get the new region to actually read contents. + // A11y APIs probably don't treat the relevant changes as "additions" until + //an annimation frame has passed. It may, in reality be more like 2-4 + // annimation frames, so delay 134 ms to be safe. + setTimeout(() => { + resolve(region); + }, 134); + }); + return updatePromise; +} + +function makeHeading(el, level) { + el.setAttribute("role", "heading"); + el.setAttribute("aria-level", level); +} + +function makeRegion(el, label) { + el.setAttribute("role", "region"); + el.setAttribute("aria-label", label); +} + +function makeButton(el, label) { + el.setAttribute("role", "button"); + if (label) { + el.setAttribute("aria-label", label); + } +} + +function makePresentational(el) { + el.setAttribute("role", "presentation"); +} + +function setLabel(el, label) { + el.setAttribute("aria-label", label); +} + +function makeHidden(el) { + el.setAttribute("aria-hidden", "true"); +} + +function setExpanded(el, expanded) { + el.setAttribute("aria-expanded", expanded ? "true" : "false"); +} + +var idCounter = 0; +// Get a node's id. If it doesn't have one, make and set one first. +function setAriaIdIfNecessary(elem) { + if (!elem.id) { + elem.setAttribute("id", "axsg-" + idCounter++); + } + return elem.id; +} + +function makeElementOwn(parentElement, listOfNodes) { + ids = []; + for (let node of listOfNodes) { + ids.push(setAriaIdIfNecessary(node)); + } + parentElement.setAttribute("aria-owns", ids.join(" ")); +} + +// Focus something even if it wasn't made focusable by the author. +function forceFocus(el) { + let focusable = el.hasAttribute("tabindex"); + if (focusable) { + el.focus(); + return; + } + el.setAttribute("tabindex", "-1"); + el.focus(); +} + +/*** Code to apply the tweaks when appropriate. ***/ + +function applyTweak(el, tweak) { + if (Array.isArray(tweak.tweak)) { + let [func, ...args] = tweak.tweak; + func(el, ...args); + } else { + tweak.tweak(el); + } +} + +function applyTweaks(root, tweaks, checkRoot, forAttrChange = false) { + for (let tweak of tweaks) { + if (!forAttrChange || tweak.whenAttrChangedOnAncestor !== false) { + for (let el of root.querySelectorAll(tweak.selector)) { + try { + applyTweak(el, tweak); + } catch (e) { + console.log("Exception while applying tweak for '" + tweak.selector + "': " + e); + } + } + } + if (checkRoot && root.matches(tweak.selector)) { + try { + applyTweak(root, tweak); + } catch (e) { + console.log("Exception while applying tweak for '" + tweak.selector + "': " + e); + } + } + } +} + +let observer = new MutationObserver(function (mutations) { + for (let mutation of mutations) { + try { + if (mutation.type === "childList") { + for (let node of mutation.addedNodes) { + if (node.nodeType != Node.ELEMENT_NODE) { + continue; + } + applyTweaks(node, DYNAMIC_TWEAKS, true); + } + } else if (mutation.type === "attributes") { + applyTweaks(mutation.target, DYNAMIC_TWEAKS, true, true); + } + } catch (e) { + // Catch exceptions for individual mutations so other mutations are still handled. + console.log("Exception while handling mutation: " + e); + } + } +}); + +function init() { + applyTweaks(document, LOAD_TWEAKS, false); + applyTweaks(document, DYNAMIC_TWEAKS, false); + options = { childList: true, subtree: true }; + if (DYNAMIC_TWEAK_ATTRIBS.length > 0) { + options.attributes = true; + options.attributeFilter = DYNAMIC_TWEAK_ATTRIBS; + } + observer.observe(document, options); +} + +/*** Define the actual tweaks. ***/ + +// Tweaks that only need to be applied on load. +const LOAD_TWEAKS = [ +]; + +// Attributes that should be watched for changes and cause dynamic tweaks to be +// applied. +const DYNAMIC_TWEAK_ATTRIBS = ["aria-label"]; + +// Tweaks that must be applied whenever an element is added/changed. +const DYNAMIC_TWEAKS = [ + {selector: '[role=dialog]', + tweak: el => { + if (el.querySelector('[role=button][aria-pressed] img')) { + // This is the reaction picker. It contains buttons which wilh switch to + // browse mode, but we want to use WhatsApp's own arrow key navigation + // here. Therefore, use role="application". Menu would be more appropriate, + // but that's tricky because WhatsApp uses aria-pressed on the buttons. + el.role = "application"; + } + } + }, + {selector: '.message-in[aria-label]', + tweak: el => { + // Remove phone numbers for unknown contacts. These have a slightly different + // format for normal chats and group chats. + const m = el.ariaLabel.match( + /^(?:[^\d ]+ (.*? ))?\+\d{1,3} [\d ]+ (.*)$/s + ); + if (m) { + el.ariaLabel = `~ ${m[1] || ""} ${m[2]}`; + } + }}, +]; + +/** add your specific initialization here, so that if you ever update the framework from new skeleton your inits are not overridden. */ +function userInit(){ + document.addEventListener("keydown", event => { + // Make alt+1 focus the first chat in the Chats list. + if (event.altKey && event.key == "1") { + const firstChat = document.querySelector('[role=grid] [role=gridcell] [aria-selected]'); + if (firstChat) { + firstChat.focus(); + } + return; + } + // Make alt+2 focus the last message in the active chat. + if (event.altKey && event.key == "2") { + const messages = document.querySelectorAll(".focusable-list-item"); + if (messages.length > 0) { + const lastMessage = messages[messages.length - 1]; + if (!lastMessage.hasAttribute("tabindex")) { + // Messages don't initially have the tabindex attribute, but they gain it + // once you navigate to them with the keyboard. + lastMessage.setAttribute("tabindex", 0); + } + lastMessage.focus(); + } + return; + } + }); + document.addEventListener("contextmenu", event => { + // The context menu key doesn't work properly for chat and message items. + // Fix that. + const focus = document.activeElement; + const button = focus.querySelector( + focus.classList.contains("focusable-list-item") ? + // Message + '[role=button][aria-expanded]' : + // Chat + 'button' + ); + if (button) { + event.preventDefault(); + event.stopPropagation(); + button.click(); + } + }, { capture: true }); + document.addEventListener("focusout", event => { + // When the tab loses focus, WhatsApp forces focus into the message list, + // regardless of where it was before. Override that absurdity. + setTimeout(() => { + if (!document.hasFocus() && document.activeElement != event.target) { + event.target.focus(); + } + }, 50); + }); +} + +/*** Lights, camera, action! ***/ +init(); +userInit();