From 7529521694b1ea2e03a5cefea12c384c163b6ea3 Mon Sep 17 00:00:00 2001 From: helloigig Date: Tue, 3 Nov 2026 08:36:29 +0000 Subject: [PATCH 1/4] fix(core): announce Tab indentation to screen readers (#2812) Indenting or outdenting a block with Tab / Shift-Tab only changed it visually, so screen reader users got no feedback that it worked or which level the block ended up at. Adds a LiveAnnouncerExtension that renders a visually hidden, polite status region next to the editor, and announces "Indented, level N" / "Outdented, level N" from the Tab handler when the block actually moves. The messages are translated in every bundled locale. --- .../managers/ExtensionManager/extensions.ts | 2 + .../extensions/LiveAnnouncer/LiveAnnouncer.ts | 62 ++++++++++ packages/core/src/extensions/index.ts | 1 + .../KeyboardShortcutsExtension.test.ts | 106 ++++++++++++++++++ .../KeyboardShortcutsExtension.ts | 32 +++++- packages/core/src/i18n/locales/ar.ts | 4 + packages/core/src/i18n/locales/de.ts | 4 + packages/core/src/i18n/locales/en.ts | 6 + packages/core/src/i18n/locales/es.ts | 4 + packages/core/src/i18n/locales/fa.ts | 4 + packages/core/src/i18n/locales/fr.ts | 4 + packages/core/src/i18n/locales/he.ts | 4 + packages/core/src/i18n/locales/hr.ts | 4 + packages/core/src/i18n/locales/is.ts | 4 + packages/core/src/i18n/locales/it.ts | 4 + packages/core/src/i18n/locales/ja.ts | 4 + packages/core/src/i18n/locales/ko.ts | 4 + packages/core/src/i18n/locales/nl.ts | 4 + packages/core/src/i18n/locales/no.ts | 4 + packages/core/src/i18n/locales/pl.ts | 4 + packages/core/src/i18n/locales/pt.ts | 4 + packages/core/src/i18n/locales/ru.ts | 4 + packages/core/src/i18n/locales/sk.ts | 4 + packages/core/src/i18n/locales/tr.ts | 4 + packages/core/src/i18n/locales/uk.ts | 4 + packages/core/src/i18n/locales/uz.ts | 4 + packages/core/src/i18n/locales/vi.ts | 4 + packages/core/src/i18n/locales/zh-tw.ts | 4 + packages/core/src/i18n/locales/zh.ts | 4 + 29 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/extensions/LiveAnnouncer/LiveAnnouncer.ts diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 2090cea491..82485fc695 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, + LiveAnnouncerExtension, LoadingExtension, NodeSelectionKeyboardExtension, PlaceholderExtension, @@ -175,6 +176,7 @@ export function getDefaultExtensions( : {}), }), LinkToolbarExtension(options), + LiveAnnouncerExtension(), LoadingExtension(), NodeSelectionKeyboardExtension(), PlaceholderExtension(options), diff --git a/packages/core/src/extensions/LiveAnnouncer/LiveAnnouncer.ts b/packages/core/src/extensions/LiveAnnouncer/LiveAnnouncer.ts new file mode 100644 index 0000000000..81036dfa17 --- /dev/null +++ b/packages/core/src/extensions/LiveAnnouncer/LiveAnnouncer.ts @@ -0,0 +1,62 @@ +import { createExtension } from "../../editor/BlockNoteExtension.js"; + +/** + * Announces short status messages to screen readers through a polite live + * region, for editor changes that only show visually (e.g. indenting a block + * with Tab). + * + * The region is a visually hidden sibling of the editor's `contenteditable` + * element, so ProseMirror never sees it and it stays near the editor in the + * accessibility tree (e.g. inside the same modal dialog). + */ +export const LiveAnnouncerExtension = createExtension(() => { + let region: HTMLElement | undefined; + + return { + key: "liveAnnouncer", + mount({ dom, signal }) { + const liveRegion = dom.ownerDocument.createElement("div"); + liveRegion.className = "bn-live-announcer"; + liveRegion.setAttribute("role", "status"); + liveRegion.setAttribute("aria-live", "polite"); + liveRegion.setAttribute("aria-atomic", "true"); + // Visually hidden, but still exposed to assistive technologies. Inline + // so it holds without any stylesheet. + Object.assign(liveRegion.style, { + position: "absolute", + width: "1px", + height: "1px", + overflow: "hidden", + clipPath: "inset(50%)", + whiteSpace: "nowrap", + }); + // The region has to be in the DOM before its content changes for the + // change to be announced, so it's added up front rather than on demand. + dom.after(liveRegion); + region = liveRegion; + + signal.addEventListener( + "abort", + () => { + liveRegion.remove(); + if (region === liveRegion) { + region = undefined; + } + }, + { once: true }, + ); + }, + /** + * Announces a message to screen readers. Does nothing while the editor + * isn't mounted. + */ + announce(message: string) { + if (!region) { + return; + } + // Replacing the text node, rather than editing it, makes screen readers + // repeat a message that's identical to the previous one. + region.replaceChildren(region.ownerDocument.createTextNode(message)); + }, + }; +}); diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts index 78a5b26fae..8e7e188e23 100644 --- a/packages/core/src/extensions/index.ts +++ b/packages/core/src/extensions/index.ts @@ -6,6 +6,7 @@ export * from "./History/History.js"; export * from "./InlineContentBoundaryEdit/InlineContentBoundaryEdit.js"; export * from "./LinkToolbar/LinkToolbar.js"; export * from "./LinkToolbar/protocols.js"; +export * from "./LiveAnnouncer/LiveAnnouncer.js"; export * from "./Loading/Loading.js"; export * from "./NodeSelectionKeyboard/NodeSelectionKeyboard.js"; export * from "./Placeholder/Placeholder.js"; diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts index a2ab754bcb..a07755b8a7 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts @@ -21,6 +21,7 @@ import { BlockNoteEditor, type BlockNoteEditorOptions, } from "../../../editor/BlockNoteEditor.js"; +import { fr } from "../../../i18n/locales/index.js"; import { createBlockSpec } from "../../../schema/index.js"; // Blocks configured with the deprecated `meta.hardBreakShortcut`, which still @@ -924,6 +925,111 @@ describe("KeyboardShortcutsExtension Shift-Tab", () => { }); }); +// #2812: indenting with Tab only changed the block visually, so screen reader +// users got no feedback that it worked, or at which level the block ended up. +describe("KeyboardShortcutsExtension Tab announcements", () => { + function createMountedEditor(cursorId: string) { + const wrapper = document.createElement("div"); + const mountPoint = document.createElement("div"); + wrapper.appendChild(mountPoint); + document.body.appendChild(wrapper); + const editor = BlockNoteEditor.create({ + schema, + initialContent: [ + { id: "a", type: "paragraph", content: "Parent" }, + { id: "b", type: "paragraph", content: "Child" }, + ], + }); + editor.mount(mountPoint); + editor.setTextCursorPosition(cursorId, "start"); + return { editor, wrapper }; + } + + function liveRegion(wrapper: HTMLElement) { + return wrapper.querySelector("[aria-live]"); + } + + function pressTab(editor: BlockNoteEditor, shiftKey = false) { + keyDown(editor, { key: "Tab", code: "Tab", shiftKey }); + } + + it("renders a polite live region outside the contenteditable", () => { + const { editor, wrapper } = createMountedEditor("b"); + const region = liveRegion(wrapper); + + expect(region).not.toBeNull(); + expect(region!.getAttribute("aria-live")).toBe("polite"); + expect(region!.getAttribute("role")).toBe("status"); + expect(editor.domElement!.contains(region)).toBe(false); + expect(region!.textContent).toBe(""); + + editor._tiptapEditor.destroy(); + wrapper.remove(); + }); + + it("announces the new level on Tab and Shift-Tab", () => { + const { editor, wrapper } = createMountedEditor("b"); + + pressTab(editor); + expect(outline(editor.document)[0].children).toHaveLength(1); + expect(liveRegion(wrapper)!.textContent).toBe("Indented, level 2"); + + pressTab(editor, true); + expect(editor.document).toHaveLength(2); + expect(liveRegion(wrapper)!.textContent).toBe("Outdented, level 1"); + + editor._tiptapEditor.destroy(); + wrapper.remove(); + }); + + it("announces nothing when the block can't be indented", () => { + const { editor, wrapper } = createMountedEditor("a"); + + pressTab(editor); + pressTab(editor, true); + + expect(editor.document).toHaveLength(2); + expect(liveRegion(wrapper)!.textContent).toBe(""); + + editor._tiptapEditor.destroy(); + wrapper.remove(); + }); + + it("uses the editor's dictionary", () => { + const wrapper = document.createElement("div"); + const mountPoint = document.createElement("div"); + wrapper.appendChild(mountPoint); + document.body.appendChild(wrapper); + const editor = BlockNoteEditor.create({ + schema, + dictionary: fr, + initialContent: [ + { id: "a", type: "paragraph", content: "Parent" }, + { id: "b", type: "paragraph", content: "Child" }, + ], + }); + editor.mount(mountPoint); + editor.setTextCursorPosition("b", "start"); + + pressTab(editor); + expect(liveRegion(wrapper)!.textContent).toBe("Retrait augmenté, niveau 2"); + + editor._tiptapEditor.destroy(); + wrapper.remove(); + }); + + it("removes the live region when the editor unmounts", () => { + const { editor, wrapper } = createMountedEditor("b"); + expect(liveRegion(wrapper)).not.toBeNull(); + + editor.unmount(); + expect(liveRegion(wrapper)).toBeNull(); + + editor._tiptapEditor.destroy(); + wrapper.remove(); + }); +}); + describe("KeyboardShortcutsExtension line breaks", () => { it("inserts a hard break on Shift-Enter by default", () => { const editor = createEditor("paragraph"); diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index b770e038a3..552ec050d6 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -1,5 +1,5 @@ import { type ChainedCommands, Extension } from "@tiptap/core"; -import { Fragment, type Node } from "prosemirror-model"; +import { Fragment, type Node, type ResolvedPos } from "prosemirror-model"; import { NodeSelection, Selection, @@ -39,6 +39,20 @@ import { import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; import { FilePanelExtension } from "../../FilePanel/FilePanel.js"; import { FormattingToolbarExtension } from "../../FormattingToolbar/FormattingToolbar.js"; +import { LiveAnnouncerExtension } from "../../LiveAnnouncer/LiveAnnouncer.js"; + +// How deeply the block at `$pos` is nested, counting top-level blocks as level +// 1. Layout blocks (e.g. columns) aren't block containers, so a block inside +// them stays at the level it visually sits at. +function nestingLevel($pos: ResolvedPos) { + let level = 0; + for (let depth = $pos.depth; depth > 0; depth--) { + if ($pos.node(depth).type.name === "blockContainer") { + level++; + } + } + return level; +} // Move a block across a layout block boundary, repair its former ancestors, and // map the caret through any repairs that change the insertion position. @@ -1026,7 +1040,21 @@ export const KeyboardShortcutsExtension = Extension.create<{ // Let the browser navigate into and out of an open toolbar. return false; } - return shift ? unnestBlock(editor) : nestBlock(editor); + const moved = shift ? unnestBlock(editor) : nestBlock(editor); + if (moved) { + // The indent is only visible, so tell screen readers about it too + // (#2812). + const { block_nesting } = editor.dictionary; + const level = nestingLevel(editor.prosemirrorState.selection.$from); + editor + .getExtension(LiveAnnouncerExtension) + ?.announce( + shift + ? block_nesting.outdented(level) + : block_nesting.indented(level), + ); + } + return moved; } return { diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts index 2a3c6e567b..29a0a0e7eb 100644 --- a/packages/core/src/i18n/locales/ar.ts +++ b/packages/core/src/i18n/locales/ar.ts @@ -192,6 +192,10 @@ export const ar: Dictionary = { add_block_button: "تبديل فارغ. انقر لإضافة كتلة.", toggle_button: "توسيع أو طي", }, + block_nesting: { + indented: (level: number) => `تمت زيادة المسافة البادئة، المستوى ${level}`, + outdented: (level: number) => `تم تقليل المسافة البادئة، المستوى ${level}`, + }, code_block: { add_source_button_text: "إضافة كود المصدر", ok_button_text: "موافق", diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts index f8040cebe2..a5804e0f97 100644 --- a/packages/core/src/i18n/locales/de.ts +++ b/packages/core/src/i18n/locales/de.ts @@ -228,6 +228,10 @@ export const de: Dictionary = { "Leerer aufklappbarer Bereich. Klicken, um einen Block hinzuzufügen.", toggle_button: "Aufklappen oder zuklappen", }, + block_nesting: { + indented: (level: number) => `Eingerückt, Ebene ${level}`, + outdented: (level: number) => `Ausgerückt, Ebene ${level}`, + }, code_block: { add_source_button_text: "Quellcode hinzufügen", ok_button_text: "OK", diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts index 36d78a052b..d6f3af6dd5 100644 --- a/packages/core/src/i18n/locales/en.ts +++ b/packages/core/src/i18n/locales/en.ts @@ -207,6 +207,12 @@ export const en = { add_block_button: "Empty toggle. Click to add a block.", toggle_button: "Expand or collapse", }, + // Screen reader announcements when a block is indented with Tab or + // outdented with Shift-Tab. + block_nesting: { + indented: (level: number) => `Indented, level ${level}`, + outdented: (level: number) => `Outdented, level ${level}`, + }, code_block: { add_source_button_text: "Add source code", ok_button_text: "OK", diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts index fc2bfe7131..98fe2e98da 100644 --- a/packages/core/src/i18n/locales/es.ts +++ b/packages/core/src/i18n/locales/es.ts @@ -207,6 +207,10 @@ export const es: Dictionary = { add_block_button: "Toggle vacío. Haz clic para añadir un bloque.", toggle_button: "Expandir o contraer", }, + block_nesting: { + indented: (level: number) => `Sangría aumentada, nivel ${level}`, + outdented: (level: number) => `Sangría reducida, nivel ${level}`, + }, code_block: { add_source_button_text: "Agregar código fuente", ok_button_text: "Aceptar", diff --git a/packages/core/src/i18n/locales/fa.ts b/packages/core/src/i18n/locales/fa.ts index a9c26ab159..749d7dd665 100644 --- a/packages/core/src/i18n/locales/fa.ts +++ b/packages/core/src/i18n/locales/fa.ts @@ -175,6 +175,10 @@ export const fa = { add_block_button: "تاشوی خالی. برای افزودن بلوک کلیک کنید.", toggle_button: "باز یا بسته کردن", }, + block_nesting: { + indented: (level: number) => `تورفتگی افزایش یافت، سطح ${level}`, + outdented: (level: number) => `تورفتگی کاهش یافت، سطح ${level}`, + }, code_block: { add_source_button_text: "افزودن کد منبع", ok_button_text: "تأیید", diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts index 7fdc33900c..13b5d9aff4 100644 --- a/packages/core/src/i18n/locales/fr.ts +++ b/packages/core/src/i18n/locales/fr.ts @@ -253,6 +253,10 @@ export const fr: Dictionary = { add_block_button: "Liste repliable vide. Cliquez pour ajouter un bloc.", toggle_button: "Déplier ou replier", }, + block_nesting: { + indented: (level: number) => `Retrait augmenté, niveau ${level}`, + outdented: (level: number) => `Retrait diminué, niveau ${level}`, + }, code_block: { add_source_button_text: "Ajouter le code source", ok_button_text: "OK", diff --git a/packages/core/src/i18n/locales/he.ts b/packages/core/src/i18n/locales/he.ts index 5f565d9a7e..58f8808e61 100644 --- a/packages/core/src/i18n/locales/he.ts +++ b/packages/core/src/i18n/locales/he.ts @@ -209,6 +209,10 @@ export const he: Dictionary = { add_block_button: "מתג ריק. לחץ כדי להוסיף בלוק.", toggle_button: "הרחבה או כיווץ", }, + block_nesting: { + indented: (level: number) => `ההזחה הוגדלה, רמה ${level}`, + outdented: (level: number) => `ההזחה הוקטנה, רמה ${level}`, + }, code_block: { add_source_button_text: "הוסף קוד מקור", ok_button_text: "אישור", diff --git a/packages/core/src/i18n/locales/hr.ts b/packages/core/src/i18n/locales/hr.ts index 96ea73f4af..69c2bcce20 100644 --- a/packages/core/src/i18n/locales/hr.ts +++ b/packages/core/src/i18n/locales/hr.ts @@ -220,6 +220,10 @@ export const hr: Dictionary = { add_block_button: "Prazan sklopivi blok. Klikni da dodaš sadržaj.", toggle_button: "Proširi ili sažmi", }, + block_nesting: { + indented: (level: number) => `Uvlaka povećana, razina ${level}`, + outdented: (level: number) => `Uvlaka smanjena, razina ${level}`, + }, code_block: { add_source_button_text: "Dodaj izvorni kôd", ok_button_text: "U redu", diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts index d8acbbafac..7780d0e838 100644 --- a/packages/core/src/i18n/locales/is.ts +++ b/packages/core/src/i18n/locales/is.ts @@ -221,6 +221,10 @@ export const is: Dictionary = { add_block_button: "Tóm fellilína. Smelltu til að bæta við blokk.", toggle_button: "Fella út eða inn", }, + block_nesting: { + indented: (level: number) => `Inndráttur aukinn, stig ${level}`, + outdented: (level: number) => `Inndráttur minnkaður, stig ${level}`, + }, code_block: { add_source_button_text: "Bæta við frumkóða", ok_button_text: "Í lagi", diff --git a/packages/core/src/i18n/locales/it.ts b/packages/core/src/i18n/locales/it.ts index 293bb10063..b20c8b0bc4 100644 --- a/packages/core/src/i18n/locales/it.ts +++ b/packages/core/src/i18n/locales/it.ts @@ -229,6 +229,10 @@ export const it: Dictionary = { add_block_button: "Toggle vuoto. Clicca per aggiungere un blocco.", toggle_button: "Espandi o comprimi", }, + block_nesting: { + indented: (level: number) => `Rientro aumentato, livello ${level}`, + outdented: (level: number) => `Rientro ridotto, livello ${level}`, + }, code_block: { add_source_button_text: "Aggiungi codice sorgente", ok_button_text: "OK", diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts index 270565579d..b6351d1db2 100644 --- a/packages/core/src/i18n/locales/ja.ts +++ b/packages/core/src/i18n/locales/ja.ts @@ -247,6 +247,10 @@ export const ja: Dictionary = { add_block_button: "空のトグルです。クリックしてブロックを追加。", toggle_button: "展開または折りたたみ", }, + block_nesting: { + indented: (level: number) => `インデントを増やしました、レベル ${level}`, + outdented: (level: number) => `インデントを減らしました、レベル ${level}`, + }, code_block: { add_source_button_text: "ソースコードを追加", ok_button_text: "OK", diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts index b1974616ef..7ccc64eb0e 100644 --- a/packages/core/src/i18n/locales/ko.ts +++ b/packages/core/src/i18n/locales/ko.ts @@ -220,6 +220,10 @@ export const ko: Dictionary = { add_block_button: "비어 있는 토글입니다. 클릭하여 블록을 추가하세요.", toggle_button: "펼치기 또는 접기", }, + block_nesting: { + indented: (level: number) => `들여쓰기 늘림, 수준 ${level}`, + outdented: (level: number) => `들여쓰기 줄임, 수준 ${level}`, + }, code_block: { add_source_button_text: "소스 코드 추가", ok_button_text: "확인", diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts index 63b8855780..c7f217350b 100644 --- a/packages/core/src/i18n/locales/nl.ts +++ b/packages/core/src/i18n/locales/nl.ts @@ -208,6 +208,10 @@ export const nl: Dictionary = { add_block_button: "Lege uitklapper. Klik om een blok toe te voegen.", toggle_button: "Uitklappen of inklappen", }, + block_nesting: { + indented: (level: number) => `Ingesprongen, niveau ${level}`, + outdented: (level: number) => `Uitgesprongen, niveau ${level}`, + }, code_block: { add_source_button_text: "Broncode toevoegen", ok_button_text: "OK", diff --git a/packages/core/src/i18n/locales/no.ts b/packages/core/src/i18n/locales/no.ts index d3653f6444..5c5ee520f4 100644 --- a/packages/core/src/i18n/locales/no.ts +++ b/packages/core/src/i18n/locales/no.ts @@ -226,6 +226,10 @@ export const no: Dictionary = { add_block_button: "Tomt toggle. Klikk for å legge til en blokk.", toggle_button: "Utvid eller skjul", }, + block_nesting: { + indented: (level: number) => `Rykket inn, nivå ${level}`, + outdented: (level: number) => `Rykket ut, nivå ${level}`, + }, code_block: { add_source_button_text: "Legg til kildekode", ok_button_text: "OK", diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts index a173b8ceaa..18e891c4cd 100644 --- a/packages/core/src/i18n/locales/pl.ts +++ b/packages/core/src/i18n/locales/pl.ts @@ -199,6 +199,10 @@ export const pl: Dictionary = { "Brak bloków do rozwinięcia. Kliknij, aby dodać pierwszego.", toggle_button: "Rozwiń lub zwiń", }, + block_nesting: { + indented: (level: number) => `Zwiększono wcięcie, poziom ${level}`, + outdented: (level: number) => `Zmniejszono wcięcie, poziom ${level}`, + }, code_block: { add_source_button_text: "Dodaj kod źródłowy", ok_button_text: "OK", diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts index 6f0acec26c..6cfa473cc7 100644 --- a/packages/core/src/i18n/locales/pt.ts +++ b/packages/core/src/i18n/locales/pt.ts @@ -199,6 +199,10 @@ export const pt: Dictionary = { add_block_button: "Toggle vazio. Clique para adicionar um bloco.", toggle_button: "Expandir ou recolher", }, + block_nesting: { + indented: (level: number) => `Recuo aumentado, nível ${level}`, + outdented: (level: number) => `Recuo diminuído, nível ${level}`, + }, code_block: { add_source_button_text: "Adicionar código-fonte", ok_button_text: "OK", diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts index f40c4562fb..1e60f2fe09 100644 --- a/packages/core/src/i18n/locales/ru.ts +++ b/packages/core/src/i18n/locales/ru.ts @@ -250,6 +250,10 @@ export const ru: Dictionary = { add_block_button: "Пустой переключатель. Нажмите, чтобы добавить блок.", toggle_button: "Развернуть или свернуть", }, + block_nesting: { + indented: (level: number) => `Отступ увеличен, уровень ${level}`, + outdented: (level: number) => `Отступ уменьшен, уровень ${level}`, + }, code_block: { add_source_button_text: "Добавить исходный код", ok_button_text: "ОК", diff --git a/packages/core/src/i18n/locales/sk.ts b/packages/core/src/i18n/locales/sk.ts index 8fa727acd7..77ae9262a4 100644 --- a/packages/core/src/i18n/locales/sk.ts +++ b/packages/core/src/i18n/locales/sk.ts @@ -207,6 +207,10 @@ export const sk = { add_block_button: "Prázdne prepínanie. Kliknite pre pridanie bloku.", toggle_button: "Rozbaliť alebo zbaliť", }, + block_nesting: { + indented: (level: number) => `Odsadenie zväčšené, úroveň ${level}`, + outdented: (level: number) => `Odsadenie zmenšené, úroveň ${level}`, + }, code_block: { add_source_button_text: "Pridať zdrojový kód", ok_button_text: "OK", diff --git a/packages/core/src/i18n/locales/tr.ts b/packages/core/src/i18n/locales/tr.ts index 7195de7e04..222935fcd2 100644 --- a/packages/core/src/i18n/locales/tr.ts +++ b/packages/core/src/i18n/locales/tr.ts @@ -213,6 +213,10 @@ export const tr = { add_block_button: "Boş açılır blok. Blok eklemek için tıklayın.", toggle_button: "Genişlet veya daralt", }, + block_nesting: { + indented: (level: number) => `Girinti artırıldı, düzey ${level}`, + outdented: (level: number) => `Girinti azaltıldı, düzey ${level}`, + }, code_block: { add_source_button_text: "Kaynak kodu ekle", ok_button_text: "Tamam", diff --git a/packages/core/src/i18n/locales/uk.ts b/packages/core/src/i18n/locales/uk.ts index 6651bb82d9..9c5429a3a9 100644 --- a/packages/core/src/i18n/locales/uk.ts +++ b/packages/core/src/i18n/locales/uk.ts @@ -232,6 +232,10 @@ export const uk: Dictionary = { add_block_button: "Порожній перемикач. Натисніть, щоб додати блок.", toggle_button: "Розгорнути або згорнути", }, + block_nesting: { + indented: (level: number) => `Відступ збільшено, рівень ${level}`, + outdented: (level: number) => `Відступ зменшено, рівень ${level}`, + }, code_block: { add_source_button_text: "Додати вихідний код", ok_button_text: "ОК", diff --git a/packages/core/src/i18n/locales/uz.ts b/packages/core/src/i18n/locales/uz.ts index d6cddcfdfc..105c32a12a 100644 --- a/packages/core/src/i18n/locales/uz.ts +++ b/packages/core/src/i18n/locales/uz.ts @@ -268,6 +268,10 @@ export const uz: Dictionary = { add_block_button: "Bo‘sh toggle. Blok qo‘shish uchun bosing.", toggle_button: "Yoyish yoki yig‘ish", }, + block_nesting: { + indented: (level: number) => `Chekinish oshirildi, daraja ${level}`, + outdented: (level: number) => `Chekinish kamaytirildi, daraja ${level}`, + }, code_block: { add_source_button_text: "Manba kodini qoʻshish", diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts index 66fc039b8c..a0749709a3 100644 --- a/packages/core/src/i18n/locales/vi.ts +++ b/packages/core/src/i18n/locales/vi.ts @@ -206,6 +206,10 @@ export const vi: Dictionary = { add_block_button: "Toggle trống. Nhấp để thêm khối.", toggle_button: "Mở rộng hoặc thu gọn", }, + block_nesting: { + indented: (level: number) => `Đã tăng thụt lề, cấp ${level}`, + outdented: (level: number) => `Đã giảm thụt lề, cấp ${level}`, + }, code_block: { add_source_button_text: "Thêm mã nguồn", ok_button_text: "OK", diff --git a/packages/core/src/i18n/locales/zh-tw.ts b/packages/core/src/i18n/locales/zh-tw.ts index 529fcd0ba1..48a9077d00 100644 --- a/packages/core/src/i18n/locales/zh-tw.ts +++ b/packages/core/src/i18n/locales/zh-tw.ts @@ -248,6 +248,10 @@ export const zhTW: Dictionary = { add_block_button: "空的切換區。點擊新增區塊。", toggle_button: "展開或收合", }, + block_nesting: { + indented: (level: number) => `已增加縮排,第 ${level} 層`, + outdented: (level: number) => `已減少縮排,第 ${level} 層`, + }, code_block: { add_source_button_text: "新增原始碼", ok_button_text: "確定", diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts index 5f62ffb029..d835b64235 100644 --- a/packages/core/src/i18n/locales/zh.ts +++ b/packages/core/src/i18n/locales/zh.ts @@ -248,6 +248,10 @@ export const zh: Dictionary = { add_block_button: "空的切换区。点击添加区块。", toggle_button: "展开或收起", }, + block_nesting: { + indented: (level: number) => `已增加缩进,第 ${level} 级`, + outdented: (level: number) => `已减少缩进,第 ${level} 级`, + }, code_block: { add_source_button_text: "添加源代码", ok_button_text: "确定", From 1ad48dae44a3b838d4d71767d14a7f47308001b0 Mon Sep 17 00:00:00 2001 From: helloigig Date: Wed, 4 Nov 2026 09:08:42 +0000 Subject: [PATCH 2/4] fix(core): fall back to English nesting announcements Custom dictionaries written before `block_nesting` existed made Tab throw a TypeError when announcing the new level. Fill the section in from `en`, the same way `emoji_picker` is. Also covers announcing a level deeper than 2. --- packages/core/src/editor/BlockNoteEditor.ts | 3 + .../KeyboardShortcutsExtension.test.ts | 56 ++++++++++++++----- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 9c3586cd86..ed2c706783 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -469,6 +469,9 @@ export class BlockNoteEditor< if (this.dictionary.emoji_picker === undefined) { this.dictionary.emoji_picker = en.emoji_picker; } + if (this.dictionary.block_nesting === undefined) { + this.dictionary.block_nesting = en.block_nesting; + } this.settings = { tables: { splitCells: options?.tables?.splitCells ?? false, diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts index a07755b8a7..280010e80c 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts @@ -21,7 +21,7 @@ import { BlockNoteEditor, type BlockNoteEditorOptions, } from "../../../editor/BlockNoteEditor.js"; -import { fr } from "../../../i18n/locales/index.js"; +import { en, fr } from "../../../i18n/locales/index.js"; import { createBlockSpec } from "../../../schema/index.js"; // Blocks configured with the deprecated `meta.hardBreakShortcut`, which still @@ -928,7 +928,10 @@ describe("KeyboardShortcutsExtension Shift-Tab", () => { // #2812: indenting with Tab only changed the block visually, so screen reader // users got no feedback that it worked, or at which level the block ended up. describe("KeyboardShortcutsExtension Tab announcements", () => { - function createMountedEditor(cursorId: string) { + function createMountedEditor( + cursorId: string, + options: Partial> = {}, + ) { const wrapper = document.createElement("div"); const mountPoint = document.createElement("div"); wrapper.appendChild(mountPoint); @@ -939,6 +942,7 @@ describe("KeyboardShortcutsExtension Tab announcements", () => { { id: "a", type: "paragraph", content: "Parent" }, { id: "b", type: "paragraph", content: "Child" }, ], + ...options, }); editor.mount(mountPoint); editor.setTextCursorPosition(cursorId, "start"); @@ -995,21 +999,31 @@ describe("KeyboardShortcutsExtension Tab announcements", () => { wrapper.remove(); }); - it("uses the editor's dictionary", () => { - const wrapper = document.createElement("div"); - const mountPoint = document.createElement("div"); - wrapper.appendChild(mountPoint); - document.body.appendChild(wrapper); - const editor = BlockNoteEditor.create({ - schema, - dictionary: fr, + it("announces deeper levels", () => { + const { editor, wrapper } = createMountedEditor("c", { initialContent: [ - { id: "a", type: "paragraph", content: "Parent" }, - { id: "b", type: "paragraph", content: "Child" }, + { + id: "a", + type: "paragraph", + content: "Parent", + children: [ + { id: "b", type: "paragraph", content: "Child" }, + { id: "c", type: "paragraph", content: "Grandchild" }, + ], + }, ], }); - editor.mount(mountPoint); - editor.setTextCursorPosition("b", "start"); + + pressTab(editor); + expect(outline(editor.document)[0].children[0].children).toHaveLength(1); + expect(liveRegion(wrapper)!.textContent).toBe("Indented, level 3"); + + editor._tiptapEditor.destroy(); + wrapper.remove(); + }); + + it("uses the editor's dictionary", () => { + const { editor, wrapper } = createMountedEditor("b", { dictionary: fr }); pressTab(editor); expect(liveRegion(wrapper)!.textContent).toBe("Retrait augmenté, niveau 2"); @@ -1018,6 +1032,20 @@ describe("KeyboardShortcutsExtension Tab announcements", () => { wrapper.remove(); }); + it("falls back to English for dictionaries without block_nesting", () => { + const { block_nesting: _blockNesting, ...dictionary } = en; + const { editor, wrapper } = createMountedEditor("b", { + // @ts-expect-error Simulate a dictionary authored before block_nesting existed. + dictionary, + }); + + pressTab(editor); + expect(liveRegion(wrapper)!.textContent).toBe("Indented, level 2"); + + editor._tiptapEditor.destroy(); + wrapper.remove(); + }); + it("removes the live region when the editor unmounts", () => { const { editor, wrapper } = createMountedEditor("b"); expect(liveRegion(wrapper)).not.toBeNull(); From 93bda07a3b3cd1e3fdd456d395e8bdc3d171c735 Mon Sep 17 00:00:00 2001 From: helloigig Date: Wed, 4 Nov 2026 09:08:46 +0000 Subject: [PATCH 3/4] test(react): scope versioning sidebar status queries to the sidebar The editor now renders its own `role="status"` live region, so page-wide `getByRole("status")` queries matched it too: strict-mode violations, and "no loader" assertions that always failed. Look for the sidebar's loading indicator inside the History region instead. --- .../VersioningSidebar.browser.test.tsx | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/react/src/components/Versioning/VersioningSidebar.browser.test.tsx b/packages/react/src/components/Versioning/VersioningSidebar.browser.test.tsx index 1a8ca8663b..e92c8407f8 100644 --- a/packages/react/src/components/Versioning/VersioningSidebar.browser.test.tsx +++ b/packages/react/src/components/Versioning/VersioningSidebar.browser.test.tsx @@ -215,6 +215,14 @@ function rows() { }); } +// The sidebar's loading indicator. Scoped to the sidebar because the editor +// renders a status region of its own for screen reader announcements. +function sidebarStatus() { + return page + .getByRole("region", { name: "History", exact: true }) + .getByRole("status"); +} + /** Capture the action runner so rejected promises can be asserted by the test. */ async function setupWithRun(fake = createFakeEndpoints()) { let run!: VersioningSidebarContextValue["run"]; @@ -914,7 +922,7 @@ describe("VersioningSidebar", () => { expect(page.getByRole("alert").element().textContent).toBe( "Failed to load version history", ); - expect(page.getByRole("status").query()).toBeNull(); + expect(sidebarStatus().query()).toBeNull(); expect(rows()).toHaveLength(0); expect(mode(editor).store.state).toMatchObject({ mode: "versions", @@ -944,7 +952,7 @@ describe("VersioningSidebar", () => { ); expect(rows()).toHaveLength(3); expect(rows()[1]!.getAttribute("aria-current")).toBe("true"); - expect(page.getByRole("status").query()).toBeNull(); + expect(sidebarStatus().query()).toBeNull(); await act(async () => { await versioning.list(); }); @@ -2006,7 +2014,7 @@ describe("VersioningSidebar", () => { , ); - expect(page.getByRole("status").element()).toBeDefined(); + expect(sidebarStatus().element()).toBeDefined(); expect( page.getByText("Loading...", { exact: true }).element(), ).toBeDefined(); @@ -2016,7 +2024,7 @@ describe("VersioningSidebar", () => { release(); }); - expect(page.getByRole("status").query()).toBeNull(); + expect(sidebarStatus().query()).toBeNull(); expect(rows()).toHaveLength(3); }); @@ -2034,7 +2042,7 @@ describe("VersioningSidebar", () => { expect(page.getByRole("list").element().getAttribute("aria-busy")).toBe( "true", ); - expect(page.getByRole("status").query()).toBeNull(); + expect(sidebarStatus().query()).toBeNull(); expect(rows()).toHaveLength(3); expect(rows()[1]).toBe(selectedRow); expect(selectedRow.getAttribute("aria-current")).toBe("true"); @@ -2060,7 +2068,7 @@ describe("VersioningSidebar", () => { const release = fake.block(); const onClose = vi.fn(); const { editor, view } = await setup({ onClose }, fake); - expect(page.getByRole("status").element()).toBeDefined(); + expect(sidebarStatus().element()).toBeDefined(); if (exit === "close") { await click(page.getByRole("button", { name: "Close", exact: true })); @@ -2361,7 +2369,7 @@ describe("VersioningSidebar", () => { await act(async () => finishRestore()); await vi.waitFor(() => { expect(viewState(mode(editor)).mode).toBe("current"); - expect(page.getByRole("status").query()).toBeNull(); + expect(sidebarStatus().query()).toBeNull(); expect(rows()).toHaveLength(3); expect(rows().every((row) => !row.hasAttribute("aria-busy"))).toBe( true, @@ -2434,7 +2442,7 @@ describe("VersioningSidebar", () => { history: { status: "success" }, }); expect(rows()).toHaveLength(3); - expect(page.getByRole("status").query()).toBeNull(); + expect(sidebarStatus().query()).toBeNull(); expect( page.getByRole("list").element().getAttribute("aria-busy"), ).toBeNull(); @@ -2481,7 +2489,7 @@ describe("VersioningSidebar", () => { page.getByRole("button", { name: "Hide changes", exact: true }).query(), ).toBeNull(); expect(rows()).toHaveLength(3); - expect(page.getByRole("status").query()).toBeNull(); + expect(sidebarStatus().query()).toBeNull(); expect(page.getByRole("alert").element().textContent).toBe( "Something went wrong. Please try again.", ); From 0ba92bb9266bc6437c21aadf62024314f9f4c6ad Mon Sep 17 00:00:00 2001 From: helloigig Date: Wed, 4 Nov 2026 10:19:50 +0000 Subject: [PATCH 4/4] fix(core): fill in missing dictionary sections without mutating Filling in `block_nesting` by assigning onto the caller's dictionary threw for frozen dictionaries, which constructed fine before. Copy the dictionary when a section is missing instead, and keep complete dictionaries as-is. --- .../src/editor/BlockNoteEditor.node.test.ts | 17 +++++++++++++++- packages/core/src/editor/BlockNoteEditor.ts | 20 ++++++++++++------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/core/src/editor/BlockNoteEditor.node.test.ts b/packages/core/src/editor/BlockNoteEditor.node.test.ts index eb70307057..64aa9e19d2 100644 --- a/packages/core/src/editor/BlockNoteEditor.node.test.ts +++ b/packages/core/src/editor/BlockNoteEditor.node.test.ts @@ -30,7 +30,22 @@ it("supplies picker strings for dictionaries that omit the picker section", () = try { expect(editor.dictionary.emoji_picker).toEqual(en.emoji_picker); expect(editor.dictionary.slash_menu).toBe(dictionary.slash_menu); - expect(editor.dictionary).toBe(dictionary); + expect(dictionary).not.toHaveProperty("emoji_picker"); + } finally { + editor._tiptapEditor.destroy(); + } +}); + +it("fills in missing sections without mutating frozen dictionaries", () => { + const { block_nesting: _nesting, ...rest } = en; + const dictionary = Object.freeze(rest); + const editor = BlockNoteEditor.create({ + // @ts-expect-error Simulate a dictionary authored before block_nesting existed. + dictionary, + }); + try { + expect(editor.dictionary.block_nesting).toBe(en.block_nesting); + expect(editor.dictionary.slash_menu).toBe(dictionary.slash_menu); } finally { editor._tiptapEditor.destroy(); } diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index ed2c706783..b6f584399d 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -465,13 +465,19 @@ export class BlockNoteEditor< ) { super(); - this.dictionary = options.dictionary || en; - if (this.dictionary.emoji_picker === undefined) { - this.dictionary.emoji_picker = en.emoji_picker; - } - if (this.dictionary.block_nesting === undefined) { - this.dictionary.block_nesting = en.block_nesting; - } + const dictionary = options.dictionary || en; + // Dictionaries written for older versions may lack newer sections. Fill + // those in from `en` on a copy, since the caller's object may be frozen or + // shared between editors. + this.dictionary = + dictionary.emoji_picker !== undefined && + dictionary.block_nesting !== undefined + ? dictionary + : { + ...dictionary, + emoji_picker: dictionary.emoji_picker ?? en.emoji_picker, + block_nesting: dictionary.block_nesting ?? en.block_nesting, + }; this.settings = { tables: { splitCells: options?.tables?.splitCells ?? false,