diff --git a/.changeset/refine-portable-text-editor.md b/.changeset/refine-portable-text-editor.md new file mode 100644 index 0000000000..fe365127d5 --- /dev/null +++ b/.changeset/refine-portable-text-editor.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Improves portable text editing with a centered writing column, a responsive toolbar, clearer empty-state guidance, direct block insertion controls, and floating controls that avoid clipping and toolbar overlap. diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 806aca20a6..98f72e2595 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -1174,7 +1174,7 @@ function FieldRenderer({ { + rootBoundary: { x: number; y: number; width: number; height: number }; + padding: number; +}; + // Import converters from inline module since we can't import from emdash package // These will be duplicated here until we set up proper package exports @@ -992,6 +1006,8 @@ interface SlashCommandItem { description: MessageDescriptor | string; icon: Icon | React.ComponentType<{ className?: string }>; command: (props: { editor: Editor; range: Range }) => void; + /** Delay document insertion until a modal-backed command returns a selection. */ + deferInsertion?: boolean; aliases?: string[]; /** * Display category. Built-in commands use `msg`-tagged descriptors; @@ -1126,6 +1142,9 @@ interface SlashMenuState { selectedIndex: number; clientRect: (() => DOMRect | null) | null; range: Range | null; + trigger: "slash" | "gutter"; + gutterBlockPos: number | null; + dismissedSlashFrom: number | null; } /** @@ -1140,6 +1159,14 @@ function createSlashCommandsExtension(options: { return Extension.create({ name: "slashCommands", + onTransaction() { + const state = getState(); + if (state.dismissedSlashFrom === null) return; + const to = Math.min(state.dismissedSlashFrom + 1, this.editor.state.doc.content.size); + if (this.editor.state.doc.textBetween(state.dismissedSlashFrom, to) !== "/") { + onStateChange((prev) => ({ ...prev, dismissedSlashFrom: null })); + } + }, addProseMirrorPlugins() { return [ @@ -1152,6 +1179,7 @@ function createSlashCommandsExtension(options: { item.command({ editor, range }); }, items: ({ query }) => filterCommands(query), + allow: ({ range }) => getState().dismissedSlashFrom !== range.from, render: () => { return { onStart: (props) => { @@ -1161,6 +1189,9 @@ function createSlashCommandsExtension(options: { selectedIndex: 0, clientRect: props.clientRect ?? null, range: props.range, + trigger: "slash", + gutterBlockPos: null, + dismissedSlashFrom: null, }); }, onUpdate: (props) => { @@ -1170,12 +1201,20 @@ function createSlashCommandsExtension(options: { selectedIndex: 0, clientRect: props.clientRect ?? null, range: props.range, + trigger: "slash", + gutterBlockPos: null, + dismissedSlashFrom: null, })); }, onKeyDown: (props) => { - if (props.event.key === "Escape") { - onStateChange((prev) => ({ ...prev, isOpen: false })); - return true; + if (props.event.key === "Escape" || props.event.key === "Tab") { + onStateChange((prev) => ({ + ...prev, + isOpen: false, + dismissedSlashFrom: props.range.from, + })); + exitSuggestion(props.view); + return props.event.key === "Escape"; } if (props.event.key === "ArrowUp") { @@ -1220,13 +1259,11 @@ function createSlashCommandsExtension(options: { }); } -/** - * Slash command menu component using Floating UI - */ +/** Slash command menu anchored to the TipTap caret. */ function SlashCommandMenu({ state, onCommand, - onClose: _onClose, + onClose, setSelectedIndex, }: { state: SlashMenuState; @@ -1236,23 +1273,13 @@ function SlashCommandMenu({ }) { const { t } = useLingui(); const containerRef = React.useRef(null); - - const { refs, floatingStyles } = useFloating({ - open: state.isOpen, - placement: "bottom-start", - middleware: [offset(8), flip(), shift({ padding: 8 })], - whileElementsMounted: autoUpdate, - }); - - // Sync virtual reference from TipTap's clientRect - React.useEffect(() => { - if (state.clientRect) { - const clientRectFn = state.clientRect; - refs.setReference({ - getBoundingClientRect: () => clientRectFn() ?? new DOMRect(), - }); - } - }, [state.clientRect, refs]); + const popupRef = React.useRef(null); + const virtualAnchor = React.useMemo( + () => ({ + getBoundingClientRect: () => state.clientRect?.() ?? new DOMRect(), + }), + [state.clientRect], + ); // Scroll selected item into view React.useEffect(() => { @@ -1282,58 +1309,120 @@ function SlashCommandMenu({ } }, [state.isOpen]); - if (!state.isOpen) return null; + React.useEffect(() => { + if (!state.isOpen) return; - return createPortal( -
{ - containerRef.current = node; - refs.setFloating(node); - }} - style={floatingStyles} - className="z-[100] rounded-lg border bg-kumo-overlay p-1 shadow-lg min-w-[220px] max-h-[300px] overflow-y-auto" - onPointerMove={() => { - hasMouseMovedRef.current = true; + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Node | null; + if (target && popupRef.current?.contains(target)) return; + onClose(); + }; + + document.addEventListener("pointerdown", handlePointerDown, true); + return () => document.removeEventListener("pointerdown", handlePointerDown, true); + }, [onClose, state.isOpen]); + + const selectedItem = state.items[state.selectedIndex]; + const selectedItemTitle = selectedItem + ? typeof selectedItem.title === "string" + ? selectedItem.title + : t(selectedItem.title) + : ""; + + return ( + { + if (!open && state.isOpen) onClose(); }} > - {state.items.length === 0 ? ( -

{t`No results`}

- ) : ( - state.items.map((item, index) => ( - + )) + )}
- - )) - )} - , - document.body, + + + + ); } @@ -2096,7 +2185,7 @@ export interface PortableTextEditorProps { export function PortableTextEditor({ value, onChange, - placeholder = "Start writing...", + placeholder, className, editable = true, "aria-labelledby": ariaLabelledby, @@ -2109,6 +2198,30 @@ export function PortableTextEditor({ onBlockSidebarClose, }: PortableTextEditorProps) { const { t } = useLingui(); + const placeholderRef = React.useRef(placeholder ?? t`Start writing, or type '/' for commands`); + placeholderRef.current = placeholder ?? t`Start writing, or type '/' for commands`; + const toolbarRef = React.useRef(null); + const floatingRootRef = React.useRef(null); + const appendBubbleMenu = React.useCallback(() => floatingRootRef.current!, []); + const getBubbleMenuCollisionOptions = React.useCallback(() => { + const viewport = window.visualViewport; + const viewportTop = viewport?.offsetTop ?? 0; + const viewportLeft = viewport?.offsetLeft ?? 0; + const viewportWidth = viewport?.width ?? window.innerWidth; + const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight); + const toolbarBottom = toolbarRef.current?.getBoundingClientRect().bottom ?? viewportTop; + const safeTop = Math.min(viewportBottom, Math.max(viewportTop, toolbarBottom)); + + return { + rootBoundary: { + x: viewportLeft, + y: safeTop, + width: viewportWidth, + height: Math.max(0, viewportBottom - safeTop), + }, + padding: 8, + }; + }, []); // Use a ref for onChange to avoid recreating the editor when the callback changes const onChangeRef = React.useRef(onChange); @@ -2140,6 +2253,7 @@ export function PortableTextEditor({ // Section picker state (for inserting sections) const [sectionPickerOpen, setSectionPickerOpen] = React.useState(false); + const pendingBlockInsertPosRef = React.useRef(null); // Slash commands state const [slashMenuState, setSlashMenuStateRaw] = React.useState({ @@ -2148,6 +2262,9 @@ export function PortableTextEditor({ selectedIndex: 0, clientRect: null, range: null, + trigger: "slash", + gutterBlockPos: null, + dismissedSlashFrom: null, }); // Ref to access current state synchronously in keyboard handlers. @@ -2192,6 +2309,7 @@ export function PortableTextEditor({ icon: ImageIcon, aliases: ["img", "photo", "picture", "url"], category: msg`Media`, + deferInsertion: true, command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).run(); setMediaPickerOpen(true); @@ -2206,6 +2324,7 @@ export function PortableTextEditor({ icon: Stack, aliases: ["pattern", "block", "template"], category: msg`Content`, + deferInsertion: true, command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).run(); setSectionPickerOpen(true); @@ -2222,6 +2341,7 @@ export function PortableTextEditor({ icon: resolveIcon(block.icon), aliases: [block.type], category: block.category ?? msg`Embeds`, + deferInsertion: true, command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).run(); setPluginBlockModal(block); @@ -2302,12 +2422,7 @@ export function PortableTextEditor({ TableCell, Placeholder.configure({ includeChildren: true, - placeholder: ({ node }) => { - if (node.type.name === "paragraph") { - return placeholder; - } - return placeholder; - }, + placeholder: () => placeholderRef.current, }), TextAlign.configure({ types: ["heading", "paragraph"], @@ -2334,7 +2449,7 @@ export function PortableTextEditor({ () => ({ attributes: { class: - "prose prose-sm sm:prose-base dark:prose-invert max-w-none focus:outline-none min-h-[200px] p-4", + "prose prose-sm sm:prose-base dark:prose-invert w-full max-w-[calc(75ch+8rem)] mx-auto focus:outline-none min-h-[200px] p-4 ps-14 pe-14 sm:ps-16 sm:pe-16", dir: "auto", }, }), @@ -2359,6 +2474,176 @@ export function PortableTextEditor({ }, }); + const openBlockInsertMenuAt = React.useCallback( + (insertPos: number) => { + if (!editor) return; + const state = slashMenuStateRef.current; + const activeSlashFrom = state.trigger === "slash" && state.isOpen ? state.range?.from : null; + if (activeSlashFrom != null) { + setSlashMenuState((prev) => ({ ...prev, dismissedSlashFrom: activeSlashFrom })); + } + exitSuggestion(editor.view); + editor.commands.focus(); + + setSlashMenuState((prev) => ({ + isOpen: true, + items: filterCommandsRef.current(""), + selectedIndex: 0, + clientRect: () => { + if (editor.isDestroyed) return null; + const coords = editor.view.coordsAtPos(insertPos); + const DOMRectCtor = editor.view.dom.ownerDocument.defaultView?.DOMRect; + if (!DOMRectCtor) return null; + return new DOMRectCtor( + coords.left, + coords.top, + coords.right - coords.left, + coords.bottom - coords.top, + ); + }, + range: { from: insertPos, to: insertPos }, + trigger: "gutter", + gutterBlockPos: insertPos, + dismissedSlashFrom: prev.dismissedSlashFrom, + })); + }, + [editor, setSlashMenuState], + ); + + const closeSlashMenu = React.useCallback(() => { + const state = slashMenuStateRef.current; + setSlashMenuState((prev) => ({ + ...prev, + isOpen: false, + gutterBlockPos: null, + dismissedSlashFrom: + state.trigger === "slash" ? (state.range?.from ?? null) : prev.dismissedSlashFrom, + })); + if (editor && state.trigger === "slash") { + exitSuggestion(editor.view); + } + }, [editor, setSlashMenuState]); + + const executeSlashCommand = React.useCallback( + (item: SlashCommandItem, state: SlashMenuState) => { + if (!editor || !state.range) return; + + let range = state.range; + if (state.trigger === "gutter") { + const insertPos = state.gutterBlockPos; + if (insertPos === null) return; + + if (item.deferInsertion) { + pendingBlockInsertPosRef.current = insertPos; + const selectionPos = editor.state.selection.from; + range = { from: selectionPos, to: selectionPos }; + } else { + const selectionPos = insertPos + 1; + const inserted = editor + .chain() + .focus() + .insertContentAt(insertPos, { type: "paragraph" }) + .setTextSelection(selectionPos) + .run(); + if (!inserted) return; + range = { from: selectionPos, to: selectionPos }; + } + } + + item.command({ editor, range }); + setSlashMenuState((prev) => ({ ...prev, isOpen: false, gutterBlockPos: null })); + }, + [editor, setSlashMenuState], + ); + + React.useEffect(() => { + if (!editor || !slashMenuState.isOpen || slashMenuState.trigger !== "gutter") return; + + const handleKeyDown = (event: KeyboardEvent) => { + const state = slashMenuStateRef.current; + if (!state.isOpen || state.trigger !== "gutter") return; + + if (event.key === "Tab") { + closeSlashMenu(); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + closeSlashMenu(); + return; + } + + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + if (state.items.length === 0) return; + event.preventDefault(); + const delta = event.key === "ArrowUp" ? -1 : 1; + setSlashMenuState((prev) => ({ + ...prev, + selectedIndex: (prev.selectedIndex + delta + prev.items.length) % prev.items.length, + })); + return; + } + + if (event.key === "Enter") { + const item = state.items[state.selectedIndex]; + if (!item || !state.range) return; + event.preventDefault(); + executeSlashCommand(item, state); + return; + } + + if ( + event.key.length === 1 && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.isComposing && + state.gutterBlockPos !== null + ) { + event.preventDefault(); + const selectionPos = state.gutterBlockPos + event.key.length + 1; + editor + .chain() + .focus() + .insertContentAt(state.gutterBlockPos, { + type: "paragraph", + content: [{ type: "text", text: event.key }], + }) + .setTextSelection(selectionPos) + .run(); + setSlashMenuState((prev) => ({ ...prev, isOpen: false, gutterBlockPos: null })); + return; + } + + if (event.key === "Backspace" || event.key === "Delete") { + event.preventDefault(); + closeSlashMenu(); + } + }; + + const editorElement = editor.view.dom; + editorElement.addEventListener("keydown", handleKeyDown, true); + return () => editorElement.removeEventListener("keydown", handleKeyDown, true); + }, [ + closeSlashMenu, + editor, + executeSlashCommand, + setSlashMenuState, + slashMenuState.isOpen, + slashMenuState.trigger, + ]); + + const handleTouchInsertBlock = React.useCallback(() => { + if (!editor) return; + const { $from } = editor.state.selection; + const topLevelPos = $from.depth > 0 ? $from.before(1) : $from.pos; + const topLevelNode = editor.state.doc.nodeAt(topLevelPos); + openBlockInsertMenuAt( + topLevelNode ? topLevelPos + topLevelNode.nodeSize : editor.state.doc.content.size, + ); + }, [editor, openBlockInsertMenuAt]); + // Notify when editor is ready, and on unmount so consumers can clear the // reference before TipTap destroys the instance (e.g. when keying by item.id // to switch translations). @@ -2372,6 +2657,27 @@ export function PortableTextEditor({ return undefined; }, [editor, onEditorReady]); + React.useEffect(() => { + const viewport = window.visualViewport; + if (!editor || !viewport) return; + + const updateBubbleMenuPositions = () => { + if (editor.isDestroyed) return; + editor.view.dispatch( + editor.state.tr + .setMeta(INLINE_BUBBLE_MENU_KEY, "updatePosition") + .setMeta(TABLE_BUBBLE_MENU_KEY, "updatePosition"), + ); + }; + + viewport.addEventListener("resize", updateBubbleMenuPositions); + viewport.addEventListener("scroll", updateBubbleMenuPositions); + return () => { + viewport.removeEventListener("resize", updateBubbleMenuPositions); + viewport.removeEventListener("scroll", updateBubbleMenuPositions); + }; + }, [editor]); + // Register plugin blocks into editor storage so the node view can look up metadata React.useEffect(() => { if (editor) { @@ -2433,21 +2739,25 @@ export function PortableTextEditor({ if (editor) { // For external providers, src is only used for admin preview // The frontend Image component uses provider + mediaId to generate proper URLs - editor - .chain() - .focus() - .setImage({ - src: item.url, - alt: item.alt || item.filename, - mediaId: item.id, - provider: item.provider || "local", - width: item.width, - height: item.height, - blurhash: item.blurhash, - dominantColor: item.dominantColor, - }) - .run(); + const attrs = { + src: item.url, + alt: item.alt || item.filename, + mediaId: item.id, + provider: item.provider || "local", + width: item.width, + height: item.height, + blurhash: item.blurhash, + dominantColor: item.dominantColor, + }; + const insertPos = pendingBlockInsertPosRef.current; + const chain = editor.chain().focus(); + if (insertPos === null) { + chain.setImage(attrs).run(); + } else { + chain.insertContentAt(insertPos, { type: "image", attrs }).run(); + } } + pendingBlockInsertPosRef.current = null; setMediaPickerOpen(false); }, [editor], @@ -2483,20 +2793,24 @@ export function PortableTextEditor({ .run(); } else { // Inserting a new block - editor - .chain() - .focus() - .insertContent({ - type: "pluginBlock", - attrs: { - blockType: pluginBlockModal.type, - id: typeof id === "string" ? id : "", - data, - }, - }) - .run(); + const content = { + type: "pluginBlock", + attrs: { + blockType: pluginBlockModal.type, + id: typeof id === "string" ? id : "", + data, + }, + }; + const insertPos = pendingBlockInsertPosRef.current; + const chain = editor.chain().focus(); + if (insertPos === null) { + chain.insertContent(content).run(); + } else { + chain.insertContentAt(insertPos, content).run(); + } } + pendingBlockInsertPosRef.current = null; setPluginBlockModal(null); setPluginBlockInitialValues(undefined); editingBlockPosRef.current = null; @@ -2507,12 +2821,10 @@ export function PortableTextEditor({ // Handle slash menu command execution const handleSlashCommand = React.useCallback( (item: SlashCommandItem) => { - if (editor && slashMenuState.range) { - item.command({ editor, range: slashMenuState.range }); - setSlashMenuState((prev) => ({ ...prev, isOpen: false })); - } + const state = slashMenuStateRef.current; + executeSlashCommand(item, state); }, - [editor, slashMenuState.range], + [executeSlashCommand], ); // Handle section selection - insert section content at cursor @@ -2526,8 +2838,14 @@ export function PortableTextEditor({ : []; const { content: prosemirrorContent } = portableTextToProsemirror(ptContent); - // Insert the content at current cursor position - editor.chain().focus().insertContent(prosemirrorContent).run(); + const insertPos = pendingBlockInsertPosRef.current; + const chain = editor.chain().focus(); + if (insertPos === null) { + chain.insertContent(prosemirrorContent).run(); + } else { + chain.insertContentAt(insertPos, prosemirrorContent).run(); + } + pendingBlockInsertPosRef.current = null; }, [editor], ); @@ -2541,63 +2859,87 @@ export function PortableTextEditor({ } return ( -
- {!minimal && ( - - )} - - -
- - {editable && } -
- {!minimal && } - - {/* Slash command menu */} - setSlashMenuState((prev) => ({ ...prev, isOpen: false }))} - setSelectedIndex={(index) => - setSlashMenuState((prev) => ({ ...prev, selectedIndex: index })) - } +
+ - - {/* Media picker for image insertion */} - +
+ {!minimal && ( + + )} +
+ + {editable && } +
+ {!minimal && } + + {/* Slash command menu */} + + setSlashMenuState((prev) => ({ ...prev, selectedIndex: index })) + } + /> - {/* Plugin block insertion/editing modal */} - { - setPluginBlockModal(null); - setPluginBlockInitialValues(undefined); - editingBlockPosRef.current = null; - }} - onInsert={handlePluginBlockInsert} - /> + {/* Media picker for image insertion */} + { + setMediaPickerOpen(open); + if (!open) pendingBlockInsertPosRef.current = null; + }} + onSelect={handleImageSelect} + mimeTypeFilter="image/" + title={t`Select Image`} + /> - {/* Section picker modal */} - + {/* Plugin block insertion/editing modal */} + { + pendingBlockInsertPosRef.current = null; + setPluginBlockModal(null); + setPluginBlockInitialValues(undefined); + editingBlockPosRef.current = null; + }} + onInsert={handlePluginBlockInsert} + /> + + {/* Section picker modal */} + { + setSectionPickerOpen(open); + if (!open) pendingBlockInsertPosRef.current = null; + }} + onSelect={handleSectionSelect} + /> +
); } @@ -2606,12 +2948,30 @@ export function PortableTextEditor({ * Bubble Menu - appears when text is selected * Shows inline formatting options and link editing */ -function EditorBubbleMenu({ editor }: { editor: Editor }) { +function EditorBubbleMenu({ + editor, + appendTo, + getCollisionOptions, +}: { + editor: Editor; + appendTo: () => HTMLElement; + getCollisionOptions: BubbleMenuCollisionOptions; +}) { const [showLinkInput, setShowLinkInput] = React.useState(false); const [linkUrl, setLinkUrl] = React.useState(""); const inputRef = React.useRef(null); const { t } = useLingui(); - + const activeMarks = useEditorState({ + editor, + selector: ({ editor: activeEditor }) => ({ + bold: activeEditor.isActive("bold"), + italic: activeEditor.isActive("italic"), + underline: activeEditor.isActive("underline"), + strike: activeEditor.isActive("strike"), + code: activeEditor.isActive("code"), + link: activeEditor.isActive("link"), + }), + }); // When bubble menu opens with link input, populate the URL React.useEffect(() => { if (showLinkInput) { @@ -2652,12 +3012,32 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) { return ( ({ + ...getCollisionOptions(), + apply: ({ availableWidth, elements }) => { + elements.floating.style.maxWidth = `${Math.max(0, availableWidth)}px`; + elements.floating.style.overflowX = "auto"; + }, + }), }} + shouldShow={({ editor: activeEditor, element, state, view }) => { + const { selection } = state; + return ( + activeEditor.isEditable && + (selection instanceof TextSelection || selection instanceof AllSelection) && + !selection.empty && + (view.hasFocus() || element.contains(document.activeElement)) + ); + }} + data-emdash-inline-bubble-menu className="z-[100] flex items-center gap-0.5 rounded-lg border bg-kumo-base p-1 shadow-lg" > {showLinkInput ? ( @@ -2665,11 +3045,12 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) { setLinkUrl(e.target.value)} onKeyDown={handleKeyDown} className="h-8 w-48 text-sm" + aria-label={t`URL`} /> - {editor.isActive("link") && ( + {activeMarks.link && ( @@ -2861,19 +3270,21 @@ function BubbleButton({ * Arrow keys move focus between buttons, Home/End jump to first/last. */ function EditorToolbar({ + toolbarRef, editor, focusMode, onFocusModeChange, + onInsertBlock, }: { + toolbarRef: React.RefObject; editor: Editor; focusMode: FocusMode; onFocusModeChange: (mode: FocusMode) => void; + onInsertBlock: () => void; }) { const { t } = useLingui(); - const [mediaPickerOpen, setMediaPickerOpen] = React.useState(false); const [showLinkPopover, setShowLinkPopover] = React.useState(false); const [linkUrl, setLinkUrl] = React.useState(""); - const toolbarRef = React.useRef(null); const linkInputRef = React.useRef(null); // Subscribe to editor state changes for reactive button states @@ -2885,9 +3296,6 @@ function EditorToolbar({ isUnderline: ctx.editor.isActive("underline"), isStrike: ctx.editor.isActive("strike"), isCode: ctx.editor.isActive("code"), - isHeading1: ctx.editor.isActive("heading", { level: 1 }), - isHeading2: ctx.editor.isActive("heading", { level: 2 }), - isHeading3: ctx.editor.isActive("heading", { level: 3 }), isBulletList: ctx.editor.isActive("bulletList"), isOrderedList: ctx.editor.isActive("orderedList"), isBlockquote: ctx.editor.isActive("blockquote"), @@ -2937,25 +3345,6 @@ function EditorToolbar({ } }; - const handleImageSelect = React.useCallback( - (item: MediaItem) => { - editor - .chain() - .focus() - .setImage({ - src: item.url, - alt: item.alt || item.filename, - mediaId: item.id, - width: item.width, - height: item.height, - blurhash: item.blurhash, - dominantColor: item.dominantColor, - }) - .run(); - }, - [editor], - ); - // Keyboard navigation for toolbar (WAI-ARIA toolbar pattern) const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => { const toolbar = toolbarRef.current; @@ -2965,18 +3354,23 @@ function EditorToolbar({ ...toolbar.querySelectorAll( 'button:not([disabled]), [role="button"]:not([disabled])', ), - ]; + ].filter((button) => button.getClientRects().length > 0); const currentIndex = buttons.findIndex((btn) => btn === document.activeElement); if (currentIndex === -1) return; + const isRtl = getComputedStyle(toolbar).direction === "rtl"; let nextIndex: number | null = null; switch (e.key) { case "ArrowRight": + nextIndex = (currentIndex + (isRtl ? -1 : 1) + buttons.length) % buttons.length; + break; + case "ArrowLeft": + nextIndex = (currentIndex + (isRtl ? 1 : -1) + buttons.length) % buttons.length; + break; case "ArrowDown": nextIndex = (currentIndex + 1) % buttons.length; break; - case "ArrowLeft": case "ArrowUp": nextIndex = (currentIndex - 1 + buttons.length) % buttons.length; break; @@ -3001,11 +3395,24 @@ function EditorToolbar({ ref={toolbarRef} role="toolbar" aria-label={t`Text formatting`} - className="sticky -top-6 z-10 border-b bg-kumo-tint p-1 flex flex-wrap gap-0.5" + className="sticky -top-6 z-10 flex flex-nowrap gap-0.5 overflow-x-auto border-b bg-kumo-tint p-1" + style={{ justifyContent: "safe center" }} onKeyDown={handleKeyDown} > {/* Text formatting */} + editor.chain().focus().toggleBold().run()} active={editorState.isBold} @@ -3047,27 +3454,7 @@ function EditorToolbar({ {/* Headings */} - editor.chain().focus().toggleHeading({ level: 1 }).run()} - active={editorState.isHeading1} - title={t`Heading 1`} - > - - editor.chain().focus().toggleHeading({ level: 2 }).run()} - active={editorState.isHeading2} - title={t`Heading 2`} - > - - editor.chain().focus().toggleHeading({ level: 3 }).run()} - active={editorState.isHeading3} - title={t`Heading 3`} - > - + @@ -3102,15 +3489,6 @@ function EditorToolbar({ > - - editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() - } - active={editor.isActive("table")} - title={t`Insert Table`} - > - @@ -3142,87 +3520,81 @@ function EditorToolbar({ - {/* Insert */} + {/* Link */} - {/* Link with popover */} -
- setShowLinkPopover(!showLinkPopover)} - active={editorState.isLink} - title={t`Insert Link`} - > - - {showLinkPopover && ( -
-
- -
- setLinkUrl(e.target.value)} - onKeyDown={handleLinkKeyDown} - className="h-8 w-52 text-sm" - /> -
-
- -
- {editorState.isLink && ( - - )} - + } + /> + +
+ +
+ setLinkUrl(e.target.value)} + onKeyDown={handleLinkKeyDown} + className="h-8 w-52 text-sm" + aria-label={t`URL`} + /> +
+
+ +
+ {editorState.isLink && ( + -
+ )} +
- )} -
- setMediaPickerOpen(true)} title={t`Insert Image`}> - - - editor - .chain() - .focus() - .insertContent({ type: "htmlBlock", attrs: { html: "" } }) - .run() - } - title={t`Insert HTML`} - > - - editor.chain().focus().setHorizontalRule().run()} - title={t`Insert Horizontal Rule`} - > - + +
); } function ToolbarGroup({ children }: { children: React.ReactNode }) { - return
{children}
; + return
{children}
; } function ToolbarSeparator() { - return
; + return
; } interface ToolbarButtonProps { @@ -3292,7 +3655,7 @@ function ToolbarButton({ onClick, active, disabled, title, children }: ToolbarBu type="button" variant="ghost" shape="square" - className={cn("h-8 w-8", active && "bg-kumo-tint text-kumo-default")} + className={cn("h-8 w-8 flex-none", active && "bg-kumo-tint text-kumo-default")} onMouseDown={(e) => e.preventDefault()} onClick={onClick} disabled={disabled} diff --git a/packages/admin/src/components/editor/DragHandleWrapper.tsx b/packages/admin/src/components/editor/DragHandleWrapper.tsx index 2d75374cdd..74997f2585 100644 --- a/packages/admin/src/components/editor/DragHandleWrapper.tsx +++ b/packages/admin/src/components/editor/DragHandleWrapper.tsx @@ -8,18 +8,22 @@ * - Block menu integration for transforms, duplicate, delete */ +import { Button } from "@cloudflare/kumo"; +import { offset } from "@floating-ui/react"; import { useLingui } from "@lingui/react/macro"; -import { DotsSixVertical } from "@phosphor-icons/react"; +import { DotsSixVertical, Plus } from "@phosphor-icons/react"; import type { Editor } from "@tiptap/core"; import { DragHandle } from "@tiptap/extension-drag-handle-react"; import type { Node as PMNode } from "@tiptap/pm/model"; import * as React from "react"; import { cn } from "../../lib/utils"; +import { getLocaleDir } from "../../locales/config.js"; import { BlockMenu } from "./BlockMenu"; interface DragHandleWrapperProps { editor: Editor; + onInsertBlock: (insertPos: number) => void; } interface HoveredNode { @@ -27,26 +31,48 @@ interface HoveredNode { pos: number; } -// Extend Editor commands type to include DragHandle commands -declare module "@tiptap/core" { - interface Commands { - dragHandle: { - lockDragHandle: () => ReturnType; - unlockDragHandle: () => ReturnType; - toggleDragHandle: () => ReturnType; - }; - } +export function _getDragHandlePlacement(direction: "ltr" | "rtl") { + return direction === "rtl" ? ("right-start" as const) : ("left-start" as const); } /** * DragHandleWrapper - Official TipTap drag handle with BlockMenu integration */ -export function DragHandleWrapper({ editor }: DragHandleWrapperProps) { - const { t } = useLingui(); +export function DragHandleWrapper({ editor, onInsertBlock }: DragHandleWrapperProps) { + const { i18n, t } = useLingui(); + const direction = getLocaleDir(i18n.locale); const [hoveredNode, setHoveredNode] = React.useState(null); const [menuOpen, setMenuOpen] = React.useState(false); const [menuAnchor, setMenuAnchor] = React.useState(null); const handleRef = React.useRef(null); + const insertPressLockedRef = React.useRef(false); + + const disableDrag = React.useCallback( + (e: React.PointerEvent) => { + e.stopPropagation(); + if (!insertPressLockedRef.current) { + insertPressLockedRef.current = true; + editor.commands.setMeta("lockDragHandle", true); + } + }, + [editor], + ); + + const restoreDrag = React.useCallback(() => { + if (insertPressLockedRef.current) { + insertPressLockedRef.current = false; + editor.commands.setMeta("lockDragHandle", menuOpen); + } + }, [editor, menuOpen]); + + React.useEffect(() => { + window.addEventListener("pointerup", restoreDrag, true); + window.addEventListener("pointercancel", restoreDrag, true); + return () => { + window.removeEventListener("pointerup", restoreDrag, true); + window.removeEventListener("pointercancel", restoreDrag, true); + }; + }, [restoreDrag]); // Handle click on drag handle to open menu const handleClick = React.useCallback( @@ -64,16 +90,27 @@ export function DragHandleWrapper({ editor }: DragHandleWrapperProps) { setMenuOpen(true); // Lock the drag handle so it stays visible while menu is open - editor.commands.lockDragHandle(); + editor.commands.setMeta("lockDragHandle", true); }, [editor, hoveredNode], ); + const handleInsertClick = React.useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!hoveredNode) return; + + onInsertBlock(hoveredNode.pos + hoveredNode.node.nodeSize); + }, + [hoveredNode, onInsertBlock], + ); + // Close the menu const handleCloseMenu = React.useCallback(() => { setMenuOpen(false); setMenuAnchor(null); - editor.commands.unlockDragHandle(); + editor.commands.setMeta("lockDragHandle", false); }, [editor]); // Handle node change from drag handle @@ -96,10 +133,11 @@ export function DragHandleWrapper({ editor }: DragHandleWrapperProps) { // tears down the Suggestion plugin view (calling onExit → setState → loop). const computePositionConfig = React.useMemo( () => ({ - placement: "left-start" as const, + placement: _getDragHandlePlacement(direction), strategy: "absolute" as const, + middleware: [offset(4)], }), - [], + [direction], ); return ( @@ -109,23 +147,49 @@ export function DragHandleWrapper({ editor }: DragHandleWrapperProps) { onNodeChange={handleNodeChange} computePositionConfig={computePositionConfig} > - +
+ + +
{/* Block menu */} diff --git a/packages/admin/src/components/editor/HeadingDropdownMenu.tsx b/packages/admin/src/components/editor/HeadingDropdownMenu.tsx new file mode 100644 index 0000000000..14eb8ebb46 --- /dev/null +++ b/packages/admin/src/components/editor/HeadingDropdownMenu.tsx @@ -0,0 +1,182 @@ +import { Button, DropdownMenu } from "@cloudflare/kumo"; +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react/macro"; +import { + CaretDown, + TextH, + TextHFive, + TextHFour, + TextHOne, + TextHSix, + TextHThree, + TextHTwo, + type Icon, +} from "@phosphor-icons/react"; +import type { Editor } from "@tiptap/core"; +import { useEditorState } from "@tiptap/react"; +import * as React from "react"; + +import { cn } from "../../lib/utils.js"; + +/** + * TipTap's heading-dropdown-menu API adapted to EmDash's Kumo primitives. + * Source pattern: `npx @tiptap/cli add heading-dropdown-menu`. + */ + +export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; + +const DEFAULT_LEVELS: readonly HeadingLevel[] = [1, 2, 3, 4, 5, 6]; + +const HEADING_LABELS: Record = { + 1: msg`Heading 1`, + 2: msg`Heading 2`, + 3: msg`Heading 3`, + 4: msg`Heading 4`, + 5: msg`Heading 5`, + 6: msg`Heading 6`, +}; + +const HEADING_ICONS: Record = { + 1: TextHOne, + 2: TextHTwo, + 3: TextHThree, + 4: TextHFour, + 5: TextHFive, + 6: TextHSix, +}; + +export function getActiveHeadingLevel( + editor: Editor | null, + levels: readonly HeadingLevel[] = DEFAULT_LEVELS, +): HeadingLevel | undefined { + if (!editor || !editor.isEditable) return undefined; + return levels.find((level) => editor.isActive("heading", { level })); +} + +function canToggleHeading(editor: Editor | null, levels: readonly HeadingLevel[]): boolean { + if (!editor || !editor.isEditable || !editor.schema.nodes.heading) return false; + return levels.some( + (level) => editor.can().setNode("heading", { level }) || editor.can().clearNodes(), + ); +} + +function toggleHeading(editor: Editor | null, level: HeadingLevel): boolean { + if (!editor || !canToggleHeading(editor, [level])) return false; + return editor.chain().focus().toggleHeading({ level }).run(); +} + +export interface UseHeadingDropdownMenuConfig { + editor?: Editor | null; + levels?: readonly HeadingLevel[]; + hideWhenUnavailable?: boolean; +} + +export function useHeadingDropdownMenu(config: UseHeadingDropdownMenuConfig = {}) { + const { editor = null, levels = DEFAULT_LEVELS, hideWhenUnavailable = false } = config; + + const state = useEditorState({ + editor, + selector: ({ editor: currentEditor }) => { + const activeLevel = getActiveHeadingLevel(currentEditor, levels); + const canToggle = canToggleHeading(currentEditor, levels); + return { + activeLevel, + isActive: activeLevel !== undefined, + canToggle, + isVisible: !hideWhenUnavailable || canToggle, + }; + }, + }); + + const activeLevel = state?.activeLevel; + + return { + isVisible: state?.isVisible ?? !hideWhenUnavailable, + activeLevel, + isActive: state?.isActive ?? false, + canToggle: state?.canToggle ?? false, + levels, + Icon: activeLevel ? HEADING_ICONS[activeLevel] : TextH, + }; +} + +export interface HeadingDropdownMenuProps extends UseHeadingDropdownMenuConfig { + className?: string; + onOpenChange?: (isOpen: boolean) => void; +} + +export const HeadingDropdownMenu = React.forwardRef( + function HeadingDropdownMenu( + { + editor = null, + levels = DEFAULT_LEVELS, + hideWhenUnavailable = false, + className, + onOpenChange, + }, + ref, + ) { + const { t } = useLingui(); + const [open, setOpen] = React.useState(false); + const { isVisible, activeLevel, isActive, canToggle, Icon } = useHeadingDropdownMenu({ + editor, + levels, + hideWhenUnavailable, + }); + + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + if (nextOpen && !canToggle) return; + setOpen(nextOpen); + onOpenChange?.(nextOpen); + }, + [canToggle, onOpenChange], + ); + + if (!isVisible) return null; + + return ( + + event.preventDefault()} + aria-label={t`Headings`} + aria-haspopup="menu" + aria-expanded={open} + > + + ); + }, +); diff --git a/packages/admin/src/styles.css b/packages/admin/src/styles.css index d379c4003c..7d414deb57 100644 --- a/packages/admin/src/styles.css +++ b/packages/admin/src/styles.css @@ -110,6 +110,11 @@ body { color: var(--text-color-kumo-default); } +/* Keep heading choices visibly distinct from the dropdown surface. */ +[data-emdash-heading-item][data-highlighted] { + background-color: var(--color-kumo-interact) !important; +} + /* Transitions.dev — Text states swap */ :root { --text-swap-dur: 150ms; @@ -224,11 +229,6 @@ body { transition: opacity 0.2s ease; } -/* Add left padding to accommodate block handles */ -.ProseMirror { - padding-left: 2.5rem; -} - /* Block dragging styles */ .ProseMirror .dragging { opacity: 0.5; @@ -319,6 +319,11 @@ body { cursor: col-resize; } +/* TipTap owns slash-menu focus, so Base UI's portal guards must not enter the Tab order. */ +.slash-command-menu-positioner > [data-base-ui-focus-guard] { + display: none; +} + /** * Prevent dialogs from overflowing the viewport on small screens. * Kumo's size variants set min-width (e.g. min-w-[32rem] for "lg") diff --git a/packages/admin/tests/components/ContentEditor.test.tsx b/packages/admin/tests/components/ContentEditor.test.tsx index b9975c38b7..1921a034dd 100644 --- a/packages/admin/tests/components/ContentEditor.test.tsx +++ b/packages/admin/tests/components/ContentEditor.test.tsx @@ -215,6 +215,16 @@ describe("ContentEditor", () => { portableTextProps.current = null; }); + it("uses a task-oriented placeholder for portable text fields", async () => { + await renderEditor({ + isNew: false, + item: makeItem(), + fields: { content: { kind: "portableText", label: "Content" } }, + }); + + expect(portableTextProps.current?.placeholder).toBe("Start writing, or type '/' for commands"); + }); + describe("block panel + mobile sheet sync", () => { const ptFields: Record = { content: { kind: "portableText", label: "Content" }, diff --git a/packages/admin/tests/editor/DragHandleWrapper.interactions.test.tsx b/packages/admin/tests/editor/DragHandleWrapper.interactions.test.tsx new file mode 100644 index 0000000000..c66888e7d5 --- /dev/null +++ b/packages/admin/tests/editor/DragHandleWrapper.interactions.test.tsx @@ -0,0 +1,112 @@ +import { i18n } from "@lingui/core"; +import type { Editor } from "@tiptap/core"; +import * as React from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { DragHandleWrapper } from "../../src/components/editor/DragHandleWrapper"; +import { render } from "../utils/render"; + +vi.mock("@tiptap/extension-drag-handle-react", () => ({ + DragHandle: ({ + children, + computePositionConfig, + }: { + children: React.ReactNode; + computePositionConfig: { + placement: string; + middleware?: Array<{ name: string; options?: [number?] }>; + }; + }) => ( +
name === "offset")?.options?.[0] ?? "" + } + > + {children} +
+ ), +})); + +vi.mock("../../src/components/editor/BlockMenu", () => ({ + BlockMenu: () => null, +})); + +describe("DragHandleWrapper interactions", () => { + it("uses Kumo buttons for both drag-handle controls", async () => { + const editor = { + view: { dom: document.createElement("div") }, + } as unknown as Editor; + const screen = await render(); + + await expect + .element(screen.getByRole("button", { name: "Insert block below" })) + .toHaveAttribute("data-kumo-component", "Button"); + await expect + .element( + screen.getByRole("button", { + name: "Block actions - drag to reorder, click for menu", + }), + ) + .toHaveAttribute("data-kumo-component", "Button"); + }); + + it("disables native block dragging while pressing the insert button", async () => { + const editorElement = document.createElement("div"); + const setMeta = vi.fn((_key: string, locked: boolean) => { + const dragHandle = document.querySelector(".drag-handle"); + if (dragHandle) dragHandle.draggable = !locked; + return true; + }); + const editor = { + view: { dom: editorElement }, + commands: { setMeta }, + } as unknown as Editor; + const screen = await render(); + const insertButton = screen.getByRole("button", { name: "Insert block below" }).element(); + const dragHandle = insertButton.closest(".drag-handle"); + expect(dragHandle).not.toBe(insertButton); + expect(dragHandle?.draggable).toBe(true); + + insertButton.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + expect(setMeta).toHaveBeenLastCalledWith("lockDragHandle", true); + expect(dragHandle?.draggable).toBe(false); + + window.dispatchEvent(new PointerEvent("pointerup")); + expect(setMeta).toHaveBeenLastCalledWith("lockDragHandle", false); + expect(dragHandle?.draggable).toBe(true); + }); + + it("places and orders controls from the admin UI direction", async () => { + const previousLocale = i18n.locale; + i18n.load("ar", {}); + i18n.load("en", {}); + i18n.activate("en"); + const editorElement = document.createElement("div"); + editorElement.dir = "ltr"; + const editor = { + view: { dom: editorElement }, + } as unknown as Editor; + + try { + const screen = await render(); + const insertButton = screen.getByRole("button", { name: "Insert block below" }).element(); + expect(insertButton.closest("[data-placement]")?.getAttribute("data-placement")).toBe( + "left-start", + ); + expect(insertButton.closest("[data-offset]")?.getAttribute("data-offset")).toBe("4"); + + i18n.activate("ar"); + await vi.waitFor(() => { + expect(insertButton.closest("[data-placement]")?.getAttribute("data-placement")).toBe( + "right-start", + ); + }); + expect(insertButton.parentElement?.className).toContain("rtl:flex-row-reverse"); + } finally { + i18n.activate(previousLocale); + } + }); +}); diff --git a/packages/admin/tests/editor/DragHandleWrapper.test.ts b/packages/admin/tests/editor/DragHandleWrapper.test.ts new file mode 100644 index 0000000000..d0d38e499c --- /dev/null +++ b/packages/admin/tests/editor/DragHandleWrapper.test.ts @@ -0,0 +1,46 @@ +import { Editor } from "@tiptap/core"; +import { DragHandlePlugin, normalizeNestedOptions } from "@tiptap/extension-drag-handle"; +import StarterKit from "@tiptap/starter-kit"; +import { describe, expect, it } from "vitest"; + +import { _getDragHandlePlacement } from "../../src/components/editor/DragHandleWrapper"; + +describe("DragHandleWrapper", () => { + it("places controls at the admin UI's logical start edge", () => { + expect(_getDragHandlePlacement("ltr")).toBe("left-start"); + expect(_getDragHandlePlacement("rtl")).toBe("right-start"); + }); + + it("locks the real drag plugin through core transaction metadata", () => { + const host = document.createElement("div"); + const editorElement = document.createElement("div"); + const dragElement = document.createElement("div"); + host.append(editorElement); + document.body.append(host); + const editor = new Editor({ + element: editorElement, + extensions: [StarterKit], + content: "

Test

", + }); + const dragPlugin = DragHandlePlugin({ + editor, + element: dragElement, + nestedOptions: normalizeNestedOptions(false), + }); + + try { + editor.registerPlugin(dragPlugin.plugin); + expect(dragElement.draggable).toBe(true); + + editor.commands.setMeta("lockDragHandle", true); + expect(dragElement.draggable).toBe(false); + + editor.commands.setMeta("lockDragHandle", false); + expect(dragElement.draggable).toBe(true); + } finally { + dragPlugin.unbind(); + editor.destroy(); + host.remove(); + } + }); +}); diff --git a/packages/admin/tests/editor/PortableTextEditor.test.tsx b/packages/admin/tests/editor/PortableTextEditor.test.tsx index 464a950e2a..38d956122b 100644 --- a/packages/admin/tests/editor/PortableTextEditor.test.tsx +++ b/packages/admin/tests/editor/PortableTextEditor.test.tsx @@ -450,6 +450,27 @@ describe("Portable Text ↔ ProseMirror conversion", () => { expect(textContent.trim()).toBe(""); }); + it("teaches writing and slash commands in the default placeholder", async () => { + await render(); + const pm = await waitForEditor(); + const placeholder = pm.querySelector("[data-placeholder]"); + expect(placeholder?.getAttribute("data-placeholder")).toBe( + "Start writing, or type '/' for commands", + ); + }); + + it("centers the writing column with responsive space for block controls", async () => { + await render(); + const pm = await waitForEditor(); + expect(pm.className).toContain("w-full"); + expect(pm.className).toContain("max-w-[calc(75ch+8rem)]"); + expect(pm.className).toContain("mx-auto"); + expect(pm.className).toContain("ps-14"); + expect(pm.className).toContain("pe-14"); + expect(pm.className).toContain("sm:ps-16"); + expect(pm.className).toContain("sm:pe-16"); + }); + it("renders bold+italic text with multiple marks", async () => { await render( , @@ -633,11 +654,13 @@ describe("Toolbar", () => { await expect.element(screen.getByRole("button", { name: "Inline Code" })).toBeInTheDocument(); }); - it("has heading buttons", async () => { + it("has a heading menu", async () => { const screen = await renderWithToolbar(); - await expect.element(screen.getByRole("button", { name: "Heading 1" })).toBeInTheDocument(); - await expect.element(screen.getByRole("button", { name: "Heading 2" })).toBeInTheDocument(); - await expect.element(screen.getByRole("button", { name: "Heading 3" })).toBeInTheDocument(); + const trigger = screen.getByRole("button", { name: "Headings" }); + await trigger.click(); + await expect.element(screen.getByRole("menuitem", { name: "Heading 1" })).toBeInTheDocument(); + await expect.element(screen.getByRole("menuitem", { name: "Heading 2" })).toBeInTheDocument(); + await expect.element(screen.getByRole("menuitem", { name: "Heading 3" })).toBeInTheDocument(); }); it("has list buttons", async () => { @@ -659,13 +682,14 @@ describe("Toolbar", () => { await expect.element(screen.getByRole("button", { name: "Align Right" })).toBeInTheDocument(); }); - it("has insert buttons", async () => { + it("keeps insertion-only actions out of the formatting toolbar", async () => { const screen = await renderWithToolbar(); + const toolbar = screen.getByRole("toolbar").element(); await expect.element(screen.getByRole("button", { name: "Insert Link" })).toBeInTheDocument(); - await expect.element(screen.getByRole("button", { name: "Insert Image" })).toBeInTheDocument(); - await expect - .element(screen.getByRole("button", { name: "Insert Horizontal Rule" })) - .toBeInTheDocument(); + expect(toolbar.querySelector('[aria-label="Insert Table"]')).toBeNull(); + expect(toolbar.querySelector('[aria-label="Insert Image"]')).toBeNull(); + expect(toolbar.querySelector('[aria-label="Insert HTML"]')).toBeNull(); + expect(toolbar.querySelector('[aria-label="Insert Horizontal Rule"]')).toBeNull(); }); it("has history buttons (initially disabled)", async () => { @@ -721,19 +745,19 @@ describe("Toolbar", () => { ); }); - it("toggles Heading 1 aria-pressed when clicked", async () => { + it("changes the current block to Heading 1 from the heading menu", async () => { const screen = await renderWithToolbar(); const pm = document.querySelector(".ProseMirror") as HTMLElement; await focusEditor(pm); - const h1Btn = screen.getByRole("button", { name: "Heading 1" }); - await expect.element(h1Btn).toHaveAttribute("aria-pressed", "false"); - - await h1Btn.click(); + const trigger = screen.getByRole("button", { name: "Headings" }); + await trigger.click(); + await screen.getByRole("menuitem", { name: "Heading 1" }).click(); await vi.waitFor( - async () => { - await expect.element(h1Btn).toHaveAttribute("aria-pressed", "true"); + () => { + expect(pm.querySelector("h1")).toBeTruthy(); + expect(trigger.element().hasAttribute("aria-pressed")).toBe(false); }, { timeout: 2000 }, ); diff --git a/packages/admin/tests/editor/bubble-menu.test.tsx b/packages/admin/tests/editor/bubble-menu.test.tsx index 505fbc2f90..f82fabced1 100644 --- a/packages/admin/tests/editor/bubble-menu.test.tsx +++ b/packages/admin/tests/editor/bubble-menu.test.tsx @@ -9,6 +9,7 @@ * when there's a text selection in the editor. */ +import { CellSelection } from "@tiptap/pm/tables"; import type { Editor } from "@tiptap/react"; import { userEvent } from "@vitest/browser/context"; import { describe, it, expect, vi } from "vitest"; @@ -104,17 +105,69 @@ const defaultValue = [ }, ]; -async function renderEditor(props: Partial = {}) { +const tableValue = [ + { + _type: "table" as const, + _key: "table-1", + hasHeaderRow: true, + rows: [ + { + _type: "tableRow" as const, + _key: "row-1", + cells: [ + { + _type: "tableCell" as const, + _key: "cell-1", + content: [{ _type: "span" as const, _key: "table-span-1", text: "Header" }], + isHeader: true, + }, + { + _type: "tableCell" as const, + _key: "cell-2", + content: [{ _type: "span" as const, _key: "table-span-2", text: "Other" }], + isHeader: true, + }, + ], + }, + { + _type: "tableRow" as const, + _key: "row-2", + cells: [ + { + _type: "tableCell" as const, + _key: "cell-3", + content: [{ _type: "span" as const, _key: "table-span-3", text: "Body" }], + }, + { + _type: "tableCell" as const, + _key: "cell-4", + content: [{ _type: "span" as const, _key: "table-span-4", text: "Cell" }], + }, + ], + }, + ], + }, +]; + +async function renderEditor(props: Partial = {}, scrollContainerTop = 0) { let editorInstance: Editor | null = null; const screen = await render( - { - editorInstance = editor; +
, + > + { + editorInstance = editor; + }} + {...props} + /> +
, ); await vi.waitFor( @@ -137,6 +190,36 @@ async function focusAndSelectAll(editor: Editor, pm: HTMLElement) { editor.commands.selectAll(); } +function getTextPosition(editor: Editor, text: string): number { + let textPosition = 0; + editor.state.doc.descendants((node, position) => { + if (node.isText && node.text === text) { + textPosition = position; + return false; + } + return undefined; + }); + return textPosition; +} + +async function focusTableCell(editor: Editor, pm: HTMLElement, text = "Header") { + pm.focus(); + editor.chain().focus().setTextSelection(getTextPosition(editor, text)).run(); + await vi.waitFor(() => expect(editor.isActive("table")).toBe(true)); +} + +async function waitForTableToolbar(): Promise { + let toolbar: HTMLElement | null = null; + await vi.waitFor( + () => { + toolbar = document.querySelector('[role="group"][aria-label="Table controls"]'); + expect(toolbar).toBeTruthy(); + }, + { timeout: 3000 }, + ); + return toolbar!; +} + /** * Find the bubble menu element. * TipTap's BubbleMenu renders as a div with role=presentation (tippy.js). @@ -184,6 +267,30 @@ function getBubbleButton(menu: HTMLElement, label: string): HTMLButtonElement | // ============================================================================= describe("Bubble Menu", () => { + it("registers stable, independent plugins for both bubble menus", async () => { + const { editor, pm } = await renderEditor({ value: [...tableValue, ...defaultValue] }); + const expectBubbleMenuPlugins = () => { + const pluginKeys = editor.state.plugins.map((plugin) => plugin.key); + expect(pluginKeys.filter((key) => key.startsWith("emdashInlineBubbleMenu"))).toHaveLength(1); + expect(pluginKeys.filter((key) => key.startsWith("emdashTableBubbleMenu"))).toHaveLength(1); + }; + + await vi.waitFor(expectBubbleMenuPlugins); + await focusTableCell(editor, pm); + await waitForTableToolbar(); + expectBubbleMenuPlugins(); + + editor.chain().focus().setTextSelection(getTextPosition(editor, "Hello world")).run(); + await vi.waitFor(() => { + expect(document.querySelector('[aria-label="Table controls"]')).toBeNull(); + }); + expectBubbleMenuPlugins(); + + await focusTableCell(editor, pm); + await waitForTableToolbar(); + expectBubbleMenuPlugins(); + }); + it("appears when text is selected", async () => { const { editor, pm } = await renderEditor(); await focusAndSelectAll(editor, pm); @@ -192,6 +299,169 @@ describe("Bubble Menu", () => { expect(menu).toBeTruthy(); }); + it("flips below a top-line selection when the sticky toolbar blocks the preferred position", async () => { + const { editor, pm } = await renderEditor(); + await focusAndSelectAll(editor, pm); + + const menu = await waitForBubbleMenu(); + const toolbar = document.querySelector( + '[role="toolbar"][aria-label="Text formatting"]', + ); + expect(toolbar).toBeTruthy(); + + await vi.waitFor(() => { + const selection = window.getSelection(); + expect(selection?.rangeCount).toBe(1); + const selectionRect = selection!.getRangeAt(0).getBoundingClientRect(); + const menuRect = menu.getBoundingClientRect(); + expect(menuRect.top).toBeGreaterThanOrEqual(selectionRect.bottom); + expect(menuRect.top).toBeGreaterThanOrEqual(toolbar!.getBoundingClientRect().bottom); + }); + }); + + it("stays above the selection when there is room below the sticky toolbar", async () => { + const value = ["First line", "Second line", "Third line"].map((text, index) => ({ + _type: "block" as const, + _key: String(index), + style: "normal" as const, + children: [{ _type: "span" as const, _key: `span-${index}`, text }], + })); + const { editor, pm } = await renderEditor({ value }, 58); + pm.focus(); + + let textPosition = 0; + editor.state.doc.descendants((node, position) => { + if (node.isText && node.text === "Third line") { + textPosition = position; + return false; + } + return undefined; + }); + editor + .chain() + .focus() + .setTextSelection({ from: textPosition, to: textPosition + 10 }) + .run(); + + const menu = await waitForBubbleMenu(); + await vi.waitFor(() => { + const selection = window.getSelection(); + expect(selection?.rangeCount).toBe(1); + const selectionRect = selection!.getRangeAt(0).getBoundingClientRect(); + const menuRect = menu.getBoundingClientRect(); + expect(menuRect.bottom).toBeLessThanOrEqual(selectionRect.top); + }); + }); + + it("keeps table controls mounted, unclipped, and below obstructing sticky chrome", async () => { + const { screen, editor, pm } = await renderEditor({ value: tableValue }); + await focusTableCell(editor, pm); + + const tableToolbar = await waitForTableToolbar(); + const floatingRoot = screen.container.querySelector("[data-emdash-editor-floating-root]"); + const clippedSurface = screen.container.querySelector("[data-emdash-editor-surface]"); + expect(floatingRoot).toBeTruthy(); + expect(floatingRoot?.contains(tableToolbar)).toBe(true); + expect(clippedSurface?.contains(tableToolbar)).toBe(false); + + await vi.waitFor(() => { + const selection = window.getSelection(); + expect(selection?.rangeCount).toBe(1); + const selectionRect = selection!.getRangeAt(0).getBoundingClientRect(); + const menuRect = tableToolbar.getBoundingClientRect(); + const formattingToolbar = document.querySelector( + '[role="toolbar"][aria-label="Text formatting"]', + ); + expect(menuRect.top).toBeGreaterThanOrEqual(selectionRect.bottom); + expect(menuRect.top).toBeGreaterThanOrEqual( + formattingToolbar!.getBoundingClientRect().bottom, + ); + }); + }); + + it("shows only inline formatting for a non-empty text selection inside a table", async () => { + const { editor, pm } = await renderEditor({ value: tableValue }); + pm.focus(); + const textPosition = getTextPosition(editor, "Body"); + editor + .chain() + .focus() + .setTextSelection({ from: textPosition, to: textPosition + 4 }) + .run(); + + await waitForBubbleMenu(); + await vi.waitFor(() => { + expect(document.querySelector('[aria-label="Table controls"]')).toBeNull(); + }); + }); + + it("shows only table controls for a cell selection", async () => { + const { editor, pm } = await renderEditor({ value: tableValue }); + pm.focus(); + const cellPositions: number[] = []; + editor.state.doc.descendants((node, position) => { + if (node.type.name === "tableHeader" || node.type.name === "tableCell") { + cellPositions.push(position); + } + }); + editor.view.dispatch( + editor.state.tr.setSelection( + CellSelection.create(editor.state.doc, cellPositions[0]!, cellPositions[3]!), + ), + ); + expect(editor.state.selection).toBeInstanceOf(CellSelection); + + await waitForTableToolbar(); + await vi.waitFor(() => expect(getBubbleMenu()).toBeNull()); + }); + + it("hides inline formatting controls when the editor becomes read-only", async () => { + const { editor, pm } = await renderEditor(); + editor.setEditable(false); + pm.tabIndex = 0; + pm.focus(); + editor.commands.selectAll(); + await new Promise((resolve) => setTimeout(resolve, 350)); + + expect(document.activeElement).toBe(pm); + expect(getBubbleMenu()).toBeNull(); + }); + + it("exposes accessible table actions and toggle state", async () => { + const { screen, editor, pm } = await renderEditor({ value: tableValue }); + await focusTableCell(editor, pm); + await waitForTableToolbar(); + + const addBefore = screen.getByRole("button", { name: "Add column before" }); + const headerToggle = screen.getByRole("button", { name: "Toggle header row" }); + await expect.element(addBefore).toBeVisible(); + expect(addBefore.element().hasAttribute("aria-pressed")).toBe(false); + await expect.element(headerToggle).toHaveAttribute("aria-pressed", "true"); + }); + + it("uses purpose-built icons for table insertion actions", async () => { + const { screen, editor, pm } = await renderEditor({ value: tableValue }); + await focusTableCell(editor, pm); + await waitForTableToolbar(); + + for (const name of [ + "Add column before", + "Add column after", + "Add row before", + "Add row after", + ]) { + const button = screen.getByRole("button", { name }).element(); + expect(button.querySelectorAll("svg")).toHaveLength(1); + expect(button.querySelector(".absolute")).toBeNull(); + } + + const beforeIcon = screen + .getByRole("button", { name: "Add column before" }) + .element() + .querySelector("svg"); + expect(beforeIcon?.getAttribute("class")).toContain("rtl:-scale-x-100"); + }); + it("shows formatting buttons: Bold, Italic, Underline, Strikethrough, Code", async () => { const { editor, pm } = await renderEditor(); await focusAndSelectAll(editor, pm); @@ -219,11 +489,13 @@ describe("Bubble Menu", () => { const menu = await waitForBubbleMenu(); const boldBtn = getBubbleButton(menu, "Bold")!; + expect(boldBtn.getAttribute("aria-pressed")).toBe("false"); boldBtn.click(); await vi.waitFor(() => { expect(editor.isActive("bold")).toBe(true); + expect(boldBtn.getAttribute("aria-pressed")).toBe("true"); }); // Verify the text is wrapped in @@ -312,6 +584,7 @@ describe("Bubble Menu", () => { // Should have a URL input with placeholder const input = menu.querySelector('input[type="url"]'); expect(input).toBeTruthy(); + expect(input?.getAttribute("aria-label")).toBe("URL"); }); it("applies link URL when Apply button is clicked", async () => { diff --git a/packages/admin/tests/editor/slash-menu.test.tsx b/packages/admin/tests/editor/slash-menu.test.tsx index 90c454fc7c..751170b534 100644 --- a/packages/admin/tests/editor/slash-menu.test.tsx +++ b/packages/admin/tests/editor/slash-menu.test.tsx @@ -10,6 +10,7 @@ */ import type { Editor } from "@tiptap/react"; +import { SuggestionPluginKey } from "@tiptap/suggestion"; import { userEvent } from "@vitest/browser/context"; import { describe, it, expect, vi } from "vitest"; @@ -26,11 +27,49 @@ vi.mock("../../src/components/MediaPickerModal", () => ({ })); vi.mock("../../src/components/SectionPickerModal", () => ({ - SectionPickerModal: () => null, + SectionPickerModal: ({ + open, + onOpenChange, + onSelect, + }: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (section: { content: unknown[] }) => void; + }) => + open ? ( + + ) : null, })); vi.mock("../../src/components/editor/DragHandleWrapper", () => ({ - DragHandleWrapper: () => null, + DragHandleWrapper: ({ + editor, + onInsertBlock, + }: { + editor: Editor; + onInsertBlock?: (insertPos: number) => void; + }) => ( + + ), })); vi.mock("../../src/components/editor/ImageNode", async () => { @@ -142,13 +181,7 @@ async function focusEditor(pm: HTMLElement) { /** Get the slash menu portal element from document.body */ function getSlashMenu(): HTMLElement | null { - const portals = document.querySelectorAll("body > div"); - for (const el of portals) { - if (el.querySelector("[data-index]") || el.textContent?.includes("No results")) { - return el as HTMLElement; - } - } - return null; + return document.querySelector("[data-slash-command-menu]"); } /** Wait for the slash menu to appear */ @@ -181,12 +214,16 @@ function getSlashMenuItems(menu: HTMLElement): HTMLButtonElement[] { /** * Check if an item is the selected/highlighted item. - * Selected items use "bg-kumo-tint text-kumo-default" (space-separated). - * Non-selected items use "hover:bg-kumo-tint/50". + * Selected items use the semantic interaction surface. */ function isItemSelected(el: HTMLElement): boolean { - // Split className by spaces and check for exact "bg-kumo-tint" token - return el.className.split(WHITESPACE_SPLIT_REGEX).includes("bg-kumo-tint"); + return el.className.split(WHITESPACE_SPLIT_REGEX).includes("bg-kumo-interact"); +} + +function isSlashSuggestionActive(editor: Editor): boolean { + return Boolean( + (SuggestionPluginKey.getState(editor.state) as { active?: boolean } | undefined)?.active, + ); } // ============================================================================= @@ -194,6 +231,222 @@ function isItemSelected(el: HTMLElement): boolean { // ============================================================================= describe("Slash Command Menu", () => { + it("keeps focus in the editor when the menu opens", async () => { + const { editor, pm } = await renderEditor(); + await focusEditor(pm); + editor.commands.insertContent("/"); + + const menu = await waitForSlashMenu(); + expect(document.activeElement).toBe(pm); + + const focusGuards = menu.parentElement?.querySelectorAll("[data-base-ui-focus-guard]"); + expect(focusGuards).toHaveLength(2); + expect(menu.parentElement?.classList.contains("slash-command-menu-positioner")).toBe(true); + }); + + it("uses a contained scroll viewport inside the menu shell", async () => { + const { editor, pm } = await renderEditor(); + await focusEditor(pm); + editor.commands.insertContent("/"); + + const menu = await waitForSlashMenu(); + const scrollViewport = menu.querySelector("[data-slash-menu-scroll-viewport]"); + + expect(scrollViewport).toBeTruthy(); + expect(scrollViewport).not.toBe(menu); + expect(scrollViewport!.className.split(WHITESPACE_SPLIT_REGEX)).toEqual( + expect.arrayContaining(["overflow-y-auto", "overscroll-contain"]), + ); + expect(menu.className.split(WHITESPACE_SPLIT_REGEX)).not.toContain("overflow-y-auto"); + }); + + it("closes on Tab and lets focus leave the editor", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + editor.commands.insertContent("/"); + await waitForSlashMenu(); + const nextFocusable = screen.getByRole("button", { name: "Test gutter insert" }).element(); + + await userEvent.keyboard("{Tab}"); + + await waitForSlashMenuClosed(); + expect(document.activeElement).toBe(nextFocusable); + expect(editor.getText()).toBe("/"); + expect(isSlashSuggestionActive(editor)).toBe(false); + }); + + it("closes on Shift+Tab and lets focus leave the editor", async () => { + const { editor, pm } = await renderEditor(); + await focusEditor(pm); + editor.commands.insertContent("/"); + await waitForSlashMenu(); + + await userEvent.keyboard("{Shift>}{Tab}{/Shift}"); + + await waitForSlashMenuClosed(); + expect(document.activeElement).not.toBe(pm); + expect(editor.getText()).toBe("/"); + expect(isSlashSuggestionActive(editor)).toBe(false); + }); + + it("closes a gutter-triggered menu on Tab without inserting content", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + const before = editor.getJSON(); + + await screen.getByRole("button", { name: "Test gutter insert" }).click(); + await waitForSlashMenu(); + await userEvent.keyboard("{Tab}"); + + await waitForSlashMenuClosed(); + expect(editor.getJSON()).toEqual(before); + }); + + it("opens from the gutter and cancels without inserting a slash", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + const before = editor.getJSON(); + + await screen.getByRole("button", { name: "Test gutter insert" }).click(); + await waitForSlashMenu(); + expect(editor.getText()).not.toContain("/"); + expect(editor.getJSON()).toEqual(before); + + await userEvent.keyboard("{Escape}"); + await waitForSlashMenuClosed(); + expect(editor.getJSON()).toEqual(before); + }); + + it("discards an untouched gutter block when clicking outside the menu", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + const before = editor.getText(); + + await screen.getByRole("button", { name: "Test gutter insert" }).click(); + await waitForSlashMenu(); + pm.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + + await waitForSlashMenuClosed(); + expect(editor.getText()).toBe(before); + }); + + it("exits the slash suggestion plugin when clicking outside the menu", async () => { + const { editor, pm } = await renderEditor(); + await focusEditor(pm); + editor.commands.insertContent("/"); + await waitForSlashMenu(); + expect(isSlashSuggestionActive(editor)).toBe(true); + + pm.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + + await waitForSlashMenuClosed(); + expect(isSlashSuggestionActive(editor)).toBe(false); + }); + + it("keeps slash suggestion dismissed when opening the gutter menu", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + editor.commands.insertContent("/"); + await waitForSlashMenu(); + + const gutterButton = screen.getByRole("button", { name: "Test gutter insert" }).element(); + gutterButton.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + gutterButton.click(); + await waitForSlashMenu(); + editor.view.dispatch(editor.state.tr.setMeta("test", true)); + + expect(isSlashSuggestionActive(editor)).toBe(false); + expect(getSlashMenu()).toBeTruthy(); + }); + + it("records slash dismissal when opening the gutter menu from the keyboard", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + editor.commands.insertContent("/"); + await waitForSlashMenu(); + + screen.getByRole("button", { name: "Test gutter insert" }).element().click(); + await waitForSlashMenu(); + editor.view.dispatch(editor.state.tr.setMeta("test", true)); + + expect(isSlashSuggestionActive(editor)).toBe(false); + expect(getSlashMenu()).toBeTruthy(); + }); + + it("does not leave a staging paragraph when a gutter command opens a modal", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + const before = editor.getJSON(); + + await screen.getByRole("button", { name: "Test gutter insert" }).click(); + const menu = await waitForSlashMenu(); + const imageCommand = getSlashMenuItems(menu).find((item) => + item.textContent?.includes("Image"), + ); + expect(imageCommand).toBeTruthy(); + + imageCommand?.click(); + await waitForSlashMenuClosed(); + expect(editor.getJSON()).toEqual(before); + }); + + it("materializes a new block when a direct gutter command is selected", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + + await screen.getByRole("button", { name: "Test gutter insert" }).click(); + const menu = await waitForSlashMenu(); + getSlashMenuItems(menu)[0]?.click(); + await waitForSlashMenuClosed(); + + const content = editor.getJSON().content; + expect(content?.[1]?.type).toBe("heading"); + expect(content?.[1]?.attrs?.level).toBe(1); + }); + + it("inserts modal-backed gutter content at the requested block position", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + + await screen.getByRole("button", { name: "Test gutter insert" }).click(); + const menu = await waitForSlashMenu(); + const sectionCommand = getSlashMenuItems(menu).find((item) => + item.textContent?.includes("Section"), + ); + sectionCommand?.click(); + await screen.getByRole("button", { name: "Select test section" }).click(); + + const content = editor.getJSON().content; + expect(content).toHaveLength(2); + expect(content?.[1]?.content?.[0]?.text).toBe("Inserted section"); + }); + + it("materializes a new gutter paragraph when the user starts typing", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + + await screen.getByRole("button", { name: "Test gutter insert" }).click(); + await waitForSlashMenu(); + await userEvent.keyboard("A"); + + await waitForSlashMenuClosed(); + const content = editor.getJSON().content; + expect(content).toHaveLength(2); + expect(content?.[1]?.content?.[0]?.text).toBe("A"); + }); + + it("does not materialize a gutter paragraph for modifier shortcuts", async () => { + const { screen, editor, pm } = await renderEditor(); + await focusEditor(pm); + const before = editor.getJSON(); + + await screen.getByRole("button", { name: "Test gutter insert" }).click(); + await waitForSlashMenu(); + await userEvent.keyboard("{Control>}b{/Control}"); + + expect(editor.getJSON()).toEqual(before); + }); + it("opens when typing / at the start of an empty line", async () => { const { editor, pm } = await renderEditor(); await focusEditor(pm); @@ -222,7 +475,9 @@ describe("Slash Command Menu", () => { expect(titles).toContain("Numbered List"); expect(titles).toContain("Quote"); expect(titles).toContain("Code Block"); + expect(titles).toContain("HTML"); expect(titles).toContain("Divider"); + expect(titles).toContain("Table"); }); it("shows descriptions for each command", async () => { @@ -292,11 +547,25 @@ describe("Slash Command Menu", () => { const menu = getSlashMenu()!; const items = getSlashMenuItems(menu); expect(isItemSelected(items[0]!)).toBe(true); + expect(items[0]?.getAttribute("aria-current")).toBe("true"); + expect(menu.querySelector('[role="status"]')?.textContent).toBe("Selected Heading 1"); }, { timeout: 3000 }, ); }); + it("uses the interaction surface for selected items", async () => { + const { editor, pm } = await renderEditor(); + await focusEditor(pm); + editor.commands.insertContent("/"); + + const menu = await waitForSlashMenu(); + const selectedItem = getSlashMenuItems(menu)[0]!; + const classes = selectedItem.className.split(WHITESPACE_SPLIT_REGEX); + expect(classes).toContain("bg-kumo-interact"); + expect(classes).not.toContain("bg-kumo-tint"); + }); + it("moves selection down with ArrowDown", async () => { const { editor, pm } = await renderEditor(); await focusEditor(pm); @@ -310,6 +579,9 @@ describe("Slash Command Menu", () => { const items = getSlashMenuItems(menu); expect(isItemSelected(items[1]!)).toBe(true); expect(isItemSelected(items[0]!)).toBe(false); + expect(items[1]?.getAttribute("aria-current")).toBe("true"); + expect(items[0]?.hasAttribute("aria-current")).toBe(false); + expect(menu.querySelector('[role="status"]')?.textContent).toBe("Selected Heading 2"); }); }); @@ -377,6 +649,7 @@ describe("Slash Command Menu", () => { // Should still be a paragraph expect(pm.querySelector("h1")).toBeNull(); + expect(isSlashSuggestionActive(editor)).toBe(false); }); it("executes command when clicking an item", async () => { @@ -439,6 +712,27 @@ describe("Slash Command Menu", () => { }); }); + it("inserts an HTML block via slash command", async () => { + const { editor, pm } = await renderEditor(); + await focusEditor(pm); + editor.commands.insertContent("/"); + + const menu = await waitForSlashMenu(); + const htmlBtn = getSlashMenuItems(menu).find( + (btn) => btn.querySelector(".font-medium")?.textContent === "HTML", + ); + expect(htmlBtn).toBeTruthy(); + htmlBtn!.click(); + + await waitForSlashMenuClosed(); + + await vi.waitFor(() => { + const htmlBlock = editor.getJSON().content?.find((node) => node.type === "htmlBlock"); + expect(htmlBlock).toBeDefined(); + expect((htmlBlock as { attrs?: { html?: string } }).attrs?.html).toBe(""); + }); + }); + it("inserts bullet list via slash command", async () => { const { editor, pm } = await renderEditor(); await focusEditor(pm); diff --git a/packages/admin/tests/editor/toolbar.test.tsx b/packages/admin/tests/editor/toolbar.test.tsx index e5a33b42ee..5656d7b4df 100644 --- a/packages/admin/tests/editor/toolbar.test.tsx +++ b/packages/admin/tests/editor/toolbar.test.tsx @@ -136,6 +136,17 @@ function getToolbarButton(screen: Awaited>, name: stri return screen.getByRole("toolbar", { name: "Text formatting" }).getByRole("button", { name }); } +async function getHeadingMenuItem( + screen: Awaited>, + name: "Heading 1" | "Heading 2" | "Heading 3", +) { + const trigger = getToolbarButton(screen, "Headings"); + trigger.element().click(); + const item = screen.getByRole("menuitem", { name }); + await expect.element(item).toBeVisible(); + return { trigger, item }; +} + // ============================================================================= // 1. Toolbar Presence and Structure // ============================================================================= @@ -147,6 +158,14 @@ describe("Toolbar Presence and Structure", () => { await expect.element(toolbar).toHaveAttribute("aria-label", "Text formatting"); }); + it("centers controls when they fit and preserves horizontal overflow", async () => { + const { screen } = await renderEditor(); + const toolbar = screen.getByRole("toolbar", { name: "Text formatting" }).element(); + + expect(toolbar.className).toContain("overflow-x-auto"); + expect(getComputedStyle(toolbar).justifyContent).toBe("safe center"); + }); + it("has all formatting buttons", async () => { const { screen } = await renderEditor(); await expect.element(screen.getByRole("button", { name: "Bold" })).toBeVisible(); @@ -156,11 +175,28 @@ describe("Toolbar Presence and Structure", () => { await expect.element(screen.getByRole("button", { name: "Inline Code" })).toBeVisible(); }); - it("has all heading buttons", async () => { + it("collapses the supported heading levels into one menu", async () => { const { screen } = await renderEditor(); - await expect.element(screen.getByRole("button", { name: "Heading 1" })).toBeVisible(); - await expect.element(screen.getByRole("button", { name: "Heading 2" })).toBeVisible(); - await expect.element(screen.getByRole("button", { name: "Heading 3" })).toBeVisible(); + const trigger = getToolbarButton(screen, "Headings"); + await expect.element(trigger).toBeVisible(); + await expect.element(trigger).toHaveAttribute("aria-haspopup", "menu"); + + trigger.element().click(); + await expect.element(screen.getByRole("menuitem", { name: "Heading 1" })).toBeVisible(); + await expect.element(screen.getByRole("menuitem", { name: "Heading 2" })).toBeVisible(); + await expect.element(screen.getByRole("menuitem", { name: "Heading 3" })).toBeVisible(); + expect( + screen + .getByRole("menuitem", { name: "Heading 1" }) + .element() + .hasAttribute("data-emdash-heading-item"), + ).toBe(true); + + const headingLabels = Array.from( + document.querySelectorAll('[role="menuitem"]'), + (item) => item.textContent?.trim(), + ); + expect(headingLabels).not.toContain("Heading 4"); }); it("has all list buttons", async () => { @@ -182,14 +218,54 @@ describe("Toolbar Presence and Structure", () => { await expect.element(screen.getByRole("button", { name: "Align Right" })).toBeVisible(); }); - it("has all insert buttons", async () => { + it("keeps insertion-only actions in the block menu", async () => { const { screen } = await renderEditor(); + const toolbar = screen.getByRole("toolbar", { name: "Text formatting" }).element(); await expect.element(screen.getByRole("button", { name: "Insert Link" })).toBeVisible(); - await expect.element(screen.getByRole("button", { name: "Insert Image" })).toBeVisible(); - await expect.element(screen.getByRole("button", { name: "Insert HTML" })).toBeVisible(); - await expect - .element(screen.getByRole("button", { name: "Insert Horizontal Rule" })) - .toBeVisible(); + expect(toolbar.querySelector('[aria-label="Insert Table"]')).toBeNull(); + expect(toolbar.querySelector('[aria-label="Insert Image"]')).toBeNull(); + expect(toolbar.querySelector('[aria-label="Insert HTML"]')).toBeNull(); + expect(toolbar.querySelector('[aria-label="Insert Horizontal Rule"]')).toBeNull(); + }); + + it("renders the link editor outside the horizontally scrolling toolbar", async () => { + const { screen } = await renderEditor(); + const toolbar = screen.getByRole("toolbar", { name: "Text formatting" }).element(); + screen.getByRole("button", { name: "Insert Link" }).element().click(); + + await vi.waitFor(() => { + const input = document.querySelector('input[placeholder="https://..."]'); + expect(input).toBeTruthy(); + expect(toolbar.contains(input)).toBe(false); + }); + }); + + it("provides an independent block inserter for coarse pointers", async () => { + const { screen } = await renderEditor(); + const toolbar = screen.getByRole("toolbar", { name: "Text formatting" }).element(); + const touchInsert = toolbar.querySelector("[data-touch-block-insert]"); + + expect(touchInsert).toBeTruthy(); + expect(touchInsert?.className).toContain("pointer-coarse:flex"); + expect(touchInsert?.getAttribute("aria-label")).toBe("Insert block after current block"); + expect(touchInsert?.tabIndex).toBe(0); + touchInsert?.click(); + await vi.waitFor(() => { + expect(document.querySelector("body > div [data-index]")).toBeTruthy(); + }); + }); + + it("includes the coarse-pointer inserter in toolbar arrow navigation when visible", async () => { + const { screen } = await renderEditor(); + const toolbar = screen.getByRole("toolbar", { name: "Text formatting" }).element(); + const touchInsert = toolbar.querySelector("[data-touch-block-insert]")!; + const bold = screen.getByRole("button", { name: "Bold" }); + touchInsert.style.display = "flex"; + bold.element().focus(); + + await userEvent.keyboard("{ArrowLeft}"); + + await vi.waitFor(() => expect(document.activeElement).toBe(touchInsert)); }); it("has history buttons", async () => { @@ -280,42 +356,45 @@ describe("Formatting Button Toggle States", () => { }); }); - it("Heading 1: click toggles aria-pressed to true and changes to h1", async () => { + it("Heading 1: click changes to h1 without exposing toggle semantics", async () => { const { screen, editor } = await renderEditor(); // Focus editor and place cursor (block commands need cursor in a paragraph) editor.commands.focus(); - const btn = screen.getByRole("button", { name: "Heading 1" }); - await expect.element(btn).toHaveAttribute("aria-pressed", "false"); - btn.element().click(); + const { trigger, item } = await getHeadingMenuItem(screen, "Heading 1"); + expect(trigger.element().hasAttribute("aria-pressed")).toBe(false); + await expect.element(trigger).toHaveAttribute("aria-expanded", "true"); + item.element().click(); await vi.waitFor(() => { - expect(btn.element().getAttribute("aria-pressed")).toBe("true"); + expect(trigger.element().hasAttribute("aria-pressed")).toBe(false); expect(editor.isActive("heading", { level: 1 })).toBe(true); }); }); - it("Heading 2: click toggles aria-pressed to true", async () => { + it("Heading 2: click changes to h2", async () => { const { screen, editor } = await renderEditor(); editor.commands.focus(); - const btn = screen.getByRole("button", { name: "Heading 2" }); - btn.element().click(); + const { trigger, item } = await getHeadingMenuItem(screen, "Heading 2"); + item.element().click(); await vi.waitFor(() => { - expect(btn.element().getAttribute("aria-pressed")).toBe("true"); + expect(trigger.element().hasAttribute("aria-pressed")).toBe(false); + expect(editor.isActive("heading", { level: 2 })).toBe(true); }); }); - it("Heading 3: click toggles aria-pressed to true", async () => { + it("Heading 3: click changes to h3", async () => { const { screen, editor } = await renderEditor(); editor.commands.focus(); - const btn = screen.getByRole("button", { name: "Heading 3" }); - btn.element().click(); + const { trigger, item } = await getHeadingMenuItem(screen, "Heading 3"); + item.element().click(); await vi.waitFor(() => { - expect(btn.element().getAttribute("aria-pressed")).toBe("true"); + expect(trigger.element().hasAttribute("aria-pressed")).toBe(false); + expect(editor.isActive("heading", { level: 3 })).toBe(true); }); }); @@ -543,26 +622,7 @@ describe("Undo/Redo", () => { }); // ============================================================================= -// 5. HTML Block Insertion -// ============================================================================= - -describe("HTML Block Insertion", () => { - it("clicking Insert HTML inserts an empty HTML block", async () => { - const { screen, editor } = await renderEditor(); - editor.commands.focus("end"); - - getToolbarButton(screen, "Insert HTML").element().click(); - - await vi.waitFor(() => { - const htmlBlock = editor.getJSON().content?.find((node) => node.type === "htmlBlock"); - expect(htmlBlock).toBeDefined(); - expect((htmlBlock as { attrs?: { html?: string } }).attrs?.html).toBe(""); - }); - }); -}); - -// ============================================================================= -// 6. Link Insertion (Toolbar Popover) +// 5. Link Insertion (Toolbar Popover) // ============================================================================= describe("Link Insertion", () => { @@ -574,7 +634,7 @@ describe("Link Insertion", () => { linkBtn.element().click(); await vi.waitFor(() => { - const input = screen.container.querySelector('input[type="url"]'); + const input = document.querySelector('input[type="url"]'); expect(input).toBeTruthy(); }); }); @@ -598,10 +658,10 @@ describe("Link Insertion", () => { screen.getByRole("button", { name: "Insert Link" }).element().click(); await vi.waitFor(() => { - expect(screen.container.querySelector('input[type="url"]')).toBeTruthy(); + expect(document.querySelector('input[type="url"]')).toBeTruthy(); }); - const input = screen.container.querySelector('input[type="url"]') as HTMLInputElement; + const input = document.querySelector('input[type="url"]') as HTMLInputElement; // Focus input and type URL input.focus(); // Use native input value setter to trigger React's onChange @@ -627,13 +687,13 @@ describe("Link Insertion", () => { screen.getByRole("button", { name: "Insert Link" }).element().click(); await vi.waitFor(() => { - expect(screen.container.querySelector('input[type="url"]')).toBeTruthy(); + expect(document.querySelector('input[type="url"]')).toBeTruthy(); }); screen.getByRole("button", { name: "Cancel" }).element().click(); await vi.waitFor(() => { - expect(screen.container.querySelector('input[type="url"]')).toBeNull(); + expect(document.querySelector('input[type="url"]')).toBeNull(); }); }); @@ -772,10 +832,27 @@ describe("WAI-ARIA Keyboard Navigation", () => { }); }); - it("Home moves focus to first button", async () => { + it("inverts horizontal arrow navigation in RTL", async () => { const { screen } = await renderEditor(); - + const toolbar = screen.getByRole("toolbar", { name: "Text formatting" }).element(); const bold = screen.getByRole("button", { name: "Bold" }); + const italic = screen.getByRole("button", { name: "Italic" }); + toolbar.style.direction = "rtl"; + italic.element().focus(); + + await userEvent.keyboard("{ArrowRight}"); + + await vi.waitFor(() => { + expect(document.activeElement).toBe(bold.element()); + }); + }); + + it("Home moves focus to first button", async () => { + const { screen } = await renderEditor(); + const toolbar = screen.getByRole("toolbar", { name: "Text formatting" }).element(); + const firstButton = [...toolbar.querySelectorAll("button")].find( + (button) => !button.disabled && button.getClientRects().length > 0, + )!; const alignCenter = screen.getByRole("button", { name: "Align Center" }); // Focus a button in the middle @@ -785,7 +862,7 @@ describe("WAI-ARIA Keyboard Navigation", () => { await userEvent.keyboard("{Home}"); await vi.waitFor(() => { - expect(document.activeElement).toBe(bold.element()); + expect(document.activeElement).toBe(firstButton); }); }); @@ -809,9 +886,11 @@ describe("WAI-ARIA Keyboard Navigation", () => { it("ArrowRight wraps from last to first button", async () => { const { screen } = await renderEditor(); - + const toolbar = screen.getByRole("toolbar", { name: "Text formatting" }).element(); const spotlightBtn = screen.getByRole("button", { name: "Spotlight Mode" }); - const bold = screen.getByRole("button", { name: "Bold" }); + const firstButton = [...toolbar.querySelectorAll("button")].find( + (button) => !button.disabled && button.getClientRects().length > 0, + )!; // Focus the last button spotlightBtn.element().focus(); @@ -820,17 +899,19 @@ describe("WAI-ARIA Keyboard Navigation", () => { await userEvent.keyboard("{ArrowRight}"); await vi.waitFor(() => { - expect(document.activeElement).toBe(bold.element()); + expect(document.activeElement).toBe(firstButton); }); }); it("ArrowLeft wraps from first to last button", async () => { const { screen } = await renderEditor(); - - const bold = screen.getByRole("button", { name: "Bold" }); + const toolbar = screen.getByRole("toolbar", { name: "Text formatting" }).element(); + const firstButton = [...toolbar.querySelectorAll("button")].find( + (button) => !button.disabled && button.getClientRects().length > 0, + )!; // Focus the first button - bold.element().focus(); + firstButton.focus(); // Press ArrowLeft - should wrap to last await userEvent.keyboard("{ArrowLeft}"); diff --git a/packages/admin/vitest.config.ts b/packages/admin/vitest.config.ts index 66417bccab..fd529588b7 100644 --- a/packages/admin/vitest.config.ts +++ b/packages/admin/vitest.config.ts @@ -6,7 +6,10 @@ export default defineConfig({ plugins: [ react({ babel: { - plugins: ["@lingui/babel-plugin-lingui-macro"], + plugins: [ + // Match the admin package build so production-fallback tests keep source messages. + ["@lingui/babel-plugin-lingui-macro", { stripMessageField: false }], + ], }, }), ],