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..79da3996f 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 {
@@ -126,6 +134,16 @@
}
}
+ a.photoswipe-slide {
+ display: inline-block;
+ height: 100%;
+ width: 100%;
+
+ &:focus {
+ outline-offset: -0.15rem;
+ }
+ }
+
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..98d365ca2 100644
--- a/web/source/css/_profile-header.css
+++ b/web/source/css/_profile-header.css
@@ -81,6 +81,24 @@
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 {
+ outline-offset: 0.2rem;
+ }
+ }
+
.avatar {
/*
Fit 100% of the wrapper.
diff --git a/web/source/css/base.css b/web/source/css/base.css
index 765453ac2..78e35339b 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.15rem dotted $link-fg;
+
+/*
+ Outline to give buttons when they're
+ focused (ie., by clicking or tabbing to them).
+*/
+$button-focus-outline: 0.15rem 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.15rem dashed $input-focus-border;
+
+/*
+ Outline to give summary elements when they're
+ focused (ie., by clicking or tabbing to them).
+*/
+$summary-focus-outline: 0.1rem dashed $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.1rem dashed $link-fg;
+
/******************************************
***** SECTION 2: BASIC GLOBAL STYLING *****
*******************************************/
@@ -88,6 +122,9 @@ body {
a {
color: $link-fg;
+ &:focus {
+ outline: $link-focus-outline;
+ }
}
/*
@@ -144,6 +181,14 @@ main {
&:hover {
background: $button-hover-bg;
}
+
+ &:focus {
+ outline: $button-focus-outline;
+ }
+}
+
+summary:focus {
+ outline: $summary-focus-outline;
}
/*
@@ -164,6 +209,11 @@ input, select, textarea, .input {
border-color: $input-focus-border;
}
+ &[type=checkbox]:focus,
+ &[type=radio]:focus {
+ 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/frontend/index.js b/web/source/frontend/index.js
index 5a6224994..f088bb3cd 100644
--- a/web/source/frontend/index.js
+++ b/web/source/frontend/index.js
@@ -143,11 +143,22 @@ 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:
+ // Link to parent status.
+ el.href = pswp.currSlide.data.parentStatus;
+ break;
+ case pswp.currSlide.data.element.dataset.pswpParentStatus:
+ // 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 +174,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 (
-
-