@relevaince/mentions 0.3.2 → 0.5.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/dist/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/components/MentionsInput.tsx
2
- import { forwardRef, useCallback as useCallback3, useImperativeHandle } from "react";
2
+ import { forwardRef, useCallback as useCallback3, useId, useImperativeHandle } from "react";
3
+ import { createPortal } from "react-dom";
3
4
  import { EditorContent } from "@tiptap/react";
4
5
 
5
6
  // src/hooks/useMentionsEditor.ts
@@ -25,6 +26,12 @@ var MentionNode = Node.create({
25
26
  atom: true,
26
27
  selectable: true,
27
28
  draggable: false,
29
+ addOptions() {
30
+ return {
31
+ onClickRef: void 0,
32
+ onHoverRef: void 0
33
+ };
34
+ },
28
35
  addAttributes() {
29
36
  return {
30
37
  id: {
@@ -57,11 +64,17 @@ var MentionNode = Node.create({
57
64
  const label = node.attrs.label;
58
65
  const prefix = DEFAULT_PREFIXES[entityType] ?? "@";
59
66
  const display = `${prefix}${label}`;
67
+ const hasClick = !!this.options.onClickRef?.current;
68
+ const extraAttrs = {};
69
+ if (hasClick) {
70
+ extraAttrs["data-mention-clickable"] = "";
71
+ }
60
72
  return [
61
73
  "span",
62
74
  mergeAttributes(HTMLAttributes, {
63
75
  "data-mention": "",
64
- class: "mention-chip"
76
+ class: "mention-chip",
77
+ ...extraAttrs
65
78
  }),
66
79
  display
67
80
  ];
@@ -72,6 +85,58 @@ var MentionNode = Node.create({
72
85
  const prefix = DEFAULT_PREFIXES[entityType] ?? "@";
73
86
  return `${prefix}${label}`;
74
87
  },
88
+ addNodeView() {
89
+ const options = this.options;
90
+ return ({ node, HTMLAttributes }) => {
91
+ const entityType = node.attrs.entityType;
92
+ const label = node.attrs.label;
93
+ const id = node.attrs.id;
94
+ const prefix = DEFAULT_PREFIXES[entityType] ?? "@";
95
+ const dom = document.createElement("span");
96
+ Object.entries(
97
+ mergeAttributes(HTMLAttributes, {
98
+ "data-mention": "",
99
+ "data-type": entityType,
100
+ "data-id": id,
101
+ class: "mention-chip"
102
+ })
103
+ ).forEach(([key, val]) => {
104
+ if (val != null && val !== false) dom.setAttribute(key, String(val));
105
+ });
106
+ dom.textContent = `${prefix}${label}`;
107
+ if (options.onClickRef?.current) {
108
+ dom.setAttribute("data-mention-clickable", "");
109
+ dom.style.cursor = "pointer";
110
+ }
111
+ dom.addEventListener("click", (event) => {
112
+ const handler = options.onClickRef?.current;
113
+ if (handler) {
114
+ event.preventDefault();
115
+ event.stopPropagation();
116
+ handler({ id, type: entityType, label }, event);
117
+ }
118
+ });
119
+ let tooltip = null;
120
+ dom.addEventListener("mouseenter", () => {
121
+ const hoverFn = options.onHoverRef?.current;
122
+ if (!hoverFn) return;
123
+ const content = hoverFn({ id, type: entityType, label });
124
+ if (!content) return;
125
+ tooltip = document.createElement("div");
126
+ tooltip.setAttribute("data-mention-tooltip", "");
127
+ tooltip.textContent = typeof content === "string" ? content : "";
128
+ dom.style.position = "relative";
129
+ dom.appendChild(tooltip);
130
+ });
131
+ dom.addEventListener("mouseleave", () => {
132
+ if (tooltip && tooltip.parentNode) {
133
+ tooltip.parentNode.removeChild(tooltip);
134
+ tooltip = null;
135
+ }
136
+ });
137
+ return { dom };
138
+ };
139
+ },
75
140
  addKeyboardShortcuts() {
76
141
  return {
77
142
  Backspace: () => this.editor.commands.command(({ tr, state }) => {
@@ -104,7 +169,13 @@ function detectTrigger(text, cursorPos, docStartPos, triggers) {
104
169
  if (before.substring(i, i + trigger.length) === trigger) {
105
170
  if (i === 0 || /\s/.test(before[i - 1])) {
106
171
  const query = before.slice(i + trigger.length);
107
- return { trigger, query, from: docStartPos + i, to: cursorPos };
172
+ return {
173
+ trigger,
174
+ query,
175
+ from: docStartPos + i,
176
+ to: cursorPos,
177
+ textBefore: before.slice(0, i)
178
+ };
108
179
  }
109
180
  }
110
181
  }
@@ -112,7 +183,7 @@ function detectTrigger(text, cursorPos, docStartPos, triggers) {
112
183
  return null;
113
184
  }
114
185
  var suggestionPluginKey = new PluginKey("mentionSuggestion");
115
- function createSuggestionExtension(triggers, callbacksRef) {
186
+ function createSuggestionExtension(triggers, callbacksRef, allowTriggerRef) {
116
187
  return Extension.create({
117
188
  name: "mentionSuggestion",
118
189
  priority: 200,
@@ -190,6 +261,20 @@ function createSuggestionExtension(triggers, callbacksRef) {
190
261
  const cursorPos = $pos.pos;
191
262
  const match = detectTrigger(blockText, cursorPos, blockStart, triggers);
192
263
  if (match) {
264
+ if (allowTriggerRef?.current) {
265
+ const allowed = allowTriggerRef.current(match.trigger, {
266
+ textBefore: match.textBefore
267
+ });
268
+ if (!allowed) {
269
+ if (active) {
270
+ active = false;
271
+ lastQuery = null;
272
+ lastTrigger = null;
273
+ callbacksRef.current.onExit();
274
+ }
275
+ return;
276
+ }
277
+ }
193
278
  const range = { from: match.from, to: match.to };
194
279
  const props = {
195
280
  query: match.query,
@@ -247,11 +332,12 @@ function serializeParagraph(node) {
247
332
  if (!node.content) return "";
248
333
  return node.content.map((child) => {
249
334
  if (child.type === "mention") {
250
- const { id, label, rootLabel } = child.attrs ?? {};
335
+ const { id, label, entityType, rootLabel } = child.attrs ?? {};
336
+ const idPart = entityType && entityType !== "unknown" ? `${entityType}:${id}` : id;
251
337
  if (rootLabel != null && rootLabel !== "") {
252
- return `@${rootLabel}[${label}](${id})`;
338
+ return `@${rootLabel}[${label}](${idPart})`;
253
339
  }
254
- return `@[${label}](${id})`;
340
+ return `@[${label}](${idPart})`;
255
341
  }
256
342
  return child.text ?? "";
257
343
  }).join("");
@@ -296,7 +382,7 @@ function extractParagraphText(node) {
296
382
  }
297
383
 
298
384
  // src/core/markdownParser.ts
299
- var MENTION_RE = /@(?:([^\[]+)\[)?\[([^\]]+)\]\((?:([^:)]+):)?([^)]+)\)/g;
385
+ var MENTION_RE = /@([^\[@]*)\[([^\]]+)\]\((?:([^:)]+):)?([^)]+)\)/g;
300
386
  function parseFromMarkdown(markdown) {
301
387
  const lines = markdown.split("\n");
302
388
  const content = lines.map((line) => {
@@ -312,7 +398,7 @@ function parseLine(line) {
312
398
  let match;
313
399
  while ((match = MENTION_RE.exec(line)) !== null) {
314
400
  const fullMatch = match[0];
315
- const rootLabel = match[1] ?? null;
401
+ const rootLabel = match[1] || null;
316
402
  const label = match[2];
317
403
  const entityType = match[3] ?? "unknown";
318
404
  const id = match[4];
@@ -358,27 +444,48 @@ function buildOutput(editor) {
358
444
  plainText: extractPlainText(json)
359
445
  };
360
446
  }
361
- function createSubmitExtension(onSubmitRef, clearOnSubmitRef) {
447
+ function collectMentionTokens(doc) {
448
+ const tokens = [];
449
+ function walk(node) {
450
+ if (node.type === "mention" && node.attrs) {
451
+ tokens.push({
452
+ id: node.attrs.id,
453
+ type: node.attrs.entityType ?? node.attrs.type,
454
+ label: node.attrs.label
455
+ });
456
+ }
457
+ if (node.content) {
458
+ for (const child of node.content) walk(child);
459
+ }
460
+ }
461
+ walk(doc);
462
+ return tokens;
463
+ }
464
+ function createSubmitExtension(onSubmitRef, clearOnSubmitRef, submitKeyRef) {
362
465
  return Extension2.create({
363
466
  name: "submitShortcut",
364
467
  priority: 150,
365
468
  addKeyboardShortcuts() {
366
469
  return {
367
470
  "Mod-Enter": () => {
368
- if (onSubmitRef.current) {
369
- onSubmitRef.current(buildOutput(this.editor));
370
- if (clearOnSubmitRef.current) {
371
- this.editor.commands.clearContent(true);
471
+ const key = submitKeyRef.current;
472
+ if (key === "mod+enter" || key === "enter") {
473
+ if (onSubmitRef.current) {
474
+ onSubmitRef.current(buildOutput(this.editor));
475
+ if (clearOnSubmitRef.current) {
476
+ this.editor.commands.clearContent(true);
477
+ }
372
478
  }
479
+ return true;
373
480
  }
374
- return true;
481
+ return false;
375
482
  }
376
483
  };
377
484
  }
378
485
  });
379
486
  }
380
487
  var enterSubmitPluginKey = new PluginKey2("enterSubmit");
381
- function createEnterExtension(onSubmitRef, clearOnSubmitRef) {
488
+ function createEnterExtension(onSubmitRef, clearOnSubmitRef, submitKeyRef) {
382
489
  return Extension2.create({
383
490
  name: "enterSubmit",
384
491
  priority: 150,
@@ -390,6 +497,11 @@ function createEnterExtension(onSubmitRef, clearOnSubmitRef) {
390
497
  props: {
391
498
  handleKeyDown(_view, event) {
392
499
  if (event.key !== "Enter") return false;
500
+ const key = submitKeyRef.current;
501
+ if (key === "none") return false;
502
+ if (key === "mod+enter") {
503
+ return false;
504
+ }
393
505
  if (event.shiftKey) {
394
506
  editor.commands.splitBlock();
395
507
  return true;
@@ -409,6 +521,33 @@ function createEnterExtension(onSubmitRef, clearOnSubmitRef) {
409
521
  }
410
522
  });
411
523
  }
524
+ var mentionRemovePluginKey = new PluginKey2("mentionRemove");
525
+ function createMentionRemoveExtension(onMentionRemoveRef) {
526
+ return Extension2.create({
527
+ name: "mentionRemoveDetector",
528
+ priority: 100,
529
+ addProseMirrorPlugins() {
530
+ return [
531
+ new Plugin2({
532
+ key: mentionRemovePluginKey,
533
+ appendTransaction(transactions, oldState, newState) {
534
+ if (!onMentionRemoveRef.current) return null;
535
+ const oldMentions = collectMentionTokens(oldState.doc.toJSON());
536
+ const newMentions = collectMentionTokens(newState.doc.toJSON());
537
+ if (oldMentions.length <= newMentions.length) return null;
538
+ const newIds = new Set(newMentions.map((m) => m.id));
539
+ for (const m of oldMentions) {
540
+ if (!newIds.has(m.id)) {
541
+ onMentionRemoveRef.current(m);
542
+ }
543
+ }
544
+ return null;
545
+ }
546
+ })
547
+ ];
548
+ }
549
+ });
550
+ }
412
551
  function useMentionsEditor({
413
552
  providers,
414
553
  value,
@@ -418,7 +557,15 @@ function useMentionsEditor({
418
557
  placeholder,
419
558
  autoFocus = false,
420
559
  editable = true,
421
- callbacksRef
560
+ callbacksRef,
561
+ onFocus,
562
+ onBlur,
563
+ submitKey = "enter",
564
+ onMentionRemove,
565
+ onMentionClick,
566
+ onMentionHover,
567
+ allowTrigger,
568
+ validateMention
422
569
  }) {
423
570
  const onChangeRef = useRef(onChange);
424
571
  onChangeRef.current = onChange;
@@ -426,6 +573,23 @@ function useMentionsEditor({
426
573
  onSubmitRef.current = onSubmit;
427
574
  const clearOnSubmitRef = useRef(clearOnSubmit);
428
575
  clearOnSubmitRef.current = clearOnSubmit;
576
+ const onFocusRef = useRef(onFocus);
577
+ onFocusRef.current = onFocus;
578
+ const onBlurRef = useRef(onBlur);
579
+ onBlurRef.current = onBlur;
580
+ const submitKeyRef = useRef(submitKey);
581
+ submitKeyRef.current = submitKey;
582
+ const onMentionRemoveRef = useRef(onMentionRemove);
583
+ onMentionRemoveRef.current = onMentionRemove;
584
+ const onMentionClickRef = useRef(onMentionClick);
585
+ onMentionClickRef.current = onMentionClick;
586
+ const onMentionHoverRef = useRef(onMentionHover);
587
+ onMentionHoverRef.current = onMentionHover;
588
+ const allowTriggerRef = useRef(allowTrigger);
589
+ allowTriggerRef.current = allowTrigger;
590
+ const validateMentionRef = useRef(validateMention);
591
+ validateMentionRef.current = validateMention;
592
+ const internalMarkdownRef = useRef(null);
429
593
  const initialContent = useMemo(() => {
430
594
  if (!value) return void 0;
431
595
  return parseFromMarkdown(value);
@@ -437,16 +601,27 @@ function useMentionsEditor({
437
601
  [triggersKey]
438
602
  );
439
603
  const suggestionExtension = useMemo(
440
- () => createSuggestionExtension(triggers, callbacksRef),
604
+ () => createSuggestionExtension(triggers, callbacksRef, allowTriggerRef),
441
605
  // eslint-disable-next-line react-hooks/exhaustive-deps
442
606
  [triggersKey]
443
607
  );
444
608
  const submitExt = useMemo(
445
- () => createSubmitExtension(onSubmitRef, clearOnSubmitRef),
609
+ () => createSubmitExtension(onSubmitRef, clearOnSubmitRef, submitKeyRef),
446
610
  []
447
611
  );
448
612
  const enterExt = useMemo(
449
- () => createEnterExtension(onSubmitRef, clearOnSubmitRef),
613
+ () => createEnterExtension(onSubmitRef, clearOnSubmitRef, submitKeyRef),
614
+ []
615
+ );
616
+ const mentionRemoveExt = useMemo(
617
+ () => createMentionRemoveExtension(onMentionRemoveRef),
618
+ []
619
+ );
620
+ const mentionNodeExt = useMemo(
621
+ () => MentionNode.configure({
622
+ onClickRef: onMentionClickRef,
623
+ onHoverRef: onMentionHoverRef
624
+ }),
450
625
  []
451
626
  );
452
627
  const editor = useEditor({
@@ -465,10 +640,11 @@ function useMentionsEditor({
465
640
  placeholder: ({ editor: editor2 }) => editor2.isEmpty ? placeholder ?? "Type a message..." : "",
466
641
  showOnlyCurrent: true
467
642
  }),
468
- MentionNode,
643
+ mentionNodeExt,
469
644
  suggestionExtension,
470
645
  submitExt,
471
- enterExt
646
+ enterExt,
647
+ mentionRemoveExt
472
648
  ],
473
649
  content: initialContent,
474
650
  autofocus: autoFocus ? "end" : false,
@@ -479,7 +655,15 @@ function useMentionsEditor({
479
655
  }
480
656
  },
481
657
  onUpdate: ({ editor: editor2 }) => {
482
- onChangeRef.current?.(buildOutput(editor2));
658
+ const output = buildOutput(editor2);
659
+ internalMarkdownRef.current = output.markdown;
660
+ onChangeRef.current?.(output);
661
+ },
662
+ onFocus: () => {
663
+ onFocusRef.current?.();
664
+ },
665
+ onBlur: () => {
666
+ onBlurRef.current?.();
483
667
  }
484
668
  });
485
669
  useEffect(() => {
@@ -487,6 +671,36 @@ function useMentionsEditor({
487
671
  editor.setEditable(editable);
488
672
  }
489
673
  }, [editor, editable]);
674
+ useEffect(() => {
675
+ if (!editor || value === void 0) return;
676
+ if (value === internalMarkdownRef.current) return;
677
+ const doc = parseFromMarkdown(value);
678
+ editor.commands.setContent(doc);
679
+ internalMarkdownRef.current = value;
680
+ }, [editor, value]);
681
+ useEffect(() => {
682
+ if (!editor || !validateMention) return;
683
+ const runValidation = async () => {
684
+ const doc = editor.getJSON();
685
+ const tokens = collectMentionTokens(doc);
686
+ const invalidIds = /* @__PURE__ */ new Set();
687
+ await Promise.all(
688
+ tokens.map(async (token) => {
689
+ const valid = await validateMention(token);
690
+ if (!valid) invalidIds.add(token.id);
691
+ })
692
+ );
693
+ editor.view.dom.querySelectorAll("[data-mention]").forEach((el) => {
694
+ const id = el.getAttribute("data-id");
695
+ if (id && invalidIds.has(id)) {
696
+ el.setAttribute("data-mention-invalid", "");
697
+ } else {
698
+ el.removeAttribute("data-mention-invalid");
699
+ }
700
+ });
701
+ };
702
+ runValidation();
703
+ }, [editor, validateMention]);
490
704
  const clear = useCallback(() => {
491
705
  editor?.commands.clearContent(true);
492
706
  }, [editor]);
@@ -495,6 +709,7 @@ function useMentionsEditor({
495
709
  if (!editor) return;
496
710
  const doc = parseFromMarkdown(markdown);
497
711
  editor.commands.setContent(doc);
712
+ internalMarkdownRef.current = markdown;
498
713
  },
499
714
  [editor]
500
715
  );
@@ -509,7 +724,28 @@ function useMentionsEditor({
509
724
  }
510
725
 
511
726
  // src/hooks/useSuggestion.ts
512
- import { useCallback as useCallback2, useRef as useRef2, useState } from "react";
727
+ import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2, useState } from "react";
728
+
729
+ // src/utils/debounce.ts
730
+ function debounce(fn, ms) {
731
+ let timer = null;
732
+ const debounced = ((...args) => {
733
+ if (timer != null) clearTimeout(timer);
734
+ timer = setTimeout(() => {
735
+ timer = null;
736
+ fn(...args);
737
+ }, ms);
738
+ });
739
+ debounced.cancel = () => {
740
+ if (timer != null) {
741
+ clearTimeout(timer);
742
+ timer = null;
743
+ }
744
+ };
745
+ return debounced;
746
+ }
747
+
748
+ // src/hooks/useSuggestion.ts
513
749
  var IDLE_STATE = {
514
750
  state: "idle",
515
751
  items: [],
@@ -520,16 +756,24 @@ var IDLE_STATE = {
520
756
  trigger: null,
521
757
  query: ""
522
758
  };
523
- function useSuggestion(providers) {
759
+ function useSuggestion(providers, options = {}) {
524
760
  const [uiState, setUIState] = useState(IDLE_STATE);
525
761
  const stateRef = useRef2(uiState);
526
762
  stateRef.current = uiState;
527
763
  const providersRef = useRef2(providers);
528
764
  providersRef.current = providers;
765
+ const onMentionAddRef = useRef2(options.onMentionAdd);
766
+ onMentionAddRef.current = options.onMentionAdd;
529
767
  const commandRef = useRef2(
530
768
  null
531
769
  );
532
770
  const providerRef = useRef2(null);
771
+ const debouncedFetchRef = useRef2(null);
772
+ useEffect2(() => {
773
+ return () => {
774
+ debouncedFetchRef.current?.cancel();
775
+ };
776
+ }, []);
533
777
  const fetchItems = useCallback2(
534
778
  async (provider, query, parent, useSearchAll) => {
535
779
  setUIState((prev) => ({ ...prev, loading: true, state: "loading" }));
@@ -560,6 +804,23 @@ function useSuggestion(providers) {
560
804
  },
561
805
  []
562
806
  );
807
+ const scheduleFetch = useCallback2(
808
+ (provider, query, parent, useSearchAll) => {
809
+ debouncedFetchRef.current?.cancel();
810
+ const ms = provider.debounceMs;
811
+ if (ms && ms > 0) {
812
+ setUIState((prev) => ({ ...prev, loading: true }));
813
+ const debouncedFn = debounce(() => {
814
+ fetchItems(provider, query, parent, useSearchAll);
815
+ }, ms);
816
+ debouncedFetchRef.current = debouncedFn;
817
+ debouncedFn();
818
+ } else {
819
+ fetchItems(provider, query, parent, useSearchAll);
820
+ }
821
+ },
822
+ [fetchItems]
823
+ );
563
824
  const onStart = useCallback2(
564
825
  (props) => {
565
826
  const provider = providersRef.current.find(
@@ -578,13 +839,31 @@ function useSuggestion(providers) {
578
839
  trigger: props.trigger,
579
840
  query: props.query
580
841
  });
842
+ if (!props.query.trim() && provider.getRecentItems) {
843
+ provider.getRecentItems().then((recentItems) => {
844
+ const tagged = recentItems.map((item) => ({
845
+ ...item,
846
+ group: item.group ?? "Recent"
847
+ }));
848
+ setUIState((prev) => ({
849
+ ...prev,
850
+ items: tagged,
851
+ loading: false,
852
+ state: "showing",
853
+ activeIndex: 0
854
+ }));
855
+ }).catch(() => {
856
+ scheduleFetch(provider, props.query);
857
+ });
858
+ return;
859
+ }
581
860
  if (props.query.trim() && provider.searchAll) {
582
- fetchItems(provider, props.query, void 0, true);
861
+ scheduleFetch(provider, props.query, void 0, true);
583
862
  } else {
584
- fetchItems(provider, props.query);
863
+ scheduleFetch(provider, props.query);
585
864
  }
586
865
  },
587
- [fetchItems]
866
+ [scheduleFetch]
588
867
  );
589
868
  const onUpdate = useCallback2(
590
869
  (props) => {
@@ -608,14 +887,31 @@ function useSuggestion(providers) {
608
887
  }));
609
888
  }
610
889
  if (props.query.trim() && provider.searchAll) {
611
- fetchItems(provider, props.query, void 0, true);
890
+ scheduleFetch(provider, props.query, void 0, true);
891
+ } else if (!props.query.trim() && provider.getRecentItems) {
892
+ provider.getRecentItems().then((recentItems) => {
893
+ const tagged = recentItems.map((item) => ({
894
+ ...item,
895
+ group: item.group ?? "Recent"
896
+ }));
897
+ setUIState((prev) => ({
898
+ ...prev,
899
+ items: tagged,
900
+ loading: false,
901
+ state: "showing",
902
+ activeIndex: 0
903
+ }));
904
+ }).catch(() => {
905
+ scheduleFetch(provider, props.query);
906
+ });
612
907
  } else {
613
- fetchItems(provider, props.query);
908
+ scheduleFetch(provider, props.query);
614
909
  }
615
910
  },
616
- [fetchItems]
911
+ [scheduleFetch]
617
912
  );
618
913
  const onExit = useCallback2(() => {
914
+ debouncedFetchRef.current?.cancel();
619
915
  providerRef.current = null;
620
916
  commandRef.current = null;
621
917
  setUIState(IDLE_STATE);
@@ -650,8 +946,8 @@ function useSuggestion(providers) {
650
946
  fetchItems(provider, "", selected);
651
947
  return;
652
948
  }
949
+ const rootLabel = current.breadcrumbs.length > 0 ? current.breadcrumbs[0].label : selected.rootLabel ?? null;
653
950
  if (commandRef.current) {
654
- const rootLabel = current.breadcrumbs.length > 0 ? current.breadcrumbs[0].label : selected.rootLabel ?? null;
655
951
  commandRef.current({
656
952
  id: selected.id,
657
953
  label: selected.label,
@@ -659,6 +955,11 @@ function useSuggestion(providers) {
659
955
  rootLabel
660
956
  });
661
957
  }
958
+ onMentionAddRef.current?.({
959
+ id: selected.id,
960
+ type: selected.type,
961
+ label: selected.label
962
+ });
662
963
  },
663
964
  [fetchItems]
664
965
  );
@@ -683,6 +984,7 @@ function useSuggestion(providers) {
683
984
  });
684
985
  }, [fetchItems]);
685
986
  const close = useCallback2(() => {
987
+ debouncedFetchRef.current?.cancel();
686
988
  setUIState(IDLE_STATE);
687
989
  }, []);
688
990
  const searchNested = useCallback2(
@@ -692,10 +994,10 @@ function useSuggestion(providers) {
692
994
  const current = stateRef.current;
693
995
  const parent = current.breadcrumbs[current.breadcrumbs.length - 1];
694
996
  if (parent) {
695
- fetchItems(provider, query, parent);
997
+ scheduleFetch(provider, query, parent);
696
998
  }
697
999
  },
698
- [fetchItems]
1000
+ [scheduleFetch]
699
1001
  );
700
1002
  const onKeyDown = useCallback2(
701
1003
  ({ event }) => {
@@ -718,6 +1020,14 @@ function useSuggestion(providers) {
718
1020
  }
719
1021
  return true;
720
1022
  }
1023
+ case "Tab": {
1024
+ event.preventDefault();
1025
+ const selectedItem = current.items[current.activeIndex];
1026
+ if (selectedItem) {
1027
+ select(selectedItem);
1028
+ }
1029
+ return true;
1030
+ }
721
1031
  case "ArrowRight": {
722
1032
  const activeItem = current.items[current.activeIndex];
723
1033
  if (activeItem?.hasChildren) {
@@ -763,7 +1073,7 @@ function useSuggestion(providers) {
763
1073
  }
764
1074
 
765
1075
  // src/components/SuggestionList.tsx
766
- import { useEffect as useEffect2, useRef as useRef3, useState as useState2 } from "react";
1076
+ import { useEffect as useEffect3, useRef as useRef3, useState as useState2 } from "react";
767
1077
 
768
1078
  // src/utils/ariaHelpers.ts
769
1079
  function comboboxAttrs(expanded, listboxId) {
@@ -792,13 +1102,13 @@ function optionAttrs(id, selected, index) {
792
1102
 
793
1103
  // src/components/SuggestionList.tsx
794
1104
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
795
- var LISTBOX_ID = "mentions-suggestion-listbox";
796
1105
  function SuggestionList({
797
1106
  items,
798
1107
  activeIndex,
799
1108
  breadcrumbs,
800
1109
  loading,
801
1110
  trigger,
1111
+ query,
802
1112
  clientRect,
803
1113
  onSelect,
804
1114
  onHover,
@@ -807,7 +1117,12 @@ function SuggestionList({
807
1117
  onNavigateUp,
808
1118
  onNavigateDown,
809
1119
  onClose,
810
- renderItem
1120
+ onFocusEditor,
1121
+ renderItem,
1122
+ renderEmpty,
1123
+ renderLoading,
1124
+ renderGroupHeader,
1125
+ listboxId
811
1126
  }) {
812
1127
  const listRef = useRef3(null);
813
1128
  const searchInputRef = useRef3(null);
@@ -815,24 +1130,30 @@ function SuggestionList({
815
1130
  const [nestedQuery, setNestedQuery] = useState2("");
816
1131
  const breadcrumbKey = breadcrumbs.map((b) => b.id).join("/");
817
1132
  const prevBreadcrumbKey = useRef3(breadcrumbKey);
818
- useEffect2(() => {
1133
+ useEffect3(() => {
819
1134
  if (prevBreadcrumbKey.current !== breadcrumbKey) {
820
1135
  setNestedQuery("");
821
1136
  prevBreadcrumbKey.current = breadcrumbKey;
822
1137
  }
823
1138
  }, [breadcrumbKey]);
824
- useEffect2(() => {
825
- if (breadcrumbs.length > 0 && searchInputRef.current) {
1139
+ const prevBreadcrumbsLen = useRef3(breadcrumbs.length);
1140
+ useEffect3(() => {
1141
+ if (prevBreadcrumbsLen.current > 0 && breadcrumbs.length === 0) {
1142
+ onFocusEditor?.();
1143
+ } else if (breadcrumbs.length > 0 && searchInputRef.current) {
826
1144
  requestAnimationFrame(() => searchInputRef.current?.focus());
827
1145
  }
828
- }, [breadcrumbKey, breadcrumbs.length]);
829
- useEffect2(() => {
1146
+ prevBreadcrumbsLen.current = breadcrumbs.length;
1147
+ }, [breadcrumbKey, breadcrumbs.length, onFocusEditor]);
1148
+ useEffect3(() => {
830
1149
  if (!listRef.current) return;
831
1150
  const active = listRef.current.querySelector('[aria-selected="true"]');
832
1151
  active?.scrollIntoView({ block: "nearest" });
833
1152
  }, [activeIndex]);
834
- const style = usePopoverPosition(clientRect);
835
- if (items.length === 0 && !loading) return null;
1153
+ const { style, position } = usePopoverPosition(clientRect);
1154
+ const activeQuery = breadcrumbs.length > 0 ? nestedQuery : query;
1155
+ const showEmpty = !loading && items.length === 0 && activeQuery.trim().length > 0;
1156
+ if (items.length === 0 && !loading && !showEmpty) return null;
836
1157
  const handleSearchKeyDown = (e) => {
837
1158
  switch (e.key) {
838
1159
  case "ArrowDown":
@@ -852,8 +1173,23 @@ function SuggestionList({
852
1173
  }
853
1174
  case "Escape":
854
1175
  e.preventDefault();
1176
+ onFocusEditor?.();
855
1177
  onClose?.();
856
1178
  break;
1179
+ case "ArrowLeft":
1180
+ if (nestedQuery === "" || e.currentTarget.selectionStart === 0) {
1181
+ e.preventDefault();
1182
+ onGoBack();
1183
+ }
1184
+ break;
1185
+ case "ArrowRight": {
1186
+ const item = items[activeIndex];
1187
+ if (item?.hasChildren) {
1188
+ e.preventDefault();
1189
+ onSelect(item);
1190
+ }
1191
+ break;
1192
+ }
857
1193
  case "Backspace":
858
1194
  if (nestedQuery === "") {
859
1195
  e.preventDefault();
@@ -862,11 +1198,13 @@ function SuggestionList({
862
1198
  break;
863
1199
  }
864
1200
  };
1201
+ const hasGroups = items.some((item) => item.group);
865
1202
  return /* @__PURE__ */ jsxs(
866
1203
  "div",
867
1204
  {
868
1205
  "data-suggestions": "",
869
1206
  "data-trigger": trigger,
1207
+ "data-suggestions-position": position,
870
1208
  style,
871
1209
  ref: listRef,
872
1210
  children: [
@@ -904,28 +1242,76 @@ function SuggestionList({
904
1242
  spellCheck: false
905
1243
  }
906
1244
  ) }),
907
- loading && /* @__PURE__ */ jsx("div", { "data-suggestion-loading": "", children: "Loading..." }),
908
- !loading && /* @__PURE__ */ jsx("div", { ...listboxAttrs(LISTBOX_ID, `${trigger ?? ""} suggestions`), children: items.map((item, index) => {
909
- const isActive = index === activeIndex;
910
- const itemId = `mention-option-${item.id}`;
911
- return /* @__PURE__ */ jsx(
912
- "div",
913
- {
914
- ...optionAttrs(itemId, isActive, index),
915
- "data-suggestion-item": "",
916
- "data-suggestion-item-active": isActive ? "" : void 0,
917
- "data-has-children": item.hasChildren ? "" : void 0,
918
- onMouseEnter: () => onHover(index),
919
- onClick: () => onSelect(item),
920
- children: renderItem ? renderItem(item, depth) : /* @__PURE__ */ jsx(DefaultSuggestionItem, { item })
921
- },
922
- item.id
923
- );
924
- }) })
1245
+ loading && /* @__PURE__ */ jsx("div", { "data-suggestion-loading": "", children: renderLoading ? renderLoading() : "Loading..." }),
1246
+ showEmpty && /* @__PURE__ */ jsx("div", { "data-suggestion-empty": "", children: renderEmpty ? renderEmpty(activeQuery) : "No results" }),
1247
+ !loading && items.length > 0 && /* @__PURE__ */ jsx("div", { ...listboxAttrs(listboxId, `${trigger ?? ""} suggestions`), children: hasGroups ? renderGroupedItems(items, activeIndex, depth, onSelect, onHover, renderItem, renderGroupHeader) : items.map((item, index) => /* @__PURE__ */ jsx(
1248
+ SuggestionItem,
1249
+ {
1250
+ item,
1251
+ index,
1252
+ isActive: index === activeIndex,
1253
+ depth,
1254
+ onSelect,
1255
+ onHover,
1256
+ renderItem
1257
+ },
1258
+ item.id
1259
+ )) })
925
1260
  ]
926
1261
  }
927
1262
  );
928
1263
  }
1264
+ function renderGroupedItems(items, activeIndex, depth, onSelect, onHover, renderItem, renderGroupHeader) {
1265
+ const elements = [];
1266
+ let lastGroup;
1267
+ items.forEach((item, index) => {
1268
+ if (item.group && item.group !== lastGroup) {
1269
+ lastGroup = item.group;
1270
+ elements.push(
1271
+ /* @__PURE__ */ jsx("div", { "data-suggestion-group-header": "", children: renderGroupHeader ? renderGroupHeader(item.group) : item.group }, `group-${item.group}`)
1272
+ );
1273
+ }
1274
+ elements.push(
1275
+ /* @__PURE__ */ jsx(
1276
+ SuggestionItem,
1277
+ {
1278
+ item,
1279
+ index,
1280
+ isActive: index === activeIndex,
1281
+ depth,
1282
+ onSelect,
1283
+ onHover,
1284
+ renderItem
1285
+ },
1286
+ item.id
1287
+ )
1288
+ );
1289
+ });
1290
+ return elements;
1291
+ }
1292
+ function SuggestionItem({
1293
+ item,
1294
+ index,
1295
+ isActive,
1296
+ depth,
1297
+ onSelect,
1298
+ onHover,
1299
+ renderItem
1300
+ }) {
1301
+ const itemId = `mention-option-${item.id}`;
1302
+ return /* @__PURE__ */ jsx(
1303
+ "div",
1304
+ {
1305
+ ...optionAttrs(itemId, isActive, index),
1306
+ "data-suggestion-item": "",
1307
+ "data-suggestion-item-active": isActive ? "" : void 0,
1308
+ "data-has-children": item.hasChildren ? "" : void 0,
1309
+ onMouseEnter: () => onHover(index),
1310
+ onClick: () => onSelect(item),
1311
+ children: renderItem ? renderItem(item, depth) : /* @__PURE__ */ jsx(DefaultSuggestionItem, { item })
1312
+ }
1313
+ );
1314
+ }
929
1315
  function DefaultSuggestionItem({ item }) {
930
1316
  return /* @__PURE__ */ jsxs(Fragment, { children: [
931
1317
  item.icon && /* @__PURE__ */ jsx("span", { "data-suggestion-item-icon": "", children: item.icon }),
@@ -934,25 +1320,48 @@ function DefaultSuggestionItem({ item }) {
934
1320
  item.hasChildren && /* @__PURE__ */ jsx("span", { "data-suggestion-item-chevron": "", "aria-hidden": "true", children: "\u203A" })
935
1321
  ] });
936
1322
  }
1323
+ var POPOVER_HEIGHT_ESTIMATE = 280;
1324
+ var POPOVER_WIDTH_ESTIMATE = 360;
937
1325
  function usePopoverPosition(clientRect) {
938
1326
  if (!clientRect) {
939
- return { display: "none" };
1327
+ return { style: { display: "none" }, position: "below" };
940
1328
  }
941
1329
  const rect = clientRect();
942
1330
  if (!rect) {
943
- return { display: "none" };
1331
+ return { style: { display: "none" }, position: "below" };
1332
+ }
1333
+ const viewportH = typeof window !== "undefined" ? window.innerHeight : 800;
1334
+ const viewportW = typeof window !== "undefined" ? window.innerWidth : 1200;
1335
+ const spaceBelow = viewportH - rect.bottom;
1336
+ const shouldFlip = spaceBelow < POPOVER_HEIGHT_ESTIMATE && rect.top > spaceBelow;
1337
+ let left = rect.left;
1338
+ if (left + POPOVER_WIDTH_ESTIMATE > viewportW) {
1339
+ left = Math.max(0, viewportW - POPOVER_WIDTH_ESTIMATE);
1340
+ }
1341
+ if (shouldFlip) {
1342
+ return {
1343
+ style: {
1344
+ position: "fixed",
1345
+ left: `${left}px`,
1346
+ bottom: `${viewportH - rect.top + 4}px`,
1347
+ zIndex: 50
1348
+ },
1349
+ position: "above"
1350
+ };
944
1351
  }
945
1352
  return {
946
- position: "fixed",
947
- left: `${rect.left}px`,
948
- top: `${rect.bottom + 4}px`,
949
- zIndex: 50
1353
+ style: {
1354
+ position: "fixed",
1355
+ left: `${left}px`,
1356
+ top: `${rect.bottom + 4}px`,
1357
+ zIndex: 50
1358
+ },
1359
+ position: "below"
950
1360
  };
951
1361
  }
952
1362
 
953
1363
  // src/components/MentionsInput.tsx
954
1364
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
955
- var LISTBOX_ID2 = "mentions-suggestion-listbox";
956
1365
  var MentionsInput = forwardRef(
957
1366
  function MentionsInput2({
958
1367
  value,
@@ -966,10 +1375,29 @@ var MentionsInput = forwardRef(
966
1375
  clearOnSubmit = true,
967
1376
  maxLength,
968
1377
  renderItem,
969
- renderChip
1378
+ renderChip,
1379
+ renderEmpty,
1380
+ renderLoading,
1381
+ renderGroupHeader,
1382
+ onFocus,
1383
+ onBlur,
1384
+ onMentionAdd,
1385
+ onMentionRemove,
1386
+ onMentionClick,
1387
+ onMentionHover,
1388
+ minHeight,
1389
+ maxHeight,
1390
+ submitKey = "enter",
1391
+ allowTrigger,
1392
+ validateMention,
1393
+ portalContainer
970
1394
  }, ref) {
971
- const { uiState, actions, callbacksRef } = useSuggestion(providers);
972
- const { editor, clear, setContent, focus } = useMentionsEditor({
1395
+ const instanceId = useId();
1396
+ const listboxId = `mentions-listbox-${instanceId}`;
1397
+ const { uiState, actions, callbacksRef } = useSuggestion(providers, {
1398
+ onMentionAdd
1399
+ });
1400
+ const { editor, getOutput, clear, setContent, focus } = useMentionsEditor({
973
1401
  providers,
974
1402
  value,
975
1403
  onChange,
@@ -978,46 +1406,70 @@ var MentionsInput = forwardRef(
978
1406
  placeholder,
979
1407
  autoFocus,
980
1408
  editable: !disabled,
981
- callbacksRef
1409
+ callbacksRef,
1410
+ onFocus,
1411
+ onBlur,
1412
+ submitKey,
1413
+ onMentionRemove,
1414
+ onMentionClick,
1415
+ onMentionHover,
1416
+ allowTrigger,
1417
+ validateMention
982
1418
  });
983
1419
  useImperativeHandle(
984
1420
  ref,
985
- () => ({ clear, setContent, focus }),
986
- [clear, setContent, focus]
1421
+ () => ({ clear, setContent, focus, getOutput }),
1422
+ [clear, setContent, focus, getOutput]
987
1423
  );
988
1424
  const isExpanded = uiState.state !== "idle";
989
1425
  const handleHover = useCallback3((index) => {
990
1426
  void index;
991
1427
  }, []);
1428
+ const handleFocusEditor = useCallback3(() => {
1429
+ editor?.commands.focus();
1430
+ }, [editor]);
1431
+ const editorStyle = {};
1432
+ if (minHeight != null) editorStyle.minHeight = `${minHeight}px`;
1433
+ if (maxHeight != null) {
1434
+ editorStyle.maxHeight = `${maxHeight}px`;
1435
+ editorStyle.overflowY = "auto";
1436
+ }
1437
+ const suggestionList = isExpanded ? /* @__PURE__ */ jsx2(
1438
+ SuggestionList,
1439
+ {
1440
+ items: uiState.items,
1441
+ activeIndex: uiState.activeIndex,
1442
+ breadcrumbs: uiState.breadcrumbs,
1443
+ loading: uiState.loading,
1444
+ trigger: uiState.trigger,
1445
+ query: uiState.query,
1446
+ clientRect: uiState.clientRect,
1447
+ onSelect: (item) => actions.select(item),
1448
+ onHover: handleHover,
1449
+ onGoBack: actions.goBack,
1450
+ onSearchNested: actions.searchNested,
1451
+ onNavigateUp: actions.navigateUp,
1452
+ onNavigateDown: actions.navigateDown,
1453
+ onClose: actions.close,
1454
+ onFocusEditor: handleFocusEditor,
1455
+ renderItem,
1456
+ renderEmpty,
1457
+ renderLoading,
1458
+ renderGroupHeader,
1459
+ listboxId
1460
+ }
1461
+ ) : null;
992
1462
  return /* @__PURE__ */ jsxs2(
993
1463
  "div",
994
1464
  {
995
1465
  className,
996
1466
  "data-mentions-input": "",
997
1467
  "data-disabled": disabled ? "" : void 0,
998
- ...comboboxAttrs(isExpanded, LISTBOX_ID2),
1468
+ ...comboboxAttrs(isExpanded, listboxId),
999
1469
  "aria-activedescendant": isExpanded && uiState.items[uiState.activeIndex] ? `mention-option-${uiState.items[uiState.activeIndex].id}` : void 0,
1000
1470
  children: [
1001
- /* @__PURE__ */ jsx2(EditorContent, { editor }),
1002
- isExpanded && /* @__PURE__ */ jsx2(
1003
- SuggestionList,
1004
- {
1005
- items: uiState.items,
1006
- activeIndex: uiState.activeIndex,
1007
- breadcrumbs: uiState.breadcrumbs,
1008
- loading: uiState.loading,
1009
- trigger: uiState.trigger,
1010
- clientRect: uiState.clientRect,
1011
- onSelect: (item) => actions.select(item),
1012
- onHover: handleHover,
1013
- onGoBack: actions.goBack,
1014
- onSearchNested: actions.searchNested,
1015
- onNavigateUp: actions.navigateUp,
1016
- onNavigateDown: actions.navigateDown,
1017
- onClose: actions.close,
1018
- renderItem
1019
- }
1020
- )
1471
+ /* @__PURE__ */ jsx2("div", { style: editorStyle, children: /* @__PURE__ */ jsx2(EditorContent, { editor }) }),
1472
+ portalContainer ? suggestionList && createPortal(suggestionList, portalContainer) : suggestionList
1021
1473
  ]
1022
1474
  }
1023
1475
  );