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
1 change: 1 addition & 0 deletions packages/core/src/blocks/ListItem/BulletListItem/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const createBulletListItemBlockSpec = createBlockSpec(
emptyEnterResets: true,
},
meta: {
listItem: true,
isolating: false,
},
parse(element) {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/blocks/ListItem/CheckListItem/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const createCheckListItemBlockSpec = createBlockSpec(
emptyEnterResets: true,
},
meta: {
listItem: true,
isolating: false,
},
parse(element) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const createNumberedListItemBlockSpec = createBlockSpec(
emptyEnterResets: true,
},
meta: {
listItem: true,
isolating: false,
},
parse(element) {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/blocks/ListItem/ToggleListItem/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const createToggleListItemBlockSpec = createBlockSpec(
};
},
meta: {
listItem: true,
isolating: false,
// A block dragged onto the toggle becomes its first child.
dropsIntoChildren: () => true,
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
HistoryExtension,
InlineContentBoundaryEditExtension,
LinkToolbarExtension,
ListAccessibilityExtension,
NodeSelectionKeyboardExtension,
PlaceholderExtension,
PositionMappingExtension,
Expand Down Expand Up @@ -174,6 +175,7 @@ export function getDefaultExtensions(
: {}),
}),
LinkToolbarExtension(options),
ListAccessibilityExtension(),
NodeSelectionKeyboardExtension(),
PlaceholderExtension(options),
ReadOnlyExtension({ editable: options._tiptapOptions?.editable }),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { afterEach, describe, expect, it } from "vite-plus/test";

import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js";
import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { createBlockSpec } from "../../schema/blocks/createSpec.js";

const customItem = createBlockSpec(
{ type: "customItem", propSchema: {}, content: "inline" },
{
meta: { listItem: true },
render() {
const dom = document.createElement("p");
return { dom, contentDOM: dom };
},
},
)();
const schema = BlockNoteSchema.create().extend({ blockSpecs: { customItem } });
const cleanups: (() => void)[] = [];

function createEditor(editable = false) {
const editor = BlockNoteEditor.create({
schema,
_tiptapOptions: { editable },
});
const host = document.createElement("div");
document.body.append(host);
editor.mount(host);
cleanups.push(() => {
editor.unmount();
host.remove();
});
return { editor, host };
}

function lists(host: HTMLElement) {
return Array.from(host.querySelectorAll('[role="list"]')).map((owner) => {
const ids = owner.getAttribute("aria-owns")!.split(" ");
return ids.map((id) => {
const item = document.getElementById(id);
expect(item).not.toBeNull();
expect(host.contains(item)).toBe(true);
expect(item!.getAttribute("role")).toBe("listitem");
expect(item!.classList.contains("bn-block-outer")).toBe(true);
return item!.getAttribute("data-id");
});
});
}

afterEach(() => {
cleanups.splice(0).forEach((cleanup) => cleanup());
});

