Mention menu
A mention menu is a popup of suggestion candidates triggered by typing @ (for users), # (for tags), or any other character you choose. The selected candidate is inserted as a mention node from the mention extension.
'use client'
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/react'
import { useMemo } from 'react'
import { tags } from '../../sample/sample-tag-data'
import { users } from '../../sample/sample-user-data'
import { TagMenu } from '../../ui/tag-menu'
import { UserMenu } from '../../ui/user-menu'
import { defineExtension } from './extension'
export default function Editor() {
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension })
}, [])
return (
<ProseKit editor={editor}>
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<div className="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} className="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
<UserMenu users={users} />
<TagMenu tags={tags} />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({
placeholder: 'Type @ to mention someone or # to tag something...',
}),
defineMention(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'export const tags = [
{ id: 1, label: 'book' },
{ id: 2, label: 'movie' },
{ id: 3, label: 'trip' },
{ id: 4, label: 'music' },
{ id: 5, label: 'art' },
{ id: 6, label: 'food' },
{ id: 7, label: 'sport' },
{ id: 8, label: 'technology' },
{ id: 9, label: 'fashion' },
{ id: 10, label: 'nature' },
]export const users = [
{ id: 1, name: 'Alex' },
{ id: 2, name: 'Alice' },
{ id: 3, name: 'Ben' },
{ id: 4, name: 'Bob' },
{ id: 5, name: 'Charlie' },
{ id: 6, name: 'Cara' },
{ id: 7, name: 'Derek' },
{ id: 8, name: 'Diana' },
{ id: 9, name: 'Ethan' },
{ id: 10, name: 'Eva' },
{ id: 11, name: 'Frank' },
{ id: 12, name: 'Fiona' },
{ id: 13, name: 'George' },
{ id: 14, name: 'Gina' },
{ id: 15, name: 'Harry' },
{ id: 16, name: 'Hannah' },
{ id: 17, name: 'Ivan' },
{ id: 18, name: 'Iris' },
{ id: 19, name: 'Jack' },
{ id: 20, name: 'Jasmine' },
{ id: 21, name: 'Kevin' },
{ id: 22, name: 'Kate' },
{ id: 23, name: 'Leo' },
{ id: 24, name: 'Lily' },
{ id: 25, name: 'Mike' },
{ id: 26, name: 'Mia' },
{ id: 27, name: 'Nathan' },
{ id: 28, name: 'Nancy' },
{ id: 29, name: 'Oscar' },
{ id: 30, name: 'Olivia' },
{ id: 31, name: 'Paul' },
{ id: 32, name: 'Penny' },
{ id: 33, name: 'Quentin' },
{ id: 34, name: 'Queen' },
{ id: 35, name: 'Roger' },
{ id: 36, name: 'Rita' },
{ id: 37, name: 'Sam' },
{ id: 38, name: 'Sara' },
{ id: 39, name: 'Tom' },
{ id: 40, name: 'Tina' },
{ id: 41, name: 'Ulysses' },
{ id: 42, name: 'Una' },
{ id: 43, name: 'Victor' },
{ id: 44, name: 'Vera' },
{ id: 45, name: 'Walter' },
{ id: 46, name: 'Wendy' },
{ id: 47, name: 'Xavier' },
{ id: 48, name: 'Xena' },
{ id: 49, name: 'Yan' },
{ id: 50, name: 'Yvonne' },
{ id: 51, name: 'Zack' },
{ id: 52, name: 'Zara' },
]export { default as TagMenu } from './tag-menu''use client'
import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/react'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompletePopup,
AutocompletePositioner,
AutocompleteRoot,
} from 'prosekit/react/autocomplete'
const regex = /#[\da-z]*$/i
export default function TagMenu(props: { tags: { id: number; label: string }[] }) {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
const handleTagInsert = (id: number, label: string) => {
editor.commands.insertMention({
id: id.toString(),
value: '#' + label,
kind: 'tag',
})
editor.commands.insertText({ text: ' ' })
}
return (
<AutocompleteRoot regex={regex}>
<AutocompletePositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup className="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
No results
</AutocompleteEmpty>
{props.tags.map((tag) => (
<AutocompleteItem
key={tag.id}
className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => handleTagInsert(tag.id, tag.label)}
>
#{tag.label}
</AutocompleteItem>
))}
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}export { default as UserMenu } from './user-menu''use client'
import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind, type Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/react'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompletePopup,
AutocompletePositioner,
AutocompleteRoot,
} from 'prosekit/react/autocomplete'
// Match inputs like "@", "@foo", "@foo bar" etc. Do not match "@ foo".
const regex = canUseRegexLookbehind() ? /(?<!\S)@(\S.*)?$/u : /@(\S.*)?$/u
export default function UserMenu(props: {
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}) {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
const handleUserInsert = (id: number, username: string) => {
editor.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
editor.commands.insertText({ text: ' ' })
}
return (
<AutocompleteRoot
regex={regex}
onQueryChange={(event) => props.onQueryChange?.(event.detail)}
onOpenChange={(event) => props.onOpenChange?.(event.detail)}
>
<AutocompletePositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup className="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
{props.loading ? 'Loading...' : 'No results'}
</AutocompleteEmpty>
{props.users.map((user) => (
<AutocompleteItem
key={user.id}
className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => handleUserInsert(user.id, user.name)}
>
<span className={props.loading ? 'opacity-50' : undefined}>
{user.name}
</span>
</AutocompleteItem>
))}
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { useMemo } from 'preact/hooks'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/preact'
import { tags } from '../../sample/sample-tag-data'
import { users } from '../../sample/sample-user-data'
import { TagMenu } from '../../ui/tag-menu'
import { UserMenu } from '../../ui/user-menu'
import { defineExtension } from './extension'
export default function Editor() {
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension })
}, [])
return (
<ProseKit editor={editor}>
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<div className="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} className="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
<UserMenu users={users} />
<TagMenu tags={tags} />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({
placeholder: 'Type @ to mention someone or # to tag something...',
}),
defineMention(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'export const tags = [
{ id: 1, label: 'book' },
{ id: 2, label: 'movie' },
{ id: 3, label: 'trip' },
{ id: 4, label: 'music' },
{ id: 5, label: 'art' },
{ id: 6, label: 'food' },
{ id: 7, label: 'sport' },
{ id: 8, label: 'technology' },
{ id: 9, label: 'fashion' },
{ id: 10, label: 'nature' },
]export const users = [
{ id: 1, name: 'Alex' },
{ id: 2, name: 'Alice' },
{ id: 3, name: 'Ben' },
{ id: 4, name: 'Bob' },
{ id: 5, name: 'Charlie' },
{ id: 6, name: 'Cara' },
{ id: 7, name: 'Derek' },
{ id: 8, name: 'Diana' },
{ id: 9, name: 'Ethan' },
{ id: 10, name: 'Eva' },
{ id: 11, name: 'Frank' },
{ id: 12, name: 'Fiona' },
{ id: 13, name: 'George' },
{ id: 14, name: 'Gina' },
{ id: 15, name: 'Harry' },
{ id: 16, name: 'Hannah' },
{ id: 17, name: 'Ivan' },
{ id: 18, name: 'Iris' },
{ id: 19, name: 'Jack' },
{ id: 20, name: 'Jasmine' },
{ id: 21, name: 'Kevin' },
{ id: 22, name: 'Kate' },
{ id: 23, name: 'Leo' },
{ id: 24, name: 'Lily' },
{ id: 25, name: 'Mike' },
{ id: 26, name: 'Mia' },
{ id: 27, name: 'Nathan' },
{ id: 28, name: 'Nancy' },
{ id: 29, name: 'Oscar' },
{ id: 30, name: 'Olivia' },
{ id: 31, name: 'Paul' },
{ id: 32, name: 'Penny' },
{ id: 33, name: 'Quentin' },
{ id: 34, name: 'Queen' },
{ id: 35, name: 'Roger' },
{ id: 36, name: 'Rita' },
{ id: 37, name: 'Sam' },
{ id: 38, name: 'Sara' },
{ id: 39, name: 'Tom' },
{ id: 40, name: 'Tina' },
{ id: 41, name: 'Ulysses' },
{ id: 42, name: 'Una' },
{ id: 43, name: 'Victor' },
{ id: 44, name: 'Vera' },
{ id: 45, name: 'Walter' },
{ id: 46, name: 'Wendy' },
{ id: 47, name: 'Xavier' },
{ id: 48, name: 'Xena' },
{ id: 49, name: 'Yan' },
{ id: 50, name: 'Yvonne' },
{ id: 51, name: 'Zack' },
{ id: 52, name: 'Zara' },
]export { default as TagMenu } from './tag-menu'import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/preact'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompletePopup,
AutocompletePositioner,
AutocompleteRoot,
} from 'prosekit/preact/autocomplete'
const regex = /#[\da-z]*$/i
export default function TagMenu(props: { tags: { id: number; label: string }[] }) {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
const handleTagInsert = (id: number, label: string) => {
editor.commands.insertMention({
id: id.toString(),
value: '#' + label,
kind: 'tag',
})
editor.commands.insertText({ text: ' ' })
}
return (
<AutocompleteRoot regex={regex}>
<AutocompletePositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup className="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
No results
</AutocompleteEmpty>
{props.tags.map((tag) => (
<AutocompleteItem
key={tag.id}
className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => handleTagInsert(tag.id, tag.label)}
>
#{tag.label}
</AutocompleteItem>
))}
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}export { default as UserMenu } from './user-menu'import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind, type Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/preact'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompletePopup,
AutocompletePositioner,
AutocompleteRoot,
} from 'prosekit/preact/autocomplete'
// Match inputs like "@", "@foo", "@foo bar" etc. Do not match "@ foo".
const regex = canUseRegexLookbehind() ? /(?<!\S)@(\S.*)?$/u : /@(\S.*)?$/u
export default function UserMenu(props: {
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}) {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
const handleUserInsert = (id: number, username: string) => {
editor.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
editor.commands.insertText({ text: ' ' })
}
return (
<AutocompleteRoot
regex={regex}
onQueryChange={(event) => props.onQueryChange?.(event.detail)}
onOpenChange={(event) => props.onOpenChange?.(event.detail)}
>
<AutocompletePositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup className="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
{props.loading ? 'Loading...' : 'No results'}
</AutocompleteEmpty>
{props.users.map((user) => (
<AutocompleteItem
key={user.id}
className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => handleUserInsert(user.id, user.name)}
>
<span className={props.loading ? 'opacity-50' : undefined}>
{user.name}
</span>
</AutocompleteItem>
))}
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/solid'
import type { JSX } from 'solid-js'
import { tags } from '../../sample/sample-tag-data'
import { users } from '../../sample/sample-user-data'
import { TagMenu } from '../../ui/tag-menu'
import { UserMenu } from '../../ui/user-menu'
import { defineExtension } from './extension'
export default function Editor(): JSX.Element {
const extension = defineExtension()
const editor = createEditor({ extension })
return (
<ProseKit editor={editor}>
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
<UserMenu users={users} />
<TagMenu tags={tags} />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({
placeholder: 'Type @ to mention someone or # to tag something...',
}),
defineMention(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'export const tags = [
{ id: 1, label: 'book' },
{ id: 2, label: 'movie' },
{ id: 3, label: 'trip' },
{ id: 4, label: 'music' },
{ id: 5, label: 'art' },
{ id: 6, label: 'food' },
{ id: 7, label: 'sport' },
{ id: 8, label: 'technology' },
{ id: 9, label: 'fashion' },
{ id: 10, label: 'nature' },
]export const users = [
{ id: 1, name: 'Alex' },
{ id: 2, name: 'Alice' },
{ id: 3, name: 'Ben' },
{ id: 4, name: 'Bob' },
{ id: 5, name: 'Charlie' },
{ id: 6, name: 'Cara' },
{ id: 7, name: 'Derek' },
{ id: 8, name: 'Diana' },
{ id: 9, name: 'Ethan' },
{ id: 10, name: 'Eva' },
{ id: 11, name: 'Frank' },
{ id: 12, name: 'Fiona' },
{ id: 13, name: 'George' },
{ id: 14, name: 'Gina' },
{ id: 15, name: 'Harry' },
{ id: 16, name: 'Hannah' },
{ id: 17, name: 'Ivan' },
{ id: 18, name: 'Iris' },
{ id: 19, name: 'Jack' },
{ id: 20, name: 'Jasmine' },
{ id: 21, name: 'Kevin' },
{ id: 22, name: 'Kate' },
{ id: 23, name: 'Leo' },
{ id: 24, name: 'Lily' },
{ id: 25, name: 'Mike' },
{ id: 26, name: 'Mia' },
{ id: 27, name: 'Nathan' },
{ id: 28, name: 'Nancy' },
{ id: 29, name: 'Oscar' },
{ id: 30, name: 'Olivia' },
{ id: 31, name: 'Paul' },
{ id: 32, name: 'Penny' },
{ id: 33, name: 'Quentin' },
{ id: 34, name: 'Queen' },
{ id: 35, name: 'Roger' },
{ id: 36, name: 'Rita' },
{ id: 37, name: 'Sam' },
{ id: 38, name: 'Sara' },
{ id: 39, name: 'Tom' },
{ id: 40, name: 'Tina' },
{ id: 41, name: 'Ulysses' },
{ id: 42, name: 'Una' },
{ id: 43, name: 'Victor' },
{ id: 44, name: 'Vera' },
{ id: 45, name: 'Walter' },
{ id: 46, name: 'Wendy' },
{ id: 47, name: 'Xavier' },
{ id: 48, name: 'Xena' },
{ id: 49, name: 'Yan' },
{ id: 50, name: 'Yvonne' },
{ id: 51, name: 'Zack' },
{ id: 52, name: 'Zara' },
]export { default as TagMenu } from './tag-menu'import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/solid'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompletePopup,
AutocompletePositioner,
AutocompleteRoot,
} from 'prosekit/solid/autocomplete'
import { For, type JSX } from 'solid-js'
const regex = /#[\da-z]*$/i
export default function TagMenu(props: { tags: { id: number; label: string }[] }): JSX.Element {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
const handleTagInsert = (id: number, label: string) => {
editor().commands.insertMention({
id: id.toString(),
value: '#' + label,
kind: 'tag',
})
editor().commands.insertText({ text: ' ' })
}
return (
<AutocompleteRoot regex={regex}>
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup class="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
No results
</AutocompleteEmpty>
<For each={props.tags}>
{(tag) => (
<AutocompleteItem
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => handleTagInsert(tag.id, tag.label)}
>
#{tag.label}
</AutocompleteItem>
)}
</For>
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}export { default as UserMenu } from './user-menu'import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind, type Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/solid'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompletePopup,
AutocompletePositioner,
AutocompleteRoot,
} from 'prosekit/solid/autocomplete'
import { For, type JSX } from 'solid-js'
// Match inputs like "@", "@foo", "@foo bar" etc. Do not match "@ foo".
const regex = canUseRegexLookbehind() ? /(?<!\S)@(\S.*)?$/u : /@(\S.*)?$/u
export default function UserMenu(props: {
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}): JSX.Element {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
const handleUserInsert = (id: number, username: string) => {
editor().commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
editor().commands.insertText({ text: ' ' })
}
return (
<AutocompleteRoot
regex={regex}
onQueryChange={(event) => props.onQueryChange?.(event.detail)}
onOpenChange={(event) => props.onOpenChange?.(event.detail)}
>
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup class="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
{props.loading ? 'Loading...' : 'No results'}
</AutocompleteEmpty>
<For each={props.users}>
{(user) => (
<AutocompleteItem
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => handleUserInsert(user.id, user.name)}
>
<span class={props.loading ? 'opacity-50' : undefined}>
{user.name}
</span>
</AutocompleteItem>
)}
</For>
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}<script lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/svelte'
import { tags } from '../../sample/sample-tag-data'
import { users } from '../../sample/sample-user-data'
import { TagMenu } from '../../ui/tag-menu'
import { UserMenu } from '../../ui/user-menu'
import { defineExtension } from './extension'
const extension = defineExtension()
const editor = createEditor({ extension })
</script>
<ProseKit {editor}>
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div {@attach editor.mount} class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
<UserMenu {users} />
<TagMenu {tags} />
</div>
</div>
</ProseKit>import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({
placeholder: 'Type @ to mention someone or # to tag something...',
}),
defineMention(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.svelte'export const tags = [
{ id: 1, label: 'book' },
{ id: 2, label: 'movie' },
{ id: 3, label: 'trip' },
{ id: 4, label: 'music' },
{ id: 5, label: 'art' },
{ id: 6, label: 'food' },
{ id: 7, label: 'sport' },
{ id: 8, label: 'technology' },
{ id: 9, label: 'fashion' },
{ id: 10, label: 'nature' },
]export const users = [
{ id: 1, name: 'Alex' },
{ id: 2, name: 'Alice' },
{ id: 3, name: 'Ben' },
{ id: 4, name: 'Bob' },
{ id: 5, name: 'Charlie' },
{ id: 6, name: 'Cara' },
{ id: 7, name: 'Derek' },
{ id: 8, name: 'Diana' },
{ id: 9, name: 'Ethan' },
{ id: 10, name: 'Eva' },
{ id: 11, name: 'Frank' },
{ id: 12, name: 'Fiona' },
{ id: 13, name: 'George' },
{ id: 14, name: 'Gina' },
{ id: 15, name: 'Harry' },
{ id: 16, name: 'Hannah' },
{ id: 17, name: 'Ivan' },
{ id: 18, name: 'Iris' },
{ id: 19, name: 'Jack' },
{ id: 20, name: 'Jasmine' },
{ id: 21, name: 'Kevin' },
{ id: 22, name: 'Kate' },
{ id: 23, name: 'Leo' },
{ id: 24, name: 'Lily' },
{ id: 25, name: 'Mike' },
{ id: 26, name: 'Mia' },
{ id: 27, name: 'Nathan' },
{ id: 28, name: 'Nancy' },
{ id: 29, name: 'Oscar' },
{ id: 30, name: 'Olivia' },
{ id: 31, name: 'Paul' },
{ id: 32, name: 'Penny' },
{ id: 33, name: 'Quentin' },
{ id: 34, name: 'Queen' },
{ id: 35, name: 'Roger' },
{ id: 36, name: 'Rita' },
{ id: 37, name: 'Sam' },
{ id: 38, name: 'Sara' },
{ id: 39, name: 'Tom' },
{ id: 40, name: 'Tina' },
{ id: 41, name: 'Ulysses' },
{ id: 42, name: 'Una' },
{ id: 43, name: 'Victor' },
{ id: 44, name: 'Vera' },
{ id: 45, name: 'Walter' },
{ id: 46, name: 'Wendy' },
{ id: 47, name: 'Xavier' },
{ id: 48, name: 'Xena' },
{ id: 49, name: 'Yan' },
{ id: 50, name: 'Yvonne' },
{ id: 51, name: 'Zack' },
{ id: 52, name: 'Zara' },
]export { default as TagMenu } from './tag-menu.svelte'<script lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/svelte'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompletePopup,
AutocompletePositioner,
AutocompleteRoot,
} from 'prosekit/svelte/autocomplete'
interface Props {
tags: { id: number; label: string }[]
}
const props: Props = $props()
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
function handleTagInsert(id: number, label: string) {
$editor.commands.insertMention({
id: id.toString(),
value: '#' + label,
kind: 'tag',
})
$editor.commands.insertText({ text: ' ' })
}
const regex = /#[\da-z]*$/i
</script>
<AutocompleteRoot {regex}>
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup class="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
No results
</AutocompleteEmpty>
{#each props.tags as tag (tag.id)}
<AutocompleteItem
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => handleTagInsert(tag.id, tag.label)}
>
#{tag.label}
</AutocompleteItem>
{/each}
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>export { default as UserMenu } from './user-menu.svelte'<script lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind, type Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/svelte'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompletePopup,
AutocompletePositioner,
AutocompleteRoot,
} from 'prosekit/svelte/autocomplete'
interface Props {
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}
const props: Props = $props()
const loading = $derived(props.loading ?? false)
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
function handleUserInsert(id: number, username: string) {
$editor.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
$editor.commands.insertText({ text: ' ' })
}
// Match inputs like "@", "@foo", "@foo bar" etc. Do not match "@ foo".
const regex = canUseRegexLookbehind() ? /(?<!\S)@(\S.*)?$/u : /@(\S.*)?$/u
</script>
<AutocompleteRoot
{regex}
onQueryChange={(event) => props.onQueryChange?.(event.detail)}
onOpenChange={(event) => props.onOpenChange?.(event.detail)}
>
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup class="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
{loading ? 'Loading...' : 'No results'}
</AutocompleteEmpty>
{#each props.users as user (user.id)}
<AutocompleteItem
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
onSelect={() => handleUserInsert(user.id, user.name)}
>
{#if loading}
<span class="opacity-50">
{user.name}
</span>
{:else}
<span>
{user.name}
</span>
{/if}
</AutocompleteItem>
{/each}
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot><script setup lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/vue'
import { tags } from '../../sample/sample-tag-data'
import { users } from '../../sample/sample-user-data'
import { TagMenu } from '../../ui/tag-menu'
import { UserMenu } from '../../ui/user-menu'
import { defineExtension } from './extension'
const extension = defineExtension()
const editor = createEditor({ extension })
</script>
<template>
<ProseKit :editor="editor">
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div :ref="(el) => editor.mount(el as HTMLElement | null)" class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500" />
<UserMenu :users="users" />
<TagMenu :tags="tags" />
</div>
</div>
</ProseKit>
</template>import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({
placeholder: 'Type @ to mention someone or # to tag something...',
}),
defineMention(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.vue'export const tags = [
{ id: 1, label: 'book' },
{ id: 2, label: 'movie' },
{ id: 3, label: 'trip' },
{ id: 4, label: 'music' },
{ id: 5, label: 'art' },
{ id: 6, label: 'food' },
{ id: 7, label: 'sport' },
{ id: 8, label: 'technology' },
{ id: 9, label: 'fashion' },
{ id: 10, label: 'nature' },
]export const users = [
{ id: 1, name: 'Alex' },
{ id: 2, name: 'Alice' },
{ id: 3, name: 'Ben' },
{ id: 4, name: 'Bob' },
{ id: 5, name: 'Charlie' },
{ id: 6, name: 'Cara' },
{ id: 7, name: 'Derek' },
{ id: 8, name: 'Diana' },
{ id: 9, name: 'Ethan' },
{ id: 10, name: 'Eva' },
{ id: 11, name: 'Frank' },
{ id: 12, name: 'Fiona' },
{ id: 13, name: 'George' },
{ id: 14, name: 'Gina' },
{ id: 15, name: 'Harry' },
{ id: 16, name: 'Hannah' },
{ id: 17, name: 'Ivan' },
{ id: 18, name: 'Iris' },
{ id: 19, name: 'Jack' },
{ id: 20, name: 'Jasmine' },
{ id: 21, name: 'Kevin' },
{ id: 22, name: 'Kate' },
{ id: 23, name: 'Leo' },
{ id: 24, name: 'Lily' },
{ id: 25, name: 'Mike' },
{ id: 26, name: 'Mia' },
{ id: 27, name: 'Nathan' },
{ id: 28, name: 'Nancy' },
{ id: 29, name: 'Oscar' },
{ id: 30, name: 'Olivia' },
{ id: 31, name: 'Paul' },
{ id: 32, name: 'Penny' },
{ id: 33, name: 'Quentin' },
{ id: 34, name: 'Queen' },
{ id: 35, name: 'Roger' },
{ id: 36, name: 'Rita' },
{ id: 37, name: 'Sam' },
{ id: 38, name: 'Sara' },
{ id: 39, name: 'Tom' },
{ id: 40, name: 'Tina' },
{ id: 41, name: 'Ulysses' },
{ id: 42, name: 'Una' },
{ id: 43, name: 'Victor' },
{ id: 44, name: 'Vera' },
{ id: 45, name: 'Walter' },
{ id: 46, name: 'Wendy' },
{ id: 47, name: 'Xavier' },
{ id: 48, name: 'Xena' },
{ id: 49, name: 'Yan' },
{ id: 50, name: 'Yvonne' },
{ id: 51, name: 'Zack' },
{ id: 52, name: 'Zara' },
]export { default as TagMenu } from './tag-menu.vue'<script setup lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/vue'
import { AutocompleteEmpty, AutocompleteItem, AutocompletePopup, AutocompletePositioner, AutocompleteRoot } from 'prosekit/vue/autocomplete'
const props = defineProps<{ tags: { id: number; label: string }[] }>()
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
function handleTagInsert(id: number, label: string) {
editor.value.commands.insertMention({
id: id.toString(),
value: '#' + label,
kind: 'tag',
})
editor.value.commands.insertText({ text: ' ' })
}
const regex = /#[\da-z]*$/i
</script>
<template>
<AutocompleteRoot :regex="regex">
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup class="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
No results
</AutocompleteEmpty>
<AutocompleteItem
v-for="tag in props.tags"
:key="tag.id"
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select="() => handleTagInsert(tag.id, tag.label)"
>
#{{ tag.label }}
</AutocompleteItem>
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
</template>export { default as UserMenu } from './user-menu.vue'<script setup lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind, type Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/vue'
import { AutocompleteEmpty, AutocompleteItem, AutocompletePopup, AutocompletePositioner, AutocompleteRoot } from 'prosekit/vue/autocomplete'
const props = defineProps<{
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}>()
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
function handleUserInsert(id: number, username: string) {
editor.value.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
editor.value.commands.insertText({ text: ' ' })
}
// Match inputs like "@", "@foo", "@foo bar" etc. Do not match "@ foo".
const regex = canUseRegexLookbehind() ? /(?<!\S)@(\S.*)?$/u : /@(\S.*)?$/u
</script>
<template>
<AutocompleteRoot
:regex="regex"
@query-change="(event) => props.onQueryChange?.(event.detail)"
@open-change="(event) => props.onOpenChange?.(event.detail)"
>
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup class="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
{{ props.loading ? 'Loading...' : 'No results' }}
</AutocompleteEmpty>
<AutocompleteItem
v-for="user in props.users"
:key="user.id"
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
@select="() => handleUserInsert(user.id, user.name)"
>
<span v-if="props.loading" class="opacity-50">
{{ user.name }}
</span>
<span v-else>
{{ user.name }}
</span>
</AutocompleteItem>
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
</template>Install
Section titled “Install”npx shadcn@latest add @prosekit/react-ui-user-menunpx shadcn@latest add @prosekit/preact-ui-user-menunpx shadcn@latest add @prosekit/solid-ui-user-menunpx shadcn@latest add @prosekit/svelte-ui-user-menunpx shadcn@latest add @prosekit/vue-ui-user-menuCopy and paste the code from the demo above into your project.
Structure
Section titled “Structure”The mention menu uses the same autocomplete primitive as the Slash menu. The difference is the regex (matches @ or # instead of /) and the onSelect callback (inserts a mention node instead of running a command).
AutocompleteRoot # root component
└── AutocompletePositioner # anchors the popup to the cursor
└── AutocompletePopup # the floating panel
├── AutocompleteEmpty # "Loading…" while fetching, "No results" otherwise
└── AutocompleteItem # one per candidate; onSelect calls insertMention()AutocompleteRoot exposes onQueryChange and onOpenChange so you can fetch candidates from a remote source and reset the list when the popup closes. The kind you pass to insertMention (e.g. 'user' or 'tag') lets the same component back several trigger characters.
API reference
Section titled “API reference”- prosekit/react/autocomplete
- prosekit/vue/autocomplete
- prosekit/preact/autocomplete
- prosekit/svelte/autocomplete
- prosekit/solid/autocomplete
See also
Section titled “See also”mentionextension: provides thementionnode andinsertMentioncommand.- Slash menu: same primitive, with
/as the trigger character. - Dynamic user search example: wires the menu to an async fetcher so candidates load from a remote source as the user types.