fix(a11y): WCAG 2.1 AA fixes (#2219)

This commit is contained in:
Philip Okugbe
2026-05-20 16:47:25 +01:00
committed by GitHub
parent 1c166c4736
commit 92c0e36e46
119 changed files with 1064 additions and 194 deletions

View File

@@ -198,7 +198,11 @@ export default function DrawioView(props: NodeViewProps) {
className={clsx(selected ? "ProseMirror-selectednode" : "")}
>
<div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray">
<ActionIcon
variant="transparent"
color="gray"
aria-label={t("Edit diagram")}
>
<IconEdit size={18} />
</ActionIcon>

View File

@@ -131,7 +131,11 @@ export default function EmbedView(props: NodeViewProps) {
className={clsx(selected ? "ProseMirror-selectednode" : "")}
>
<div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray">
<ActionIcon
variant="transparent"
color="gray"
aria-label={t("Edit embed")}
>
<IconEdit size={18} />
</ActionIcon>

View File

@@ -44,9 +44,11 @@ function EmojiList({
const [cats, setCats] = useState<EmojiCategory[]>([]);
const [activeCat, setActiveCat] = useState("");
const [focusZone, setFocusZone] = useState<"grid" | "tabs">("grid");
const [announce, setAnnounce] = useState("");
const listViewport = useRef<HTMLDivElement>(null);
const gridViewport = useRef<HTMLDivElement>(null);
const catBar = useRef<HTMLDivElement>(null);
const userInteractedRef = useRef(false);
const searching = query.length > 0;
const browseLoading = !searching && cats.length === 0;
@@ -74,6 +76,53 @@ function EmojiList({
vp?.querySelector<HTMLElement>(`[data-i="${idx}"]`)?.scrollIntoView({ block: "nearest" });
}, [idx, searching, focusZone]);
// Announce picker open and selection changes via a live region. Focus
// stays in the editor, so without this the screen reader has no way to
// know the picker exists or that arrow keys are changing the selection.
// The setTimeout defers the open message past the initial render so the
// live region is in the DOM before its content changes (screen readers
// ignore content that's present at mount time).
useEffect(() => {
const timer = setTimeout(() => {
setAnnounce(
t("Emoji picker open. Use arrow keys to navigate, Enter to select."),
);
}, 100);
return () => clearTimeout(timer);
}, [t]);
useEffect(() => {
// Skip data-driven updates (idx reset, async cat load); only announce
// selection changes that come from real user navigation.
if (!userInteractedRef.current) return;
if (focusZone === "tabs") {
if (activeCat) setAnnounce(t("{{name}} category", { name: activeCat }));
return;
}
if (searching) {
const item = items[idx];
if (item)
setAnnounce(
t("{{name}}, {{n}} of {{total}}", {
name: item.id,
n: idx + 1,
total: items.length,
}),
);
return;
}
const entry = gridItems[idx];
if (entry)
setAnnounce(
t("{{name}}, {{n}} of {{total}}", {
name: entry.id,
n: idx + 1,
total: gridItems.length,
}),
);
}, [idx, activeCat, focusZone, searching, items, gridItems, t]);
const pickSearchItem = useCallback(
(i: number) => {
const item = items[i];
@@ -94,6 +143,13 @@ function EmojiList({
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"].includes(
e.key,
)
) {
userInteractedRef.current = true;
}
if (searching) {
if (e.key === "ArrowDown") { e.preventDefault(); setIdx((i) => Math.min(i + 1, items.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setIdx((i) => Math.max(i - 1, 0)); }
@@ -131,6 +187,24 @@ function EmojiList({
role="listbox"
aria-label={t("Emoji picker")}
>
<div
role="status"
aria-live="polite"
aria-atomic="true"
style={{
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0,0,0,0)",
whiteSpace: "nowrap",
border: 0,
}}
>
{announce}
</div>
{searching ? (
<>
{isLoading && <Loader m="xs" size="xs" color="blue" type="dots" />}
@@ -171,6 +245,7 @@ function EmojiList({
title={c.id}
role="tab"
aria-selected={isActive}
aria-label={t("{{name}} category", { name: c.id })}
className={clsx(classes.catTab, {
[classes.catTabActive]: isActive,
[classes.catTabFocused]: isFocused,
@@ -190,6 +265,9 @@ function EmojiList({
key={entry.id}
data-i={i}
title={`:${entry.id}:`}
role="option"
aria-selected={i === idx}
aria-label={entry.id}
className={clsx(classes.emojiBtn, { [classes.active]: i === idx })}
onClick={() => pickGridItem(entry)}
onMouseEnter={() => setIdx(i)}

View File

@@ -240,7 +240,11 @@ export default function ExcalidrawView(props: NodeViewProps) {
className={clsx(selected ? "ProseMirror-selectednode" : "")}
>
<div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray">
<ActionIcon
variant="transparent"
color="gray"
aria-label={t("Edit drawing")}
>
<IconEdit size={18} />
</ActionIcon>

View File

@@ -149,8 +149,13 @@ export default function MathBlockView(props: NodeViewProps) {
></Textarea>
<Flex justify="flex-end" align="flex-end">
<ActionIcon variant="light" color="red">
<IconTrashX size={18} onClick={() => props.deleteNode()} />
<ActionIcon
variant="light"
color="red"
aria-label={t("Delete equation")}
onClick={() => props.deleteNode()}
>
<IconTrashX size={18} />
</ActionIcon>
</Flex>
</Stack>

View File

@@ -3,6 +3,7 @@ import React, {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
@@ -15,6 +16,7 @@ import {
ScrollArea,
Text,
UnstyledButton,
VisuallyHidden,
} from "@mantine/core";
import clsx from "clsx";
import classes from "./mention.module.css";
@@ -45,6 +47,8 @@ import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(1);
const viewportRef = useRef<HTMLDivElement>(null);
const [countAnnouncement, setCountAnnouncement] = useState("");
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
const { pageSlug, spaceSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useSpaceQuery(spaceSlug);
@@ -182,6 +186,45 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
setSelectedIndex(1);
}, [suggestion]);
const selectableCount = useMemo(
() => renderItems.filter((item) => item.entityType !== "header").length,
[renderItems],
);
useEffect(() => {
if (renderItems.length === 0) {
setCountAnnouncement(t("No results"));
return;
}
setCountAnnouncement(
t("{{count}} result available", { count: selectableCount }),
);
}, [renderItems.length, selectableCount, t]);
useEffect(() => {
const item = renderItems[selectedIndex];
if (!item || item.entityType === "header") {
setSelectionAnnouncement("");
return;
}
if (item.entityType === "user") {
setSelectionAnnouncement(`${t("People")}: ${item.label}`);
return;
}
if (item.entityType === "page") {
if (item.id === null) {
setSelectionAnnouncement(`${t("Create page")}: ${item.label}`);
return;
}
const pageLabel = item.label || t("Untitled");
setSelectionAnnouncement(
item.spaceName
? `${t("Pages")}: ${pageLabel}, ${item.spaceName}`
: `${t("Pages")}: ${pageLabel}`,
);
}
}, [selectedIndex, renderItems, t]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
@@ -269,6 +312,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
if (renderItems.length === 0) {
return (
<Paper id="mention" shadow="md" py="xs" withBorder radius="md">
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
{countAnnouncement}
</VisuallyHidden>
<Text c="dimmed" size="sm" px="sm">
{t("No results")}
</Text>
@@ -295,6 +341,12 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
aria-label={t("Mention suggestions")}
aria-activedescendant={`mention-option-${selectedIndex}`}
>
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
{countAnnouncement}
</VisuallyHidden>
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
{selectionAnnouncement}
</VisuallyHidden>
<ScrollArea.Autosize
viewportRef={viewportRef}
mah={350}

View File

@@ -10,6 +10,7 @@ import {
ScrollArea,
Text,
UnstyledButton,
VisuallyHidden,
} from "@mantine/core";
import classes from "./slash-menu.module.css";
import clsx from "clsx";
@@ -29,6 +30,8 @@ const CommandList = ({
const { t } = useTranslation();
const [selectedIndex, setSelectedIndex] = useState(0);
const viewportRef = useRef<HTMLDivElement>(null);
const [countAnnouncement, setCountAnnouncement] = useState("");
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
const flatItems = useMemo(() => {
return Object.values(items).flat();
@@ -79,6 +82,25 @@ const CommandList = ({
setSelectedIndex(0);
}, [flatItems]);
useEffect(() => {
if (flatItems.length === 0) {
setCountAnnouncement("");
return;
}
setCountAnnouncement(
t("{{count}} command available", { count: flatItems.length }),
);
}, [flatItems.length, t]);
useEffect(() => {
const item = flatItems[selectedIndex];
if (!item) {
setSelectionAnnouncement("");
return;
}
setSelectionAnnouncement(`${t(item.title)}, ${t(item.description)}`);
}, [selectedIndex, flatItems, t]);
useEffect(() => {
viewportRef.current
?.querySelector(`[data-item-index="${selectedIndex}"]`)
@@ -95,6 +117,12 @@ const CommandList = ({
aria-label={t("Slash commands")}
aria-activedescendant={`slash-command-option-${selectedIndex}`}
>
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
{countAnnouncement}
</VisuallyHidden>
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
{selectionAnnouncement}
</VisuallyHidden>
<ScrollArea
viewportRef={viewportRef}
h={350}

View File

@@ -3,7 +3,7 @@ import { TextSelection } from "@tiptap/pm/state";
import React, { FC, useEffect, useRef, useState } from "react";
import classes from "./table-of-contents.module.css";
import clsx from "clsx";
import { Box, Text } from "@mantine/core";
import { Box, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
type TableOfContentsProps = {
@@ -156,9 +156,9 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
return (
<>
{props.isShare && (
<Text mb="md" fw={500}>
<Title order={2} size="h6" mb="md" fw={500}>
{t("Table of contents")}
</Text>
</Title>
)}
<div className={props.isShare ? classes.leftBorder : ""}>
{links.map((item, idx) => (