EditorMentionMenu

GitLab
Um menu de menções que exibe sugestões de usuários ao digitar um caractere de gatilho no editor.

Uso

O componente EditorMentionMenu exibe um menu de sugestões de usuários ao digitar um caractere de gatilho (o padrão é @) no editor e insere a menção selecionada usando o pacote @tiptap/extension-mention. O caractere de gatilho também é usado como prefixo ao renderizar a menção inserida.

Ele usa o composable useEditorMenu, construído sobre o utilitário Suggestion do TipTap, para filtrar os itens conforme você digita e dar suporte à navegação por teclado (setas, enter para selecionar, escape para fechar).
Ele deve ser usado dentro do slot padrão de um componente Editor para ter acesso à instância do editor.
Saiba mais sobre a extensão Mention na documentação do TipTap.

Itens

Use a prop items como um array de objetos com as seguintes propriedades:

  • label: string
  • avatar?: AvatarProps
  • icon?: string
  • description?: string
  • disabled?: boolean
Você também pode passar um array de arrays para a prop items para criar grupos separados de itens.

Caractere

Use a prop char para alterar o caractere de gatilho. O padrão é @. O caractere de gatilho também é usado como prefixo ao renderizar a menção inserida (ex.: #channel em vez de @channel).

<template>
  <NEditor v-slot="{ editor }">
    <NEditorMentionMenu :editor="editor" :items="channels" char="#" />
  </NEditor>
</template>
Você pode usar vários componentes EditorMentionMenu no mesmo editor com props char e plugin-key diferentes para suportar diferentes tipos de menção.
<template>
  <NEditor v-slot="{ editor }">
    <NEditorMentionMenu :editor="editor" :items="users" plugin-key="mentionMenu" />
    <NEditorMentionMenu :editor="editor" :items="tags" char="#" plugin-key="tagMenu" />
  </NEditor>
</template>

Sugestão 4.7+

Use a prop suggestion para personalizar o comportamento de correspondência do Suggestion do TipTap.

Isso é útil quando o caractere de gatilho deve abrir logo após outros caracteres em vez de exigir o prefixo de espaço em branco padrão.

<template>
  <NEditor v-slot="{ editor }">
    <NEditorMentionMenu
      :editor="editor"
      :items="items"
      char="#"
      :suggestion="{
        allowedPrefixes: null
      }"
    />
  </NEditor>
</template>

Opções

Use a prop options para personalizar o comportamento de posicionamento usando as opções do Floating UI.

<template>
  <NEditor v-slot="{ editor }">
    <NEditorMentionMenu
      :editor="editor"
      :items="items"
      :options="{
        placement: 'bottom-start',
        offset: 4
      }"
    />
  </NEditor>
</template>

Exemplos

Com filtro ignorado 4.4+

Você pode definir a prop ignore-filter como true para desabilitar a busca interna e usar sua própria lógica de busca. Use v-model:search-term para acessar o termo de busca atual e buscar itens de uma API.

Este exemplo usa refDebounced para aplicar debounce nas chamadas de API.

API

Props

Prop Default Type
size'md' "xs" | "md" | "sm" | "lg" | "xl"
items T[] | T[][]
editorEditor
char'@' string

The trigger character (e.g., '/', '@', ':')

pluginKey'mentionMenu' string

Plugin key to identify this menu

filterFields['label'] string[]

Fields to filter items by.

limit42 number

Maximum number of items to display

options{ strategy: 'absolute', placement: 'bottom-start', offset: 8, shift: { padding: 8 } } FloatingUIOptions

The options for positioning the menu. Those are passed to Floating UI and include options for the placement, offset, flip, shift, size, autoPlacement, hide, and inline middleware.

suggestion Omit<Partial<SuggestionOptions<any, any>>, "editor" | "char" | "pluginKey" | "items" | "command" | "render">

Optional TipTap Suggestion matching options.

appendTo HTMLElement | (): HTMLElement

The DOM element to append the menu to. Default is the editor's parent element.

