import type { Middleware } from '@floating-ui/dom' import { Editor, Extension } from '@tiptap/core' import StarterKit from '@tiptap/starter-kit' import { describe, expect, it, vi } from 'vitest' import { exitSuggestion, Suggestion, SuggestionPluginKey } from '../suggestion.js' describe('suggestion integration', () => { it('should respect shouldShow returning false', async () => { const shouldShow = vi.fn().mockReturnValue(false) const items = vi.fn().mockReturnValue([]) const render = vi.fn() const MentionExtension = Extension.create({ name: 'mention-false', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', shouldShow, items, render: () => ({ onStart: render, onUpdate: render, onExit: render, }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) editor.chain().insertContent('@').run() // Flush microtasks because plugin view update is async await Promise.resolve() expect(shouldShow).toHaveBeenCalled() expect(render).not.toHaveBeenCalled() editor.destroy() }) it('should respect shouldShow returning true', async () => { const shouldShow = vi.fn().mockReturnValue(true) const render = vi.fn() const items = vi.fn().mockReturnValue([]) const MentionExtension = Extension.create({ name: 'mention-true', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', shouldShow, items, render: () => ({ onStart: render, onUpdate: render, onExit: render, }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) editor.chain().insertContent('@').run() // Flush microtasks because plugin view update is async await Promise.resolve() expect(shouldShow).toHaveBeenCalled() expect(render).toHaveBeenCalled() editor.destroy() }) it('should pass transaction to shouldShow', async () => { let capturedProps: any = null const shouldShow = vi.fn(props => { capturedProps = props return true }) const MentionExtension = Extension.create({ name: 'mention-props', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', shouldShow, }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) editor.chain().insertContent('@').run() // Flush microtasks await Promise.resolve() // The transaction is passed as a flat property expect(capturedProps.transaction).toBeDefined() expect(capturedProps.query).toBe('') expect(capturedProps.text).toBe('@') // Check that we receive the correct editor instance expect(capturedProps.editor).toBe(editor) editor.destroy() }) }) describe('suggestion dismissal', () => { /** Builds a minimal editor with a single @-mention suggestion and returns helpers. */ function setup( options: { allowSpaces?: boolean allowToIncludeChar?: boolean shouldResetDismissed?: Parameters[0]['shouldResetDismissed'] } = {}, ) { const onStart = vi.fn() const onUpdate = vi.fn() const onExit = vi.fn() const MentionExtension = Extension.create({ name: 'mention-dismiss', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', allowSpaces: options.allowSpaces, allowToIncludeChar: options.allowToIncludeChar, items: () => [], shouldResetDismissed: options.shouldResetDismissed, render: () => ({ onStart, onUpdate, onExit }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) return { editor, onStart, onUpdate, onExit } } it('does not re-open the suggestion when the user keeps typing in the same word after dismissal', async () => { const { editor, onStart, onUpdate } = setup() // Trigger suggestion editor.chain().insertContent('@fo').run() await Promise.resolve() expect(onStart).toHaveBeenCalledTimes(1) // Dismiss via exitSuggestion (same as pressing Escape) exitSuggestion(editor.view, SuggestionPluginKey) await Promise.resolve() const startCallsBefore = onStart.mock.calls.length const updateCallsBefore = onUpdate.mock.calls.length // Keep typing in the same word editor.chain().insertContent('o').run() await Promise.resolve() expect(onStart.mock.calls.length).toBe(startCallsBefore) expect(onUpdate.mock.calls.length).toBe(updateCallsBefore) editor.destroy() }) it('removes the suggestion decoration when the suggestion is dismissed', async () => { const { editor } = setup() editor.chain().insertContent('@foo').run() await Promise.resolve() expect(editor.view.dom.querySelector('.suggestion')).not.toBeNull() exitSuggestion(editor.view, SuggestionPluginKey) await Promise.resolve() expect(editor.view.dom.querySelector('.suggestion')).toBeNull() editor.destroy() }) it('removes the suggestion decoration on Escape even when the renderer handles the keydown', async () => { const MentionExtension = Extension.create({ name: 'mention-escape-handled', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', items: () => [], render: () => ({ onKeyDown: ({ event }) => event.key === 'Escape', }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) editor.chain().insertContent('@foo').run() await Promise.resolve() expect(editor.view.dom.querySelector('.suggestion')).not.toBeNull() editor.view.someProp('handleKeyDown', f => f(editor.view, new KeyboardEvent('keydown', { key: 'Escape' })), ) await Promise.resolve() expect(editor.view.dom.querySelector('.suggestion')).toBeNull() editor.destroy() }) it('keeps the suggestion decoration removed while dismissal is being preserved', async () => { const { editor } = setup({ allowSpaces: true }) editor.chain().insertContent('@foo').run() await Promise.resolve() exitSuggestion(editor.view, SuggestionPluginKey) await Promise.resolve() editor.chain().insertContent(' bar').run() await Promise.resolve() expect(editor.view.dom.querySelector('.suggestion')).toBeNull() editor.destroy() }) it('re-opens the suggestion after a space is inserted following dismissal', async () => { const { editor, onStart } = setup() // Trigger and dismiss editor.chain().insertContent('@foo').run() await Promise.resolve() exitSuggestion(editor.view, SuggestionPluginKey) await Promise.resolve() const startCallsBefore = onStart.mock.calls.length // Space clears dismissed state; typing a new @ afterwards should open suggestion editor.chain().insertContent(' @').run() await Promise.resolve() expect(onStart.mock.calls.length).toBeGreaterThan(startCallsBefore) editor.destroy() }) it('re-opens the suggestion after a newline is inserted following dismissal', async () => { const { editor, onStart } = setup() // Trigger and dismiss editor.chain().insertContent('@foo').run() await Promise.resolve() exitSuggestion(editor.view, SuggestionPluginKey) await Promise.resolve() const startCallsBefore = onStart.mock.calls.length // Newline clears dismissed state; typing a new @ afterwards should open suggestion editor.commands.enter() await Promise.resolve() editor.chain().insertContent('@').run() await Promise.resolve() expect(onStart.mock.calls.length).toBeGreaterThan(startCallsBefore) editor.destroy() }) it('keeps the suggestion dismissed across spaces when allowSpaces is enabled', async () => { const { editor, onStart, onUpdate } = setup({ allowSpaces: true }) editor.chain().insertContent('@foo').run() await Promise.resolve() expect(onStart).toHaveBeenCalledTimes(1) exitSuggestion(editor.view, SuggestionPluginKey) await Promise.resolve() const startCallsBefore = onStart.mock.calls.length const updateCallsBefore = onUpdate.mock.calls.length editor.chain().insertContent(' bar').run() await Promise.resolve() expect(onStart.mock.calls.length).toBe(startCallsBefore) expect(onUpdate.mock.calls.length).toBe(updateCallsBefore) editor.destroy() }) it('does not treat spaces as part of the dismissed context when allowToIncludeChar disables allowSpaces', async () => { const { editor, onStart } = setup({ allowSpaces: true, allowToIncludeChar: true }) editor.chain().insertContent('@foo').run() await Promise.resolve() expect(onStart).toHaveBeenCalledTimes(1) exitSuggestion(editor.view, SuggestionPluginKey) await Promise.resolve() const startCallsBefore = onStart.mock.calls.length editor.chain().insertContent(' @').run() await Promise.resolve() expect(onStart.mock.calls.length).toBeGreaterThan(startCallsBefore) editor.destroy() }) it('re-opens the suggestion when the trigger char is deleted and retyped', async () => { const { editor, onStart } = setup() // Trigger and dismiss editor.chain().insertContent('@').run() await Promise.resolve() exitSuggestion(editor.view, SuggestionPluginKey) await Promise.resolve() const startCallsBefore = onStart.mock.calls.length // Delete the @ — cursor leaves trigger context, dismissedFrom clears editor.commands.deleteRange({ from: 1, to: 2 }) await Promise.resolve() // Retype @ editor.chain().insertContent('@').run() await Promise.resolve() expect(onStart.mock.calls.length).toBeGreaterThan(startCallsBefore) editor.destroy() }) it('re-opens the suggestion when a different trigger is typed elsewhere', async () => { const { editor, onStart } = setup() // Trigger and dismiss at first @ editor.chain().insertContent('@foo').run() await Promise.resolve() exitSuggestion(editor.view, SuggestionPluginKey) await Promise.resolve() const startCallsBefore = onStart.mock.calls.length // Move to a new word and type a fresh @ editor.chain().insertContent(' @').run() await Promise.resolve() expect(onStart.mock.calls.length).toBeGreaterThan(startCallsBefore) editor.destroy() }) it('allows consumers to reset the dismissed context manually', async () => { const shouldResetDismissed = vi.fn(({ transaction }) => transaction.doc.textBetween(0, transaction.doc.content.size, '\n').includes('.'), ) const { editor, onStart } = setup({ shouldResetDismissed }) editor.chain().insertContent('@foo').run() await Promise.resolve() exitSuggestion(editor.view, SuggestionPluginKey) await Promise.resolve() const startCallsBefore = onStart.mock.calls.length editor.chain().insertContent('.').run() await Promise.resolve() expect(shouldResetDismissed).toHaveBeenCalled() expect(onStart.mock.calls.length).toBeGreaterThan(startCallsBefore) editor.destroy() }) }) describe('suggestion minQueryLength', () => { it('should not call items when query is shorter than minQueryLength', async () => { const items = vi.fn().mockReturnValue([]) const onStart = vi.fn() const onUpdate = vi.fn() const onExit = vi.fn() const MentionExtension = Extension.create({ name: 'mention-min-query', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', minQueryLength: 2, items, render: () => ({ onStart, onUpdate, onExit }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) // Type @a — query "a" is too short (length 1 < 2) editor.chain().insertContent('@a').run() await Promise.resolve() expect(onStart).toHaveBeenCalledTimes(1) // items should not have been called because query.length < minQueryLength expect(items).not.toHaveBeenCalled() // The props passed to onStart should have items: [] expect(onStart).toHaveBeenCalledWith(expect.objectContaining({ items: [] })) // Continue typing to reach minQueryLength editor.chain().insertContent('b').run() await Promise.resolve() // items should be called now with query 'ab' expect(items).toHaveBeenCalledWith( expect.objectContaining({ editor: expect.any(Object), query: 'ab', }), ) editor.destroy() }) }) describe('suggestion initialItems', () => { it('should pass initialItems to onBeforeStart/onBeforeUpdate and resolved items to onStart/onUpdate', async () => { const initialItems = [{ id: 1, label: 'Popular' }] const resolvedItems = [{ id: 2, label: 'Filtered' }] const items = vi.fn().mockResolvedValue(resolvedItems) const onBeforeStart = vi.fn() const onBeforeUpdate = vi.fn() const onStart = vi.fn() const onUpdate = vi.fn() const onExit = vi.fn() const MentionExtension = Extension.create({ name: 'mention-initial-items', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', initialItems, items, render: () => ({ onBeforeStart, onBeforeUpdate, onStart, onUpdate, onExit }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) // Type @ to start suggestion editor.chain().insertContent('@').run() await Promise.resolve() await Promise.resolve() // onBeforeStart should receive initialItems expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ items: initialItems })) // onStart mounts immediately with the initial items while loading expect(onStart).toHaveBeenLastCalledWith( expect.objectContaining({ items: initialItems, loading: true }), ) // onUpdate receives the async-resolved items expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ items: resolvedItems })) // items() should still have been called expect(items).toHaveBeenCalledWith( expect.objectContaining({ editor: expect.any(Object), query: '', }), ) // Reset mocks for the update phase items.mockClear() onBeforeUpdate.mockClear() onUpdate.mockClear() // Type another character to trigger an update editor.chain().insertContent('a').run() await Promise.resolve() await Promise.resolve() // onBeforeUpdate should also receive initialItems expect(onBeforeUpdate).toHaveBeenCalledWith(expect.objectContaining({ items: initialItems })) // onUpdate should receive the async-resolved items expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ items: resolvedItems })) expect(items).toHaveBeenCalledWith( expect.objectContaining({ editor: expect.any(Object), query: 'a', }), ) editor.destroy() }) }) describe('suggestion loading state', () => { it('should set loading to true in before callbacks and false after when items() is called', async () => { const items = vi.fn().mockResolvedValue([]) const onBeforeStart = vi.fn() const onBeforeUpdate = vi.fn() const onStart = vi.fn() const onUpdate = vi.fn() const onExit = vi.fn() const MentionExtension = Extension.create({ name: 'mention-loading', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', items, render: () => ({ onBeforeStart, onBeforeUpdate, onStart, onUpdate, onExit }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) // Type @ to start suggestion — triggers async items() editor.chain().insertContent('@').run() await Promise.resolve() await Promise.resolve() // onBeforeStart fires before items() resolves → loading should be true expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ loading: true })) // onStart fires immediately with loading enabled expect(onStart).toHaveBeenLastCalledWith(expect.objectContaining({ loading: true })) // onUpdate fires after items() resolves → loading should be false expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ loading: false })) // Type another char to trigger an update editor.chain().insertContent('a').run() await Promise.resolve() await Promise.resolve() // onBeforeUpdate fires before items() resolves → loading should be true expect(onBeforeUpdate).toHaveBeenCalledWith(expect.objectContaining({ loading: true })) // onUpdate fires after items() resolves → loading should be false expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ loading: false })) editor.destroy() }) it('should recover when items() rejects', async () => { const items = vi.fn().mockRejectedValue(new Error('boom')) const onBeforeStart = vi.fn() const onUpdate = vi.fn() const onStart = vi.fn() const MentionExtension = Extension.create({ name: 'mention-loading-rejects', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', items, render: () => ({ onBeforeStart, onStart, onUpdate }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) editor.chain().insertContent('@').run() await Promise.resolve() await Promise.resolve() expect(items).toHaveBeenCalledTimes(1) expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ loading: true })) expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ loading: false })) editor.destroy() }) it('should set loading to false in all callbacks when minQueryLength blocks items()', async () => { const items = vi.fn().mockResolvedValue([]) const onBeforeStart = vi.fn() const onStart = vi.fn() const MentionExtension = Extension.create({ name: 'mention-loading-blocked', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', minQueryLength: 3, items, render: () => ({ onBeforeStart, onStart }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) // Type @a — query "a" is too short, items() won't be called editor.chain().insertContent('@a').run() await Promise.resolve() // No async call happens → loading should be false expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ loading: false })) expect(onStart).toHaveBeenCalledWith(expect.objectContaining({ loading: false })) editor.destroy() }) }) describe('suggestion AbortSignal', () => { it('should pass signal to items() and abort previous signal on new query', async () => { const signals: AbortSignal[] = [] let resolveFirst: (value: unknown) => void = () => {} const items = vi.fn().mockImplementation(({ signal }) => { signals.push(signal) // First call returns a promise that we control if (signals.length === 1) { return new Promise(resolve => { resolveFirst = resolve }) } // Subsequent calls resolve immediately return [] }) const onStart = vi.fn() const onUpdate = vi.fn() const MentionExtension = Extension.create({ name: 'mention-abort', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', items, render: () => ({ onStart, onUpdate }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) // Type @ to start suggestion — first items() call starts but doesn't resolve editor.chain().insertContent('@').run() await Promise.resolve() expect(items).toHaveBeenCalledTimes(1) expect(signals[0].aborted).toBe(false) // Type a — triggers a second items() while first is still in-flight editor.chain().insertContent('a').run() await Promise.resolve() // items() should have been called a second time expect(items).toHaveBeenCalledTimes(2) // The first signal should be aborted expect(signals[0].aborted).toBe(true) // The second signal should be fresh expect(signals[1].aborted).toBe(false) // Clean up the hanging promise resolveFirst([]) await Promise.resolve() editor.destroy() }) it('should not emit stale callbacks after a request is superseded', async () => { const signals: AbortSignal[] = [] let resolveFirst: (value: unknown) => void = () => {} const items = vi.fn().mockImplementation(({ signal }) => { signals.push(signal) if (signals.length === 1) { return new Promise(resolve => { resolveFirst = resolve }) } return [] }) const onStart = vi.fn() const onUpdate = vi.fn() const MentionExtension = Extension.create({ name: 'mention-abort-stale-callbacks', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', items, render: () => ({ onStart, onUpdate }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) editor.chain().insertContent('@').run() await Promise.resolve() editor.chain().insertContent('a').run() await Promise.resolve() await Promise.resolve() const startCallsBefore = onStart.mock.calls.length const updateCallsBefore = onUpdate.mock.calls.length resolveFirst([]) await Promise.resolve() await Promise.resolve() expect(onStart.mock.calls.length).toBe(startCallsBefore) expect(onUpdate.mock.calls.length).toBe(updateCallsBefore) editor.destroy() }) it('should pass signal as a property in the items callback props', async () => { const items = vi.fn().mockImplementation(({ signal }) => { expect(signal).toBeInstanceOf(AbortSignal) return [] }) const MentionExtension = Extension.create({ name: 'mention-abort-prop', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', items, }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) editor.chain().insertContent('@').run() await Promise.resolve() expect(items).toHaveBeenCalled() // The callback assertion already checked signal is an AbortSignal editor.destroy() }) }) describe('suggestion debounce', () => { it('should delay the items() call by the configured debounce time', async () => { vi.useFakeTimers() const items = vi.fn().mockResolvedValue([]) const onBeforeStart = vi.fn() const onStart = vi.fn() const MentionExtension = Extension.create({ name: 'mention-debounce', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', debounce: 100, items, render: () => ({ onBeforeStart, onStart }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) // Type @ to trigger suggestion editor.chain().insertContent('@').run() // items() should not have been called yet (debounce pending) expect(items).not.toHaveBeenCalled() // onBeforeStart should fire immediately (not debounced) expect(onBeforeStart).toHaveBeenCalled() // Advance past the debounce window await vi.advanceTimersByTimeAsync(100) // Now items() should have been called expect(items).toHaveBeenCalledTimes(1) // onStart fires after items resolves expect(onStart).toHaveBeenCalled() vi.useRealTimers() editor.destroy() }) it('should cancel pending debounce work on destroy', async () => { vi.useFakeTimers() const items = vi.fn().mockResolvedValue([]) const onBeforeStart = vi.fn() const onStart = vi.fn() const MentionExtension = Extension.create({ name: 'mention-debounce-destroy', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', debounce: 100, items, render: () => ({ onBeforeStart, onStart }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) editor.chain().insertContent('@').run() await Promise.resolve() expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ loading: true })) const startCallsBefore = onStart.mock.calls.length editor.destroy() await vi.advanceTimersByTimeAsync(100) expect(items).not.toHaveBeenCalled() expect(onStart.mock.calls.length).toBe(startCallsBefore) vi.useRealTimers() }) it('should reset the debounce timer on rapid typing', async () => { vi.useFakeTimers() const items = vi.fn().mockResolvedValue([]) const MentionExtension = Extension.create({ name: 'mention-debounce-rapid', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', debounce: 100, items, }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) // Type @a editor.chain().insertContent('@a').run() await vi.advanceTimersByTimeAsync(60) // Type b before debounce fires editor.chain().insertContent('b').run() await vi.advanceTimersByTimeAsync(60) // Debounce should have reset — items not called yet expect(items).not.toHaveBeenCalled() // Advance past remaining debounce from last keystroke await vi.advanceTimersByTimeAsync(100) // Should have been called only once (not twice) expect(items).toHaveBeenCalledTimes(1) vi.useRealTimers() editor.destroy() }) }) describe('suggestion positioning options', () => { it('should forward placement, offset, container, and flip to SuggestionProps', async () => { const onStart = vi.fn() const MentionExtension = Extension.create({ name: 'mention-positioning', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', placement: 'top-start', offset: { mainAxis: 8, crossAxis: 4 }, container: '.my-container', flip: false, render: () => ({ onStart }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) editor.chain().insertContent('@').run() await Promise.resolve() expect(onStart).toHaveBeenCalledWith( expect.objectContaining({ placement: 'top-start', offset: { mainAxis: 8, crossAxis: 4 }, container: '.my-container', flip: false, }), ) editor.destroy() }) it('should use defaults when positioning options are not set', async () => { const onStart = vi.fn() const MentionExtension = Extension.create({ name: 'mention-positioning-defaults', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', render: () => ({ onStart }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) editor.chain().insertContent('@').run() await Promise.resolve() expect(onStart).toHaveBeenCalledWith( expect.objectContaining({ placement: 'bottom-start', offset: { mainAxis: 4, crossAxis: 0 }, flip: true, }), ) editor.destroy() }) it('should pass through floatingUi config and middleware', async () => { const customMiddleware = { name: 'custom', fn: vi.fn(() => ({ x: 0, y: 0 })), } as Middleware const onStart = vi.fn() const MentionExtension = Extension.create({ name: 'mention-floating-ui', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', placement: 'top-start', offset: { mainAxis: 8, crossAxis: 4 }, flip: false, floatingUi: { strategy: 'fixed', middleware: [customMiddleware], }, render: () => ({ onStart }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) editor.chain().insertContent('@').run() await Promise.resolve() expect(onStart).toHaveBeenCalledWith( expect.objectContaining({ floatingUi: expect.objectContaining({ placement: 'top-start', strategy: 'fixed', }), }), ) const floatingUi = onStart.mock.calls[0][0].floatingUi expect(floatingUi.middleware).toHaveLength(2) expect(floatingUi.middleware).toEqual(expect.arrayContaining([customMiddleware])) editor.destroy() }) }) describe('suggestion mount', () => { // Captures `props.mount` from a started suggestion so each test can call it // against a real element. async function getMount(container?: string | HTMLElement) { const onStart = vi.fn() const MentionExtension = Extension.create({ name: 'mention-mount', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', container, render: () => ({ onStart }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) editor.chain().insertContent('@').run() await Promise.resolve() return { mount: onStart.mock.calls[0][0].mount, editor } } it('mounts the element into document.body and removes it on unmount', async () => { const { mount, editor } = await getMount() const element = document.createElement('div') const unmount = mount(element) expect(element.parentElement).toBe(document.body) unmount() expect(element.isConnected).toBe(false) editor.destroy() }) it('mounts the element into a provided container', async () => { const container = document.createElement('div') document.body.appendChild(container) const { mount, editor } = await getMount(container) const element = document.createElement('div') const unmount = mount(element) expect(element.parentElement).toBe(container) unmount() container.remove() editor.destroy() }) it('leaves an already-mounted element in place (escape hatch)', async () => { const { mount, editor } = await getMount() const element = document.createElement('div') const host = document.createElement('div') host.appendChild(element) document.body.appendChild(host) const unmount = mount(element) expect(element.parentElement).toBe(host) // We did not mount it, so unmount must not remove it. unmount() expect(element.parentElement).toBe(host) host.remove() editor.destroy() }) }) describe('suggestion outside click', () => { async function setup(dismissOnOutsideClick?: boolean) { const onStart = vi.fn() const MentionExtension = Extension.create({ name: 'mention-outside-click', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '@', dismissOnOutsideClick, render: () => ({ onStart }), }), ] }, }) const editor = new Editor({ extensions: [StarterKit, MentionExtension], content: '

', }) editor.chain().insertContent('@').run() await Promise.resolve() const mount = onStart.mock.calls[0][0].mount const isActive = () => SuggestionPluginKey.getState(editor.state)?.active === true return { mount, editor, isActive } } it('dismisses when clicking outside the popup and editor', async () => { const { mount, editor, isActive } = await setup() const element = document.createElement('div') const unmount = mount(element) expect(isActive()).toBe(true) const outside = document.createElement('div') document.body.appendChild(outside) outside.dispatchEvent(new Event('pointerdown', { bubbles: true })) expect(isActive()).toBe(false) unmount() outside.remove() editor.destroy() }) it('does not dismiss when clicking inside the popup', async () => { const { mount, editor, isActive } = await setup() const element = document.createElement('div') const unmount = mount(element) element.dispatchEvent(new Event('pointerdown', { bubbles: true })) expect(isActive()).toBe(true) unmount() editor.destroy() }) it('does not attach the listener when dismissOnOutsideClick is false', async () => { const { mount, editor, isActive } = await setup(false) const element = document.createElement('div') const unmount = mount(element) const outside = document.createElement('div') document.body.appendChild(outside) outside.dispatchEvent(new Event('pointerdown', { bubbles: true })) expect(isActive()).toBe(true) unmount() outside.remove() editor.destroy() }) })