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/ariakit/src/menu/Menu.tsx b/packages/ariakit/src/menu/Menu.tsx index 177dc37f73..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 { @@ -25,6 +23,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; @@ -63,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 df8e01128b..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} @@ -51,7 +52,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..4eca7d3a5f 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); @@ -40,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) => ( (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..519f3ce431 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..7058398840 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} + 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..04f3233080 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} > { 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} > diff --git a/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx b/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx index 36e1ecca2f..df96f2a6d6 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} > { 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" > - + {mainTooltip} diff --git a/packages/shadcn/src/menu/Menu.tsx b/packages/shadcn/src/menu/Menu.tsx index abe101b9fa..fd64f69740 100644 --- a/packages/shadcn/src/menu/Menu.tsx +++ b/packages/shadcn/src/menu/Menu.tsx @@ -1,13 +1,11 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, useEditorPortalElement } from "@blocknote/react"; +import { ComponentProps } from "@blocknote/react"; import { ChevronRight } from "lucide-react"; 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 Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { const { @@ -15,6 +13,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => { 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 +82,11 @@ 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. + // `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 793530ce3b..886a7af479 100644 --- a/packages/shadcn/src/popover/popover.tsx +++ b/packages/shadcn/src/popover/popover.tsx @@ -1,13 +1,11 @@ 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"; import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; -const PortalRootContext = createContext( - undefined, -); +const PortalRootContext = createContext(null); export const Popover = ( props: ComponentProps["Generic"]["Popover"]["Root"], @@ -18,6 +16,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 +62,17 @@ 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. + // `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} @@ -127,16 +132,21 @@ export const ToolbarSelect = forwardRef< HTMLDivElement, ComponentProps["FormattingToolbar"]["Select"] >((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 +174,7 @@ export const ToolbarSelect = forwardRef<