Skip to content

refactor(react): one vocabulary and simpler plumbing for portal elements - #3052

Merged
matthewlipski merged 79 commits into
mainfrom
portals-cleanup-v2
Sep 22, 2026
Merged

matthewlipski merged 79 commits into
mainfrom
portals-cleanup-v2

Conversation

@YousefED

@YousefED YousefED commented Sep 4, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The portal-element layer of the mobile stack: how BlockNote's floating UI (toolbars, menus, popovers, forms) decides where in the DOM it renders. It reworks the portal consolidation from #3046 and #3054 (both now superseded by this PR, with their commits in its history), then fixes what the consolidation broke on desktop.

Stacked on mobile-toolbar-demo (#2939); the layers above are #3028 → #3031 (stack #3056). Only this layer's own commits are the diff.

What ships

One vocabulary, two concepts. portalElement everywhere: PortalElement, portalElements, resolvePortalElement, usePortalElement, PortalElementOverride, editor.registerPortalElement / unregisterPortalElement, and the portalElement prop on every adapter popover, menu and select (was portalRoot). The themed root (.bn-root) is a single function, applyThemedRoot(element), on BlockNoteViewContext.

Where floating UI renders, top to bottom:

  1. The default is the element wrapping the editor element (bn-container in the default layout; the element you render BlockNoteViewEditor into under renderEditor={false}). This is what main did. An earlier revision of this PR used the bn-container instead; the docs demo showed why that is wrong: with the editor in a scrolling pane next to a sidebar, the table extend button escaped the pane and painted over the sidebar while the table stayed clipped. Floating UI clips and scrolls with the editor; escaping is what portalElements is for.
  2. portalElements on BlockNoteView (global default or per component) and a controller's portalElement prop redirect a floating component elsewhere, via PortalElementOverride, which mounts a themed root inside the target and registers it with the editor so focus inside it still counts as focus within the editor.
  3. The menus and popovers a floating component opens render inside that component's wrapper, next to the component, through a zero-size PortalElementAnchor. They share the wrapper's stacking context and visibility (the ariakit colors submenu paints above the drag handle without a z-index override; ariakit and shadcn dropdowns hide with their toolbar instead of staying orphaned when it scrolls away), they follow the wrapper under portalElements, and for the mobile toolbar they sit outside its scroll strip, which iOS WebKit would otherwise not paint. Adapters keep a required portalElement prop.

Desktop fallout of portalling, fixed here. Portalling menus out of the toolbar, side menu and table handle broke three things: CSS scoped on the opener (.bn-toolbar .mantine-Menu-item, .bn-side-menu .mantine-Menu-dropdown, …) stopped applying, so menu items grew and the drag-handle menu lost its min-width; the e2e drag-handle selector assumed nesting; and Mantine's useFocusTrap on the toolbar, armed once focus was within it, pulled focus back into the toolbar a tick after a form opened, so the link form's URL field lost focus (always for the link toolbar's Edit button, and for 0 ms synthetic clicks in e2e). Styles are rescoped on the dropdowns' own classes, the selector matches the menu by class, and the trap is gone: Tab moves through the buttons and on, as in the other skins. The color menu's deferred editor.focus() existed only to work around the trap.

GenericPopover's closing snapshot ignores the anchor's holder, otherwise a popover whose children are already gone would snapshot an empty wrapper and vanish instead of fading out.

Behaviour changes (release notes)

  • editor.mount(element, { portalTarget }) loses its options argument; call editor.registerPortalElement(el) for UI rendered outside the editor's DOM. useEditorDOMElement throws without an editor. PortalElement no longer admits null. portalRoot → portalElement across the adapters; portalElement is required on adapter Popover, Menu and ToolbarSelect. All introduced after v0.54.0, so pre-release surface.
  • The Mantine toolbar no longer traps Tab.
  • Menus, popovers and forms opened from a floating component are DOM descendants of that component's wrapper, not of the editor container. Selectors or styles that assumed either the old inline nesting or the container need updating.

Testing

  • tests/src/end-to-end/portals/portalElements.test.tsx: default target, external targets with themed roots, per-element selectors, document.body, and the renderEditor={false} layout (red on the container default).
  • tests/src/end-to-end/portals/floatingComponentMenus.test.tsx, per skin: menus render next to the component inside its wrapper, hide with it, follow it under portalElements; the component still fades out with its content. Red without the anchor on all skins for the first, on ariakit and shadcn for the second.
  • tests/src/end-to-end/linktoolbar/linkToolbar.test.tsx: 0 ms clicks on the link button and the link toolbar's Edit keep the URL field focused (red with the trap).
  • Render profile pinned in packages/mantine/src/BlockNoteView.browser.test.tsx (portal setups cost the same commits as the default).
  • Full desktop chromium suite green in Docker; new files green on Chromium, Firefox and WebKit; the android instance on the stack top shows only its known pre-existing ariakit skinParity red.

Known follow-ups (not in this PR)

  • Roving focus (arrow keys) for the Mantine and shadcn toolbars, matching ariakit.
  • Ariakit on the mobile toolbar: tapping the link button opens the popover but focus stays on the toolbar button and the URL input never receives it (the pre-existing skinParity red on the android instance). Neither keeping editor focus on touch in the ariakit ToolbarButton nor turning Ariakit's autofocus off on the popover fixes it; the composite toolbar's own focus handling is the likely cause.
  • Clicking a thread mark during the comment composer's fade-out does not select the thread (pre-existing).

Changes since the first review round

  • Nested views resolve their own portal element. A BlockNoteView rendered inside another view's floating UI (the comments composer, an editor in a custom block's popover) inherited the outer view's anchor or override through context; that element is registered with the outer editor only, so the nested editor's own menus and popovers counted as outside it for isWithinEditor and the focus tracking built on it. BlockNoteViewContainer now wraps its content in an internal PortalElementReset. Pinned in portalElements.test.tsx (red without the wrapper).
  • preventFocusOnOpen contract written out once on ToolbarSelect in ComponentsContext, the Menu type points to it: the UI library must not move focus into the surface when it opens; an input inside that asks for focus itself still gets it. (The popover no longer takes the prop at all, see fix(ui): commit popover forms through submit, not a key handler #3030.)
  • Link toolbar tests per skin. linkToolbar.test.tsx now runs the create and edit flows for mantine, ariakit and shadcn. Reason: the ariakit create flow was broken on the stack while CI stayed green, because the skin screenshot tests use the global 2% pixel tolerance and the link toolbar is about 1% of the frame, so "no link, no toolbar" compared equal to the baseline. The ariakit, shadcn and theming screenshot tests now wait for .bn-link-toolbar before comparing.
  • The insertion-effect change to PortalElementOverride that an earlier revision carried here moved up to fix(ui): commit popover forms through submit, not a key handler #3030, where it is red-first (the ariakit mobile link flow); on this layer alone only its own pin test could see it.

The "Known follow-ups" above are outdated on one point: the ariakit mobile link flow is fixed in #3030.

Summary by CodeRabbit

  • New Features

    • Floating menus, popovers, toolbars, and suggestions now render in configurable, themed portal elements.
    • Portal targets can be customized per component, including externally rendered editor containers.
    • Mobile controls preserve editor focus and keep the on-screen keyboard visible when opening menus.
    • Floating components follow their wrappers and avoid overflow clipping.
  • Bug Fixes

    • Improved floating UI visibility, positioning, theming, and focus behavior.
  • Documentation

    • Updated portal configuration guidance and examples.
  • Tests

    • Added coverage for portal placement, theming, scrolling, focus, and menu behavior.

matthewlipski and others added 30 commits August 3, 2026 18:58
- Made mobile toolbar no longer experimental & part of default UI
- Updated example
- Removed hover styles for mobile
- Gated comment and link buttons to only show when selection is not empty
…ple (#2985)

* docs: name the mobile toolbar layouts, add layout toggle to example

Introduce "scrolling document" (default) vs "pinned scroll container"
(opt-in) as the names for the two page layouts the mobile formatting
toolbar supports, and restructure the docs section around them.

- Docs: simple-first rewrite of the Mobile Formatting Toolbar section
  (default layout, then the opt-in layout with its two CSS rules).
- Example: stop embedding it in the docs (`docs: false`) - its
  page-level CSS (html/body overflow, full-viewport fixed scroll host,
  `.prose` rules) leaks into the docs page since examples render inline.
  Link to the standalone playground example instead.
- Example: add a nav-bar switch that toggles the pinned scroll container
  layout via a class on <html>, so both layouts can be compared.
- Playground: `.mantine-AppShell-root` width 100vw -> 100%, which caused
  a horizontal scrollbar on any example taller than the viewport.
- Align README, JSDoc and example comments with the new naming;
  regenerate examples.gen.tsx.

* Implemented PR feedback

---------

Co-authored-by: Matthew Lipski <matthewlipski@gmail.com>
`PortalElement` is `HTMLElement | string` and `PortalElementOverride` takes
`HTMLElement | undefined`, but two doc comments still promised that `null`
means `document.body`. Nothing accepts it any more.
Each `*` in `allowedDevOrigins` matches one address segment, so `172.*.*.*`
admitted all of 172/8 rather than the private range the comment describes.
Spell out the sixteen second segments of 172.16.0.0/12 instead.
…opment only

The check for `interactive-widget=resizes-content` moves out of
`useVirtualKeyboard` into `BlockNoteViewEditor`'s mount effect, so it reaches
developers on desktop and with a custom formatting toolbar alike. It lives in
its own module (not re-exported from the package) and returns early when
`process.env.NODE_ENV` is "production": the library build leaves that
expression in place for the consumer's bundler, so production builds stay
silent and a page without a bundler still gets the hint.
The playground already puts `bn-scroll-container` on its root, so the
example's own container is nested inside it, and its "scrolling document"
switch has to turn the page's container off. Both are CSS in the example:
a nested container defers to the outer one, and a body class set by the
switch neutralises any container on the page. The library stylesheet and
the playground stay as they are.
A view rendered inside another view's floating UI (the comments composer, an
editor in a custom block's popover) inherited the outer view's portal anchor
or override through context. That element is registered with the outer editor
only, so the nested editor's own menus and popovers counted as outside it for
isWithinEditor and the focus tracking built on it. BlockNoteViewContainer now
resets the portal element for its subtree to the editor's own default.
When set, the UI library must not move focus into the surface when it opens;
an input inside that asks for focus itself still gets it.
The link form tests now run for mantine, ariakit and shadcn: each skin's
popover decides on its own how the URL field takes focus. The per-skin
screenshot tests also wait for the link toolbar before comparing, since a
missing toolbar stays within the 2% screenshot tolerance.
# Conflicts:
#	packages/react/src/editor/BlockNoteView.tsx
@matthewlipski
matthewlipski removed this pull request from stack #3056 September 21, 2026 12:30
@matthewlipski
matthewlipski added this pull request to stack #3102 September 21, 2026 12:31
Base automatically changed from mobile-toolbar-demo to main September 22, 2026 08:08
@matthewlipski
matthewlipski force-pushed the portals-cleanup-v2 branch 2 times, most recently from 6da4916 to 3265ebc Compare September 22, 2026 08:18
@matthewlipski
matthewlipski merged commit 9674042 into main Sep 22, 2026
43 checks passed

This branch was successfully deployed

2 active deployments
Preview – blocknote-website — 3265ebc4 Deployed Sep 22, 2026 by vercel[bot]
Preview – blocknote — 3265ebc4 Deployed Sep 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants