@relevaince/mentions 0.3.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,12 +11,28 @@ This is **not** a simple `@mention` dropdown. It is a resource-addressing langua
11
11
  - **Cross-group search** — `searchAll` finds items across every level with a single query
12
12
  - **Nested search** — search input inside the dropdown to filter children in real time
13
13
  - **Async providers** — fetch suggestions from any API
14
+ - **Debounced fetching** — per-provider `debounceMs` prevents API floods
14
15
  - **Structured output** — returns `{ markdown, tokens, plainText }` on every change
15
16
  - **Markdown serialization** — `@[label](id)` token syntax for storage and LLM context
16
17
  - **Markdown parsing** — `extractFromMarkdown()` returns tokens + plain text from a markdown string
17
18
  - **Headless styling** — zero bundled CSS, style via `data-*` attributes with Tailwind or plain CSS
18
19
  - **Accessible** — full ARIA combobox pattern with keyboard navigation
19
20
  - **SSR compatible** — safe for Next.js with `"use client"` directive
21
+ - **Grouped suggestions** — section items under headers (e.g. "Active", "Pending")
22
+ - **Recently used items** — show recent mentions when the query is empty
23
+ - **Empty state** — customizable "No results" message
24
+ - **Mention lifecycle** — `onMentionAdd` / `onMentionRemove` callbacks
25
+ - **Mention interaction** — `onMentionClick` / `onMentionHover` handlers on chips
26
+ - **Mention validation** — mark stale/invalid mentions with `validateMention`
27
+ - **Controlled value** — reactive `value` prop for external content updates
28
+ - **Auto-resize** — `minHeight` / `maxHeight` for growing editor
29
+ - **Submit key customization** — choose Enter, Cmd+Enter, or none
30
+ - **Edge-aware popover** — flips above when near viewport bottom
31
+ - **Portal support** — render the dropdown into a custom container
32
+ - **Tab to complete** — Tab selects the active suggestion
33
+ - **Trigger gating** — `allowTrigger` to conditionally suppress the dropdown
34
+ - **Streaming support** — stream AI-generated text into the editor with automatic mention parsing
35
+ - **Multi-instance** — unique ARIA IDs per component instance
20
36
 
21
37
  ## Install
22
38
 
@@ -38,6 +54,7 @@ import { MentionsInput, type MentionProvider } from "@relevaince/mentions";
38
54
  const workspaceProvider: MentionProvider = {
39
55
  trigger: "@",
40
56
  name: "Workspaces",
57
+ debounceMs: 200,
41
58
  async getRootItems(query) {
42
59
  const res = await fetch(`/api/workspaces?q=${query}`);
43
60
  return res.json();
@@ -46,6 +63,10 @@ const workspaceProvider: MentionProvider = {
46
63
  const res = await fetch(`/api/workspaces/${parent.id}/files?q=${query}`);
47
64
  return res.json();
48
65
  },
66
+ async getRecentItems() {
67
+ const res = await fetch("/api/workspaces/recent");
68
+ return res.json();
69
+ },
49
70
  };
50
71
 
51
72
  function Chat() {
@@ -58,7 +79,11 @@ function Chat() {
58
79
  console.log(output.plainText); // "Summarize Marketing"
59
80
  }}
60
81
  onSubmit={(output) => sendMessage(output)}
82
+ onMentionAdd={(token) => console.log("Added:", token)}
83
+ onMentionRemove={(token) => console.log("Removed:", token)}
61
84
  placeholder="Ask anything..."
85
+ minHeight={40}
86
+ maxHeight={200}
62
87
  />
63
88
  );
64
89
  }
