@tiptap/suggestion 3.26.1 → 3.27.1

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.
@@ -1,3 +1,4 @@
1
+ import type { Middleware } from '@floating-ui/dom'
1
2
  import { Editor, Extension } from '@tiptap/core'
2
3
  import StarterKit from '@tiptap/starter-kit'
3
4
  import { describe, expect, it, vi } from 'vitest'
@@ -409,3 +410,839 @@ describe('suggestion dismissal', () => {
409
410
  editor.destroy()
410
411
  })
411
412
  })
413
+
414
+ describe('suggestion minQueryLength', () => {
415
+ it('should not call items when query is shorter than minQueryLength', async () => {
416
+ const items = vi.fn().mockReturnValue([])
417
+ const onStart = vi.fn()
418
+ const onUpdate = vi.fn()
419
+ const onExit = vi.fn()
420
+
421
+ const MentionExtension = Extension.create({
422
+ name: 'mention-min-query',
423
+ addProseMirrorPlugins() {
424
+ return [
425
+ Suggestion({
426
+ editor: this.editor,
427
+ char: '@',
428
+ minQueryLength: 2,
429
+ items,
430
+ render: () => ({ onStart, onUpdate, onExit }),
431
+ }),
432
+ ]
433
+ },
434
+ })
435
+
436
+ const editor = new Editor({
437
+ extensions: [StarterKit, MentionExtension],
438
+ content: '<p></p>',
439
+ })
440
+
441
+ // Type @a — query "a" is too short (length 1 < 2)
442
+ editor.chain().insertContent('@a').run()
443
+ await Promise.resolve()
444
+
445
+ expect(onStart).toHaveBeenCalledTimes(1)
446
+ // items should not have been called because query.length < minQueryLength
447
+ expect(items).not.toHaveBeenCalled()
448
+ // The props passed to onStart should have items: []
449
+ expect(onStart).toHaveBeenCalledWith(expect.objectContaining({ items: [] }))
450
+
451
+ // Continue typing to reach minQueryLength
452
+ editor.chain().insertContent('b').run()
453
+ await Promise.resolve()
454
+
455
+ // items should be called now with query 'ab'
456
+ expect(items).toHaveBeenCalledWith(
457
+ expect.objectContaining({
458
+ editor: expect.any(Object),
459
+ query: 'ab',
460
+ }),
461
+ )
462
+
463
+ editor.destroy()
464
+ })
465
+ })
466
+
467
+ describe('suggestion initialItems', () => {
468
+ it('should pass initialItems to onBeforeStart/onBeforeUpdate and resolved items to onStart/onUpdate', async () => {
469
+ const initialItems = [{ id: 1, label: 'Popular' }]
470
+ const resolvedItems = [{ id: 2, label: 'Filtered' }]
471
+ const items = vi.fn().mockResolvedValue(resolvedItems)
472
+ const onBeforeStart = vi.fn()
473
+ const onBeforeUpdate = vi.fn()
474
+ const onStart = vi.fn()
475
+ const onUpdate = vi.fn()
476
+ const onExit = vi.fn()
477
+
478
+ const MentionExtension = Extension.create({
479
+ name: 'mention-initial-items',
480
+ addProseMirrorPlugins() {
481
+ return [
482
+ Suggestion({
483
+ editor: this.editor,
484
+ char: '@',
485
+ initialItems,
486
+ items,
487
+ render: () => ({ onBeforeStart, onBeforeUpdate, onStart, onUpdate, onExit }),
488
+ }),
489
+ ]
490
+ },
491
+ })
492
+
493
+ const editor = new Editor({
494
+ extensions: [StarterKit, MentionExtension],
495
+ content: '<p></p>',
496
+ })
497
+
498
+ // Type @ to start suggestion
499
+ editor.chain().insertContent('@').run()
500
+ await Promise.resolve()
501
+ await Promise.resolve()
502
+
503
+ // onBeforeStart should receive initialItems
504
+ expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ items: initialItems }))
505
+ // onStart mounts immediately with the initial items while loading
506
+ expect(onStart).toHaveBeenLastCalledWith(
507
+ expect.objectContaining({ items: initialItems, loading: true }),
508
+ )
509
+ // onUpdate receives the async-resolved items
510
+ expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ items: resolvedItems }))
511
+ // items() should still have been called
512
+ expect(items).toHaveBeenCalledWith(
513
+ expect.objectContaining({
514
+ editor: expect.any(Object),
515
+ query: '',
516
+ }),
517
+ )
518
+
519
+ // Reset mocks for the update phase
520
+ items.mockClear()
521
+ onBeforeUpdate.mockClear()
522
+ onUpdate.mockClear()
523
+
524
+ // Type another character to trigger an update
525
+ editor.chain().insertContent('a').run()
526
+ await Promise.resolve()
527
+ await Promise.resolve()
528
+
529
+ // onBeforeUpdate should also receive initialItems
530
+ expect(onBeforeUpdate).toHaveBeenCalledWith(expect.objectContaining({ items: initialItems }))
531
+ // onUpdate should receive the async-resolved items
532
+ expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ items: resolvedItems }))
533
+ expect(items).toHaveBeenCalledWith(
534
+ expect.objectContaining({
535
+ editor: expect.any(Object),
536
+ query: 'a',
537
+ }),
538
+ )
539
+
540
+ editor.destroy()
541
+ })
542
+ })
543
+
544
+ describe('suggestion loading state', () => {
545
+ it('should set loading to true in before callbacks and false after when items() is called', async () => {
546
+ const items = vi.fn().mockResolvedValue([])
547
+ const onBeforeStart = vi.fn()
548
+ const onBeforeUpdate = vi.fn()
549
+ const onStart = vi.fn()
550
+ const onUpdate = vi.fn()
551
+ const onExit = vi.fn()
552
+
553
+ const MentionExtension = Extension.create({
554
+ name: 'mention-loading',
555
+ addProseMirrorPlugins() {
556
+ return [
557
+ Suggestion({
558
+ editor: this.editor,
559
+ char: '@',
560
+ items,
561
+ render: () => ({ onBeforeStart, onBeforeUpdate, onStart, onUpdate, onExit }),
562
+ }),
563
+ ]
564
+ },
565
+ })
566
+
567
+ const editor = new Editor({
568
+ extensions: [StarterKit, MentionExtension],
569
+ content: '<p></p>',
570
+ })
571
+
572
+ // Type @ to start suggestion — triggers async items()
573
+ editor.chain().insertContent('@').run()
574
+ await Promise.resolve()
575
+ await Promise.resolve()
576
+
577
+ // onBeforeStart fires before items() resolves → loading should be true
578
+ expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ loading: true }))
579
+ // onStart fires immediately with loading enabled
580
+ expect(onStart).toHaveBeenLastCalledWith(expect.objectContaining({ loading: true }))
581
+ // onUpdate fires after items() resolves → loading should be false
582
+ expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ loading: false }))
583
+
584
+ // Type another char to trigger an update
585
+ editor.chain().insertContent('a').run()
586
+ await Promise.resolve()
587
+ await Promise.resolve()
588
+
589
+ // onBeforeUpdate fires before items() resolves → loading should be true
590
+ expect(onBeforeUpdate).toHaveBeenCalledWith(expect.objectContaining({ loading: true }))
591
+ // onUpdate fires after items() resolves → loading should be false
592
+ expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ loading: false }))
593
+
594
+ editor.destroy()
595
+ })
596
+
597
+ it('should recover when items() rejects', async () => {
598
+ const items = vi.fn().mockRejectedValue(new Error('boom'))
599
+ const onBeforeStart = vi.fn()
600
+ const onUpdate = vi.fn()
601
+ const onStart = vi.fn()
602
+
603
+ const MentionExtension = Extension.create({
604
+ name: 'mention-loading-rejects',
605
+ addProseMirrorPlugins() {
606
+ return [
607
+ Suggestion({
608
+ editor: this.editor,
609
+ char: '@',
610
+ items,
611
+ render: () => ({ onBeforeStart, onStart, onUpdate }),
612
+ }),
613
+ ]
614
+ },
615
+ })
616
+
617
+ const editor = new Editor({
618
+ extensions: [StarterKit, MentionExtension],
619
+ content: '<p></p>',
620
+ })
621
+
622
+ editor.chain().insertContent('@').run()
623
+ await Promise.resolve()
624
+ await Promise.resolve()
625
+
626
+ expect(items).toHaveBeenCalledTimes(1)
627
+ expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ loading: true }))
628
+ expect(onUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ loading: false }))
629
+
630
+ editor.destroy()
631
+ })
632
+
633
+ it('should set loading to false in all callbacks when minQueryLength blocks items()', async () => {
634
+ const items = vi.fn().mockResolvedValue([])
635
+ const onBeforeStart = vi.fn()
636
+ const onStart = vi.fn()
637
+
638
+ const MentionExtension = Extension.create({
639
+ name: 'mention-loading-blocked',
640
+ addProseMirrorPlugins() {
641
+ return [
642
+ Suggestion({
643
+ editor: this.editor,
644
+ char: '@',
645
+ minQueryLength: 3,
646
+ items,
647
+ render: () => ({ onBeforeStart, onStart }),
648
+ }),
649
+ ]
650
+ },
651
+ })
652
+
653
+ const editor = new Editor({
654
+ extensions: [StarterKit, MentionExtension],
655
+ content: '<p></p>',
656
+ })
657
+
658
+ // Type @a — query "a" is too short, items() won't be called
659
+ editor.chain().insertContent('@a').run()
660
+ await Promise.resolve()
661
+
662
+ // No async call happens → loading should be false
663
+ expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ loading: false }))
664
+ expect(onStart).toHaveBeenCalledWith(expect.objectContaining({ loading: false }))
665
+
666
+ editor.destroy()
667
+ })
668
+ })
669
+
670
+ describe('suggestion AbortSignal', () => {
671
+ it('should pass signal to items() and abort previous signal on new query', async () => {
672
+ const signals: AbortSignal[] = []
673
+ let resolveFirst: (value: unknown) => void = () => {}
674
+
675
+ const items = vi.fn().mockImplementation(({ signal }) => {
676
+ signals.push(signal)
677
+ // First call returns a promise that we control
678
+ if (signals.length === 1) {
679
+ return new Promise(resolve => {
680
+ resolveFirst = resolve
681
+ })
682
+ }
683
+ // Subsequent calls resolve immediately
684
+ return []
685
+ })
686
+
687
+ const onStart = vi.fn()
688
+ const onUpdate = vi.fn()
689
+
690
+ const MentionExtension = Extension.create({
691
+ name: 'mention-abort',
692
+ addProseMirrorPlugins() {
693
+ return [
694
+ Suggestion({
695
+ editor: this.editor,
696
+ char: '@',
697
+ items,
698
+ render: () => ({ onStart, onUpdate }),
699
+ }),
700
+ ]
701
+ },
702
+ })
703
+
704
+ const editor = new Editor({
705
+ extensions: [StarterKit, MentionExtension],
706
+ content: '<p></p>',
707
+ })
708
+
709
+ // Type @ to start suggestion — first items() call starts but doesn't resolve
710
+ editor.chain().insertContent('@').run()
711
+ await Promise.resolve()
712
+
713
+ expect(items).toHaveBeenCalledTimes(1)
714
+ expect(signals[0].aborted).toBe(false)
715
+
716
+ // Type a — triggers a second items() while first is still in-flight
717
+ editor.chain().insertContent('a').run()
718
+ await Promise.resolve()
719
+
720
+ // items() should have been called a second time
721
+ expect(items).toHaveBeenCalledTimes(2)
722
+ // The first signal should be aborted
723
+ expect(signals[0].aborted).toBe(true)
724
+ // The second signal should be fresh
725
+ expect(signals[1].aborted).toBe(false)
726
+
727
+ // Clean up the hanging promise
728
+ resolveFirst([])
729
+ await Promise.resolve()
730
+
731
+ editor.destroy()
732
+ })
733
+
734
+ it('should not emit stale callbacks after a request is superseded', async () => {
735
+ const signals: AbortSignal[] = []
736
+ let resolveFirst: (value: unknown) => void = () => {}
737
+
738
+ const items = vi.fn().mockImplementation(({ signal }) => {
739
+ signals.push(signal)
740
+
741
+ if (signals.length === 1) {
742
+ return new Promise(resolve => {
743
+ resolveFirst = resolve
744
+ })
745
+ }
746
+
747
+ return []
748
+ })
749
+
750
+ const onStart = vi.fn()
751
+ const onUpdate = vi.fn()
752
+
753
+ const MentionExtension = Extension.create({
754
+ name: 'mention-abort-stale-callbacks',
755
+ addProseMirrorPlugins() {
756
+ return [
757
+ Suggestion({
758
+ editor: this.editor,
759
+ char: '@',
760
+ items,
761
+ render: () => ({ onStart, onUpdate }),
762
+ }),
763
+ ]
764
+ },
765
+ })
766
+
767
+ const editor = new Editor({
768
+ extensions: [StarterKit, MentionExtension],
769
+ content: '<p></p>',
770
+ })
771
+
772
+ editor.chain().insertContent('@').run()
773
+ await Promise.resolve()
774
+
775
+ editor.chain().insertContent('a').run()
776
+ await Promise.resolve()
777
+ await Promise.resolve()
778
+
779
+ const startCallsBefore = onStart.mock.calls.length
780
+ const updateCallsBefore = onUpdate.mock.calls.length
781
+
782
+ resolveFirst([])
783
+ await Promise.resolve()
784
+ await Promise.resolve()
785
+
786
+ expect(onStart.mock.calls.length).toBe(startCallsBefore)
787
+ expect(onUpdate.mock.calls.length).toBe(updateCallsBefore)
788
+
789
+ editor.destroy()
790
+ })
791
+
792
+ it('should pass signal as a property in the items callback props', async () => {
793
+ const items = vi.fn().mockImplementation(({ signal }) => {
794
+ expect(signal).toBeInstanceOf(AbortSignal)
795
+ return []
796
+ })
797
+
798
+ const MentionExtension = Extension.create({
799
+ name: 'mention-abort-prop',
800
+ addProseMirrorPlugins() {
801
+ return [
802
+ Suggestion({
803
+ editor: this.editor,
804
+ char: '@',
805
+ items,
806
+ }),
807
+ ]
808
+ },
809
+ })
810
+
811
+ const editor = new Editor({
812
+ extensions: [StarterKit, MentionExtension],
813
+ content: '<p></p>',
814
+ })
815
+
816
+ editor.chain().insertContent('@').run()
817
+ await Promise.resolve()
818
+
819
+ expect(items).toHaveBeenCalled()
820
+ // The callback assertion already checked signal is an AbortSignal
821
+
822
+ editor.destroy()
823
+ })
824
+ })
825
+
826
+ describe('suggestion debounce', () => {
827
+ it('should delay the items() call by the configured debounce time', async () => {
828
+ vi.useFakeTimers()
829
+
830
+ const items = vi.fn().mockResolvedValue([])
831
+ const onBeforeStart = vi.fn()
832
+ const onStart = vi.fn()
833
+
834
+ const MentionExtension = Extension.create({
835
+ name: 'mention-debounce',
836
+ addProseMirrorPlugins() {
837
+ return [
838
+ Suggestion({
839
+ editor: this.editor,
840
+ char: '@',
841
+ debounce: 100,
842
+ items,
843
+ render: () => ({ onBeforeStart, onStart }),
844
+ }),
845
+ ]
846
+ },
847
+ })
848
+
849
+ const editor = new Editor({
850
+ extensions: [StarterKit, MentionExtension],
851
+ content: '<p></p>',
852
+ })
853
+
854
+ // Type @ to trigger suggestion
855
+ editor.chain().insertContent('@').run()
856
+
857
+ // items() should not have been called yet (debounce pending)
858
+ expect(items).not.toHaveBeenCalled()
859
+ // onBeforeStart should fire immediately (not debounced)
860
+ expect(onBeforeStart).toHaveBeenCalled()
861
+
862
+ // Advance past the debounce window
863
+ await vi.advanceTimersByTimeAsync(100)
864
+
865
+ // Now items() should have been called
866
+ expect(items).toHaveBeenCalledTimes(1)
867
+ // onStart fires after items resolves
868
+ expect(onStart).toHaveBeenCalled()
869
+
870
+ vi.useRealTimers()
871
+ editor.destroy()
872
+ })
873
+
874
+ it('should cancel pending debounce work on destroy', async () => {
875
+ vi.useFakeTimers()
876
+
877
+ const items = vi.fn().mockResolvedValue([])
878
+ const onBeforeStart = vi.fn()
879
+ const onStart = vi.fn()
880
+
881
+ const MentionExtension = Extension.create({
882
+ name: 'mention-debounce-destroy',
883
+ addProseMirrorPlugins() {
884
+ return [
885
+ Suggestion({
886
+ editor: this.editor,
887
+ char: '@',
888
+ debounce: 100,
889
+ items,
890
+ render: () => ({ onBeforeStart, onStart }),
891
+ }),
892
+ ]
893
+ },
894
+ })
895
+
896
+ const editor = new Editor({
897
+ extensions: [StarterKit, MentionExtension],
898
+ content: '<p></p>',
899
+ })
900
+
901
+ editor.chain().insertContent('@').run()
902
+ await Promise.resolve()
903
+
904
+ expect(onBeforeStart).toHaveBeenCalledWith(expect.objectContaining({ loading: true }))
905
+
906
+ const startCallsBefore = onStart.mock.calls.length
907
+
908
+ editor.destroy()
909
+ await vi.advanceTimersByTimeAsync(100)
910
+
911
+ expect(items).not.toHaveBeenCalled()
912
+ expect(onStart.mock.calls.length).toBe(startCallsBefore)
913
+
914
+ vi.useRealTimers()
915
+ })
916
+
917
+ it('should reset the debounce timer on rapid typing', async () => {
918
+ vi.useFakeTimers()
919
+
920
+ const items = vi.fn().mockResolvedValue([])
921
+
922
+ const MentionExtension = Extension.create({
923
+ name: 'mention-debounce-rapid',
924
+ addProseMirrorPlugins() {
925
+ return [
926
+ Suggestion({
927
+ editor: this.editor,
928
+ char: '@',
929
+ debounce: 100,
930
+ items,
931
+ }),
932
+ ]
933
+ },
934
+ })
935
+
936
+ const editor = new Editor({
937
+ extensions: [StarterKit, MentionExtension],
938
+ content: '<p></p>',
939
+ })
940
+
941
+ // Type @a
942
+ editor.chain().insertContent('@a').run()
943
+ await vi.advanceTimersByTimeAsync(60)
944
+
945
+ // Type b before debounce fires
946
+ editor.chain().insertContent('b').run()
947
+ await vi.advanceTimersByTimeAsync(60)
948
+
949
+ // Debounce should have reset — items not called yet
950
+ expect(items).not.toHaveBeenCalled()
951
+
952
+ // Advance past remaining debounce from last keystroke
953
+ await vi.advanceTimersByTimeAsync(100)
954
+
955
+ // Should have been called only once (not twice)
956
+ expect(items).toHaveBeenCalledTimes(1)
957
+
958
+ vi.useRealTimers()
959
+ editor.destroy()
960
+ })
961
+ })
962
+
963
+ describe('suggestion positioning options', () => {
964
+ it('should forward placement, offset, container, and flip to SuggestionProps', async () => {
965
+ const onStart = vi.fn()
966
+
967
+ const MentionExtension = Extension.create({
968
+ name: 'mention-positioning',
969
+ addProseMirrorPlugins() {
970
+ return [
971
+ Suggestion({
972
+ editor: this.editor,
973
+ char: '@',
974
+ placement: 'top-start',
975
+ offset: { mainAxis: 8, crossAxis: 4 },
976
+ container: '.my-container',
977
+ flip: false,
978
+ render: () => ({ onStart }),
979
+ }),
980
+ ]
981
+ },
982
+ })
983
+
984
+ const editor = new Editor({
985
+ extensions: [StarterKit, MentionExtension],
986
+ content: '<p></p>',
987
+ })
988
+
989
+ editor.chain().insertContent('@').run()
990
+ await Promise.resolve()
991
+
992
+ expect(onStart).toHaveBeenCalledWith(
993
+ expect.objectContaining({
994
+ placement: 'top-start',
995
+ offset: { mainAxis: 8, crossAxis: 4 },
996
+ container: '.my-container',
997
+ flip: false,
998
+ }),
999
+ )
1000
+
1001
+ editor.destroy()
1002
+ })
1003
+
1004
+ it('should use defaults when positioning options are not set', async () => {
1005
+ const onStart = vi.fn()
1006
+
1007
+ const MentionExtension = Extension.create({
1008
+ name: 'mention-positioning-defaults',
1009
+ addProseMirrorPlugins() {
1010
+ return [
1011
+ Suggestion({
1012
+ editor: this.editor,
1013
+ char: '@',
1014
+ render: () => ({ onStart }),
1015
+ }),
1016
+ ]
1017
+ },
1018
+ })
1019
+
1020
+ const editor = new Editor({
1021
+ extensions: [StarterKit, MentionExtension],
1022
+ content: '<p></p>',
1023
+ })
1024
+
1025
+ editor.chain().insertContent('@').run()
1026
+ await Promise.resolve()
1027
+
1028
+ expect(onStart).toHaveBeenCalledWith(
1029
+ expect.objectContaining({
1030
+ placement: 'bottom-start',
1031
+ offset: { mainAxis: 4, crossAxis: 0 },
1032
+ flip: true,
1033
+ }),
1034
+ )
1035
+
1036
+ editor.destroy()
1037
+ })
1038
+
1039
+ it('should pass through floatingUi config and middleware', async () => {
1040
+ const customMiddleware = {
1041
+ name: 'custom',
1042
+ fn: vi.fn(() => ({ x: 0, y: 0 })),
1043
+ } as Middleware
1044
+
1045
+ const onStart = vi.fn()
1046
+
1047
+ const MentionExtension = Extension.create({
1048
+ name: 'mention-floating-ui',
1049
+ addProseMirrorPlugins() {
1050
+ return [
1051
+ Suggestion({
1052
+ editor: this.editor,
1053
+ char: '@',
1054
+ placement: 'top-start',
1055
+ offset: { mainAxis: 8, crossAxis: 4 },
1056
+ flip: false,
1057
+ floatingUi: {
1058
+ strategy: 'fixed',
1059
+ middleware: [customMiddleware],
1060
+ },
1061
+ render: () => ({ onStart }),
1062
+ }),
1063
+ ]
1064
+ },
1065
+ })
1066
+
1067
+ const editor = new Editor({
1068
+ extensions: [StarterKit, MentionExtension],
1069
+ content: '<p></p>',
1070
+ })
1071
+
1072
+ editor.chain().insertContent('@').run()
1073
+ await Promise.resolve()
1074
+
1075
+ expect(onStart).toHaveBeenCalledWith(
1076
+ expect.objectContaining({
1077
+ floatingUi: expect.objectContaining({
1078
+ placement: 'top-start',
1079
+ strategy: 'fixed',
1080
+ }),
1081
+ }),
1082
+ )
1083
+
1084
+ const floatingUi = onStart.mock.calls[0][0].floatingUi
1085
+ expect(floatingUi.middleware).toHaveLength(2)
1086
+ expect(floatingUi.middleware).toEqual(expect.arrayContaining([customMiddleware]))
1087
+
1088
+ editor.destroy()
1089
+ })
1090
+ })
1091
+
1092
+ describe('suggestion mount', () => {
1093
+ // Captures `props.mount` from a started suggestion so each test can call it
1094
+ // against a real element.
1095
+ async function getMount(container?: string | HTMLElement) {
1096
+ const onStart = vi.fn()
1097
+
1098
+ const MentionExtension = Extension.create({
1099
+ name: 'mention-mount',
1100
+ addProseMirrorPlugins() {
1101
+ return [
1102
+ Suggestion({
1103
+ editor: this.editor,
1104
+ char: '@',
1105
+ container,
1106
+ render: () => ({ onStart }),
1107
+ }),
1108
+ ]
1109
+ },
1110
+ })
1111
+
1112
+ const editor = new Editor({
1113
+ extensions: [StarterKit, MentionExtension],
1114
+ content: '<p></p>',
1115
+ })
1116
+
1117
+ editor.chain().insertContent('@').run()
1118
+ await Promise.resolve()
1119
+
1120
+ return { mount: onStart.mock.calls[0][0].mount, editor }
1121
+ }
1122
+
1123
+ it('mounts the element into document.body and removes it on unmount', async () => {
1124
+ const { mount, editor } = await getMount()
1125
+ const element = document.createElement('div')
1126
+
1127
+ const unmount = mount(element)
1128
+ expect(element.parentElement).toBe(document.body)
1129
+
1130
+ unmount()
1131
+ expect(element.isConnected).toBe(false)
1132
+
1133
+ editor.destroy()
1134
+ })
1135
+
1136
+ it('mounts the element into a provided container', async () => {
1137
+ const container = document.createElement('div')
1138
+ document.body.appendChild(container)
1139
+
1140
+ const { mount, editor } = await getMount(container)
1141
+ const element = document.createElement('div')
1142
+
1143
+ const unmount = mount(element)
1144
+ expect(element.parentElement).toBe(container)
1145
+
1146
+ unmount()
1147
+ container.remove()
1148
+ editor.destroy()
1149
+ })
1150
+
1151
+ it('leaves an already-mounted element in place (escape hatch)', async () => {
1152
+ const { mount, editor } = await getMount()
1153
+ const element = document.createElement('div')
1154
+ const host = document.createElement('div')
1155
+ host.appendChild(element)
1156
+ document.body.appendChild(host)
1157
+
1158
+ const unmount = mount(element)
1159
+ expect(element.parentElement).toBe(host)
1160
+
1161
+ // We did not mount it, so unmount must not remove it.
1162
+ unmount()
1163
+ expect(element.parentElement).toBe(host)
1164
+
1165
+ host.remove()
1166
+ editor.destroy()
1167
+ })
1168
+ })
1169
+
1170
+ describe('suggestion outside click', () => {
1171
+ async function setup(dismissOnOutsideClick?: boolean) {
1172
+ const onStart = vi.fn()
1173
+
1174
+ const MentionExtension = Extension.create({
1175
+ name: 'mention-outside-click',
1176
+ addProseMirrorPlugins() {
1177
+ return [
1178
+ Suggestion({
1179
+ editor: this.editor,
1180
+ char: '@',
1181
+ dismissOnOutsideClick,
1182
+ render: () => ({ onStart }),
1183
+ }),
1184
+ ]
1185
+ },
1186
+ })
1187
+
1188
+ const editor = new Editor({
1189
+ extensions: [StarterKit, MentionExtension],
1190
+ content: '<p></p>',
1191
+ })
1192
+
1193
+ editor.chain().insertContent('@').run()
1194
+ await Promise.resolve()
1195
+
1196
+ const mount = onStart.mock.calls[0][0].mount
1197
+ const isActive = () => SuggestionPluginKey.getState(editor.state)?.active === true
1198
+
1199
+ return { mount, editor, isActive }
1200
+ }
1201
+
1202
+ it('dismisses when clicking outside the popup and editor', async () => {
1203
+ const { mount, editor, isActive } = await setup()
1204
+ const element = document.createElement('div')
1205
+ const unmount = mount(element)
1206
+
1207
+ expect(isActive()).toBe(true)
1208
+
1209
+ const outside = document.createElement('div')
1210
+ document.body.appendChild(outside)
1211
+ outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
1212
+
1213
+ expect(isActive()).toBe(false)
1214
+
1215
+ unmount()
1216
+ outside.remove()
1217
+ editor.destroy()
1218
+ })
1219
+
1220
+ it('does not dismiss when clicking inside the popup', async () => {
1221
+ const { mount, editor, isActive } = await setup()
1222
+ const element = document.createElement('div')
1223
+ const unmount = mount(element)
1224
+
1225
+ element.dispatchEvent(new Event('pointerdown', { bubbles: true }))
1226
+
1227
+ expect(isActive()).toBe(true)
1228
+
1229
+ unmount()
1230
+ editor.destroy()
1231
+ })
1232
+
1233
+ it('does not attach the listener when dismissOnOutsideClick is false', async () => {
1234
+ const { mount, editor, isActive } = await setup(false)
1235
+ const element = document.createElement('div')
1236
+ const unmount = mount(element)
1237
+
1238
+ const outside = document.createElement('div')
1239
+ document.body.appendChild(outside)
1240
+ outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
1241
+
1242
+ expect(isActive()).toBe(true)
1243
+
1244
+ unmount()
1245
+ outside.remove()
1246
+ editor.destroy()
1247
+ })
1248
+ })