diff --git a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts index 05338f5f97..ecb573212a 100644 --- a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts +++ b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts @@ -8,40 +8,10 @@ import { InlineContentSchema, StyleSchema, } from "../../../schema/index.js"; +import { addListAccessibilityAttributes } from "./util/addListAccessibilityAttributes.js"; +import { addListItemIndexAttributes } from "./util/addListItemIndexAttributes.js"; import { serializeBlocksInternalHTML } from "./util/serializeBlocksInternalHTML.js"; -// This is normally handled using decorations in the -// `NumberedListIndexingDecorationPlugin`. This does not run when exporting, so -// we have to add the necessary HTML attributes ourselves. -const addIndexToNumberedListItems = (element: HTMLElement) => { - const numberedListItems = element.querySelectorAll( - '[data-content-type="numberedListItem"]', - ); - numberedListItems.forEach((numberedListItem) => { - const prevNumberedListItem = numberedListItem - .closest(".bn-block-outer") - ?.previousElementSibling?.querySelector( - '[data-content-type="numberedListItem"]', - ); - - if (!prevNumberedListItem) { - numberedListItem.setAttribute( - "data-index", - numberedListItem.getAttribute("data-start") || "1", - ); - } else { - const prevNumberedListItemIndex = - prevNumberedListItem.getAttribute("data-index"); - numberedListItem.setAttribute( - "data-index", - (parseInt(prevNumberedListItemIndex || "0") + 1).toString(), - ); - } - }); - - return element; -}; - // Makes the checkboxes in check list items read-only, as the HTML should be // static and therefore read-only when rendered. const makeCheckListItemsReadOnly = (element: HTMLElement) => { @@ -152,7 +122,14 @@ export const createInternalHTMLSerializer = < // functionality so that the rendered HTML looks identical to that of a live // editor. const transforms: ((element: HTMLElement) => HTMLElement)[] = [ - addIndexToNumberedListItems, + addListItemIndexAttributes, + // Static exports need list semantics even when the source editor is editable. + (element) => + addListAccessibilityAttributes( + element, + (type) => + !!editor.schema.blockSpecs[type]?.implementation.meta.listItem, + ), makeCheckListItemsReadOnly, forceToggleBlocksShow, addTableMinCellWidths, diff --git a/packages/core/src/api/exporters/html/util/addListAccessibilityAttributes.ts b/packages/core/src/api/exporters/html/util/addListAccessibilityAttributes.ts new file mode 100644 index 0000000000..aeab3f45d4 --- /dev/null +++ b/packages/core/src/api/exporters/html/util/addListAccessibilityAttributes.ts @@ -0,0 +1,50 @@ +import { createListOwner } from "../../../../extensions/ListItem/createListOwner.js"; + +function getBlockContent(block: Element | null) { + if (!block?.matches('[data-node-type="blockOuter"]')) { + return undefined; + } + const content = block.querySelector(".bn-block-content"); + // A nested list item must not be mistaken for its parent's content. + return content?.closest('[data-node-type="blockOuter"]') === block + ? content + : undefined; +} + +/** Mirrors ListItemExtension for static full HTML, where plugins do not run. */ +export function addListAccessibilityAttributes( + element: HTMLElement, + isListItem: (type: string) => boolean, +) { + const blocks = element.querySelectorAll('[data-node-type="blockOuter"]'); + for (const block of blocks) { + const content = getBlockContent(block); + const type = content?.getAttribute("data-content-type"); + if (!type || !isListItem(type)) { + continue; + } + const previousContent = getBlockContent(block.previousElementSibling); + if (previousContent?.getAttribute("data-content-type") === type) { + // This item was already handled with the preceding same-type siblings. + continue; + } + + const ids: string[] = []; + let item: Element | null = block; + while (item) { + const itemContent = getBlockContent(item); + if (itemContent?.getAttribute("data-content-type") !== type) { + break; + } + const id = encodeURIComponent(item.getAttribute("data-id")!); + item.setAttribute("id", id); + item.setAttribute("role", "listitem"); + ids.push(id); + item = item.nextElementSibling; + } + if (ids.length > 0) { + block.before(createListOwner(element.ownerDocument, ids)); + } + } + return element; +} diff --git a/packages/core/src/api/exporters/html/util/addListItemIndexAttributes.ts b/packages/core/src/api/exporters/html/util/addListItemIndexAttributes.ts new file mode 100644 index 0000000000..4b7577421c --- /dev/null +++ b/packages/core/src/api/exporters/html/util/addListItemIndexAttributes.ts @@ -0,0 +1,30 @@ +// Mirrors getListItemIndexDecorations when exporting internal HTML, where +// editor plugins do not run. +export function addListItemIndexAttributes(element: HTMLElement) { + const numberedListItems = element.querySelectorAll( + '[data-content-type="numberedListItem"]', + ); + numberedListItems.forEach((numberedListItem) => { + const prevNumberedListItem = numberedListItem + .closest(".bn-block-outer") + ?.previousElementSibling?.querySelector( + '[data-content-type="numberedListItem"]', + ); + + if (!prevNumberedListItem) { + numberedListItem.setAttribute( + "data-index", + numberedListItem.getAttribute("data-start") || "1", + ); + } else { + const prevNumberedListItemIndex = + prevNumberedListItem.getAttribute("data-index"); + numberedListItem.setAttribute( + "data-index", + (parseInt(prevNumberedListItemIndex || "0") + 1).toString(), + ); + } + }); + + return element; +} diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 3a5ed1e357..545dbcb71f 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -1,5 +1,6 @@ import { DOMSerializer, Fragment, Node } from "prosemirror-model"; +import { UniqueID } from "../../../../extensions/tiptap-extensions/UniqueID/UniqueID.js"; import { PartialBlock } from "../../../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; import { @@ -156,7 +157,12 @@ function serializeBlock< const children = block.children || []; const impl = editor.blockImplementations[block.type as any].implementation; - const fullBlock = { ...block, props, children } as any; + const fullBlock = { + ...block, + id: block.id ?? UniqueID.options.generateID(), + props, + children, + } as any; if (isLayoutNode(editor.pmSchema.nodes[block.type as any])) { // A layout block is its frame. `renderFrameDOM` stamps the frame's root with @@ -204,7 +210,7 @@ function serializeBlock< // wrap the block in a blockContainer const bc = BC_NODE.spec?.toDOM?.( BC_NODE.create({ - id: block.id, + id: fullBlock.id, ...props, }), ) as { diff --git a/packages/core/src/blocks/Heading/a11y/block.a11y.test.ts b/packages/core/src/blocks/Heading/a11y/block.a11y.test.ts index 00c2bd840a..d95e4e2322 100644 --- a/packages/core/src/blocks/Heading/a11y/block.a11y.test.ts +++ b/packages/core/src/blocks/Heading/a11y/block.a11y.test.ts @@ -42,7 +42,10 @@ test("read-only toggle heading: Tab reaches the toggle and Enter expands/collaps const content = page.getByText("Nested read-only content", { exact: true }); await expect.element(content).not.toBeVisible(); await expect.element(toggle).toHaveAttribute("aria-expanded", "false"); - await checkAriaSnapshot(page.getByRole("textbox"), "collapsed"); + await checkAriaSnapshot( + page.getByRole("main").getByRole("document"), + "collapsed", + ); // Start at Before editor and reach the toggle through the page's tab order. // Do not focus the button directly: that would hide a keyboard trap. @@ -57,12 +60,18 @@ test("read-only toggle heading: Tab reaches the toggle and Enter expands/collaps await pressKey("Enter"); await expect.element(content).toBeVisible(); await expect.element(toggle).toHaveAttribute("aria-expanded", "true"); - await checkAriaSnapshot(page.getByRole("textbox"), "expanded"); + await checkAriaSnapshot( + page.getByRole("main").getByRole("document"), + "expanded", + ); await expect.element(toggle).toHaveFocus(); await pressKey("Enter"); await expect.element(content).not.toBeVisible(); await expect.element(toggle).toHaveAttribute("aria-expanded", "false"); - await checkAriaSnapshot(page.getByRole("textbox"), "collapsed again"); + await checkAriaSnapshot( + page.getByRole("main").getByRole("document"), + "collapsed again", + ); await expect.element(toggle).toHaveFocus(); await pressKey("Tab"); await expect diff --git a/packages/core/src/blocks/Heading/a11y/block.a11y.test.ts-snapshots/aria/browser.snap b/packages/core/src/blocks/Heading/a11y/block.a11y.test.ts-snapshots/aria/browser.snap index bb05a9c68c..abf6495d37 100644 --- a/packages/core/src/blocks/Heading/a11y/block.a11y.test.ts-snapshots/aria/browser.snap +++ b/packages/core/src/blocks/Heading/a11y/block.a11y.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/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/block.ts b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts index 57530d741b..dc1a1f0f7a 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts @@ -7,7 +7,6 @@ import { parseDefaultProps, } from "../../defaultProps.js"; import { getListItemContent } from "../getListItemContent.js"; -import { NumberedListIndexingDecorationPlugin } from "./IndexingPlugin.js"; export type NumberedListItemBlockConfig = ReturnType< typeof createNumberedListItemBlockConfig @@ -35,6 +34,7 @@ export const createNumberedListItemBlockSpec = createBlockSpec( emptyEnterResets: true, }, meta: { + listItem: true, isolating: false, }, parse(element) { @@ -137,7 +137,6 @@ export const createNumberedListItemBlockSpec = createBlockSpec( return true; }, }, - prosemirrorPlugins: [NumberedListIndexingDecorationPlugin()], }), ], ); 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/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/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index e7f95358d2..4795071207 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -17,6 +17,7 @@ import { HistoryExtension, InlineContentBoundaryEditExtension, LinkToolbarExtension, + ListItemExtension, NodeSelectionKeyboardExtension, PlaceholderExtension, PositionMappingExtension, @@ -174,6 +175,7 @@ export function getDefaultExtensions( : {}), }), LinkToolbarExtension(options), + ListItemExtension(), NodeSelectionKeyboardExtension(), PlaceholderExtension(options), ReadOnlyExtension({ editable: options._tiptapOptions?.editable }), diff --git a/packages/core/src/extensions/ListItem/ListItem.browser.test.ts b/packages/core/src/extensions/ListItem/ListItem.browser.test.ts new file mode 100644 index 0000000000..a1653c3f04 --- /dev/null +++ b/packages/core/src/extensions/ListItem/ListItem.browser.test.ts @@ -0,0 +1,326 @@ +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 items", () => { + it("updates list owners when items are indented and unindented", () => { + const { editor, host } = createEditor(true); + editor.replaceBlocks(editor.document, [ + { id: "a", type: "numberedListItem", content: "One" }, + { id: "b", type: "numberedListItem", content: "Two" }, + { id: "c", type: "numberedListItem", content: "Three" }, + ]); + editor.setTextCursorPosition("b", "start"); + editor.nestBlock(); + expect(lists(host)).toEqual([["a", "c"], ["b"]]); + expect( + Array.from(host.querySelectorAll("[data-index]"), (item) => + item.getAttribute("data-index"), + ), + ).toEqual(["1", "1", "2"]); + editor.unnestBlock(); + expect(lists(host)).toEqual([["a", "b", "c"]]); + expect( + Array.from(host.querySelectorAll("[data-index]"), (item) => + item.getAttribute("data-index"), + ), + ).toEqual(["1", "2", "3"]); + }); + + it("removes an owner when the last list at the end of a group is deleted", () => { + const { editor, host } = createEditor(true); + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph" }, + { id: "a", type: "bulletListItem" }, + { id: "b", type: "bulletListItem" }, + ]); + editor.removeBlocks(["a", "b"]); + expect(lists(host)).toEqual([]); + expect(host.querySelector('[role="listitem"]')).toBeNull(); + }); + + 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("exposes list ownership in both editing modes, including after edits", () => { + const { editor, host } = createEditor(true); + editor.replaceBlocks(editor.document, [ + { id: "a", type: "bulletListItem" }, + { id: "b", type: "bulletListItem" }, + ]); + expect(lists(host)).toEqual([["a", "b"]]); + editor.isEditable = false; + expect(lists(host)).toEqual([["a", "b"]]); + editor.isEditable = true; + expect(lists(host)).toEqual([["a", "b"]]); + expect(host.querySelector('[data-id="a"]')!.id).toBe("a"); + editor.updateBlock("b", { type: "numberedListItem" }); + expect(lists(host)).toEqual([["a"], ["b"]]); + const readOnly = editor.getExtension(ReadOnlyExtension)!; + readOnly.setReadOnly(true, "preview"); + expect(lists(host)).toEqual([["a"], ["b"]]); + readOnly.setReadOnly(false, "preview"); + expect(lists(host)).toEqual([["a"], ["b"]]); + }); + + it("keeps numbering in both modes while list boundaries and starts change", () => { + const { editor, host } = createEditor(true); + function indices() { + return Array.from(host.querySelectorAll("[data-index]"), (item) => + Number(item.getAttribute("data-index")), + ); + } + editor.replaceBlocks(editor.document, [ + { + id: "a", + type: "numberedListItem", + props: { start: 4 }, + children: [ + { type: "numberedListItem", props: { start: 8 } }, + { type: "numberedListItem" }, + ], + }, + { id: "b", type: "numberedListItem", props: { start: 20 } }, + { id: "c", type: "numberedListItem" }, + ]); + // A start on a later item only takes effect when it begins a new run. + expect(indices()).toEqual([4, 8, 9, 5, 6]); + editor.isEditable = false; + expect(indices()).toEqual([4, 8, 9, 5, 6]); + expect(lists(host)[0]).toEqual(["a", "b", "c"]); + editor.updateBlock("a", { type: "paragraph" }); + expect(indices()).toEqual([8, 9, 20, 21]); + expect(lists(host)).toHaveLength(2); + editor.isEditable = true; + expect(indices()).toEqual([8, 9, 20, 21]); + expect(lists(host)[1]).toEqual(["b", "c"]); + editor.updateBlock("b", { props: { start: 3 } }); + expect(indices()).toEqual([8, 9, 3, 4]); + editor.isEditable = false; + expect(indices()).toEqual([8, 9, 3, 4]); + }); + + 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('[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(true); + 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("uses block IDs for ownership and escapes whitespace", () => { + const { editor, host } = createEditor(); + editor.replaceBlocks(editor.document, [ + { id: "same id", type: "bulletListItem" }, + ]); + expect(lists(host)).toEqual([["same id"]]); + expect(editor.document[0].id).toBe("same id"); + expect(host.querySelector('[role="listitem"]')!.id).toBe("same%20id"); + }); + + it("updates the owner before an edited list when items are inserted, deleted, or renamed", () => { + const { editor, host } = createEditor(true); + editor.replaceBlocks(editor.document, [ + { id: "a", type: "numberedListItem", props: { start: 4 } }, + { id: "c", type: "numberedListItem" }, + ]); + editor.insertBlocks([{ id: "b", type: "numberedListItem" }], "c", "before"); + expect(lists(host)).toEqual([["a", "b", "c"]]); + editor.removeBlocks(["b"]); + expect(lists(host)).toEqual([["a", "c"]]); + + // An ID-only change has an empty step map, but still changes aria-owns. + editor.transact((tr) => { + tr.doc.descendants((node, pos) => { + if (node.attrs.id === "c") { + tr.setNodeAttribute(pos, "id", "renamed item"); + } + }); + }); + expect(lists(host)).toEqual([["a", "renamed item"]]); + editor.removeBlocks(["a"]); + expect(lists(host)).toEqual([["renamed item"]]); + expect(host.querySelector("[data-index]")!.getAttribute("data-index")).toBe( + "1", + ); + }); + + it("reuses list owners while typing and moving their positions", () => { + const { editor, host } = createEditor(true); + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { id: "a", type: "bulletListItem", content: "Apples" }, + { id: "b", type: "bulletListItem", content: "Oranges" }, + ]); + const owner = host.querySelector("[aria-owns]"); + editor.setTextCursorPosition("b", "end"); + editor.insertInlineContent("!"); + expect(lists(host)).toEqual([["a", "b"]]); + expect(host.querySelector("[aria-owns]")).toBe(owner); + editor.setTextCursorPosition("before", "end"); + editor.insertInlineContent(" more text"); + expect(lists(host)).toEqual([["a", "b"]]); + expect(host.querySelector("[aria-owns]")).toBe(owner); + }); + + it("updates separate nested lists in one transaction", () => { + const { editor, host } = createEditor(true); + editor.replaceBlocks(editor.document, [ + { + id: "a", + type: "numberedListItem", + children: [ + { id: "a1", type: "numberedListItem" }, + { id: "a2", type: "numberedListItem" }, + ], + }, + { + id: "b", + type: "numberedListItem", + children: [ + { id: "b1", type: "numberedListItem" }, + { id: "b2", type: "numberedListItem" }, + ], + }, + ]); + editor.transact(() => { + editor.updateBlock("a1", { type: "paragraph" }); + editor.updateBlock("b1", { props: { start: 7 } }); + }); + expect(lists(host)).toEqual([["a", "b"], ["a2"], ["b1", "b2"]]); + expect( + Array.from(host.querySelectorAll("[data-index]"), (item) => + Number(item.getAttribute("data-index")), + ), + ).toEqual([1, 1, 2, 7, 8]); + }); +}); diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.test.ts b/packages/core/src/extensions/ListItem/ListItem.test.ts similarity index 93% rename from packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.test.ts rename to packages/core/src/extensions/ListItem/ListItem.test.ts index f3d24db68b..5709c88a8f 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.test.ts +++ b/packages/core/src/extensions/ListItem/ListItem.test.ts @@ -1,13 +1,13 @@ import { Selection } from "prosemirror-state"; import { afterEach, describe, expect, it } from "vite-plus/test"; -import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; /** * @vitest-environment jsdom */ -const PLUGIN_KEY = "numbered-list-indexing-decorations$"; +import { listItemPluginKey } from "./ListItem.js"; // Track editors created in each test so we can unmount them in afterEach — // otherwise prosemirror-view's DOMObserver leaves a setTimeout alive that @@ -30,18 +30,18 @@ function createEditor() { function getDecorationSet(editor: BlockNoteEditor) { const view = editor._tiptapEditor.view; - const plugin = view.state.plugins.find((p) => (p as any).key === PLUGIN_KEY); - if (!plugin) { - throw new Error("IndexingPlugin not found"); - } - return plugin.getState(view.state)!.decorations; + return listItemPluginKey.getState(view.state)!.indexDecorations; } /** Returns all decoration specs in document order. */ function getDecoSpecs(editor: BlockNoteEditor) { const decoSet = getDecorationSet(editor); const doc = editor._tiptapEditor.view.state.doc; - const decos = decoSet.find(0, doc.nodeSize - 2); + const decos = decoSet.find( + 0, + doc.nodeSize - 2, + (spec) => spec.index !== undefined, + ); return decos.map((d: any) => d.spec); } @@ -49,7 +49,11 @@ function getDecoSpecs(editor: BlockNoteEditor) { function getDataIndices(editor: BlockNoteEditor) { const decoSet = getDecorationSet(editor); const doc = editor._tiptapEditor.view.state.doc; - const decos = decoSet.find(0, doc.nodeSize - 2); + const decos = decoSet.find( + 0, + doc.nodeSize - 2, + (spec) => spec.index !== undefined, + ); return decos.map((d: any) => { // Decoration attrs are stored on the decoration object const attrs = (d as any).type?.attrs ?? (d as any).attrs ?? (d as any).type; @@ -71,7 +75,7 @@ function setBlocks( ); } -describe("IndexingPlugin: basic numbering", () => { +describe("ListItemExtension: basic numbering", () => { it("assigns sequential indices to a contiguous numbered list", () => { const editor = createEditor(); setBlocks(editor, [ @@ -118,7 +122,7 @@ describe("IndexingPlugin: basic numbering", () => { }); }); -describe("IndexingPlugin: updates on structural changes", () => { +describe("ListItemExtension: updates on structural changes", () => { it("updates indices when a block is deleted from the middle", () => { const editor = createEditor(); setBlocks(editor, [ @@ -233,7 +237,7 @@ describe("IndexingPlugin: updates on structural changes", () => { }); }); -describe("IndexingPlugin: typing preserves indices (early exit)", () => { +describe("ListItemExtension: typing preserves indices", () => { it("indices unchanged after typing in the first block", () => { const editor = createEditor(); setBlocks(editor, [ @@ -295,7 +299,7 @@ describe("IndexingPlugin: typing preserves indices (early exit)", () => { }); }); -describe("IndexingPlugin: decoration specs", () => { +describe("ListItemExtension: decoration specs", () => { it("decorations have correct spec with index, isFirst, hasStart", () => { const editor = createEditor(); setBlocks(editor, [ @@ -328,7 +332,7 @@ describe("IndexingPlugin: decoration specs", () => { }); }); -describe("IndexingPlugin: selection-only transactions", () => { +describe("ListItemExtension: selection-only transactions", () => { it("does not recompute decorations on selection change", () => { const editor = createEditor(); setBlocks(editor, [ diff --git a/packages/core/src/extensions/ListItem/ListItem.ts b/packages/core/src/extensions/ListItem/ListItem.ts new file mode 100644 index 0000000000..e398d5460d --- /dev/null +++ b/packages/core/src/extensions/ListItem/ListItem.ts @@ -0,0 +1,48 @@ +import { Plugin, PluginKey, type Transaction } from "prosemirror-state"; +import { DecorationSet } from "prosemirror-view"; + +import { createExtension } from "../../editor/BlockNoteExtension.js"; +import { getListItemA11yDecorations } from "./getListItemA11yDecorations.js"; +import { getListItemIndexDecorations } from "./getListItemIndexDecorations.js"; + +type ListItemPluginState = { + indexDecorations: DecorationSet; + decorations: DecorationSet; +}; + +export const listItemPluginKey = new PluginKey("listItem"); + +/** Numbers list items and supplies list ownership in both editing modes. */ +export const ListItemExtension = createExtension(({ editor }) => { + function getDecorations(tr: Transaction, previousIndices: DecorationSet) { + const indexDecorations = getListItemIndexDecorations(tr, previousIndices); + const a11yDecorations = getListItemA11yDecorations( + tr.doc, + (type) => !!editor.schema.blockSpecs[type]?.implementation.meta.listItem, + ); + return { + indexDecorations, + decorations: a11yDecorations.add(tr.doc, indexDecorations.find()), + }; + } + + return { + key: "listItem", + prosemirrorPlugins: [ + new Plugin({ + key: listItemPluginKey, + state: { + init: (_, state) => getDecorations(state.tr, DecorationSet.empty), + apply: (tr, previous) => + tr.docChanged + ? getDecorations(tr, previous.indexDecorations) + : previous, + }, + props: { + decorations: (state) => + listItemPluginKey.getState(state)?.decorations, + }, + }), + ], + }; +}); diff --git a/packages/core/src/extensions/ListItem/createListOwner.ts b/packages/core/src/extensions/ListItem/createListOwner.ts new file mode 100644 index 0000000000..698d6d5448 --- /dev/null +++ b/packages/core/src/extensions/ListItem/createListOwner.ts @@ -0,0 +1,11 @@ +/** A visually hidden owner whose items remain in their original DOM. */ +export function createListOwner(doc: Document, ids: string[]) { + const owner = doc.createElement("div"); + owner.setAttribute("data-bn-list-owner", ""); + owner.setAttribute("role", "list"); + owner.setAttribute("aria-owns", ids.join(" ")); + // Keep it in the accessibility tree without affecting layout. + owner.style.cssText = + "position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0"; + return owner; +} diff --git a/packages/core/src/extensions/ListItem/getListItemA11yDecorations.ts b/packages/core/src/extensions/ListItem/getListItemA11yDecorations.ts new file mode 100644 index 0000000000..8f3e965600 --- /dev/null +++ b/packages/core/src/extensions/ListItem/getListItemA11yDecorations.ts @@ -0,0 +1,62 @@ +import type { Node } from "prosemirror-model"; +import { Decoration, DecorationSet } from "prosemirror-view"; + +import { createListOwner } from "./createListOwner.js"; + +export function getListItemA11yDecorations( + doc: Node, + isListItem: (type: string) => boolean, +): DecorationSet { + const decorations: Decoration[] = []; + + function visit(parent: Node, start: number) { + let list: { type: string; pos: number; ids: string[] } | undefined; + + function finishList() { + if (!list) { + return; + } + const { pos, ids } = list; + decorations.push( + Decoration.widget( + pos, + (view) => createListOwner(view.dom.ownerDocument, ids), + { side: -1, key: ids.join(" "), ignoreSelection: true }, + ), + ); + list = undefined; + } + + parent.forEach((node, offset) => { + const pos = start + offset; + const type = + node.type.name === "blockContainer" + ? node.firstChild!.type.name + : undefined; + if (type && isListItem(type)) { + if (list && list.type !== type) { + finishList(); + } + list ??= { type, pos, ids: [] }; + const id = + typeof node.attrs.id === "string" + ? encodeURIComponent(node.attrs.id) + : `bn-list-item-pos-${pos}`; + list.ids.push(id); + // Include the entire block so nested lists belong to their item. + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { id, role: "listitem" }), + ); + } else { + finishList(); + } + if (!node.isLeaf && !node.isTextblock) { + visit(node, pos + 1); + } + }); + finishList(); + } + + visit(doc, 0); + return DecorationSet.create(doc, decorations); +} diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts b/packages/core/src/extensions/ListItem/getListItemIndexDecorations.ts similarity index 83% rename from packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts rename to packages/core/src/extensions/ListItem/getListItemIndexDecorations.ts index 0222fff46a..a819dc4002 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts +++ b/packages/core/src/extensions/ListItem/getListItemIndexDecorations.ts @@ -1,9 +1,8 @@ import type { Node } from "@tiptap/pm/model"; import type { Transaction } from "@tiptap/pm/state"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { getBlockInfoFromNode } from "../../../api/getBlockInfoFromPos.js"; +import { getBlockInfoFromNode } from "../../api/getBlockInfoFromPos.js"; // Loosely based on https://github.com/ueberdosis/tiptap/blob/7ac01ef0b816a535e903b5ca92492bff110a71ae/packages/extension-mathematics/src/MathematicsPlugin.ts (MIT) @@ -119,16 +118,13 @@ function calculateListItemIndex( * Get the decorations for the current state based on the previous state, * and the transaction that was applied to get to the current state */ -function getDecorations( +export function getListItemIndexDecorations( tr: Transaction, - previousPluginState: { decorations: DecorationSet }, + previousDecorations: DecorationSet, ) { const map = new Map(); - const nextDecorationSet = previousPluginState.decorations.map( - tr.mapping, - tr.doc, - ); + const nextDecorationSet = previousDecorations.map(tr.mapping, tr.doc); // Find the start of the first change to limit traversal scope. // We only need to check from the change point forward, since earlier @@ -198,42 +194,11 @@ function getDecorations( nextDecorationSet.find(deco.from, deco.to), ); - return { - decorations: nextDecorationSet + return ( + nextDecorationSet // Remove existing decorations that are going to be replaced .remove(decorationsToRemove) // Add any new decorations - .add(tr.doc, decorationsToAdd), - }; + .add(tr.doc, decorationsToAdd) + ); } - -/** - * This plugin adds decorations to numbered list items to display their index. - */ -export const NumberedListIndexingDecorationPlugin = () => { - return new Plugin<{ decorations: DecorationSet }>({ - key: new PluginKey("numbered-list-indexing-decorations"), - - state: { - init(_config, state) { - // We create an empty transaction to get the decorations for the initial state based on the initial content - return getDecorations(state.tr, { - decorations: DecorationSet.empty, - }); - }, - apply(tr, previousPluginState) { - if (!tr.docChanged && previousPluginState.decorations) { - // Selection-only changes don't affect list indices, just reuse existing decorations - return previousPluginState; - } - return getDecorations(tr, previousPluginState); - }, - }, - - props: { - decorations(state) { - return this.getState(state)?.decorations ?? DecorationSet.empty; - }, - }, - }); -}; 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/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts index c9ed09e7b4..826329b3b0 100644 --- a/packages/core/src/extensions/index.ts +++ b/packages/core/src/extensions/index.ts @@ -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 "./ListItem/ListItem.js"; export * from "./LinkToolbar/protocols.js"; export * from "./NodeSelectionKeyboard/NodeSelectionKeyboard.js"; export * from "./Placeholder/Placeholder.js"; diff --git a/packages/core/src/pm-nodes/BlockGroup.ts b/packages/core/src/pm-nodes/BlockGroup.ts index bba1d84f4d..b473bc78a7 100644 --- a/packages/core/src/pm-nodes/BlockGroup.ts +++ b/packages/core/src/pm-nodes/BlockGroup.ts @@ -15,6 +15,8 @@ export const BlockGroup = Node.create<{ }, parseHTML() { return [ + // Exported accessibility owners are chrome, not document blocks. + { tag: "div[data-bn-list-owner]", ignore: true }, { tag: "div", getAttrs: (element) => { diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 5af9f0eb23..4dd004470c 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -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; } /** 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.a11y.test.ts b/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts index ad788786d2..2376fa824d 100644 --- a/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts +++ b/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts @@ -118,7 +118,10 @@ test("read-only editor: Tab reaches inline links and leaves the editor", async ( ], { editable: false }, ); - await checkAriaSnapshot(page.getByRole("textbox"), "read-only editor"); + await checkAriaSnapshot( + page.getByRole("main").getByRole("document"), + "read-only editor", + ); const link = page.getByRole("link", { name: "Documentation", exact: true }); // The editor itself may be a tab stop; it must not trap focus before the link. for ( diff --git a/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/aria/browser.snap b/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/aria/browser.snap index 0c20a42d08..8a9f173f54 100644 --- a/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/aria/browser.snap +++ b/packages/react/src/editor/a11y/BlockNoteView.a11y.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,13 +78,15 @@ 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" `; 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/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-size-200-percent-chromium-linux.png b/packages/react/src/editor/a11y/BlockNoteView.a11y.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.a11y.test.ts-snapshots/visual/default-schema-text-size-200-percent-chromium-linux.png and b/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-size-200-percent-chromium-linux.png differ diff --git a/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-spacing-chromium-linux.png b/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-spacing-chromium-linux.png index c63121f961..e9948d75aa 100644 Binary files a/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-spacing-chromium-linux.png and b/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-spacing-chromium-linux.png differ 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..f2a7925461 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 2

