refactor(react): one vocabulary and simpler plumbing for portal elements - #3052
Merged
Merged
Conversation
- 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
…p or mobile version should be used
…with external keyboard
…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
nperez0111
approved these changes
Sep 14, 2026
matthewlipski
approved these changes
Sep 16, 2026
4 tasks
matthewlipski
removed this pull request from stack #3056
September 21, 2026 12:30
matthewlipski
added this pull request to stack #3102
September 21, 2026 12:31
matthewlipski
force-pushed
the
portals-cleanup-v2
branch
2 times, most recently
from
September 22, 2026 08:18
6da4916 to
3265ebc
Compare
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
portalElementeverywhere:PortalElement,portalElements,resolvePortalElement,usePortalElement,PortalElementOverride,editor.registerPortalElement/unregisterPortalElement, and theportalElementprop on every adapter popover, menu and select (wasportalRoot). The themed root (.bn-root) is a single function,applyThemedRoot(element), onBlockNoteViewContext.Where floating UI renders, top to bottom:
bn-containerin the default layout; the element you renderBlockNoteViewEditorinto underrenderEditor={false}). This is whatmaindid. An earlier revision of this PR used thebn-containerinstead; 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 whatportalElementsis for.portalElementsonBlockNoteView(globaldefaultor per component) and a controller'sportalElementprop redirect a floating component elsewhere, viaPortalElementOverride, 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.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 underportalElements, and for the mobile toolbar they sit outside its scroll strip, which iOS WebKit would otherwise not paint. Adapters keep a requiredportalElementprop.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'suseFocusTrapon 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 deferrededitor.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; calleditor.registerPortalElement(el)for UI rendered outside the editor's DOM.useEditorDOMElementthrows without an editor.PortalElementno longer admitsnull.portalRoot→portalElementacross the adapters;portalElementis required on adapter Popover, Menu and ToolbarSelect. All introduced after v0.54.0, so pre-release surface.Testing
tests/src/end-to-end/portals/portalElements.test.tsx: default target, external targets with themed roots, per-element selectors,document.body, and therenderEditor={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 underportalElements; 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).packages/mantine/src/BlockNoteView.browser.test.tsx(portal setups cost the same commits as the default).Known follow-ups (not in this PR)
ToolbarButtonnor turning Ariakit's autofocus off on the popover fixes it; the composite toolbar's own focus handling is the likely cause.Changes since the first review round
BlockNoteViewrendered 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 forisWithinEditorand the focus tracking built on it.BlockNoteViewContainernow wraps its content in an internalPortalElementReset. Pinned inportalElements.test.tsx(red without the wrapper).preventFocusOnOpencontract written out once onToolbarSelectinComponentsContext, 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.)linkToolbar.test.tsxnow 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-toolbarbefore comparing.PortalElementOverridethat 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
Bug Fixes
Documentation
Tests