Sometimes the menu needs to be appended to a different DOM context due to accessibility, clipping, or z-index issues.

ignoreFilterfalseboolean

Whether to ignore the default filtering. When true, items will not be filtered which is useful for custom filtering (useAsyncData, useFetch, etc.).

searchTerm'' string
ui { content?: SlotClass; viewport?: SlotClass; group?: SlotClass; label?: SlotClass; separator?: SlotClass; item?: SlotClass; itemLeadingIcon?: SlotClass; itemLeadingAvatar?: SlotClass; itemLeadingAvatarSize?: SlotClass; itemWrapper?: SlotClass; itemLabel?: SlotClass; itemDescription?: SlotClass; itemLabelExternalIcon?: SlotClass; }

Tema

app.config.ts
export default defineAppConfig({
  ui: {
    editorMentionMenu: {
      slots: {
        content: 'min-w-48 max-w-60 max-h-96 bg-default shadow-lg rounded-md ring ring-default overflow-hidden data-[state=open]:animate-[scale-in_100ms_ease-out] data-[state=closed]:animate-[scale-out_100ms_ease-in] origin-(--reka-dropdown-menu-content-transform-origin) flex flex-col',
        viewport: 'relative divide-y divide-default scroll-py-1 overflow-y-auto flex-1',
        group: 'p-1 isolate',
        label: 'w-full flex items-center font-semibold text-highlighted',
        separator: '-mx-1 my-1 h-px bg-border',
        item: 'group relative w-full flex items-start select-none outline-none before:absolute before:z-[-1] before:inset-px before:rounded-md data-disabled:cursor-not-allowed data-disabled:opacity-75',
        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 text-muted',
        itemLabelExternalIcon: 'inline-block size-3 align-top 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: 'text-highlighted before:bg-elevated/75',
            itemLeadingIcon: 'text-default'
          },
          false: {
            item: [
              'text-default data-highlighted:not-data-disabled:text-highlighted data-highlighted:not-data-disabled:before:bg-elevated/50',
              'transition-colors before:transition-colors'
            ],
            itemLeadingIcon: [
              'text-dimmed group-data-highlighted:not-group-data-disabled:text-default',
              'transition-colors'
            ]
          }
        }
      },
      defaultVariants: {
        size: 'md'
      }
    }
  }
})
vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from '@nitro/ui/vite'

export default defineConfig({
  plugins: [
    vue(),
    ui({
      ui: {
        editorMentionMenu: {
          slots: {
            content: 'min-w-48 max-w-60 max-h-96 bg-default shadow-lg rounded-md ring ring-default overflow-hidden data-[state=open]:animate-[scale-in_100ms_ease-out] data-[state=closed]:animate-[scale-out_100ms_ease-in] origin-(--reka-dropdown-menu-content-transform-origin) flex flex-col',
            viewport: 'relative divide-y divide-default scroll-py-1 overflow-y-auto flex-1',
            group: 'p-1 isolate',
            label: 'w-full flex items-center font-semibold text-highlighted',
            separator: '-mx-1 my-1 h-px bg-border',
            item: 'group relative w-full flex items-start select-none outline-none before:absolute before:z-[-1] before:inset-px before:rounded-md data-disabled:cursor-not-allowed data-disabled:opacity-75',
            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 text-muted',
            itemLabelExternalIcon: 'inline-block size-3 align-top 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: 'text-highlighted before:bg-elevated/75',
                itemLeadingIcon: 'text-default'
              },
              false: {
                item: [
                  'text-default data-highlighted:not-data-disabled:text-highlighted data-highlighted:not-data-disabled:before:bg-elevated/50',
                  'transition-colors before:transition-colors'
                ],
                itemLeadingIcon: [
                  'text-dimmed group-data-highlighted:not-group-data-disabled:text-default',
                  'transition-colors'
                ]
              }
            }
          },
          defaultVariants: {
            size: 'md'
          }
        }
      }
    })
  ]
})

Changelog

No recent changes