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 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
- | `renderMention?` | `(displayText: string) => ReactNode` | Custom mention highlight rendering |
65
- | `mentionClassName?` | `string` | CSS class for highlighted mentions in the overlay |
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 - display text
129
- // engine.mentions - active mentions with positions
130
- // engine.activeTrigger - currently active trigger (or null)
131
- // engine.filteredOptions - filtered suggestions
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
@@ -8,6 +8,7 @@ interface MentionDropdownProps {
8
8
  onSelect: (item: unknown) => void;
9
9
  onLoadMore?: () => void;
10
10
  loading: boolean;
11
+ loadingText?: string;
11
12
  position: CaretPosition;
12
13
  width: number;
13
14
  className?: string;
@@ -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
  }
@@ -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 classMap = new Map;
200
+ const triggerMap = new Map;
196
201
  for (const t of triggers) {
197
- classMap.set(t.trigger, t.mentionClassName ?? "mentionize-mention");
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 cls = classMap.get(m.trigger) ?? "mentionize-mention";
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 (!cache.has(parsed.key)) {
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=2D34F896A558FE4664756E2164756E21
938
+ //# debugId=B71F10C79A7CF6EF64756E2164756E21
881
939
  //# sourceMappingURL=index.js.map