@@ -81,19 +106,29 @@ type MentionToken = {
81
106
 
82
107
  ### MentionProvider
83
108
 
84
- Register one provider per trigger character. Each provider fetches suggestions and optionally supports nested drill-down and cross-group search:
109
+ Register one provider per trigger character. Each provider fetches suggestions and optionally supports nested drill-down, cross-group search, debouncing, and recent items:
85
110
 
86
111
  ```ts
87
112
  type MentionProvider = {
88
- trigger: string; // "@", "#", ":"
89
- name: string; // human label
113
+ trigger: string;
114
+ name: string;
90
115
  getRootItems: (query: string) => Promise<MentionItem[]>;
91
116
  getChildren?: (parent: MentionItem, query: string) => Promise<MentionItem[]>;
92
- searchAll?: (query: string) => Promise<MentionItem[]>; // flat search across all levels
117
+ searchAll?: (query: string) => Promise<MentionItem[]>;
118
+ debounceMs?: number;
119
+ getRecentItems?: () => Promise<MentionItem[]>;
93
120
  };
94
121
  ```
95
122
 
96
- When `searchAll` is provided, typing a non-empty query at the root level will call `searchAll` instead of `getRootItems`, returning a flat list of results from every level (root items and their children).
123
+ | Property | Type | Required | Description |
124
+ |----------|------|----------|-------------|
125
+ | `trigger` | `string` | Yes | Character(s) that activate this provider |
126
+ | `name` | `string` | Yes | Human-readable name for ARIA labels |
127
+ | `getRootItems` | `(query) => Promise<MentionItem[]>` | Yes | Top-level suggestions |
128
+ | `getChildren` | `(parent, query) => Promise<MentionItem[]>` | No | Child suggestions for drill-down |
129
+ | `searchAll` | `(query) => Promise<MentionItem[]>` | No | Flat search across all levels |
130
+ | `debounceMs` | `number` | No | Delay before fetching (prevents API floods) |
131
+ | `getRecentItems` | `() => Promise<MentionItem[]>` | No | Recently used items, shown on empty query |
97
132
 
98
133
  ### MentionItem
99
134
 
@@ -104,15 +139,16 @@ type MentionItem = {
104
139
  id: string;
105
140
  type: string;
106
141
  label: string;
107
- icon?: ReactNode; // rendered before the label
108
- description?: string; // secondary text
109
- hasChildren?: boolean; // when true, selecting drills into a child level
142
+ icon?: ReactNode;
143
+ description?: string;
144
+ hasChildren?: boolean;
110
145
  data?: unknown;
111
- rootLabel?: string; // parent label for flat search results (used in serialization)
146
+ rootLabel?: string;
147
+ group?: string; // group items under section headers
112
148
  };
113
149
  ```
114
150
 
115
- Set `rootLabel` on items returned by `searchAll` that originate from a nested level. This ensures correct `@rootLabel[label](id)` serialization.
151
+ Set `group` on items to render them under section headers in the dropdown (e.g. "Active", "Pending", "Recent").
116
152
 
117
153
  ### MentionsOutput
118
154
 
@@ -120,9 +156,9 @@ Structured output returned on every change and submit:
120
156
 
121
157
  ```ts
122
158
  type MentionsOutput = {
123
- markdown: string; // "@[Marketing](ws_123) summarize files"
124
- tokens: MentionToken[]; // [{ id: "ws_123", type: "workspace", label: "Marketing" }]
125
- plainText: string; // "Marketing summarize files"
159
+ markdown: string;
160
+ tokens: MentionToken[];
161
+ plainText: string;
126
162
  };
127
163
  ```
128
164
 
@@ -130,22 +166,39 @@ type MentionsOutput = {
130
166
 
131
167
  | Prop | Type | Default | Description |
132
168
  |------|------|---------|-------------|
133
- | `value` | `string` | — | Initial markdown content with `@[label](id)` tokens |
169
+ | `value` | `string` | — | Controlled markdown content (reactive — updates editor on change) |
134
170
  | `providers` | `MentionProvider[]` | **required** | Suggestion providers, one per trigger |
135
171
  | `onChange` | `(output: MentionsOutput) => void` | — | Called on every content change |
136
- | `onSubmit` | `(output: MentionsOutput) => void` | — | Called on Enter or Cmd+Enter |
172
+ | `onSubmit` | `(output: MentionsOutput) => void` | — | Called on submit shortcut |
137
173
  | `placeholder` | `string` | `"Type a message..."` | Placeholder text |
138
174
  | `autoFocus` | `boolean` | `false` | Focus editor on mount |
139
175
  | `disabled` | `boolean` | `false` | Disable editing |
140
176
  | `className` | `string` | — | CSS class on the wrapper |
141
177
  | `maxLength` | `number` | — | Max plain text character count |
178
+ | `clearOnSubmit` | `boolean` | `true` | Auto-clear after `onSubmit` |
179
+ | `submitKey` | `"enter" \| "mod+enter" \| "none"` | `"enter"` | Which key combo triggers submit |
180
+ | `minHeight` | `number` | — | Minimum editor height in px |
181
+ | `maxHeight` | `number` | — | Maximum editor height in px (enables scroll) |
182
+ | `onFocus` | `() => void` | — | Called when editor gains focus |
183
+ | `onBlur` | `() => void` | — | Called when editor loses focus |
184
+ | `onMentionAdd` | `(token: MentionToken) => void` | — | Called when a mention is inserted |
185
+ | `onMentionRemove` | `(token: MentionToken) => void` | — | Called when a mention is deleted |
186
+ | `onMentionClick` | `(token: MentionToken, event: MouseEvent) => void` | — | Called when a mention chip is clicked |
187
+ | `onMentionHover` | `(token: MentionToken) => ReactNode` | — | Return content for a hover tooltip |
142
188
  | `renderItem` | `(item, depth) => ReactNode` | — | Custom suggestion item renderer |
143
- | `clearOnSubmit` | `boolean` | `true` | Auto-clear the editor after `onSubmit` fires |
144
189
  | `renderChip` | `(token) => ReactNode` | — | Custom inline mention chip renderer |
190
+ | `renderEmpty` | `(query: string) => ReactNode` | — | Custom empty state (no results) |
191
+ | `renderLoading` | `() => ReactNode` | — | Custom loading indicator |
192
+ | `renderGroupHeader` | `(group: string) => ReactNode` | — | Custom section header renderer |
193
+ | `allowTrigger` | `(trigger, { textBefore }) => boolean` | — | Conditionally suppress the dropdown |
194
+ | `validateMention` | `(token) => boolean \| Promise<boolean>` | — | Validate mentions; invalid ones get `data-mention-invalid` |
195
+ | `portalContainer` | `HTMLElement` | — | Render dropdown into a custom DOM node |
196
+ | `streaming` | `boolean` | `false` | Signals the editor is receiving streamed content (suppresses triggers, blocks user input, throttles onChange) |
197
+ | `onStreamingComplete` | `(output: MentionsOutput) => void` | — | Fires once when `streaming` transitions from `true` to `false` with the final output |
145
198
 
146
199
  ## Imperative ref API
147
200
 
148
- `MentionsInput` supports `forwardRef` for programmatic control. This is essential for flows like clearing after submit, injecting prompts from a library, or "Enhance Prompt" features.
201
+ `MentionsInput` supports `forwardRef` for programmatic control:
149
202
 
150
203
  ```tsx
151
204
  import { useRef } from "react";
@@ -158,16 +211,15 @@ function Chat() {
158
211
  <>
159
212
  <MentionsInput ref={ref} providers={providers} />
160
213
 
161
- <button onClick={() => ref.current?.clear()}>
162
- Clear
163
- </button>
164
-
214
+ <button onClick={() => ref.current?.clear()}>Clear</button>
165
215
  <button onClick={() => {
166
216
  ref.current?.setContent("Summarize @[NDA](contract:c_1) risks");
167
217
  ref.current?.focus();
168
- }}>
169
- Use Prompt
170
- </button>
218
+ }}>Use Prompt</button>
219
+ <button onClick={() => {
220
+ const output = ref.current?.getOutput();
221
+ console.log(output?.tokens);
222
+ }}>Read Output</button>
171
223
  </>
