From 19cfa8d126a2ff54298150529e58e5e4f5495f09 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Wed, 9 Apr 2025 14:14:20 +0200
Subject: [PATCH] [bugfix] Fix a couple accessibility issues with `:focus`
elements (#3979)
* [bugfix/frontend] Fix accessibility/focus issues in settings + web ui
* fix little error
* tweaks
---
web/source/css/_colors.css | 1 +
web/source/css/_media-wrapper.css | 33 ++++++++
web/source/css/_profile-header.css | 19 +++++
web/source/css/base.css | 54 +++++++++++++
web/source/css/status.css | 8 ++
web/source/frontend/index.js | 75 +++++++++++++++----
.../settings/components/form/inputs.tsx | 29 +++++--
web/source/settings/components/status.tsx | 64 ++++++++++++----
.../settings/components/username-lozenge.tsx | 22 +++---
web/source/settings/lib/form/file.tsx | 2 +-
web/source/settings/style.css | 46 ++++++------
.../http-header-permissions/overview.tsx | 23 +++---
.../domain-permissions/drafts/index.tsx | 23 +++---
.../domain-permissions/excludes/index.tsx | 23 +++---
.../moderation/domain-permissions/form.tsx | 16 +++-
.../subscriptions/common.tsx | 23 +++---
.../views/moderation/reports/search.tsx | 23 +++---
.../views/user/applications/search.tsx | 23 +++---
.../views/user/export-import/export.tsx | 5 --
.../views/user/interactions/search.tsx | 23 +++---
.../settings/views/user/profile/profile.tsx | 4 +-
web/template/login_button.tmpl | 2 +-
web/template/status.tmpl | 8 +-
web/template/status_attachment.tmpl | 8 +-
24 files changed, 405 insertions(+), 152 deletions(-)
diff --git a/web/source/css/_colors.css b/web/source/css/_colors.css
index f8fb979a1..77f562df4 100644
--- a/web/source/css/_colors.css
+++ b/web/source/css/_colors.css
@@ -80,6 +80,7 @@ $profile-bg: $gray4;
$button-bg: $blue2;
$button-fg: $gray1;
$button-hover-bg: $blue3;
+$button-focus-border: $blue3;
$button-danger-bg: $error3;
$button-danger-fg: $white1;
diff --git a/web/source/css/_media-wrapper.css b/web/source/css/_media-wrapper.css
index a567cb0fd..55ad6eba0 100644
--- a/web/source/css/_media-wrapper.css
+++ b/web/source/css/_media-wrapper.css
@@ -74,6 +74,14 @@
div.blurhash-container > canvas {
display: none;
}
+
+ /*
+ Hide focus outline on click
+ to avoid ugly artifacts.
+ */
+ &:focus {
+ outline: none;
+ }
}
summary {
@@ -109,6 +117,16 @@
.hide {
display: none;
}
+
+ &:focus-visible {
+ /*
+ Can't rely on media having background with
+ decent contrast so inset and use button-fg
+ instead so focus is definitely visible.
+ */
+ outline: 0.25rem dashed $button-fg;
+ outline-offset: -0.25rem;
+ }
}
.show.sensitive {
@@ -126,6 +144,21 @@
}
}
+ a.photoswipe-slide {
+ display: inline-block;
+ height: 100%;
+ width: 100%;
+
+ /*
+ Inset outline to avoid outline
+ being hidden by overflow: hidden.
+ */
+ &:focus-visible {
+ outline: $button-focus-outline;
+ outline-offset: -0.25rem;
+ }
+ }
+
video.plyr-video, .plyr {
position: absolute;
height: 100%;
diff --git a/web/source/css/_profile-header.css b/web/source/css/_profile-header.css
index b4ebadf8d..cba67ffa1 100644
--- a/web/source/css/_profile-header.css
+++ b/web/source/css/_profile-header.css
@@ -81,6 +81,25 @@
height: $avatar-size;
width: $avatar-size;
+ /*
+ Link to open media in slide
+ should fill entire media wrapper.
+ */
+ a.photoswipe-slide {
+ display: inline-block;
+ height: 100%;
+ width: 100%;
+
+ /*
+ Offset to avoid clashing with
+ thick border around avatars.
+ */
+ &:focus-visible {
+ outline: $button-focus-outline;
+ outline-offset: 0.25rem;
+ }
+ }
+
.avatar {
/*
Fit 100% of the wrapper.
diff --git a/web/source/css/base.css b/web/source/css/base.css
index 765453ac2..6a5a6dd36 100644
--- a/web/source/css/base.css
+++ b/web/source/css/base.css
@@ -68,6 +68,40 @@ $br-inner: 0.2rem;
*/
$fa-fw: 1.28571429em;
+/*
+ Outline to give links when they're
+ focused (ie., by clicking or tabbing to them).
+*/
+$link-focus-outline: 0.25rem dotted $link-fg;
+
+/*
+ Outline to give buttons when they're
+ focused (ie., by clicking or tabbing to them).
+*/
+$button-focus-outline: 0.25rem dashed $button-focus-border;
+
+/*
+ Outline to give input elements like radio buttons
+ and checkboxes when they're focused (ie., by clicking
+ or tabbing to them).
+*/
+$input-clickable-focus-outline: 0.25rem dashed $input-focus-border;
+
+/*
+ Outline to give summary elements when they're
+ focused (ie., by clicking or tabbing to them).
+*/
+$summary-focus-outline: 0.25rem dotted $link-fg;
+
+/*
+ Outline to give
elements when they're
+ focused (ie., by clicking or tabbing to them).
+
+ This is used when we've got a preformatted
+ code block with a scroll bar inside of it.
+*/
+$pre-focus-outline: 0.25rem dashed $link-fg;
+
/******************************************
***** SECTION 2: BASIC GLOBAL STYLING *****
*******************************************/
@@ -88,6 +122,9 @@ body {
a {
color: $link-fg;
+ &:focus-visible {
+ outline: $link-focus-outline;
+ }
}
/*
@@ -144,6 +181,14 @@ main {
&:hover {
background: $button-hover-bg;
}
+
+ &:focus-visible {
+ outline: $button-focus-outline;
+ }
+}
+
+summary:focus-visible {
+ outline: $summary-focus-outline;
}
/*
@@ -164,6 +209,11 @@ input, select, textarea, .input {
border-color: $input-focus-border;
}
+ &[type=checkbox]:focus-visible,
+ &[type=radio]:focus-visible {
+ outline: $input-clickable-focus-outline;
+ }
+
&:invalid, .invalid & {
border-color: $input-error-border;
}
@@ -342,6 +392,10 @@ pre, pre[class*="language-"] {
white-space: pre;
overflow-x: auto;
+ &:focus {
+ outline: $pre-focus-outline;
+ }
+
/*
Code inside a pre block, ie.,
diff --git a/web/source/css/status.css b/web/source/css/status.css
index ec6cac3e5..6f2c458f4 100644
--- a/web/source/css/status.css
+++ b/web/source/css/status.css
@@ -299,6 +299,14 @@
position: absolute;
z-index: 0;
+
+ &:focus-visible {
+ /*
+ Inset focus to compensate for themes where
+ statuses have a really thick border.
+ */
+ outline-offset: -0.25rem;
+ }
}
&:first-child {
diff --git a/web/source/frontend/index.js b/web/source/frontend/index.js
index 5a6224994..6d4b1470d 100644
--- a/web/source/frontend/index.js
+++ b/web/source/frontend/index.js
@@ -143,11 +143,23 @@ lightbox.on('uiRegister', function() {
el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener');
pswp.on('change', () => {
- el.href = pswp.currSlide.data.parentStatus
- ? pswp.currSlide.data.parentStatus
- : pswp.currSlide.data.element.dataset.pswpParentStatus;
+ switch (true) {
+ case pswp.currSlide.data.parentStatus !== undefined:
+ // Link to parent status.
+ el.href = pswp.currSlide.data.parentStatus;
+ break;
+ case pswp.currSlide.data.element !== undefined &&
+ pswp.currSlide.data.element.dataset.pswpParentStatus !== undefined:
+ // Link to parent status.
+ el.href = pswp.currSlide.data.element.dataset.pswpParentStatus;
+ break;
+ default:
+ // Link to profile.
+ const location = window.location;
+ el.href = "//" + location.host + location.pathname;
+ }
});
- }
+ }
});
});
@@ -163,26 +175,63 @@ function dynamicSpoiler(className, updateFunc) {
});
}
-dynamicSpoiler("text-spoiler", (spoiler) => {
- const button = spoiler.querySelector(".button");
+dynamicSpoiler("text-spoiler", (details) => {
+ const summary = details.children[0];
+ const button = details.querySelector(".button");
+ // Use button inside summary to
+ // toggle post body visibility.
+ button.tabIndex = "0";
+ button.setAttribute("aria-role", "button");
+ button.onclick = () => {
+ details.click();
+ };
+
+ // Let enter also trigger the button
+ // (for those using keyboard to navigate).
+ button.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ summary.click();
+ }
+ });
+
+ // Change button text depending on
+ // whether spoiler is open or closed rn.
return () => {
- button.textContent = spoiler.open
+ button.textContent = details.open
? "Show less"
: "Show more";
};
});
-dynamicSpoiler("media-spoiler", (spoiler) => {
- const eye = spoiler.querySelector(".eye.button");
- const video = spoiler.querySelector(".plyr-video");
+dynamicSpoiler("media-spoiler", (details) => {
+ const summary = details.children[0];
+ const button = details.querySelector(".eye.button");
+ const video = details.querySelector(".plyr-video");
const loopingAuto = !reduceMotion.matches && video != null && video.classList.contains("gifv");
+ // Use button *instead of summary*
+ // to toggle media visibility.
+ summary.tabIndex = "-1";
+ button.tabIndex = "0";
+ button.setAttribute("aria-role", "button");
+ button.onclick = () => {
+ details.click();
+ };
+
+ // Let enter also trigger the button
+ // (for those using keyboard to navigate).
+ button.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ summary.click();
+ }
+ });
+
return () => {
- if (spoiler.open) {
- eye.setAttribute("aria-label", "Hide media");
+ if (details.open) {
+ button.setAttribute("aria-label", "Hide media");
} else {
- eye.setAttribute("aria-label", "Show media");
+ button.setAttribute("aria-label", "Show media");
if (video && !loopingAuto) {
video.pause();
}
diff --git a/web/source/settings/components/form/inputs.tsx b/web/source/settings/components/form/inputs.tsx
index 498499db6..c26b88f6a 100644
--- a/web/source/settings/components/form/inputs.tsx
+++ b/web/source/settings/components/form/inputs.tsx
@@ -17,7 +17,7 @@
along with this program. If not, see .
*/
-import React from "react";
+import React, { useRef } from "react";
import type {
ReactNode,
@@ -119,23 +119,36 @@ export interface FileInputProps extends React.DetailedHTMLProps<
}
export function FileInput({ label, field, ...props }: FileInputProps) {
- const { onChange, ref, infoComponent } = field;
+ const ref = useRef(null);
+ const { onChange, infoComponent } = field;
const id = nanoid();
+ const onClick = () => {
+ ref.current?.click();
+ };
return (
-
-