From f69f5c9b52dbd220ca553ae6505e0e5748897379 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 4 Sep 2026 18:40:13 +0200 Subject: [PATCH 1/3] Always pass `editorPortalElement` to `portalRoot` --- packages/ariakit/src/menu/Menu.tsx | 3 +++ packages/ariakit/src/popover/Popover.tsx | 10 ++++++++- .../ariakit/src/toolbar/ToolbarSelect.tsx | 9 +++++++- packages/mantine/src/menu/Menu.tsx | 16 ++++++++++---- packages/mantine/src/popover/Popover.tsx | 16 ++++++++++---- .../mantine/src/toolbar/ToolbarSelect.tsx | 15 +++++++++---- .../react/src/components/Comments/Comment.tsx | 7 ++++++- .../DefaultButtons/ColorStyleButton.tsx | 20 +++++++----------- .../DefaultButtons/CreateLinkButton.tsx | 19 +++++++---------- .../DefaultButtons/FileCaptionButton.tsx | 19 +++++++---------- .../DefaultButtons/FileRenameButton.tsx | 19 +++++++---------- .../DefaultButtons/FileReplaceButton.tsx | 13 +++++++----- .../DefaultSelects/BlockTypeSelect.tsx | 10 ++++----- .../DefaultButtons/EditLinkButton.tsx | 3 +++ .../DefaultButtons/DragHandleButton.tsx | 3 +++ .../DefaultItems/BlockColorsItem.tsx | 8 ++++++- .../TableHandles/TableCellButton.tsx | 3 +++ .../DefaultButtons/ColorPicker.tsx | 8 ++++++- .../components/TableHandles/TableHandle.tsx | 3 +++ .../DefaultButtons/ColorPicker.tsx | 8 ++++++- .../components/Versioning/CurrentSnapshot.tsx | 7 ++++++- .../src/components/Versioning/Snapshot.tsx | 7 ++++++- .../react/src/editor/ComponentsContext.tsx | 21 +++++++++++++++++++ packages/shadcn/src/menu/Menu.tsx | 13 ++++++------ packages/shadcn/src/popover/popover.tsx | 16 +++++++------- packages/shadcn/src/toolbar/Toolbar.tsx | 17 +++++++++------ 26 files changed, 199 insertions(+), 94 deletions(-) diff --git a/packages/ariakit/src/menu/Menu.tsx b/packages/ariakit/src/menu/Menu.tsx index 177dc37f73..94ec44ecab 100644 --- a/packages/ariakit/src/menu/Menu.tsx +++ b/packages/ariakit/src/menu/Menu.tsx @@ -25,6 +25,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { onOpenChange, position, portalRoot, + // ariakit's `virtualFocus` keeps DOM focus on the editor (roving via + // `aria-activedescendant`), so there is no focus to suppress here. + preventFocusOnOpen: _preventFocusOnOpen, sub: _sub, // unused ...rest } = props; diff --git a/packages/ariakit/src/popover/Popover.tsx b/packages/ariakit/src/popover/Popover.tsx index df8e01128b..d0e557998c 100644 --- a/packages/ariakit/src/popover/Popover.tsx +++ b/packages/ariakit/src/popover/Popover.tsx @@ -51,7 +51,15 @@ export const PopoverContent = forwardRef< export const Popover = ( props: ComponentProps["Generic"]["Popover"]["Root"], ) => { - const { children, open, onOpenChange, position, portalRoot, ...rest } = props; + const { + children, + open, + onOpenChange, + position, + portalRoot, + preventFocusOnOpen: _preventFocusOnOpen, // unused; see Menu.tsx + ...rest + } = props; assertEmpty(rest); diff --git a/packages/ariakit/src/toolbar/ToolbarSelect.tsx b/packages/ariakit/src/toolbar/ToolbarSelect.tsx index 26d817976e..4b28092eb3 100644 --- a/packages/ariakit/src/toolbar/ToolbarSelect.tsx +++ b/packages/ariakit/src/toolbar/ToolbarSelect.tsx @@ -16,7 +16,14 @@ export const ToolbarSelect = forwardRef< HTMLDivElement, ComponentProps["FormattingToolbar"]["Select"] >((props, ref) => { - const { className, items, isDisabled, portalRoot, ...rest } = props; + const { + className, + items, + isDisabled, + portalRoot, + preventFocusOnOpen: _preventFocusOnOpen, // unused; see Menu.tsx + ...rest + } = props; assertEmpty(rest); diff --git a/packages/mantine/src/menu/Menu.tsx b/packages/mantine/src/menu/Menu.tsx index 4a04322152..e28f568175 100644 --- a/packages/mantine/src/menu/Menu.tsx +++ b/packages/mantine/src/menu/Menu.tsx @@ -16,7 +16,15 @@ const SubMenuContext = createContext< >(undefined); export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { - const { children, onOpenChange, position, portalRoot, sub, ...rest } = props; + const { + children, + onOpenChange, + position, + portalRoot, + preventFocusOnOpen, + sub, + ...rest + } = props; assertEmpty(rest); @@ -38,9 +46,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { { - const { open, onOpenChange, position, portalRoot, children, ...rest } = props; + const { + open, + onOpenChange, + position, + portalRoot, + preventFocusOnOpen, + children, + ...rest + } = props; assertEmpty(rest); @@ -20,9 +28,9 @@ export const Popover = ( middlewares={{ size: { padding: 20 } }} withinPortal={!!portalRoot} portalProps={portalRoot ? { target: portalRoot } : undefined} - // Do not move focus to the dropdown on mobile, as it blurs the editor's - // contentEditable and dismisses the on-screen keyboard. - trapFocus={portalRoot ? false : undefined} + // Do not move focus to the dropdown when requested (mobile), as it blurs + // the editor's contentEditable and dismisses the on-screen keyboard. + trapFocus={preventFocusOnOpen ? false : undefined} opened={open} onChange={onOpenChange} position={position} diff --git a/packages/mantine/src/toolbar/ToolbarSelect.tsx b/packages/mantine/src/toolbar/ToolbarSelect.tsx index 16f7023c16..567d723270 100644 --- a/packages/mantine/src/toolbar/ToolbarSelect.tsx +++ b/packages/mantine/src/toolbar/ToolbarSelect.tsx @@ -14,7 +14,14 @@ export const ToolbarSelect = forwardRef< HTMLDivElement, ComponentProps["FormattingToolbar"]["Select"] >((props, ref) => { - const { className, items, isDisabled, portalRoot, ...rest } = props; + const { + className, + items, + isDisabled, + portalRoot, + preventFocusOnOpen, + ...rest + } = props; assertEmpty(rest); @@ -32,9 +39,9 @@ export const ToolbarSelect = forwardRef< exitDuration: 0, }} disabled={isDisabled} - // Do not move focus to the dropdown on mobile, as it blurs the editor's - // contentEditable and dismisses the on-screen keyboard. - trapFocus={portalRoot ? false : undefined} + // Do not move focus to the dropdown when requested (mobile), as it blurs + // the editor's contentEditable and dismisses the on-screen keyboard. + trapFocus={preventFocusOnOpen ? false : undefined} middlewares={{ flip: true, shift: true, diff --git a/packages/react/src/components/Comments/Comment.tsx b/packages/react/src/components/Comments/Comment.tsx index d4a6df40d6..edd45a84b4 100644 --- a/packages/react/src/components/Comments/Comment.tsx +++ b/packages/react/src/components/Comments/Comment.tsx @@ -18,6 +18,7 @@ import { Components, useComponentsContext, } from "../../editor/ComponentsContext.js"; +import { useEditorPortalElement } from "../../editor/EditorPortalProvider.js"; import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useExtension } from "../../hooks/useExtension.js"; import { useDictionary } from "../../i18n/dictionary.js"; @@ -161,6 +162,7 @@ export const Comment = ({ }); const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const [isEditing, setEditing] = useState(false); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); @@ -289,7 +291,10 @@ export const Comment = ({ ))} {(canDeleteComment || canEditComment) && ( - + { const dict = useDictionary(); const uiMode = useUIMode(); const editorPortalElement = useEditorPortalElement(); - // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop - // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, InlineContentSchema, @@ -145,14 +141,14 @@ export const ColorStyleButton = () => { return ( { const dict = useDictionary(); const uiMode = useUIMode(); const editorPortalElement = useEditorPortalElement(); - // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop - // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const formattingToolbar = useExtension(FormattingToolbarExtension); // eslint-disable-next-line @typescript-eslint/unbound-method -- showSelection is a plain object method, not a class method @@ -136,13 +132,14 @@ export const CreateLinkButton = () => { {/* TODO: hide tooltip on click */} diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx index 7c59fbaeff..bfca8ca31b 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx @@ -20,10 +20,6 @@ export const FileCaptionButton = () => { const Components = useComponentsContext()!; const uiMode = useUIMode(); const editorPortalElement = useEditorPortalElement(); - // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop - // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -112,13 +108,14 @@ export const FileCaptionButton = () => { { const Components = useComponentsContext()!; const uiMode = useUIMode(); const editorPortalElement = useEditorPortalElement(); - // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop - // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -112,13 +108,14 @@ export const FileRenameButton = () => { { const Components = useComponentsContext()!; const uiMode = useUIMode(); const editorPortalElement = useEditorPortalElement(); - // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop - // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -73,7 +69,14 @@ export const FileReplaceButton = () => { editor.focus(); } }} - portalRoot={portalRoot} + // Portal the popover into the editor's themed portal target so it + // inherits styling and escapes any scroll-container overflow clipping. + // On mobile that target is the toolbar's body-level container (see + // `MobileFormattingToolbarController`), and `preventFocusOnOpen` stops + // focus moving into the popover, which would blur the editor and dismiss + // the on-screen keyboard. + portalRoot={editorPortalElement ?? undefined} + preventFocusOnOpen={uiMode === "mobile"} > { const Components = useComponentsContext()!; const uiMode = useUIMode(); const editorPortalElement = useEditorPortalElement(); - // Only portal (and suppress dropdown focus) in the mobile toolbar; desktop - // renders inline with default focus behavior. - const portalRoot = - uiMode === "mobile" ? (editorPortalElement ?? undefined) : undefined; const editor = useBlockNoteEditor< BlockSchema, @@ -220,7 +216,11 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { ); }; diff --git a/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx b/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx index 52cef731f4..41b6e03fc2 100644 --- a/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx +++ b/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx @@ -1,4 +1,5 @@ import { useComponentsContext } from "../../../editor/ComponentsContext.js"; +import { useEditorPortalElement } from "../../../editor/EditorPortalProvider.js"; import { useDictionary } from "../../../i18n/dictionary.js"; import { EditLinkMenuItems } from "../EditLinkMenuItems.js"; import { LinkToolbarProps } from "../LinkToolbarProps.js"; @@ -10,11 +11,13 @@ export const EditLinkButton = ( >, ) => { const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const dict = useDictionary(); return ( { const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const dict = useDictionary(); const sideMenu = useExtension(SideMenuExtension); @@ -39,6 +41,7 @@ export const DragHandleButton = ( } }} position={"left"} + portalRoot={editorPortalElement ?? undefined} > { const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const editor = useBlockNoteEditor(); @@ -30,7 +32,11 @@ export const BlockColorsItem = (props: { children: ReactNode }) => { } return ( - + { const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const editor = useBlockNoteEditor(); @@ -45,6 +47,7 @@ export const TableCellButton = ( } }} position={"right"} + portalRoot={editorPortalElement ?? undefined} > diff --git a/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx b/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx index 36e1ecca2f..fc68457d1a 100644 --- a/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx +++ b/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx @@ -3,6 +3,7 @@ import { TableHandlesExtension } from "@blocknote/core/extensions"; import { ReactNode } from "react"; import { useComponentsContext } from "../../../../editor/ComponentsContext.js"; +import { useEditorPortalElement } from "../../../../editor/EditorPortalProvider.js"; import { useBlockNoteEditor } from "../../../../hooks/useBlockNoteEditor.js"; import { useExtensionState } from "../../../../hooks/useExtension.js"; import { useDictionary } from "../../../../i18n/dictionary.js"; @@ -10,6 +11,7 @@ import { ColorPicker } from "../../../ColorPicker/ColorPicker.js"; export const ColorPickerButton = (props: { children?: ReactNode }) => { const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const dict = useDictionary(); const editor = useBlockNoteEditor(); @@ -74,7 +76,11 @@ export const ColorPickerButton = (props: { children?: ReactNode }) => { } return ( - + { const editor = useBlockNoteEditor(); const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const [isDragging, setIsDragging] = useState(false); @@ -66,6 +68,7 @@ export const TableHandle = ( } }} position={"right"} + portalRoot={editorPortalElement ?? undefined} > { const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const dict = useDictionary(); const editor = useBlockNoteEditor< { table: DefaultBlockSchema["table"] }, @@ -104,7 +106,11 @@ export const ColorPickerButton = < const firstCell = mapTableCell(currentCells[0].cell); return ( - + { const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const { canPreviewCurrent, previewCurrentVersion, exitPreview } = useExtension(VersioningExtension); const selected = useExtensionState(VersioningExtension, { @@ -72,7 +74,10 @@ export const CurrentSnapshot = ({ variant="action-toolbar" className="bn-action-toolbar" > - + { const Components = useComponentsContext()!; + const editorPortalElement = useEditorPortalElement(); const { canRestore, restore, @@ -110,7 +112,10 @@ export const Snapshot = ({ variant="action-toolbar" className="bn-action-toolbar" > - + { onOpenChange, position: _position, // Unused portalRoot, + // base-ui manages menu focus itself; unlike Mantine there is no focus to + // suppress, so this is intentionally unused. + preventFocusOnOpen: _preventFocusOnOpen, sub, ...rest } = props; @@ -81,11 +84,9 @@ export const MenuDropdown = forwardRef< const ShadCNComponents = useShadCNComponentsContext()!; - const portalRoot = useContext(PortalRootContext); - // Default to the ambient portal target (a themed `.bn-root`) so the menu - // inherits light/dark mode instead of the document body's. - const editorPortalElement = useEditorPortalElement(); - const container = portalRoot ?? editorPortalElement ?? undefined; + // The `portalRoot` supplied at the call site is a themed `.bn-root`, so the + // menu inherits light/dark mode instead of the document body's. + const container = useContext(PortalRootContext) ?? undefined; if (sub) { return ( diff --git a/packages/shadcn/src/popover/popover.tsx b/packages/shadcn/src/popover/popover.tsx index 793530ce3b..a6735dfd75 100644 --- a/packages/shadcn/src/popover/popover.tsx +++ b/packages/shadcn/src/popover/popover.tsx @@ -1,5 +1,5 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, useEditorPortalElement } from "@blocknote/react"; +import { ComponentProps } from "@blocknote/react"; import { createContext, forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; @@ -18,6 +18,9 @@ export const Popover = ( onOpenChange, position: _position, // unused portalRoot, + // base-ui manages popover focus itself; unlike Mantine there is no focus to + // suppress, so this is intentionally unused. + preventFocusOnOpen: _preventFocusOnOpen, ...rest } = props; @@ -61,16 +64,15 @@ export const PopoverContent = forwardRef< const ShadCNComponents = useShadCNComponentsContext()!; - const portalRoot = useContext(PortalRootContext); - // Default to the ambient portal target (a themed `.bn-root`) so popovers - // inherit light/dark mode instead of the document body's, and escape the - // mobile formatting toolbar's horizontal scroll clip. - const editorPortalElement = useEditorPortalElement(); + // The `portalRoot` supplied at the call site is a themed `.bn-root`, so + // popovers inherit light/dark mode instead of the document body's, and escape + // the mobile formatting toolbar's horizontal scroll clip. + const container = useContext(PortalRootContext) ?? undefined; return ( ((props, ref) => { - const { className, items, isDisabled, portalRoot, ...rest } = props; + const { + className, + items, + isDisabled, + portalRoot, + // base-ui manages select focus itself; unlike Mantine there is no focus to + // suppress, so this is intentionally unused. + preventFocusOnOpen: _preventFocusOnOpen, + ...rest + } = props; assertEmpty(rest); const ShadCNComponents = useShadCNComponentsContext()!; - // Default to the ambient portal target (a themed `.bn-root`) so the dropdown - // inherits light/dark mode instead of the body's. - const editorPortalElement = useEditorPortalElement(); - // TODO? const SelectItemContent = (props: any) => (
@@ -164,7 +169,7 @@ export const ToolbarSelect = forwardRef< Date: Sat, 5 Sep 2026 09:27:49 +0200 Subject: [PATCH 2/3] refactor: require portalRoot and never fall back to the body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `portalRoot` is now required on `Menu.Root`, `Popover.Root` and the toolbar select. The UI-library adapters never read the editor's context, so they cannot fall back to it themselves; making the prop required lets the compiler enforce what was a convention. Call sites pass the ambient element as-is — `null` before the editor has mounted — instead of coercing it to `undefined`. What each adapter does with `null` follows its library, so none of them portal to the document body: - shadcn passes it through: Base UI waits for a container. - ariakit toggles `portal` off: it renders inline until there is one (Ariakit appends a fresh div to the body for null and undefined alike). - mantine already rendered inline on a falsy value. The shadcn tooltips (`ToolbarButton`, `Badge`) keep reading the ambient element through `useEditorPortalElement`. That is the one documented exception: mantine and ariakit tooltips render inline and would ignore a passed element, so a prop would buy nothing there. --- packages/ariakit/src/menu/Menu.tsx | 9 +++++---- packages/ariakit/src/popover/Popover.tsx | 9 +++++---- packages/ariakit/src/toolbar/ToolbarSelect.tsx | 5 ++++- packages/react/src/components/Comments/Comment.tsx | 2 +- packages/react/src/components/Comments/EmojiPicker.tsx | 2 +- .../DefaultButtons/ColorStyleButton.tsx | 2 +- .../DefaultButtons/CreateLinkButton.tsx | 2 +- .../DefaultButtons/FileCaptionButton.tsx | 2 +- .../DefaultButtons/FileRenameButton.tsx | 2 +- .../DefaultButtons/FileReplaceButton.tsx | 2 +- .../FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx | 2 +- .../LinkToolbar/DefaultButtons/EditLinkButton.tsx | 2 +- .../SideMenu/DefaultButtons/DragHandleButton.tsx | 2 +- .../DragHandleMenu/DefaultItems/BlockColorsItem.tsx | 2 +- .../src/components/TableHandles/TableCellButton.tsx | 2 +- .../TableCellMenu/DefaultButtons/ColorPicker.tsx | 2 +- .../react/src/components/TableHandles/TableHandle.tsx | 2 +- .../TableHandleMenu/DefaultButtons/ColorPicker.tsx | 2 +- .../react/src/components/Versioning/CurrentSnapshot.tsx | 2 +- packages/react/src/components/Versioning/Snapshot.tsx | 2 +- packages/react/src/editor/ComponentsContext.tsx | 6 +++--- packages/shadcn/src/badge/Badge.tsx | 4 ++++ packages/shadcn/src/menu/Menu.tsx | 8 ++++---- packages/shadcn/src/popover/popover.tsx | 8 ++++---- packages/shadcn/src/toolbar/Toolbar.tsx | 9 +++++++-- 25 files changed, 53 insertions(+), 39 deletions(-) diff --git a/packages/ariakit/src/menu/Menu.tsx b/packages/ariakit/src/menu/Menu.tsx index 94ec44ecab..f1a5c527c0 100644 --- a/packages/ariakit/src/menu/Menu.tsx +++ b/packages/ariakit/src/menu/Menu.tsx @@ -15,9 +15,7 @@ import { createContext, forwardRef, useContext } from "react"; // Threads the `portalRoot` override from `Menu` (the provider) down to // `MenuDropdown`, where ariakit's `portalElement` prop actually lives. -const PortalRootContext = createContext( - undefined, -); +const PortalRootContext = createContext(null); export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { const { @@ -66,7 +64,10 @@ export const MenuDropdown = forwardRef< {children} diff --git a/packages/ariakit/src/popover/Popover.tsx b/packages/ariakit/src/popover/Popover.tsx index d0e557998c..93d2d8141b 100644 --- a/packages/ariakit/src/popover/Popover.tsx +++ b/packages/ariakit/src/popover/Popover.tsx @@ -8,9 +8,7 @@ import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; import { createContext, forwardRef, useContext } from "react"; -const PortalRootContext = createContext( - undefined, -); +const PortalRootContext = createContext(null); export const PopoverTrigger = forwardRef< HTMLButtonElement, @@ -40,7 +38,10 @@ export const PopoverContent = forwardRef< className || "", variant === "panel-popover" ? "bn-ak-panel-popover" : "", )} - portalElement={portalRoot ?? undefined} + // Ariakit falls back to a body-appended div for a missing element, so + // don't portal at all until there is one (editor not mounted yet). + portal={portalRoot !== null} + portalElement={portalRoot} ref={ref} > {children} diff --git a/packages/ariakit/src/toolbar/ToolbarSelect.tsx b/packages/ariakit/src/toolbar/ToolbarSelect.tsx index 4b28092eb3..4eca7d3a5f 100644 --- a/packages/ariakit/src/toolbar/ToolbarSelect.tsx +++ b/packages/ariakit/src/toolbar/ToolbarSelect.tsx @@ -47,7 +47,10 @@ export const ToolbarSelect = forwardRef< className={mergeCSSClasses("bn-ak-popover", className || "")} ref={ref} gutter={4} - portalElement={portalRoot ?? undefined} + // Ariakit falls back to a body-appended div for a missing element, + // so don't portal at all until there is one (editor not mounted yet). + portal={portalRoot !== null} + portalElement={portalRoot} > {items.map((option) => (
{ // `MobileFormattingToolbarController`), and `preventFocusOnOpen` stops // focus moving into the dropdown, which would blur the editor and dismiss // the on-screen keyboard. - portalRoot={editorPortalElement ?? undefined} + portalRoot={editorPortalElement} preventFocusOnOpen={uiMode === "mobile"} > diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx index 31567965e3..f8dc565d2e 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx @@ -138,7 +138,7 @@ export const CreateLinkButton = () => { // `MobileFormattingToolbarController`), and `preventFocusOnOpen` stops // focus moving into the popover, which would blur the editor and dismiss // the on-screen keyboard. - portalRoot={editorPortalElement ?? undefined} + portalRoot={editorPortalElement} preventFocusOnOpen={uiMode === "mobile"} > diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx index bfca8ca31b..7058398840 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx @@ -114,7 +114,7 @@ export const FileCaptionButton = () => { // `MobileFormattingToolbarController`), and `preventFocusOnOpen` stops // focus moving into the popover, which would blur the editor and dismiss // the on-screen keyboard. - portalRoot={editorPortalElement ?? undefined} + portalRoot={editorPortalElement} preventFocusOnOpen={uiMode === "mobile"} > diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx index 8de84ad1f8..b516f58d39 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx @@ -114,7 +114,7 @@ export const FileRenameButton = () => { // `MobileFormattingToolbarController`), and `preventFocusOnOpen` stops // focus moving into the popover, which would blur the editor and dismiss // the on-screen keyboard. - portalRoot={editorPortalElement ?? undefined} + portalRoot={editorPortalElement} preventFocusOnOpen={uiMode === "mobile"} > diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx index 97312cac63..59ccca4fb5 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx @@ -75,7 +75,7 @@ export const FileReplaceButton = () => { // `MobileFormattingToolbarController`), and `preventFocusOnOpen` stops // focus moving into the popover, which would blur the editor and dismiss // the on-screen keyboard. - portalRoot={editorPortalElement ?? undefined} + portalRoot={editorPortalElement} preventFocusOnOpen={uiMode === "mobile"} > diff --git a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx index 0319053db8..8f2ef1f352 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx @@ -219,7 +219,7 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { // Portal the dropdown into the editor's themed portal target so it // inherits styling; on mobile `preventFocusOnOpen` keeps focus in the // editor so the on-screen keyboard stays up. - portalRoot={editorPortalElement ?? undefined} + portalRoot={editorPortalElement} preventFocusOnOpen={uiMode === "mobile"} /> ); diff --git a/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx b/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx index 41b6e03fc2..04f3233080 100644 --- a/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx +++ b/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx @@ -17,7 +17,7 @@ export const EditLinkButton = ( return ( { diff --git a/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx b/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx index fc68457d1a..df96f2a6d6 100644 --- a/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx +++ b/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx @@ -79,7 +79,7 @@ export const ColorPickerButton = (props: { children?: ReactNode }) => { ( - undefined, -); +const PortalRootContext = createContext(null); export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { const { @@ -86,7 +84,9 @@ export const MenuDropdown = forwardRef< // The `portalRoot` supplied at the call site is a themed `.bn-root`, so the // menu inherits light/dark mode instead of the document body's. - const container = useContext(PortalRootContext) ?? undefined; + // `null` (editor not mounted yet) makes Base UI wait for a container + // instead of falling back to the body; nothing is open at that point. + const container = useContext(PortalRootContext); if (sub) { return ( diff --git a/packages/shadcn/src/popover/popover.tsx b/packages/shadcn/src/popover/popover.tsx index a6735dfd75..886a7af479 100644 --- a/packages/shadcn/src/popover/popover.tsx +++ b/packages/shadcn/src/popover/popover.tsx @@ -5,9 +5,7 @@ import { createContext, forwardRef, ReactElement, useContext } from "react"; import { cn } from "../lib/utils.js"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; -const PortalRootContext = createContext( - undefined, -); +const PortalRootContext = createContext(null); export const Popover = ( props: ComponentProps["Generic"]["Popover"]["Root"], @@ -67,7 +65,9 @@ export const PopoverContent = forwardRef< // The `portalRoot` supplied at the call site is a themed `.bn-root`, so // popovers inherit light/dark mode instead of the document body's, and escape // the mobile formatting toolbar's horizontal scroll clip. - const container = useContext(PortalRootContext) ?? undefined; + // `null` (editor not mounted yet) makes Base UI wait for a container + // instead of falling back to the body; nothing is open at that point. + const container = useContext(PortalRootContext); return ( ( // Portal the tooltip into the ambient portal target (a themed `.bn-root`) // so it inherits the editor's light/dark color scheme instead of the // document body's. + // NOTE: Only ShadCN Badge / Tooltip depend on useEditorPortalElement. + // Alternative would be to pass a portalElement to these components, but they + // would be ignored by ariakit / mantine. For now keep these two exceptions + // (ideally skin components don't have a dependency on the editor's context) + const editorPortalElement = useEditorPortalElement(); const trigger = @@ -112,7 +117,7 @@ export const ToolbarButton = forwardRef( {mainTooltip} @@ -169,7 +174,7 @@ export const ToolbarSelect = forwardRef< Date: Sat, 5 Sep 2026 09:43:08 +0200 Subject: [PATCH 3/3] fix: pass the required portalRoot in the SettingsSelect examples `vp run build` typechecks example projects that lint doesn't cover: the three `SettingsSelect` copies render a `Toolbar.Select` without the now required prop. They sit inside `BlockNoteView`, so they read the ambient element and pass it. Also drops the last `?? undefined` on a portal value: the shadcn `Badge` tooltip passes the element as-is, like `ToolbarButton` already does, so nothing falls back to the body. --- .../05-comments/src/SettingsSelect.tsx | 11 ++++++++++- .../06-comments-with-sidebar/src/SettingsSelect.tsx | 11 ++++++++++- .../11-versioning-yjs13/src/SettingsSelect.tsx | 11 ++++++++++- packages/shadcn/src/badge/Badge.tsx | 2 +- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/examples/07-collaboration/05-comments/src/SettingsSelect.tsx b/examples/07-collaboration/05-comments/src/SettingsSelect.tsx index 0dfc79dc3f..683d19de84 100644 --- a/examples/07-collaboration/05-comments/src/SettingsSelect.tsx +++ b/examples/07-collaboration/05-comments/src/SettingsSelect.tsx @@ -1,4 +1,8 @@ -import { ComponentProps, useComponentsContext } from "@blocknote/react"; +import { + ComponentProps, + useComponentsContext, + useEditorPortalElement, +} from "@blocknote/react"; // This component is used to display a selection dropdown with a label. By using // the useComponentsContext hook, we can create it out of existing components @@ -9,6 +13,10 @@ export const SettingsSelect = (props: { items: ComponentProps["FormattingToolbar"]["Select"]["items"]; }) => { const Components = useComponentsContext()!; + // The select's dropdown portals into the editor's portal element, which keeps + // it themed and clear of any overflow clipping. The prop is required, so it + // can't be left out by accident. + const editorPortalElement = useEditorPortalElement(); return (
@@ -17,6 +25,7 @@ export const SettingsSelect = (props: {
diff --git a/examples/07-collaboration/06-comments-with-sidebar/src/SettingsSelect.tsx b/examples/07-collaboration/06-comments-with-sidebar/src/SettingsSelect.tsx index 0dfc79dc3f..683d19de84 100644 --- a/examples/07-collaboration/06-comments-with-sidebar/src/SettingsSelect.tsx +++ b/examples/07-collaboration/06-comments-with-sidebar/src/SettingsSelect.tsx @@ -1,4 +1,8 @@ -import { ComponentProps, useComponentsContext } from "@blocknote/react"; +import { + ComponentProps, + useComponentsContext, + useEditorPortalElement, +} from "@blocknote/react"; // This component is used to display a selection dropdown with a label. By using // the useComponentsContext hook, we can create it out of existing components @@ -9,6 +13,10 @@ export const SettingsSelect = (props: { items: ComponentProps["FormattingToolbar"]["Select"]["items"]; }) => { const Components = useComponentsContext()!; + // The select's dropdown portals into the editor's portal element, which keeps + // it themed and clear of any overflow clipping. The prop is required, so it + // can't be left out by accident. + const editorPortalElement = useEditorPortalElement(); return (
@@ -17,6 +25,7 @@ export const SettingsSelect = (props: {
diff --git a/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx b/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx index 0dfc79dc3f..683d19de84 100644 --- a/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx +++ b/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx @@ -1,4 +1,8 @@ -import { ComponentProps, useComponentsContext } from "@blocknote/react"; +import { + ComponentProps, + useComponentsContext, + useEditorPortalElement, +} from "@blocknote/react"; // This component is used to display a selection dropdown with a label. By using // the useComponentsContext hook, we can create it out of existing components @@ -9,6 +13,10 @@ export const SettingsSelect = (props: { items: ComponentProps["FormattingToolbar"]["Select"]["items"]; }) => { const Components = useComponentsContext()!; + // The select's dropdown portals into the editor's portal element, which keeps + // it themed and clear of any overflow clipping. The prop is required, so it + // can't be left out by accident. + const editorPortalElement = useEditorPortalElement(); return (
@@ -17,6 +25,7 @@ export const SettingsSelect = (props: {
diff --git a/packages/shadcn/src/badge/Badge.tsx b/packages/shadcn/src/badge/Badge.tsx index 30003b1ad8..3027da457d 100644 --- a/packages/shadcn/src/badge/Badge.tsx +++ b/packages/shadcn/src/badge/Badge.tsx @@ -58,7 +58,7 @@ export const Badge = forwardRef< {mainTooltip}