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
@@ -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>
@@ -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>
@@ -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)}
@@ -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>
@@ -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>
@@ -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}
@@ -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}
@@ -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) => (
@@ -22,7 +22,7 @@ import { useTranslation } from "react-i18next";
import { IContributor } from "@/features/page/types/page.types.ts";
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
import clsx from "clsx";
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
@@ -125,7 +125,7 @@ type PageBylineProps = {
function PageByline({ creator, contributors, readOnly }: PageBylineProps) {
const { t } = useTranslation();
const toggleAside = useToggleAside();
const detailsTriggerProps = useAsideTriggerProps("details");
const otherContributors = (contributors ?? []).filter(
(c) => c.id !== creator?.id,
@@ -141,7 +141,9 @@ function PageByline({ creator, contributors, readOnly }: PageBylineProps) {
{creator && (
<Popover position="bottom-start" shadow="md" width={280} withArrow>
<Popover.Target>
<UnstyledButton>
<UnstyledButton
aria-label={t("Created by {{name}}", { name: creator.name })}
>
<Group gap={6}>
<CustomAvatar
avatarUrl={creator.avatarUrl}
@@ -203,7 +205,7 @@ function PageByline({ creator, contributors, readOnly }: PageBylineProps) {
variant="subtle"
color="gray"
aria-label={t("Details")}
onClick={() => toggleAside("details")}
{...detailsTriggerProps}
>
<IconInfoCircle size={20} stroke={1.5} />
</ActionIcon>
@@ -43,7 +43,6 @@ import {
import CommentDialog from "@/features/comment/components/comment-dialog";
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu";
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import { TableHandlesLayer } from "@/features/editor/components/table/handle/table-handles-layer";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
@@ -74,6 +73,7 @@ import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import { useTranslation } from "react-i18next";
interface PageEditorProps {
pageId: string;
@@ -88,6 +88,7 @@ export default function PageEditor({
content,
canComment,
}: PageEditorProps) {
const { t } = useTranslation();
const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false);
const editorRef = useRef<Editor | null>(null);
@@ -232,6 +233,9 @@ export default function PageEditor({
editorProps: {
scrollThreshold: 80,
scrollMargin: 80,
attributes: {
"aria-label": t("Page content"),
},
handleDOMEvents: {
keydown: (_view, event) => {
if (platformModifierKey(event) && event.code === "KeyS") {
@@ -391,6 +395,11 @@ export default function PageEditor({
immediatelyRender={true}
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": t("Page content"),
},
}}
/>
) : (
<div className="editor-container" style={{ position: "relative" }}>
@@ -421,9 +430,7 @@ export default function PageEditor({
{editor &&
!editorIsEditable &&
(editable || canComment) &&
providersRef.current && (
<ReadonlyBubbleMenu editor={editor} />
)}
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
{showCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} />
)}
@@ -1,7 +1,7 @@
.ProseMirror .is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: #adb5bd;
color: var(--mantine-color-placeholder);
pointer-events: none;
height: 0;
@@ -13,7 +13,7 @@
.ProseMirror .is-empty::before {
content: attr(data-placeholder);
float: left;
color: #adb5bd;
color: var(--mantine-color-placeholder);
pointer-events: none;
height: 0;
@@ -87,6 +87,9 @@ export function TitleEditor({
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: {
"aria-label": t("Page title"),
},
handleDOMEvents: {
keydown: (_view, event) => {
if (platformModifierKey(event) && event.code === "KeyS") {