From 015422539161ac9436b5a8185f6ba77cd82fcbb4 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 3 Nov 2026 12:57:44 +0100 Subject: [PATCH] fix(a11y): derive read-only root semantics and preserve caret navigation --- .../blocks/Heading/a11y/block.aria.test.ts | 2 +- .../aria/browser.snap | 6 ++-- .../Heading/a11y/block.keyboard.test.ts | 2 +- packages/core/src/editor/Block.css | 2 ++ packages/core/src/editor/BlockNoteEditor.ts | 4 +++ .../ReadOnly/ReadOnly.browser.test.ts | 34 +++++++++++++++++++ .../core/src/extensions/ReadOnly/ReadOnly.ts | 8 +++++ packages/react/src/editor/BlockNoteView.tsx | 8 +---- .../editor/a11y/BlockNoteView.aria.test.ts | 5 ++- .../aria/browser.snap | 2 +- tests/a11y/helpers.tsx | 6 ++-- 11 files changed, 63 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/extensions/ReadOnly/ReadOnly.browser.test.ts diff --git a/packages/core/src/blocks/Heading/a11y/block.aria.test.ts b/packages/core/src/blocks/Heading/a11y/block.aria.test.ts index d4e5e3a3e7..0d9d13b9d2 100644 --- a/packages/core/src/blocks/Heading/a11y/block.aria.test.ts +++ b/packages/core/src/blocks/Heading/a11y/block.aria.test.ts @@ -12,7 +12,7 @@ test("read-only toggle heading: Tab reaches the toggle and Enter expands/collaps resetToggleHeading(); await openEditor(toggleHeadingDocument, { editable: false }); await focusToggleHeading(); - const editor = page.getByRole("textbox"); + const editor = page.getByRole("main").getByRole("document"); await checkAriaSnapshot(editor, "collapsed"); await activateToggleHeading(); await checkAriaSnapshot(editor, "expanded"); diff --git a/packages/core/src/blocks/Heading/a11y/block.aria.test.ts-snapshots/aria/browser.snap b/packages/core/src/blocks/Heading/a11y/block.aria.test.ts-snapshots/aria/browser.snap index bb05a9c68c..abf6495d37 100644 --- a/packages/core/src/blocks/Heading/a11y/block.aria.test.ts-snapshots/aria/browser.snap +++ b/packages/core/src/blocks/Heading/a11y/block.aria.test.ts-snapshots/aria/browser.snap @@ -1,19 +1,19 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it > collapsed 1`] = ` -"- textbox: +"- document: - button "Expand or collapse" - heading "Details" [level=2]" `; exports[`read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it > collapsed again 1`] = ` -"- textbox: +"- document: - button "Expand or collapse" - heading "Details" [level=2]" `; exports[`read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it > expanded 1`] = ` -"- textbox: +"- document: - button "Expand or collapse" [expanded] - heading "Details" [level=2] - paragraph: Nested read-only content" diff --git a/packages/core/src/blocks/Heading/a11y/block.keyboard.test.ts b/packages/core/src/blocks/Heading/a11y/block.keyboard.test.ts index a052350434..0bfb8c8247 100644 --- a/packages/core/src/blocks/Heading/a11y/block.keyboard.test.ts +++ b/packages/core/src/blocks/Heading/a11y/block.keyboard.test.ts @@ -41,7 +41,7 @@ test("read-only toggle heading: Tab reaches the toggle and Enter expands/collaps await openEditor(toggleHeadingDocument, { editable: false }); const toggleButton = await focusToggleHeadingWithAssertions(); await expect - .element(page.getByRole("textbox")) + .element(page.getByRole("main").getByRole("document")) .toMatchScreenshot("toggle-heading-focused.png", {}); await activateToggleHeading(); await expect.element(toggleButton).toHaveFocus(); diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index 63b03a8fcb..786cdb20cb 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -347,6 +347,8 @@ NESTED BLOCKS /* Checked */ .bn-block-content[data-content-type="checkListItem"] > div:has(> input) { + /* Keep vertical caret navigation out of the non-editable checkbox wrapper. */ + user-select: none; height: 24px; } diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 699dcbbc5b..cb93118adb 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -533,6 +533,10 @@ export class BlockNoteEditor< scrollMargin: { top: 72, bottom: 72, left: 0, right: 0 }, ...newOptions._tiptapOptions?.editorProps, attributes: { + // Let ReadOnlyExtension supply the role dynamically instead of + // inheriting TipTap's unconditional textbox role. Explicit consumer + // roles below still take precedence. + role: "", // As of TipTap v2.5.0 the tabIndex is removed when the editor is not // editable, so you can't focus it. We want to revert this as we have // UI behaviour that relies on it. diff --git a/packages/core/src/extensions/ReadOnly/ReadOnly.browser.test.ts b/packages/core/src/extensions/ReadOnly/ReadOnly.browser.test.ts new file mode 100644 index 0000000000..0428546365 --- /dev/null +++ b/packages/core/src/extensions/ReadOnly/ReadOnly.browser.test.ts @@ -0,0 +1,34 @@ +import { afterEach, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { ReadOnlyExtension } from "./ReadOnly.js"; + +const cleanups: (() => void)[] = []; + +afterEach(() => { + cleanups.splice(0).forEach((cleanup) => cleanup()); +}); + +it("exposes read-only content as a document and restores input semantics", () => { + const editor = BlockNoteEditor.create(); + const host = document.createElement("div"); + document.body.append(host); + editor.mount(host); + cleanups.push(() => { + editor.unmount(); + host.remove(); + }); + const readOnly = editor.getExtension(ReadOnlyExtension)!; + expect(host.getAttribute("role")).toBe("textbox"); + expect(host.getAttribute("aria-autocomplete")).toBe("list"); + editor.isEditable = false; + expect(host.getAttribute("role")).toBe("document"); + expect(host.hasAttribute("aria-autocomplete")).toBe(false); + expect(host.hasAttribute("aria-haspopup")).toBe(false); + readOnly.setReadOnly(true, "preview"); + editor.isEditable = true; + expect(host.getAttribute("role")).toBe("document"); + readOnly.setReadOnly(false, "preview"); + expect(host.getAttribute("role")).toBe("textbox"); + expect(host.getAttribute("aria-haspopup")).toBe("listbox"); +}); diff --git a/packages/core/src/extensions/ReadOnly/ReadOnly.ts b/packages/core/src/extensions/ReadOnly/ReadOnly.ts index a15876bbb4..538dd8af73 100644 --- a/packages/core/src/extensions/ReadOnly/ReadOnly.ts +++ b/packages/core/src/extensions/ReadOnly/ReadOnly.ts @@ -42,6 +42,14 @@ export const ReadOnlyExtension = createExtension( new Plugin({ key: PLUGIN_KEY, props: { + attributes: (): Record => + store.state.isEditable && store.state.enabledSet.size === 0 + ? { + role: "textbox", + "aria-autocomplete": "list", + "aria-haspopup": "listbox", + } + : { role: "document" }, editable: () => store.state.isEditable && store.state.enabledSet.size === 0, }, diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index 89cc465424..e5f497b7ae 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -400,12 +400,6 @@ const ContentEditableElement = (props: { }) => { const { autoFocus, mount, contentEditableProps } = props; return ( -
+
); }; diff --git a/packages/react/src/editor/a11y/BlockNoteView.aria.test.ts b/packages/react/src/editor/a11y/BlockNoteView.aria.test.ts index 96257653ea..85b3e09c29 100644 --- a/packages/react/src/editor/a11y/BlockNoteView.aria.test.ts +++ b/packages/react/src/editor/a11y/BlockNoteView.aria.test.ts @@ -14,5 +14,8 @@ test("default schema: arrow-key document navigation", async () => { test("read-only editor: Tab reaches inline links and leaves the editor", async () => { await openEditor(readOnlyLinkDocument, { editable: false }); await focusReadOnlyLink(); - await checkAriaSnapshot(page.getByRole("textbox"), "read-only editor"); + await checkAriaSnapshot( + page.getByRole("main").getByRole("document"), + "read-only editor", + ); }); diff --git a/packages/react/src/editor/a11y/BlockNoteView.aria.test.ts-snapshots/aria/browser.snap b/packages/react/src/editor/a11y/BlockNoteView.aria.test.ts-snapshots/aria/browser.snap index a82f3e74ec..8a9f173f54 100644 --- a/packages/react/src/editor/a11y/BlockNoteView.aria.test.ts-snapshots/aria/browser.snap +++ b/packages/react/src/editor/a11y/BlockNoteView.aria.test.ts-snapshots/aria/browser.snap @@ -86,7 +86,7 @@ exports[`default schema: arrow-key document navigation > default schema 1`] = ` `; exports[`read-only editor: Tab reaches inline links and leaves the editor > read-only editor 1`] = ` -"- textbox: +"- document: - paragraph: - link "Documentation": - /url: https://www.blocknotejs.org/" diff --git a/tests/a11y/helpers.tsx b/tests/a11y/helpers.tsx index 3430ca7b22..afcee79f9e 100644 --- a/tests/a11y/helpers.tsx +++ b/tests/a11y/helpers.tsx @@ -76,9 +76,11 @@ export async function openEditor( }} />, ); - await expect.element(page.getByRole("textbox")).toBeVisible(); await expect - .element(page.getByRole("textbox")) + .element(page.getByRole(editable ? "textbox" : "document")) + .toBeVisible(); + await expect + .element(page.getByRole(editable ? "textbox" : "document")) .toHaveAttribute("contenteditable", String(editable)); await document.fonts.ready; if (screenReader) {