172
224
  );
173
225
  }
@@ -179,43 +231,64 @@ function Chat() {
179
231
  |--------|-----------|-------------|
180
232
  | `clear` | `() => void` | Clears all editor content |
181
233
  | `setContent` | `(markdown: string) => void` | Replaces content with a markdown string (mention tokens are parsed) |
234
+ | `appendText` | `(text: string) => void` | Appends plain text at the end (no mention parsing — use for plain-text streaming) |
182
235
  | `focus` | `() => void` | Focuses the editor and places the cursor at the end |
236
+ | `getOutput` | `() => MentionsOutput \| null` | Reads the current structured output without waiting for onChange |
183
237
 
184
- ### Auto-clear on submit
238
+ ## Streaming
185
239
 
186
- By default, the editor clears itself after `onSubmit` fires. Disable with `clearOnSubmit={false}` if you want to manage clearing yourself:
240
+ Stream AI-generated text into the editor while maintaining mention state. Set `streaming={true}` to enter streaming mode: the suggestion dropdown is suppressed, user keyboard/paste input is blocked, and `onChange` is throttled (~150 ms). Call `ref.current.setContent(accumulated)` on each chunk — completed mention tokens are parsed into chips automatically.
187
241
 
188
242
  ```tsx
243
+ const ref = useRef<MentionsInputHandle>(null);
244
+ const [isStreaming, setIsStreaming] = useState(false);
245
+
246
+ async function enhancePrompt() {
247
+ setIsStreaming(true);
248
+ let accumulated = "";
249
+
250
+ const stream = await fetchEnhancedPrompt(currentPrompt);
251
+ for await (const chunk of stream) {
252
+ accumulated += chunk;
253
+ ref.current?.setContent(accumulated);
254
+ }
255
+
256
+ setIsStreaming(false);
257
+ }
258
+
189
259
  <MentionsInput
190
- onSubmit={(output) => sendMessage(output)}
191
- clearOnSubmit={false} // don't auto-clear
260
+ ref={ref}
261
+ streaming={isStreaming}
262
+ providers={providers}
263
+ onChange={handleChange}
264
+ onStreamingComplete={(output) => {
265
+ console.log("Final tokens:", output.tokens);
266
+ }}
192
267
  />
193
268
  ```
194
269
 
195
- ## Nested mentions
196
-
197
- The killer feature. When a `MentionItem` has `hasChildren: true`, selecting it drills into the next level using `provider.getChildren()`:
270
+ **How it works:**
198
271
 
199
- ```
200
- @workspace choose workspace choose file inside workspace
201
- @contract → choose contract → choose clause
202
- :web → choose provider → type query
203
- ```
272
+ 1. Set `streaming={true}` — the editor enters streaming mode
273
+ 2. On each chunk, accumulate the full text and call `ref.current.setContent(accumulated)`
274
+ 3. Incomplete mention tokens (e.g. `@[NDA`) render as plain text until the full `@[label](id)` syntax is received, then snap into mention chips
275
+ 4. Set `streaming={false}` the editor exits streaming mode, fires a final `onChange` and `onStreamingComplete`
204
276
 
205
- When inside a nested level, a search input appears in the dropdown. Type to filter children in real time. Press `Backspace` on an empty search input to go back one level.
277
+ For plain-text-only streaming (no mention syntax in chunks), use `ref.current.appendText(chunk)` instead for better performance.
206
278
 
207
- Keyboard navigation:
279
+ ## Keyboard shortcuts
208
280
 
209
281
  | Key | Context | Action |
210
282
  |-----|---------|--------|
211
283
  | `↑` `↓` | Suggestions open | Navigate suggestions |
212
284
  | `Enter` | Suggestions open | Select / drill into children |
285
+ | `Tab` | Suggestions open | Select the active suggestion |
213
286
  | `→` | Suggestions open | Drill into children (if item has children) |
214
287
  | `←` | Nested level | Go back one level |
215
288
  | `Backspace` | Nested level, empty search | Go back one level |
216
289
  | `Escape` | Suggestions open | Close suggestions |
217
- | `Enter` | Editor (no suggestions) | Submit message |
218
- | `Shift+Enter` | Editor | New line |
290
+ | `Enter` | Editor (default `submitKey`) | Submit message |
291
+ | `Shift+Enter` | Editor (default `submitKey`) | New line |
219
292
  | `Cmd/Ctrl+Enter` | Editor | Submit message |
220
293
 
221
294
  ## Markdown format
@@ -224,6 +297,7 @@ Mentions serialize to a compact token syntax:
224
297
 
225
298
  ```
226
299
  @[Marketing Workspace](ws_123) summarize the latest files
300
+ @Marketing[Q4 Strategy.pdf](file:file_1) review this document
227
301
  ```
228
302
 
229
303
  Use the standalone helpers for server-side processing:
@@ -235,38 +309,38 @@ import {
235
309
  extractFromMarkdown,
236
310
  } from "@relevaince/mentions";
237
311
 
238
- // Parse markdown into a Tiptap-compatible JSON document
239
312
  const doc = parseFromMarkdown("Check @[NDA](contract:c_44) for risks");
240
-
241
- // Serialize a Tiptap JSON document back to markdown
242
313
  const md = serializeToMarkdown(doc);
243
-
244
- // Extract tokens and plain text directly from a markdown string
245
314
  const { tokens, plainText } = extractFromMarkdown(
246
315
  "Summarize @Marketing[Q4 Strategy.pdf](file:file_1) for the team"
247
316
  );
248
- // tokens → [{ id: "file_1", type: "file", label: "Q4 Strategy.pdf" }]
249
- // plainText → "Summarize Q4 Strategy.pdf for the team"
250
317
  ```
251
318
 
252
319
  ## Styling
253
320
 
254
- Zero bundled CSS. Every element exposes `data-*` attributes for styling:
321
+ Zero bundled CSS. Every element exposes `data-*` attributes:
255
322
 
256
323
  ```css
257
- /* Mention chips inside the editor */
258
- [data-mention] { /* base chip */ }
259
- [data-mention][data-type="workspace"] { /* workspace chip */ }
260
- [data-mention][data-type="contract"] { /* contract chip */ }
324
+ /* Mention chips */
325
+ [data-mention] { /* base chip */ }
326
+ [data-mention][data-type="workspace"] { /* workspace chip */ }
327
+ [data-mention][data-type="contract"] { /* contract chip */ }
328
+ [data-mention-clickable] { /* clickable chip (when onMentionClick set) */ }
329
+ [data-mention-invalid] { /* invalid/stale mention */ }
330
+ [data-mention-tooltip] { /* hover tooltip container */ }
261
331
 
262
332
  /* Suggestion popover */
263
- [data-suggestions] { /* popover wrapper */ }
264
- [data-suggestion-item] { /* each item */ }
265
- [data-suggestion-item-active] { /* highlighted item */ }
266
- [data-suggestion-breadcrumb] { /* breadcrumb bar (nested) */ }
267
- [data-suggestion-search] { /* search input wrapper (nested) */ }
268
- [data-suggestion-search-input] { /* search input field */ }
269
- [data-suggestion-loading] { /* loading indicator */ }
333
+ [data-suggestions] { /* popover wrapper */ }
334
+ [data-suggestions-position="above"] { /* when popover flips above */ }
335
+ [data-suggestions-position="below"] { /* when popover is below */ }
336
+ [data-suggestion-item] { /* each item */ }
337
+ [data-suggestion-item-active] { /* highlighted item */ }
338
+ [data-suggestion-group-header] { /* group section header */ }
339
+ [data-suggestion-empty] { /* "no results" state */ }
340
+ [data-suggestion-loading] { /* loading indicator */ }
341
+ [data-suggestion-breadcrumb] { /* breadcrumb bar (nested) */ }
342
+ [data-suggestion-search] { /* search input wrapper */ }
343
+ [data-suggestion-search-input] { /* search input field */ }
270
344
  ```
271
345
 
272
346
  Example with Tailwind:
@@ -288,6 +362,18 @@ Example with Tailwind:
288
362
  [data-suggestion-item-active] {
289
363
  @apply bg-neutral-100;
290
364
  }
365
+
366
+ [data-suggestion-group-header] {
367
+ @apply px-3 py-1 text-[10px] font-semibold text-neutral-400 uppercase tracking-wider;
368
+ }
369
+
370
+ [data-suggestion-empty] {
371
+ @apply px-3 py-2 text-xs text-neutral-400 italic;
372
+ }
373
+
374
+ [data-mention-invalid] {
375
+ @apply opacity-50 line-through;
376
+ }
291
377
  ```
292
378
 
293
379
  ## Architecture
@@ -295,30 +381,31 @@ Example with Tailwind:
295
381
  ```
296
382
  <MentionsInput>
297
383
  ├── Tiptap Editor (ProseMirror)
298
- │ ├── MentionNode Extension — inline atom nodes with id/label/type
299
- │ ├── Suggestion Plugin — multi-trigger detection + popover callbacks
300
- │ ├── Enter/Submit Extension — Enter submits, Shift+Enter new line
384
+ │ ├── MentionNode Extension — inline atom nodes with id/label/type + click/hover
385
+ │ ├── Suggestion Plugin — multi-trigger detection + allowTrigger gating
386
+ │ ├── Enter/Submit Extension — configurable submit key (Enter/Mod+Enter/none)
387
+ │ ├── Mention Remove Detector — transaction-based onMentionRemove
301
388
  │ └── Markdown Parser/Serializer — doc ↔ @[label](id) + extractFromMarkdown
302
- └── SuggestionList (React) — headless popover with ARIA + nested search
389
+ └── SuggestionList (React) — headless popover with ARIA, groups, edge-aware positioning
303
390
  ```
304
391
 
305
- Consumers interact with a single React component. Tiptap and ProseMirror are internal implementation details.
392
+ ## Testing
393
+
394
+ ```bash
395
+ npm test # run all tests
396
+ npm run test:watch # watch mode
397
+ ```
306
398
 
307
399
  ## Development
308
400
 
309
401
  ```bash
310
- # Install dependencies
311
- npm install
312
-
313
- # Build the library
314
- npm run build
402
+ npm install # install dependencies
403
+ npm run build # build the library
404
+ npm test # run tests
315
405
 
316
- # Run the demo app
317
- cd demo && npm install && npx vite
406
+ cd demo && npm install && npx vite # run the demo app
318
407
  ```
319
408
 
320
- The demo app registers mock providers for workspaces, contracts, and web search. It displays live structured output below the editor.
321
-
322
409
  ## License
323
410
 
324
411
  MIT
package/dist/index.d.mts CHANGED
@@ -25,6 +25,8 @@ type MentionItem = {
25
25
  * item originates from a nested level.
26
26
  */
27
27
  rootLabel?: string;
28
+ /** Optional group name for sectioned suggestion lists. Items with the same group are grouped together. */
29
+ group?: string;
28
30
  };
29
31
  /**
30
32
  * Hierarchical suggestion source registered per trigger character.
@@ -48,6 +50,10 @@ type MentionProvider = {
48
50
  * results from `searchAll` are shown instead of `getRootItems`.
49
51
  */
50
52
  searchAll?: (query: string) => Promise<MentionItem[]>;
53
+ /** Debounce delay in ms before fetching suggestions. `0` or `undefined` means no debounce. */
54
+ debounceMs?: number;
55
+ /** Fetch recently used items, shown when the query is empty. Items are auto-grouped under "Recent". */
56
+ getRecentItems?: () => Promise<MentionItem[]>;
51
57
  };
