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() }) })