1
- import { useCallback , useEffect , useRef , type FC , type ReactNode } from 'react'
1
+ import React , { useEffect , useMemo , useRef , useState } from 'react'
2
2
import {
3
3
Command ,
4
4
CommandEmpty ,
5
5
CommandGroup ,
6
- CommandInput ,
7
6
CommandItem ,
8
7
CommandList
9
8
} from '@webview/components/ui/command'
@@ -12,157 +11,120 @@ import {
12
11
PopoverContent ,
13
12
PopoverTrigger
14
13
} from '@webview/components/ui/popover'
15
- import { useCallbackRef } from '@webview/hooks/use-callback-ref'
16
14
import { useControllableState } from '@webview/hooks/use-controllable-state'
17
- import type { IMentionStrategy , MentionOption } from '@webview/types/chat'
15
+ import { useKeyboardNavigation } from '@webview/hooks/use-keyboard-navigation'
16
+ import { IMentionStrategy , MentionOption } from '@webview/types/chat'
17
+ import { cn } from '@webview/utils/common'
18
+ import { useEvent } from 'react-use'
18
19
19
20
export interface SelectedMentionStrategy {
20
21
strategy : IMentionStrategy
21
22
strategyAddData : any
22
23
}
23
24
24
25
interface MentionSelectorProps {
26
+ searchQuery ?: string
25
27
mentionOptions : MentionOption [ ]
26
28
onSelect : ( option : SelectedMentionStrategy ) => void
27
29
open ?: boolean
28
30
onOpenChange ?: ( open : boolean ) => void
29
- lexicalMode ?: boolean
30
- searchQuery ?: string
31
- onSearchQueryChange ?: ( searchQuery : string ) => void
32
- children : ReactNode
31
+ onCloseWithoutSelect ?: ( ) => void
32
+ children : React . ReactNode
33
33
}
34
34
35
- export const MentionSelector : FC < MentionSelectorProps > = ( {
35
+ export const MentionSelector : React . FC < MentionSelectorProps > = ( {
36
+ searchQuery = '' ,
36
37
mentionOptions,
37
38
onSelect,
38
39
open,
39
40
onOpenChange,
40
- lexicalMode,
41
- searchQuery,
42
- onSearchQueryChange,
41
+ onCloseWithoutSelect,
43
42
children
44
43
} ) => {
45
44
const commandRef = useRef < HTMLDivElement > ( null )
45
+ const [ currentOptions , setCurrentOptions ] =
46
+ useState < MentionOption [ ] > ( mentionOptions )
46
47
47
48
const [ isOpen = false , setIsOpen ] = useControllableState ( {
48
49
prop : open ,
49
50
defaultProp : false ,
50
51
onChange : onOpenChange
51
52
} )
52
53
53
- const [ internalSearchQuery , setInternalSearchQuery ] = useControllableState ( {
54
- prop : searchQuery ,
55
- defaultProp : '' ,
56
- onChange : onSearchQueryChange
57
- } )
58
-
59
54
useEffect ( ( ) => {
60
- if ( ! lexicalMode || mentionOptions . length > 0 ) return
61
-
62
- setIsOpen ( false )
63
- } , [ mentionOptions . length , setIsOpen , lexicalMode ] )
64
-
65
- const handleSelect = useCallback (
66
- ( currentValue : string ) => {
67
- const selectedOption = mentionOptions . find (
68
- option => option . category === currentValue
69
- )
70
- if ( selectedOption ) {
71
- onSelect ( {
72
- strategy : selectedOption . mentionStrategies [ 0 ] ! ,
73
- strategyAddData : { label : selectedOption . label }
74
- } )
75
- }
76
- setIsOpen ( false )
77
- } ,
78
- [ mentionOptions , onSelect ]
79
- )
80
-
81
- const filterMentions = useCallback (
82
- ( value : string , search : string ) => {
83
- const option = mentionOptions . find ( opt => opt . category === value )
84
- if ( ! option ) return 0
85
-
86
- const label = option . label . toLowerCase ( )
87
- search = search . toLowerCase ( )
88
-
89
- if ( label === search ) return 1
90
- if ( label . startsWith ( search ) ) return 0.8
91
- if ( label . includes ( search ) ) return 0.6
92
-
93
- // Calculate a basic fuzzy match score
94
- let score = 0
95
- let searchIndex = 0
96
- for ( let i = 0 ; i < label . length && searchIndex < search . length ; i ++ ) {
97
- if ( label [ i ] === search [ searchIndex ] ) {
98
- score += 1
99
- searchIndex ++
100
- }
101
- }
102
- return score / Math . max ( label . length , search . length )
103
- } ,
104
- [ mentionOptions ]
105
- )
106
-
107
- const handleKeyDown = useCallbackRef ( ( event : KeyboardEvent ) => {
108
- if ( ! lexicalMode || ! isOpen ) return
55
+ if ( ! isOpen ) {
56
+ setCurrentOptions ( mentionOptions )
57
+ }
58
+ } , [ isOpen , mentionOptions ] )
109
59
110
- const commandEl = commandRef . current
111
- if ( ! commandEl ) return
60
+ const filteredOptions = useMemo ( ( ) => {
61
+ if ( ! searchQuery ) return currentOptions
62
+ return currentOptions . filter ( option =>
63
+ option . label . toLowerCase ( ) . includes ( searchQuery . toLowerCase ( ) )
64
+ )
65
+ } , [ currentOptions , searchQuery ] )
112
66
113
- if ( [ 'ArrowUp' , 'ArrowDown' , 'Enter' ] . includes ( event . key ) ) {
114
- event . preventDefault ( )
67
+ const itemRefs = useRef < ( HTMLDivElement | null ) [ ] > ( [ ] )
68
+ const { focusedIndex, setFocusedIndex, handleKeyDown } =
69
+ useKeyboardNavigation ( {
70
+ itemCount : filteredOptions . length ,
71
+ itemRefs,
72
+ onEnter : el => el ?. click ( )
73
+ } )
115
74
116
- const syntheticEvent = new KeyboardEvent ( event . type , {
117
- key : event . key ,
118
- code : event . code ,
119
- isComposing : event . isComposing ,
120
- location : event . location ,
121
- repeat : event . repeat ,
122
- bubbles : true
123
- } )
124
- commandEl . dispatchEvent ( syntheticEvent )
125
- }
126
- } )
75
+ useEvent ( 'keydown' , handleKeyDown )
127
76
128
77
useEffect ( ( ) => {
129
- if ( ! lexicalMode ) return
130
-
131
- document . addEventListener ( 'keydown' , handleKeyDown )
132
- return ( ) => {
133
- document . removeEventListener ( 'keydown' , handleKeyDown )
78
+ setFocusedIndex ( 0 )
79
+ } , [ filteredOptions ] )
80
+
81
+ const handleSelect = ( option : MentionOption ) => {
82
+ if ( option . children ) {
83
+ setCurrentOptions ( option . children )
84
+ onCloseWithoutSelect ?.( )
85
+ } else {
86
+ onSelect ( {
87
+ strategy : option . mentionStrategies [ 0 ] ! ,
88
+ strategyAddData : option . data || { label : option . label }
89
+ } )
90
+ setIsOpen ( false )
134
91
}
135
- } , [ lexicalMode , handleKeyDown , isOpen ] )
92
+ }
136
93
137
94
return (
138
95
< Popover open = { isOpen } onOpenChange = { setIsOpen } >
139
96
< PopoverTrigger asChild > { children } </ PopoverTrigger >
140
97
< PopoverContent
141
- className = " w-[200px] p-0"
98
+ className = { cn ( ' w-[200px] p-0' , ! isOpen && 'hidden' ) }
142
99
updatePositionStrategy = "always"
143
100
side = "top"
144
- onOpenAutoFocus = { e => lexicalMode && e . preventDefault ( ) }
145
- onCloseAutoFocus = { e => lexicalMode && e . preventDefault ( ) }
101
+ align = "start"
102
+ onOpenAutoFocus = { e => e . preventDefault ( ) }
103
+ onCloseAutoFocus = { e => e . preventDefault ( ) }
146
104
onKeyDown = { e => e . stopPropagation ( ) }
147
105
>
148
- < Command ref = { commandRef } filter = { filterMentions } >
149
- < CommandInput
150
- hidden = { lexicalMode }
151
- showSearchIcon = { false }
152
- placeholder = "Search mention..."
153
- className = "h-9"
154
- value = { internalSearchQuery }
155
- onValueChange = { setInternalSearchQuery }
156
- />
106
+ < Command ref = { commandRef } shouldFilter = { false } >
157
107
< CommandList >
158
- < CommandEmpty > No mention type found.</ CommandEmpty >
159
- < CommandGroup >
160
- { mentionOptions . map ( option => (
108
+ < CommandEmpty > No results found.</ CommandEmpty >
109
+ < CommandGroup
110
+ className = { cn ( filteredOptions . length === 0 ? 'p-0' : 'p-1' ) }
111
+ >
112
+ { filteredOptions . map ( ( option , index ) => (
161
113
< CommandItem
162
- key = { option . category }
163
- value = { option . category }
164
- onSelect = { handleSelect }
165
- className = "px-1.5 py-1"
114
+ key = { option . label }
115
+ defaultValue = ""
116
+ value = ""
117
+ onSelect = { ( ) => handleSelect ( option ) }
118
+ className = { cn (
119
+ 'px-1.5 py-1' ,
120
+ focusedIndex === index &&
121
+ 'bg-primary text-primary-foreground'
122
+ ) }
123
+ ref = { el => {
124
+ if ( itemRefs . current ) {
125
+ itemRefs . current [ index ] = el
126
+ }
127
+ } }
166
128
>
167
129
{ option . label }
168
130
</ CommandItem >
0 commit comments