mentionize 0.0.1 → 0.0.3
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 +117 -8
- package/dist/cjs/MentionDropdown.d.ts +1 -0
- package/dist/cjs/MentionHighlighter.d.ts +1 -0
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.js +66 -8
- package/dist/cjs/index.js.map +6 -6
- package/dist/cjs/types.d.ts +20 -4
- package/dist/cjs/useMentionEngine.d.ts +1 -0
- package/dist/esm/MentionDropdown.d.ts +1 -0
- package/dist/esm/MentionHighlighter.d.ts +1 -0
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +66 -8
- package/dist/esm/index.js.map +6 -6
- package/dist/esm/types.d.ts +20 -4
- package/dist/esm/useMentionEngine.d.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# Mentionize
|
|
2
2
|
|
|
3
|
-
A React library for building mention inputs with support for multiple triggers, async search, and full customization. It provides a transparent textarea overlaid on a highlighted div to display mentions, and a dropdown for suggestions. With zero dependencies.
|
|
3
|
+
A React library for building mention inputs with support for multiple triggers, async search, and full customization. It provides a transparent textarea overlaid on a highlighted div to display mentions, and a dropdown for suggestions. With zero dependencies other than React.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
+
npm install react
|
|
9
|
+
npm install react-dom
|
|
10
|
+
|
|
8
11
|
npm install mentionize
|
|
9
12
|
```
|
|
10
13
|
|
|
@@ -57,12 +60,14 @@ Defines how a trigger character activates suggestions and how mentions are seria
|
|
|
57
60
|
| `displayText` | `(item: T) => string` | Converts an item to its visible text |
|
|
58
61
|
| `serialize` | `(item: T) => string` | Converts an item to its serialized form in the raw value |
|
|
59
62
|
| `pattern` | `RegExp` | Regex to detect serialized mentions (must use global flag) |
|
|
60
|
-
| `parseMatch` | `(match: RegExpExecArray) => { displayText: string; key: string }` | Parses a regex match back into display text and key |
|
|
63
|
+
| `parseMatch` | `(match: RegExpExecArray) => { displayText: string; key: string; item?: T }` | Parses a regex match back into display text and key. Optionally returns `item` to seed the engine cache. |
|
|
61
64
|
| `options?` | `T[]` | Static options array (client-side filtering) |
|
|
62
65
|
| `onSearch?` | `(query: string, page: number) => Promise<{ items: T[]; hasMore: boolean }>` | Async search with pagination |
|
|
63
66
|
| `renderOption?` | `(item: T, highlighted: boolean) => ReactNode` | Custom option rendering |
|
|
64
|
-
| `
|
|
65
|
-
| `
|
|
67
|
+
| `optionClassName?` | `string \| ((item: T) => string)` | CSS class for dropdown options, or a function for conditional styling per item |
|
|
68
|
+
| `renderMention?` | `(displayText: string, item?: unknown) => ReactNode` | Custom mention highlight rendering |
|
|
69
|
+
| `mentionClassName?` | `string \| ((mention: MentionItemData) => string)` | CSS class for highlighted mentions, or a function for conditional styling |
|
|
70
|
+
| `onSelect?` | `(item: T) => Promise<string \| null> \| string \| null` | Action trigger: runs instead of inserting a mention. Returns text to insert or null to cancel. |
|
|
66
71
|
|
|
67
72
|
### `MentionInputProps`
|
|
68
73
|
|
|
@@ -81,7 +86,10 @@ Defines how a trigger character activates suggestions and how mentions are seria
|
|
|
81
86
|
| `highlighterClassName?` | `string` | Highlighter overlay className |
|
|
82
87
|
| `dropdownClassName?` | `string` | Dropdown className |
|
|
83
88
|
| `dropdownWidth?` | `number` | Dropdown width in pixels (default: 250) |
|
|
89
|
+
| `loadingText?` | `string` | Text shown while loading async results (default: `"Loading..."`) |
|
|
84
90
|
| `renderDropdown?` | `(props: DropdownRenderProps) => ReactNode` | Full custom dropdown rendering |
|
|
91
|
+
| `aria-label?` | `string` | Accessible label for the textarea |
|
|
92
|
+
| `aria-describedby?` | `string` | ID of an element describing the textarea |
|
|
85
93
|
|
|
86
94
|
## Multiple Triggers
|
|
87
95
|
|
|
@@ -112,6 +120,43 @@ const trigger: MentionTrigger<User> = {
|
|
|
112
120
|
};
|
|
113
121
|
```
|
|
114
122
|
|
|
123
|
+
## Cache Seeding via `parseMatch`
|
|
124
|
+
|
|
125
|
+
By default the engine only recognizes mentions whose items are already cached (from `options`, `onSearch` results, or previous selections). When a mention is injected externally — for example by a `/` command picker or when loading initial content containing mentions for items that haven't been searched yet — the cache may not contain the underlying item, so the mention won't be highlighted or serialized.
|
|
126
|
+
|
|
127
|
+
To solve this, `parseMatch` can optionally return an `item` field. When present, the engine seeds its internal cache with that item during raw-to-visible parsing, making the mention immediately detectable:
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
const modelTrigger: MentionTrigger<Model> = {
|
|
131
|
+
trigger: "@",
|
|
132
|
+
displayText: (model) => model.label,
|
|
133
|
+
serialize: (model) => `@[${model.label}](model:${model.id})`,
|
|
134
|
+
pattern: /@\[([^\]]+)\]\(model:([^)]+)\)/g,
|
|
135
|
+
parseMatch: (match) => {
|
|
136
|
+
const id = match[2]!;
|
|
137
|
+
const label = match[1]!;
|
|
138
|
+
// Look up the item from your own data source
|
|
139
|
+
const cached = myModelCache.get(id);
|
|
140
|
+
return {
|
|
141
|
+
displayText: label,
|
|
142
|
+
key: id,
|
|
143
|
+
item: cached, // if defined, seeds the engine cache
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
onSearch: async (query, page) => {
|
|
147
|
+
const res = await fetch(`/api/models?q=${query}&page=${page}`);
|
|
148
|
+
return res.json();
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
This is useful when:
|
|
154
|
+
- A command picker (e.g. `/` trigger with `onSelect`) injects a mention into the input
|
|
155
|
+
- The input is initialized with raw text containing mentions for items not in `options`
|
|
156
|
+
- Items are known at parse time but haven't been searched via `onSearch` yet
|
|
157
|
+
|
|
158
|
+
The `item` field is optional and fully backward-compatible — existing `parseMatch` implementations that only return `displayText` and `key` continue to work unchanged.
|
|
159
|
+
|
|
115
160
|
## Headless Usage
|
|
116
161
|
|
|
117
162
|
Use `useMentionEngine` directly for full control over rendering:
|
|
@@ -125,13 +170,14 @@ const engine = useMentionEngine({
|
|
|
125
170
|
onChange: setValue,
|
|
126
171
|
});
|
|
127
172
|
|
|
128
|
-
// engine.visible
|
|
129
|
-
// engine.mentions
|
|
130
|
-
// engine.activeTrigger
|
|
131
|
-
// engine.filteredOptions
|
|
173
|
+
// engine.visible - display text
|
|
174
|
+
// engine.mentions - active mentions with positions
|
|
175
|
+
// engine.activeTrigger - currently active trigger (or null)
|
|
176
|
+
// engine.filteredOptions - filtered suggestions
|
|
132
177
|
// engine.handleTextChange(text, caretPos)
|
|
133
178
|
// engine.handleKeyDown(event, textarea)
|
|
134
179
|
// engine.selectOption(item, textarea)
|
|
180
|
+
// engine.getItemForMention(triggerChar, key) - look up cached item for a mention
|
|
135
181
|
```
|
|
136
182
|
|
|
137
183
|
## Styling
|
|
@@ -148,6 +194,69 @@ Mentionize uses a transparent textarea overlaid on a highlighted div. Apply styl
|
|
|
148
194
|
/>
|
|
149
195
|
```
|
|
150
196
|
|
|
197
|
+
### Conditional Mention Styling
|
|
198
|
+
|
|
199
|
+
Use a function for `mentionClassName` to style mentions dynamically based on the underlying item data:
|
|
200
|
+
|
|
201
|
+
```tsx
|
|
202
|
+
import type { MentionItemData } from "mentionize";
|
|
203
|
+
|
|
204
|
+
const userTrigger: MentionTrigger<User> = {
|
|
205
|
+
trigger: "@",
|
|
206
|
+
mentionClassName: (mention: MentionItemData) => {
|
|
207
|
+
const user = mention.item as User;
|
|
208
|
+
switch (user?.role) {
|
|
209
|
+
case "Engineer": return "mention-engineer";
|
|
210
|
+
case "Designer": return "mention-designer";
|
|
211
|
+
case "PM": return "mention-pm";
|
|
212
|
+
default: return "mention-user";
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
// Apply the same conditional styling to dropdown options
|
|
216
|
+
optionClassName: (user) => {
|
|
217
|
+
switch (user.role) {
|
|
218
|
+
case "Engineer": return "mention-engineer";
|
|
219
|
+
case "Designer": return "mention-designer";
|
|
220
|
+
case "PM": return "mention-pm";
|
|
221
|
+
default: return "mention-user";
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
// ...other config
|
|
225
|
+
};
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The `MentionItemData` object contains `key`, `displayText`, `trigger`, and `item` (the original cached item). Use `optionClassName` (string or function receiving the item directly) to apply matching styles to dropdown options.
|
|
229
|
+
|
|
230
|
+
### Action Triggers
|
|
231
|
+
|
|
232
|
+
Use `onSelect` to create triggers that run an action instead of inserting a mention. The callback receives the selected item and returns a string to insert as plain text, or `null` to cancel:
|
|
233
|
+
|
|
234
|
+
```tsx
|
|
235
|
+
const commandTrigger: MentionTrigger<Command> = {
|
|
236
|
+
trigger: "/",
|
|
237
|
+
displayText: (cmd) => cmd.label,
|
|
238
|
+
// serialize/pattern/parseMatch still needed for the dropdown
|
|
239
|
+
serialize: (cmd) => `/[${cmd.label}](cmd:${cmd.id})`,
|
|
240
|
+
pattern: /\/\[([^\]]+)\]\(cmd:([^)]+)\)/g,
|
|
241
|
+
parseMatch: (match) => ({ displayText: match[1]!, key: match[2]! }),
|
|
242
|
+
options: [
|
|
243
|
+
{ id: "date", label: "Insert Date" },
|
|
244
|
+
{ id: "emoji", label: "Pick Emoji" },
|
|
245
|
+
],
|
|
246
|
+
onSelect: async (cmd) => {
|
|
247
|
+
if (cmd.id === "date") return new Date().toLocaleDateString();
|
|
248
|
+
if (cmd.id === "emoji") {
|
|
249
|
+
// simulate async work
|
|
250
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
251
|
+
return "🎉";
|
|
252
|
+
}
|
|
253
|
+
return null; // cancel — nothing inserted
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
When `onSelect` is defined, selecting an option calls the function instead of inserting a mention. The trigger text and query are replaced by the returned string.
|
|
259
|
+
|
|
151
260
|
Per-trigger mention highlights can be styled via `mentionClassName`:
|
|
152
261
|
|
|
153
262
|
```tsx
|
|
@@ -5,6 +5,7 @@ interface MentionHighlighterProps {
|
|
|
5
5
|
mentions: ActiveMention[];
|
|
6
6
|
triggers: MentionTrigger<any>[];
|
|
7
7
|
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
|
8
|
+
getItemForMention?: (triggerChar: string, key: string) => unknown;
|
|
8
9
|
className?: string;
|
|
9
10
|
style?: React.CSSProperties;
|
|
10
11
|
}
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -3,4 +3,4 @@ export { MentionHighlighter } from "./MentionHighlighter.tsx";
|
|
|
3
3
|
export { MentionDropdown } from "./MentionDropdown.tsx";
|
|
4
4
|
export { useMentionEngine } from "./useMentionEngine.ts";
|
|
5
5
|
export { useCaretPosition } from "./useCaretPosition.ts";
|
|
6
|
-
export type { MentionTrigger, MentionInputProps, ActiveMention, DropdownRenderProps, CaretPosition, } from "./types.ts";
|
|
6
|
+
export type { MentionTrigger, MentionItemData, MentionInputProps, ActiveMention, DropdownRenderProps, CaretPosition, } from "./types.ts";
|
package/dist/cjs/index.js
CHANGED
|
@@ -64,6 +64,7 @@ var MentionDropdown = ({
|
|
|
64
64
|
onSelect,
|
|
65
65
|
onLoadMore,
|
|
66
66
|
loading,
|
|
67
|
+
loadingText,
|
|
67
68
|
position,
|
|
68
69
|
width,
|
|
69
70
|
className
|
|
@@ -118,10 +119,12 @@ var MentionDropdown = ({
|
|
|
118
119
|
children: [
|
|
119
120
|
items.map((item, i) => {
|
|
120
121
|
const isHighlighted = i === highlightedIndex;
|
|
122
|
+
const optCls = typeof trigger.optionClassName === "function" ? trigger.optionClassName(item) : trigger.optionClassName;
|
|
121
123
|
if (trigger.renderOption) {
|
|
122
124
|
return /* @__PURE__ */ jsx_dev_runtime.jsxDEV("div", {
|
|
123
125
|
role: "option",
|
|
124
126
|
"aria-selected": isHighlighted,
|
|
127
|
+
className: optCls,
|
|
125
128
|
"data-mentionize-option-index": i,
|
|
126
129
|
"data-mentionize-option-highlighted": isHighlighted || undefined,
|
|
127
130
|
onMouseEnter: () => onHighlight(i),
|
|
@@ -132,6 +135,7 @@ var MentionDropdown = ({
|
|
|
132
135
|
return /* @__PURE__ */ jsx_dev_runtime.jsxDEV("div", {
|
|
133
136
|
role: "option",
|
|
134
137
|
"aria-selected": isHighlighted,
|
|
138
|
+
className: optCls,
|
|
135
139
|
"data-mentionize-option-index": i,
|
|
136
140
|
"data-mentionize-option": "",
|
|
137
141
|
"data-mentionize-option-highlighted": isHighlighted || undefined,
|
|
@@ -142,7 +146,7 @@ var MentionDropdown = ({
|
|
|
142
146
|
}),
|
|
143
147
|
loading && /* @__PURE__ */ jsx_dev_runtime.jsxDEV("div", {
|
|
144
148
|
"data-mentionize-loading": "",
|
|
145
|
-
children: "Loading..."
|
|
149
|
+
children: loadingText ?? "Loading..."
|
|
146
150
|
}, undefined, false, undefined, this),
|
|
147
151
|
onLoadMore && !loading && /* @__PURE__ */ jsx_dev_runtime.jsxDEV("div", {
|
|
148
152
|
ref: sentinelRef,
|
|
@@ -172,6 +176,7 @@ var MentionHighlighter = ({
|
|
|
172
176
|
mentions,
|
|
173
177
|
triggers,
|
|
174
178
|
textareaRef,
|
|
179
|
+
getItemForMention,
|
|
175
180
|
className,
|
|
176
181
|
style
|
|
177
182
|
}) => {
|
|
@@ -192,9 +197,9 @@ var MentionHighlighter = ({
|
|
|
192
197
|
const sorted = mentions.slice().sort((a, b) => a.start - b.start);
|
|
193
198
|
if (!sorted.length)
|
|
194
199
|
return textToNodes(visible);
|
|
195
|
-
const
|
|
200
|
+
const triggerMap = new Map;
|
|
196
201
|
for (const t of triggers) {
|
|
197
|
-
|
|
202
|
+
triggerMap.set(t.trigger, t);
|
|
198
203
|
}
|
|
199
204
|
const nodes = [];
|
|
200
205
|
let last = 0;
|
|
@@ -204,7 +209,21 @@ var MentionHighlighter = ({
|
|
|
204
209
|
nodes.push(...textToNodes(visible.slice(last, m.start)));
|
|
205
210
|
}
|
|
206
211
|
const mentionText = visible.slice(m.start, m.end);
|
|
207
|
-
const
|
|
212
|
+
const t = triggerMap.get(m.trigger);
|
|
213
|
+
let cls = "mentionize-mention";
|
|
214
|
+
if (t) {
|
|
215
|
+
if (typeof t.mentionClassName === "function") {
|
|
216
|
+
const item = getItemForMention?.(m.trigger, m.key) ?? null;
|
|
217
|
+
cls = t.mentionClassName({
|
|
218
|
+
key: m.key,
|
|
219
|
+
displayText: m.displayText,
|
|
220
|
+
trigger: m.trigger,
|
|
221
|
+
item
|
|
222
|
+
});
|
|
223
|
+
} else if (t.mentionClassName) {
|
|
224
|
+
cls = t.mentionClassName;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
208
227
|
nodes.push(import_react2.default.createElement("span", {
|
|
209
228
|
key: `mention-${i}`,
|
|
210
229
|
className: cls,
|
|
@@ -217,7 +236,7 @@ var MentionHighlighter = ({
|
|
|
217
236
|
nodes.push(...textToNodes(visible.slice(last)));
|
|
218
237
|
}
|
|
219
238
|
return nodes;
|
|
220
|
-
}, [visible, mentions, triggers]);
|
|
239
|
+
}, [visible, mentions, triggers, getItemForMention]);
|
|
221
240
|
import_react2.useLayoutEffect(() => {
|
|
222
241
|
const el = ref.current;
|
|
223
242
|
if (!el)
|
|
@@ -446,7 +465,11 @@ function useMentionEngine(options) {
|
|
|
446
465
|
parts.push(result.slice(lastIndex, m.index));
|
|
447
466
|
const parsed = t.parseMatch(m);
|
|
448
467
|
const cache = getCache(t.trigger);
|
|
449
|
-
if (
|
|
468
|
+
if (parsed.item !== undefined) {
|
|
469
|
+
if (!cache.has(parsed.key) || cache.get(parsed.key) === null) {
|
|
470
|
+
cache.set(parsed.key, parsed.item);
|
|
471
|
+
}
|
|
472
|
+
} else if (!cache.has(parsed.key)) {
|
|
450
473
|
cache.set(parsed.key, null);
|
|
451
474
|
}
|
|
452
475
|
parts.push(t.trigger + parsed.displayText);
|
|
@@ -618,10 +641,41 @@ function useMentionEngine(options) {
|
|
|
618
641
|
setActiveTrigger(null);
|
|
619
642
|
}
|
|
620
643
|
}, [detectActiveTrigger, emitSync]);
|
|
644
|
+
const getItemForMention = import_react4.useCallback((triggerChar, key) => {
|
|
645
|
+
const cache = getCache(triggerChar);
|
|
646
|
+
return cache.get(key) ?? null;
|
|
647
|
+
}, []);
|
|
621
648
|
const selectOption = import_react4.useCallback((item, textarea) => {
|
|
622
649
|
if (!activeTrigger)
|
|
623
650
|
return;
|
|
624
651
|
const t = activeTrigger.trigger;
|
|
652
|
+
if (t.onSelect) {
|
|
653
|
+
const before2 = visible.slice(0, activeTrigger.startPos);
|
|
654
|
+
const after2 = visible.slice(textarea.selectionStart);
|
|
655
|
+
const savedStartPos = activeTrigger.startPos;
|
|
656
|
+
setActiveTrigger(null);
|
|
657
|
+
const result = t.onSelect(item);
|
|
658
|
+
const applyResult = (value) => {
|
|
659
|
+
if (value !== null) {
|
|
660
|
+
const newVis2 = before2 + value + after2;
|
|
661
|
+
const pos2 = savedStartPos + value.length;
|
|
662
|
+
caretPosRef.current = pos2;
|
|
663
|
+
setVisible(newVis2);
|
|
664
|
+
emitSync(newVis2);
|
|
665
|
+
} else {
|
|
666
|
+
const newVis2 = before2 + after2;
|
|
667
|
+
caretPosRef.current = savedStartPos;
|
|
668
|
+
setVisible(newVis2);
|
|
669
|
+
emitSync(newVis2);
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
if (result instanceof Promise) {
|
|
673
|
+
result.then(applyResult);
|
|
674
|
+
} else {
|
|
675
|
+
applyResult(result);
|
|
676
|
+
}
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
625
679
|
const cache = getCache(t.trigger);
|
|
626
680
|
const key = getItemKey(t, item);
|
|
627
681
|
cache.set(key, item);
|
|
@@ -699,7 +753,8 @@ function useMentionEngine(options) {
|
|
|
699
753
|
loadMore,
|
|
700
754
|
rawToVisible,
|
|
701
755
|
visibleToRaw,
|
|
702
|
-
caretPosRef
|
|
756
|
+
caretPosRef,
|
|
757
|
+
getItemForMention
|
|
703
758
|
};
|
|
704
759
|
}
|
|
705
760
|
function getItemKey(trigger, item) {
|
|
@@ -739,6 +794,7 @@ var MentionInput = import_react5.forwardRef(({
|
|
|
739
794
|
highlighterClassName,
|
|
740
795
|
dropdownClassName,
|
|
741
796
|
dropdownWidth = 250,
|
|
797
|
+
loadingText,
|
|
742
798
|
renderDropdown,
|
|
743
799
|
"aria-label": ariaLabel,
|
|
744
800
|
"aria-describedby": ariaDescribedBy
|
|
@@ -811,6 +867,7 @@ var MentionInput = import_react5.forwardRef(({
|
|
|
811
867
|
mentions: engine.mentions,
|
|
812
868
|
triggers,
|
|
813
869
|
textareaRef,
|
|
870
|
+
getItemForMention: engine.getItemForMention,
|
|
814
871
|
className: highlighterClassName,
|
|
815
872
|
style: SHARED_STYLE
|
|
816
873
|
}, undefined, false, undefined, this),
|
|
@@ -868,6 +925,7 @@ var MentionInput = import_react5.forwardRef(({
|
|
|
868
925
|
onSelect: handleSelect,
|
|
869
926
|
onLoadMore: engine.searchHasMore ? engine.loadMore : undefined,
|
|
870
927
|
loading: engine.searchLoading,
|
|
928
|
+
loadingText,
|
|
871
929
|
position: dropdownPos,
|
|
872
930
|
width: dropdownWidth,
|
|
873
931
|
className: dropdownClassName
|
|
@@ -877,5 +935,5 @@ var MentionInput = import_react5.forwardRef(({
|
|
|
877
935
|
});
|
|
878
936
|
MentionInput.displayName = "MentionInput";
|
|
879
937
|
|
|
880
|
-
//# debugId=
|
|
938
|
+
//# debugId=B71F10C79A7CF6EF64756E2164756E21
|
|
881
939
|
//# sourceMappingURL=index.js.map
|