Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [4.6.1](https://github.com/nuxt/ui/compare/v4.6.0...v4.6.1) (2026-03-24)

### Features

* **EditorSuggestionMenu:** add `suggestion` prop to expose TipTap suggestion matching options ([#6233](https://github.com/nuxt/ui/issues/6233))

## [4.6.0](https://github.com/nuxt/ui/compare/v4.5.1...v4.6.0) (2026-03-23)

### ⚠ BREAKING CHANGES
Expand Down
21 changes: 21 additions & 0 deletions docs/content/docs/2.components/editor-suggestion-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,27 @@ Use the `char` prop to change the trigger character. Defaults to `/`{lang="ts-ty
</template>
```

### Suggestion

Use the `suggestion` prop to customize TipTap's [Suggestion matching behavior](https://tiptap.dev/docs/editor/api/utilities/suggestion#settings).

This is useful when the trigger character should open directly after other characters instead of requiring the default whitespace prefix.

```vue
<template>
<UEditor v-slot="{ editor }">
<UEditorSuggestionMenu
:editor="editor"
:items="items"
char=":"
:suggestion="{
allowedPrefixes: null
}"
/>
</UEditor>
</template>
```

### Options

Use the `options` prop to customize the positioning behavior using [Floating UI options](https://floating-ui.com/docs/computeposition#options).
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/components/EditorSuggestionMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export type EditorSuggestionMenuItem<H extends EditorCustomHandlers = EditorCust
| EditorSuggestionMenuSeparatorItem
| EditorSuggestionMenuActionItem<H>

export interface EditorSuggestionMenuProps<T extends EditorSuggestionMenuItem = EditorSuggestionMenuItem> extends Partial<Pick<EditorMenuOptions<T>, 'editor' | 'char' | 'pluginKey' | 'filterFields' | 'limit' | 'options' | 'appendTo'>> {
export interface EditorSuggestionMenuProps<T extends EditorSuggestionMenuItem = EditorSuggestionMenuItem> extends Partial<Pick<EditorMenuOptions<T>, 'editor' | 'char' | 'pluginKey' | 'filterFields' | 'limit' | 'options' | 'suggestion' | 'appendTo'>> {
/**
* @defaultValue 'md'
*/
Expand Down Expand Up @@ -90,6 +90,7 @@ onMounted(async () => {
filterFields: props.filterFields,
limit: props.limit,
options: props.options,
suggestion: props.suggestion,
appendTo: props.appendTo,
ui,
onSelect: (editor, range, item) => {
Expand Down
7 changes: 6 additions & 1 deletion src/runtime/composables/useEditorMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { computePosition } from '@floating-ui/dom'
import type { Strategy, Placement } from '@floating-ui/dom'
import type { Editor } from '@tiptap/vue-3'
import { VueRenderer } from '@tiptap/vue-3'
import type { SuggestionProps } from '@tiptap/suggestion'
import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'
import Suggestion from '@tiptap/suggestion'
import { PluginKey } from '@tiptap/pm/state'
import type { FloatingUIOptions } from '../types/editor'
Expand Down Expand Up @@ -65,6 +65,10 @@ export interface EditorMenuOptions<T = any> {
* @see https://floating-ui.com/docs/computePosition#options
*/
options?: FloatingUIOptions
/**
* Optional TipTap Suggestion matching options.
*/
suggestion?: Partial<SuggestionOptions>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
/**
* The DOM element to append the menu to. Default is the editor's parent element.
*
Expand Down Expand Up @@ -493,6 +497,7 @@ export function useEditorMenu<T = any>(options: EditorMenuOptions<T>) {
pluginKey: pluginKeyInstance,
editor: options.editor,
char: options.char,
...(options.suggestion || {}),
items: ({ query: q }: { query: string }) => {
// Update the searchTerm ref for external access
searchTerm.value = q
Expand Down
125 changes: 125 additions & 0 deletions test/composables/useEditorMenu.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { computed } from 'vue'
import { beforeEach, describe, expect, it, vi, expectTypeOf } from 'vitest'
import type { SuggestionOptions } from '@tiptap/suggestion'
import { useEditorMenu } from '../../src/runtime/composables/useEditorMenu'
import type { EditorMenuOptions } from '../../src/runtime/composables/useEditorMenu'
import type { EditorSuggestionMenuProps } from '../../src/runtime/components/EditorSuggestionMenu.vue'

const { suggestionMock } = vi.hoisted(() => ({
suggestionMock: vi.fn((config: any) => config)
}))

vi.mock('@tiptap/suggestion', () => ({
default: suggestionMock
}))

function createEditor() {
const dom = document.createElement('div')
const parent = document.createElement('div')
parent.appendChild(dom)

return {
isDestroyed: false,
view: {
dom,
state: {
tr: {
setMeta: vi.fn(() => ({}))
}
},
dispatch: vi.fn()
}
} as any
}

function createOptions(overrides: Partial<EditorMenuOptions<{ label: string }>> = {}): EditorMenuOptions<{ label: string }> {
return {
editor: createEditor(),
char: ':',
pluginKey: 'suggestion-menu',
items: [{ label: 'Alpha' }, { label: 'Beta' }],
onSelect: vi.fn(),
renderItem: vi.fn(() => []),
ui: computed(() => ({
content: () => '',
viewport: () => '',
group: () => '',
label: () => '',
separator: () => '',
item: () => '',
itemLeadingIcon: () => '',
itemWrapper: () => '',
itemLabel: () => '',
itemDescription: () => ''
})),
...overrides
}
}

function getSuggestionConfig() {
const config = suggestionMock.mock.calls[0]?.[0]

if (!config) {
throw new Error('Suggestion should be called exactly once')
}

return config
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

describe('useEditorMenu', () => {
beforeEach(() => {
suggestionMock.mockClear()
})

it('forwards suggestion matching options', () => {
useEditorMenu(createOptions({
suggestion: {
allowedPrefixes: null,
allowSpaces: true,
startOfLine: true
}
}))

const config = getSuggestionConfig()

expect(config.allowedPrefixes).toBeNull()
expect(config.allowSpaces).toBe(true)
expect(config.startOfLine).toBe(true)
expect(config.char).toBe(':')
})

it('keeps existing defaults when suggestion is omitted', () => {
useEditorMenu(createOptions())

const config = getSuggestionConfig()
const items = config.items({ query: 'al' })

expect(config).not.toHaveProperty('allowedPrefixes')
expect(items).toEqual([{ label: 'Alpha' }])
})

it('keeps menu callbacks authoritative over suggestion overrides', () => {
const suggestionItems = vi.fn(() => [])
const suggestionCommand = vi.fn()
const suggestionRender = vi.fn()

useEditorMenu(createOptions({
suggestion: {
items: suggestionItems,
command: suggestionCommand,
render: suggestionRender
} as Partial<SuggestionOptions>
}))

const config = getSuggestionConfig()

expect(config.items).not.toBe(suggestionItems)
expect(config.command).not.toBe(suggestionCommand)
expect(config.render).not.toBe(suggestionRender)
})

it('types suggestion options on the composable and component props', () => {
expectTypeOf<EditorMenuOptions<{ label: string }>['suggestion']>().toMatchTypeOf<Partial<SuggestionOptions> | undefined>()
expectTypeOf<EditorSuggestionMenuProps['suggestion']>().toMatchTypeOf<Partial<SuggestionOptions> | undefined>()
})
})
Loading