diff --git a/.changeset/fix-table-block-delete.md b/.changeset/fix-table-block-delete.md new file mode 100644 index 000000000..78c16a9f2 --- /dev/null +++ b/.changeset/fix-table-block-delete.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Fixes deleting tables from the portable text editor's block actions menu. diff --git a/.changeset/polish-block-actions-menu.md b/.changeset/polish-block-actions-menu.md new file mode 100644 index 000000000..f0203d9f0 --- /dev/null +++ b/.changeset/polish-block-actions-menu.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Updates the portable text editor's block actions menu with animated transitions, clearer hover feedback, accessible keyboard navigation, and stable positioning while moving between blocks. diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index d437dabd0..17ca6cea2 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -2563,6 +2563,7 @@ export function PortableTextEditor({ Subscript, Superscript, Table.configure({ + allowTableNodeSelection: true, resizable: true, }), TableRow, diff --git a/packages/admin/src/components/editor/BlockMenu.tsx b/packages/admin/src/components/editor/BlockMenu.tsx index 696a08449..913be26ba 100644 --- a/packages/admin/src/components/editor/BlockMenu.tsx +++ b/packages/admin/src/components/editor/BlockMenu.tsx @@ -7,11 +7,10 @@ * - Duplicate * - Delete * - * Uses Floating UI for positioning relative to the selected block. + * Uses Kumo's menu primitive, anchored to the selected block's drag handle. */ -import { Button } from "@cloudflare/kumo"; -import { useFloating, offset, flip, shift, autoUpdate } from "@floating-ui/react"; +import { Button, DropdownMenu } from "@cloudflare/kumo"; import type { MessageDescriptor } from "@lingui/core"; import { msg } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; @@ -35,10 +34,9 @@ import { import { NodeSelection } from "@tiptap/pm/state"; import type { Editor } from "@tiptap/react"; import * as React from "react"; -import { createPortal } from "react-dom"; -import { useStableCallback } from "../../lib/hooks"; import { cn } from "../../lib/utils"; +import { getLocaleDir } from "../../locales/config.js"; import { CaretNext, CaretPrev } from "../ArrowIcons.js"; /** @@ -142,6 +140,8 @@ const blockTransforms: BlockTransform[] = [ }, ]; +const POPOVER_TRANSITION_MS = 150; + interface BlockMenuProps { editor: Editor; /** The DOM element of the selected block (for positioning) */ @@ -150,84 +150,43 @@ interface BlockMenuProps { isOpen: boolean; /** Callback to close the menu */ onClose: () => void; + /** Callback after the menu's exit transition completes */ + onCloseComplete?: () => void; } /** * Block Menu - floating menu for block-level actions */ -export function BlockMenu({ editor, anchorElement, isOpen, onClose }: BlockMenuProps) { - const { t } = useLingui(); +export function BlockMenu({ + editor, + anchorElement, + isOpen, + onClose, + onCloseComplete, +}: BlockMenuProps) { + const { i18n, t } = useLingui(); const [showTransforms, setShowTransforms] = React.useState(false); - const menuRef = React.useRef(null); - const stableOnClose = useStableCallback(onClose); - - const { refs, floatingStyles } = useFloating({ - open: isOpen, - placement: "left-start", - middleware: [offset({ mainAxis: 8, crossAxis: 0 }), flip(), shift({ padding: 8 })], - whileElementsMounted: autoUpdate, - }); - - // Sync the anchor element - React.useEffect(() => { - if (anchorElement) { - refs.setReference(anchorElement); - } - }, [anchorElement, refs]); - - // Close on escape - React.useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - if (showTransforms) { - setShowTransforms(false); - } else { - stableOnClose(); - } - } - }; + const anchorRef = React.useRef(anchorElement); + const menuActionsRef = React.useRef<{ unmount: () => void; close: () => void } | null>(null); + const direction = getLocaleDir(i18n.locale); - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [isOpen, stableOnClose, showTransforms]); + React.useLayoutEffect(() => { + if (anchorElement) anchorRef.current = anchorElement; + }, [anchorElement]); - // Close on click outside React.useEffect(() => { - if (!isOpen) return; - - const handleClickOutside = (e: MouseEvent) => { - const target = e.target; - // Don't close if clicking on the drag handle or menu itself - if (target instanceof Node && menuRef.current?.contains(target)) return; - if (target instanceof Element && target.closest("[data-block-handle]")) return; - - stableOnClose(); - }; - - // Delay to avoid immediate close from the click that opened it - const timer = setTimeout(() => { - document.addEventListener("mousedown", handleClickOutside); - }, 0); + if (isOpen) return; - return () => { - clearTimeout(timer); - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [isOpen, stableOnClose]); - - // Reset submenu state when menu closes - React.useEffect(() => { - if (!isOpen) { - setShowTransforms(false); - } + setShowTransforms(false); + const delay = window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? 0 + : POPOVER_TRANSITION_MS; + const timer = window.setTimeout(() => menuActionsRef.current?.unmount(), delay); + return () => window.clearTimeout(timer); }, [isOpen]); const handleDuplicate = () => { if (!(editor.state.selection instanceof NodeSelection)) { - onClose(); return; } @@ -242,95 +201,126 @@ export function BlockMenu({ editor, anchorElement, isOpen, onClose }: BlockMenuP return true; }) .run(); - - onClose(); }; const handleDelete = () => { if (!(editor.state.selection instanceof NodeSelection)) { - onClose(); return; } editor.chain().focus().deleteSelection().run(); - onClose(); }; const handleTransform = (transform: BlockTransform) => { transform.transform(editor); - onClose(); }; - if (!isOpen) return null; - - return createPortal( -
{ - menuRef.current = node; - refs.setFloating(node); + return ( + { + if (!open) onCloseComplete?.(); + }} + onOpenChange={(open, eventDetails) => { + if (open) return; + if (eventDetails.reason === "trigger-hover") { + eventDetails.cancel(); + return; + } + if (showTransforms && eventDetails.reason === "escape-key") { + eventDetails.cancel(); + setShowTransforms(false); + return; + } + eventDetails.preventUnmountOnClose(); + onClose(); }} - style={floatingStyles} - className="z-[100] rounded-lg border bg-kumo-overlay shadow-lg min-w-[180px] overflow-hidden" > - {showTransforms ? ( - // Transform submenu -
- -
- {blockTransforms.map((transform) => ( - - ))} -
- ) : ( - // Main menu -
- - -
- -
- )} -
, - document.body, + {t`Back`} + + + {blockTransforms.map((transform) => ( + + ))} + + ) : ( + <> + + + + + + )} + + ); } diff --git a/packages/admin/src/components/editor/DragHandleWrapper.tsx b/packages/admin/src/components/editor/DragHandleWrapper.tsx index 74997f258..867587b25 100644 --- a/packages/admin/src/components/editor/DragHandleWrapper.tsx +++ b/packages/admin/src/components/editor/DragHandleWrapper.tsx @@ -109,6 +109,9 @@ export function DragHandleWrapper({ editor, onInsertBlock }: DragHandleWrapperPr // Close the menu const handleCloseMenu = React.useCallback(() => { setMenuOpen(false); + }, []); + + const handleMenuCloseComplete = React.useCallback(() => { setMenuAnchor(null); editor.commands.setMeta("lockDragHandle", false); }, [editor]); @@ -119,13 +122,10 @@ export function DragHandleWrapper({ editor, onInsertBlock }: DragHandleWrapperPr if (data.node) { setHoveredNode({ node: data.node, pos: data.pos }); } else { - // Only clear if menu is not open - if (!menuOpen) { - setHoveredNode(null); - } + setHoveredNode(null); } }, - [menuOpen], + [], ); // Stable reference — DragHandle's useEffect depends on this by reference. @@ -186,6 +186,8 @@ export function DragHandleWrapper({ editor, onInsertBlock }: DragHandleWrapperPr onClick={handleClick} data-block-handle aria-label={t`Block actions - drag to reorder, click for menu`} + aria-haspopup="menu" + aria-expanded={menuOpen} >