diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.test.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.test.ts new file mode 100644 index 0000000000..44dc05b8ea --- /dev/null +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.test.ts @@ -0,0 +1,288 @@ +// @vitest-environment node +import { DOMSerializer, Schema } from "prosemirror-model"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import type { PartialBlock } from "../../../../blocks/defaultBlocks.js"; +import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import type { + InlineContentSchema, + StyleSchema, +} from "../../../../schema/index.js"; +import { serializeBlocksInternalHTML } from "./serializeBlocksInternalHTML.js"; + +type FixtureSchema = Record< + string, + { type: string; content: "none"; propSchema: { start: { default: number } } } +>; +type FixtureBlock = PartialBlock< + FixtureSchema, + InlineContentSchema, + StyleSchema +>; + +// These tests exercise sibling traversal and attribute placement, not browser +// rendering or parsing. Real HTML round trips and the accessibility tree need +// the browser suite; this deliberately supplies no global document/jsdom. +class ElementAdapter { + attributes = new Map(); + children: ElementAdapter[] = []; + + constructor(readonly name: string) {} + + setAttribute(name: string, value: string) { + this.attributes.set(name, value); + } + + getAttribute(name: string) { + return this.attributes.get(name) ?? null; + } + + appendChild(child: ElementAdapter) { + if (child.name === "fragment") { + this.children.push(...child.children); + } else { + this.children.push(child); + } + } + + append(child: ElementAdapter) { + this.appendChild(child); + } +} + +vi.mock("../../../../schema/blocks/frame.js", () => ({ + markFrameSlot: vi.fn(), + renderFrameDOM: (_editor: unknown, block: { type: string; id?: string }) => { + if (block.type !== "layout" && block.type !== "framed") { + return undefined; + } + const dom = new ElementAdapter(block.type); + const slot = new ElementAdapter("slot"); + dom.setAttribute("data-id", block.id ?? ""); + dom.appendChild(slot); + return { dom, slot }; + }, +})); + +vi.mock("../../../../pm-nodes/BlockContainer.js", () => ({ + setBlockColorAttributes: vi.fn(), +})); + +const listTypes = [ + "bulletListItem", + "numberedListItem", + "checkListItem", + "toggleListItem", + "custom", + "framed", +]; + +function fixture( + types = ["paragraph", "unmarked", "layout", ...listTypes], + serializeInternalHTML = serializeBlocksInternalHTML, +) { + const schema = new Schema({ + nodes: { + doc: { content: "blockGroup" }, + text: {}, + blockGroup: { + content: "blockContainer*", + toDOM: () => { + const dom = new ElementAdapter("group"); + return { + dom: dom as unknown as HTMLElement, + contentDOM: dom as unknown as HTMLElement, + }; + }, + }, + blockContainer: { + attrs: { id: { default: null } }, + toDOM: (node) => { + const dom = new ElementAdapter("outer"); + const contentDOM = new ElementAdapter("inner"); + contentDOM.setAttribute("data-id", node.attrs.id); + dom.appendChild(contentDOM); + return { + dom: dom as unknown as HTMLElement, + contentDOM: contentDOM as unknown as HTMLElement, + }; + }, + }, + ...Object.fromEntries( + types.map((type) => [ + type, + { + group: + type === "layout" ? "childContainer bnBlock" : "blockContent", + }, + ]), + ), + }, + }); + const editor = { + pmSchema: schema, + schema: { + blockSchema: Object.fromEntries( + types.map((type) => [type, { propSchema: {} }]), + ), + }, + blockImplementations: Object.fromEntries( + types.map((type) => [ + type, + { + implementation: { + meta: { listItem: listTypes.includes(type) }, + render: () => ({ dom: new ElementAdapter("content") }), + }, + }, + ]), + ), + } as unknown as BlockNoteEditor< + FixtureSchema, + InlineContentSchema, + StyleSchema + >; + const document = { + createDocumentFragment: () => new ElementAdapter("fragment"), + } as unknown as Document; + return function serialize(blocks: FixtureBlock[]) { + return serializeInternalHTML( + editor, + blocks, + DOMSerializer.fromSchema(schema), + { document }, + ) as unknown as ElementAdapter; + }; +} + +function inner(outer: ElementAdapter) { + return outer.children[0]; +} + +function expectRun(outers: ElementAdapter[]) { + expect(outers[0].getAttribute("role")).toBe("list"); + const items = outers.map(inner); + for (const item of items) { + expect(item.getAttribute("role")).toBe("listitem"); + expect(item.getAttribute("id")).toMatch(/^[A-Za-z][\w-]+$/); + } + expect(outers[0].getAttribute("aria-owns")).toBe( + items.length === 1 + ? null + : items + .slice(1) + .map((item) => item.getAttribute("id")) + .join(" "), + ); + for (const outer of outers.slice(1)) { + expect(outer.getAttribute("role")).toBeNull(); + expect(outer.getAttribute("aria-owns")).toBeNull(); + } +} + +describe("static internal HTML list relationships", () => { + it("keeps combined exports from independent module contexts uniquely owned", async () => { + async function isolatedExport() { + vi.resetModules(); + const { serializeBlocksInternalHTML } = + await import("./serializeBlocksInternalHTML.js"); + return fixture( + undefined, + serializeBlocksInternalHTML, + )([ + { type: "bulletListItem", id: "same-a" }, + { type: "bulletListItem", id: "same-b" }, + ]); + } + + const first = await isolatedExport(); + const second = await isolatedExport(); + expectRun(first.children); + expectRun(second.children); + const combined = [...first.children, ...second.children]; + const ids = combined.map((outer) => inner(outer).getAttribute("id")); + expect(new Set(ids).size).toBe(combined.length); + for (const outer of combined) { + for (const id of outer.getAttribute("aria-owns")?.split(" ") ?? []) { + expect(ids.filter((target) => target === id)).toHaveLength(1); + } + } + }); + + it("groups exact consecutive types; numbered restarts do not split runs", () => { + const result = fixture()([ + { type: "paragraph" }, + { type: "bulletListItem" }, + { type: "bulletListItem" }, + { type: "numberedListItem" }, + { type: "numberedListItem", props: { start: 7 } }, + { type: "bulletListItem" }, + { type: "unmarked" }, + { type: "bulletListItem" }, + ]); + expectRun(result.children.slice(1, 3)); + expectRun(result.children.slice(3, 5)); + expectRun(result.children.slice(5, 6)); + expectRun(result.children.slice(7, 8)); + expect(inner(result.children[0]).getAttribute("role")).toBeNull(); + expect(result.children[6].getAttribute("role")).toBeNull(); + expect(inner(result.children[6]).getAttribute("role")).toBeNull(); + }); + + it.each(listTypes)( + "supports %s without a numbered-list schema dependency", + (type) => { + const result = fixture([type])([{ type }, { type }]); + expectRun(result.children); + }, + ); + + it("keeps nested and framed content inside its item and layout parents separate", () => { + const result = fixture()([ + { + type: "bulletListItem", + children: [ + { type: "paragraph", id: "nested-text" }, + { type: "bulletListItem" }, + { type: "bulletListItem" }, + ], + }, + { type: "bulletListItem" }, + { + type: "layout", + children: [{ type: "bulletListItem" }, { type: "bulletListItem" }], + }, + { type: "layout", children: [{ type: "bulletListItem" }] }, + { type: "framed", children: [{ type: "bulletListItem" }] }, + ]); + expectRun(result.children.slice(0, 2)); + const nested = inner(result.children[0]).children[1]; + expect(inner(nested.children[0]).getAttribute("data-id")).toBe( + "nested-text", + ); + expectRun(nested.children.slice(1)); + expectRun(result.children[2].children[0].children); + expectRun(result.children[3].children[0].children); + expectRun(result.children.slice(4)); + const frame = inner(result.children[4]).children[0]; + expectRun(frame.children[0].children[1].children); + }); + + it("uses unique serialization IDs even for missing, duplicate, and unsafe block IDs", () => { + const serialize = fixture(); + const blocks: FixtureBlock[] = [ + { type: "bulletListItem", id: 'unsafe " < & /' }, + { type: "bulletListItem", id: 'unsafe " < & /' }, + { type: "bulletListItem" }, + ]; + const first = serialize(blocks); + const second = serialize(blocks); + expectRun(first.children); + expectRun(second.children); + const ids = [...first.children, ...second.children].map((outer) => + inner(outer).getAttribute("id"), + ); + expect(new Set(ids).size).toBe(6); + expect(inner(first.children[0]).getAttribute("data-id")).toBe(blocks[0].id); + }); +}); diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 3a5ed1e357..21457b3b58 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -1,4 +1,5 @@ import { DOMSerializer, Fragment, Node } from "prosemirror-model"; +import { uuidv4 } from "lib0/random"; import { PartialBlock } from "../../../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; @@ -20,6 +21,36 @@ import { markFrameSlot, renderFrameDOM, } from "../../../../schema/blocks/frame.js"; + +type SerializedListItem = { + outer: HTMLElement; + inner: HTMLElement; + id: string; +}; + +// Keep the representation here: the first outer wrapper owns a list, whose +// natural first child and explicitly owned later children encompass whole items. +function addListAttributes(items: SerializedListItem[]) { + if (items.length === 0) { + return; + } + for (const item of items) { + item.inner.setAttribute("role", "listitem"); + item.inner.setAttribute("id", item.id); + } + const owner = items[0].outer; + owner.setAttribute("role", "list"); + if (items.length > 1) { + owner.setAttribute( + "aria-owns", + items + .slice(1) + .map((item) => item.id) + .join(" "), + ); + } +} + export function serializeInlineContentInternalHTML< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -172,7 +203,7 @@ function serializeBlock< if (children.length > 0) { frame.slot.append(serializeBlocks(editor, children, serializer, options)); } - return frame.dom; + return { dom: frame.dom, listItemDOM: undefined }; } // Every block but a layout block declares `render` (`validateRenderHooks`). @@ -234,7 +265,7 @@ function serializeBlock< serializeBlocksInternalHTML(editor, children, serializer, options), ); } - return bc.dom; + return { dom: bc.dom, listItemDOM: bc.contentDOM }; } function serializeBlocks< @@ -249,11 +280,39 @@ function serializeBlocks< ) { const doc = options?.document ?? document; const fragment = doc.createDocumentFragment(); + // Separate from document IDs and the mounted editor's namespace. Random + // scopes let exports from independent workers/page loads share a document. + const scope = uuidv4(); + let runType: string | undefined; + let run: SerializedListItem[] = []; - for (const block of blocks) { - const blockDOM = serializeBlock(editor, block, serializer, options); - fragment.appendChild(blockDOM); + for (const [index, block] of blocks.entries()) { + const { dom, listItemDOM } = serializeBlock( + editor, + block, + serializer, + options, + ); + const type = block.type; + const listItem = + listItemDOM && + type !== undefined && + editor.blockImplementations[type].implementation.meta?.listItem === true; + if (!listItem || type !== runType) { + addListAttributes(run); + run = []; + runType = listItem ? type : undefined; + } + if (listItem && listItemDOM) { + run.push({ + outer: dom, + inner: listItemDOM, + id: `bn-html-list-${scope}-${index}`, + }); + } + fragment.appendChild(dom); } + addListAttributes(run); return fragment; } diff --git a/packages/core/src/blocks/ListItem/BulletListItem/block.ts b/packages/core/src/blocks/ListItem/BulletListItem/block.ts index 6c791299fb..f2aa416b2c 100644 --- a/packages/core/src/blocks/ListItem/BulletListItem/block.ts +++ b/packages/core/src/blocks/ListItem/BulletListItem/block.ts @@ -33,6 +33,7 @@ export const createBulletListItemBlockSpec = createBlockSpec( emptyEnterResets: true, }, meta: { + listItem: true, isolating: false, }, parse(element) { diff --git a/packages/core/src/blocks/ListItem/CheckListItem/block.ts b/packages/core/src/blocks/ListItem/CheckListItem/block.ts index 936efc3be0..d1a983dd88 100644 --- a/packages/core/src/blocks/ListItem/CheckListItem/block.ts +++ b/packages/core/src/blocks/ListItem/CheckListItem/block.ts @@ -33,6 +33,7 @@ export const createCheckListItemBlockSpec = createBlockSpec( emptyEnterResets: true, }, meta: { + listItem: true, isolating: false, }, parse(element) { diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.test.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.test.ts index f3d24db68b..01dbdecb74 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.test.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.test.ts @@ -233,7 +233,7 @@ describe("IndexingPlugin: updates on structural changes", () => { }); }); -describe("IndexingPlugin: typing preserves indices (early exit)", () => { +describe("IndexingPlugin: typing preserves indices", () => { it("indices unchanged after typing in the first block", () => { const editor = createEditor(); setBlocks(editor, [ @@ -293,6 +293,23 @@ describe("IndexingPlugin: typing preserves indices (early exit)", () => { const indicesAfter = getDataIndices(editor); expect(indicesAfter).toEqual(indicesBefore); }); + + it("preserves decoration metadata through inline edits", () => { + const editor = createEditor(); + setBlocks(editor, [ + { type: "numberedListItem", content: "a" }, + { type: "numberedListItem", content: "b" }, + ]); + + const before = getDecoSpecs(editor); + const view = editor._tiptapEditor.view; + view.dispatch(view.state.tr.insertText("x", 4)); + + const after = getDecoSpecs(editor); + expect(after).toEqual(before); + expect(after[0]).toBe(before[0]); + expect(after[1]).toBe(before[1]); + }); }); describe("IndexingPlugin: decoration specs", () => { diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts index 57530d741b..00387f8764 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts @@ -35,6 +35,7 @@ export const createNumberedListItemBlockSpec = createBlockSpec( emptyEnterResets: true, }, meta: { + listItem: true, isolating: false, }, parse(element) { diff --git a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts index b516cf9d9e..af1a9c44da 100644 --- a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts +++ b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts @@ -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, diff --git a/packages/core/src/blocks/ListItem/getListItemContent.ts b/packages/core/src/blocks/ListItem/getListItemContent.ts index 8a200b4dab..216a1eef8f 100644 --- a/packages/core/src/blocks/ListItem/getListItemContent.ts +++ b/packages/core/src/blocks/ListItem/getListItemContent.ts @@ -78,6 +78,7 @@ export function getListItemContent( // Structure above is `blockGroup[]>` // We want to extract the first `blockContainer` node's content, and see if it is a text block. const listItemsFirstChild = blockGroupNode.firstChild?.firstChild; + const firstBlockContainer = blockGroupNode.firstChild!; // If the first node is not a text block, then it's first child is not compatible with the list item node. if (!listItemsFirstChild?.isTextblock) { @@ -93,11 +94,13 @@ export function getListItemContent( listItemsFirstChild.content, ); - // We have `blockGroup[]>` - // We want to extract out the rest of the nodes as `<...blockContainer[]>` - const remainingListItemChildren = blockGroupNode.content.cut( - // +2 for the `blockGroup` node's start and end markers - listItemsFirstChild.nodeSize + 2, + // We have `blockGroup, ...blockContainer[]>` + // We want to extract out the rest of the nodes as `<...blockContainer[]>`. + // The first block's own children (e.g. a parsed `` body) come first. + const firstBlockChildren = + firstBlockContainer.maybeChild(1)?.content ?? Fragment.empty; + const remainingListItemChildren = firstBlockChildren.append( + blockGroupNode.content.cut(firstBlockContainer.nodeSize), ); const hasRemainingListItemChildren = remainingListItemChildren.size > 0; diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index e7f95358d2..22589dc24f 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -9,6 +9,7 @@ import { Gapcursor } from "@tiptap/extensions/gap-cursor"; import { createDropFileExtension } from "../../../api/clipboard/fromClipboard/fileDropExtension.js"; import { createPasteFromClipboardExtension } from "../../../api/clipboard/fromClipboard/pasteExtension.js"; import { createCopyToClipboardExtension } from "../../../api/clipboard/toClipboard/copyExtension.js"; +import { ListAccessibilityExtension } from "../../../extensions/ListAccessibility/ListAccessibility.js"; import { BlockChangeExtension, DropCursorExtension, @@ -174,6 +175,7 @@ export function getDefaultExtensions( : {}), }), LinkToolbarExtension(options), + ListAccessibilityExtension(), NodeSelectionKeyboardExtension(), PlaceholderExtension(options), ReadOnlyExtension({ editable: options._tiptapOptions?.editable }), diff --git a/packages/core/src/extensions/ListAccessibility/ListAccessibility.browser.test.ts b/packages/core/src/extensions/ListAccessibility/ListAccessibility.browser.test.ts new file mode 100644 index 0000000000..a3a124fcf3 --- /dev/null +++ b/packages/core/src/extensions/ListAccessibility/ListAccessibility.browser.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { ListAccessibilityExtension } from "./ListAccessibility.js"; + +const editors: BlockNoteEditor[] = []; +const hosts: HTMLElement[] = []; + +afterEach(() => { + while (editors.length) { + editors.pop()!._tiptapEditor.destroy(); + } + while (hosts.length) { + hosts.pop()!.remove(); + } +}); + +function mountEditor() { + const host = document.createElement("div"); + document.body.append(host); + hosts.push(host); + const editor = BlockNoteEditor.create({ + initialContent: [ + { + id: "parent", + type: "bulletListItem", + content: "one", + children: [ + { id: "child-a", type: "bulletListItem", content: "child one" }, + { id: "child-b", type: "bulletListItem", content: "child two" }, + ], + }, + { id: "second", type: "bulletListItem", content: "two" }, + ], + domAttributes: { block: { id: "consumer-id", role: "article" } }, + }); + editor.mount(host); + editors.push(editor); + return editor; +} + +function blockElements(editor: BlockNoteEditor) { + return [ + ...editor.prosemirrorView.dom.querySelectorAll( + '[data-node-type="blockContainer"]', + ), + ]; +} + +describe("list accessibility node view integration", () => { + it("sets unique list item IDs and owns references per sibling run", () => { + const editor = mountEditor(); + const blocks = blockElements(editor); + + expect(blocks).toHaveLength(4); + expect(blocks.map((block) => block.getAttribute("role"))).toEqual([ + "listitem", + "listitem", + "listitem", + "listitem", + ]); + expect(new Set(blocks.map((block) => block.id)).size).toBe(blocks.length); + expect(blocks[0].parentElement?.getAttribute("role")).toBe("list"); + expect(blocks[0].parentElement?.getAttribute("aria-owns")).toBe( + blocks[3].id, + ); + expect(blocks[1].parentElement?.getAttribute("role")).toBe("list"); + expect(blocks[1].parentElement?.getAttribute("aria-owns")).toBe( + blocks[2].id, + ); + expect(blocks[2].parentElement?.getAttribute("role")).toBeNull(); + expect(blocks[3].parentElement?.getAttribute("role")).toBeNull(); + expect(blocks[3].parentElement?.hasAttribute("aria-owns")).toBe(false); + }); + + it("preserves consumer attributes on non-list blocks and restores on unregister", () => { + const editor = mountEditor(); + const blocks = blockElements(editor); + editor.updateBlock("child-a", { type: "paragraph" }); + + const paragraph = blockElements(editor).find( + (block) => block.getAttribute("role") === "article", + ); + expect(paragraph).toBeDefined(); + expect(paragraph?.id).toBe("consumer-id"); + + editor.unregisterExtension(ListAccessibilityExtension); + expect( + blockElements(editor).every( + (block) => + block.getAttribute("role") === "article" && + block.id === "consumer-id", + ), + ).toBe(true); + expect(blocks).toHaveLength(4); + }); +}); diff --git a/packages/core/src/extensions/ListAccessibility/ListAccessibility.test.ts b/packages/core/src/extensions/ListAccessibility/ListAccessibility.test.ts new file mode 100644 index 0000000000..f223bc6c53 --- /dev/null +++ b/packages/core/src/extensions/ListAccessibility/ListAccessibility.test.ts @@ -0,0 +1,111 @@ +// @vitest-environment node +import { Schema } from "prosemirror-model"; +import { EditorState } from "prosemirror-state"; +import { describe, expect, it } from "vite-plus/test"; + +import { + createListAccessibilityPlugin, + listAccessibilityKey, +} from "./ListAccessibility.js"; + +const schema = new Schema({ + nodes: { + doc: { content: "holder" }, + holder: { content: "blockContainer*" }, + blockContainer: { content: "(bullet | numbered | paragraph) holder?" }, + bullet: { content: "text*" }, + numbered: { content: "text*" }, + paragraph: { content: "text*" }, + text: {}, + }, +}); + +function block(type: string, children?: ReturnType) { + return schema.node("blockContainer", null, [ + schema.node(type, null, schema.text("item")), + ...(children ? [children] : []), + ]); +} + +function createState(blocks: ReturnType[]) { + return EditorState.create({ + schema, + doc: schema.node("doc", null, schema.node("holder", null, blocks)), + plugins: [createListAccessibilityPlugin(new Set(["bullet", "numbered"]))], + }); +} + +function entries(state: EditorState) { + return listAccessibilityKey + .getState(state)! + .decorations.find() + .sort((a, b) => a.from - b.from) + .map(({ spec }) => spec.listItem); +} + +describe("list accessibility plugin", () => { + it("decorates consecutive same-type list items and separates other types", () => { + const value = createState([ + block("bullet"), + block("bullet"), + block("numbered"), + block("paragraph"), + block("bullet"), + ]); + const result = entries(value); + + expect(result.map((item) => item.owner)).toEqual([true, false, true, true]); + expect(result[0].members).toEqual([result[1].id]); + expect(result[1].members).toEqual([]); + expect(result[2].members).toEqual([]); + expect(result[3].members).toEqual([]); + }); + + it("starts a separate run for nested children", () => { + const value = createState([ + block( + "bullet", + schema.node("holder", null, [block("bullet"), block("bullet")]), + ), + block("bullet"), + ]); + const result = entries(value); + + expect(result.map((item) => item.owner)).toEqual([ + true, + true, + false, + false, + ]); + expect(result[0].members).toEqual([result[3].id]); + expect(result[1].members).toEqual([result[2].id]); + }); + + it("recomputes after structural edits but reuses state on selection-only transactions", () => { + let value = createState([block("bullet"), block("bullet")]); + const before = listAccessibilityKey.getState(value)!; + value = value.apply(value.tr.insert(1, block("paragraph"))); + const after = listAccessibilityKey.getState(value)!; + + expect(after).not.toBe(before); + expect(entries(value).map((item) => item.owner)).toEqual([true, false]); + + const selectionOnly = value.apply(value.tr.setMeta("selectionOnly", true)); + expect(listAccessibilityKey.getState(selectionOnly)).toBe(after); + }); + + it("keeps list ownership stable through inline text edits", () => { + const value = createState([block("bullet"), block("bullet")]); + const before = entries(value); + const updated = value.apply(value.tr.insertText("x", 3)); + const after = entries(updated); + + expect(listAccessibilityKey.getState(updated)).not.toBe( + listAccessibilityKey.getState(value), + ); + expect(after).toHaveLength(2); + expect(after[0]).toBe(before[0]); + expect(after[1]).toBe(before[1]); + expect(after[0].members).toEqual([after[1].id]); + }); +}); diff --git a/packages/core/src/extensions/ListAccessibility/ListAccessibility.ts b/packages/core/src/extensions/ListAccessibility/ListAccessibility.ts new file mode 100644 index 0000000000..cd3d363fe6 --- /dev/null +++ b/packages/core/src/extensions/ListAccessibility/ListAccessibility.ts @@ -0,0 +1,142 @@ +import { uuidv4 } from "lib0/random"; +import { Plugin, PluginKey } from "prosemirror-state"; +import { DecorationSet, type DecorationSource } from "prosemirror-view"; + +import { createExtension } from "../../editor/BlockNoteExtension.js"; +import type { Item } from "./ListMembership.js"; +import { createListDecorations } from "./ListMembership.js"; +import { changesOnlyInlineContent } from "./inlineContentChange.js"; + +type ListState = { decorations: DecorationSet }; +export const listAccessibilityKey = new PluginKey( + "listAccessibility", +); + +export function createListAccessibilityPlugin(types: ReadonlySet) { + return new Plugin({ + key: listAccessibilityKey, + state: { + init(_, state) { + return { decorations: createListDecorations(state.doc, types) }; + }, + apply(tr, before) { + if (!tr.docChanged) { + return before; + } + const inline = changesOnlyInlineContent(tr); + if (inline) { + return { + decorations: before.decorations.map(tr.mapping, tr.doc), + }; + } + return { + decorations: createListDecorations(tr.doc, types), + }; + }, + }, + props: { + decorations(state) { + return this.getState(state)!.decorations; + }, + }, + }); +} + +/** A local NodeView adapter: no positions, node identity index or DOM registry. */ +export function listBlockAttributes( + outer: HTMLElement, + inner: HTMLElement, + decorations: DecorationSource, + idForItem: (itemId: string) => string, +) { + let original: + | { + id: string | null; + role: string | null; + outerRole: string | null; + owns: string | null; + } + | undefined; + let previous: Item | undefined; + function set( + element: HTMLElement, + name: string, + value: string | null | undefined, + ) { + if (value == null) { + if (element.hasAttribute(name)) { + element.removeAttribute(name); + } + } else if (element.getAttribute(name) !== value) { + element.setAttribute(name, value); + } + } + function apply(decorations: DecorationSource) { + let item: Item | undefined; + decorations.forEachSet((set) => { + item ??= set.find(1, 2, (spec) => spec.listItem)[0]?.spec.listItem; + }); + if (item === previous) { + return; + } + previous = item; + if (item && !original) { + original = { + id: inner.getAttribute("id"), + role: inner.getAttribute("role"), + outerRole: outer.getAttribute("role"), + owns: outer.getAttribute("aria-owns"), + }; + } + const itemIds = item ? [item.id, ...item.members] : []; + const ids = itemIds.map(idForItem); + const id = item ? ids[0] : undefined; + const owns = + item?.owner && item.members.length ? ids.slice(1).join(" ") : undefined; + set(inner, "id", item ? id : original?.id); + set(inner, "role", item ? "listitem" : original?.role); + set( + outer, + "role", + item ? (item.owner ? "list" : undefined) : original?.outerRole, + ); + set(outer, "aria-owns", owns ?? (item ? undefined : original?.owns)); + if (!item) { + original = undefined; + } + } + apply(decorations); + return { + update: apply, + destroy() { + apply(DecorationSet.empty); + }, + }; +} + +const viewIds = new WeakMap(); + +export function listItemId(view: object, itemId: string) { + let prefix = viewIds.get(view); + if (!prefix) { + prefix = `bn-list-view-${uuidv4()}-`; + viewIds.set(view, prefix); + } + return `${prefix}${itemId}`; +} + +export const ListAccessibilityExtension = createExtension(({ editor }) => ({ + key: "listAccessibility", + prosemirrorPlugins: [ + createListAccessibilityPlugin( + new Set( + Object.values(editor.schema.blockSpecs) + .filter( + (spec) => + spec.implementation.meta?.listItem && !spec.config.layoutOnly, + ) + .map((spec) => spec.config.type), + ), + ), + ], +})); diff --git a/packages/core/src/extensions/ListAccessibility/ListMembership.test.ts b/packages/core/src/extensions/ListAccessibility/ListMembership.test.ts new file mode 100644 index 0000000000..db19762841 --- /dev/null +++ b/packages/core/src/extensions/ListAccessibility/ListMembership.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment node +import { Schema } from "prosemirror-model"; +import { EditorState } from "prosemirror-state"; +import { describe, expect, it } from "vite-plus/test"; + +import { createListDecorations } from "./ListMembership.js"; + +const schema = new Schema({ + nodes: { + doc: { content: "holder" }, + holder: { content: "blockContainer*" }, + blockContainer: { + content: "(bullet | paragraph) holder?", + }, + bullet: { content: "text*" }, + paragraph: { content: "text*" }, + text: {}, + }, +}); + +function block(type: string, children?: ReturnType) { + return schema.node("blockContainer", null, [ + schema.node(type, null, schema.text("item")), + ...(children ? [children] : []), + ]); +} + +function state(blocks: ReturnType[]) { + return EditorState.create({ + schema, + doc: schema.node("doc", null, schema.node("holder", null, blocks)), + }); +} + +function items(doc: ReturnType) { + return createListDecorations(doc, new Set(["bullet"])) + .find() + .sort((a, b) => a.from - b.from) + .map((decoration) => decoration.spec.listItem); +} + +describe("list accessibility decorations", () => { + it("groups exact-type sibling runs and gives each run one owner", () => { + const value = state([ + block("bullet"), + block("bullet"), + block("paragraph"), + block("bullet"), + ]); + const result = items(value.doc); + + expect(result.map((item) => item.owner)).toEqual([true, false, true]); + expect(result[0].members).toEqual([result[1].id]); + expect(result[1].members).toEqual([]); + expect(result[2].members).toEqual([]); + expect(new Set(result.map((item) => item.id)).size).toBe(result.length); + }); + + it("builds nested sibling runs independently", () => { + const value = state([ + block( + "bullet", + schema.node("holder", null, [block("bullet"), block("bullet")]), + ), + block("bullet"), + ]); + const result = items(value.doc); + + expect(result.map((item) => item.owner)).toEqual([ + true, + true, + false, + false, + ]); + expect(result[0].members).toEqual([result[3].id]); + expect(result[1].members).toEqual([result[2].id]); + expect(result[3].members).toEqual([]); + }); + + it("rebuilds after document edits without mutating the previous state", () => { + const value = state([block("bullet"), block("bullet")]); + const previous = items(value.doc); + const next = value.apply(value.tr.insert(1, block("paragraph"))); + const rebuilt = items(next.doc); + + expect(rebuilt.map((item) => item.owner)).toEqual([true, false]); + expect(previous.map((item) => item.owner)).toEqual([true, false]); + }); +}); diff --git a/packages/core/src/extensions/ListAccessibility/ListMembership.ts b/packages/core/src/extensions/ListAccessibility/ListMembership.ts new file mode 100644 index 0000000000..04fe833212 --- /dev/null +++ b/packages/core/src/extensions/ListAccessibility/ListMembership.ts @@ -0,0 +1,88 @@ +import type { Node as PMNode } from "prosemirror-model"; +import { Decoration, DecorationSet } from "prosemirror-view"; + +export type Item = Readonly<{ + id: string; + owner: boolean; + members: readonly string[]; +}>; + +function collect( + node: PMNode, + types: ReadonlySet, + items: Decoration[], + pos: number, + nextId: { value: number }, +) { + if (node.isLeaf || node.isTextblock) { + return; + } + + const children = node.content.content; + let start = 0; + let startOffset = 0; + while (start < children.length) { + const firstChild = children[start]; + const first = + firstChild.type.name === "blockContainer" ? firstChild.firstChild : null; + let end = start + 1; + let endOffset = startOffset + firstChild.nodeSize; + while ( + first && + end < children.length && + (children[end].type.name === "blockContainer" + ? children[end].firstChild + : null + )?.type === first.type + ) { + endOffset += children[end].nodeSize; + end++; + } + + if (first && types.has(first.type.name)) { + const ids: string[] = []; + for (let index = 0; index < end - start; index++) { + ids[index] = `bn-list-item-${++nextId.value}`; + } + + let offset = startOffset; + for (let index = 0; index < end - start; index++) { + const child = children[start + index]; + const item: Item = { + id: ids[index], + owner: index === 0, + members: index === 0 ? ids.slice(1) : [], + }; + const itemPos = pos + 2 + offset; + const itemNode = child.firstChild!; + items.push( + Decoration.node( + itemPos, + itemPos + itemNode.nodeSize, + {}, + { + listItem: item, + }, + ), + ); + offset += child.nodeSize; + } + } + + startOffset = endOffset; + start = end; + } + + let offset = 0; + for (let index = 0; index < children.length; index++) { + const child = children[index]; + collect(child, types, items, pos + 1 + offset, nextId); + offset += child.nodeSize; + } +} + +export function createListDecorations(doc: PMNode, types: ReadonlySet) { + const items: Decoration[] = []; + collect(doc, types, items, -1, { value: 0 }); + return DecorationSet.create(doc, items); +} diff --git a/packages/core/src/extensions/ListAccessibility/a11y/ListAccessibility.aria.test.ts b/packages/core/src/extensions/ListAccessibility/a11y/ListAccessibility.aria.test.ts new file mode 100644 index 0000000000..21ab70a910 --- /dev/null +++ b/packages/core/src/extensions/ListAccessibility/a11y/ListAccessibility.aria.test.ts @@ -0,0 +1,63 @@ +import { test } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import { checkAriaSnapshot, openEditor } from "@root/tests/a11y/helpers.js"; + +test("nested and sibling list items expose list semantics", async () => { + await openEditor([ + { + id: "parent", + type: "bulletListItem", + content: "Parent item", + children: [ + { id: "nested-one", type: "bulletListItem", content: "Nested one" }, + { id: "nested-two", type: "bulletListItem", content: "Nested two" }, + ], + }, + { id: "sibling", type: "bulletListItem", content: "Sibling item" }, + ]); + + await checkAriaSnapshot( + page.getByRole("textbox"), + "nested and sibling list semantics", + ); +}); + +test("numbered list starts and nested numbering appear in the accessibility tree", async () => { + await openEditor([ + { + id: "first", + type: "numberedListItem", + props: { start: 4 }, + content: "First item", + children: [ + { id: "nested-first", type: "numberedListItem", content: "Nested one" }, + { + id: "nested-second", + type: "numberedListItem", + content: "Nested two", + }, + ], + }, + { id: "second", type: "numberedListItem", content: "Second item" }, + ]); + + await checkAriaSnapshot( + page.getByRole("textbox"), + "numbered list starts and nested list semantics", + ); +}); + +test("numbered list indexing restarts after a non-list block", async () => { + await openEditor([ + { id: "first", type: "numberedListItem", content: "First" }, + { id: "second", type: "numberedListItem", content: "Second" }, + { id: "separator", type: "paragraph", content: "Separator" }, + { id: "restart", type: "numberedListItem", content: "Restart" }, + ]); + + await checkAriaSnapshot( + page.getByRole("textbox"), + "numbered list indexing restarts after separator", + ); +}); diff --git a/packages/core/src/extensions/ListAccessibility/a11y/ListAccessibility.aria.test.ts-snapshots/aria/browser.snap b/packages/core/src/extensions/ListAccessibility/a11y/ListAccessibility.aria.test.ts-snapshots/aria/browser.snap new file mode 100644 index 0000000000..50a75e206f --- /dev/null +++ b/packages/core/src/extensions/ListAccessibility/a11y/ListAccessibility.aria.test.ts-snapshots/aria/browser.snap @@ -0,0 +1,53 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`nested and sibling list items expose list semantics > nested and sibling list semantics 1`] = ` +"- textbox: + - list: + - listitem: + - text: • + - paragraph: Parent item + - list: + - listitem: + - text: ◦ + - paragraph: Nested one + - listitem: + - text: ◦ + - paragraph: Nested two + - listitem: + - text: • + - paragraph: Sibling item" +`; + +exports[`numbered list indexing restarts after a non-list block > numbered list indexing restarts after separator 1`] = ` +"- textbox: + - list: + - listitem: + - text: "1." + - paragraph: First + - listitem: + - text: "2." + - paragraph: Second + - paragraph: Separator + - list: + - listitem: + - text: "1." + - paragraph: Restart" +`; + +exports[`numbered list starts and nested numbering appear in the accessibility tree > numbered list starts and nested list semantics 1`] = ` +"- textbox: + - list: + - listitem: + - text: "4." + - paragraph: First item + - list: + - listitem: + - text: "1." + - paragraph: Nested one + - listitem: + - text: "2." + - paragraph: Nested two + - listitem: + - text: "5." + - paragraph: Second item" +`; diff --git a/packages/core/src/extensions/ListAccessibility/inlineContentChange.ts b/packages/core/src/extensions/ListAccessibility/inlineContentChange.ts new file mode 100644 index 0000000000..687b29de68 --- /dev/null +++ b/packages/core/src/extensions/ListAccessibility/inlineContentChange.ts @@ -0,0 +1,39 @@ +import { + AddMarkStep, + RemoveMarkStep, + ReplaceStep, +} from "prosemirror-transform"; +import type { Transaction } from "prosemirror-state"; + +/** True when every step edits inline content without changing block structure. */ +export function changesOnlyInlineContent(tr: Transaction) { + return tr.steps.every((step, index) => { + if (step instanceof AddMarkStep || step instanceof RemoveMarkStep) { + return true; + } + if ( + !(step instanceof ReplaceStep) || + step.slice.openStart || + step.slice.openEnd + ) { + return false; + } + + const doc = tr.docs[index]; + if (!doc) { + return false; + } + const from = doc.resolve(step.from); + if (!from.parent.isTextblock || step.to > from.end(from.depth)) { + return false; + } + + const inserted = step.slice.content.content; + for (let index = 0; index < inserted.length; index++) { + if (!inserted[index].isInline) { + return false; + } + } + return true; + }); +} diff --git a/packages/core/src/pm-nodes/BlockContainer.ts b/packages/core/src/pm-nodes/BlockContainer.ts index aad3429a0b..f720ae0ec6 100644 --- a/packages/core/src/pm-nodes/BlockContainer.ts +++ b/packages/core/src/pm-nodes/BlockContainer.ts @@ -6,6 +6,10 @@ import { import type { NodeView } from "@tiptap/pm/view"; import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import { + listBlockAttributes, + listItemId, +} from "../extensions/ListAccessibility/ListAccessibility.js"; import { blockFrameView, findFrameSlot, @@ -149,6 +153,12 @@ export const BlockContainer = Node.create<{ contentDOM: HTMLElement; }; const editor = this.options.editor; + const listBlock = listBlockAttributes( + dom, + block, + props.innerDecorations, + (itemId) => listItemId(editor.prosemirrorView!, itemId), + ); // The block's frame: React draws its own, otherwise the block's // vanilla `renderFrame`. A drawn frame goes inside `.bn-block`; a block // without one keeps its content and children in `.bn-block` itself. @@ -169,6 +179,7 @@ export const BlockContainer = Node.create<{ setBlockColorAttributes(block, attrs); } + const frameHooks = frameNodeViewHooks(frame); const nodeView: NodeView = { dom, contentDOM: frame.slot, @@ -190,9 +201,26 @@ export const BlockContainer = Node.create<{ if (!node.firstChild!.sameMarkup(before.firstChild!)) { setBlockColorAttributes(block, node.firstChild!.attrs); } + listBlock.update(innerDecorations); return true; }, - ...frameNodeViewHooks(frame), + ...frameHooks, + destroy() { + listBlock.destroy(); + frameHooks.destroy?.(); + }, + ignoreMutation(mutation) { + if ( + mutation.type === "attributes" && + (mutation.target === dom || mutation.target === block) && + (mutation.attributeName === "role" || + mutation.attributeName === "id" || + mutation.attributeName === "aria-owns") + ) { + return true; + } + return frameHooks.ignoreMutation(mutation); + }, }; return nodeView; }) as unknown as NodeViewRenderer; diff --git a/packages/core/src/schema/blocks/meta.test.ts b/packages/core/src/schema/blocks/meta.test.ts index 192babda55..f6b7c1d711 100644 --- a/packages/core/src/schema/blocks/meta.test.ts +++ b/packages/core/src/schema/blocks/meta.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; +import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js"; +import { createBlockSpec } from "./createSpec.js"; import { resolveBlockMeta } from "./meta.js"; describe("resolveBlockMeta", () => { @@ -10,6 +12,7 @@ describe("resolveBlockMeta", () => { defining: true, isolating: true, draggable: true, + listItem: false, }); }); @@ -26,6 +29,7 @@ describe("resolveBlockMeta", () => { defining: true, isolating: false, draggable: true, + listItem: false, highlight, }); }); @@ -51,3 +55,69 @@ describe("resolveBlockMeta", () => { ).toBe(true); }); }); + +describe("list item metadata", () => { + it("defaults to non-list while preserving explicit opt-ins and opt-outs", () => { + expect(resolveBlockMeta(undefined, {}).listItem).toBe(false); + expect(resolveBlockMeta({ listItem: true }, {}).listItem).toBe(true); + expect(resolveBlockMeta({ listItem: false }, {}).listItem).toBe(false); + }); + + it.each([ + "bulletListItem", + "numberedListItem", + "checkListItem", + "toggleListItem", + ] as const)("opts the built-in %s into list semantics", (type) => { + const schema = BlockNoteSchema.create(); + expect(schema.blockSpecs[type].implementation.meta.listItem).toBe(true); + }); + + it("does not opt other built-in blocks into list semantics", () => { + const schema = BlockNoteSchema.create(); + for (const [type, spec] of Object.entries(schema.blockSpecs)) { + if ( + ![ + "bulletListItem", + "numberedListItem", + "checkListItem", + "toggleListItem", + ].includes(type) + ) { + expect(spec.implementation.meta.listItem, type).toBe(false); + } + } + }); + + it("lets a regular custom block opt in without a numbered-list block", () => { + const customListItem = createBlockSpec( + { type: "customListItem", propSchema: {}, content: "inline" }, + { + meta: { listItem: true }, + render() { + throw new Error("Metadata tests must not render a block"); + }, + }, + )(); + const custom = createBlockSpec( + { type: "custom", propSchema: {}, content: "inline" }, + { + render() { + throw new Error("Metadata tests must not render a block"); + }, + }, + )(); + const schema = BlockNoteSchema.create({ + blockSpecs: { customListItem, custom }, + }); + + expect(schema.blockSpecs.custom.implementation.meta.listItem).toBe(false); + expect(Object.keys(schema.blockSpecs)).toEqual([ + "customListItem", + "custom", + ]); + expect(schema.blockSpecs.customListItem.implementation.meta.listItem).toBe( + true, + ); + }); +}); diff --git a/packages/core/src/schema/blocks/meta.ts b/packages/core/src/schema/blocks/meta.ts index 0be0248ebe..844db0a59e 100644 --- a/packages/core/src/schema/blocks/meta.ts +++ b/packages/core/src/schema/blocks/meta.ts @@ -27,6 +27,7 @@ export function resolveBlockMeta< return { ...meta, selectable: meta?.selectable ?? true, + listItem: meta?.listItem ?? false, code: isLayoutBlock ? false : (meta?.code ?? false), defining: isLayoutBlock ? true : (meta?.defining ?? true), isolating: isLayoutBlock ? false : (meta?.isolating ?? true), diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 5af9f0eb23..40ffb5b262 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -49,6 +49,14 @@ export interface BlockConfigMeta< */ selectable?: boolean; + /** + * Whether this regular block participates in accessible lists. Consecutive + * sibling blocks of the same opted-in type form a list. This does not add + * markers, numbering, or keyboard behavior. + * @default false + */ + listItem?: boolean; + /** * The accept mime types for the file block */ @@ -112,7 +120,12 @@ export type ResolvedBlockConfigMeta< Required< Pick< BlockConfigMeta, - "selectable" | "code" | "defining" | "isolating" | "draggable" + | "selectable" + | "listItem" + | "code" + | "defining" + | "isolating" + | "draggable" > >; 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 0c20a42d08..a82f3e74ec 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 @@ -13,14 +13,42 @@ exports[`default schema: arrow-key document navigation > default schema 1`] = ` - code: code. - text: textColor. backgroundColor. - heading "Heading level one" [level=1] - - text: • - - paragraph: Bullet list item - - text: "1." - - paragraph: Numbered list item - - checkbox - - paragraph: Unchecked task - - button "Expand or collapse" - - paragraph: Toggle list item + - list: + - listitem: + - text: • + - paragraph: Bullet list item + - paragraph: Explanation inside the list item + - list: + - listitem: + - text: ◦ + - paragraph: First nested bullet + - listitem: + - text: ◦ + - paragraph: Second nested bullet + - listitem: + - text: • + - paragraph: Second bullet list item + - list: + - listitem: + - text: "1." + - paragraph: Numbered list item + - listitem: + - text: "2." + - paragraph: Second numbered list item + - list: + - listitem: + - checkbox + - paragraph: Unchecked task + - listitem: + - checkbox + - paragraph: Second task + - list: + - listitem: + - button "Expand or collapse" + - paragraph: Toggle list item + - listitem: + - button "Expand or collapse" + - paragraph: Second toggle list item - blockquote: Quoted text - code: const answer = 42; - separator @@ -50,8 +78,10 @@ exports[`default schema: arrow-key document navigation > default schema 1`] = ` - heading "Heading level 6" [level=6] - button "Expand or collapse" - heading "Toggle heading" [level=2] - - checkbox [checked] - - paragraph: Completed task + - list: + - listitem: + - checkbox [checked] + - paragraph: Completed task - paragraph: End of document" `; diff --git a/packages/react/src/editor/a11y/BlockNoteView.visual.test.ts-snapshots/visual/default-schema-text-size-200-percent-chromium-linux.png b/packages/react/src/editor/a11y/BlockNoteView.visual.test.ts-snapshots/visual/default-schema-text-size-200-percent-chromium-linux.png index 66737dfa7a..d6f38bad63 100644 Binary files a/packages/react/src/editor/a11y/BlockNoteView.visual.test.ts-snapshots/visual/default-schema-text-size-200-percent-chromium-linux.png and b/packages/react/src/editor/a11y/BlockNoteView.visual.test.ts-snapshots/visual/default-schema-text-size-200-percent-chromium-linux.png differ diff --git a/packages/react/src/editor/a11y/BlockNoteView.visual.test.ts-snapshots/visual/default-schema-text-spacing-chromium-linux.png b/packages/react/src/editor/a11y/BlockNoteView.visual.test.ts-snapshots/visual/default-schema-text-spacing-chromium-linux.png index c63121f961..e9948d75aa 100644 Binary files a/packages/react/src/editor/a11y/BlockNoteView.visual.test.ts-snapshots/visual/default-schema-text-spacing-chromium-linux.png and b/packages/react/src/editor/a11y/BlockNoteView.visual.test.ts-snapshots/visual/default-schema-text-spacing-chromium-linux.png differ diff --git a/packages/server-util/src/context/ServerBlockNoteEditor.test.ts b/packages/server-util/src/context/ServerBlockNoteEditor.test.ts index fd45b2fadd..9dae6f1612 100644 --- a/packages/server-util/src/context/ServerBlockNoteEditor.test.ts +++ b/packages/server-util/src/context/ServerBlockNoteEditor.test.ts @@ -113,7 +113,10 @@ describe("Test ServerBlockNoteEditor", () => { it("converts to HTML (blocksToFullHTML)", async () => { const html = await editor.blocksToFullHTML(blocks); - expect(html).toMatchSnapshot(); + // Export scopes are random; keep the list roles and item index in scope. + expect( + html.replaceAll(/bn-html-list-[0-9a-f-]{36}-/g, "bn-html-list-export-"), + ).toMatchSnapshot(); }); it("converts to and from markdown (blocksToMarkdownLossy)", async () => { diff --git a/packages/server-util/src/context/__snapshots__/ServerBlockNoteEditor.test.ts.snap b/packages/server-util/src/context/__snapshots__/ServerBlockNoteEditor.test.ts.snap index b14833b3f9..b77fcf39ea 100644 --- a/packages/server-util/src/context/__snapshots__/ServerBlockNoteEditor.test.ts.snap +++ b/packages/server-util/src/context/__snapshots__/ServerBlockNoteEditor.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Test ServerBlockNoteEditor > converts to HTML (blocksToFullHTML) 1`] = `"Heading 2Paragraphlist itemCaptionExampleCaption"`; +exports[`Test ServerBlockNoteEditor > converts to HTML (blocksToFullHTML) 1`] = `"Heading 2Paragraphlist itemCaptionExampleCaption"`; exports[`Test ServerBlockNoteEditor > converts to and from HTML (blocksToHTMLLossy) 1`] = `"Heading 2Paragraphlist itemCaptionExampleCaption"`; diff --git a/tests/a11y/documents.ts b/tests/a11y/documents.ts index 186e5b0284..8a490db83a 100644 --- a/tests/a11y/documents.ts +++ b/tests/a11y/documents.ts @@ -100,7 +100,57 @@ export const blockExamples = { export const defaultSchemaDocument: PartialBlock[] = [ { id: "start", type: "paragraph", content: "Start of document" }, - ...Object.entries(blockExamples).map(([id, block]) => ({ ...block, id })), + ...Object.entries(blockExamples).flatMap(([id, block]): PartialBlock[] => { + const first = { ...block, id }; + // Exercise adjacent and nested items during caret navigation, text scaling, + // and screen-reader testing. Include transitions between list block types. + switch (block.type) { + case "bulletListItem": + return [ + { + ...first, + children: [ + { + type: "paragraph", + content: "Explanation inside the list item", + }, + { type: "bulletListItem", content: "First nested bullet" }, + { type: "bulletListItem", content: "Second nested bullet" }, + ], + }, + { + id: `${id}-second`, + type: block.type, + content: "Second bullet list item", + }, + ]; + case "numberedListItem": + return [ + first, + { + id: `${id}-second`, + type: block.type, + content: "Second numbered list item", + }, + ]; + case "checkListItem": + return [ + first, + { id: `${id}-second`, type: block.type, content: "Second task" }, + ]; + case "toggleListItem": + return [ + first, + { + id: `${id}-second`, + type: block.type, + content: "Second toggle list item", + }, + ]; + default: + return [first]; + } + }), ...[2, 3, 4, 5, 6].map((level) => ({ id: `heading-${level}`, type: "heading" as const, diff --git a/tests/src/end-to-end/listAccessibility/listAccessibility.test.tsx b/tests/src/end-to-end/listAccessibility/listAccessibility.test.tsx new file mode 100644 index 0000000000..f0598c40d0 --- /dev/null +++ b/tests/src/end-to-end/listAccessibility/listAccessibility.test.tsx @@ -0,0 +1,374 @@ +import { + BlockNoteEditor, + BlockNoteSchema, + createBlockSpec, +} from "@blocknote/core"; +import { withMultiColumn } from "@blocknote/xl-multi-column"; +import { afterEach, describe, expect, test } from "vite-plus/test"; +import { userEvent } from "../../utils/context.js"; +import { expectElement } from "../../utils/editor.js"; + +// These tests need real NodeViews, frame slots, and HTML parsing. Automated +// accessibility-tree assertions do not replace manual screen-reader testing. +const editors: { _tiptapEditor: { destroy(): void } }[] = []; +const roots: HTMLElement[] = []; + +afterEach(() => { + for (const editor of editors.splice(0)) { + editor._tiptapEditor.destroy(); + } + for (const root of roots.splice(0)) { + root.remove(); + } +}); + +function host() { + const root = document.createElement("div"); + document.body.append(root); + roots.push(root); + return root; +} + +function mount() { + const editor = BlockNoteEditor.create({ + initialContent: [ + { id: "a", type: "bulletListItem", content: "Alpha" }, + { + id: "b", + type: "bulletListItem", + content: "Beta", + children: [ + { id: "p", type: "paragraph", content: "Nested paragraph" }, + { id: "c", type: "checkListItem", content: "Charlie" }, + { id: "d", type: "checkListItem", content: "Delta" }, + ], + }, + { id: "n1", type: "numberedListItem", content: "One" }, + { + id: "n2", + type: "numberedListItem", + props: { start: 7 }, + content: "Seven", + }, + { id: "t", type: "toggleListItem", content: "Toggle" }, + { id: "end", type: "paragraph", content: "After" }, + ], + }); + editors.push(editor); + const root = host(); + editor.mount(root); + return { editor, root }; +} + +function item(root: HTMLElement, id: string) { + const node = [...root.querySelectorAll(".bn-block")].find( + (element) => element.dataset.id === id, + ); + if (!node) { + throw new Error(`Missing block ${id}`); + } + return node; +} + +function owner(root: HTMLElement, id: string) { + return item(root, id).closest(".bn-block-outer")!; +} + +function assertReferences(root: HTMLElement) { + const ids = [...root.querySelectorAll("[role=listitem]")].map( + (element) => element.id, + ); + expect(ids.every(Boolean)).toBe(true); + expect(new Set(ids).size).toBe(ids.length); + const owned = new Set(); + for (const list of root.querySelectorAll("[role=list]")) { + for (const id of (list.getAttribute("aria-owns") || "") + .split(" ") + .filter(Boolean)) { + expect(owned.has(id)).toBe(false); + owned.add(id); + const target = document.getElementById(id); + expect(target?.getAttribute("role")).toBe("listitem"); + expect(root.contains(target)).toBe(true); + expect(target?.contains(list)).toBe(false); + } + } +} + +describe("list accessibility through real core views", () => { + test("browser accessibility tree exposes each owned item once and in order", async () => { + const editor = BlockNoteEditor.create({ + // Isolate list ownership from the editor's default root-role policy. + domAttributes: { editor: { role: "document" } }, + initialContent: [ + { type: "bulletListItem", content: "Alpha" }, + { type: "bulletListItem", content: "Beta" }, + ], + }); + editors.push(editor); + const root = host(); + editor.mount(root); + for (const editable of [true, false]) { + editor.isEditable = editable; + await expectElement(root.querySelector('[role="list"]')) + .toMatchAriaInlineSnapshot(` + - list: + - listitem: + - paragraph: Alpha + - listitem: + - paragraph: Beta + `); + } + // Snapshot the entire reading surface, not just the owner: a stale visible + // sibling would otherwise escape a subtree-only assertion. + await expectElement(editor.prosemirrorView.dom).toMatchAriaInlineSnapshot(` + - document: + - list: + - listitem: + - paragraph: Alpha + - listitem: + - paragraph: Beta + `); + const exported = host(); + exported.innerHTML = editor.blocksToFullHTML(editor.document); + await expectElement(exported).toMatchAriaInlineSnapshot(` + - list: + - listitem: + - paragraph: Alpha + - listitem: + - paragraph: Beta + `); + }); + + test("groups exact sibling types, including singleton and nested whole items", async () => { + const { editor, root } = mount(); + for (const editable of [true, false, true]) { + editor.isEditable = editable; + expect(root.querySelectorAll("[role=list]")).toHaveLength(4); + expect(root.querySelectorAll("[role=listitem]")).toHaveLength(7); + expect(owner(root, "a").getAttribute("aria-owns")).toBe( + item(root, "b").id, + ); + expect(owner(root, "c").getAttribute("aria-owns")).toBe( + item(root, "d").id, + ); + expect(owner(root, "n1").getAttribute("aria-owns")).toBe( + item(root, "n2").id, + ); + expect(owner(root, "t").hasAttribute("aria-owns")).toBe(false); + expect(item(root, "b").contains(item(root, "p"))).toBe(true); + expect(item(root, "b").contains(owner(root, "c"))).toBe(true); + expect(item(root, "p").hasAttribute("role")).toBe(false); + assertReferences(root); + } + await expectElement(owner(root, "a")).toMatchAriaInlineSnapshot(` + - list: + - listitem: + - paragraph: Alpha + - listitem: + - paragraph: Beta + - paragraph: Nested paragraph + - list: + - listitem: + - checkbox + - paragraph: Charlie + - listitem: + - checkbox + - paragraph: Delta + `); + // Existing indexing counts through a later explicit start; accessibility + // must neither change that behavior nor split the accessible run. + expect(item(root, "n2").querySelector('[data-index="2"]')).not.toBeNull(); + expect(item(root, "n2").querySelector('[data-start="7"]')).not.toBeNull(); + }); + + test("transfers ownership after prepend, split, removal, undo and redo", () => { + const { editor, root } = mount(); + editor.insertBlocks( + [{ id: "first", type: "bulletListItem", content: "First" }], + "a", + "before", + ); + expect(owner(root, "first").getAttribute("role")).toBe("list"); + expect(owner(root, "a").hasAttribute("role")).toBe(false); + expect(owner(root, "first").getAttribute("aria-owns")).toBe( + `${item(root, "a").id} ${item(root, "b").id}`, + ); + editor.updateBlock("a", { type: "paragraph" }); + expect(owner(root, "first").hasAttribute("aria-owns")).toBe(false); + expect(owner(root, "b").getAttribute("role")).toBe("list"); + editor.removeBlocks(["first"]); + assertReferences(root); + editor.undo(); + assertReferences(root); + editor.redo(); + assertReferences(root); + }); + + test("keyboard split and merge retain the caret inside the accessible run", async () => { + const { editor, root } = mount(); + editor.setTextCursorPosition("a", "end"); + editor.focus(); + await userEvent.keyboard("{Enter}"); + const inserted = editor.getTextCursorPosition().block; + expect(inserted.type).toBe("bulletListItem"); + expect(root.querySelectorAll("[role=listitem]")).toHaveLength(8); + expect(owner(root, "a").getAttribute("aria-owns")).toBe( + `${item(root, inserted.id).id} ${item(root, "b").id}`, + ); + await userEvent.keyboard("New"); + editor.setTextCursorPosition(inserted, "start"); + await userEvent.keyboard("{Backspace}"); + expect(editor.getTextCursorPosition().block.id).toBe(inserted.id); + expect(editor.getTextCursorPosition().block.type).toBe("paragraph"); + expect(owner(root, "a").hasAttribute("aria-owns")).toBe(false); + expect(owner(root, "b").getAttribute("role")).toBe("list"); + assertReferences(root); + await userEvent.keyboard("{Backspace}"); + expect(editor.getTextCursorPosition().block.id).toBe("a"); + expect(root.querySelectorAll("[role=listitem]")).toHaveLength(7); + expect(owner(root, "a").getAttribute("aria-owns")).toBe(item(root, "b").id); + assertReferences(root); + }); + + test("uses distinct namespaces for simultaneously mounted identical documents", () => { + const first = mount(); + const second = mount(); + const ids = [ + ...document.querySelectorAll("[role=listitem]"), + ].map((node) => node.id); + expect(ids).toHaveLength(14); + expect(new Set(ids).size).toBe(ids.length); + assertReferences(first.root); + assertReferences(second.root); + }); + + test("preserves consumer block attributes outside list participation", () => { + const editor = BlockNoteEditor.create({ + domAttributes: { + block: { + role: "group", + id: "consumer-block", + "aria-label": "Consumer label", + }, + }, + initialContent: [{ id: "p", type: "paragraph", content: "Paragraph" }], + }); + editors.push(editor); + const root = host(); + editor.mount(root); + function expectConsumerAttributes() { + expect(item(root, "p").getAttribute("role")).toBe("group"); + expect(item(root, "p").id).toBe("consumer-block"); + expect(item(root, "p").getAttribute("aria-label")).toBe("Consumer label"); + } + expectConsumerAttributes(); + editor.updateBlock("p", { type: "bulletListItem" }); + expect(item(root, "p").getAttribute("role")).toBe("listitem"); + expect(item(root, "p").id).not.toBe("consumer-block"); + editor.updateBlock("p", { type: "paragraph" }); + expectConsumerAttributes(); + }); + + test("typing, marks and selection preserve item nodes and ownership attributes", () => { + const { editor, root } = mount(); + const before = item(root, "b"); + expect(before.getAttribute("role")).toBe("listitem"); + const beforeID = before.id; + const beforeOwns = owner(root, "a").getAttribute("aria-owns"); + const observer = new MutationObserver(() => {}); + observer.observe(root, { + subtree: true, + attributes: true, + attributeFilter: ["role", "id", "aria-owns"], + }); + try { + editor.setTextCursorPosition("b", "end"); + editor.insertInlineContent(" typed"); + editor.addStyles({ bold: true }); + editor.insertInlineContent(" bold"); + expect(item(root, "b")).toBe(before); + expect(before.id).toBe(beforeID); + expect(owner(root, "a").getAttribute("aria-owns")).toBe(beforeOwns); + expect(observer.takeRecords()).toHaveLength(0); + expect(editor.getTextCursorPosition().block.id).toBe("b"); + } finally { + observer.disconnect(); + } + }); + + test("custom-only schema gets list semantics without numbered lists", () => { + const custom = createBlockSpec( + { type: "custom", propSchema: {}, content: "inline" }, + { + meta: { listItem: true }, + render() { + const dom = document.createElement("p"); + return { dom, contentDOM: dom }; + }, + }, + )(); + const editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ blockSpecs: { custom } }), + initialContent: [ + { id: "custom-a", type: "custom", content: "Custom A" }, + { id: "custom-b", type: "custom", content: "Custom B" }, + ], + }); + editors.push(editor); + const root = host(); + editor.mount(root); + expect(root.querySelectorAll("[role=list]")).toHaveLength(1); + expect(root.querySelectorAll("[role=listitem]")).toHaveLength(2); + assertReferences(root); + }); + + test("layout children form independent runs in live and static HTML", () => { + const editor = BlockNoteEditor.create({ + schema: withMultiColumn(BlockNoteSchema.create()), + initialContent: [ + { + type: "columnList", + children: [ + { + type: "column", + children: [ + { type: "bulletListItem", content: "Left A" }, + { type: "bulletListItem", content: "Left B" }, + ], + }, + { + type: "column", + children: [{ type: "bulletListItem", content: "Right A" }], + }, + ], + }, + ], + }); + editors.push(editor); + const root = host(); + editor.mount(root); + expect(root.querySelectorAll("[role=list]")).toHaveLength(2); + const html = editor.blocksToFullHTML(editor.document); + const exported = host(); + exported.innerHTML = html; + expect(exported.querySelectorAll("[role=list]")).toHaveLength(2); + assertReferences(root); + assertReferences(exported); + expect(editor.tryParseHTMLToBlocks(html)).toEqual(editor.document); + }); + + test("static HTML survives real parsing without changing document or external export", () => { + const { editor, root } = mount(); + const before = editor.document; + const external = editor.blocksToHTMLLossy(before); + const exported = host(); + exported.innerHTML = editor.blocksToFullHTML(before); + expect(exported.querySelectorAll("[role=list]")).toHaveLength(4); + expect(exported.querySelectorAll("[role=listitem]")).toHaveLength(7); + assertReferences(exported); + assertReferences(root); + expect(editor.tryParseHTMLToBlocks(exported.innerHTML)).toEqual(before); + expect(editor.blocksToHTMLLossy(editor.document)).toBe(external); + }); +}); diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/document.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/document.html index b87505e81f..9e4fb424f5 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/document.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/document.html @@ -40,15 +40,33 @@ Section 1 - - + + First point - + Second point diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/misc.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/misc.html index b9dfbb8404..0536c466e0 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/misc.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/misc.html @@ -41,8 +41,14 @@ - - + + diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html index 521c42d103..58effdff75 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html @@ -1,20 +1,50 @@ - - + + Bullet List Item 1 - + Bullet List Item 2 - - + + - + - - + + @@ -46,7 +94,13 @@ - + - - + + - - + + Bullet List Item 1 - + Bullet List Item 2 - - + + - + Numbered List Item 2 - - + + @@ -44,7 +92,13 @@ - + Check List Item 2 - - + + - - + + - + - - + + ` whitespace so code-block snapshots show actual newlines // instead of having them collapsed by the prettifier. const PRETTIFY_OPTIONS = { tag_wrap: true, ignore: ["code"] }; +const SERIALIZED_LIST_ID = + /^bn-html-list-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}-\d+$/; + +// Serialization scopes intentionally differ between calls. Normalize only the +// generated ID/IDREF tokens for snapshots, retaining target identity and order. +// Duplicate targets and dangling references must fail rather than be hidden by +// normalization. Document IDs and all non-accessibility markup stay untouched. +function normalizeSerializedListIDs(html: string) { + const container = document.createElement("div"); + container.innerHTML = html; + const ids = new Map(); + for (const element of container.querySelectorAll("[id]")) { + const id = element.id; + if (!SERIALIZED_LIST_ID.test(id)) { + continue; + } + expect(ids.has(id), `Duplicate serialized list target: ${id}`).toBe(false); + const normalized = `bn-html-list-item-${ids.size + 1}`; + ids.set(id, normalized); + } + // Rewrite attributes in tags only, not text/code. Do not reserialize the + // parsed DOM: that would introduce unrelated parser repairs such as tbody. + return html.replace(/<(?:[^>"']|"[^"]*"|'[^']*')*>/g, (tag) => + tag.replace( + /(\s(?:id|aria-owns)=")([^"]*)(")/g, + (_attribute, prefix: string, value: string, suffix: string) => + prefix + + value.replace(/\S+/g, (id) => { + if (!SERIALIZED_LIST_ID.test(id)) { + return id; + } + expect(ids.has(id), `Unresolved serialized list target: ${id}`).toBe( + true, + ); + return ids.get(id)!; + }) + + suffix, + ), + ); +} export const testExportBlockNoteHTML = async < B extends BlockSchema, @@ -28,7 +68,10 @@ export const testExportBlockNoteHTML = async < addIdsToBlocks(testCase.content); await expect( - prettify(editor.blocksToFullHTML(testCase.content), PRETTIFY_OPTIONS), + prettify( + normalizeSerializedListIDs(editor.blocksToFullHTML(testCase.content)), + PRETTIFY_OPTIONS, + ), ).toMatchFileSnapshot(`./__snapshots__/blocknoteHTML/${testCase.name}.html`); }; diff --git a/tests/src/utils/editor.ts b/tests/src/utils/editor.ts index 278e1119cf..db64cf583f 100644 --- a/tests/src/utils/editor.ts +++ b/tests/src/utils/editor.ts @@ -96,6 +96,7 @@ export async function compareDocToSnapshot(name: string) { // the bare `vitest` module, but its own `expect` is typed from an internal // module, so the augmentation doesn't attach. Type the accessor locally. type ElementMatchers = { + toMatchAriaInlineSnapshot(snapshot: string): Promise; toMatchScreenshot( name?: string, options?: {
Paragraph
list item
Example
Caption
First point
Second point
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html index 521c42d103..58effdff75 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html @@ -1,20 +1,50 @@
Bullet List Item 1
Bullet List Item 2