From 92c0e36e466de654abe6c4ccf7ebe828b58b24bf Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 20 May 2026 16:47:25 +0100 Subject: [PATCH] fix(a11y): WCAG 2.1 AA fixes (#2219) --- .../public/locales/en-US/translation.json | 36 ++++++++- .../src/components/common/avatar-uploader.tsx | 10 ++- apps/client/src/components/common/copy.tsx | 10 ++- .../src/components/common/export-modal.tsx | 2 +- .../src/components/common/recent-changes.tsx | 4 +- .../src/components/layouts/global/aside.tsx | 14 +++- .../layouts/global/global-app-shell.tsx | 11 ++- .../layouts/global/global-sidebar.module.css | 10 +++ .../layouts/global/global-sidebar.tsx | 2 + .../components/settings/settings-title.tsx | 2 +- .../ui/clickable-table-row.module.css | 29 +++++++ .../src/components/ui/custom-avatar.tsx | 14 ++-- .../destination-picker-modal.tsx | 2 +- .../components/ui/sidebar-toggle-button.tsx | 9 ++- .../src/components/ui/skip-to-main.module.css | 27 +++++++ .../client/src/components/ui/skip-to-main.tsx | 13 ++++ .../ee/ai-chat/components/ai-chat-sidebar.tsx | 4 +- .../ai-chat/components/chat-empty-state.tsx | 6 +- .../src/ee/ai-chat/components/chat-input.tsx | 11 ++- .../ai-chat/components/chat-message-list.tsx | 49 +++++++++++- .../ee/ai-chat/components/chat-message.tsx | 23 +++++- .../src/ee/ai-chat/styles/ai-chat.module.css | 2 + .../ee/ai-chat/styles/chat-input.module.css | 4 +- .../ee/ai-chat/styles/chat-sidebar.module.css | 13 +++- .../components/api-key-created-modal.tsx | 1 + .../components/create-api-key-modal.tsx | 1 + .../components/revoke-api-key-modal.tsx | 1 + .../components/update-api-key-modal.tsx | 1 + .../src/ee/components/ldap-login-modal.tsx | 5 ++ .../mfa/components/mfa-backup-codes-modal.tsx | 5 ++ .../ee/mfa/components/mfa-disable-modal.tsx | 5 ++ .../components/page-share-modal.tsx | 8 +- .../components/page-verification-modal.tsx | 36 +++++---- .../components/verification-list-table.tsx | 4 +- .../components/create-scim-token-modal.tsx | 1 + .../components/revoke-scim-token-modal.tsx | 1 + .../components/scim-token-created-modal.tsx | 1 + .../ee/scim/components/scim-token-table.tsx | 6 +- .../components/update-scim-token-modal.tsx | 1 + .../components/sso-provider-modal.tsx | 1 + .../components/template-preview-modal.tsx | 2 +- .../src/ee/template/pages/template-editor.tsx | 1 + .../features/auth/components/auth-layout.tsx | 2 +- .../auth/components/invite-sign-up-form.tsx | 5 ++ .../features/auth/components/login-form.tsx | 21 ++++- .../auth/components/password-reset-form.tsx | 5 ++ .../auth/components/setup-workspace-form.tsx | 5 ++ .../comment/components/comment-editor.tsx | 6 ++ .../components/comment-list-with-tabs.tsx | 2 + .../comment/components/comment.module.css | 5 ++ .../editor/components/drawio/drawio-view.tsx | 6 +- .../editor/components/embed/embed-view.tsx | 6 +- .../components/emoji-menu/emoji-list.tsx | 78 +++++++++++++++++++ .../components/excalidraw/excalidraw-view.tsx | 6 +- .../editor/components/math/math-block.tsx | 9 ++- .../components/mention/mention-list.tsx | 52 +++++++++++++ .../components/slash-menu/command-list.tsx | 28 +++++++ .../table-of-contents/table-of-contents.tsx | 6 +- .../src/features/editor/full-editor.tsx | 10 ++- .../src/features/editor/page-editor.tsx | 15 +++- .../features/editor/styles/placeholder.css | 4 +- .../src/features/editor/title-editor.tsx | 3 + .../favorite/components/star-button.tsx | 39 ++++++++-- .../components/add-group-member-modal.tsx | 7 +- .../group/components/create-group-form.tsx | 1 + .../group/components/create-group-modal.tsx | 7 +- .../group/components/edit-group-form.tsx | 11 ++- .../group/components/edit-group-modal.tsx | 12 ++- .../group/components/group-action-menu.tsx | 26 +++++-- .../features/group/components/group-list.tsx | 15 +++- .../group/components/group-members.tsx | 6 +- .../home/components/created-by-me.tsx | 4 +- .../home/components/favorites-pages.tsx | 4 +- .../components/notification-popover.tsx | 42 ++++++++-- .../components/backlinks-modal.tsx | 2 +- .../page-history/components/history-modal.tsx | 4 +- .../page/components/copy-page-modal.tsx | 2 +- .../components/header/page-header-menu.tsx | 9 ++- .../page/components/move-page-modal.tsx | 2 +- .../components/trash-page-content-modal.tsx | 2 +- .../features/page/trash/components/trash.tsx | 6 +- .../tree/components/space-tree-node-menu.tsx | 2 +- .../page/tree/components/space-tree-row.tsx | 2 +- .../components/search-spotlight-filters.tsx | 4 +- .../search/components/search-spotlight.tsx | 15 +++- .../components/share-search-spotlight.tsx | 1 + .../session/components/session-list.tsx | 17 +++- .../features/share/components/share-list.tsx | 4 +- .../features/share/components/share-shell.tsx | 8 +- .../components/add-space-members-modal.tsx | 44 +++++++---- .../space/components/create-space-form.tsx | 23 +++++- .../space/components/create-space-modal.tsx | 7 +- .../space/components/settings-modal.tsx | 2 +- .../space/components/sidebar/switch-space.tsx | 2 + .../components/space-carousel.module.css | 5 ++ .../space/components/space-carousel.tsx | 10 +-- .../space/components/space-details.tsx | 6 +- .../space/components/space-filter-menu.tsx | 17 +++- .../space/components/space-grid.module.css | 5 ++ .../features/space/components/space-grid.tsx | 8 +- .../features/space/components/space-list.tsx | 12 ++- .../space/components/space-members.tsx | 6 +- .../components/space-security-settings.tsx | 6 +- .../spaces-page/all-spaces-list.module.css | 8 +- .../spaces-page/all-spaces-list.tsx | 18 ++++- .../spaces-page/favorite-spaces-grid.tsx | 7 +- .../features/user/components/change-email.tsx | 13 +++- .../user/components/change-password.tsx | 10 +++ .../user/components/notification-pref.tsx | 19 +++-- .../members/components/invite-action-menu.tsx | 6 +- .../components/workspace-invite-form.tsx | 6 ++ apps/client/src/hooks/use-toggle-aside.tsx | 18 +++++ apps/client/src/lib/utils.tsx | 9 ++- apps/client/src/main.tsx | 1 + .../src/pages/favorites/favorites-page.tsx | 6 +- apps/client/src/pages/spaces/spaces.tsx | 2 +- apps/client/src/styles/a11y-overrides.css | 27 +++++++ apps/client/src/theme.ts | 29 +++++++ packages/editor-ext/src/lib/indent.ts | 10 ++- 119 files changed, 1064 insertions(+), 194 deletions(-) create mode 100644 apps/client/src/components/ui/clickable-table-row.module.css create mode 100644 apps/client/src/components/ui/skip-to-main.module.css create mode 100644 apps/client/src/components/ui/skip-to-main.tsx create mode 100644 apps/client/src/styles/a11y-overrides.css diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index c0b67e9d1..ec40a1967 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -411,6 +411,10 @@ "Write...": "Write...", "Column count": "Column count", "{{count}} Columns": "{{count}} Columns", + "{{count}} command available_one": "1 command available", + "{{count}} command available_other": "{{count}} commands available", + "{{count}} result available_one": "1 result available", + "{{count}} result available_other": "{{count}} results available", "Equal columns": "Equal columns", "Left sidebar": "Left sidebar", "Right sidebar": "Right sidebar", @@ -876,9 +880,12 @@ "AI Chat": "AI Chat", "Analyze for insights": "Analyze for insights", "Ask anything...": "Ask anything...", + "Assistant said:": "Assistant said:", "Chat history": "Chat history", "Chat name": "Chat name", + "Chat transcript": "Chat transcript", "Close": "Close", + "Copy assistant response": "Copy assistant response", "Docmost AI": "Docmost AI", "Failed to load chat. An error occurred.": "Failed to load chat. An error occurred.", "Failed to render this message.": "Failed to render this message.", @@ -888,6 +895,8 @@ "No chats found": "No chats found", "No conversations yet": "No conversations yet", "Open full page": "Open full page", + "Scroll to bottom": "Scroll to bottom", + "You said:": "You said:", "Previous 7 days": "Previous 7 days", "Previous 30 days": "Previous 30 days", "Search chats...": "Search chats...", @@ -1050,5 +1059,30 @@ "Updated {{date}}": "Updated {{date}}", "Cell actions": "Cell actions", "Column actions": "Column actions", - "Row actions": "Row actions" + "Row actions": "Row actions", + "Filter": "Filter", + "Page title": "Page title", + "Page content": "Page content", + "Member actions": "Member actions", + "Toggle password visibility": "Toggle password visibility", + "Send comment": "Send comment", + "Token actions": "Token actions", + "Template settings": "Template settings", + "Edit diagram": "Edit diagram", + "Edit embed": "Edit embed", + "Edit drawing": "Edit drawing", + "Delete equation": "Delete equation", + "Invite actions": "Invite actions", + "Get started": "Get started", + "* indicates required fields": "* indicates required fields", + "List of spaces in this workspace": "List of spaces in this workspace", + "Active sessions": "Active sessions", + "Add {{name}} to favorites": "Add {{name}} to favorites", + "Remove {{name}} from favorites": "Remove {{name}} from favorites", + "Added to favorites": "Added to favorites", + "Removed from favorites": "Removed from favorites", + "Added {{name}} to favorites": "Added {{name}} to favorites", + "Removed {{name}} from favorites": "Removed {{name}} from favorites", + "Page menu for {{name}}": "Page menu for {{name}}", + "Create subpage of {{name}}": "Create subpage of {{name}}" } diff --git a/apps/client/src/components/common/avatar-uploader.tsx b/apps/client/src/components/common/avatar-uploader.tsx index 750e4ba68..d7ac5f403 100644 --- a/apps/client/src/components/common/avatar-uploader.tsx +++ b/apps/client/src/components/common/avatar-uploader.tsx @@ -80,12 +80,20 @@ export default function AvatarUploader({ } }; - const ariaLabel = { + const actionLabel = { [AvatarIconType.AVATAR]: t("Change avatar"), [AvatarIconType.SPACE_ICON]: t("Change space icon"), [AvatarIconType.WORKSPACE_ICON]: t("Change workspace icon"), }[type]; + // Per WCAG 2.5.3 (Label in Name), the accessible name must include the + // visible text. When no image is set, the avatar renders the name's + // initials, so prepend the name to the action label. + const ariaLabel = + !currentImageUrl && fallbackName + ? `${fallbackName} – ${actionLabel}` + : actionLabel; + const handleRemove = async () => { if (disabled) return; diff --git a/apps/client/src/components/common/copy.tsx b/apps/client/src/components/common/copy.tsx index 2144417b9..8bf4ec938 100644 --- a/apps/client/src/components/common/copy.tsx +++ b/apps/client/src/components/common/copy.tsx @@ -8,15 +8,19 @@ interface CopyProps { text: string; size?: MantineSize; color?: MantineColor; + /** Override the accessible name (and tooltip) when not yet copied. Lets callers disambiguate adjacent copy buttons for screen readers. */ + label?: string; } -export default function CopyTextButton({ text, size }: CopyProps) { +export default function CopyTextButton({ text, size, label }: CopyProps) { const { t } = useTranslation(); + const copyLabel = label ?? t("Copy"); + return ( {({ copied, copy }) => ( @@ -25,7 +29,7 @@ export default function CopyTextButton({ text, size }: CopyProps) { variant="subtle" onClick={copy} size={size} - aria-label={copied ? t("Copied") : t("Copy")} + aria-label={copied ? t("Copied") : copyLabel} > {copied ? : } diff --git a/apps/client/src/components/common/export-modal.tsx b/apps/client/src/components/common/export-modal.tsx index 53de82467..2a83debf9 100644 --- a/apps/client/src/components/common/export-modal.tsx +++ b/apps/client/src/components/common/export-modal.tsx @@ -81,7 +81,7 @@ export default function ExportModal({ {t(`Export ${type}`)} - + diff --git a/apps/client/src/components/common/recent-changes.tsx b/apps/client/src/components/common/recent-changes.tsx index 8e0e56f29..f37b26f67 100644 --- a/apps/client/src/components/common/recent-changes.tsx +++ b/apps/client/src/components/common/recent-changes.tsx @@ -17,6 +17,7 @@ import { EmptyState } from "@/components/ui/empty-state.tsx"; import { getSpaceUrl } from "@/lib/config.ts"; import { useTranslation } from "react-i18next"; import { getInitialsColor } from "@/lib/get-initials-color.ts"; +import rowClasses from "@/components/ui/clickable-table-row.module.css"; interface Props { spaceId?: string; @@ -41,9 +42,10 @@ export default function RecentChanges({ spaceId }: Props) { {pages.map((page) => ( - + diff --git a/apps/client/src/components/layouts/global/aside.tsx b/apps/client/src/components/layouts/global/aside.tsx index 23ebe7b7c..556adbf17 100644 --- a/apps/client/src/components/layouts/global/aside.tsx +++ b/apps/client/src/components/layouts/global/aside.tsx @@ -1,22 +1,28 @@ -import { ActionIcon, Box, Group, ScrollArea, Text, Tooltip } from "@mantine/core"; +import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core"; import { IconX } from "@tabler/icons-react"; import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx"; import { useAtom } from "jotai"; import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; -import React, { ReactNode } from "react"; +import React, { ReactNode, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx"; import { useAtomValue } from "jotai"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel"; import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx"; +import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx"; export default function Aside() { - const [{ tab }, setAsideState] = useAtom(asideStateAtom); + const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom); const { t } = useTranslation(); const pageEditor = useAtomValue(pageEditorAtom); const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false })); + useEffect(() => { + if (!isAsideOpen) return; + document.getElementById(ASIDE_PANEL_ID)?.focus(); + }, [isAsideOpen, tab]); + let title: string; let component: ReactNode; @@ -48,7 +54,7 @@ export default function Aside() { <> {tab !== "chat" && ( - {t(title)} + {t(title)} + + } {showGlobalSidebar && } - + {isSettingsRoute ? ( {children} @@ -137,6 +141,8 @@ export default function GlobalAppShell({ {isPageRoute && ( )} + ); } diff --git a/apps/client/src/components/layouts/global/global-sidebar.module.css b/apps/client/src/components/layouts/global/global-sidebar.module.css index 1385ec20d..8c33b41ab 100644 --- a/apps/client/src/components/layouts/global/global-sidebar.module.css +++ b/apps/client/src/components/layouts/global/global-sidebar.module.css @@ -31,6 +31,11 @@ color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); } + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 2px; + } + &[data-active] { &, & :hover { @@ -96,4 +101,9 @@ ); color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); } + + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 2px; + } } diff --git a/apps/client/src/components/layouts/global/global-sidebar.tsx b/apps/client/src/components/layouts/global/global-sidebar.tsx index 6882f81d4..4670dae40 100644 --- a/apps/client/src/components/layouts/global/global-sidebar.tsx +++ b/apps/client/src/components/layouts/global/global-sidebar.tsx @@ -92,6 +92,7 @@ export default function GlobalSidebar() { key={item.label} className={classes.link} data-active={active === item.path || undefined} + aria-current={active === item.path ? "page" : undefined} to={item.path} onClick={handleNavClick} > @@ -159,6 +160,7 @@ export default function GlobalSidebar() { diff --git a/apps/client/src/components/settings/settings-title.tsx b/apps/client/src/components/settings/settings-title.tsx index d626cc7bc..959a6fea3 100644 --- a/apps/client/src/components/settings/settings-title.tsx +++ b/apps/client/src/components/settings/settings-title.tsx @@ -4,7 +4,7 @@ import { Divider, Title } from '@mantine/core'; export default function SettingsTitle({ title }: { title: string }) { return ( <> - + <Title order={1} size="h3"> {title} diff --git a/apps/client/src/components/ui/clickable-table-row.module.css b/apps/client/src/components/ui/clickable-table-row.module.css new file mode 100644 index 000000000..49f516f6c --- /dev/null +++ b/apps/client/src/components/ui/clickable-table-row.module.css @@ -0,0 +1,29 @@ +/* + * Focus styling for list-style tables (recent changes, favorites, all + * spaces, groups, verified pages, shares). + * + * Per WAI-ARIA Authoring Practices and Adrian Roselli's guidance on table + * accessibility (https://adrianroselli.com/2020/02/block-links-cards-clickable-regions-etc.html), + * data tables should not be made fully clickable. Only the title cell is the + * link, and that link is what receives Tab focus. + * + * - `.row` adds a subtle background tint when the row contains the focused + * element, so keyboard users can see which row they're inspecting. + * - `.link` adds a visible :focus-visible outline on the title link itself. + * + * No stretched-link pseudo here on purpose: absolutely-positioned pseudos + * inside table cells cause column reflow on focus in Chromium. + */ + +.row:focus-within { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-6) + ); +} + +.link:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 2px; + border-radius: var(--mantine-radius-sm); +} diff --git a/apps/client/src/components/ui/custom-avatar.tsx b/apps/client/src/components/ui/custom-avatar.tsx index ac6e84133..0cf20a51b 100644 --- a/apps/client/src/components/ui/custom-avatar.tsx +++ b/apps/client/src/components/ui/custom-avatar.tsx @@ -16,14 +16,18 @@ interface CustomAvatarProps { mt?: string | number; } -// `color.shade` pairs whose filled background meets WCAG AA (4.5:1) against -// white text. Avoids lime/yellow/green/orange — even their dark shades have -// weak white-text contrast. +// `color.shade` pairs whose contrast meets WCAG AA (4.5:1) in BOTH variants: +// - filled: white text on the shade as bg +// - light: shade as text on the color's light-bg (10% color.6 over white) +// Avoids lime/yellow/green/orange — even their dark shades have weak +// contrast. grape and indigo were bumped from .7 to darker shades because +// the original picks failed: grape.7 was 4.02/3.61 (both fail) and +// indigo.7 was 4.98/4.39 (light fails by a hair). const SAFE_INITIALS_COLORS: MantineColor[] = [ "blue.8", "cyan.9", - "grape.7", - "indigo.7", + "grape.9", + "indigo.8", "pink.8", "red.8", "violet.7", diff --git a/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx b/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx index 21c906969..198d29959 100644 --- a/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx +++ b/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx @@ -41,7 +41,7 @@ export function DestinationPickerModal({ {title} - + { const SidebarToggle = React.forwardRef( ({ opened, size = "sm", ...others }, ref) => { return ( - + {opened ? ( ) : ( diff --git a/apps/client/src/components/ui/skip-to-main.module.css b/apps/client/src/components/ui/skip-to-main.module.css new file mode 100644 index 000000000..aeb04a090 --- /dev/null +++ b/apps/client/src/components/ui/skip-to-main.module.css @@ -0,0 +1,27 @@ +.skipLink { + position: absolute; + top: 8px; + left: 8px; + z-index: 9999; + padding: 8px 16px; + background: var(--mantine-color-body); + color: var(--mantine-color-text); + border: 2px solid var(--mantine-color-blue-6); + border-radius: 4px; + text-decoration: none; + font-weight: 500; + font-size: var(--mantine-font-size-sm); + transform: translateY(-200%); + transition: transform 0.15s ease-out; +} + +.skipLink:focus { + transform: translateY(0); + outline: none; +} + +@media print { + .skipLink { + display: none !important; + } +} diff --git a/apps/client/src/components/ui/skip-to-main.tsx b/apps/client/src/components/ui/skip-to-main.tsx new file mode 100644 index 000000000..6b09d13ab --- /dev/null +++ b/apps/client/src/components/ui/skip-to-main.tsx @@ -0,0 +1,13 @@ +import { useTranslation } from "react-i18next"; +import classes from "./skip-to-main.module.css"; + +export const MAIN_CONTENT_ID = "main-content"; + +export function SkipToMain() { + const { t } = useTranslation(); + return ( + + {t("Skip to main content")} + + ); +} diff --git a/apps/client/src/ee/ai-chat/components/ai-chat-sidebar.tsx b/apps/client/src/ee/ai-chat/components/ai-chat-sidebar.tsx index ea616f439..3aa199f38 100644 --- a/apps/client/src/ee/ai-chat/components/ai-chat-sidebar.tsx +++ b/apps/client/src/ee/ai-chat/components/ai-chat-sidebar.tsx @@ -120,7 +120,7 @@ export default function AiChatSidebar() { return (
- {t("AI Chat")} +

{t("AI Chat")}

(
-
{group.label}
+

{group.label}

{group.chats.map((chat) => (
{t("Docmost AI")}
-
+

{t("What can I help you with?")} -

+
-
Get started
+

{t("Get started")}

{SUGGESTIONS.map((s) => ( )} - + diff --git a/apps/client/src/ee/template/pages/template-editor.tsx b/apps/client/src/ee/template/pages/template-editor.tsx index 717016aa4..439cbb964 100644 --- a/apps/client/src/ee/template/pages/template-editor.tsx +++ b/apps/client/src/ee/template/pages/template-editor.tsx @@ -283,6 +283,7 @@ export default function TemplateEditor() { variant="subtle" color="gray" size="md" + aria-label={t("Template settings")} onClick={() => { setDraftSpaceId(spaceId); openSettings(); diff --git a/apps/client/src/features/auth/components/auth-layout.tsx b/apps/client/src/features/auth/components/auth-layout.tsx index d05ad7ec5..e893c5a7c 100644 --- a/apps/client/src/features/auth/components/auth-layout.tsx +++ b/apps/client/src/features/auth/components/auth-layout.tsx @@ -20,7 +20,7 @@ export function AuthLayout({ children }: AuthLayoutProps) { Docmost - {children} +
{children}
); } diff --git a/apps/client/src/features/auth/components/invite-sign-up-form.tsx b/apps/client/src/features/auth/components/invite-sign-up-form.tsx index 91d8e167b..6af74b0ed 100644 --- a/apps/client/src/features/auth/components/invite-sign-up-form.tsx +++ b/apps/client/src/features/auth/components/invite-sign-up-form.tsx @@ -103,6 +103,11 @@ export function InviteSignUpForm() { placeholder={t("Your password")} variant="filled" mt="md" + visibilityToggleButtonProps={{ + "aria-label": t("Toggle password visibility"), + "aria-hidden": false, + tabIndex: 0, + }} {...form.getInputProps("password")} />
@@ -391,6 +392,7 @@ const PageCommentInput = ({ onSave, isLoading }) => { variant="filled" radius="xl" size="sm" + aria-label={t("Send comment")} onClick={handleSave} onMouseDown={(e) => e.preventDefault()} loading={isLoading} diff --git a/apps/client/src/features/comment/components/comment.module.css b/apps/client/src/features/comment/components/comment.module.css index 590324990..dfa61b790 100644 --- a/apps/client/src/features/comment/components/comment.module.css +++ b/apps/client/src/features/comment/components/comment.module.css @@ -22,6 +22,11 @@ .commentEditor { + &[data-editable][data-surface="muted"] .ProseMirror:not(.focused) { + border-radius: var(--mantine-radius-sm); + box-shadow: 0 0 0 1px light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-4)); + } + .focused { border-radius: var(--mantine-radius-sm); box-shadow: 0 0 0 2px var(--mantine-color-blue-3); diff --git a/apps/client/src/features/editor/components/drawio/drawio-view.tsx b/apps/client/src/features/editor/components/drawio/drawio-view.tsx index fe10bf058..534f42fea 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-view.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-view.tsx @@ -198,7 +198,11 @@ export default function DrawioView(props: NodeViewProps) { className={clsx(selected ? "ProseMirror-selectednode" : "")} >
- + diff --git a/apps/client/src/features/editor/components/embed/embed-view.tsx b/apps/client/src/features/editor/components/embed/embed-view.tsx index 176f14ad7..840746712 100644 --- a/apps/client/src/features/editor/components/embed/embed-view.tsx +++ b/apps/client/src/features/editor/components/embed/embed-view.tsx @@ -131,7 +131,11 @@ export default function EmbedView(props: NodeViewProps) { className={clsx(selected ? "ProseMirror-selectednode" : "")} >
- + diff --git a/apps/client/src/features/editor/components/emoji-menu/emoji-list.tsx b/apps/client/src/features/editor/components/emoji-menu/emoji-list.tsx index e08d320a1..87704ef3b 100644 --- a/apps/client/src/features/editor/components/emoji-menu/emoji-list.tsx +++ b/apps/client/src/features/editor/components/emoji-menu/emoji-list.tsx @@ -44,9 +44,11 @@ function EmojiList({ const [cats, setCats] = useState([]); const [activeCat, setActiveCat] = useState(""); const [focusZone, setFocusZone] = useState<"grid" | "tabs">("grid"); + const [announce, setAnnounce] = useState(""); const listViewport = useRef(null); const gridViewport = useRef(null); const catBar = useRef(null); + const userInteractedRef = useRef(false); const searching = query.length > 0; const browseLoading = !searching && cats.length === 0; @@ -74,6 +76,53 @@ function EmojiList({ vp?.querySelector(`[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")} > +
+ {announce} +
{searching ? ( <> {isLoading && } @@ -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)} diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx index 658743a37..44214cddf 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx @@ -240,7 +240,11 @@ export default function ExcalidrawView(props: NodeViewProps) { className={clsx(selected ? "ProseMirror-selectednode" : "")} >
- + diff --git a/apps/client/src/features/editor/components/math/math-block.tsx b/apps/client/src/features/editor/components/math/math-block.tsx index 014b9a6fe..13a3afaa3 100644 --- a/apps/client/src/features/editor/components/math/math-block.tsx +++ b/apps/client/src/features/editor/components/math/math-block.tsx @@ -149,8 +149,13 @@ export default function MathBlockView(props: NodeViewProps) { > - - props.deleteNode()} /> + props.deleteNode()} + > + diff --git a/apps/client/src/features/editor/components/mention/mention-list.tsx b/apps/client/src/features/editor/components/mention/mention-list.tsx index 8f6269060..a098a1afe 100644 --- a/apps/client/src/features/editor/components/mention/mention-list.tsx +++ b/apps/client/src/features/editor/components/mention/mention-list.tsx @@ -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((props, ref) => { const [selectedIndex, setSelectedIndex] = useState(1); const viewportRef = useRef(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((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((props, ref) => { if (renderItems.length === 0) { return ( + + {countAnnouncement} + {t("No results")} @@ -295,6 +341,12 @@ const MentionList = forwardRef((props, ref) => { aria-label={t("Mention suggestions")} aria-activedescendant={`mention-option-${selectedIndex}`} > + + {countAnnouncement} + + + {selectionAnnouncement} + (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}`} > + + {countAnnouncement} + + + {selectionAnnouncement} + = (props) => { return ( <> {props.isShare && ( - + {t("Table of contents")} - </Text> + )}
{links.map((item, idx) => ( diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index 412a3b3de..98930db28 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -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 && ( - + toggleAside("details")} + {...detailsTriggerProps} > diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 39ae593a2..a703561f5 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -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(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"), + }, + }} /> ) : (
@@ -421,9 +430,7 @@ export default function PageEditor({ {editor && !editorIsEditable && (editable || canComment) && - providersRef.current && ( - - )} + providersRef.current && } {showCommentPopup && ( )} diff --git a/apps/client/src/features/editor/styles/placeholder.css b/apps/client/src/features/editor/styles/placeholder.css index be5225bf6..9d42f22b0 100644 --- a/apps/client/src/features/editor/styles/placeholder.css +++ b/apps/client/src/features/editor/styles/placeholder.css @@ -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; diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index 3ff2d7614..fefe9f330 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -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") { diff --git a/apps/client/src/features/favorite/components/star-button.tsx b/apps/client/src/features/favorite/components/star-button.tsx index 7ff8ff77d..2b341c736 100644 --- a/apps/client/src/features/favorite/components/star-button.tsx +++ b/apps/client/src/features/favorite/components/star-button.tsx @@ -1,4 +1,5 @@ import { ActionIcon, Tooltip } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; import { IconStar, IconStarFilled } from "@tabler/icons-react"; import { useFavoriteIds, @@ -14,6 +15,8 @@ type StarButtonProps = { pageId?: string; spaceId?: string; templateId?: string; + /** Name of the item being favorited, used to make the button's accessible name descriptive. */ + name?: string; size?: number; }; @@ -25,7 +28,7 @@ function getEntityId(props: StarButtonProps): string | undefined { } export default function StarButton(props: StarButtonProps) { - const { type, size = 18 } = props; + const { type, name, size = 18 } = props; const { t } = useTranslation(); const favoriteIds = useFavoriteIds(type); const addMutation = useAddFavoriteMutation(); @@ -47,22 +50,46 @@ export default function StarButton(props: StarButtonProps) { }; if (isFavorited) { - removeMutation.mutate(params); + removeMutation.mutate(params, { + onSuccess: () => { + notifications.show({ + message: name + ? t("Removed {{name}} from favorites", { name }) + : t("Removed from favorites"), + }); + }, + }); } else { - addMutation.mutate(params); + addMutation.mutate(params, { + onSuccess: () => { + notifications.show({ + message: name + ? t("Added {{name}} to favorites", { name }) + : t("Added to favorites"), + }); + }, + }); } }; - const label = isFavorited + // Tooltip label stays short. Accessible name expands to include the item + // so screen reader users can distinguish stars on different rows. + const tooltipLabel = isFavorited ? t("Remove from favorites") : t("Add to favorites"); + const ariaLabel = name + ? isFavorited + ? t("Remove {{name}} from favorites", { name }) + : t("Add {{name}} to favorites", { name }) + : tooltipLabel; + return ( - + - + diff --git a/apps/client/src/features/group/components/create-group-modal.tsx b/apps/client/src/features/group/components/create-group-modal.tsx index 1cff53fbd..307a38f3c 100644 --- a/apps/client/src/features/group/components/create-group-modal.tsx +++ b/apps/client/src/features/group/components/create-group-modal.tsx @@ -11,7 +11,12 @@ export default function CreateGroupModal() { <> - + diff --git a/apps/client/src/features/group/components/edit-group-form.tsx b/apps/client/src/features/group/components/edit-group-form.tsx index f8b1671cd..68b6ba60c 100644 --- a/apps/client/src/features/group/components/edit-group-form.tsx +++ b/apps/client/src/features/group/components/edit-group-form.tsx @@ -9,6 +9,7 @@ import { z } from "zod/v4"; import { useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { zod4Resolver } from "mantine-form-zod-resolver"; +import { IGroup } from "@/features/group/types/group.types.ts"; const formSchema = z.object({ name: z.string().min(2).max(100), @@ -18,13 +19,16 @@ const formSchema = z.object({ type FormValues = z.infer; interface EditGroupFormProps { onClose?: () => void; + group?: IGroup; } -export function EditGroupForm({ onClose }: EditGroupFormProps) { +export function EditGroupForm({ onClose, group: groupProp }: EditGroupFormProps) { const { t } = useTranslation(); const updateGroupMutation = useUpdateGroupMutation(); const { isSuccess } = updateGroupMutation; - const { groupId } = useParams(); - const { data: group } = useGroupQuery(groupId); + const { groupId: routeGroupId } = useParams(); + const groupId = groupProp?.id ?? routeGroupId; + const { data: queriedGroup } = useGroupQuery(groupProp ? undefined : groupId); + const group = groupProp ?? queriedGroup; useEffect(() => { if (isSuccess) { @@ -66,6 +70,7 @@ export function EditGroupForm({ onClose }: EditGroupFormProps) { label={t("Group name")} placeholder={t("e.g Developers")} variant="filled" + data-autofocus {...form.getInputProps("name")} /> diff --git a/apps/client/src/features/group/components/edit-group-modal.tsx b/apps/client/src/features/group/components/edit-group-modal.tsx index 4da933f25..a2fc28f2f 100644 --- a/apps/client/src/features/group/components/edit-group-modal.tsx +++ b/apps/client/src/features/group/components/edit-group-modal.tsx @@ -1,23 +1,31 @@ import { Divider, Modal } from "@mantine/core"; import { EditGroupForm } from "@/features/group/components/edit-group-form.tsx"; import { useTranslation } from "react-i18next"; +import { IGroup } from "@/features/group/types/group.types.ts"; interface EditGroupModalProps { opened: boolean; onClose: () => void; + group?: IGroup; } export default function EditGroupModal({ opened, onClose, + group, }: EditGroupModalProps) { const { t } = useTranslation(); return ( <> - + - + ); diff --git a/apps/client/src/features/group/components/group-action-menu.tsx b/apps/client/src/features/group/components/group-action-menu.tsx index 8c3dccb05..cb50f4bfa 100644 --- a/apps/client/src/features/group/components/group-action-menu.tsx +++ b/apps/client/src/features/group/components/group-action-menu.tsx @@ -10,18 +10,28 @@ import { useDisclosure } from "@mantine/hooks"; import EditGroupModal from "@/features/group/components/edit-group-modal.tsx"; import { modals } from "@mantine/modals"; import { useTranslation } from "react-i18next"; +import { IGroup } from "@/features/group/types/group.types.ts"; -export default function GroupActionMenu() { +interface GroupActionMenuProps { + group?: IGroup; +} + +export default function GroupActionMenu(props: GroupActionMenuProps = {}) { const { t } = useTranslation(); - const { groupId } = useParams(); - const { data: group, isLoading } = useGroupQuery(groupId); + const { groupId: routeGroupId } = useParams(); + const groupId = props.group?.id ?? routeGroupId; + const { data: queriedGroup } = useGroupQuery(props.group ? undefined : groupId); + const group = props.group ?? queriedGroup; const deleteGroupMutation = useDeleteGroupMutation(); const navigate = useNavigate(); const [opened, { open, close }] = useDisclosure(false); const onDelete = async () => { await deleteGroupMutation.mutateAsync(groupId); - navigate("/settings/groups"); + // Only navigate away if we're currently viewing this group's detail page. + if (routeGroupId === groupId) { + navigate("/settings/groups"); + } }; const openDeleteModal = () => @@ -53,7 +63,11 @@ export default function GroupActionMenu() { arrowPosition="center" > - + @@ -76,7 +90,7 @@ export default function GroupActionMenu() { )} - + ); } diff --git a/apps/client/src/features/group/components/group-list.tsx b/apps/client/src/features/group/components/group-list.tsx index d88e1ec02..5f5fed472 100644 --- a/apps/client/src/features/group/components/group-list.tsx +++ b/apps/client/src/features/group/components/group-list.tsx @@ -1,4 +1,4 @@ -import { Table, Group, Text, Anchor } from "@mantine/core"; +import { Table, Group, Text, Anchor, VisuallyHidden } from "@mantine/core"; import { useGetGroupsQuery } from "@/features/group/queries/group-query"; import { Link } from "react-router-dom"; import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx"; @@ -12,6 +12,8 @@ import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx"; import { SearchInput } from "@/components/common/search-input.tsx"; import NoTableResults from "@/components/common/no-table-results.tsx"; import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx"; +import rowClasses from "@/components/ui/clickable-table-row.module.css"; +import GroupActionMenu from "@/features/group/components/group-action-menu.tsx"; export default function GroupList() { const { t } = useTranslation(); @@ -34,13 +36,16 @@ export default function GroupList() { {t("Group")} {t("Members")} + + {t("Actions")} + {data?.items.length > 0 ? ( data?.items.map((group: IGroup, index: number) => ( - + prefetchGroupMembers(group.id)}> @@ -80,10 +86,13 @@ export default function GroupList() { {formatMemberCount(group.memberCount, t)} + + + )) ) : ( - + )}
diff --git a/apps/client/src/features/group/components/group-members.tsx b/apps/client/src/features/group/components/group-members.tsx index 56807bf8d..14c5903a7 100644 --- a/apps/client/src/features/group/components/group-members.tsx +++ b/apps/client/src/features/group/components/group-members.tsx @@ -88,7 +88,11 @@ export default function GroupMembersList() { arrowPosition="center" > - + diff --git a/apps/client/src/features/home/components/created-by-me.tsx b/apps/client/src/features/home/components/created-by-me.tsx index 99051357e..bc79da994 100644 --- a/apps/client/src/features/home/components/created-by-me.tsx +++ b/apps/client/src/features/home/components/created-by-me.tsx @@ -17,6 +17,7 @@ import { EmptyState } from "@/components/ui/empty-state"; import { getSpaceUrl } from "@/lib/config"; import { useTranslation } from "react-i18next"; import { getInitialsColor } from "@/lib/get-initials-color"; +import rowClasses from "@/components/ui/clickable-table-row.module.css"; type Props = { spaceId?: string; @@ -49,9 +50,10 @@ export default function CreatedByMe({ spaceId }: Props) { {pages.map((page) => ( - + {favorites.map((fav) => fav.page ? ( - + ("direct"); const [filter, setFilter] = useState("all"); + const [filterMenuOpened, setFilterMenuOpened] = useState(false); + const [moreMenuOpened, setMoreMenuOpened] = useState(false); const { data: unreadData } = useUnreadCountQuery(); const markAllRead = useMarkAllReadMutation(); const unreadCount = unreadData?.count ?? 0; + const isSubMenuOpen = filterMenuOpened || moreMenuOpened; const handleMarkAllRead = () => { markAllRead.mutate(); @@ -51,6 +54,9 @@ export function NotificationPopover() { opened={opened} onChange={setOpened} withArrow + trapFocus + returnFocus + closeOnEscape={!isSubMenuOpen} > @@ -80,14 +86,25 @@ export function NotificationPopover() { style={{ width: "min(420px, calc(100vw - 24px))" }} > - + {t("Notifications")} - </Text> + - + - + @@ -113,10 +130,21 @@ export function NotificationPopover() { - + - + diff --git a/apps/client/src/features/page-details/components/backlinks-modal.tsx b/apps/client/src/features/page-details/components/backlinks-modal.tsx index 83fc31147..1fbd771a3 100644 --- a/apps/client/src/features/page-details/components/backlinks-modal.tsx +++ b/apps/client/src/features/page-details/components/backlinks-modal.tsx @@ -23,7 +23,7 @@ export function BacklinksModal({ {t("Backlinks")} - + diff --git a/apps/client/src/features/page-history/components/history-modal.tsx b/apps/client/src/features/page-history/components/history-modal.tsx index 08f05c9e9..05768638a 100644 --- a/apps/client/src/features/page-history/components/history-modal.tsx +++ b/apps/client/src/features/page-history/components/history-modal.tsx @@ -32,7 +32,7 @@ export default function HistoryModal({ pageId, pageTitle }: Props) { {t("Page history")} - + - + diff --git a/apps/client/src/features/page/components/copy-page-modal.tsx b/apps/client/src/features/page/components/copy-page-modal.tsx index 4745f731c..b03bd1e5e 100644 --- a/apps/client/src/features/page/components/copy-page-modal.tsx +++ b/apps/client/src/features/page/components/copy-page-modal.tsx @@ -80,7 +80,7 @@ export default function CopyPageModal({ {t("Copy page")} - + diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 75b113eaa..d82cb494c 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -18,7 +18,7 @@ import { IconWifiOff, } from "@tabler/icons-react"; import React, { useEffect, useRef, useState } from "react"; -import useToggleAside from "@/hooks/use-toggle-aside.tsx"; +import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx"; import { useAtom, useAtomValue } from "jotai"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; import { useDisclosure, useHotkeys } from "@mantine/hooks"; @@ -64,7 +64,8 @@ interface PageHeaderMenuProps { } export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { const { t } = useTranslation(); - const toggleAside = useToggleAside(); + const commentsTriggerProps = useAsideTriggerProps("comments"); + const tocTriggerProps = useAsideTriggerProps("toc"); const { pageSlug } = useParams(); const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug), @@ -109,7 +110,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { variant="subtle" color="dark" aria-label={t("Comments")} - onClick={() => toggleAside("comments")} + {...commentsTriggerProps} > @@ -120,7 +121,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { variant="subtle" color="dark" aria-label={t("Table of contents")} - onClick={() => toggleAside("toc")} + {...tocTriggerProps} > diff --git a/apps/client/src/features/page/components/move-page-modal.tsx b/apps/client/src/features/page/components/move-page-modal.tsx index 74880084b..907bea2f2 100644 --- a/apps/client/src/features/page/components/move-page-modal.tsx +++ b/apps/client/src/features/page/components/move-page-modal.tsx @@ -75,7 +75,7 @@ export default function MovePageModal({ {t("Move page")} - + diff --git a/apps/client/src/features/page/trash/components/trash-page-content-modal.tsx b/apps/client/src/features/page/trash/components/trash-page-content-modal.tsx index c9aad622c..842c4257c 100644 --- a/apps/client/src/features/page/trash/components/trash-page-content-modal.tsx +++ b/apps/client/src/features/page/trash/components/trash-page-content-modal.tsx @@ -28,7 +28,7 @@ export default function TrashPageContentModal({ {t("Preview")} - + diff --git a/apps/client/src/features/page/trash/components/trash.tsx b/apps/client/src/features/page/trash/components/trash.tsx index da33d828f..2b5ca9649 100644 --- a/apps/client/src/features/page/trash/components/trash.tsx +++ b/apps/client/src/features/page/trash/components/trash.tsx @@ -150,7 +150,11 @@ export default function Trash() { - + diff --git a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx index 91a21b052..27b0ce210 100644 --- a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx +++ b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx @@ -125,7 +125,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { { e.preventDefault(); diff --git a/apps/client/src/features/page/tree/components/space-tree-row.tsx b/apps/client/src/features/page/tree/components/space-tree-row.tsx index 1ed148350..3da690066 100644 --- a/apps/client/src/features/page/tree/components/space-tree-row.tsx +++ b/apps/client/src/features/page/tree/components/space-tree-row.tsx @@ -274,7 +274,7 @@ function CreateNode({ { e.preventDefault(); diff --git a/apps/client/src/features/search/components/search-spotlight-filters.tsx b/apps/client/src/features/search/components/search-spotlight-filters.tsx index 4502c755a..d41bef7e7 100644 --- a/apps/client/src/features/search/components/search-spotlight-filters.tsx +++ b/apps/client/src/features/search/components/search-spotlight-filters.tsx @@ -175,6 +175,8 @@ export function SearchSpotlightFilters({ {contentTypeOptions.map((option) => ( !option.disabled && contentType !== option.value && @@ -200,7 +202,7 @@ export function SearchSpotlightFilters({ )} - {contentType === option.value && } + {contentType === option.value && } ))} diff --git a/apps/client/src/features/search/components/search-spotlight.tsx b/apps/client/src/features/search/components/search-spotlight.tsx index 5a2980a38..4c5269f15 100644 --- a/apps/client/src/features/search/components/search-spotlight.tsx +++ b/apps/client/src/features/search/components/search-spotlight.tsx @@ -1,6 +1,6 @@ import { Spotlight } from "@mantine/spotlight"; import { IconSearch, IconSparkles } from "@tabler/icons-react"; -import { Group, Button } from "@mantine/core"; +import { Group, Button, VisuallyHidden } from "@mantine/core"; import React, { useState, useMemo, useEffect } from "react"; import { useDebouncedValue } from "@mantine/hooks"; import { useTranslation } from "react-i18next"; @@ -126,6 +126,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { } style={{ flex: 1 }} onKeyDown={(e) => { @@ -161,6 +162,18 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { /> + + {isAiMode + ? query.length > 0 && !isAiLoading && !aiSearchResult + ? t("No answer available") + : "" + : query.length > 0 && !isLoading + ? resultItems.length === 0 + ? t("No results found") + : t("{{count}} results found", { count: resultItems.length }) + : ""} + + {isAiMode ? ( <> diff --git a/apps/client/src/features/search/components/share-search-spotlight.tsx b/apps/client/src/features/search/components/share-search-spotlight.tsx index dd0d5181f..eccaeb4db 100644 --- a/apps/client/src/features/search/components/share-search-spotlight.tsx +++ b/apps/client/src/features/search/components/share-search-spotlight.tsx @@ -74,6 +74,7 @@ export function ShareSearchSpotlight({ shareId }: ShareSearchSpotlightProps) { > } /> diff --git a/apps/client/src/features/session/components/session-list.tsx b/apps/client/src/features/session/components/session-list.tsx index 6549a6e5f..9e519942c 100644 --- a/apps/client/src/features/session/components/session-list.tsx +++ b/apps/client/src/features/session/components/session-list.tsx @@ -7,6 +7,7 @@ import { Stack, Table, Text, + VisuallyHidden, } from "@mantine/core"; import { IconDevices } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; @@ -33,11 +34,16 @@ export default function SessionList() { if (isLoading) { return (
+ + {t("Active sessions")} + {t("Device Name")} {t("Last Active")} - + + {t("Action")} + @@ -90,11 +96,18 @@ export default function SessionList() { )}
+ + {t("Active sessions")} + {t("Device Name")} {t("Last Active")} - {otherSessions.length > 0 && } + {otherSessions.length > 0 && ( + + {t("Action")} + + )} diff --git a/apps/client/src/features/share/components/share-list.tsx b/apps/client/src/features/share/components/share-list.tsx index 147c8bb90..37ea4abf6 100644 --- a/apps/client/src/features/share/components/share-list.tsx +++ b/apps/client/src/features/share/components/share-list.tsx @@ -14,6 +14,7 @@ import { getPageIcon } from "@/lib"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { EmptyState } from "@/components/ui/empty-state.tsx"; import classes from "./share.module.css"; +import rowClasses from "@/components/ui/clickable-table-row.module.css"; export default function ShareList() { const { t } = useTranslation(); @@ -38,7 +39,7 @@ export default function ShareList() { {data?.items.map((share: ISharedItem, index: number) => ( - + + + 1 && { navbar: { @@ -242,7 +245,7 @@ export default function ShareShell({ )} - + {children} {data && shareId && !(data.features?.length > 0) && } @@ -264,5 +267,6 @@ export default function ShareShell({ + ); } diff --git a/apps/client/src/features/space/components/add-space-members-modal.tsx b/apps/client/src/features/space/components/add-space-members-modal.tsx index 5efd32f6b..79910ae3a 100644 --- a/apps/client/src/features/space/components/add-space-members-modal.tsx +++ b/apps/client/src/features/space/components/add-space-members-modal.tsx @@ -1,6 +1,6 @@ import { Button, Divider, Group, Modal, Stack } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; -import React, { useState } from "react"; +import React, { useId, useState } from "react"; import { useAddSpaceMemberMutation } from "@/features/space/queries/space-query.ts"; import { MultiMemberSelect } from "@/features/space/components/multi-member-select.tsx"; import { SpaceMemberRole } from "@/features/space/components/space-member-role.tsx"; @@ -14,6 +14,7 @@ export default function AddSpaceMembersModal({ spaceId, }: AddSpaceMemberModalProps) { const { t } = useTranslation(); + const titleId = useId(); const [opened, { open, close }] = useDisclosure(false); const [memberIds, setMemberIds] = useState([]); const [role, setRole] = useState(SpaceRole.WRITER); @@ -51,24 +52,33 @@ export default function AddSpaceMembersModal({ return ( <> - - + + + + + {t("Add space members")} + + + + - - - - + + + + - - - - + + + + + + ); } diff --git a/apps/client/src/features/space/components/create-space-form.tsx b/apps/client/src/features/space/components/create-space-form.tsx index 896b599b3..d5d26d57b 100644 --- a/apps/client/src/features/space/components/create-space-form.tsx +++ b/apps/client/src/features/space/components/create-space-form.tsx @@ -1,4 +1,4 @@ -import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core"; +import { Group, Box, Button, TextInput, Stack, Textarea, Text } from "@mantine/core"; import React, { useEffect } from "react"; import { useForm } from "@mantine/form"; import { zod4Resolver } from "mantine-form-zod-resolver"; @@ -69,10 +69,25 @@ export function CreateSpaceForm() { navigate(getSpaceUrl(createdSpace.slug)); }; + function handleValidationFailure(errors: Record) { + const firstInvalidId = Object.keys(errors)[0]; + if (firstInvalidId) { + document.getElementById(firstInvalidId)?.focus(); + } + } + return ( <> - handleSubmit(values))}> + handleSubmit(values), + handleValidationFailure, + )} + > + + {t("* indicates required fields")} + @@ -89,6 +106,7 @@ export function CreateSpaceForm() { label={t("Space slug")} placeholder={t("e.g product")} variant="filled" + errorProps={{ role: "alert" }} {...form.getInputProps("slug")} /> @@ -100,6 +118,7 @@ export function CreateSpaceForm() { autosize minRows={2} maxRows={8} + errorProps={{ role: "alert" }} {...form.getInputProps("description")} /> diff --git a/apps/client/src/features/space/components/create-space-modal.tsx b/apps/client/src/features/space/components/create-space-modal.tsx index 152e1950c..22f3e40c3 100644 --- a/apps/client/src/features/space/components/create-space-modal.tsx +++ b/apps/client/src/features/space/components/create-space-modal.tsx @@ -11,7 +11,12 @@ export default function CreateSpaceModal() { <> - + diff --git a/apps/client/src/features/space/components/settings-modal.tsx b/apps/client/src/features/space/components/settings-modal.tsx index fb8d24f63..3d24d100c 100644 --- a/apps/client/src/features/space/components/settings-modal.tsx +++ b/apps/client/src/features/space/components/settings-modal.tsx @@ -48,7 +48,7 @@ export default function SpaceSettingsModal({ {space?.name} - +
diff --git a/apps/client/src/features/space/components/sidebar/switch-space.tsx b/apps/client/src/features/space/components/sidebar/switch-space.tsx index 89e0f64ee..7531a5926 100644 --- a/apps/client/src/features/space/components/sidebar/switch-space.tsx +++ b/apps/client/src/features/space/components/sidebar/switch-space.tsx @@ -38,6 +38,8 @@ export function SwitchSpace({ shadow="md" opened={opened} onChange={toggle} + trapFocus + returnFocus >
+ + + {t("List of spaces in this workspace")} + + {t("Space")} {t("Members")} - + + {t("Action")} + {spaces.length > 0 ? ( spaces.map((space) => ( - + prefetchSpace(space.slug, space.id)} > - + diff --git a/apps/client/src/features/space/components/spaces-page/favorite-spaces-grid.tsx b/apps/client/src/features/space/components/spaces-page/favorite-spaces-grid.tsx index 131de2995..a2a2ec263 100644 --- a/apps/client/src/features/space/components/spaces-page/favorite-spaces-grid.tsx +++ b/apps/client/src/features/space/components/spaces-page/favorite-spaces-grid.tsx @@ -1,4 +1,4 @@ -import { Text, SimpleGrid, Card, rem, Group, Box, Button } from "@mantine/core"; +import { Text, SimpleGrid, Card, rem, Group, Box, Button, Title } from "@mantine/core"; import { useState } from "react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -30,9 +30,9 @@ export default function FavoriteSpacesGrid() { return ( - + {t("Favorite spaces")} - </Text> + {visibleSpaces.map((fav) => ( @@ -53,6 +53,7 @@ export default function FavoriteSpacesGrid() { diff --git a/apps/client/src/features/user/components/change-email.tsx b/apps/client/src/features/user/components/change-email.tsx index 16f74b427..8c841b401 100644 --- a/apps/client/src/features/user/components/change-email.tsx +++ b/apps/client/src/features/user/components/change-email.tsx @@ -36,7 +36,13 @@ export default function ChangeEmail() { */} - + {t( "To change your email, you have to enter your password and new email.", @@ -80,6 +86,11 @@ function ChangeEmailForm() { placeholder={t("Enter your password")} variant="filled" mb="md" + visibilityToggleButtonProps={{ + "aria-label": t("Toggle password visibility"), + "aria-hidden": false, + tabIndex: 0, + }} {...form.getInputProps("password")} /> diff --git a/apps/client/src/features/user/components/change-password.tsx b/apps/client/src/features/user/components/change-password.tsx index 0925cdc48..9c0b4cb3d 100644 --- a/apps/client/src/features/user/components/change-password.tsx +++ b/apps/client/src/features/user/components/change-password.tsx @@ -95,6 +95,11 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) { variant="filled" mb="md" data-autofocus + visibilityToggleButtonProps={{ + "aria-label": t("Toggle password visibility"), + "aria-hidden": false, + tabIndex: 0, + }} {...form.getInputProps("oldPassword")} /> @@ -103,6 +108,11 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) { placeholder={t("Enter your new password")} variant="filled" mb="md" + visibilityToggleButtonProps={{ + "aria-label": t("Toggle password visibility"), + "aria-hidden": false, + tabIndex: 0, + }} {...form.getInputProps("newPassword")} /> diff --git a/apps/client/src/features/user/components/notification-pref.tsx b/apps/client/src/features/user/components/notification-pref.tsx index e8a983ede..cce519f91 100644 --- a/apps/client/src/features/user/components/notification-pref.tsx +++ b/apps/client/src/features/user/components/notification-pref.tsx @@ -3,7 +3,7 @@ import { updateUser } from "@/features/user/services/user-service.ts"; import { IUser, IUserSettings } from "@/features/user/types/user.types.ts"; import { Switch, Text, Title, Stack } from "@mantine/core"; import { useAtom } from "jotai"; -import React, { useState } from "react"; +import React, { useId, useState } from "react"; import { useTranslation } from "react-i18next"; import { ResponsiveSettingsRow, @@ -64,6 +64,8 @@ function NotificationToggle({ description: string; }) { const { t } = useTranslation(); + const switchId = useId(); + const descriptionId = useId(); const [user, setUser] = useAtom(userAtom); const [checked, setChecked] = useState( user.settings?.notifications?.[settingKey] !== false, @@ -83,14 +85,21 @@ function NotificationToggle({ return ( - {t(label)} - + + {t(label)} + + {t(description)} - + ); @@ -101,7 +110,7 @@ export default function NotificationPref() { return ( - {t("Email notifications")} + {t("Email notifications")} {notificationItems.map((item) => ( - + diff --git a/apps/client/src/features/workspace/components/members/components/workspace-invite-form.tsx b/apps/client/src/features/workspace/components/members/components/workspace-invite-form.tsx index aa9d3d90a..8deddc7d5 100644 --- a/apps/client/src/features/workspace/components/members/components/workspace-invite-form.tsx +++ b/apps/client/src/features/workspace/components/members/components/workspace-invite-form.tsx @@ -56,6 +56,12 @@ export function WorkspaceInviteForm({ onClose }: Props) { maxDropdownHeight={200} maxTags={50} onChange={setEmails} + data-autofocus + autoComplete="off" + data-1p-ignore + data-lpignore="true" + data-bwignore + data-form-type="other" />