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
@@ -120,7 +120,7 @@ export default function AiChatSidebar() {
return (
<div className={classes.sidebar}>
<div className={classes.header}>
<span className={classes.title}>{t("AI Chat")}</span>
<h2 className={classes.title}>{t("AI Chat")}</h2>
<Tooltip label={t("New chat")} openDelay={250} withArrow>
<ActionIcon
component={Link}
@@ -176,7 +176,7 @@ export default function AiChatSidebar() {
))
: groupedChats.map((group) => (
<div key={group.key} className={classes.chatGroup}>
<div className={classes.chatGroupLabel}>{group.label}</div>
<h3 className={classes.chatGroupLabel}>{group.label}</h3>
{group.chats.map((chat) => (
<AiChatSidebarItem
key={chat.id}
@@ -56,9 +56,9 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
<div className={classes.emptyState}>
<IconSparkles size={48} stroke={1.5} className={classes.emptyStateIcon} />
<div className={classes.emptyStateBrand}>{t("Docmost AI")}</div>
<div className={classes.emptyStateTitle}>
<h1 className={classes.emptyStateTitle}>
{t("What can I help you with?")}
</div>
</h1>
<div className={classes.emptyStateInput}>
<ChatInput
@@ -71,7 +71,7 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
</div>
<div className={classes.suggestionsSection}>
<div className={classes.suggestionsLabel}>Get started</div>
<h2 className={classes.suggestionsLabel}>{t("Get started")}</h2>
<div className={classes.suggestionsGrid}>
{SUGGESTIONS.map((s) => (
<button
@@ -226,6 +226,7 @@ export default function ChatInput({
],
editorProps: {
attributes: {
role: "textbox",
"aria-label": placeholder || t("Ask anything... Use @ to mention pages"),
"aria-multiline": "true",
},
@@ -335,7 +336,15 @@ export default function ChatInput({
<EditorContent editor={editor} className={classes.editorContent} />
<div className={classes.actions}>
<Popover opened={plusMenuOpen} onChange={setPlusMenuOpen} position="top-start" width={220} shadow="md">
<Popover
opened={plusMenuOpen}
onChange={setPlusMenuOpen}
position="top-start"
width={220}
shadow="md"
trapFocus
returnFocus
>
<Popover.Target>
<button
type="button"
@@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { IconArrowDown, IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { VisuallyHidden } from "@mantine/core";
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
import ChatMessage from "./chat-message";
import classes from "../styles/ai-chat.module.css";
@@ -33,6 +34,7 @@ export default function ChatMessageList({
streamingContent,
streamingToolCalls,
}: Props) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
@@ -40,6 +42,38 @@ export default function ChatMessageList({
const prevScrollTopRef = useRef(0);
const [showScrollButton, setShowScrollButton] = useState(false);
// Dedicated status-region announcement for screen readers. Rather than
// putting aria-live on the whole transcript (which re-fires for every
// streamed token), announce "AI is thinking…" when streaming starts and
// the full assistant reply once streaming completes — a single, clean read.
const [statusAnnouncement, setStatusAnnouncement] = useState("");
const wasStreamingRef = useRef(false);
useEffect(() => {
const justStartedStreaming = isStreaming && !wasStreamingRef.current;
const justFinishedStreaming = !isStreaming && wasStreamingRef.current;
if (justStartedStreaming) {
setStatusAnnouncement(t("AI is thinking..."));
} else if (justFinishedStreaming) {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.role === "assistant" && lastMessage.content) {
// Strip markdown punctuation so screen readers don't read symbols
// like # * _ ` ~ aloud. A plain-text version is fine — the styled
// version stays in the DOM for visual users.
const plainText = lastMessage.content
.replace(/[#*_`~]/g, "")
.replace(/\s+/g, " ")
.trim();
setStatusAnnouncement(plainText);
} else {
setStatusAnnouncement("");
}
}
wasStreamingRef.current = isStreaming;
}, [isStreaming, messages, t]);
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const container = containerRef.current;
if (!container) return;
@@ -127,7 +161,18 @@ export default function ChatMessageList({
return (
<div className={classes.messageListWrapper}>
<div ref={containerRef} className={classes.messageList}>
{/* Single status region for chat announcements. Kept outside the
scrolling transcript so changes here trigger one polite read per
state change instead of re-announcing every streamed token. */}
<VisuallyHidden role="status" aria-live="polite">
{statusAnnouncement}
</VisuallyHidden>
<div
ref={containerRef}
className={classes.messageList}
aria-label={t("Chat transcript")}
>
{messages.map((msg) => (
<ErrorBoundary
key={msg.id}
@@ -162,7 +207,7 @@ export default function ChatMessageList({
{showScrollButton && (
<button
type="button"
aria-label="Scroll to bottom"
aria-label={t("Scroll to bottom")}
className={classes.scrollToBottomButton}
onClick={() => scrollToBottom("smooth")}
>
@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import DOMPurify from "dompurify";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
@@ -43,6 +44,7 @@ export default function ChatMessage({
streamingToolCalls,
}: Props) {
const navigate = useNavigate();
const { t } = useTranslation();
const handleContentClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
@@ -78,7 +80,11 @@ export default function ChatMessage({
}[]) || [];
return (
<div className={classes.userMessage}>
<div
className={classes.userMessage}
role="article"
aria-label={t("You said:")}
>
<div className={classes.userBubble}>
{attachments.length > 0 && (
<div className={classes.messageAttachments}>
@@ -100,8 +106,16 @@ export default function ChatMessage({
);
}
// Only label the article when there's something meaningful to announce.
// Tool-only assistant turns (no text) shouldn't announce "Assistant said:" with empty content.
const hasAnnouncableContent = Boolean(content);
return (
<div className={classes.assistantMessage}>
<div
className={classes.assistantMessage}
role="article"
aria-label={hasAnnouncableContent ? t("Assistant said:") : undefined}
>
<div className={classes.messageContent}>
{toolCalls && toolCalls.length > 0 && (
<ChatToolGroup toolCalls={toolCalls} isStreaming={isStreaming} />
@@ -131,7 +145,10 @@ export default function ChatMessage({
</div>
{!isStreaming && message.content && (
<div className={classes.messageActions}>
<CopyTextButton text={message?.content} />
<CopyTextButton
text={message?.content}
label={t("Copy assistant response")}
/>
</div>
)}
</div>
@@ -106,6 +106,7 @@
font-size: 1.5rem;
font-weight: 600;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
margin-top: 0;
margin-bottom: var(--mantine-spacing-xl);
text-align: center;
}
@@ -128,6 +129,7 @@
color: var(--mantine-color-dimmed);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 0;
margin-bottom: var(--mantine-spacing-sm);
}
@@ -114,7 +114,7 @@
}
:global(.ProseMirror p.is-editor-empty:first-child::before) {
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
color: var(--mantine-color-placeholder);
content: attr(data-placeholder);
float: left;
height: 0;
@@ -183,7 +183,7 @@
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: none;
cursor: pointer;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
transition: color 150ms, background-color 150ms;
@mixin hover {
@@ -15,6 +15,7 @@
}
.title {
margin: 0;
font-weight: 600;
font-size: var(--mantine-font-size-sm);
}
@@ -33,6 +34,7 @@
}
.chatGroupLabel {
margin: 0;
padding: 4px var(--mantine-spacing-xs);
font-size: var(--mantine-font-size-xs);
font-weight: 600;
@@ -118,7 +120,8 @@
color: inherit;
}
.chatItem:hover .chatItemDate {
.chatItem:hover .chatItemDate,
.chatItem:focus-within .chatItemDate {
opacity: 0;
}
@@ -133,6 +136,12 @@
position: relative;
}
.chatItem:hover .chatItemActions {
.chatItem:hover .chatItemActions,
.chatItem:focus-within .chatItemActions {
opacity: 1;
}
.chatItemActions :global(.mantine-ActionIcon-root):focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
@@ -33,6 +33,7 @@ export function ApiKeyCreatedModal({
onClose={onClose}
title={t("{{credential}} created", { credential: t("API key") })}
size="lg"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Alert
@@ -107,6 +107,7 @@ export function CreateApiKeyModal({
onClose={handleClose}
title={t("Create {{credential}}", { credential: t("API key") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
@@ -32,6 +32,7 @@ export function RevokeApiKeyModal({
onClose={onClose}
title={t("Revoke {{credential}}", { credential: t("API key") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Text>
@@ -55,6 +55,7 @@ export function UpdateApiKeyModal({
onClose={onClose}
title={t("Update {{credential}}", { credential: t("API key") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
@@ -111,6 +111,11 @@ export function LdapLoginModal({
placeholder={t("Enter your LDAP password")}
variant="filled"
disabled={isLoading}
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("password")}
/>
@@ -130,6 +130,11 @@ export function MfaBackupCodesModal({
label={t("Confirm password")}
placeholder={t("Enter your password")}
variant="filled"
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("confirmPassword")}
autoFocus
data-autofocus
@@ -107,6 +107,11 @@ export function MfaDisableModal({
<PasswordInput
label={t("Password")}
placeholder={t("Enter your password")}
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("confirmPassword")}
autoFocus
data-autofocus
@@ -79,7 +79,13 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
{t("Share")}
</Button>
<Modal opened={opened} onClose={close} title={t("Share")} size={600}>
<Modal
opened={opened}
onClose={close}
title={t("Share")}
size={600}
closeButtonProps={{ "aria-label": t("Close") }}
>
<Tabs value={activeTab} color="dark" onChange={setActiveTab}>
<Tabs.List mb="md">
<Tabs.Tab value="access">{t("Access")}</Tabs.Tab>
@@ -4,8 +4,8 @@ import {
Menu,
Modal,
Text,
ThemeIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
@@ -100,15 +100,20 @@ export function PageVerificationBadge({
if (!pageId) return null;
if (!hasVerificationFeature) {
if (readOnly) return null;
const lockedLabel = `${t("Add verification")}${upgradeLabel}`;
// Use ActionIcon (a real <button>) instead of a ThemeIcon so the tooltip
// is reachable on keyboard focus, and screen readers announce the upgrade
// hint via the accessible name. Click is a no-op since the feature is
// gated; the tooltip explains why.
return (
<Tooltip
label={`${t("Add verification")}${upgradeLabel}`}
withArrow
openDelay={250}
>
<ThemeIcon variant="subtle" color="gray">
<Tooltip label={lockedLabel} withArrow openDelay={250}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={lockedLabel}
>
<IconShieldCheck size={20} stroke={1.5} />
</ThemeIcon>
</ActionIcon>
</Tooltip>
);
}
@@ -132,20 +137,25 @@ export function PageVerificationBadge({
<>
{status !== "none" ? (
<Tooltip label={tooltipLabel} withArrow openDelay={250}>
<Group
gap={4}
<UnstyledButton
onClick={open}
style={{ cursor: "pointer" }}
wrap="nowrap"
aria-label={tooltipLabel}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
}}
>
<IconRosetteDiscountCheckFilled
size={18}
color={`var(--mantine-color-${getStatusColor(status).replace(".", "-")})`}
aria-hidden="true"
/>
<Text size="sm" c={getStatusColor(status)}>
{getStatusLabel(status, t)}
</Text>
</Group>
</UnstyledButton>
</Tooltip>
) : !readOnly ? (
<Tooltip label={t("Set up verification")} withArrow openDelay={250}>
@@ -18,6 +18,7 @@ import { CustomAvatar } from "@/components/ui/custom-avatar";
import { buildPageUrl } from "@/features/page/page.utils";
import { format } from "date-fns";
import NoTableResults from "@/components/common/no-table-results";
import rowClasses from "@/components/ui/clickable-table-row.module.css";
const MAX_VISIBLE_VERIFIERS = 5;
@@ -124,12 +125,13 @@ export default function VerificationListTable({
);
return (
<Table.Tr key={item.id}>
<Table.Tr key={item.id} className={rowClasses.row}>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{ color: "var(--mantine-color-text)" }}
className={rowClasses.link}
component={Link}
to={pageUrl}
>
@@ -52,6 +52,7 @@ export function CreateScimTokenModal({
onClose={handleClose}
title={t("Create {{credential}}", { credential: t("SCIM token") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
@@ -29,6 +29,7 @@ export function RevokeScimTokenModal({
onClose={onClose}
title={t("Revoke {{credential}}", { credential: t("SCIM token") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Text>
@@ -32,6 +32,7 @@ export function ScimTokenCreatedModal({
onClose={onClose}
title={t("{{credential}} created", { credential: t("SCIM token") })}
size="lg"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Alert
@@ -93,7 +93,11 @@ export function ScimTokenTable({
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("Token actions")}
>
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
@@ -52,6 +52,7 @@ export function UpdateScimTokenModal({
onClose={onClose}
title={t("Update {{credential}}", { credential: t("SCIM token") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
@@ -32,6 +32,7 @@ export default function SsoProviderModal({
ssoProviderType: provider.type.toUpperCase(),
})}
onClose={onClose}
closeButtonProps={{ "aria-label": t("Close") }}
>
{provider.type === SSO_PROVIDER.SAML && (
<SsoSamlForm provider={provider} onClose={onClose} />
@@ -52,7 +52,7 @@ export default function TemplatePreviewModal({
{t("Edit")}
</Button>
)}
<Modal.CloseButton />
<Modal.CloseButton aria-label={t("Close")} />
</Group>
</Modal.Header>
<Modal.Body p={0}>
@@ -283,6 +283,7 @@ export default function TemplateEditor() {
variant="subtle"
color="gray"
size="md"
aria-label={t("Template settings")}
onClick={() => {
setDraftSpaceId(spaceId);
openSettings();