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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion packages/core/src/editor/BlockNoteEditor.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
17 changes: 13 additions & 4 deletions packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,10 +465,19 @@ export class BlockNoteEditor<
) {
super();

this.dictionary = options.dictionary || en;
if (this.dictionary.emoji_picker === undefined) {
this.dictionary.emoji_picker = en.emoji_picker;
}
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
HistoryExtension,
InlineContentBoundaryEditExtension,
LinkToolbarExtension,
LiveAnnouncerExtension,
LoadingExtension,
NodeSelectionKeyboardExtension,
PlaceholderExtension,
Expand Down Expand Up @@ -175,6 +176,7 @@ export function getDefaultExtensions(
: {}),
}),
LinkToolbarExtension(options),
LiveAnnouncerExtension(),
LoadingExtension(),
NodeSelectionKeyboardExtension(),
PlaceholderExtension(options),
Expand Down
62 changes: 62 additions & 0 deletions packages/core/src/extensions/LiveAnnouncer/LiveAnnouncer.ts
Original file line number Diff line number Diff line change
@@ -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));
},
};
});
1 change: 1 addition & 0 deletions packages/core/src/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
BlockNoteEditor,
type BlockNoteEditorOptions,
} from "../../../editor/BlockNoteEditor.js";
import { en, fr } from "../../../i18n/locales/index.js";
import { createBlockSpec } from "../../../schema/index.js";

// Blocks configured with the deprecated `meta.hardBreakShortcut`, which still
Expand Down Expand Up @@ -924,6 +925,139 @@ 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,
options: Partial<BlockNoteEditorOptions<any, any, any>> = {},
) {
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" },
],
...options,
});
editor.mount(mountPoint);
editor.setTextCursorPosition(cursorId, "start");
return { editor, wrapper };
}

function liveRegion(wrapper: HTMLElement) {
return wrapper.querySelector<HTMLElement>("[aria-live]");
}

function pressTab(editor: BlockNoteEditor<any, any, any>, 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("announces deeper levels", () => {
const { editor, wrapper } = createMountedEditor("c", {
initialContent: [
{
id: "a",
type: "paragraph",
content: "Parent",
children: [
{ id: "b", type: "paragraph", content: "Child" },
{ id: "c", type: "paragraph", content: "Grandchild" },
],
},
],
});

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");

editor._tiptapEditor.destroy();
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();

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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const level = nestingLevel(editor.prosemirrorState.selection.$from);
editor
.getExtension(LiveAnnouncerExtension)
?.announce(
shift
? block_nesting.outdented(level)
: block_nesting.indented(level),
);
}
return moved;
}

return {
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/i18n/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "موافق",
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/i18n/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/i18n/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/i18n/locales/fa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "تأیید",
Expand Down
Loading