From 51824d4e47e1c2e562ef61baaf9fb3fc160711da Mon Sep 17 00:00:00 2001
From: Minwook Shin <163576506+minwookshin@users.noreply.github.com>
Date: Tue, 22 Sep 2026 20:57:32 -0400
Subject: [PATCH 1/3] feat(examples): add keyboard block actions with focus
recovery
---
.../21-keyboard-block-actions/.bnexample.json | 7 +
.../21-keyboard-block-actions/README.md | 26 ++
.../21-keyboard-block-actions/index.html | 17 +
.../21-keyboard-block-actions/main.tsx | 11 +
.../21-keyboard-block-actions/package.json | 31 ++
.../21-keyboard-block-actions/src/App.tsx | 311 ++++++++++++++++++
.../21-keyboard-block-actions/tsconfig.json | 32 ++
.../21-keyboard-block-actions/vite-env.d.ts | 1 +
.../21-keyboard-block-actions/vite.config.ts | 35 ++
playground/src/examples.gen.tsx | 26 ++
pnpm-lock.yaml | 46 +++
.../keyboard-block-actions.test.tsx | 173 ++++++++++
12 files changed, 716 insertions(+)
create mode 100644 examples/03-ui-components/21-keyboard-block-actions/.bnexample.json
create mode 100644 examples/03-ui-components/21-keyboard-block-actions/README.md
create mode 100644 examples/03-ui-components/21-keyboard-block-actions/index.html
create mode 100644 examples/03-ui-components/21-keyboard-block-actions/main.tsx
create mode 100644 examples/03-ui-components/21-keyboard-block-actions/package.json
create mode 100644 examples/03-ui-components/21-keyboard-block-actions/src/App.tsx
create mode 100644 examples/03-ui-components/21-keyboard-block-actions/tsconfig.json
create mode 100644 examples/03-ui-components/21-keyboard-block-actions/vite-env.d.ts
create mode 100644 examples/03-ui-components/21-keyboard-block-actions/vite.config.ts
create mode 100644 tests/src/end-to-end/keyboard-block-actions/keyboard-block-actions.test.tsx
diff --git a/examples/03-ui-components/21-keyboard-block-actions/.bnexample.json b/examples/03-ui-components/21-keyboard-block-actions/.bnexample.json
new file mode 100644
index 0000000000..c5664984d2
--- /dev/null
+++ b/examples/03-ui-components/21-keyboard-block-actions/.bnexample.json
@@ -0,0 +1,7 @@
+{
+ "playground": true,
+ "docs": false,
+ "author": "minwookshin",
+ "tags": ["Intermediate", "Accessibility", "UI Components", "Block Side Menu"],
+ "dependencies": { "react-icons": "^5.5.0" }
+}
diff --git a/examples/03-ui-components/21-keyboard-block-actions/README.md b/examples/03-ui-components/21-keyboard-block-actions/README.md
new file mode 100644
index 0000000000..a04944fea8
--- /dev/null
+++ b/examples/03-ui-components/21-keyboard-block-actions/README.md
@@ -0,0 +1,26 @@
+# Keyboard Block Actions
+
+A runnable interaction proposal for [#2854](https://github.com/TypeCellOS/BlockNote/issues/2854). Place the caret in a block and press **Shift+F10** (or the Context Menu key) to open its actions. Arrow keys move between actions. Escape or Tab closes the menu and returns to the same editor selection; Tab keeps its existing indentation behavior while editing.
+
+This example uses BlockNote's public editor APIs, `BlockPopover`, and the same Mantine menu primitives and `bn-menu-*` classes as the default adapter. It does not change library shortcuts or the hover side menu. The explicit “Block actions” button makes the interaction discoverable without requiring the shortcut.
+
+It reuses an existing Mantine provider when embedded in the playground and supplies one when run independently. It also loads Mantine's core styles for the demo controls outside the editor, matching the playground setup.
+
+The proposal covers one current block, including a nested block and its children. Multi-block selections, IME composition, and read-only editors do not intercept the shortcut. Selecting multiple blocks disables the visible action button and explains that the actions support one block at a time. Dismissing by clicking elsewhere preserves the newly clicked focus target. Deleting a block moves the caret to a surviving neighbor; duplication regenerates IDs for every descendant. Opening or cancelling the menu does not create an undo entry.
+
+The menu intentionally demonstrates three structural actions rather than claiming full parity with the existing drag-handle menu. Color submenus, all three UI adapters, shortcut documentation/localization, and the final library API need maintainer agreement before promoting this example into a default behavior.
+
+**Try it out:**
+
+1. Put the caret in the middle of a sentence. Open the menu and press Escape, then type: the caret should be unchanged.
+2. Open again, use the arrow keys, and duplicate a nested block. Its content and children are copied with fresh IDs.
+3. Delete a block and use the editor's Undo shortcut.
+4. Toggle read-only mode. The action button is disabled and Shift+F10 is left to the browser.
+5. Select text across multiple blocks. The disabled button explains the unsupported selection; collapse the selection to use the actions again.
+
+The browser regressions import this example directly and exercise keyboard focus, exact caret restoration, indentation, nested identities, Undo, unsupported selections, and narrow layout. Run them with the repository's Docker runner: `pnpm e2e keyboard-block-actions --retry=0 --maxWorkers=1`.
+
+**Relevant Docs:**
+
+- [Side Menu](/docs/react/components/side-menu)
+- [Manipulating Content](/docs/reference/editor/manipulating-content)
diff --git a/examples/03-ui-components/21-keyboard-block-actions/index.html b/examples/03-ui-components/21-keyboard-block-actions/index.html
new file mode 100644
index 0000000000..125a30258f
--- /dev/null
+++ b/examples/03-ui-components/21-keyboard-block-actions/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+ Keyboard Block Actions
+
+
+
+
+
+
+
diff --git a/examples/03-ui-components/21-keyboard-block-actions/main.tsx b/examples/03-ui-components/21-keyboard-block-actions/main.tsx
new file mode 100644
index 0000000000..1260513388
--- /dev/null
+++ b/examples/03-ui-components/21-keyboard-block-actions/main.tsx
@@ -0,0 +1,11 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./src/App.jsx";
+
+const root = createRoot(document.getElementById("root")!);
+root.render(
+
+
+ ,
+);
diff --git a/examples/03-ui-components/21-keyboard-block-actions/package.json b/examples/03-ui-components/21-keyboard-block-actions/package.json
new file mode 100644
index 0000000000..3746873093
--- /dev/null
+++ b/examples/03-ui-components/21-keyboard-block-actions/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@blocknote/example-ui-components-keyboard-block-actions",
+ "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "type": "module",
+ "private": true,
+ "version": "0.12.4",
+ "scripts": {
+ "start": "vite",
+ "dev": "vite",
+ "build:prod": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@blocknote/ariakit": "latest",
+ "@blocknote/core": "latest",
+ "@blocknote/mantine": "latest",
+ "@blocknote/react": "latest",
+ "@blocknote/shadcn": "latest",
+ "@mantine/core": "^9.0.2",
+ "@mantine/hooks": "^9.0.2",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3",
+ "react-icons": "^5.5.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.3",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "vite": "^8.0.0"
+ }
+}
diff --git a/examples/03-ui-components/21-keyboard-block-actions/src/App.tsx b/examples/03-ui-components/21-keyboard-block-actions/src/App.tsx
new file mode 100644
index 0000000000..8988c00146
--- /dev/null
+++ b/examples/03-ui-components/21-keyboard-block-actions/src/App.tsx
@@ -0,0 +1,311 @@
+import type { Block, BlockNoteEditor, PartialBlock } from "@blocknote/core";
+import "@mantine/core/styles.css";
+import "@blocknote/core/fonts/inter.css";
+import { BlockNoteView } from "@blocknote/mantine";
+import "@blocknote/mantine/style.css";
+import {
+ BlockPopover,
+ useCreateBlockNote,
+ useEditorState,
+} from "@blocknote/react";
+import {
+ ActionIcon,
+ Button,
+ Group,
+ Kbd,
+ MantineContext,
+ MantineProvider,
+ Menu,
+ Stack,
+ Switch,
+ Text,
+} from "@mantine/core";
+import {
+ type KeyboardEvent,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import { MdDragIndicator } from "react-icons/md";
+
+function copyBlock(block: Block): PartialBlock {
+ return {
+ ...block,
+ id: undefined,
+ children: block.children.map(copyBlock),
+ };
+}
+
+type Action = "add" | "duplicate" | "delete";
+
+function KeyboardBlockMenu({
+ editor,
+ blockId,
+ onClose,
+}: {
+ editor: BlockNoteEditor;
+ blockId: string;
+ onClose: (restoreFocus?: boolean) => void;
+}) {
+ const menuRef = useRef(null);
+
+ // A collaborative edit may remove the context while the menu is open.
+ useEffect(
+ () =>
+ editor.onChange(() => {
+ if (editor.getBlock(blockId)) {
+ return;
+ }
+ const hadFocus = menuRef.current?.contains(
+ menuRef.current.ownerDocument.activeElement,
+ );
+ onClose(hadFocus);
+ }),
+ [editor, blockId, onClose],
+ );
+
+ function closeAndFocusEditor() {
+ onClose(true);
+ }
+
+ function runAction(action: Action) {
+ const block = editor.getBlock(blockId);
+ if (!block || !editor.isEditable) {
+ closeAndFocusEditor();
+ return;
+ }
+ if (action === "delete") {
+ const { nextBlock, prevBlock } = editor.getTextCursorPosition();
+ editor.removeBlocks([block]);
+ const neighbor = nextBlock ?? prevBlock ?? editor.document[0];
+ if (neighbor && editor.getBlock(neighbor.id)) {
+ editor.setTextCursorPosition(neighbor, "start");
+ }
+ } else {
+ const [inserted] = editor.insertBlocks(
+ [action === "duplicate" ? copyBlock(block) : { type: "paragraph" }],
+ block,
+ "after",
+ );
+ editor.setTextCursorPosition(inserted, "start");
+ }
+ closeAndFocusEditor();
+ }
+
+ function handleMenuKeyDown(event: KeyboardEvent) {
+ if (event.key !== "Escape" && event.key !== "Tab") {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ closeAndFocusEditor();
+ }
+
+ return (
+
+
+
+ );
+}
+
+function KeyboardBlockActions() {
+ const [blockId, setBlockId] = useState();
+ const [isReadOnly, setReadOnly] = useState(false);
+ const shouldRestoreFocus = useRef(false);
+ const editor = useCreateBlockNote({
+ initialContent: [
+ {
+ id: "intro",
+ type: "heading",
+ props: { level: 2 },
+ content: "Keyboard block actions",
+ },
+ {
+ id: "paragraph",
+ type: "paragraph",
+ content:
+ "Keep your caret here. Open the menu, press Escape, and continue writing.",
+ },
+ {
+ id: "parent",
+ type: "bulletListItem",
+ content: "A block with a child",
+ children: [
+ {
+ id: "child",
+ type: "bulletListItem",
+ content: "Try the shortcut in this nested block.",
+ },
+ ],
+ },
+ {
+ id: "last",
+ type: "paragraph",
+ content: "Tab still indents. Undo still restores deleted content.",
+ },
+ ],
+ });
+ const hasMultipleBlocks = useEditorState({
+ editor,
+ on: "selection",
+ selector: ({ editor }) => (editor.getSelection()?.blocks.length ?? 0) > 1,
+ });
+
+ // Restore only after the menu's focus trap has unmounted, so it cannot
+ // move focus away from the editor during its own cleanup.
+ useEffect(() => {
+ if (blockId || !shouldRestoreFocus.current) {
+ return;
+ }
+ shouldRestoreFocus.current = false;
+ editor.focus();
+ }, [blockId, editor]);
+
+ function closeMenu(restoreFocus = false) {
+ shouldRestoreFocus.current ||= restoreFocus;
+ setBlockId(undefined);
+ }
+
+ function openMenu() {
+ if (!editor.isEditable || (editor.getSelection()?.blocks.length ?? 0) > 1) {
+ return;
+ }
+ shouldRestoreFocus.current = false;
+ setBlockId(editor.getTextCursorPosition().block.id);
+ }
+
+ function handleEditorKeyDown(event: KeyboardEvent) {
+ const isShortcut =
+ event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey);
+ if (
+ !isShortcut ||
+ event.altKey ||
+ event.ctrlKey ||
+ event.metaKey ||
+ event.nativeEvent.isComposing ||
+ !editor.isEditable ||
+ !(event.target instanceof Node) ||
+ !editor.domElement?.contains(event.target) ||
+ (editor.getSelection()?.blocks.length ?? 0) > 1
+ ) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ openMenu();
+ }
+
+ return (
+
+
+
+ Open block actions with Shift + F10. Escape
+ returns to writing.
+
+ {
+ setBlockId(undefined);
+ setReadOnly(event.currentTarget.checked);
+ }}
+ />
+
+
+
+
+ {hasMultipleBlocks
+ ? "Select a single block to open its actions."
+ : "Actions apply to the block at your caret."}
+
+
+
+ {blockId && (
+
+ )}
+
+
+ );
+}
+
+export default function App() {
+ const mantineContext = useContext(MantineContext);
+ const example = ;
+
+ // The playground supplies this context; a standalone example does not.
+ if (mantineContext) {
+ return example;
+ }
+
+ return {example};
+}
diff --git a/examples/03-ui-components/21-keyboard-block-actions/tsconfig.json b/examples/03-ui-components/21-keyboard-block-actions/tsconfig.json
new file mode 100644
index 0000000000..2aa62c56e6
--- /dev/null
+++ b/examples/03-ui-components/21-keyboard-block-actions/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "composite": true,
+ "paths": {
+ "@shared/*": ["../../../shared/*"]
+ }
+ },
+ "include": ["."],
+ "__ADD_FOR_LOCAL_DEV_references": [
+ {
+ "path": "../../../packages/core/"
+ },
+ {
+ "path": "../../../packages/react/"
+ }
+ ]
+}
diff --git a/examples/03-ui-components/21-keyboard-block-actions/vite-env.d.ts b/examples/03-ui-components/21-keyboard-block-actions/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/examples/03-ui-components/21-keyboard-block-actions/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/03-ui-components/21-keyboard-block-actions/vite.config.ts b/examples/03-ui-components/21-keyboard-block-actions/vite.config.ts
new file mode 100644
index 0000000000..a96f1f04ff
--- /dev/null
+++ b/examples/03-ui-components/21-keyboard-block-actions/vite.config.ts
@@ -0,0 +1,35 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import react from "@vitejs/plugin-react";
+import * as fs from "fs";
+import * as path from "path";
+import { defineConfig } from "vite";
+// https://vitejs.dev/config/
+export default defineConfig(((conf: { command: string }) => ({
+ plugins: [react()],
+ optimizeDeps: {},
+ build: {
+ sourcemap: true,
+ },
+ resolve: {
+ alias:
+ conf.command === "build" ||
+ !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ ? {}
+ : ({
+ // The repo-wide alias for the shared test-utils directory (private,
+ // so it only resolves inside the monorepo). Harmless for examples
+ // that don't use it.
+ "@shared": path.resolve(__dirname, "../../../shared/"),
+ // Comment out the lines below to load a built version of blocknote
+ // or, keep as is to load live from sources with live reload working
+ "@blocknote/core": path.resolve(
+ __dirname,
+ "../../packages/core/src/",
+ ),
+ "@blocknote/react": path.resolve(
+ __dirname,
+ "../../packages/react/src/",
+ ),
+ } as any),
+ },
+})) as Parameters[0]);
diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx
index 25279c4644..f055f8d42f 100644
--- a/playground/src/examples.gen.tsx
+++ b/playground/src/examples.gen.tsx
@@ -891,6 +891,32 @@ export const examples = {
readme:
"By default, BlockNote's floating components (formatting toolbar, slash menu, table handles, etc.) mount next to the editor, inside its `bn-container` (or inside whatever you render `BlockNoteViewEditor` into). The `portalElements` prop on `BlockNoteView` lets you change that: globally via `default`, or per component by key. The menus and popovers a floating component opens follow it wherever it mounts.\n\nThis example renders two editors side-by-side, both wrapped in a small `overflow: hidden` container. The left editor uses the default, so the slash menu is clipped by the editor's bounds. The right editor passes `portalElements={{ default: document.body }}` so the floating components escape the wrapper and render fully.\n\n```tsx\n\n```\n\n**Relevant Docs:**\n\n- [UI Components](/docs/react/components)\n- [Mobile Formatting Toolbar](/docs/react/components/formatting-toolbar#mobile-formatting-toolbar)",
},
+ {
+ projectSlug: "keyboard-block-actions",
+ fullSlug: "ui-components/keyboard-block-actions",
+ pathFromRoot: "examples/03-ui-components/21-keyboard-block-actions",
+ config: {
+ playground: true,
+ docs: false,
+ author: "minwookshin",
+ tags: [
+ "Intermediate",
+ "Accessibility",
+ "UI Components",
+ "Block Side Menu",
+ ],
+ dependencies: {
+ "react-icons": "^5.5.0",
+ } as any,
+ },
+ title: "Keyboard Block Actions",
+ group: {
+ pathFromRoot: "examples/03-ui-components",
+ slug: "ui-components",
+ },
+ readme:
+ "A runnable interaction proposal for [#2854](https://github.com/TypeCellOS/BlockNote/issues/2854). Place the caret in a block and press **Shift+F10** (or the Context Menu key) to open its actions. Arrow keys move between actions. Escape or Tab closes the menu and returns to the same editor selection; Tab keeps its existing indentation behavior while editing.\n\nThis example uses BlockNote's public editor APIs, `BlockPopover`, and the same Mantine menu primitives and `bn-menu-*` classes as the default adapter. It does not change library shortcuts or the hover side menu. The explicit “Block actions” button makes the interaction discoverable without requiring the shortcut.\n\nIt reuses an existing Mantine provider when embedded in the playground and supplies one when run independently.\n\nThe proposal covers one current block, including a nested block and its children. Multi-block selections, IME composition, and read-only editors do not intercept the shortcut. Selecting multiple blocks disables the visible action button and explains that the actions support one block at a time. Dismissing by clicking elsewhere preserves the newly clicked focus target. Deleting a block moves the caret to a surviving neighbor; duplication regenerates IDs for every descendant. Opening or cancelling the menu does not create an undo entry.\n\nThe menu intentionally demonstrates three structural actions rather than claiming full parity with the existing drag-handle menu. Color submenus, all three UI adapters, shortcut documentation/localization, and the final library API need maintainer agreement before promoting this example into a default behavior.\n\n**Try it out:**\n\n1. Put the caret in the middle of a sentence. Open the menu and press Escape, then type: the caret should be unchanged.\n2. Open again, use the arrow keys, and duplicate a nested block. Its content and children are copied with fresh IDs.\n3. Delete a block and use the editor's Undo shortcut.\n4. Toggle read-only mode. The action button is disabled and Shift+F10 is left to the browser.\n5. Select text across multiple blocks. The disabled button explains the unsupported selection; collapse the selection to use the actions again.\n\nThe browser regressions import this example directly and exercise keyboard focus, exact caret restoration, indentation, nested identities, Undo, unsupported selections, and narrow layout. Run them with the repository's Docker runner: `pnpm e2e keyboard-block-actions --retry=0 --maxWorkers=1`.\n\n**Relevant Docs:**\n\n- [Side Menu](/docs/react/components/side-menu)\n- [Manipulating Content](/docs/reference/editor/manipulating-content)",
+ },
],
},
theming: {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e5f25fe913..1972af11c7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2292,6 +2292,52 @@ importers:
specifier: ^8.0.0
version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0)
+ examples/03-ui-components/21-keyboard-block-actions:
+ dependencies:
+ '@blocknote/ariakit':
+ specifier: latest
+ version: link:../../../packages/ariakit
+ '@blocknote/core':
+ specifier: latest
+ version: link:../../../packages/core
+ '@blocknote/mantine':
+ specifier: latest
+ version: link:../../../packages/mantine
+ '@blocknote/react':
+ specifier: latest
+ version: link:../../../packages/react
+ '@blocknote/shadcn':
+ specifier: latest
+ version: link:../../../packages/shadcn
+ '@mantine/core':
+ specifier: ^9.0.2
+ version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@mantine/hooks':
+ specifier: ^9.0.2
+ version: 9.1.1(react@19.2.5)
+ react:
+ specifier: ^19.2.3
+ version: 19.2.5
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.5(react@19.2.5)
+ react-icons:
+ specifier: ^5.5.0
+ version: 5.6.0(react@19.2.5)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.3
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0))
+ vite:
+ specifier: ^8.0.0
+ version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0)
+
examples/04-theming/01-theming-dom-attributes:
dependencies:
'@blocknote/ariakit':
diff --git a/tests/src/end-to-end/keyboard-block-actions/keyboard-block-actions.test.tsx b/tests/src/end-to-end/keyboard-block-actions/keyboard-block-actions.test.tsx
new file mode 100644
index 0000000000..58171cd87e
--- /dev/null
+++ b/tests/src/end-to-end/keyboard-block-actions/keyboard-block-actions.test.tsx
@@ -0,0 +1,173 @@
+import App from "@examples/03-ui-components/21-keyboard-block-actions/src/App";
+import { beforeEach, describe, expect, test } from "vite-plus/test";
+import { render } from "vitest-browser-react";
+import { MOD, page, userEvent } from "../../utils/context.js";
+import { waitForSelector } from "../../utils/editor.js";
+
+const blockSelector = '[data-node-type="blockContainer"][data-id]';
+
+async function focusBlock(id: string) {
+ await userEvent.click(
+ await waitForSelector(`[data-id="${id}"] .bn-inline-content`),
+ );
+ await userEvent.keyboard("{Home}{ArrowRight}{ArrowRight}");
+ await expect.element(page.getByRole("textbox")).toHaveFocus();
+ await expect
+ .poll(() => {
+ const anchor = window.getSelection()?.anchorNode;
+ const element =
+ anchor instanceof Element ? anchor : anchor?.parentElement;
+ return element?.closest(blockSelector)?.getAttribute("data-id");
+ })
+ .toBe(id);
+}
+
+async function openActions() {
+ await userEvent.keyboard("{Shift>}{F10}{/Shift}");
+ await expect.element(page.getByRole("menu")).toBeVisible();
+}
+
+beforeEach(async () => {
+ await page.viewport(1280, 720);
+ // Render the portable example directly, without a playground provider.
+ await render();
+ await waitForSelector(".bn-editor");
+});
+
+describe("Keyboard block actions example", () => {
+ test("opens on the first action and restores the exact caret after Escape or Tab", async () => {
+ await focusBlock("paragraph");
+ const selection = window.getSelection();
+ const anchorNode = selection?.anchorNode;
+ const anchorOffset = selection?.anchorOffset;
+ expect(anchorNode).toBeTruthy();
+
+ for (const key of ["{Escape}", "{Tab}"]) {
+ await openActions();
+ await expect
+ .element(page.getByRole("menuitem", { name: "Add paragraph below" }))
+ .toHaveFocus();
+ await userEvent.keyboard(key);
+ await expect.element(page.getByRole("menu")).not.toBeInTheDocument();
+ await expect
+ .poll(() => document.activeElement?.classList.contains("bn-editor"))
+ .toBe(true);
+ expect(window.getSelection()?.anchorNode).toBe(anchorNode);
+ expect(window.getSelection()?.anchorOffset).toBe(anchorOffset);
+ }
+ });
+
+ test("preserves the editor's Tab indentation shortcut", async () => {
+ await focusBlock("last");
+ await userEvent.keyboard("{Tab}");
+ await expect
+ .poll(() =>
+ document
+ .querySelector('[data-id="last"]')
+ ?.parentElement?.closest(blockSelector)
+ ?.getAttribute("data-id"),
+ )
+ .toBe("parent");
+ await expect.element(page.getByRole("menu")).not.toBeInTheDocument();
+ await userEvent.keyboard("{Shift>}{Tab}{/Shift}");
+ await expect
+ .poll(() =>
+ document
+ .querySelector('[data-id="last"]')
+ ?.parentElement?.closest(blockSelector),
+ )
+ .toBeNull();
+ });
+
+ test("duplicates a nested block with unique parent and descendant IDs", async () => {
+ await focusBlock("parent");
+ const originalIds = Array.from(
+ document.querySelectorAll(blockSelector),
+ (block) => block.getAttribute("data-id"),
+ );
+ await openActions();
+ await userEvent.keyboard("{ArrowDown}{Enter}");
+ await expect.element(page.getByRole("menu")).not.toBeInTheDocument();
+ await expect
+ .poll(() => document.querySelectorAll(blockSelector).length)
+ .toBe(originalIds.length + 2);
+ const blocks = Array.from(document.querySelectorAll(blockSelector));
+ const ids = blocks.map((block) => block.getAttribute("data-id"));
+ expect(new Set(ids).size).toBe(ids.length);
+ const copiedParent = blocks.find(
+ (block) =>
+ !originalIds.includes(block.getAttribute("data-id")) &&
+ block.querySelector(".bn-inline-content")?.textContent ===
+ "A block with a child",
+ );
+ expect(copiedParent).toBeTruthy();
+ expect(copiedParent?.querySelector(blockSelector)?.textContent).toContain(
+ "Try the shortcut in this nested block.",
+ );
+ });
+
+ test("deletes the current block and restores its content with native undo", async () => {
+ await focusBlock("paragraph");
+ await openActions();
+ await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}");
+ await expect
+ .poll(() => document.querySelector('[data-id="paragraph"]'))
+ .toBeNull();
+ await expect
+ .poll(() => document.activeElement?.classList.contains("bn-editor"))
+ .toBe(true);
+ await userEvent.keyboard(`{${MOD}>}z{/${MOD}}`);
+ const restored = await waitForSelector('[data-id="paragraph"]');
+ expect(restored.textContent).toContain("Keep your caret here.");
+ });
+
+ test("explains unsupported selections and disables read-only actions", async () => {
+ await focusBlock("paragraph");
+ // Extend a native range across blocks without OS-specific Select All behavior.
+ await userEvent.keyboard("{Shift>}{ArrowUp}{/Shift}");
+ await expect
+ .element(page.getByRole("button", { name: "Block actions", exact: true }))
+ .toBeDisabled();
+ await expect
+ .element(page.getByRole("status"))
+ .toHaveTextContent("Select a single block");
+ await userEvent.keyboard("{ArrowRight}");
+ await expect
+ .element(page.getByRole("button", { name: "Block actions", exact: true }))
+ .toBeEnabled();
+ await userEvent.click(page.getByLabelText("Read-only"));
+ await expect
+ .element(page.getByRole("button", { name: "Block actions", exact: true }))
+ .toBeDisabled();
+ await expect
+ .poll(() =>
+ document.querySelector(".bn-editor")?.getAttribute("contenteditable"),
+ )
+ .toBe("false");
+ await userEvent.keyboard("{Shift>}{F10}{/Shift}");
+ await expect.element(page.getByRole("menu")).not.toBeInTheDocument();
+ });
+
+ test("keeps the keyboard menu inside a narrow viewport", async () => {
+ await page.viewport(390, 844);
+ await focusBlock("paragraph");
+ await openActions();
+ const menu = await waitForSelector('[role="menu"]');
+ await expect
+ .poll(() => menu.getBoundingClientRect().left)
+ .toBeGreaterThanOrEqual(0);
+ await expect
+ .poll(() => menu.getBoundingClientRect().right)
+ .toBeLessThanOrEqual(window.innerWidth);
+ await expect
+ .poll(() => menu.getBoundingClientRect().top)
+ .toBeGreaterThanOrEqual(0);
+ await expect
+ .poll(() => menu.getBoundingClientRect().bottom)
+ .toBeLessThanOrEqual(window.innerHeight);
+ expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(
+ window.innerWidth,
+ );
+ await userEvent.keyboard("{Escape}");
+ });
+});
From c0db67860fa0ae52760d84474e3c92e5ac4927a9 Mon Sep 17 00:00:00 2001
From: Minwook Shin <163576506+minwookshin@users.noreply.github.com>
Date: Thu, 24 Sep 2026 16:49:05 -0400
Subject: [PATCH 2/3] fix(examples): expose keyboard menu state and HTML
doctype
---
.../21-keyboard-block-actions/index.html | 1 +
.../21-keyboard-block-actions/src/App.tsx | 4 ++
.../template-react/index.html.template.tsx | 44 ++++++++++---------
.../keyboard-block-actions.test.tsx | 23 ++++++++++
4 files changed, 51 insertions(+), 21 deletions(-)
diff --git a/examples/03-ui-components/21-keyboard-block-actions/index.html b/examples/03-ui-components/21-keyboard-block-actions/index.html
index 125a30258f..af0d025e21 100644
--- a/examples/03-ui-components/21-keyboard-block-actions/index.html
+++ b/examples/03-ui-components/21-keyboard-block-actions/index.html
@@ -1,3 +1,4 @@
+
diff --git a/examples/03-ui-components/21-keyboard-block-actions/src/App.tsx b/examples/03-ui-components/21-keyboard-block-actions/src/App.tsx
index 8988c00146..443579d22c 100644
--- a/examples/03-ui-components/21-keyboard-block-actions/src/App.tsx
+++ b/examples/03-ui-components/21-keyboard-block-actions/src/App.tsx
@@ -130,6 +130,7 @@ function KeyboardBlockMenu({