52
58
 
53
59
  /**
@@ -112,6 +118,49 @@ type MentionsInputProps = {
112
118
  renderItem?: (item: MentionItem, depth: number) => ReactNode;
113
119
  /** Custom renderer for inline mention chips. */
114
120
  renderChip?: (token: MentionToken) => ReactNode;
121
+ /** Custom renderer for empty suggestion state. Receives the current query. */
122
+ renderEmpty?: (query: string) => ReactNode;
123
+ /** Custom renderer for the suggestion loading indicator. */
124
+ renderLoading?: () => ReactNode;
125
+ /** Custom renderer for suggestion group section headers. */
126
+ renderGroupHeader?: (group: string) => ReactNode;
127
+ /** Called when the editor gains focus. */
128
+ onFocus?: () => void;
129
+ /** Called when the editor loses focus. */
130
+ onBlur?: () => void;
131
+ /** Called when a mention token is inserted. */
132
+ onMentionAdd?: (token: MentionToken) => void;
133
+ /** Called when a mention token is removed. */
134
+ onMentionRemove?: (token: MentionToken) => void;
135
+ /** Called when a mention chip is clicked. */
136
+ onMentionClick?: (token: MentionToken, event: MouseEvent) => void;
137
+ /** Return a ReactNode to show as a tooltip when hovering over a mention chip. */
138
+ onMentionHover?: (token: MentionToken) => ReactNode;
139
+ /** Minimum height of the editor in pixels. */
140
+ minHeight?: number;
141
+ /** Maximum height of the editor in pixels. Enables scrolling. */
142
+ maxHeight?: number;
143
+ /** Which key combo triggers `onSubmit`. Defaults to `"enter"`. */
144
+ submitKey?: "enter" | "mod+enter" | "none";
145
+ /** Conditionally suppress the suggestion dropdown. Return `false` to block. */
146
+ allowTrigger?: (trigger: string, context: {
147
+ textBefore: string;
148
+ }) => boolean;
149
+ /** Validate existing mentions. Return `false` to mark as invalid. */
150
+ validateMention?: (token: MentionToken) => boolean | Promise<boolean>;
151
+ /** DOM element to portal the suggestion dropdown into. */
152
+ portalContainer?: HTMLElement;
153
+ /**
154
+ * Signals the editor is receiving streamed content (e.g. from an AI).
155
+ * When `true`: suggestion triggers are suppressed, user keyboard/paste
156
+ * input is blocked, and `onChange` is throttled (~150 ms).
157
+ */
158
+ streaming?: boolean;
159
+ /**
160
+ * Fires once when `streaming` transitions from `true` to `false`
161
+ * with the final structured output.
162
+ */
163
+ onStreamingComplete?: (output: MentionsOutput) => void;
115
164
  };