Paragraph

list item

Example
Caption

Example

Caption
"`; +exports[`Test ServerBlockNoteEditor > converts to HTML (blocksToFullHTML) 1`] = `"

Heading 2

Paragraph

list item

Example
Caption

Example

Caption
"`; exports[`Test ServerBlockNoteEditor > converts to and from HTML (blocksToHTMLLossy) 1`] = `"

Heading 2

Paragraph

  • list item

Example
Caption
Example

Caption

"`; diff --git a/tests/a11y/commands.ts b/tests/a11y/commands.ts index f3cb724626..7eb589c3b1 100644 --- a/tests/a11y/commands.ts +++ b/tests/a11y/commands.ts @@ -50,6 +50,12 @@ export const readTranscript: BrowserCommand<[]> = async () => export const stopReader: BrowserCommand<[]> = async () => (await reader()).stop(); +export const nextReader: BrowserCommand<[]> = async () => { + const screenReader = await reader(); + await screenReader.next(); + return screenReader.lastSpokenPhrase(); +}; + export const readAriaSnapshot: BrowserCommand<[selector: string]> = async ( context, selector, diff --git a/tests/a11y/documents.ts b/tests/a11y/documents.ts index 186e5b0284..67d7f54537 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 }; + // Adjacent items exercise list ownership/counts rather than only isolated + // one-item lists. Switching types also exercises list boundaries. + 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/a11y/helpers.tsx b/tests/a11y/helpers.tsx index 2c3fa9ea37..cf70d97b80 100644 --- a/tests/a11y/helpers.tsx +++ b/tests/a11y/helpers.tsx @@ -4,6 +4,7 @@ import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { useCreateBlockNote } from "@blocknote/react"; import { render } from "vitest-browser-react"; +export { cleanup } from "vitest-browser-react"; import axe from "axe-core"; import { expect, type TestContext } from "vite-plus/test"; import { commands, page, server } from "vite-plus/test/browser"; @@ -20,6 +21,7 @@ export const selectToLineEndKey = const browserCommands = commands as typeof commands & { readAriaSnapshot(selector: string): Promise; startReader(): Promise; + nextReader(): Promise; pressKey(key: string): Promise; readTranscript(): Promise; stopReader(): Promise; @@ -60,9 +62,11 @@ export async function openEditor( theme={theme} />, ); - 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) { @@ -110,6 +114,10 @@ export function readSelection() { }; } +export async function nextReader() { + return browserCommands.nextReader(); +} + export async function pressKey(key: string) { await waitForEditorFocus(); return browserCommands.pressKey(key); diff --git a/tests/a11y/vite.config.ts b/tests/a11y/vite.config.ts index cf8f6dfd1d..332879babd 100644 --- a/tests/a11y/vite.config.ts +++ b/tests/a11y/vite.config.ts @@ -3,6 +3,7 @@ import { resolve } from "node:path"; import { defineConfig } from "vite-plus"; import { playwright } from "vite-plus/test/browser/providers/playwright"; import { + nextReader, readAriaSnapshot, startReader, pressKey, @@ -84,6 +85,7 @@ export default defineConfig({ ], commands: { readAriaSnapshot, + nextReader, startReader, pressKey, readTranscript, 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..8f5e7630d2 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,14 +40,32 @@

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..9143e36f92 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,7 +41,19 @@

-
+
+

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..1d38990199 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,19 +1,49 @@

-
+
+

Bullet List Item 1

-
+

Bullet List Item 2

-
+
+
-
+
-
+
+
@@ -45,7 +93,13 @@
-
+
-
+
+