describe("list accessibility", () => {
it("exposes read-only content as a document and restores input semantics", () => {
const { editor, host } = createEditor(true);
const root = host;
const readOnly = editor.getExtension(ReadOnlyExtension)!;
expect(root.getAttribute("role")).toBe("textbox");
expect(root.getAttribute("aria-autocomplete")).toBe("list");
editor.isEditable = false;
expect(root.getAttribute("role")).toBe("document");
expect(root.hasAttribute("aria-autocomplete")).toBe(false);
expect(root.hasAttribute("aria-haspopup")).toBe(false);
readOnly.setReadOnly(true, "preview");
editor.isEditable = true;
expect(root.getAttribute("role")).toBe("document");
readOnly.setReadOnly(false, "preview");
expect(root.getAttribute("role")).toBe("textbox");
expect(root.getAttribute("aria-haspopup")).toBe("listbox");
});

it("only exposes list ownership while read-only, including after edits", () => {
const { editor, host } = createEditor(true);
editor.replaceBlocks(editor.document, [
{ id: "a", type: "bulletListItem" },
{ id: "b", type: "bulletListItem" },
]);
expect(host.querySelector('[aria-owns], [role="listitem"]')).toBeNull();
editor.isEditable = false;
expect(lists(host)).toEqual([["a", "b"]]);
editor.isEditable = true;
expect(host.querySelector('[aria-owns], [role="listitem"]')).toBeNull();
expect(host.querySelector('[data-id="a"]')!.hasAttribute("id")).toBe(false);
editor.updateBlock("b", { type: "numberedListItem" });
const readOnly = editor.getExtension(ReadOnlyExtension)!;
readOnly.setReadOnly(true, "preview");
expect(lists(host)).toEqual([["a"], ["b"]]);
readOnly.setReadOnly(false, "preview");
expect(host.querySelector('[aria-owns], [role="listitem"]')).toBeNull();
});

it("groups same-type siblings and supports custom list items", () => {
const { editor, host } = createEditor();
editor.replaceBlocks(editor.document, [
{ id: "a", type: "bulletListItem" },
{ id: "b", type: "bulletListItem" },
{ id: "c", type: "numberedListItem" },
{ type: "paragraph" },
{ id: "d", type: "numberedListItem" },
{ id: "e", type: "checkListItem" },
{ id: "f", type: "toggleListItem" },
{ id: "g", type: "customItem" },
{ id: "h", type: "customItem" },
]);
expect(lists(host)).toEqual([
["a", "b"],
["c"],
["d"],
["e"],
["f"],
["g", "h"],
]);
const owner = host.querySelector<HTMLElement>('[role="list"]')!;
expect(owner.nextElementSibling?.getAttribute("data-id")).toBe("a");
expect(getComputedStyle(owner).display).not.toBe("none");
expect(getComputedStyle(owner).visibility).toBe("visible");
expect(owner.hasAttribute("aria-hidden")).toBe(false);
});

it("keeps non-list children and nested lists inside their parent item", () => {
const { editor, host } = createEditor();
editor.replaceBlocks(editor.document, [
{
id: "parent",
type: "bulletListItem",
children: [
{ id: "explanation", type: "paragraph", content: "Explanation" },
{ id: "nested", type: "numberedListItem" },
],
},
{ id: "sibling", type: "bulletListItem" },
]);
expect(lists(host)).toEqual([["parent", "sibling"], ["nested"]]);
const parent = host.querySelector('[role="listitem"][data-id="parent"]')!;
expect(parent.querySelector('[data-id="explanation"]')).not.toBeNull();
expect(parent.querySelector('[role="list"]')).not.toBeNull();
expect(
host.querySelector('[data-id="explanation"]')!.hasAttribute("role"),
).toBe(false);
});

it("splits, merges, and removes lists when blocks change", () => {
const { editor, host } = createEditor();
editor.replaceBlocks(editor.document, [
{ id: "a", type: "bulletListItem" },
{ id: "b", type: "bulletListItem" },
{ id: "c", type: "bulletListItem" },
]);
const originalId = host.querySelector('[role="listitem"][data-id="a"]')!.id;
editor.updateBlock("b", { type: "paragraph" });
expect(lists(host)).toEqual([["a"], ["c"]]);
expect(host.querySelector('[data-id="b"]')!.hasAttribute("role")).toBe(
false,
);
editor.updateBlock("b", { type: "bulletListItem" });
expect(lists(host)).toEqual([["a", "b", "c"]]);
expect(host.querySelector('[role="listitem"][data-id="a"]')!.id).toBe(
originalId,
);
editor.removeBlocks(["a", "b", "c"]);
expect(lists(host)).toEqual([]);
expect(host.querySelector('[role="listitem"]')).toBeNull();
});

it("namespaces IDs across editors and works in read-only mode", () => {
const first = createEditor();
const second = createEditor();
for (const { editor, host } of [first, second]) {
editor.replaceBlocks(editor.document, [
{ id: "same id", type: "bulletListItem" },
]);
editor.isEditable = false;
expect(lists(host)).toEqual([["same id"]]);
expect(editor.document[0].id).toBe("same id");
}
expect(first.host.querySelector('[role="listitem"]')!.id).not.toBe(
second.host.querySelector('[role="listitem"]')!.id,
);
});
});
107 changes: 107 additions & 0 deletions packages/core/src/extensions/ListAccessibility/ListAccessibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { Node } from "prosemirror-model";
import { Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";

import { createExtension } from "../../editor/BlockNoteExtension.js";
import { UniqueID } from "../tiptap-extensions/UniqueID/UniqueID.js";

const pluginKey = new PluginKey<DecorationSet>("list-accessibility");

/** Supplies read-only list ownership without changing the block hierarchy. */
export const ListAccessibilityExtension = createExtension(({ editor }) => {
const prefix = `bn-list-item-${UniqueID.options.generateID()}`;

function decorate(doc: Node): DecorationSet {
const decorations: Decoration[] = [];

function visit(parent: Node, start: number) {
let run: { type: string; pos: number; ids: string[] } | undefined;

function finishRun() {
if (!run) {
return;
}
const owns = run.ids.join(" ");
decorations.push(
Decoration.widget(
run.pos,
(view) => {
const dom = view.dom.ownerDocument.createElement("div");
dom.setAttribute("role", "list");
dom.setAttribute("aria-owns", owns);
// Keep the owner in the accessibility tree without affecting
// layout. display:none/visibility:hidden would hide the list.
dom.style.cssText =
"position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0";
return dom;
},
{ side: -1, key: owns, ignoreSelection: true },
),
);
run = undefined;
}

parent.forEach((node, offset) => {
const pos = start + offset;
const type =
node.type.name === "blockContainer"
? node.firstChild?.type.name
: undefined;
if (
type &&
editor.schema.blockSpecs[type]?.implementation.meta.listItem
) {
if (run && run.type !== type) {
finishRun();
}
const currentRun = (run ??= { type, pos, ids: [] });
// Namespace IDs per editor, including when the same document is
// mounted twice. Encode block IDs so whitespace cannot split IDREFs.
const id = `${prefix}-${
typeof node.attrs.id === "string"
? `id-${encodeURIComponent(node.attrs.id)}`
: `pos-${pos}`
}`;
currentRun.ids.push(id);
// A blockContainer's root is .bn-block-outer: the item includes its
// children, including paragraphs and any nested lists.
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
id,
role: "listitem",
}),
);
} else {
finishRun();
}
if (!node.isLeaf && !node.isTextblock) {
visit(node, pos + 1);
}
});
finishRun();
}

visit(doc, 0);
return DecorationSet.create(doc, decorations);
}

