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 (
 		
- -