# llms.txt # mentionly > Mention input for AI chat composers. A framework-agnostic engine, `@mentionly/core`, turns a contenteditable element into an editor with atomic mention entities, async and paginated suggestion sources, IME-safe keyboard handling, and one serializable output format (`Part[]`). Adapters: Vue 3 (`mentionly`, with ready-made components), React (`@mentionly/react`, headless hook), Svelte 5 (`@mentionly/svelte`, headless). When you work inside a project that already depends on mentionly, read the `llms.txt` shipped in the installed package instead (`node_modules/mentionly/llms.txt` or `node_modules/@mentionly//llms.txt`): it matches the installed version. # llms/notes.md ## Setup Install one adapter per framework. Every adapter depends on `@mentionly/core` and re-exports its types (`Part`, `MentionTrigger`, `MentionItem`, …), so import types from the adapter you installed. | Framework | Install | Peer | |---|---|---| | Vue 3 | `npm i mentionly` (recommended). `@mentionly/vue` is the same code under a scoped name; import components and `style.css` from the package you installed | `vue ^3.3` | | React | `npm i @mentionly/react` | `react >=18` | | Svelte 5 | `npm i @mentionly/svelte` | `svelte ^5` | | Plain DOM, or server-side conversion only | `npm i @mentionly/core` | none | The converters `@mentionly/core/ai-sdk` and `@mentionly/core/mcp` live in `@mentionly/core`, which every adapter already installs. A trigger, an item, and what a selected mention produces: ```ts import type { MentionTrigger } from '@mentionly/react' // or mentionly, @mentionly/svelte, @mentionly/core const users = [{ id: 'u1', label: 'Alice', uri: 'user://alice' }] const triggers: MentionTrigger[] = [ { char: '@', // the trigger character items: users, // or (query, page?) => items | Promise toData: (item) => ({ uri: item.uri }), // called on selection with the full item }, ] // After the user types "hi @" and picks Alice, getParts() returns: // [ // { type: 'text', text: 'hi ' }, // { type: 'mention', trigger: '@', id: 'u1', label: 'Alice', data: { uri: 'user://alice' } }, // ] ``` ```ts interface MentionItem { id: string; label: string; [key: string]: any } // id and label become the mention's id and label interface MentionTrigger { char: string // '@', '#', '/', '$', … items: MentionItem[] | ((query: string, page?: { offset: number; limit: number }) => MentionItem[] | { items: MentionItem[]; hasMore?: boolean } | Promise) toData?: (item: MentionItem) => unknown // becomes MentionPart.data pagination?: { pageSize: number } // enables paging; a bare array result infers hasMore from length >= limit debounce?: number // ms before calling an async items function, default 0 mode?: 'inline' | 'command' // 'command': run onSelect(item) and remove the typed text instead of inserting a mention onSelect?: (item: MentionItem) => void // command mode only allowMidWord?: boolean // default false, see Triggers below } type Part = | { type: 'text'; text: string } | { type: 'mention'; trigger: string; id: string; label: string; data?: T } ``` Shared options for every adapter (`useMention`, `createMention`, `new MentionCore`): ```ts { triggers: MentionTrigger[]; insertSpaceAfter?: boolean /* default true: add a space after an inserted mention */; popupMode?: 'fixed' | 'cursor' /* default 'fixed' */; popupScrollBehavior?: 'reposition' | 'close' | 'ignore' } ``` ## Content - Read content with `getParts()` and restore it with `setContent(parts)`. `Part[]` is the format to store and to restore drafts from; converters below turn it into transport formats (AI SDK, MCP). `setContent(getParts())` restores the same text and mentions, with whitespace normalized as described below. - `getParts()` returns `Part[]` with `data: unknown`. Narrow it with a type guard or `as Part<{ uri: string }>[]` when you need typed `data`. - Put everything the backend needs about a mention into the trigger's `toData(item)`. It runs when the user selects the item and receives the full item, so fields such as `uri` reach the output. - Edit the editor only through the API: `insertMention({ id, label, trigger?, data? })`, `setContent`, `clear`, `focus`. The core owns the contenteditable DOM and inserts through `document.execCommand`, so edits stay on the browser undo stack. - `getParts()` trims leading and trailing whitespace, merges adjacent text, and turns non-breaking spaces into regular spaces. ## Triggers - A trigger opens the list at the start of the text, after whitespace, after punctuation, or after non-ASCII text such as `你好@`. After an ASCII letter, digit or underscore (`a@b.com`) it stays closed; `allowMidWord: true` opens it everywhere. - An `items` array is filtered by the core: case-insensitive substring match on `label`. An `items` function receives the typed `query` and returns already-filtered results. `state.filteredItems` is always the final list to render. - With `pagination`, the `items` function receives `page = { offset, limit }` as its second argument and may return `{ items, hasMore }`. ## Keyboard - While the list is open and has items, the core handles ArrowUp / ArrowDown (move), Enter and Tab (select the active item) and Escape (close), and calls `preventDefault()` on those events. In every other case (list closed, or open but empty) the event passes through untouched. - The core listens on the editor element itself (bubbling phase), so your own keydown handler on that element or an ancestor runs after it. The headless adapters have no submit option: implement Enter-to-submit yourself. - So: submit on Enter when the event was not already handled. React: check `e.nativeEvent.defaultPrevented` (React's synthetic `e.defaultPrevented` does not see the core's native handling). Svelte, Vue and plain DOM listen to native events: check `e.defaultPrevented`. ## Rendering your own list (React, Svelte, Vue `useMention`, plain `MentionCore`) - State to render from: `isOpen`, `filteredItems`, `activeIndex`, `query`, `activeTrigger`, `loading`, `loadingMore`, `hasMore`, `error`, `isEmpty`, `popupPosition`. - `ids` is `{ listbox: string; option(index: number): string }`. Give the list element `id={ids.listbox}` and `role="listbox"`, and each option `id={ids.option(index)}`, `role="option"` and `aria-selected`. The core sets `role="combobox"` and the `aria-*` wiring on the editor element, so the editor needs no `role`. - Select with `select(item)` (it takes the item, not an index). Call `preventDefault()` in each option's `mousedown` handler and `select(item)` on click: the editor keeps focus, and the list stays open until the selection lands. - Position the list with `popupPosition = { top, left, width? }`: numbers in px, viewport coordinates for `position: fixed`, `{ top: 0, left: 0 }` until the list first opens; add `transform: translateY(-100%)` to place it above. In `'fixed'` mode the coordinates are the editor's top-left and `width` is the editor's width; in `'cursor'` mode they follow the caret. - Pagination: call `loadMore()` when the list scrolls near its bottom, and also right after a page renders while `hasMore` is true and the list's `scrollHeight <= clientHeight` (a first page that does not overflow never scrolls). - Placeholder: the core renders none. Set `data-placeholder="…"` on the editor and style `[contenteditable]:empty::before { content: attr(data-placeholder) }`, or show your own element while `isEmpty` is true. ## Framework specifics - React: `const { ref, state, ids, select, getParts, … } = useMention({ triggers })`. State fields live under `state` (`state.isOpen`, `state.filteredItems`, …). Keep `triggers` referentially stable (module-level constant or `useMemo`): a new array reference makes the hook call `setOptions`, which closes the list and drops in-flight requests, and development builds warn after three such renders. Attach `ref` to the contenteditable element and render no React children inside it. - Svelte: inside a component's ` ``` `@mentionly/vue` exposes exactly the same API — swap the import to `import { MentionInput } from '@mentionly/vue'` and `import '@mentionly/vue/style.css'`. ### Headless mode (Vue) `useMention()` gives you the engine and the DOM event handlers; you render the editor and the dropdown yourself. ```vue ``` Always use `ids.listbox` / `ids.option(index)` for the listbox and options — the core writes `aria-controls` / `aria-activedescendant` on the editor pointing at those ids. ## Quick start (React) ```tsx import { useMemo } from 'react' import { useMention } from '@mentionly/react' import type { MentionTrigger } from '@mentionly/react' const USERS = [ { id: 'u1', label: 'Alice' }, { id: 'u2', label: 'Bob' }, ] export function Composer() { // ⚠ `triggers` must keep a stable reference: a module-level constant or useMemo(). // A new array on every render makes the hook call setOptions(), which closes the // popup and invalidates in-flight requests. Development builds warn when the // reference changes on 3 renders in a row. const triggers = useMemo( () => [{ char: '@', items: USERS, toData: (item) => ({ userId: item.id }) }], [], ) const { ref, state, ids, select, getParts, clear } = useMention({ triggers }) return ( <> {/* core owns everything inside this element: do not render React children into it */}
{state.isOpen && (
    {state.filteredItems.map((item, index) => (
  • e.preventDefault()} onClick={() => select(item)} > {item.label}
  • ))}
)} ) } ``` `useMention()` returns a callback `ref` (attach it to the editor), the core snapshot as `state`, the a11y `ids`, plus `select` / `loadMore` / `close` / `getParts` / `getPlainText` / `clear` / `setContent` / `focus` / `insertMention` and the underlying `core`. A complete, copyable component — keyboard submit, scroll-into-view, pagination and error state — lives in [`examples/react/src/MentionInput.tsx`](./examples/react/src/MentionInput.tsx). Copy that file plus `MentionInput.css` into your project and restyle the `mi-*` class names. ## Quick start (Svelte 5) ```svelte
{#if mention.state.isOpen}
    {#each mention.state.filteredItems as item, index (item.id)}
  • e.preventDefault()} onclick={() => mention.select(item)} > {item.label}
  • {/each}
{/if} ``` `createMention()` returns `state` (a `$state` mirror of the core snapshot), `ids`, `mention` (the action), `core`, `setOptions`, and the same method set as the other adapters. A complete, copyable component lives in [`examples/svelte/src/MentionInput.svelte`](./examples/svelte/src/MentionInput.svelte). ## Output format The engine reads the editor DOM into a framework-independent `Part[]`: ```ts import type { Part, TextPart, MentionPart } from '@mentionly/core' interface TextPart { type: 'text' text: string } interface MentionPart { type: 'mention' trigger: string // '@', '#', '/', ... id: string label: string data?: T // whatever trigger.toData(item) returned at selection time } type Part = TextPart | MentionPart ``` ```ts core.getParts() // Part[] core.getPlainText() // 'hello @Alice' — text plus trigger + label for each mention ``` - **`toData(item)`** runs once, when the item is selected, and its result is persisted on the mention node itself. `getParts()` later returns it as `data`, so the mention survives serialization even if the original item is gone. - **`getParts()` normalizes** the DOM: adjacent text is merged, non-breaking spaces (inserted after a mention by default) become regular spaces, and leading/trailing whitespace plus empty text parts are trimmed away. - **`setContent(parts)`** restores the editor from a `Part[]` (or a legacy 1.x `ContentPart[]`, see the [migration guide](./MIGRATION.md)), so `setContent(getParts())` round-trips. ## AI and agent interoperability Both converters are subpath exports of `@mentionly/core` and use structural types, so the core stays dependency-free. ### Vercel AI SDK — `@mentionly/core/ai-sdk` ```ts import { toUIMessageParts, fromUIMessageParts } from '@mentionly/core/ai-sdk' const uiParts = toUIMessageParts(core.getParts()) // [{ type: 'text', text: 'hi ' }, // { type: 'data-mention', data: { trigger: '@', id: 'u1', label: 'Alice' } }] const parts = fromUIMessageParts(message.parts) // reads text + data-* parts, skips the rest ``` - `toUIMessageParts(parts, { dataPartName })` — data part name defaults to `'mention'`. - `fromUIMessageParts(uiParts, { dataPartName })` — pass `dataPartName` to accept only that data part, or omit it to accept any `data-*` part whose payload has `trigger`, `id` and `label`. - The two functions are inverses: `fromUIMessageParts(toUIMessageParts(x))` deep-equals `x`. > **Server side:** AI SDK's `convertToModelMessages` drops data parts from user messages by > default, so the model never sees the mention. Pass `convertDataPart` to turn mentions into > something the model can read (returning `undefined` ignores the part): > > ```ts > import { convertToModelMessages, type UIMessage } from 'ai' > import type { MentionDataPayload } from '@mentionly/core/ai-sdk' > > type MyMessage = UIMessage > > const modelMessages = await convertToModelMessages(messages, { > convertDataPart: (part) => { > if (part.type === 'data-mention') { > return { type: 'text', text: `@${part.data.label}(${part.data.id})` } > } > return undefined > }, > }) > ``` ### MCP — `@mentionly/core/mcp` ```ts import { toMCPContent } from '@mentionly/core/mcp' toMCPContent(core.getParts()) // [{ type: 'text', text: 'see ' }, // { type: 'resource_link', uri: 'file:///a.ts', name: 'a.ts' }] ``` - `text` parts become `{ type: 'text', text }`; adjacent text blocks (including text from mentions without a uri) are merged. - A mention whose data has a string `uri` becomes `{ type: 'resource_link', uri, name: label }` (`name` is the mention label). - By default the uri comes from `part.data?.uri`. Use `getUri(part)` / `getMimeType(part)` to read it from anywhere else; a mention with no uri degrades to the text `trigger + label`. ## Features - **Multiple triggers** — `@`, `#`, `/` or any character you want. - **Atomic mention entities** — `contenteditable="false"` spans, indivisible and styled. - **One engine, three adapters** — `@mentionly/core` holds the logic; Vue ships components, React and Svelte are headless. - **Async data sources** — debounce, race protection and an `error` state. - **Backend pagination** — the data source gets `{ offset, limit }` and the list loads the next page on scroll or keyboard navigation; return `{ items, hasMore }` for an explicit next page. - **Command mode** — `/slash` commands that fire a callback instead of inserting an entity. - **IME compatible** — correct handling of Chinese / Japanese / Korean composition. - **Serialization** — `getParts()` / `setContent()` for submitting and editing saved messages. - **Teleport dropdown (Vue)** — the dropdown teleports to `` by default, avoiding `overflow: hidden` clipping. - **Cursor-following popup** — `popupMode="cursor"` positions the dropdown at the caret; `popupScrollBehavior` controls what happens on scroll (`reposition` / `close` / `ignore`). - **Word-boundary aware** — with the default `allowMidWord: false`, a trigger directly after an ASCII word character does not open the list, so `a@b.com` stays a plain email; whitespace, CJK characters and punctuation still trigger. - **Zero runtime dependencies in the core** — `@mentionly/core` pulls in nothing; the adapters add only Vue / React / Svelte as peer dependencies. ## Trigger configuration ```ts interface MentionTrigger { char: string // Trigger character mode?: 'inline' | 'command' // Default 'inline' allowMidWord?: boolean // Allow the trigger right after an ASCII word char, default false items: MentionItem[] // Static array | ((query: string, page?: { offset: number; limit: number }) // or function => MentionItemsResult | Promise) pagination?: { pageSize: number } // Enable backend pagination (function sources only) debounce?: number // Async debounce in ms, default 0 toData?: (item: MentionItem) => unknown // Persisted on the mention as `data` onSelect?: (item: MentionItem) => void // Command mode callback } // Function sources may return a bare array, or an object with an explicit hasMore: type MentionItemsResult = MentionItem[] | { items: MentionItem[]; hasMore?: boolean } ``` ### Multiple triggers ```ts const triggers: MentionTrigger[] = [ { char: '@', items: users, toData: (item) => ({ kind: 'user', userId: item.id }) }, { char: '#', items: topics, toData: (item) => ({ kind: 'topic', topicId: item.id }) }, { char: '/', mode: 'command', items: commands, onSelect: (item) => handleCommand(item) }, ] ``` ### Async data source ```ts { char: '@', items: async (query) => { const res = await fetch(`/api/search?q=${query}`) return res.json() }, debounce: 200, } ``` ### Backend pagination (load more) ```ts { char: '@', pagination: { pageSize: 20 }, items: async (query, page) => { const res = await fetch(`/api/users?q=${query}&offset=${page.offset}&limit=${page.limit}`) const users = await res.json() // Option 1 — return a bare array; hasMore is inferred from `length >= limit` return users // Option 2 — be explicit about whether there is a next page // return { items: users.list, hasMore: users.hasNext } }, } ``` - **Function sources only** — static arrays still render in full. - **Backward compatible** — without `pagination`, the `(query) => items` contract is unchanged (no second argument is passed). - `loading` covers the first page (replaces the list) and `loadingMore` covers subsequent pages (keeps the list, shows a footer indicator). Both are exposed by every adapter, and by the Vue `#list` slot. ## `MentionInput` (`@mentionly/vue`) ### Props | Prop | Type | Default | Description | |------|------|---------|-------------| | `triggers` | `MentionTrigger[]` | required | Trigger configs | | `placeholder` | `string` | `''` | Placeholder text | | `disabled` | `boolean` | `false` | Disable the input | | `maxHeight` | `string` | `'200px'` | Max editor height | | `submitOnEnter` | `boolean` | `true` | Submit on Enter | | `onEnter` | `(e: KeyboardEvent) => void` | `-` | Called on Enter before submit; call `e.preventDefault()` to block submit/newline | | `popupMode` | `'fixed' \| 'cursor'` | `'fixed'` | Popup positioning mode | | `popupScrollBehavior` | `'reposition' \| 'close' \| 'ignore'` | `'reposition'` | Popup behaviour on scroll | | `teleport` | `boolean` | `true` | Teleport the dropdown to `` | ### Events | Event | Payload | Description | |-------|---------|-------------| | `submit` | `Part[]` | Fired on Enter | | `change` | `Part[]` | Fired on content change | ### Slots | Slot | Props | Description | |------|-------|-------------| | `#list` | `{ items, activeIndex, select, loading, hasMore, loadingMore, loadMore, ids }` | Custom dropdown | | `#item` | `{ item, active, select }` | Custom item rendering | | `#empty` | `{ query }` | No results | | `#error` | `{ error }` | Data source error (replaces the default alert box) | | `#loading` | `{}` | Loading state (first page) | | `#loading-more` | `{}` | "Load more" indicator while fetching the next page | | `#actions` | `{ submit, clear, isEmpty }` | Custom action bar | | `#inner-actions` | `{ submit, clear, isEmpty }` | Inside the editor area, below the input (e.g. send button) | | `#default` | `{ submit, clear, isEmpty, focus, getParts }` | Bottom of the wrapper, free-form content | ### Exposed methods Access via a template ref: ```ts const inputRef = ref() inputRef.value.getParts() // Part[] inputRef.value.getDataParts() // DataPart[] (deprecated, 1.x compatible) inputRef.value.getPlainText() // string inputRef.value.clear() inputRef.value.setContent(parts) // Part[] (or legacy ContentPart[]) inputRef.value.insertMention({ id: 'ctx-1', label: 'Selection Context', data: { kind: 'context', sourceId: 'ctx-1' }, }) inputRef.value.focus() inputRef.value.ids // { listbox, option(index) } ``` `insertMention()` also inserts custom atomic nodes that are not registered on any trigger. ## Accessibility The core sets and maintains the combobox wiring on the editor element: | Attribute | Value | |-----------|-------| | `role` | `combobox` (unless you set your own `role`) | | `aria-autocomplete` | `list` | | `aria-expanded` | `true` while the dropdown is open | | `aria-controls` | `ids.listbox` | | `aria-activedescendant` | `ids.option(activeIndex)` while open with results | Render the dropdown as `role="listbox"` with `id={ids.listbox}`, and each option as `role="option"` with `id={ids.option(index)}` and `aria-selected`. Vue's `MentionList` already does this; in React and Svelte you wire it up yourself. ↑ / ↓ / Enter / Tab / Escape, including IME composition, are handled by the core — do not reimplement them. ## Error state When an async data source rejects, the core exposes it instead of throwing: - `state.error` (React / Svelte) or the `error` ref (Vue) holds the rejection; it resets to `null` on the next successful load and on `close()`. - `MentionInput` renders `#error` (default: an alert box with "Failed to load suggestions."). - A failed first page clears the list; a failed next page keeps the already loaded items and `hasMore` so the user can retry. ## Migrating from 1.x `mentionly` keeps working as a forwarding package, but parts of the output format and some APIs changed. See **[MIGRATION.md](./MIGRATION.md)** for every change with before/after code. ## License [MIT](./LICENSE) # packages/core/README.md # @mentionly/core Framework-agnostic engine behind [mentionly](https://github.com/jz0ojiang/mentionly): a `contenteditable` mention input with atomic mention entities, async and paginated data sources, IME handling and DOM serialization. No framework, no runtime dependencies — it only talks to the browser DOM (`HTMLElement`, `document`, `window`, `Selection`, `Range`, `execCommand`). The constructor touches no DOM, so importing and constructing are SSR-safe. Vue, React and Svelte users normally install the matching adapter instead: [`@mentionly/vue`](https://www.npmjs.com/package/@mentionly/vue), [`@mentionly/react`](https://www.npmjs.com/package/@mentionly/react), [`@mentionly/svelte`](https://www.npmjs.com/package/@mentionly/svelte), or the forwarding [`mentionly`](https://www.npmjs.com/package/mentionly) package. > **For AI agents:** this package ships `llms.txt` (`node_modules/@mentionly/core/llms.txt`) with setup and integration rules for its installed version. Site-wide docs: [llms.txt](https://im0o.top/mentionly/llms.txt), [llms-full.txt](https://im0o.top/mentionly/llms-full.txt). ## Install ```bash npm install @mentionly/core ``` ## Usage ```ts import { MentionCore } from '@mentionly/core' const core = new MentionCore({ triggers: [ { char: '@', items: [ { id: 'u1', label: 'Alice' }, { id: 'u2', label: 'Bob' }, ], toData: (item) => ({ kind: 'user', userId: item.id }), }, ], }) // Attach the editor element; `attach()` also binds the input/keyboard/IME handlers core.attach(document.querySelector('[contenteditable]')) core.subscribe((state) => render(state)) core.getParts() // [{ type: 'mention', trigger: '@', id: 'u1', label: 'Alice', data: { … } }] ``` ## API ### `new MentionCore(options)` | Option | Type | Description | |--------|------|-------------| | `triggers` | `MentionTrigger[]` | Trigger configs (`char`, `mode`, `items`, `pagination`, `debounce`, `toData`, `onSelect`) | | `insertSpaceAfter` | `boolean` | Insert a non-breaking space after a mention, default `true` | | `popupMode` | `'fixed' \| 'cursor'` | Popup positioning mode, default `'fixed'` | | `popupScrollBehavior` | `'reposition' \| 'close' \| 'ignore'` | Popup behaviour on scroll, default `'reposition'` | ### Lifecycle | Member | Description | |--------|-------------| | `setElement(el \| null)` | Bind/unbind the editor element. Idempotent | | `start({ bindHandlers? })` | Attach viewport listeners (and, by default, the DOM handlers) once | | `attach(el, opts?)` | `setElement(el)` + `start(opts)` | | `stop()` | Tear down listeners, timers and handlers | | `setOptions(partial)` | Merge options; a changed `triggers` reference closes the list and aborts in-flight requests | ### State `getState()` returns an immutable snapshot and `subscribe(fn)` notifies **synchronously**. The snapshot keeps the same reference while nothing changed: ```ts interface MentionState { isOpen: boolean filteredItems: MentionItem[] activeIndex: number query: string activeTrigger: string | null loading: boolean error: unknown | null loadingMore: boolean hasMore: boolean popupPosition: PopupPosition isEmpty: boolean } ``` `core.ids` provides the a11y ids (`ids.listbox`, `ids.option(index)`) that the core wires into the editor's `aria-controls` / `aria-activedescendant` / `role="combobox"`. ### Methods | Member | Description | |--------|-------------| | `select(item)` | Select a candidate (inline insert or command-mode callback) | | `insertMention(payload, opts?)` | Insert an atomic mention node programmatically | | `loadMore()` | Append the next page (paginated function sources only) | | `close()` | Close the popup and abort in-flight requests | | `getParts()` | Read the editor into `Part[]` (merged, trimmed, NBSP normalized) | | `getDataParts()` | **Deprecated** (3.0) — 1.x `DataPart[]` output | | `getPlainText()` | Editor content as plain text | | `clear()` | Empty the editor | | `setContent(parts)` | Restore the editor from `Part[]` (or legacy `ContentPart[]`) | | `focus()` | Focus the editor and move the caret to the end | | `handlers` | DOM handlers keyed by event name, for adapters that bind them themselves | ### Content format ```ts type Part = TextPart | MentionPart interface TextPart { type: 'text'; text: string } interface MentionPart { type: 'mention' trigger: string id: string label: string data?: T } ``` `toData(item)` runs when an item is selected and its result is persisted on the mention node, so `getParts()` can return it as `data` later. ## Converter subpaths Both subpaths use structural types, so the core stays dependency-free. ### `@mentionly/core/ai-sdk` ```ts import { toUIMessageParts, fromUIMessageParts } from '@mentionly/core/ai-sdk' const uiParts = toUIMessageParts(core.getParts()) // [{ type: 'text', text: 'hi ' }, // { type: 'data-mention', data: { trigger: '@', id: 'u1', label: 'Alice' } }] ``` AI SDK's `convertToModelMessages` drops data parts by default — pass `convertDataPart` on the server so the model can read the mentions (see the JSDoc in `src/ai-sdk.ts` for a full example). ### `@mentionly/core/mcp` ```ts import { toMCPContent } from '@mentionly/core/mcp' toMCPContent(core.getParts()) // [{ type: 'text', text: 'see ' }, // { type: 'resource_link', uri: 'file:///a.ts', name: 'a.ts' }] ``` Mentions whose data carries a string `uri` become `resource_link` blocks; the rest degrade to text. Override extraction with `getUri` / `getMimeType`. ## Documentation - [Root README](https://github.com/jz0ojiang/mentionly/blob/main/README.md) - [Migration guide (1.x → 2.0)](https://github.com/jz0ojiang/mentionly/blob/main/MIGRATION.md) - [Live demo](https://im0o.top/mentionly) ## License [MIT](https://github.com/jz0ojiang/mentionly/blob/main/LICENSE) # packages/vue/README.md # @mentionly/vue Vue 3 adapter for [mentionly](https://github.com/jz0ojiang/mentionly): a lightweight `contenteditable` mention input for AI chat scenarios, with atomic mention entities, async and paginated data sources, IME handling and serialization. This package is a thin Vue layer over the framework-agnostic [`@mentionly/core`](https://www.npmjs.com/package/@mentionly/core) engine. It exposes: - `useMention()` — headless composable: state as refs, DOM handlers, and the core methods. - `MentionInput` — batteries-included component (editor + dropdown + slots). - `MentionList` — the default dropdown, usable on its own. Vue 3 is a peer dependency; `@mentionly/core` is installed automatically. > **For AI agents:** this package ships `llms.txt` (`node_modules/@mentionly/vue/llms.txt`) with setup and integration rules for its installed version. Site-wide docs: [llms.txt](https://im0o.top/mentionly/llms.txt), [llms-full.txt](https://im0o.top/mentionly/llms-full.txt). ## Install ```bash npm install @mentionly/vue ``` If you are upgrading from 1.x, the forwarding package [`mentionly`](https://www.npmjs.com/package/mentionly) exposes the same API and ships `mentionly/style.css`. ## Usage ```vue ``` ### Headless ```vue ``` ## API ### `useMention(options)` `options` is a `UseMentionOptions` (`triggers`, `insertSpaceAfter`, `popupMode`, `popupScrollBehavior`). Returns refs (`isOpen`, `filteredItems`, `activeIndex`, `query`, `activeTrigger`, `loading`, `error`, `popupPosition`, `hasMore`, `loadingMore`, `isEmpty`), `editorRef`, `ids`, `handlers`, and the methods `select`, `insertMention`, `loadMore`, `close`, `getParts`, `getDataParts` (deprecated), `getPlainText`, `clear`, `setContent`, `focus`. ### `MentionInput` Props: `triggers`, `placeholder`, `disabled`, `maxHeight`, `submitOnEnter`, `onEnter`, `popupMode`, `popupScrollBehavior`, `teleport`. Events: `submit(parts: Part[])`, `change(parts: Part[])`. Slots: `#list` (with `ids`), `#item`, `#empty`, `#error`, `#loading`, `#loading-more`, `#actions`, `#inner-actions`, `#default`. Exposed through a template ref: `getParts()`, `getDataParts()` (deprecated), `getPlainText()`, `clear()`, `setContent(parts)`, `insertMention(payload)`, `focus()`, `ids`. ### `MentionList` Props: `items`, `activeIndex`, `loading`, `query`, `ids` (**required**), `hasMore`, `loadingMore`. ## Documentation - [Root README](https://github.com/jz0ojiang/mentionly/blob/main/README.md) - [Migration guide (1.x → 2.0)](https://github.com/jz0ojiang/mentionly/blob/main/MIGRATION.md) - [Live demo](https://im0o.top/mentionly) ## License [MIT](https://github.com/jz0ojiang/mentionly/blob/main/LICENSE) # packages/react/README.md # @mentionly/react Headless React hook for [mentionly](https://github.com/jz0ojiang/mentionly): a `contenteditable` mention input for AI chat scenarios, with atomic mention entities, async and paginated data sources, IME handling and serialization. `useMention()` wraps the framework-agnostic [`@mentionly/core`](https://www.npmjs.com/package/@mentionly/core) engine in `useSyncExternalStore`, reads its immutable snapshot, and lets the core own the editor DOM and its events. There are no published components — you render the editor and the dropdown. React (`>=18`) is a peer dependency; `@mentionly/core` is installed automatically. > **For AI agents:** this package ships `llms.txt` (`node_modules/@mentionly/react/llms.txt`) with setup and integration rules for its installed version. Site-wide docs: [llms.txt](https://im0o.top/mentionly/llms.txt), [llms-full.txt](https://im0o.top/mentionly/llms-full.txt). ## Install ```bash npm install @mentionly/react ``` A complete, copyable component (keyboard submit, scroll-into-view, pagination, error state) lives in [`examples/react/src/MentionInput.tsx`](https://github.com/jz0ojiang/mentionly/blob/main/examples/react/src/MentionInput.tsx). ## Usage ```tsx import { useMemo } from 'react' import { useMention } from '@mentionly/react' import type { MentionTrigger, Part } from '@mentionly/react' const USERS = [ { id: 'u1', label: 'Alice' }, { id: 'u2', label: 'Bob' }, ] export function Composer({ onSubmit }: { onSubmit: (parts: Part[]) => void }) { // ⚠ `triggers` must keep a stable reference: a module-level constant or useMemo(). // A new array on every render makes the hook call setOptions(), which closes the popup // and invalidates in-flight requests. Development builds warn after 3 such renders. const triggers = useMemo( () => [{ char: '@', items: USERS, toData: (item) => ({ kind: 'user', userId: item.id }) }], [], ) const { ref, state, ids, core, select, getParts, clear } = useMention({ triggers }) return ( <> {/* core owns everything inside this element — do not render React children into it */}
{state.isOpen && (
    {state.filteredItems.map((item, index) => (
  • e.preventDefault()} onClick={() => select(item)} > {item.label}
  • ))}
)} ) } ``` ## Building your own list The list UI is yours, so a few details the Vue component handles internally are your job: - **Select on click without losing focus.** Call `preventDefault()` on the option's `mousedown` (then select in `click`, or select directly in `mousedown`). Otherwise the editor blurs first, the core closes the list on blur, and the click selects nothing. - **Fill the first page.** Paginated sources load the next page when the list scrolls near its bottom. If the first page does not overflow the list, it can never scroll, so call `loadMore()` yourself when `hasMore` is true and the list's `scrollHeight <= clientHeight` (or render a "Load more" button). - **Check `nativeEvent.defaultPrevented` in your own `onKeyDown`.** The core handles Enter / arrows on the native event (for example Enter selects the active item while the list is open). React's synthetic `e.defaultPrevented` does not reflect that, so a submit-on-Enter handler that checks it would submit right after the selection. Read `e.nativeEvent.defaultPrevented` instead. The examples in the repository implement all of these. ## API ### `useMention(options: MentionCoreOptions)` | Member | Description | |--------|-------------| | `ref` | Callback ref for the editor element. Attaches on mount, detaches on unmount (StrictMode-safe) | | `state` | The core snapshot: `isOpen`, `filteredItems`, `activeIndex`, `query`, `activeTrigger`, `loading`, `error`, `loadingMore`, `hasMore`, `popupPosition`, `isEmpty` | | `ids` | `{ listbox, option(index) }` — required on the dropdown root and options | | `core` | The underlying `MentionCore` instance | | `select(item)` | Select a candidate | | `insertMention(payload, opts?)` | Insert an atomic mention node programmatically | | `loadMore()` | Append the next page (paginated function sources) | | `close()` | Close the popup and abort in-flight requests | | `getParts()` | Read the editor into `Part[]` | | `getPlainText()` | Editor content as plain text | | `clear()` | Empty the editor | | `setContent(parts)` | Restore from `Part[]` | | `focus()` | Focus the editor, caret at the end | Options are diffed per render (`triggers`, `insertSpaceAfter`, `popupMode`, `popupScrollBehavior`) and only written back to the core when they actually change. ### Things to know 1. **Keep `triggers` referentially stable.** A new array on every render triggers `setOptions()`, which closes the list and aborts in-flight requests. Use a module-level constant or `useMemo()`; development builds warn once after 3 consecutive renders with a changing reference. 2. **Never render React children into the editor element.** The core mutates that DOM directly (mention spans, caret). For a placeholder use `data-placeholder` plus `:empty::before` in CSS. 3. **Don't implement keyboard navigation.** ↑ / ↓ / Enter / Tab / Escape and IME composition are handled by the core; read `state.isOpen` / `core.getState()` in your own `keydown` only to decide when Enter should submit. ## Content format ```ts type Part = TextPart | MentionPart interface TextPart { type: 'text'; text: string } interface MentionPart { type: 'mention' trigger: string id: string label: string data?: T } ``` `toData(item)` runs at selection time and its result is persisted on the mention node, so `getParts()` can return it as `data` later without keeping the original item around. ## Documentation - [Root README](https://github.com/jz0ojiang/mentionly/blob/main/README.md) - [Migration guide (1.x → 2.0)](https://github.com/jz0ojiang/mentionly/blob/main/MIGRATION.md) - [Live demo](https://im0o.top/mentionly) ## License [MIT](https://github.com/jz0ojiang/mentionly/blob/main/LICENSE) # packages/svelte/README.md # @mentionly/svelte Svelte 5 headless adapter for [mentionly](https://github.com/jz0ojiang/mentionly): a `contenteditable` mention input for AI chat scenarios, with atomic mention entities, async and paginated data sources, IME handling and serialization. `createMention()` wraps the framework-agnostic [`@mentionly/core`](https://www.npmjs.com/package/@mentionly/core) engine: it mirrors the core snapshot into `$state` and returns the `use:mention` action that binds and unbinds the editor element. There are no published components — you render the editor and the dropdown. Svelte 5 (runes) is a peer dependency; `@mentionly/core` is installed automatically. > **For AI agents:** this package ships `llms.txt` (`node_modules/@mentionly/svelte/llms.txt`) with setup and integration rules for its installed version. Site-wide docs: [llms.txt](https://im0o.top/mentionly/llms.txt), [llms-full.txt](https://im0o.top/mentionly/llms-full.txt). ## Install ```bash npm install @mentionly/svelte ``` A complete, copyable component lives in [`examples/svelte/src/MentionInput.svelte`](https://github.com/jz0ojiang/mentionly/blob/main/examples/svelte/src/MentionInput.svelte). ## Usage ```svelte
{#if mention.state.isOpen}
    {#each mention.state.filteredItems as item, index (item.id)}
  • e.preventDefault()} onclick={() => mention.select(item)} > {item.label}
  • {/each}
{/if} ``` ### Reactive props `createMention()` reads its options once. When a prop that affects the core changes, mirror it back with `setOptions()`: ```svelte ``` ## Building your own list The list UI is yours, so two details the Vue component handles internally are your job: - **Select on click without losing focus.** Call `preventDefault()` on the option's `mousedown` (then select in `click`, or select directly in `mousedown`). Otherwise the editor blurs first, the core closes the list on blur, and the click selects nothing. - **Fill the first page.** Paginated sources load the next page when the list scrolls near its bottom. If the first page does not overflow the list, it can never scroll, so call `loadMore()` yourself when `hasMore` is true and the list's `scrollHeight <= clientHeight` (or render a "Load more" button). The examples in the repository implement both. ## API ### `createMention(options: MentionCoreOptions)` | Member | Description | |--------|-------------| | `state` | Reactive `$state` mirror of the core snapshot: `isOpen`, `filteredItems`, `activeIndex`, `query`, `activeTrigger`, `loading`, `error`, `loadingMore`, `hasMore`, `popupPosition`, `isEmpty` | | `ids` | `{ listbox, option(index) }` — required on the dropdown root and options | | `mention` | The action: `
` — binds the element, subscribes to the core, and cleans both up on destroy | | `core` | The underlying `MentionCore` instance | | `setOptions(partial)` | Merge options; a changed `triggers` reference closes the list and aborts in-flight requests | | `select(item)` | Select a candidate | | `insertMention(payload, opts?)` | Insert an atomic mention node programmatically | | `loadMore()` | Append the next page (paginated function sources) | | `close()` | Close the popup and abort in-flight requests | | `getParts()` | Read the editor into `Part[]` | | `getPlainText()` | Editor content as plain text | | `clear()` | Empty the editor | | `setContent(parts)` | Restore from `Part[]` | | `focus()` | Focus the editor, caret at the end | ### Things to know 1. **Render the listbox with `ids`.** `mention.ids.listbox` on the root, `mention.ids.option(i)` on each option; the core writes `aria-controls` / `aria-activedescendant` on the editor. 2. **Never render Svelte children into the editor element.** The core mutates that DOM directly (mention spans, caret). For a placeholder use `data-placeholder` plus `:empty::before` in CSS. 3. **Don't implement keyboard navigation.** ↑ / ↓ / Enter / Tab / Escape and IME composition are handled by the core. ## Content format ```ts type Part = TextPart | MentionPart interface TextPart { type: 'text'; text: string } interface MentionPart { type: 'mention' trigger: string id: string label: string data?: T } ``` `toData(item)` runs at selection time and its result is persisted on the mention node, so `getParts()` can return it as `data` later without keeping the original item around. ## Documentation - [Root README](https://github.com/jz0ojiang/mentionly/blob/main/README.md) - [Migration guide (1.x → 2.0)](https://github.com/jz0ojiang/mentionly/blob/main/MIGRATION.md) - [Live demo](https://im0o.top/mentionly) ## License [MIT](https://github.com/jz0ojiang/mentionly/blob/main/LICENSE) # examples/react/src/MentionInput.tsx ```tsx /** * 一个「可直接复制到自己项目」的完整 mention 输入框组件(React 版)。 * * 复制方式:把 MentionInput.tsx + MentionInput.css 拷到你的项目,改样式即可 * (依赖只有 @mentionly/react、react 和这个 CSS 文件)。 * * 三条要点(与 Vue 版一致): * 1. 编辑器是空的 contenteditable
,通过 hook 的 `ref` 交给 core。 * ⚠ 不要往这个 div 里渲染 React 子节点 —— 内部 DOM(mention span、光标等)由 core 直接管理。 * 2. 候选列表的 id 必须用 `ids.listbox` / `ids.option(i)`:core 会自动在编辑器上设置 * aria-controls / aria-activedescendant 指向它们(并补上 role="combobox",编辑器自己不要写 role)。 * 3. ↑ ↓ Enter Tab Esc 的键盘导航全部由 core 处理,示例里不要再实现一遍;这里只补「列表关闭时 * Enter 提交」和 onEnter 回调。 * * Vue 插槽在这里对应成 render props: * #inner-actions → renderInnerActions #actions → renderActions * #item → renderItem #list → renderList * #empty → renderEmpty #loading → renderLoading * #loading-more → renderLoadingMore #error → renderError * #default → children(函数形式) */ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from 'react' import { useMention } from '@mentionly/react' import type { InsertMentionOptions, InsertMentionPayload, MentionCoreIds, MentionItem, MentionTrigger, Part, PopupMode, PopupScrollBehavior, } from '@mentionly/react' import './MentionInput.css' /** 命令式方法(用 ref 拿到,对应 Vue 的 defineExpose) */ export interface MentionInputHandle { insertMention: (payload: InsertMentionPayload, options?: InsertMentionOptions) => boolean setContent: (parts: Part[]) => void getParts: () => Part[] getPlainText: () => string clear: () => void focus: () => void /** core 生成的无障碍 id(自定义列表渲染时需要) */ ids: MentionCoreIds } /** `#inner-actions` / `#actions` 插槽参数 */ export interface MentionActionsSlotProps { submit: () => void clear: () => void isEmpty: boolean } /** 默认插槽(Vue `#default`)参数 */ export interface MentionDefaultSlotProps extends MentionActionsSlotProps { focus: () => void getParts: () => Part[] } /** `#item` 插槽参数 */ export interface MentionItemSlotProps { item: MentionItem active: boolean select: () => void } /** `#list` 插槽参数 */ export interface MentionListSlotProps { items: MentionItem[] activeIndex: number select: (item: MentionItem) => void loading: boolean hasMore: boolean loadingMore: boolean loadMore: () => void query: string ids: MentionCoreIds } export interface MentionInputProps { /** 触发器配置。数据变化时请用 useMemo / state 保持稳定引用,否则列表会被重置 */ triggers: MentionTrigger[] placeholder?: string disabled?: boolean /** 编辑器最大高度(CSS 长度,默认 '200px') */ maxHeight?: string /** Enter 提交(Shift+Enter 换行)。列表打开时 Enter 始终用于选中候选项 */ submitOnEnter?: boolean /** * Enter 按下且列表关闭时先调用(Shift+Enter 不触发)。 * 调用 `e.preventDefault()` 可阻止提交(例如流式输出时)。 */ onEnter?: (e: KeyboardEvent) => void /** 弹出列表定位模式 */ popupMode?: PopupMode /** 滚动时弹窗行为 */ popupScrollBehavior?: PopupScrollBehavior /** 提交回调:收到 getParts() 的 Part[] */ onSubmit?: (parts: Part[]) => void /** 内容变化回调 */ onChange?: (parts: Part[]) => void // ── 插槽对应(Vue slot → render prop) ── /** Vue `#default`:编辑器下方的兜底插槽 */ children?: (props: MentionDefaultSlotProps) => ReactNode /** Vue `#inner-actions`:编辑器内部操作区 */ renderInnerActions?: (props: MentionActionsSlotProps) => ReactNode /** Vue `#actions`:编辑器下方操作栏 */ renderActions?: (props: MentionActionsSlotProps) => ReactNode /** Vue `#item`:自定义候选项渲染(内容在 .mentionly-list-item 内) */ renderItem?: (props: MentionItemSlotProps) => ReactNode /** Vue `#list`:整块自定义候选列表 */ renderList?: (props: MentionListSlotProps) => ReactNode /** Vue `#empty` */ renderEmpty?: (props: { query: string }) => ReactNode /** Vue `#loading` */ renderLoading?: () => ReactNode /** Vue `#loading-more` */ renderLoadingMore?: () => ReactNode /** Vue `#error` */ renderError?: (props: { error: unknown }) => ReactNode } const NEAR_BOTTOM_PX = 24 /** * 默认候选列表(对应 Vue 的 MentionList.vue)。 * 结构与类名与 Vue 版保持一致,样式在 MentionInput.css 里。 */ interface DefaultMentionListProps { items: MentionItem[] activeIndex: number query: string loading: boolean hasMore: boolean loadingMore: boolean ids: MentionCoreIds select: (item: MentionItem) => void loadMore: () => void renderItem?: (props: MentionItemSlotProps) => ReactNode renderEmpty?: (props: { query: string }) => ReactNode renderLoading?: () => ReactNode renderLoadingMore?: () => ReactNode } function DefaultMentionList({ items, activeIndex, query, loading, hasMore, loadingMore, ids, select, loadMore, renderItem, renderEmpty, renderLoading, renderLoadingMore, }: DefaultMentionListProps) { const listRef = useRef(null) const rafRef = useRef(null) const itemCount = items.length const requestLoadMore = useCallback(() => { if (hasMore && !loadingMore) loadMore() }, [hasMore, loadingMore, loadMore]) // 选中项始终滚进可视区(对应 Vue MentionList 里的 activeIndex watch) useEffect(() => { const container = listRef.current if (!container) return const active = container.querySelector('.mentionly-list-item--active') active?.scrollIntoView?.({ block: 'nearest' }) }, [activeIndex]) // 首屏 / 追加后若内容撑不满容器(无法滚动),主动补下一页直到填满或没有更多 useEffect(() => { const el = listRef.current if (!el) return if (el.scrollHeight <= el.clientHeight + 1) requestLoadMore() }, [itemCount, requestLoadMore]) const handleScroll = useCallback(() => { if (rafRef.current !== null) return rafRef.current = window.requestAnimationFrame(() => { rafRef.current = null const el = listRef.current if (!el) return if (el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX) requestLoadMore() }) }, [requestLoadMore]) useEffect( () => () => { if (rafRef.current !== null) window.cancelAnimationFrame(rafRef.current) }, [], ) return (
{loading ? ( renderLoading ? ( renderLoading() ) : (
Loading...
) ) : items.length > 0 ? ( <> {items.map((item, index) => { const active = index === activeIndex return (
{ e.preventDefault() select(item) }} > {renderItem ? ( renderItem({ item, active, select: () => select(item) }) ) : ( <> {item.label} {item.desc ? {item.desc} : null} )}
) })} {loadingMore && (renderLoadingMore ? ( renderLoadingMore() ) : (
Loading more...
))} ) : renderEmpty ? ( renderEmpty({ query }) ) : (
No results
)}
) } export const MentionInput = forwardRef(function MentionInput( { triggers, placeholder = '', disabled = false, maxHeight = '200px', submitOnEnter = true, onEnter, popupMode = 'fixed', popupScrollBehavior = 'reposition', onSubmit, onChange, children, renderInnerActions, renderActions, renderItem, renderList, renderEmpty, renderLoading, renderLoadingMore, renderError, }, ref, ) { const { ref: editorRef, state, ids, core, select, loadMore, getParts, getPlainText, clear, setContent, insertMention, focus, } = useMention({ triggers, popupMode, popupScrollBehavior }) useImperativeHandle( ref, () => ({ insertMention, setContent, getParts, getPlainText, clear, focus, ids }), [insertMention, setContent, getParts, getPlainText, clear, focus, ids], ) const submit = useCallback(() => { // 用 getState() 拿实时状态:React 闭包里的 state 可能停留在上一次渲染 if (core.getState().isEmpty) return onSubmit?.(getParts()) clear() }, [core, getParts, clear, onSubmit]) const handleKeyDown = useCallback( (e: ReactKeyboardEvent) => { const native = e.nativeEvent // 输入法组词中:交给 IME,不提交 if (native.isComposing || native.keyCode === 229) return // 列表打开时 core 已处理导航 / Enter / Tab / Esc;它 preventDefault 过就不再重复处理 // (列表为空时 core 不会 preventDefault,Enter 应继续走提交,与 Vue 版一致) // ⚠ 读 nativeEvent.defaultPrevented:core 与 onEnter 都是在原生事件上调 preventDefault, // React 合成事件的 defaultPrevented 不会跟着变。 if (core.getState().isOpen && native.defaultPrevented) return if (e.key !== 'Enter' || e.shiftKey) return onEnter?.(native) if (native.defaultPrevented) return if (!submitOnEnter) return e.preventDefault() submit() }, [core, onEnter, submitOnEnter, submit], ) const handleInput = useCallback(() => { if (onChange) onChange(getParts()) }, [onChange, getParts]) const { isOpen, filteredItems, activeIndex, query, loading, loadingMore, hasMore, popupPosition, isEmpty, error } = state const dropdownStyle = useMemo(() => { const pos = popupPosition if (popupMode === 'cursor') { return { position: 'fixed', top: pos.top, left: pos.left, transform: 'translateY(-100%)', marginTop: -4, width: 'max-content', minWidth: 200, maxWidth: 320, zIndex: 9999, } } // fixed 模式:下拉框在编辑器正上方,宽度与编辑器一致 return { position: 'fixed', top: pos.top, left: pos.left, transform: 'translateY(-100%)', marginTop: -4, width: pos.width ?? 0, zIndex: 9999, } }, [popupPosition, popupMode]) return (
{/* 输入框内部操作区 */} {renderInnerActions?.({ submit, clear, isEmpty })}
{/* 数据源错误(error 非 null 时显示) */} {error !== null && (
{renderError ? renderError({ error }) : 'Failed to load suggestions.'}
)} {/* 候选项下拉列表 */} {isOpen && (
{renderList ? ( renderList({ items: filteredItems, activeIndex, select, loading, hasMore, loadingMore, loadMore, query, ids }) ) : ( )}
)} {/* 操作栏 */} {renderActions?.({ submit, clear, isEmpty })} {/* 兜底插槽 */} {typeof children === 'function' ? children({ submit, clear, isEmpty, focus, getParts }) : null}
) }) ``` # examples/svelte/src/MentionInput.svelte ```svelte
{#if innerActions} {@render innerActions({ submit, clear, isEmpty: mention.state.isEmpty })} {/if}
{#if mention.state.error !== null} {/if} {#if mention.state.isOpen}
{#if list} {@render list({ items: mention.state.filteredItems, activeIndex: mention.state.activeIndex, select: selectItem, loading: mention.state.loading, hasMore: mention.state.hasMore, loadingMore: mention.state.loadingMore, loadMore: mention.loadMore, ids: mention.ids, })} {:else}
{#if mention.state.loading} {#if loading} {@render loading()} {:else}
Loading...
{/if} {:else if mention.state.filteredItems.length > 0} {#each mention.state.filteredItems as mentionItem, index (mentionItem.id)}
{ event.preventDefault() selectItem(mentionItem) }} > {#if item} {@render item({ item: mentionItem, active: index === mention.state.activeIndex, select: () => selectItem(mentionItem), })} {:else} {mentionItem.label} {#if mentionItem.desc} {mentionItem.desc} {/if} {/if}
{/each} {#if mention.state.loadingMore} {#if loadingMore} {@render loadingMore()} {:else}
Loading more...
{/if} {/if} {:else} {#if empty} {@render empty({ query: mention.state.query })} {:else}
No results
{/if} {/if}
{/if}
{/if} {#if actions} {@render actions({ submit, clear, isEmpty: mention.state.isEmpty })} {/if} {#if children} {@render children({ submit, clear, isEmpty: mention.state.isEmpty, focus, getParts })} {/if}
``` # MIGRATION.md # Migrating from 1.x to 2.0 2.0 splits mentionly into a framework-agnostic engine plus three adapters, and replaces the 1.x `ContentPart` / `DataPart` output with a single `Part[]` format. `mentionly` itself keeps working as a forwarding package for Vue, and the deprecated 1.x APIs still run (they are removed in 3.0), so most apps can migrate one piece at a time. Everything below is written as **what changed → how to change it → before/after code**. - [1. Package layout](#1-package-layout) - [2. `getParts()` returns `Part[]`](#2-getparts-returns-part) - [3. `MentionInput` `submit` / `change` now emit `Part[]`](#3-mentioninput-submit--change-now-emit-part) - [4. `dataPart`, `schema`, `getDataParts()`, `DataPart`, `ContentPart` are deprecated](#4-datapart-schema-getdataparts-datapart-contentpart-are-deprecated) - [5. `insertMention()` has a new payload](#5-insertmention-has-a-new-payload) - [6. `setContent()` accepts legacy `ContentPart[]`](#6-setcontent-accepts-legacy-contentpart) - [7. `MentionList` needs the new required `ids` prop](#7-mentionlist-needs-the-new-required-ids-prop) - [8. The editor `role` is now `combobox`](#8-the-editor-role-is-now-combobox) - [9. New: `error` state and the `#error` slot](#9-new-error-state-and-the-error-slot) - [10. New: `ids` for a11y wiring](#10-new-ids-for-a11y-wiring) - [11. New: changing `triggers` closes the list and aborts requests](#11-new-changing-triggers-closes-the-list-and-aborts-requests) - [12. `allowMidWord` boundary rule (introduced in 1.2.1)](#12-allowmidword-boundary-rule-introduced-in-121) ## 1. Package layout **What changed** - The logic moved out of the Vue package into `@mentionly/core`, a framework-agnostic engine that depends on the browser DOM but not on any framework. `@mentionly/vue` is now a thin adapter. - Two new adapters ship alongside it: `@mentionly/react` (headless `useMention` hook) and `@mentionly/svelte` (headless `createMention()` + `use:mention`). - Two new converter subpaths ship with the core: `@mentionly/core/ai-sdk` and `@mentionly/core/mcp`. - `mentionly` is still published and still exposes the exact `@mentionly/vue` API, but it is **no longer a zero-dependency package**: it forwards `@mentionly/vue`, which depends on `@mentionly/core`. Vue 3 remains only a peer dependency. **How to change it** Nothing is required — `npm install mentionly` and `import { MentionInput } from 'mentionly'` keep working. If you want the framework-agnostic parts without Vue, depend on `@mentionly/core` directly. If you are on React or Svelte, add the matching adapter. ```bash # 1.x npm install mentionly # 2.0 — still valid npm install mentionly # 2.0 — alternative, framework-agnostic / per-framework npm install @mentionly/core npm install @mentionly/vue # or @mentionly/react, @mentionly/svelte ``` ```ts // 1.x — the engine was internal; only the Vue API was public // (there was no @mentionly/core to import from) // 2.0 import { MentionCore } from '@mentionly/core' ``` ## 2. `getParts()` returns `Part[]` **What changed** In 1.x `getParts()` returned the raw internal `ContentPart[]`. In 2.0 it returns `Part[]`, a public, framework-independent format: | 1.x `ContentPart` | 2.0 `Part` | |-------------------|------------| | `{ type: 'text', content }` | `{ type: 'text', text }` | | `{ type: 'mention', triggeredBy, id, label, dataPart? }` | `{ type: 'mention', trigger, id, label, data? }` | `getParts()` now also **normalizes** the output: adjacent text is merged, non-breaking spaces inserted after a mention become regular spaces, leading/trailing whitespace is trimmed and empty text parts are dropped. 1.x `getParts()` returned those bytes verbatim. **How to change it** Rename the fields when you read the result: `content` → `text`, `triggeredBy` → `trigger`, `dataPart` → `data`. If you persisted 1.x parts, see [section 6](#6-setcontent-accepts-legacy-contentpart) for reading them back. ```ts // 1.x const parts = inputRef.value.getParts() // [ // { type: 'text', content: 'Check ' }, // { type: 'mention', triggeredBy: '@', id: '1', label: 'Project Alpha', // dataPart: { dataType: 'mentioned_ref', projectId: '1' } }, // ] // 2.0 const parts = inputRef.value.getParts() // [ // { type: 'text', text: 'Check ' }, // { type: 'mention', trigger: '@', id: '1', label: 'Project Alpha', // data: { kind: 'project', projectId: '1' } }, // ] // Reading either shape without breaking 1.x consumers: const text = part.type === 'text' ? (part.text ?? part.content) : `${part.trigger ?? part.triggeredBy}${part.label}` ``` ## 3. `MentionInput` `submit` / `change` now emit `Part[]` **What changed** | Event | 1.x payload | 2.0 payload | |-------|-------------|-------------| | `submit` | `DataPart[]` (from `getDataParts()`) | `Part[]` (from `getParts()`) | | `change` | `ContentPart[]` (from `getParts()`) | `Part[]` | The `submit` payload is the interesting one: 1.x flattened your `dataPart` transformer result into the part (`{ type: 'data', dataType: 'mentioned_ref', projectId: '1' }`). 2.0 nests it under the mention (`{ type: 'mention', trigger: '@', id: '1', label: 'Project Alpha', data: { … } }`). Update the backend contract accordingly, or keep using `getDataParts()` (deprecated) if the server must stay untouched for now. **How to change it** ```vue ``` ```vue ``` ## 4. `dataPart`, `schema`, `getDataParts()`, `DataPart`, `ContentPart` are deprecated **What changed** The 1.x mapping APIs still exist and behave exactly as before, but they are deprecated and will be removed in 3.0: - `MentionTrigger.dataPart` → use `toData` - `MentionTrigger.schema` → use `toData` - `getDataParts()` → use `getParts()` - `DataPart` / `ContentPart` types → use `Part` / `TextPart` / `MentionPart` > These legacy APIs live in `@mentionly/core` and are surfaced by `@mentionly/vue` (and the > `mentionly` forwarding package). `@mentionly/react` and `@mentionly/svelte` are new in 2.0 and > never exposed `getDataParts()`, `DataPart` or `ContentPart`. `getDataParts()` output is **identical to 1.x** (including the flattened `{ type: 'data', … }` parts, custom `type` overrides and the original NBSP bytes in text). The `DataPart` type was widened from `{ type: 'data' } & Record` to `{ type: string } & Record` so your own `type` overrides still typecheck. `toData` is not a rename of `dataPart`: `toData` receives the whole item and its return value is persisted on the mention node, then read back by `getParts()` as `data`. `dataPart` output is kept in a separate slot and only surfaces through `getDataParts()`. **How to change it** ```ts // 1.x const triggers = [ { char: '@', items: projects, dataPart: (item) => ({ dataType: 'mentioned_ref', projectId: item.id }) }, { char: '#', items: tags, schema: { type: 'tag_ref', mapping: { tagId: 'id', tagName: 'label' } } }, ] const dataParts = inputRef.value.getDataParts() ``` ```ts // 2.0 const triggers: MentionTrigger[] = [ { char: '@', items: projects, toData: (item) => ({ kind: 'mentioned_ref', projectId: item.id }) }, { char: '#', items: tags, toData: (item) => ({ kind: 'tag_ref', tagId: item.id, tagName: item.label }) }, ] const parts: Part[] = inputRef.value.getParts() ``` ## 5. `insertMention()` has a new payload **What changed** | 1.x | 2.0 | |-----|-----| | `{ id, label, triggeredBy?, dataPart? }` | `{ id, label, trigger?, data? }` | `triggeredBy` is still read (as a fallback for `trigger`) and `dataPart` still works as a map or as a function receiving `{ id, label, triggeredBy }`, but both are deprecated. Only `data` ends up in `getParts().data`; a legacy `dataPart` remains visible through `getDataParts()`. **How to change it** ```ts // 1.x inputRef.value.insertMention({ id: 'ctx-1', label: 'Selection Context', dataPart: (item) => ({ dataType: 'selection_ref', sourceId: item.id, title: item.label }), }) ``` ```ts // 2.0 inputRef.value.insertMention({ id: 'ctx-1', label: 'Selection Context', trigger: '@', // optional, defaults to '' data: { kind: 'selection_ref', sourceId: 'ctx-1', title: 'Selection Context' }, }) ``` The second argument is unchanged: `{ appendSpace?: boolean, focus?: boolean }`. ## 6. `setContent()` accepts legacy `ContentPart[]` **What changed** `setContent()` accepts both shapes and detects them per part, so you can keep feeding it content you saved with 1.x. The first time legacy input is seen it logs `[mentionly] setContent(ContentPart[]) is deprecated; use Part[] instead.` — **once per page load**, not once per call. The React and Svelte adapters type `setContent()` as `(parts: Part[]) => void` and do not accept the 1.x `ContentPart[]` shape. **How to change it** Prefer storing `Part[]` going forward; convert old rows on read. ```ts // 1.x inputRef.value.setContent([ { type: 'text', content: 'Check ' }, { type: 'mention', triggeredBy: '@', id: '1', label: 'Project Alpha' }, { type: 'text', content: ' deployment status' }, ]) ``` ```ts // 2.0 inputRef.value.setContent([ { type: 'text', text: 'Check ' }, { type: 'mention', trigger: '@', id: '1', label: 'Project Alpha' }, { type: 'text', text: ' deployment status' }, ]) ``` ## 7. `MentionList` needs the new required `ids` prop **What changed** `MentionList` gained a required `ids: MentionCoreIds` prop. It is used for the `listbox` id and per-option ids so the editor's `aria-controls` / `aria-activedescendant` can point at them. `MentionInput` passes it automatically; only direct `MentionList` users are affected. **How to change it** ```vue ``` ```vue ``` If you render a custom dropdown through `#list`, the slot also provides `ids` now — use `ids.listbox` and `ids.option(index)` on your elements. ## 8. The editor `role` is now `combobox` **What changed** 1.x `MentionInput` hard-coded `role="textbox"` and `aria-multiline="true"` on the editor. The core now owns the a11y attributes: it sets `role="combobox"`, `aria-autocomplete="list"`, `aria-expanded`, `aria-controls` and `aria-activedescendant`, and removes the 1.x attributes. `role="combobox"` is only applied when the element has no `role` attribute of its own, so an explicit `role` you set still wins (the core then keeps managing the `aria-*` wiring). **How to change it** If you had tests or CSS keyed on the old role, update them. ```ts // 1.x container.querySelector('[role="textbox"]') // 2.0 container.querySelector('[role="combobox"]') // or, framework-agnostically, just use the editor element you already have a ref to ``` ```css /* 1.x */ .editor[role='textbox'] { … } /* 2.0 */ .editor[role='combobox'] { … } ``` ## 9. New: `error` state and the `#error` slot **What changed** Async data source failures used to be silent. 2.0 surfaces them: - `error` (Vue ref) / `state.error` (React, Svelte) holds the rejection, and resets to `null` on the next successful load or on `close()`. - `MentionInput` renders a `#error` slot. Default content: ```html ``` A failed first page clears the list; a failed subsequent page keeps the loaded items and `hasMore` so the user can retry. **How to change it** Nothing is required. To show your own message: ```vue ``` ## 10. New: `ids` for a11y wiring **What changed** Every adapter now exposes `ids: { listbox: string; option(index: number): string }`. Use them on the listbox root and each option; the core writes `aria-controls` / `aria-activedescendant` on the editor pointing at them. `MentionInput` also exposes `ids` on its template ref. **How to change it** Headless users should adopt `ids` (the built-in Vue `MentionList` already does). ```vue
    • …
    ``` ## 11. New: changing `triggers` closes the list and aborts requests **What changed** `MentionCore.setOptions()` now compares the `triggers` reference; when it changes, the core closes and clears the list and invalidates all in-flight data source requests. Previously the popup stayed open on the previous config's results, and a response that was already in flight could still land there. All adapters go through this path: - **Vue** — the `useMention` watcher on `triggers` / `insertSpaceAfter` / `popupMode` / `popupScrollBehavior` calls `setOptions`. - **React** — `useMention` diffs the same fields per render and warns in development when `triggers` changes on 3 renders in a row. - **Svelte** — `setOptions` is returned from `createMention()` and must be called when a prop changes. **How to change it** Keep the `triggers` reference stable across renders/updates. ```tsx // React — ❌ a new array on every render closes the popup and aborts requests // React — ✅ module-level constant or useMemo const TRIGGERS: MentionTrigger[] = [{ char: '@', items: USERS }] ``` ```svelte ``` Development warning text: ``` [mentionly/react] `triggers` changed on every render. This closes the popup and aborts in-flight requests. Pass a stable reference: a module-level constant or useMemo(). ``` ## 12. `allowMidWord` boundary rule (introduced in 1.2.1) **What changed** Since 1.2.1 the trigger is only detected at a word boundary: when the character right before the trigger matches `/\w/` (ASCII letter, digit or underscore) and `allowMidWord` is not set, the trigger is ignored. This stops `a@b.com` from opening the mention list. The start of the text node, whitespace, non-breaking spaces, CJK characters and punctuation still trigger normally. If you are upgrading from an older 1.x release, this may already be a behaviour change you have not accounted for. **How to change it** Keep the default unless you need the old "trigger anywhere" behaviour: ```ts // 2.0 default — `a@b.com` stays an email { char: '@', items: users } // opt back into the pre-1.2.1 behaviour { char: '@', items: users, allowMidWord: true } ``` ## What did not change - `MentionTrigger` fields `char`, `mode`, `items`, `pagination`, `debounce`, `onSelect`. - The dropdown keyboard handling: ↑ / ↓ wrap around, Enter / Tab select, Escape closes, and IME composition is left to the browser. - `MentionInput` props `placeholder`, `disabled`, `maxHeight`, `submitOnEnter`, `onEnter`, `popupMode`, `popupScrollBehavior`, `teleport`, and all slots except the new `#error`. - `useMention()`'s Vue return shape (`editorRef`, `isOpen`, `filteredItems`, …) — including `isEmpty`, which stays a computed ref. - `insertMention()`'s second argument and the `mentionly/style.css` entry point.