Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/src/blocks/Heading/a11y/block.aria.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/editor/Block.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/extensions/ReadOnly/ReadOnly.browser.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
8 changes: 8 additions & 0 deletions packages/core/src/extensions/ReadOnly/ReadOnly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ export const ReadOnlyExtension = createExtension(
new Plugin({
key: PLUGIN_KEY,
props: {
attributes: (): Record<string, string> =>
store.state.isEditable && store.state.enabledSet.size === 0
? {
role: "textbox",
"aria-autocomplete": "list",
"aria-haspopup": "listbox",
}
: { role: "document" },
Comment on lines +45 to +52

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very smart love it

editable: () =>
store.state.isEditable && store.state.enabledSet.size === 0,
},
Expand Down
8 changes: 1 addition & 7 deletions packages/react/src/editor/BlockNoteView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,12 +400,6 @@ const ContentEditableElement = (props: {
}) => {
const { autoFocus, mount, contentEditableProps } = props;
return (
<div
aria-autocomplete="list"
aria-haspopup="listbox"
data-bn-autofocus={autoFocus}
ref={mount}
{...contentEditableProps}
/>
<div data-bn-autofocus={autoFocus} ref={mount} {...contentEditableProps} />
);
};
5 changes: 4 additions & 1 deletion packages/react/src/editor/a11y/BlockNoteView.aria.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down
6 changes: 4 additions & 2 deletions tests/a11y/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading