From 63a23d179ad9230d70b26ab24e1218eccc9e5b2e Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 20 Oct 2026 19:21:09 +0200 Subject: [PATCH 1/2] Fixed action toolbar in comments not being keyboard accessible --- packages/ariakit/src/comments/Comment.tsx | 31 ++------- packages/mantine/src/comments/Comment.tsx | 18 ++--- packages/react/package.json | 1 + .../react/src/components/Comments/Comment.tsx | 4 ++ .../src/components/Comments/EmojiPicker.tsx | 5 ++ .../Comments/a11y/Comment.a11y.test.ts | 34 ++++++++++ .../src/components/Comments/a11y/helpers.ts | 65 +++++++++++++++++++ packages/react/src/editor/styles.css | 12 ++++ packages/shadcn/src/comments/Comment.tsx | 33 +++------- pnpm-lock.yaml | 3 + tests/a11y/helpers.tsx | 31 +++++++-- 11 files changed, 167 insertions(+), 70 deletions(-) create mode 100644 packages/react/src/components/Comments/a11y/Comment.a11y.test.ts create mode 100644 packages/react/src/components/Comments/a11y/helpers.ts diff --git a/packages/ariakit/src/comments/Comment.tsx b/packages/ariakit/src/comments/Comment.tsx index 3bf49022e3..58e5fe124e 100644 --- a/packages/ariakit/src/comments/Comment.tsx +++ b/packages/ariakit/src/comments/Comment.tsx @@ -1,12 +1,8 @@ import { Group as AriakitGroup } from "@ariakit/react"; import { assertEmpty } from "@blocknote/core"; -import { - ComponentProps, - useDictionary, - useFocusWithin, -} from "@blocknote/react"; -import { forwardRef, useState } from "react"; +import { ComponentProps, useDictionary } from "@blocknote/react"; +import { forwardRef } from "react"; const AuthorInfo = forwardRef< HTMLDivElement, @@ -64,27 +60,12 @@ export const Comment = forwardRef< assertEmpty(rest, false); - const [hovered, setHovered] = useState(false); - const { focused, ref: focusRef } = useFocusWithin(); - - const doShowActions = - actions && - (showActions === true || - showActions === undefined || - (showActions === "hover" && hovered) || - focused || - emojiPickerOpen); - return ( - setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - {doShowActions ? ( + + {actions && (showActions !== false || emojiPickerOpen) ? ( - {doShowActions ? ( + + {actions && (showActions !== false || emojiPickerOpen) ? ( @@ -281,6 +282,7 @@ export const Comment = ({ (thread.resolved ? ( diff --git a/packages/react/src/components/Comments/EmojiPicker.tsx b/packages/react/src/components/Comments/EmojiPicker.tsx index 06c20bc4e3..aa0c12f070 100644 --- a/packages/react/src/components/Comments/EmojiPicker.tsx +++ b/packages/react/src/components/Comments/EmojiPicker.tsx @@ -3,6 +3,7 @@ import { ReactNode, useState } from "react"; import { useBlockNoteContext } from "../../editor/BlockNoteContext.js"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; import { usePortalElement } from "../../editor/PortalElementOverride.js"; +import { useDictionary } from "../../i18n/dictionary.js"; import Picker from "./EmojiMartPicker.js"; export const EmojiPicker = (props: { @@ -11,6 +12,7 @@ export const EmojiPicker = (props: { children: ReactNode; }) => { const [open, setOpen] = useState(false); + const dict = useDictionary(); const Components = useComponentsContext()!; const blockNoteContext = useBlockNoteContext()!; @@ -20,6 +22,9 @@ export const EmojiPicker = (props: {
{ // Needed as the Picker component's onClickOutside handler // fires immediately after otherwise, preventing the popover diff --git a/packages/react/src/components/Comments/a11y/Comment.a11y.test.ts b/packages/react/src/components/Comments/a11y/Comment.a11y.test.ts new file mode 100644 index 0000000000..b117079023 --- /dev/null +++ b/packages/react/src/components/Comments/a11y/Comment.a11y.test.ts @@ -0,0 +1,34 @@ +import { focusCommentAction, openComments } from "./helpers.js"; +import { expect, test } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import { pressKey } from "@root/tests/a11y/helpers.js"; + +test("comment actions toolbar: Add reaction", async () => { + await openComments(); + await focusCommentAction("Add reaction"); + await pressKey("Enter"); + await expect + .element(page.getByRole("dialog", { name: "Add reaction" })) + .toBeVisible(); +}); + +test("comment actions toolbar: Resolve", async () => { + const { threadStore, threadId } = await openComments(); + await focusCommentAction("Resolve"); + await pressKey("Enter"); + await expect.poll(() => threadStore.getThread(threadId).resolved).toBe(true); +}); + +test("comment actions toolbar: Re-open", async () => { + const { threadStore, threadId } = await openComments({ resolved: true }); + await focusCommentAction("Re-open"); + await pressKey("Enter"); + await expect.poll(() => threadStore.getThread(threadId).resolved).toBe(false); +}); + +test("comment actions toolbar: More actions", async () => { + await openComments(); + await focusCommentAction("More actions"); + await pressKey("Enter"); + await expect.element(page.getByRole("menu")).toBeVisible(); +}); diff --git a/packages/react/src/components/Comments/a11y/helpers.ts b/packages/react/src/components/Comments/a11y/helpers.ts new file mode 100644 index 0000000000..17e0989797 --- /dev/null +++ b/packages/react/src/components/Comments/a11y/helpers.ts @@ -0,0 +1,65 @@ +import { createUserStore } from "@blocknote/core"; +import { + CommentsExtension, + DefaultThreadStoreAuth, +} from "@blocknote/core/comments"; +import { YjsThreadStore } from "@blocknote/core/y"; +import { ThreadsSidebar } from "@blocknote/react"; +import { createElement } from "react"; +import * as Y from "@y/y"; + +import { expect } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import { openEditor, pressKey } from "@root/tests/a11y/helpers.js"; + +export async function openComments({ resolved = false } = {}) { + const doc = new Y.Doc(); + const threadStore = new YjsThreadStore( + "user", + doc.get("threads"), + new DefaultThreadStoreAuth("user", "editor"), + ); + const thread = await threadStore.createThread({ + initialComment: { body: [{ type: "paragraph", content: "A comment" }] }, + }); + if (resolved) { + await threadStore.resolveThread({ threadId: thread.id }); + } + const users = createUserStore(async () => [ + { + id: "user", + username: "Test user", + avatarUrl: "/image.svg", + }, + ]); + await users.loadUsers(["user"]); + await openEditor([{ type: "paragraph", content: "Commented document" }], { + extensions: [CommentsExtension({ threadStore, resolveUsers: users })], + children: createElement(ThreadsSidebar, { filter: "all" }), + }); + return { threadStore, threadId: thread.id }; +} + +export async function focusCommentAction(control: string) { + const toolbar = page.getByRole("toolbar"); + const button = toolbar.getByRole("button", { name: control, exact: true }); + await expect.element(button).toBeEnabled(); + // Start at "Before editor" and reach the action without hovering the comment. + for (let step = 0; step < 10; step++) { + if (button.element() === document.activeElement) { + break; + } + await pressKey("Tab"); + } + await expect + .element(button, { message: `${control} is reachable with Tab` }) + .toHaveFocus(); + await expect + .poll( + () => + getComputedStyle(button.element().closest(".bn-comment-toolbar")!) + .opacity, + ) + .toBe("1"); + return { toolbar, button }; +} diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index 8ce0056370..91aab9b6e3 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -276,6 +276,18 @@ SideMenuController offsets its position to keep it centered on the line. */ flex-grow: 1; } +/* Keep hover actions in the tab order and reveal them when focus enters. */ +.bn-comment-toolbar[data-show="hover"] { + opacity: 0; + pointer-events: none; +} + +*:hover > .bn-comment-toolbar[data-show="hover"], +*:focus-within > .bn-comment-toolbar[data-show="hover"] { + opacity: 1; + pointer-events: auto; +} + /* Thread sidebar styling */ .bn-threads-sidebar { border-radius: var(--bn-border-radius-medium); diff --git a/packages/shadcn/src/comments/Comment.tsx b/packages/shadcn/src/comments/Comment.tsx index ecde20bb9f..d21f71840a 100644 --- a/packages/shadcn/src/comments/Comment.tsx +++ b/packages/shadcn/src/comments/Comment.tsx @@ -1,10 +1,6 @@ import { assertEmpty } from "@blocknote/core"; -import { - ComponentProps, - useDictionary, - useFocusWithin, -} from "@blocknote/react"; -import { forwardRef, useState } from "react"; +import { ComponentProps, useDictionary } from "@blocknote/react"; +import { forwardRef } from "react"; import { cn } from "../lib/utils.js"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; @@ -77,26 +73,13 @@ export const Comment = forwardRef< assertEmpty(rest); - const [hovered, setHovered] = useState(false); - const { focused, ref: focusRef } = useFocusWithin(); - - const doShowActions = - actions && - (showActions === true || - showActions === undefined || - (showActions === "hover" && hovered) || - focused || - emojiPickerOpen); - return ( -
setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - {doShowActions ? ( -
+
+ {actions && (showActions !== false || emojiPickerOpen) ? ( +
{actions}
) : null} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8900ba6725..08cdec4922 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5818,6 +5818,9 @@ importers: '@vitejs/plugin-react': specifier: ^6.0.1 version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0)) + '@y/y': + specifier: 14.0.0-rc.26 + version: 14.0.0-rc.26 react: specifier: ^19.2.5 version: 19.2.5 diff --git a/tests/a11y/helpers.tsx b/tests/a11y/helpers.tsx index 18ec817990..83567af56d 100644 --- a/tests/a11y/helpers.tsx +++ b/tests/a11y/helpers.tsx @@ -1,8 +1,9 @@ -import type { PartialBlock } from "@blocknote/core"; +import type { ExtensionFactoryInstance, PartialBlock } from "@blocknote/core"; import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { useCreateBlockNote } from "@blocknote/react"; +import type { ReactNode } from "react"; import { render } from "vitest-browser-react"; import axe from "axe-core"; import { expect, type TestContext } from "vite-plus/test"; @@ -27,17 +28,23 @@ function EditorFixture({ initialContent, editable, theme, + extensions, + children, }: { initialContent: PartialBlock[]; editable: boolean; theme: "light" | "dark"; + extensions?: ExtensionFactoryInstance[]; + children?: ReactNode; }) { - const editor = useCreateBlockNote({ initialContent }); + const editor = useCreateBlockNote({ initialContent, extensions }); return (

BlockNote accessibility fixture

- + + {children} +
); @@ -48,7 +55,14 @@ export async function openEditor( { editable = true, theme = "light", - }: { editable?: boolean; theme?: "light" | "dark" } = {}, + extensions, + children, + }: { + editable?: boolean; + theme?: "light" | "dark"; + extensions?: ExtensionFactoryInstance[]; + children?: ReactNode; + } = {}, ) { await page.viewport(1280, 900); await render( @@ -56,11 +70,14 @@ export async function openEditor( initialContent={initialContent} editable={editable} theme={theme} - />, + extensions={extensions} + > + {children} + , ); - await expect.element(page.getByRole("textbox")).toBeVisible(); + await expect.element(page.getByRole("textbox").first()).toBeVisible(); await expect - .element(page.getByRole("textbox")) + .element(page.getByRole("textbox").first()) .toHaveAttribute("contenteditable", String(editable)); await document.fonts.ready; if (screenReader) { From ad65989c55df0c4ba154f67b793206052aadfb20 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 20 Oct 2026 20:08:48 +0200 Subject: [PATCH 2/2] Reverted `package.json` changes --- packages/react/package.json | 1 - pnpm-lock.yaml | 3 --- 2 files changed, 4 deletions(-) diff --git a/packages/react/package.json b/packages/react/package.json index 6008f918b4..245d6547df 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -79,7 +79,6 @@ "@types/react": "^19.2.3", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", - "@y/y": "14.0.0-rc.26", "react": "^19.2.5", "react-dom": "^19.2.5", "rimraf": "^5.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08cdec4922..8900ba6725 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5818,9 +5818,6 @@ importers: '@vitejs/plugin-react': specifier: ^6.0.1 version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0)) - '@y/y': - specifier: 14.0.0-rc.26 - version: 14.0.0-rc.26 react: specifier: ^19.2.5 version: 19.2.5