return {
key: "listAccessibility",
prosemirrorPlugins: [
new Plugin<DecorationSet>({
key: pluginKey,
state: {
init: (_, state) => decorate(state.doc),
apply: (tr, previous) =>
tr.docChanged ? decorate(tr.doc) : previous,
},
props: {
// ReadOnlyExtension dispatches when editability changes, so this
// also removes both owners and item attributes when editing resumes.
decorations: (state) =>
editor.isEditable ? DecorationSet.empty : pluginKey.getState(state),
},
}),
],
};
});
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" },
editable: () =>
store.state.isEditable && store.state.enabledSet.size === 0,
},
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from "./FormattingToolbar/FormattingToolbar.js";
export * from "./History/History.js";
export * from "./InlineContentBoundaryEdit/InlineContentBoundaryEdit.js";
export * from "./LinkToolbar/LinkToolbar.js";
export * from "./ListAccessibility/ListAccessibility.js";
export * from "./LinkToolbar/protocols.js";
export * from "./NodeSelectionKeyboard/NodeSelectionKeyboard.js";
export * from "./Placeholder/Placeholder.js";
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/schema/blocks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ export interface BlockConfigMeta<
* block's source is hidden behind its preview and edited via the popup.
*/
hasPreview?: boolean;

/**
* Exposes this block as an accessible list item in read-only mode. Siblings of the
* same block type form a list. Does not change markers, keyboard behavior,
* or HTML export.
* @default false
*/
listItem?: boolean;
}

/**
Expand Down
Loading
Loading