fix(editor): keep completion menus placed and keyboard results visible (#34992)

* fix(editor): keep keyboard completion results visible

* fix(editor): keep completion menus placed and keyboard results visible

* fix(editor): restore link bubble anchor and keep completion menus tied to their editor

Restore the link bubble's required anchorElement, keep editor clicks and
refocus from dismissing completion menus (selection sync still governs),
close them when the editor is hidden, and cover keyboard scrolling and
outside dismissal.

* fix(editor): share #34980's editor-visibility dismissal so completion menus close when their workspace is hidden but painted

* fix(editor): read latest dismiss callback via useEffectEvent instead of render-time ref write

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-29 17:19:34 -07:00
committed by GitHub
co-authored by OrcaWin
parent b683573faa
commit 9d2f744725
8 changed files with 265 additions and 32 deletions
@@ -1297,19 +1297,12 @@
opacity: 1;
}
.rich-markdown-slash-menu {
position: absolute;
z-index: 30;
.rich-markdown-slash-menu[data-slot='popover-content'] {
display: flex;
width: min(300px, calc(100% - 24px));
max-height: min(440px, calc(100vh - 120px));
width: min(300px, calc(100vw - 16px));
max-height: min(440px, var(--radix-popover-content-available-height));
flex-direction: column;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--border) 76%, transparent);
border-radius: 6px;
background: var(--popover);
color: var(--popover-foreground);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
}
.rich-markdown-slash-search {
@@ -1339,6 +1332,7 @@
}
.rich-markdown-slash-results {
position: relative;
min-height: 0;
overflow-y: auto;
}
@@ -1399,19 +1393,13 @@
font-family: Geist, sans-serif;
}
.rich-markdown-doc-link-menu {
position: absolute;
z-index: 30;
.rich-markdown-doc-link-menu[data-slot='popover-content'] {
position: relative;
display: flex;
width: min(360px, calc(100% - 24px));
max-height: 320px;
width: min(360px, calc(100vw - 16px));
max-height: min(320px, var(--radix-popover-content-available-height));
flex-direction: column;
overflow-y: auto;
border: 1px solid color-mix(in srgb, var(--border) 76%, transparent);
border-radius: 12px;
background: color-mix(in srgb, var(--background) 92%, transparent);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(16px);
}
.rich-markdown-doc-link-item {
@@ -0,0 +1,69 @@
import { useMemo, type ReactNode, type Ref } from 'react'
import type { Editor } from '@tiptap/react'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { useDismissWhenEditorHidden } from './use-dismiss-when-editor-hidden'
type RichMarkdownCompletionPopoverProps = {
editor: Editor | null
position: number
onClose: () => void
className: string
role: 'dialog' | 'listbox'
ariaLabel: string
viewportRef?: Ref<HTMLDivElement>
children: ReactNode
}
export function RichMarkdownCompletionPopover({
editor,
position,
onClose,
className,
role,
ariaLabel,
viewportRef,
children
}: RichMarkdownCompletionPopoverProps): React.JSX.Element {
const editorDom = editor && !editor.isDestroyed ? editor.view.dom : null
useDismissWhenEditorHidden(editorDom, onClose)
const virtualRef = useMemo(
() => ({
current: {
contextElement: editor && !editor.isDestroyed ? editor.view.dom : undefined,
getBoundingClientRect: () => {
if (!editor || editor.isDestroyed) {
return new DOMRect()
}
const cursor = Math.max(0, Math.min(position, editor.state.doc.content.size))
const coords = editor.view.coordsAtPos(cursor)
return new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top)
}
}
}),
[editor, position]
)
return (
<Popover open onOpenChange={(open) => !open && onClose()}>
<PopoverAnchor virtualRef={virtualRef} />
<PopoverContent
asChild
align="start"
sideOffset={8}
collisionPadding={8}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
// Editor clicks and refocus keep the menu; selection sync decides whether it still applies.
onInteractOutside={(event) => {
if (event.target instanceof Node && editorDom?.contains(event.target)) {
event.preventDefault()
}
}}
>
<div ref={viewportRef} className={className} role={role} aria-label={ariaLabel}>
{children}
</div>
</PopoverContent>
</Popover>
)
}
@@ -4,6 +4,8 @@ import { cn } from '@/lib/utils'
import { commitRow } from './rich-markdown-commands'
import type { DocLinkMenuRow, DocLinkMenuState } from './rich-markdown-commands'
import { translate } from '@/i18n/i18n'
import { RichMarkdownCompletionPopover } from './RichMarkdownCompletionPopover'
import { useRichMarkdownCompletionScroll } from './useRichMarkdownCompletionScroll'
type RichMarkdownDocLinkMenuProps = {
editor: Editor | null
@@ -11,6 +13,7 @@ type RichMarkdownDocLinkMenuProps = {
rows: DocLinkMenuRow[]
totalMatches: number
selectedIndex: number
onClose: () => void
}
export function RichMarkdownDocLinkMenu({
@@ -18,15 +21,20 @@ export function RichMarkdownDocLinkMenu({
menu,
rows,
totalMatches,
selectedIndex
selectedIndex,
onClose
}: RichMarkdownDocLinkMenuProps): React.JSX.Element {
const { viewportRef, activeItemRef } = useRichMarkdownCompletionScroll(selectedIndex, rows)
const overflow = totalMatches > rows.length
return (
<div
className="rich-markdown-doc-link-menu"
style={{ left: menu.left, top: menu.top }}
<RichMarkdownCompletionPopover
editor={editor}
position={menu.to}
onClose={onClose}
viewportRef={viewportRef}
className="rich-markdown-doc-link-menu scrollbar-sleek"
role="listbox"
aria-label={translate(
ariaLabel={translate(
'auto.components.editor.RichMarkdownDocLinkMenu.0e8489bc11',
'Markdown document links'
)}
@@ -44,7 +52,10 @@ export function RichMarkdownDocLinkMenu({
return (
<button
key={rowKey}
ref={index === selectedIndex ? activeItemRef : undefined}
type="button"
role="option"
aria-selected={index === selectedIndex}
className={cn('rich-markdown-doc-link-item', index === selectedIndex && 'is-active')}
// Prevent blur before click so the commit transaction retains its editor selection.
onMouseDown={(event) => event.preventDefault()}
@@ -79,6 +90,6 @@ export function RichMarkdownDocLinkMenu({
'↑↓ navigate&nbsp;&nbsp;↵ select&nbsp;&nbsp;esc dismiss'
)}
</div>
</div>
</RichMarkdownCompletionPopover>
)
}
@@ -377,6 +377,8 @@ export default function RichMarkdownEditor({
onImagePick={handleLocalImagePick}
onEmojiPick={menu.openEmojiMenu}
onCloseEmojiMenu={() => menu.setEmojiMenu(null)}
onCloseSlashMenu={() => menu.setSlashMenu(null)}
onCloseDocLinkMenu={() => menu.setDocLinkMenu(null)}
onOpenAnnotationPopover={review.openAnnotationPopover}
onCancelAnnotationPopover={() => {
review.setAnnotationPopover(null)
@@ -110,6 +110,8 @@ type RichMarkdownEditorSurfaceProps = {
onImagePick: () => void
onEmojiPick: (menu: SlashMenuState) => void
onCloseEmojiMenu: () => void
onCloseSlashMenu: () => void
onCloseDocLinkMenu: () => void
onOpenAnnotationPopover: () => void
onCancelAnnotationPopover: () => void
onSubmitAnnotation: (body: string) => Promise<void>
@@ -170,6 +172,8 @@ export function RichMarkdownEditorSurface({
onImagePick,
onEmojiPick,
onCloseEmojiMenu,
onCloseSlashMenu,
onCloseDocLinkMenu,
onOpenAnnotationPopover,
onCancelAnnotationPopover,
onSubmitAnnotation,
@@ -291,6 +295,7 @@ export function RichMarkdownEditorSurface({
selectedIndex={selectedCommandIndex}
onImagePick={onImagePick}
onEmojiPick={() => onEmojiPick(slashMenu)}
onClose={onCloseSlashMenu}
/>
) : null}
{emojiMenu ? (
@@ -309,6 +314,7 @@ export function RichMarkdownEditorSurface({
rows={docLinkRows}
totalMatches={docLinkTotalMatches}
selectedIndex={selectedDocLinkIndex}
onClose={onCloseDocLinkMenu}
/>
) : null}
<RichMarkdownAnnotationOverlay
@@ -0,0 +1,117 @@
// @vitest-environment happy-dom
import { Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { RichMarkdownSlashMenu } from './RichMarkdownSlashMenu'
import { slashCommands } from './rich-markdown-slash-commands'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
const ROW_HEIGHT = 30
const VIEWPORT_HEIGHT = 90
let editor: Editor
let editorElement: HTMLDivElement
let host: HTMLDivElement
let reactRoot: Root
beforeEach(() => {
editorElement = document.createElement('div')
document.body.append(editorElement)
editor = new Editor({ element: editorElement, extensions: [StarterKit], content: '<p>/</p>' })
vi.spyOn(editor.view, 'coordsAtPos').mockReturnValue({ left: 0, right: 0, top: 0, bottom: 0 })
host = document.createElement('div')
document.body.append(host)
reactRoot = createRoot(host)
})
afterEach(() => {
act(() => reactRoot.unmount())
editor.destroy()
document.body.innerHTML = ''
vi.restoreAllMocks()
})
function renderMenu(selectedIndex: number, onClose: () => void): void {
act(() =>
reactRoot.render(
<RichMarkdownSlashMenu
editor={editor}
slashMenu={{ query: '', from: 1, to: 2, left: 0, top: 0 }}
filteredCommands={slashCommands}
selectedIndex={selectedIndex}
onImagePick={vi.fn()}
onEmojiPick={vi.fn()}
onClose={onClose}
/>
)
)
}
function layOutResults(): HTMLElement {
const viewport = document.querySelector<HTMLElement>('.rich-markdown-slash-results')
if (!viewport) {
throw new Error('Missing slash results')
}
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: VIEWPORT_HEIGHT })
viewport.querySelectorAll<HTMLElement>('[role="option"]').forEach((option, index) => {
Object.defineProperty(option, 'offsetTop', { configurable: true, value: index * ROW_HEIGHT })
Object.defineProperty(option, 'offsetHeight', { configurable: true, value: ROW_HEIGHT })
})
return viewport
}
it('scrolls keyboard selection into view in both directions', () => {
const onClose = vi.fn()
renderMenu(0, onClose)
const viewport = layOutResults()
renderMenu(10, onClose)
expect(viewport.scrollTop).toBe(11 * ROW_HEIGHT - VIEWPORT_HEIGHT)
renderMenu(2, onClose)
expect(viewport.scrollTop).toBe(2 * ROW_HEIGHT)
})
it('stays open for editor clicks and closes for presses outside the editor', async () => {
const onClose = vi.fn()
renderMenu(0, onClose)
// Radix arms its outside-pointer listener on the next task after mount.
await act(() => new Promise((resolve) => setTimeout(resolve, 0)))
const press = (target: Element): void => {
act(() => {
target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 }))
target.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
})
}
press(editor.view.dom)
expect(onClose).not.toHaveBeenCalled()
const outside = document.createElement('button')
document.body.append(outside)
press(outside)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('closes when its workspace is hidden while still painted (opacity-0 + inert)', async () => {
const workspace = document.createElement('div')
document.body.append(workspace)
workspace.append(editorElement)
const onClose = vi.fn()
renderMenu(0, onClose)
workspace.className = 'absolute inset-0 flex'
await act(() => Promise.resolve())
expect(onClose).not.toHaveBeenCalled()
workspace.inert = true
workspace.setAttribute('aria-hidden', 'true')
await act(() => Promise.resolve())
expect(onClose).toHaveBeenCalled()
})
@@ -6,6 +6,8 @@ import { runSlashCommand } from './rich-markdown-slash-commands'
import type { SlashCommand, SlashMenuState } from './rich-markdown-slash-commands'
import type { SlashCommandGroup } from './rich-markdown-slash-command-primitives'
import { translate } from '@/i18n/i18n'
import { RichMarkdownCompletionPopover } from './RichMarkdownCompletionPopover'
import { useRichMarkdownCompletionScroll } from './useRichMarkdownCompletionScroll'
function slashCommandGroupLabel(group: SlashCommandGroup): string {
switch (group) {
@@ -37,6 +39,7 @@ type RichMarkdownSlashMenuProps = {
selectedIndex: number
onImagePick: () => void
onEmojiPick: () => void
onClose: () => void
}
export function RichMarkdownSlashMenu({
@@ -45,16 +48,23 @@ export function RichMarkdownSlashMenu({
filteredCommands,
selectedIndex,
onImagePick,
onEmojiPick
onEmojiPick,
onClose
}: RichMarkdownSlashMenuProps): React.JSX.Element {
const { viewportRef, activeItemRef } = useRichMarkdownCompletionScroll(
selectedIndex,
filteredCommands
)
let currentGroup: SlashCommand['group'] | null = null
return (
<div
<RichMarkdownCompletionPopover
editor={editor}
position={slashMenu.to}
onClose={onClose}
className="rich-markdown-slash-menu"
style={{ left: slashMenu.left, top: slashMenu.top }}
role="dialog"
aria-label={translate(
ariaLabel={translate(
'auto.components.editor.RichMarkdownSlashMenu.2e0400b958',
'Slash commands'
)}
@@ -75,7 +85,7 @@ export function RichMarkdownSlashMenu({
)}
/>
</div>
<div className="rich-markdown-slash-results scrollbar-sleek" role="listbox">
<div ref={viewportRef} className="rich-markdown-slash-results scrollbar-sleek" role="listbox">
{filteredCommands.length === 0 ? (
<div className="rich-markdown-slash-empty">
{translate(
@@ -95,6 +105,7 @@ export function RichMarkdownSlashMenu({
</div>
) : null}
<button
ref={index === selectedIndex ? activeItemRef : undefined}
type="button"
title={command.description}
role="option"
@@ -123,6 +134,6 @@ export function RichMarkdownSlashMenu({
})
)}
</div>
</div>
</RichMarkdownCompletionPopover>
)
}
@@ -0,0 +1,29 @@
import { useEffect, useRef, type RefObject } from 'react'
export function useRichMarkdownCompletionScroll(
selectedIndex: number,
rows: readonly unknown[]
): {
viewportRef: RefObject<HTMLDivElement | null>
activeItemRef: RefObject<HTMLButtonElement | null>
} {
const viewportRef = useRef<HTMLDivElement>(null)
const activeItemRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
const viewport = viewportRef.current
const item = activeItemRef.current
if (!viewport || !item) {
return
}
const top = item.offsetTop
const bottom = top + item.offsetHeight
if (top < viewport.scrollTop) {
viewport.scrollTop = top
} else if (bottom > viewport.scrollTop + viewport.clientHeight) {
viewport.scrollTop = bottom - viewport.clientHeight
}
}, [selectedIndex, rows])
return { viewportRef, activeItemRef }
}