116
165
  /**
117
166
  * Imperative handle exposed via `ref` on `<MentionsInput>`.
@@ -123,15 +172,20 @@ type MentionsInputProps = {
123
172
  * ref.current.clear();
124
173
  * ref.current.setContent("Hello @[Marketing](ws_123)");
125
174
  * ref.current.focus();
175
+ * ref.current.getOutput(); // { markdown, tokens, plainText }
126
176
  * ```
127
177
  */
128
178
  type MentionsInputHandle = {
129
179
  /** Clear all editor content. */
130
180
  clear: () => void;
131
- /** Replace editor content with a markdown string. */
181
+ /** Replace editor content with a markdown string. Mention tokens are parsed. */
132
182
  setContent: (markdown: string) => void;
183
+ /** Append plain text at the end of the document (no mention parsing). */
184
+ appendText: (text: string) => void;
133
185
  /** Focus the editor. */
134
186
  focus: () => void;
187
+ /** Read the current structured output without waiting for onChange. */
188
+ getOutput: () => MentionsOutput | null;
135
189
  };
136
190
 
137
191
  /**
@@ -140,14 +194,6 @@ type MentionsInputHandle = {
140
194
  * A structured text editor with typed entity tokens.
141
195
  * Consumers register `providers` for each trigger character,
142
196
  * and receive structured output via `onChange` and `onSubmit`.
143
- *
144
- * Supports an imperative ref handle for programmatic control:
145
- * ```tsx
146
- * const ref = useRef<MentionsInputHandle>(null);
147
- * ref.current.clear();
148
- * ref.current.setContent("@[Marketing](ws_123) summarize");
149
- * ref.current.focus();
150
- * ```
151
197
  */
152
198
  declare const MentionsInput: React.ForwardRefExoticComponent<MentionsInputProps & React.RefAttributes<MentionsInputHandle>>;
153
199
 
package/dist/index.d.ts CHANGED
@@ -25,6 +25,8 @@ type MentionItem = {
25
25
  * item originates from a nested level.
26
26
  */
27
27
  rootLabel?: string;
28
+ /** Optional group name for sectioned suggestion lists. Items with the same group are grouped together. */
29
+ group?: string;
28
30
  };
29
31
  /**
30
32
  * Hierarchical suggestion source registered per trigger character.
@@ -48,6 +50,10 @@ type MentionProvider = {
48
50
  * results from `searchAll` are shown instead of `getRootItems`.
49
51
  */
50
52
  searchAll?: (query: string) => Promise<MentionItem[]>;
53
+ /** Debounce delay in ms before fetching suggestions. `0` or `undefined` means no debounce. */
54
+ debounceMs?: number;
55
+ /** Fetch recently used items, shown when the query is empty. Items are auto-grouped under "Recent". */
56
+ getRecentItems?: () => Promise<MentionItem[]>;
51
57
  };
52
58
 
53
59
  /**
@@ -112,6 +118,49 @@ type MentionsInputProps = {
112
118
  renderItem?: (item: MentionItem, depth: number) => ReactNode;
113
119
  /** Custom renderer for inline mention chips. */
114
120
  renderChip?: (token: MentionToken) => ReactNode;
121
+ /** Custom renderer for empty suggestion state. Receives the current query. */
122
+ renderEmpty?: (query: string) => ReactNode;
123
+ /** Custom renderer for the suggestion loading indicator. */
124
+ renderLoading?: () => ReactNode;
125
+ /** Custom renderer for suggestion group section headers. */
126
+ renderGroupHeader?: (group: string) => ReactNode;
127
+ /** Called when the editor gains focus. */
128
+ onFocus?: () => void;
129
+ /** Called when the editor loses focus. */
130
+ onBlur?: () => void;
131
+ /** Called when a mention token is inserted. */
132
+ onMentionAdd?: (token: MentionToken) => void;
133
+ /** Called when a mention token is removed. */
134
+ onMentionRemove?: (token: MentionToken) => void;
135
+ /** Called when a mention chip is clicked. */
136
+ onMentionClick?: (token: MentionToken, event: MouseEvent) => void;
137
+ /** Return a ReactNode to show as a tooltip when hovering over a mention chip. */
138
+ onMentionHover?: (token: MentionToken) => ReactNode;
139
+ /** Minimum height of the editor in pixels. */
140
+ minHeight?: number;
141
+ /** Maximum height of the editor in pixels. Enables scrolling. */
142
+ maxHeight?: number;
143
+ /** Which key combo triggers `onSubmit`. Defaults to `"enter"`. */
144
+ submitKey?: "enter" | "mod+enter" | "none";
145
+ /** Conditionally suppress the suggestion dropdown. Return `false` to block. */
146
+ allowTrigger?: (trigger: string, context: {
147
+ textBefore: string;
148
+ }) => boolean;
149
+ /** Validate existing mentions. Return `false` to mark as invalid. */
150
+ validateMention?: (token: MentionToken) => boolean | Promise<boolean>;
151
+ /** DOM element to portal the suggestion dropdown into. */
152
+ portalContainer?: HTMLElement;
153
+ /**
154
+ * Signals the editor is receiving streamed content (e.g. from an AI).
155
+ * When `true`: suggestion triggers are suppressed, user keyboard/paste
156
+ * input is blocked, and `onChange` is throttled (~150 ms).
157
+ */
158
+ streaming?: boolean;
159
+ /**
160
+ * Fires once when `streaming` transitions from `true` to `false`
161
+ * with the final structured output.
162
+ */
163
+ onStreamingComplete?: (output: MentionsOutput) => void;
115
164
  };
