mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-30 19:02:24 -05:00 
			
		
		
		
	[bugfix/frontend] Fix accessibility/focus issues in settings + web ui
This commit is contained in:
		
					parent
					
						
							
								365b575341
							
						
					
				
			
			
				commit
				
					
						bfee7041c0
					
				
			
		
					 23 changed files with 380 additions and 152 deletions
				
			
		|  | @ -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; | ||||
|  |  | |||
|  | @ -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%; | ||||
|  |  | |||
|  | @ -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. | ||||
|  |  | |||
|  | @ -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 <pre> 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., | ||||
| 		 | ||||
|  |  | |||
|  | @ -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(); | ||||
| 			} | ||||
|  |  | |||
|  | @ -17,7 +17,7 @@ | |||
| 	along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | ||||
| */ | ||||
| 
 | ||||
| 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<HTMLInputElement>(null); | ||||
| 	const { onChange, infoComponent } = field; | ||||
| 	const id = nanoid(); | ||||
| 	const onClick = () => { | ||||
| 		ref.current?.click(); | ||||
| 	}; | ||||
| 
 | ||||
| 	return ( | ||||
| 		<div className="form-field file"> | ||||
| 			<label className="label-label" htmlFor={id}> | ||||
| 				{label} | ||||
| 			</label> | ||||
| 			<label className="label-button" htmlFor={id}> | ||||
| 				<div className="file-input button">Browse</div> | ||||
| 			<label | ||||
| 				className="label-wrapper" | ||||
| 				htmlFor={id} | ||||
| 				tabIndex={0} | ||||
| 				onClick={onClick} | ||||
| 				onKeyDown={e => e.key === "Enter" && onClick()} | ||||
| 				role="button" | ||||
| 			> | ||||
| 				<div className="label-label"> | ||||
| 					{label} | ||||
| 				</div> | ||||
| 				<div className="label-button"> | ||||
| 					<div className="file-input button">Browse</div> | ||||
| 				</div> | ||||
| 			</label> | ||||
| 			<input | ||||
| 				id={id} | ||||
| 				type="file" | ||||
| 				className="hidden" | ||||
| 				onChange={onChange} | ||||
| 				ref={ref ? ref as RefObject<HTMLInputElement> : undefined} | ||||
| 				ref={ref} | ||||
| 				{...props} | ||||
| 			/> | ||||
| 			{infoComponent} | ||||
|  |  | |||
|  | @ -17,7 +17,7 @@ | |||
| 	along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | ||||
| */ | ||||
| 
 | ||||
| import React from "react"; | ||||
| import React, { useRef } from "react"; | ||||
| import { useVerifyCredentialsQuery } from "../lib/query/login"; | ||||
| import { MediaAttachment, Status as StatusType } from "../lib/types/status"; | ||||
| import sanitize from "sanitize-html"; | ||||
|  | @ -122,10 +122,26 @@ function StatusBody({ status }: { status: StatusType }) { | |||
| 		content = sanitize(status.content); | ||||
| 	} | ||||
| 
 | ||||
| 	const detailsRef = useRef<HTMLDetailsElement>(null); | ||||
| 	const detailsOnClick = () => { | ||||
| 		detailsRef.current?.click(); | ||||
| 	}; | ||||
| 
 | ||||
| 	const summaryRef = useRef<HTMLElement>(null); | ||||
| 	const summaryOnClick = () => { | ||||
| 		summaryRef.current?.click(); | ||||
| 	}; | ||||
| 
 | ||||
| 	return ( | ||||
| 		<div className="status-body"> | ||||
| 			<details className="text-spoiler"> | ||||
| 				<summary> | ||||
| 			<details | ||||
| 				className="text-spoiler" | ||||
| 				ref={detailsRef} | ||||
| 			> | ||||
| 				<summary | ||||
| 					tabIndex={-1} | ||||
| 					ref={summaryRef} | ||||
| 				> | ||||
| 					<div | ||||
| 						className="spoiler-content" | ||||
| 						lang={status.language} | ||||
|  | @ -140,6 +156,8 @@ function StatusBody({ status }: { status: StatusType }) { | |||
| 						role="button" | ||||
| 						tabIndex={0} | ||||
| 						aria-label="Toggle content visibility" | ||||
| 						onClick={detailsOnClick} | ||||
| 						onKeyDown={e => e.key === "Enter" && summaryOnClick()} | ||||
| 					> | ||||
| 						Toggle content visibility | ||||
| 					</span> | ||||
|  | @ -183,23 +201,41 @@ function StatusMedia({ status }: { status: StatusType }) { | |||
| } | ||||
| 
 | ||||
| function StatusMediaEntry({ media }: { media: MediaAttachment }) { | ||||
| 	const detailsRef = useRef<HTMLDetailsElement>(null); | ||||
| 	const detailsOnClick = () => { | ||||
| 		detailsRef.current?.click(); | ||||
| 	}; | ||||
| 
 | ||||
| 	const summaryRef = useRef<HTMLElement>(null); | ||||
| 	const summaryOnClick = () => { | ||||
| 		summaryRef.current?.click(); | ||||
| 	}; | ||||
| 	 | ||||
| 	return ( | ||||
| 		<div className="media-wrapper"> | ||||
| 			<details className="image-spoiler media-spoiler"> | ||||
| 				<summary> | ||||
| 					<div className="show sensitive button" aria-hidden="true">Show media</div> | ||||
| 					<span className="eye button" role="button" tabIndex={0} aria-label="Toggle show media"> | ||||
| 				<summary tabIndex={-1} ref={summaryRef}> | ||||
| 					<div | ||||
| 						className="show sensitive button" | ||||
| 						role="button" | ||||
| 						tabIndex={0} | ||||
| 						aria-hidden="true" | ||||
| 						onClick={detailsOnClick} | ||||
| 						onKeyDown={e => e.key === "Enter" && summaryOnClick()} | ||||
| 					> | ||||
| 						Show media | ||||
| 					</div> | ||||
| 					<span | ||||
| 						className="eye button" | ||||
| 						role="button" | ||||
| 						tabIndex={0} | ||||
| 						aria-label="Toggle show media" | ||||
| 						onClick={detailsOnClick} | ||||
| 						onKeyDown={e => e.key === "Enter" && summaryOnClick()} | ||||
| 					> | ||||
| 						<i className="hide fa fa-fw fa-eye-slash" aria-hidden="true"></i> | ||||
| 						<i className="show fa fa-fw fa-eye" aria-hidden="true"></i> | ||||
| 					</span> | ||||
| 					<img | ||||
| 						src={media.preview_url} | ||||
| 						loading="lazy" | ||||
| 						alt={media.description} | ||||
| 						title={media.description} | ||||
| 						width={media.meta.small.width} | ||||
| 						height={media.meta.small.height} | ||||
| 					/> | ||||
| 				</summary> | ||||
| 				<a | ||||
| 					href={media.url} | ||||
|  |  | |||
|  | @ -150,19 +150,21 @@ function ReadyUsernameLozenge({ account, linkTo, backLocation, classNames }: Rea | |||
| 
 | ||||
| 	if (linkTo) { | ||||
| 		className += " pseudolink"; | ||||
| 		const onClick = () => { | ||||
| 			// When clicking on an account, direct
 | ||||
| 			// to the detail view for that account.
 | ||||
| 			setLocation(linkTo, { | ||||
| 				// Store the back location in history so
 | ||||
| 				// the detail view can use it to return to
 | ||||
| 				// this page (including query parameters).
 | ||||
| 				state: { backLocation: backLocation } | ||||
| 			}); | ||||
| 		}; | ||||
| 		return ( | ||||
| 			<span | ||||
| 				className={className} | ||||
| 				onClick={() => { | ||||
| 					// When clicking on an account, direct
 | ||||
| 					// to the detail view for that account.
 | ||||
| 					setLocation(linkTo, { | ||||
| 						// Store the back location in history so
 | ||||
| 						// the detail view can use it to return to
 | ||||
| 						// this page (including query parameters).
 | ||||
| 						state: { backLocation: backLocation } | ||||
| 					}); | ||||
| 				}} | ||||
| 				onClick={onClick} | ||||
| 				onKeyDown={e => e.key === "Enter" && onClick()} | ||||
| 				role="link" | ||||
| 				tabIndex={0} | ||||
| 			> | ||||
|  |  | |||
|  | @ -84,7 +84,7 @@ export default function useFileInput( | |||
| 	} | ||||
| 
 | ||||
| 	const infoComponent = ( | ||||
| 		<span className="form-info"> | ||||
| 		<span className="form-info text-cutoff"> | ||||
| 			{info | ||||
| 				? info | ||||
| 				: initialInfo | ||||
|  |  | |||
|  | @ -567,40 +567,34 @@ form { | |||
| } | ||||
| 
 | ||||
| .form-field.file { | ||||
| 	display: grid; | ||||
| 	grid-template-columns: auto 1fr; | ||||
| 	grid-template-rows: auto auto; | ||||
| 	grid-template-areas: | ||||
| 		"label-label  label-label" | ||||
| 		"label-button file-info" | ||||
| 	; | ||||
| 	 | ||||
| 	.label-label { | ||||
| 		grid-area: label-label; | ||||
| 	} | ||||
| 	display: flex; | ||||
| 	position: relative; | ||||
| 	overflow: hidden; | ||||
| 
 | ||||
| 	.label-button { | ||||
| 		grid-area: label-button; | ||||
| 	.label-wrapper { | ||||
| 		width: fit-content; | ||||
| 		display: flex; | ||||
| 		flex-direction: column; | ||||
| 		&:focus { | ||||
| 			outline: 0.15rem dashed $button-focus-border; | ||||
| 			outline-offset: -0.15rem; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	.form-info { | ||||
| 		grid-area: file-info; | ||||
| 		position: absolute; | ||||
| 		font-weight: initial; | ||||
| 		align-self: end; | ||||
| 		margin-left: 4.25rem; | ||||
| 		margin-bottom: 0.3rem; | ||||
| 
 | ||||
| 		.error { | ||||
| 			padding: 0.1rem; | ||||
|   			line-height: 1.4rem; | ||||
| 			line-height: 1.4rem; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| span.form-info { | ||||
| 	flex: 1 1 auto; | ||||
| 	overflow: hidden; | ||||
| 	text-overflow: ellipsis; | ||||
| 	white-space: nowrap; | ||||
| 	padding: 0.3rem 0; | ||||
| 	font-weight: initial; | ||||
| } | ||||
| 
 | ||||
| .checkbox-list { | ||||
| 	.header, .entry { | ||||
| 		display: grid; | ||||
|  | @ -1337,6 +1331,9 @@ button.tab-button { | |||
| .pseudolink { | ||||
| 	cursor: pointer; | ||||
| 	text-decoration: none; | ||||
| 	&:focus { | ||||
| 		outline: 0.15rem dotted $button-focus-border; | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| .info-list { | ||||
|  | @ -1738,7 +1735,6 @@ button.tab-button { | |||
| 	 | ||||
| 			.mutation-button { | ||||
| 				width: 100%; | ||||
| 				overflow-x: hidden; | ||||
| 	 | ||||
| 				button { | ||||
| 					font-size: 1rem; | ||||
|  |  | |||
|  | @ -65,20 +65,23 @@ export default function HeaderPermsOverview() { | |||
| 	} = useGetHeaderAllowsQuery(NoArg, { skip: permType !== "allow" }); | ||||
| 
 | ||||
| 	const itemToEntry = (perm: HeaderPermission) => { | ||||
| 		const onClick = () => { | ||||
| 			// When clicking on a header perm,
 | ||||
| 			// go to the detail view for perm.
 | ||||
| 			setLocation(`/${permType}s/${perm.id}`, { | ||||
| 				// Store the back location in
 | ||||
| 				// history so the detail view
 | ||||
| 				// can use it to return here.
 | ||||
| 				state: { backLocation: location } | ||||
| 			}); | ||||
| 		}; | ||||
| 		 | ||||
| 		return ( | ||||
| 			<dl | ||||
| 				key={perm.id} | ||||
| 				className="entry pseudolink" | ||||
| 				onClick={() => { | ||||
| 					// When clicking on a header perm,
 | ||||
| 					// go to the detail view for perm.
 | ||||
| 					setLocation(`/${permType}s/${perm.id}`, { | ||||
| 						// Store the back location in
 | ||||
| 						// history so the detail view
 | ||||
| 						// can use it to return here.
 | ||||
| 						state: { backLocation: location } | ||||
| 					}); | ||||
| 				}} | ||||
| 				onClick={onClick} | ||||
| 				onKeyDown={e => e.key === "Enter" && onClick()} | ||||
| 				role="link" | ||||
| 				tabIndex={0} | ||||
| 			> | ||||
|  |  | |||
|  | @ -211,21 +211,24 @@ function DraftListEntry({ permDraft, linkTo, backLocation }: DraftEntryProps) { | |||
| 
 | ||||
| 	const title = `${permTypeUpper} ${domain}`; | ||||
| 
 | ||||
| 	const onClick = () => { | ||||
| 		// When clicking on a draft, direct
 | ||||
| 		// to the detail view for that draft.
 | ||||
| 		setLocation(linkTo, { | ||||
| 			// Store the back location in history so
 | ||||
| 			// the detail view can use it to return to
 | ||||
| 			// this page (including query parameters).
 | ||||
| 			state: { backLocation: backLocation } | ||||
| 		}); | ||||
| 	}; | ||||
| 
 | ||||
| 	return ( | ||||
| 		<span | ||||
| 			className={`pseudolink domain-permission-draft entry ${permType}`} | ||||
| 			aria-label={title} | ||||
| 			title={title} | ||||
| 			onClick={() => { | ||||
| 				// When clicking on a draft, direct
 | ||||
| 				// to the detail view for that draft.
 | ||||
| 				setLocation(linkTo, { | ||||
| 					// Store the back location in history so
 | ||||
| 					// the detail view can use it to return to
 | ||||
| 					// this page (including query parameters).
 | ||||
| 					state: { backLocation: backLocation } | ||||
| 				}); | ||||
| 			}} | ||||
| 			onClick={onClick} | ||||
| 			onKeyDown={e => e.key === "Enter" && onClick()} | ||||
| 			role="link" | ||||
| 			tabIndex={0} | ||||
| 		> | ||||
|  |  | |||
|  | @ -186,21 +186,24 @@ function ExcludeListEntry({ permExclude, linkTo, backLocation }: ExcludeEntryPro | |||
| 		return <ErrorC error={new Error("id was undefined")} />; | ||||
| 	} | ||||
| 
 | ||||
| 	const onClick = () => { | ||||
| 		// When clicking on a exclude, direct
 | ||||
| 		// to the detail view for that exclude.
 | ||||
| 		setLocation(linkTo, { | ||||
| 			// Store the back location in history so
 | ||||
| 			// the detail view can use it to return to
 | ||||
| 			// this page (including query parameters).
 | ||||
| 			state: { backLocation: backLocation } | ||||
| 		}); | ||||
| 	}; | ||||
| 
 | ||||
| 	return ( | ||||
| 		<span | ||||
| 			className={`pseudolink domain-permission-exclude entry`} | ||||
| 			aria-label={`Exclude ${domain}`} | ||||
| 			title={`Exclude ${domain}`} | ||||
| 			onClick={() => { | ||||
| 				// When clicking on a exclude, direct
 | ||||
| 				// to the detail view for that exclude.
 | ||||
| 				setLocation(linkTo, { | ||||
| 					// Store the back location in history so
 | ||||
| 					// the detail view can use it to return to
 | ||||
| 					// this page (including query parameters).
 | ||||
| 					state: { backLocation: backLocation } | ||||
| 				}); | ||||
| 			}} | ||||
| 			onClick={onClick} | ||||
| 			onKeyDown={e => e.key === "Enter" && onClick()} | ||||
| 			role="link" | ||||
| 			tabIndex={0} | ||||
| 		> | ||||
|  |  | |||
|  | @ -17,7 +17,7 @@ | |||
| 	along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | ||||
| */ | ||||
| 
 | ||||
| import React from "react"; | ||||
| import React, { useRef } from "react"; | ||||
| 
 | ||||
| import { useEffect } from "react"; | ||||
| import { useExportDomainListMutation } from "../../../lib/query/admin/domain-permissions/export"; | ||||
|  | @ -70,6 +70,11 @@ export default function ImportExportForm({ form, submitParse, parseResult }: Imp | |||
| 		/* eslint-disable-next-line react-hooks/exhaustive-deps */ | ||||
| 	}, [exportResult]); | ||||
| 
 | ||||
| 	const importFileRef = useRef<HTMLInputElement>(null); | ||||
| 	const importFileOnClick = () => { | ||||
| 		importFileRef.current?.click(); | ||||
| 	}; | ||||
| 
 | ||||
| 	return ( | ||||
| 		<> | ||||
| 			<h1>Import / Export domain permissions</h1> | ||||
|  | @ -101,7 +106,13 @@ export default function ImportExportForm({ form, submitParse, parseResult }: Imp | |||
| 						showError={false} | ||||
| 						disabled={form.permType.value === undefined || form.permType.value.length === 0} | ||||
| 					/> | ||||
| 					<label className={`button with-icon${form.permType.value === undefined || form.permType.value.length === 0 ? " disabled" : ""}`}> | ||||
| 					<label | ||||
| 						className={`button with-icon${form.permType.value === undefined || form.permType.value.length === 0 ? " disabled" : ""}`} | ||||
| 						tabIndex={0} | ||||
| 						onClick={importFileOnClick} | ||||
| 						onKeyDown={e => e.key === "Enter" && importFileOnClick()} | ||||
| 						role="button" | ||||
| 					> | ||||
| 						<i className="fa fa-fw " aria-hidden="true" /> | ||||
| 						Import file | ||||
| 						<input | ||||
|  | @ -110,6 +121,7 @@ export default function ImportExportForm({ form, submitParse, parseResult }: Imp | |||
| 							onChange={fileChanged} | ||||
| 							accept="application/json,text/plain,text/csv" | ||||
| 							disabled={form.permType.value === undefined || form.permType.value.length === 0} | ||||
| 							ref={importFileRef} | ||||
| 						/> | ||||
| 					</label> | ||||
| 					<b /> {/* grid filler */} | ||||
|  |  | |||
|  | @ -109,21 +109,24 @@ export function SubscriptionListEntry({ permSub, linkTo, backLocation }: Subscri | |||
| 		successfullyFetchedAtStr = new Date(successfullyFetchedAt).toDateString(); | ||||
| 	} | ||||
| 
 | ||||
| 	const onClick = () => { | ||||
| 		// When clicking on a subscription, direct
 | ||||
| 		// to the detail view for that subscription.
 | ||||
| 		setLocation(linkTo, { | ||||
| 			// Store the back location in history so
 | ||||
| 			// the detail view can use it to return to
 | ||||
| 			// this page (including query parameters).
 | ||||
| 			state: { backLocation: backLocation } | ||||
| 		}); | ||||
| 	}; | ||||
| 
 | ||||
| 	return ( | ||||
| 		<span | ||||
| 			className={`pseudolink domain-permission-subscription entry`} | ||||
| 			aria-label={ariaLabel} | ||||
| 			title={ariaLabel} | ||||
| 			onClick={() => { | ||||
| 				// When clicking on a subscription, direct
 | ||||
| 				// to the detail view for that subscription.
 | ||||
| 				setLocation(linkTo, { | ||||
| 					// Store the back location in history so
 | ||||
| 					// the detail view can use it to return to
 | ||||
| 					// this page (including query parameters).
 | ||||
| 					state: { backLocation: backLocation } | ||||
| 				}); | ||||
| 			}} | ||||
| 			onClick={onClick} | ||||
| 			onKeyDown={e => e.key === "Enter" && onClick()} | ||||
| 			role="link" | ||||
| 			tabIndex={0} | ||||
| 		> | ||||
|  |  | |||
|  | @ -184,21 +184,24 @@ function ReportListEntry({ report, linkTo, backLocation }: ReportEntryProps) { | |||
| 	const created = new Date(report.created_at).toLocaleString(); | ||||
| 	const title = `${status}. @${target.account.acct} was reported by @${from.account.acct} on ${created}. Reason: "${comment}"`; | ||||
| 
 | ||||
| 	const onClick = () => { | ||||
| 		// When clicking on a report, direct
 | ||||
| 		// to the detail view for that report.
 | ||||
| 		setLocation(linkTo, { | ||||
| 			// Store the back location in history so
 | ||||
| 			// the detail view can use it to return to
 | ||||
| 			// this page (including query parameters).
 | ||||
| 			state: { backLocation: backLocation } | ||||
| 		}); | ||||
| 	}; | ||||
| 
 | ||||
| 	return ( | ||||
| 		<span | ||||
| 			className={`pseudolink report entry${report.action_taken ? " resolved" : ""}`} | ||||
| 			aria-label={title} | ||||
| 			title={title} | ||||
| 			onClick={() => { | ||||
| 				// When clicking on a report, direct
 | ||||
| 				// to the detail view for that report.
 | ||||
| 				setLocation(linkTo, { | ||||
| 					// Store the back location in history so
 | ||||
| 					// the detail view can use it to return to
 | ||||
| 					// this page (including query parameters).
 | ||||
| 					state: { backLocation: backLocation } | ||||
| 				}); | ||||
| 			}} | ||||
| 			onClick={onClick} | ||||
| 			onKeyDown={e => e.key === "Enter" && onClick()} | ||||
| 			role="link" | ||||
| 			tabIndex={0} | ||||
| 		> | ||||
|  |  | |||
|  | @ -139,21 +139,24 @@ function ApplicationListEntry({ app, linkTo, backLocation }: ApplicationListEntr | |||
| 	const created = useCreated(app); | ||||
| 	const redirectURIs = useRedirectURIs(app); | ||||
| 
 | ||||
| 	const onClick = () => { | ||||
| 		// When clicking on an app, direct
 | ||||
| 		// to the detail view for that app.
 | ||||
| 		setLocation(linkTo, { | ||||
| 			// Store the back location in history so
 | ||||
| 			// the detail view can use it to return to
 | ||||
| 			// this page (including query parameters).
 | ||||
| 			state: { backLocation: backLocation } | ||||
| 		}); | ||||
| 	}; | ||||
| 
 | ||||
| 	return ( | ||||
| 		<span | ||||
| 			className={`pseudolink application entry`} | ||||
| 			aria-label={`${app.name}`} | ||||
| 			title={`${app.name}`} | ||||
| 			onClick={() => { | ||||
| 				// When clicking on an app, direct
 | ||||
| 				// to the detail view for that app.
 | ||||
| 				setLocation(linkTo, { | ||||
| 					// Store the back location in history so
 | ||||
| 					// the detail view can use it to return to
 | ||||
| 					// this page (including query parameters).
 | ||||
| 					state: { backLocation: backLocation } | ||||
| 				}); | ||||
| 			}} | ||||
| 			onClick={onClick} | ||||
| 			onKeyDown={e => e.key === "Enter" && onClick()} | ||||
| 			role="link" | ||||
| 			tabIndex={0} | ||||
| 		> | ||||
|  |  | |||
|  | @ -102,7 +102,6 @@ export default function Export({ exportStats }: { exportStats: AccountExportStat | |||
| 						Following {exportStats.following_count} account{ exportStats.following_count !== 1 && "s" } | ||||
| 					</span> | ||||
| 					<MutationButton | ||||
| 						className="text-cutoff" | ||||
| 						label="Download following.csv" | ||||
| 						type="button" | ||||
| 						onClick={() => exportFollowing()} | ||||
|  | @ -116,7 +115,6 @@ export default function Export({ exportStats }: { exportStats: AccountExportStat | |||
| 						Followed by {exportStats.followers_count} account{ exportStats.followers_count !== 1 && "s" } | ||||
| 					</span> | ||||
| 					<MutationButton | ||||
| 						className="text-cutoff" | ||||
| 						label="Download followers.csv" | ||||
| 						type="button" | ||||
| 						onClick={() => exportFollowers()} | ||||
|  | @ -130,7 +128,6 @@ export default function Export({ exportStats }: { exportStats: AccountExportStat | |||
| 						Created {exportStats.lists_count} list{ exportStats.lists_count !== 1 && "s" } | ||||
| 					</span> | ||||
| 					<MutationButton | ||||
| 						className="text-cutoff" | ||||
| 						label="Download lists.csv" | ||||
| 						type="button" | ||||
| 						onClick={() => exportLists()} | ||||
|  | @ -144,7 +141,6 @@ export default function Export({ exportStats }: { exportStats: AccountExportStat | |||
| 						Blocking {exportStats.blocks_count} account{ exportStats.blocks_count !== 1 && "s" } | ||||
| 					</span> | ||||
| 					<MutationButton | ||||
| 						className="text-cutoff" | ||||
| 						label="Download blocks.csv" | ||||
| 						type="button" | ||||
| 						onClick={() => exportBlocks()} | ||||
|  | @ -158,7 +154,6 @@ export default function Export({ exportStats }: { exportStats: AccountExportStat | |||
| 						Muting {exportStats.mutes_count} account{ exportStats.mutes_count !== 1 && "s" } | ||||
| 					</span> | ||||
| 					<MutationButton | ||||
| 						className="text-cutoff" | ||||
| 						label="Download mutes.csv" | ||||
| 						type="button" | ||||
| 						onClick={() => exportMutes()} | ||||
|  |  | |||
|  | @ -174,21 +174,24 @@ function ReqsListEntry({ req, linkTo, backLocation }: ReqsListEntryProps) { | |||
| 	const ourContent = useContent(req.status); | ||||
| 	const theirContent = useContent(req.reply); | ||||
| 
 | ||||
| 	const onClick = () => { | ||||
| 		// When clicking on a request, direct
 | ||||
| 		// to the detail view for that request.
 | ||||
| 		setLocation(linkTo, { | ||||
| 			// Store the back location in history so
 | ||||
| 			// the detail view can use it to return to
 | ||||
| 			// this page (including query parameters).
 | ||||
| 			state: { backLocation: backLocation } | ||||
| 		}); | ||||
| 	}; | ||||
| 
 | ||||
| 	return ( | ||||
| 		<span | ||||
| 			className={`pseudolink entry interaction-request`} | ||||
| 			aria-label={label} | ||||
| 			title={label} | ||||
| 			onClick={() => { | ||||
| 				// When clicking on a request, direct
 | ||||
| 				// to the detail view for that request.
 | ||||
| 				setLocation(linkTo, { | ||||
| 					// Store the back location in history so
 | ||||
| 					// the detail view can use it to return to
 | ||||
| 					// this page (including query parameters).
 | ||||
| 					state: { backLocation: backLocation } | ||||
| 				}); | ||||
| 			}} | ||||
| 			onClick={onClick} | ||||
| 			onKeyDown={e => e.key === "Enter" && onClick()} | ||||
| 			role="link" | ||||
| 			tabIndex={0} | ||||
| 		> | ||||
|  |  | |||
|  | @ -167,6 +167,7 @@ function ProfileForm({ data: profile }: ProfileFormProps) { | |||
| 					<MutationButton | ||||
| 						className="delete-header-button" | ||||
| 						label="Delete header" | ||||
| 						tabIndex={0} | ||||
| 						disabled={noHeader} | ||||
| 						result={deleteHeaderRes} | ||||
| 						onClick={(e) => { | ||||
|  | @ -179,7 +180,7 @@ function ProfileForm({ data: profile }: ProfileFormProps) { | |||
| 						}} | ||||
| 					/> | ||||
| 				</fieldset> | ||||
| 				 | ||||
| 
 | ||||
| 				<fieldset className="file-input-with-image-description"> | ||||
| 					<legend>Avatar</legend> | ||||
| 					<FileInput | ||||
|  | @ -197,6 +198,7 @@ function ProfileForm({ data: profile }: ProfileFormProps) { | |||
| 					<MutationButton | ||||
| 						className="delete-avatar-button" | ||||
| 						label="Delete avatar" | ||||
| 						tabIndex={0} | ||||
| 						disabled={noAvatar} | ||||
| 						result={deleteAvatarRes} | ||||
| 						onClick={(e) => { | ||||
|  |  | |||
|  | @ -18,5 +18,5 @@ | |||
| */ -}} | ||||
| 
 | ||||
| {{- if .showLoginButton }} | ||||
| <div class="login"><a href="/login" class="button with-icon">Log in</a></div> | ||||
| <div class="login"><a tabindex="0" href="/login" class="button with-icon">Log in</a></div> | ||||
| {{- end }} | ||||
|  | @ -51,7 +51,13 @@ media photoswipe-gallery {{ (len .) | oddOrEven }} {{ if eq (len .) 1 }}single{{ | |||
|             <div class="spoiler-content p-summary" lang="{{- .LanguageTag.TagStr -}}"> | ||||
|                 {{ noescape .SpoilerContent | emojify .Emojis }} | ||||
|             </div> | ||||
|             <span class="button" role="button" tabindex="0">Toggle visibility</span> | ||||
|             {{- with . }} | ||||
|             {{- /* | ||||
|                 IMPORTANT: Button semantics and aria labels in "button" class below are added in | ||||
|                 frontend/index.js, as the button doesn't function as a button without javascript. | ||||
|             */ -}} | ||||
|             {{- end }} | ||||
|             <span class="button">Toggle visibility</span> | ||||
|         </summary> | ||||
|         <div class="text"> | ||||
|             {{- with . }} | ||||
|  |  | |||
|  | @ -54,7 +54,13 @@ | |||
|             {{- end }} | ||||
|         > | ||||
|             <div class="show sensitive button" aria-hidden="true">Show sensitive</div> | ||||
|             <span class="eye button" role="button" tabindex="0" aria-label="Toggle media visibility"> | ||||
|             {{- with . }} | ||||
|             {{- /* | ||||
|                 IMPORTANT: Button semantics and aria labels in "eye button" class below are added in | ||||
|                 frontend/index.js, as the button doesn't function as a button without javascript. | ||||
|             */ -}} | ||||
|             {{- end }} | ||||
|             <span class="eye button"> | ||||
|                 <i class="hide fa fa-fw fa-eye-slash" aria-hidden="true"></i> | ||||
|                 <i class="show fa fa-fw fa-eye" aria-hidden="true"></i> | ||||
|             </span> | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue