Skip to content

feat(builder): add a floating toolbar to the selected block - #1025

Merged
mobeenabdullah merged 1 commit into
mainfrom
feat/builder-block-toolbar
Aug 18, 2026
Merged

mobeenabdullah merged 1 commit into
mainfrom
feat/builder-block-toolbar

Conversation

@mobeenabdullah

Copy link
Copy Markdown
Collaborator

Completes B-16. The breadcrumb half shipped in #1013; this is the toolbar half.

Why

Select parent, move up, move down, duplicate and delete all existed already — and every one of them was reachable only by keystroke. An author who has not read a shortcut list has no way to discover that this editor can duplicate a block at all, so for most of the people using it those verbs were effectively absent.

The design decisions worth arguing with

It presses the same verbs the keys press. BlockKeyboardActions now publishes them to whatever it wraps, and the toolbar consumes them. Nothing in the toolbar applies an op. A bar with its own ops would be a second answer to "what does duplicate do", and the two would drift — the author meets that as a button and a keystroke that disagree, long after either was written. It is also why the announcements are right without the toolbar containing one: the verbs announce into the single live region that component already owns.

Unavailable actions stay in place, and stay focusable. aria-disabled, never the disabled attribute. A disabled button leaves the tab sequence, and the reason it is disabled — "Caption inside this block is locked" — is precisely what a keyboard or screen-reader author needs and would then never receive. Hiding them instead would change the bar's width and order as the selection moves, so the control an author is aiming at has moved by the time they arrive.

A dimmed press is passed on rather than swallowed. This started as a guard in the toolbar. Stub-verification showed removing the guard moved no test result — every verb already refuses what it cannot do — so the guard was dead code that could only ever disagree with the rule beside it. Removing it is also strictly better behaviour: a lock refusal announces, so a dimmed Delete pressed by a keyboard author now says "Caption is locked. Unlock it to delete it." instead of nothing.

A lock stops a move and a delete but not a duplicate, and a lock inside a block stops its delete without stopping its move. Both come from lockBlockingMove / lockBlockingDelete rather than from reading node.locked, and the second case is the one that can tell the two apart.

The insertion point is a line, the toolbar is a rectangle — but both live in canvas content coordinates. Scrolling carries the bar with its block at no cost; there is no scroll listener and no frame on which the two disagree.

One change outside the toolbar

canvas.tsx learns that editor chrome drawn over the page is not the page (data-nx-chrome). Without it, a press on any overlay control resolves to "no node", which the canvas reads as a background click and uses to clear the very selection that control acts on — the toolbar would run its action and then watch itself disappear. An attribute rather than each overlay calling stopPropagation, because the rule belongs to what chrome is, and the version where every new overlay has to remember it is the version where the next one does not.

Accessibility

  • WAI-ARIA toolbar pattern: role="toolbar", one tab stop, arrows within, Home/End, wrapping.
  • SC 2.5.7 and 2.1.1 unaffected — the keyboard path is unchanged and is what this presses.
  • SC 2.5.8: 28px targets, over the 24px floor.
  • Every colour is a --nx-builder-* token aliasing the vetted admin palette. Zero literals, zero opacity-modified colours — packages/builder/src is outside lint-design.mjs's roots and outside the contrast suite, so nothing would have caught a bad one.

Verified

In a browser (playground, :3123, auth control asserted first — h1 reads "Welcome, Dev"):

  • Duplicate creates the copy, selection follows it, Name reads "Kid copy", and the selection is not cleared.
  • Action states update live: after duplicating, Move up flips to enabled and Move down to disabled; after Move up, they swap back.
  • Locking flips Move down and Delete to disabled while Duplicate stays enabled.
  • Pressing the dimmed Delete announces "Kid copy is locked. Unlock it to delete it." and deletes nothing.
  • Select parent moves to the Section and then correctly disables itself at top level.
  • Roving tabindex: arrows move, End jumps, wrapping works, exactly one tab stop in the bar.
  • Background click clears the selection and the bar disappears; clicking a block brings both back.
  • The bar hides for the duration of a drag, the drop indicator shows, and the bar returns after.

In tests: 682 in packages/builder (up from 652), 61 in plugin-page-builder, types and lint clean on both. Every new test stub-verified in both directions.

Known, not fixed here

For a block near the canvas top the bar flips below it and therefore overlaps the following block. That is the standard trade for this pattern and the flip is the correct half of it — clamping to the canvas top instead would draw the bar on the selected block.

A pre-existing console error in the autosave-recovery lane (["autosave-recovery","collection","pages",…] returning undefined) is visible in the editor and is untouched by this change.

The structural verbs existed only as keystrokes, so an author who had not
read a shortcut list had no way to discover that the editor could duplicate
a block at all. The bar puts select-parent, move up, move down, duplicate
and delete at the block they act on.

It presses the same verbs the keys press. BlockKeyboardActions now publishes
them to what it wraps, so a button and a keystroke cannot drift apart and
both announce into the one live region.

Unavailable actions stay in place and stay focusable through aria-disabled,
because the reason they are unavailable is exactly what a keyboard author
needs and 'disabled' would take it out of the tab sequence with them.

The canvas learns that chrome drawn over the page is not the page: without
it a press on any overlay control resolves to 'no node' and clears the very
selection that control acts on.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex please review this PR

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@mobeenabdullah, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

Limit details: You’ve used all 1 included review currently available under your plan.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dd7e2e36-eead-4904-826b-88d381dc9250

📥 Commits

Reviewing files that changed from the base of the PR and between 5557e49 and 17c181a.

⛔ Files ignored due to path filters (1)
  • .changeset/builder-block-toolbar.md is excluded by !.changeset/**
📒 Files selected for processing (10)
  • packages/builder/src/block-toolbar.test.tsx
  • packages/builder/src/block-toolbar.tsx
  • packages/builder/src/canvas.tsx
  • packages/builder/src/index.ts
  • packages/builder/src/keyboard-actions.tsx
  • packages/builder/src/shell.ts
  • packages/builder/src/styles/builder-chrome.css
  • packages/builder/src/toolbar-actions.test.ts
  • packages/builder/src/toolbar-actions.ts
  • packages/plugin-page-builder/src/admin/BlocksField.tsx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Aug 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

@nextlyhq/adapter-drizzle

npm i https://pkg.pr.new/@nextlyhq/adapter-drizzle@17c181a

@nextlyhq/adapter-mysql

npm i https://pkg.pr.new/@nextlyhq/adapter-mysql@17c181a

@nextlyhq/adapter-postgres

npm i https://pkg.pr.new/@nextlyhq/adapter-postgres@17c181a

@nextlyhq/adapter-sqlite

npm i https://pkg.pr.new/@nextlyhq/adapter-sqlite@17c181a

@nextlyhq/admin

npm i https://pkg.pr.new/@nextlyhq/admin@17c181a

@nextlyhq/admin-css

npm i https://pkg.pr.new/@nextlyhq/admin-css@17c181a

@nextlyhq/blocks-engine

npm i https://pkg.pr.new/@nextlyhq/blocks-engine@17c181a

@nextlyhq/blocks-react

npm i https://pkg.pr.new/@nextlyhq/blocks-react@17c181a

@nextlyhq/builder

npm i https://pkg.pr.new/@nextlyhq/builder@17c181a

create-nextly-app

npm i https://pkg.pr.new/create-nextly-app@17c181a

@nextlyhq/eslint-plugin

npm i https://pkg.pr.new/@nextlyhq/eslint-plugin@17c181a

nextly

npm i https://pkg.pr.new/nextly@17c181a

@nextlyhq/plugin-form-builder

npm i https://pkg.pr.new/@nextlyhq/plugin-form-builder@17c181a

@nextlyhq/plugin-page-builder

npm i https://pkg.pr.new/@nextlyhq/plugin-page-builder@17c181a

@nextlyhq/plugin-sdk

npm i https://pkg.pr.new/@nextlyhq/plugin-sdk@17c181a

@nextlyhq/plugin-seo

npm i https://pkg.pr.new/@nextlyhq/plugin-seo@17c181a

@nextlyhq/storage-s3

npm i https://pkg.pr.new/@nextlyhq/storage-s3@17c181a

@nextlyhq/storage-uploadthing

npm i https://pkg.pr.new/@nextlyhq/storage-uploadthing@17c181a

@nextlyhq/storage-vercel-blob

npm i https://pkg.pr.new/@nextlyhq/storage-vercel-blob@17c181a

@nextlyhq/ui

npm i https://pkg.pr.new/@nextlyhq/ui@17c181a

commit: 17c181a

@mobeenabdullah
mobeenabdullah merged commit d1a86b6 into main Aug 18, 2026
13 checks passed
@github-actions github-actions Bot added type: docs Documentation only scope: plugin @nextlyhq/plugin-* packages labels Aug 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Fallow audit report

Found 40 findings.

Details
Severity Rule Location Description
major fallow/unused-dependency apps/playground/package.json:24 Package '@nextlyhq/adapter-drizzle' is in dependencies but never imported; imported in other workspaces: packages/adapter-mysql, packages/adapter-postgres, packages/adapter-sqlite, packages/nextly
major fallow/unused-dependency apps/playground/package.json:25 Package '@nextlyhq/adapter-mysql' is in dependencies but never imported; imported in other workspaces: packages/nextly
major fallow/unused-dependency apps/playground/package.json:26 Package '@nextlyhq/adapter-postgres' is in dependencies but never imported; imported in other workspaces: packages/nextly
major fallow/unused-dependency apps/playground/package.json:27 Package '@nextlyhq/adapter-sqlite' is in dependencies but never imported; imported in other workspaces: packages/nextly
major fallow/unused-dependency apps/playground/package.json:42 Package 'sharp' is in dependencies but never imported; imported in other workspaces: packages/nextly
minor fallow/unused-dev-dependency apps/playground/package.json:45 Package '@eslint/eslintrc' is in devDependencies but never imported
minor fallow/unused-dev-dependency apps/playground/package.json:54 Package 'better-sqlite3' is in devDependencies but never imported; imported in other workspaces: packages/adapter-sqlite, packages/nextly
minor fallow/unused-dev-dependency apps/playground/package.json:55 Package 'culori' is in devDependencies but never imported; imported in other workspaces: packages/ui
minor fallow/unused-dev-dependency e2e/package.json:17 Package '@nextlyhq/adapter-drizzle' is in devDependencies but never imported; imported in other workspaces: packages/adapter-mysql, packages/adapter-postgres, packages/adapter-sqlite, packages/nextly
minor fallow/unused-dev-dependency e2e/package.json:18 Package '@nextlyhq/adapter-sqlite' is in devDependencies but never imported; imported in other workspaces: packages/nextly
minor fallow/unused-dev-dependency e2e/package.json:22 Package '@nextlyhq/plugin-form-builder' is in devDependencies but never imported; imported in other workspaces: apps/playground
minor fallow/unused-dev-dependency e2e/package.json:23 Package '@nextlyhq/plugin-page-builder' is in devDependencies but never imported; imported in other workspaces: apps/playground
minor fallow/dev-dependency-in-production package.json:92 devDependency '@manypkg/get-packages' is imported by production code at runtime (consider moving to dependencies)
minor fallow/dev-dependency-in-production package.json:104 devDependency 'micromatch' is imported by production code at runtime (consider moving to dependencies)
minor fallow/unused-dev-dependency package.json:106 Package 'publint' is in devDependencies but never imported
major fallow/unused-dependency packages/admin/package.json:110 Package '@lexical/history' is in dependencies but never imported
major fallow/unused-dependency packages/admin/package.json:119 Package '@radix-ui/react-accordion' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:120 Package '@radix-ui/react-alert-dialog' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:121 Package '@radix-ui/react-avatar' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:122 Package '@radix-ui/react-checkbox' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:123 Package '@radix-ui/react-collapsible' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:124 Package '@radix-ui/react-dialog' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:125 Package '@radix-ui/react-dropdown-menu' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:127 Package '@radix-ui/react-popover' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:128 Package '@radix-ui/react-radio-group' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:129 Package '@radix-ui/react-select' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:130 Package '@radix-ui/react-separator' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:132 Package '@radix-ui/react-switch' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:133 Package '@radix-ui/react-tabs' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:134 Package '@radix-ui/react-tooltip' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:140 Package 'cmdk' is in dependencies but never imported; imported in other workspaces: packages/ui
major fallow/unused-dependency packages/admin/package.json:149 Package 'tailwind-variants' is in dependencies but never imported
major fallow/unused-dependency packages/admin/package.json:150 Package 'tailwindcss-animate' is in dependencies but never imported
minor fallow/unused-dev-dependency packages/builder/package.json:68 Package '@nextlyhq/plugin-sdk' is in devDependencies but never imported; imported in other workspaces: apps/playground, packages/plugin-form-builder, packages/plugin-page-builder, packages/plugin-sdk, packages/plugin-seo
major fallow/unused-dependency packages/plugin-page-builder/package.json:51 Package '@dnd-kit/dom' is in dependencies but never imported
major fallow/unused-dependency packages/plugin-page-builder/package.json:52 Package '@dnd-kit/react' is in dependencies but never imported
major fallow/unused-dependency packages/plugin-page-builder/package.json:56 Package 'isomorphic-dompurify' is in dependencies but never imported; imported in other workspaces: packages/nextly
minor fallow/unused-dev-dependency packages/plugin-page-builder/package.json:63 Package '@tanstack/react-query' is in devDependencies but never imported; imported in other workspaces: packages/admin, packages/plugin-form-builder
minor fallow/unused-dev-dependency packages/plugin-page-builder/package.json:72 Package '@dnd-kit/abstract' is in devDependencies but never imported
major fallow/unused-dependency packages/telemetry/package.json:32 Package 'env-paths' is in dependencies but never imported

Generated by fallow.

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex please review this PR

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex please review this PR

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: plugin @nextlyhq/plugin-* packages type: docs Documentation only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant