EditorSuggestionMenu
A command menu that displays formatting and action suggestions when typing the / character in the editor.
Usage
The EditorSuggestionMenu component displays a menu of formatting and action suggestions when typing a trigger character in the editor and executes the corresponding handler when an item is selected.
It uses the
useEditorMenu composable built on top of TipTap's Suggestion utility to filter items as you type and support keyboard navigation (arrow keys, enter to select, escape to close).It must be used inside an Editor component's default slot to have access to the editor instance.
<script setup lang="ts">
import type { EditorSuggestionMenuItem } from 'pohon-ui'
const value = ref(`# Suggestion Menu
Type / to open the suggestion menu and browse available formatting commands.`)
const items: Array<Array<EditorSuggestionMenuItem>> = [
[
{
type: 'label',
label: 'Text'
},
{
kind: 'paragraph',
label: 'Paragraph',
icon: 'i-lucide-type'
},
{
kind: 'heading',
level: 1,
label: 'Heading 1',
icon: 'i-lucide-heading-1'
},
{
kind: 'heading',
level: 2,
label: 'Heading 2',
icon: 'i-lucide-heading-2'
},
{
kind: 'heading',
level: 3,
label: 'Heading 3',
icon: 'i-lucide-heading-3'
}
],
[
{
type: 'label',
label: 'Lists'
},
{
kind: 'bulletList',
label: 'Bullet List',
icon: 'i-lucide-list'
},
{
kind: 'orderedList',
label: 'Numbered List',
icon: 'i-lucide-list-ordered'
}
],
[
{
type: 'label',
label: 'Insert'
},
{
kind: 'blockquote',
label: 'Blockquote',
icon: 'i-lucide-text-quote'
},
{
kind: 'codeBlock',
label: 'Code Block',
icon: 'i-lucide-square-code'
},
{
kind: 'horizontalRule',
label: 'Divider',
icon: 'i-lucide-separator-horizontal'
}
]
]
// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined
</script>
<template>
<PEditor
v-slot="{ editor }"
v-model="value"
content-type="markdown"
placeholder="Type / for commands..."
class="min-h-21 w-full"
>
<PEditorSuggestionMenu :editor="editor" :items="items" :append-to="appendToBody" />
</PEditor>
</template><script setup lang="ts">
import { ref } from 'vue'
import type { EditorSuggestionMenuItem } from 'pohon-ui'
const value = ref(`# Suggestion Menu
Type / to open the suggestion menu and browse available formatting commands.`)
const items: Array<Array<EditorSuggestionMenuItem>> = [
[
{
type: 'label',
label: 'Text'
},
{
kind: 'paragraph',
label: 'Paragraph',
icon: 'i-lucide-type'
},
{
kind: 'heading',
level: 1,
label: 'Heading 1',
icon: 'i-lucide-heading-1'
},
{
kind: 'heading',
level: 2,
label: 'Heading 2',
icon: 'i-lucide-heading-2'
},
{
kind: 'heading',
level: 3,
label: 'Heading 3',
icon: 'i-lucide-heading-3'
}
],
[
{
type: 'label',
label: 'Lists'
},
{
kind: 'bulletList',
label: 'Bullet List',
icon: 'i-lucide-list'
},
{
kind: 'orderedList',
label: 'Numbered List',
icon: 'i-lucide-list-ordered'
}
],
[
{
type: 'label',
label: 'Insert'
},
{
kind: 'blockquote',
label: 'Blockquote',
icon: 'i-lucide-text-quote'
},
{
kind: 'codeBlock',
label: 'Code Block',
icon: 'i-lucide-square-code'
},
{
kind: 'horizontalRule',
label: 'Divider',
icon: 'i-lucide-separator-horizontal'
}
]
]
// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined
</script>
<template>
<PEditor
v-slot="{ editor }"
v-model="value"
content-type="markdown"
placeholder="Type / for commands..."
class="min-h-21 w-full"
>
<PEditorSuggestionMenu :editor="editor" :items="items" :append-to="appendToBody" />
</PEditor>
</template>Items
Use the items prop as an array of objects with the following properties:
kind?: "textAlign" | "heading" | "link" | "image" | "blockquote" | "bulletList" | "orderedList" | "taskList" | "codeBlock" | "horizontalRule" | "paragraph" | "clearFormatting" | "duplicate" | "delete" | "moveUp" | "moveDown" | "suggestion" | "mention" | "emoji"label?: stringdescription?: stringicon?: stringtype?: "label" | "separator"disabled?: boolean
<script setup lang="ts">
import type { EditorSuggestionMenuItem } from 'pohon-ui';
const value = ref(`Type / to see a list of commands.
You can customize the items with icons, labels, and descriptions.`);
const items: Array<Array<EditorSuggestionMenuItem>> = [[{
type: 'label',
label: 'Text Styles',
}, {
kind: 'paragraph',
label: 'Paragraph',
icon: 'i-lucide-type',
}, {
kind: 'heading',
level: 1,
label: 'Heading 1',
icon: 'i-lucide-heading-1',
}, {
kind: 'heading',
level: 2,
label: 'Heading 2',
icon: 'i-lucide-heading-2',
}, {
kind: 'heading',
level: 3,
label: 'Heading 3',
icon: 'i-lucide-heading-3',
}], [{
type: 'label',
label: 'Lists',
}, {
kind: 'bulletList',
label: 'Bullet List',
icon: 'i-lucide-list',
}, {
kind: 'orderedList',
label: 'Numbered List',
icon: 'i-lucide-list-ordered',
}], [{
type: 'label',
label: 'Blocks',
}, {
kind: 'blockquote',
label: 'Blockquote',
icon: 'i-lucide-text-quote',
}, {
kind: 'codeBlock',
label: 'Code Block',
icon: 'i-lucide-square-code',
}, {
kind: 'horizontalRule',
label: 'Divider',
icon: 'i-lucide-separator-horizontal',
}]];
</script>
<template>
<PEditor
v-slot="{ editor }"
v-model="value"
content-type="markdown"
placeholder="Type / for commands..."
class="min-h-19 w-full"
>
<PEditorSuggestionMenu
:editor="editor"
:items="items"
/>
</PEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { EditorSuggestionMenuItem } from 'pohon-ui';
const value = ref(`Type / to see a list of commands.
You can customize the items with icons, labels, and descriptions.`);
const items: Array<Array<EditorSuggestionMenuItem>> = [[{
type: 'label',
label: 'Text Styles',
}, {
kind: 'paragraph',
label: 'Paragraph',
icon: 'i-lucide-type',
}, {
kind: 'heading',
level: 1,
label: 'Heading 1',
icon: 'i-lucide-heading-1',
}, {
kind: 'heading',
level: 2,
label: 'Heading 2',
icon: 'i-lucide-heading-2',
}, {
kind: 'heading',
level: 3,
label: 'Heading 3',
icon: 'i-lucide-heading-3',
}], [{
type: 'label',
label: 'Lists',
}, {
kind: 'bulletList',
label: 'Bullet List',
icon: 'i-lucide-list',
}, {
kind: 'orderedList',
label: 'Numbered List',
icon: 'i-lucide-list-ordered',
}], [{
type: 'label',
label: 'Blocks',
}, {
kind: 'blockquote',
label: 'Blockquote',
icon: 'i-lucide-text-quote',
}, {
kind: 'codeBlock',
label: 'Code Block',
icon: 'i-lucide-square-code',
}, {
kind: 'horizontalRule',
label: 'Divider',
icon: 'i-lucide-separator-horizontal',
}]];
</script>
<template>
<PEditor
v-slot="{ editor }"
v-model="value"
content-type="markdown"
placeholder="Type / for commands..."
class="min-h-19 w-full"
>
<PEditorSuggestionMenu
:editor="editor"
:items="items"
/>
</PEditor>
</template>
You can also pass an array of arrays to the
items prop to create separated groups of items.Use
type: 'label' for section headers and type: 'separator' for visual dividers to organize commands into logical groups for better discoverability.Char
Use the char prop to change the trigger character. Defaults to /.
<template>
<PEditor v-slot="{ editor }">
<PEditorSuggestionMenu
:editor="editor"
:items="items"
char=">"
/>
</PEditor>
</template>
Suggestion
Use the suggestion prop to customize TipTap's Suggestion matching behavior.
This is useful when the trigger character should open directly after other characters instead of requiring the default whitespace prefix.
<template>
<PEditor v-slot="{ editor }">
<PEditorSuggestionMenu
:editor="editor"
:items="items"
char=":"
:suggestion="{
allowedPrefixes: null,
}"
/>
</PEditor>
</template>
Options
Use the options prop to customize the positioning behavior using Floating UI options.
<template>
<PEditor v-slot="{ editor }">
<PEditorSuggestionMenu
:editor="editor"
:items="items"
:options="{
placement: 'bottom-start',
offset: 4,
}"
/>
</PEditor>
</template>
API
Props
| Prop | Default | Type |
|---|
Theme
app.config.ts
export default defineAppConfig({
ui: {
editorSuggestionMenu: {
slots: {
content: 'rounded-md bg-background flex flex-col max-h-96 max-w-60 min-w-48 ring ring-border shadow-lg origin-$akar-dropdown-menu-content-transform-origin overflow-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
viewport: 'relative divide-y divide-border scroll-py-1 overflow-y-auto flex-1',
group: 'p-1 isolate',
label: 'color-text-highlighted font-semibold flex w-full items-center',
separator: '-mx-1 my-1 h-px bg-border',
item: 'group outline-none flex w-full select-none items-start relative before:rounded-md before:content-empty before:inset-px before:absolute before:-z-1 data-[disabled]:opacity-75 data-[disabled]:cursor-not-allowed',
itemLeadingIcon: 'shrink-0 flex items-center justify-center',
itemLeadingAvatar: 'shrink-0',
itemLeadingAvatarSize: '',
itemWrapper: 'flex-1 flex flex-col text-start min-w-0',
itemLabel: 'truncate',
itemDescription: 'truncate color-text-muted',
itemLabelExternalIcon: 'inline-block size-3 align-top color-text-dimmed'
},
variants: {
size: {
xs: {
label: 'p-1 text-[10px]/3 gap-1',
item: 'p-1 text-xs gap-1',
itemLeadingIcon: 'size-4 text-sm',
itemLeadingAvatarSize: '3xs'
},
sm: {
label: 'p-1.5 text-[10px]/3 gap-1.5',
item: 'p-1.5 text-xs gap-1.5',
itemLeadingIcon: 'size-4 text-sm',
itemLeadingAvatarSize: '3xs'
},
md: {
label: 'p-1.5 text-xs gap-1.5',
item: 'p-1.5 text-sm gap-1.5',
itemLeadingIcon: 'size-5 text-base',
itemLeadingAvatarSize: '2xs'
},
lg: {
label: 'p-2 text-xs gap-2',
item: 'p-2 text-sm gap-2',
itemLeadingIcon: 'size-5 text-base',
itemLeadingAvatarSize: '2xs'
},
xl: {
label: 'p-2 text-sm gap-2',
item: 'p-2 text-base gap-2',
itemLeadingIcon: 'size-6 text-xl',
itemLeadingAvatarSize: 'xs'
}
},
active: {
true: {
item: 'color-text-highlighted before:bg-background-elevated/75',
itemLeadingIcon: 'color-text'
},
false: {
item: [
'color-text data-[highlighted]:not-data-[disabled]:color-text-highlighted data-[highlighted]:not-data-[disabled]:before:bg-background-elevated/50',
'transition-colors before:transition-colors'
],
itemLeadingIcon: [
'color-text-dimmed [.group[data-highlighted]:not([data-disabled])_&]:color-text',
'transition-colors'
]
}
}
},
defaultVariants: {
size: 'md'
}
}
}
})vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from 'pohon-ui/vite'
export default defineConfig({
plugins: [
vue(),
ui({
ui: {
editorSuggestionMenu: {
slots: {
content: 'rounded-md bg-background flex flex-col max-h-96 max-w-60 min-w-48 ring ring-border shadow-lg origin-$akar-dropdown-menu-content-transform-origin overflow-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
viewport: 'relative divide-y divide-border scroll-py-1 overflow-y-auto flex-1',
group: 'p-1 isolate',
label: 'color-text-highlighted font-semibold flex w-full items-center',
separator: '-mx-1 my-1 h-px bg-border',
item: 'group outline-none flex w-full select-none items-start relative before:rounded-md before:content-empty before:inset-px before:absolute before:-z-1 data-[disabled]:opacity-75 data-[disabled]:cursor-not-allowed',
itemLeadingIcon: 'shrink-0 flex items-center justify-center',
itemLeadingAvatar: 'shrink-0',
itemLeadingAvatarSize: '',
itemWrapper: 'flex-1 flex flex-col text-start min-w-0',
itemLabel: 'truncate',
itemDescription: 'truncate color-text-muted',
itemLabelExternalIcon: 'inline-block size-3 align-top color-text-dimmed'
},
variants: {
size: {
xs: {
label: 'p-1 text-[10px]/3 gap-1',
item: 'p-1 text-xs gap-1',
itemLeadingIcon: 'size-4 text-sm',
itemLeadingAvatarSize: '3xs'
},
sm: {
label: 'p-1.5 text-[10px]/3 gap-1.5',
item: 'p-1.5 text-xs gap-1.5',
itemLeadingIcon: 'size-4 text-sm',
itemLeadingAvatarSize: '3xs'
},
md: {
label: 'p-1.5 text-xs gap-1.5',
item: 'p-1.5 text-sm gap-1.5',
itemLeadingIcon: 'size-5 text-base',
itemLeadingAvatarSize: '2xs'
},
lg: {
label: 'p-2 text-xs gap-2',
item: 'p-2 text-sm gap-2',
itemLeadingIcon: 'size-5 text-base',
itemLeadingAvatarSize: '2xs'
},
xl: {
label: 'p-2 text-sm gap-2',
item: 'p-2 text-base gap-2',
itemLeadingIcon: 'size-6 text-xl',
itemLeadingAvatarSize: 'xs'
}
},
active: {
true: {
item: 'color-text-highlighted before:bg-background-elevated/75',
itemLeadingIcon: 'color-text'
},
false: {
item: [
'color-text data-[highlighted]:not-data-[disabled]:color-text-highlighted data-[highlighted]:not-data-[disabled]:before:bg-background-elevated/50',
'transition-colors before:transition-colors'
],
itemLeadingIcon: [
'color-text-dimmed [.group[data-highlighted]:not([data-disabled])_&]:color-text',
'transition-colors'
]
}
}
},
defaultVariants: {
size: 'md'
}
}
}
})
]
})Changelog
No recent changes