116
165
  /**
117
166
  * Imperative handle exposed via `ref` on `<MentionsInput>`.
@@ -123,15 +172,20 @@ type MentionsInputProps = {
123
172
  * ref.current.clear();
124
173
  * ref.current.setContent("Hello @[Marketing](ws_123)");
125
174
  * ref.current.focus();
175
+ * ref.current.getOutput(); // { markdown, tokens, plainText }
126
176
  * ```
127
177
  */
128
178
  type MentionsInputHandle = {
129
179
  /** Clear all editor content. */
130
180
  clear: () => void;
131
- /** Replace editor content with a markdown string. */
181
+ /** Replace editor content with a markdown string. Mention tokens are parsed. */
132
182
  setContent: (markdown: string) => void;
183
+ /** Append plain text at the end of the document (no mention parsing). */
184
+ appendText: (text: string) => void;
133
185
  /** Focus the editor. */
134
186
  focus: () => void;
187
+ /** Read the current structured output without waiting for onChange. */
188
+ getOutput: () => MentionsOutput | null;
135
189
  };
136
190
 
137
191
  /**
@@ -140,14 +194,6 @@ type MentionsInputHandle = {
140
194
  * A structured text editor with typed entity tokens.
141
195
  * Consumers register `providers` for each trigger character,
142
196
  * and receive structured output via `onChange` and `onSubmit`.
143
- *
144
- * Supports an imperative ref handle for programmatic control:
145
- * ```tsx
146
- * const ref = useRef<MentionsInputHandle>(null);
147
- * ref.current.clear();
148
- * ref.current.setContent("@[Marketing](ws_123) summarize");
149
- * ref.current.focus();
150
- * ```
151
197
  */
152
198
  declare const MentionsInput: React.ForwardRefExoticComponent<MentionsInputProps & React.RefAttributes<MentionsInputHandle>>;
153
199