@@ -181,20 +181,36 @@ function ToolbarButton({
181
181
) ;
182
182
}
183
183
184
+ type UserItem = {
185
+ user : string ;
186
+ displayName : string | null ;
187
+ } ;
188
+
184
189
type TextAreaProps = {
185
190
classes ?: string ;
186
191
containerRef ?: Ref < HTMLTextAreaElement > ;
187
192
atMentionsEnabled : boolean ;
193
+ usersMatchingTerm : ( mention : string ) => UserItem [ ] ;
188
194
} ;
189
195
190
196
function TextArea ( {
191
197
classes,
192
198
containerRef,
193
199
atMentionsEnabled,
200
+ usersMatchingTerm,
194
201
...restProps
195
202
} : TextAreaProps & JSX . TextareaHTMLAttributes < HTMLTextAreaElement > ) {
196
203
const [ popoverOpen , setPopoverOpen ] = useState ( false ) ;
204
+ const [ activeMention , setActiveMention ] = useState < string > ( ) ;
197
205
const textareaRef = useSyncedRef ( containerRef ) ;
206
+ const [ highlightedSuggestion , setHighlightedSuggestion ] = useState ( 0 ) ;
207
+ const suggestions = useMemo ( ( ) => {
208
+ if ( ! atMentionsEnabled || activeMention === undefined ) {
209
+ return [ ] ;
210
+ }
211
+
212
+ return usersMatchingTerm ( activeMention ) ;
213
+ } , [ activeMention , atMentionsEnabled , usersMatchingTerm ] ) ;
198
214
199
215
useEffect ( ( ) => {
200
216
if ( ! atMentionsEnabled ) {
@@ -212,17 +228,46 @@ function TextArea({
212
228
if ( e . key === 'Escape' ) {
213
229
return ;
214
230
}
215
- setPopoverOpen (
216
- termBeforePosition ( textarea . value , textarea . selectionStart ) . startsWith (
217
- '@' ,
218
- ) ,
231
+
232
+ const termBeforeCaret = termBeforePosition (
233
+ textarea . value ,
234
+ textarea . selectionStart ,
219
235
) ;
236
+ const isAtMention = termBeforeCaret . startsWith ( '@' ) ;
237
+
238
+ setPopoverOpen ( isAtMention ) ;
239
+ setActiveMention ( isAtMention ? termBeforeCaret . substring ( 1 ) : undefined ) ;
240
+ } ) ;
241
+
242
+ listenerCollection . add ( textarea , 'keydown' , e => {
243
+ if (
244
+ ! popoverOpen ||
245
+ suggestions . length === 0 ||
246
+ ! [ 'ArrowDown' , 'ArrowUp' , 'Enter' ] . includes ( e . key )
247
+ ) {
248
+ return ;
249
+ }
250
+
251
+ // When vertical arrows or Enter are pressed while the popover is open
252
+ // with suggestions, highlight or pick the right suggestion.
253
+ e . preventDefault ( ) ;
254
+ e . stopPropagation ( ) ;
255
+
256
+ if ( e . key === 'ArrowDown' ) {
257
+ setHighlightedSuggestion ( prev =>
258
+ Math . min ( prev + 1 , suggestions . length ) ,
259
+ ) ;
260
+ } else if ( e . key === 'ArrowUp' ) {
261
+ setHighlightedSuggestion ( prev => Math . max ( prev - 1 , 0 ) ) ;
262
+ } else if ( e . key === 'Enter' ) {
263
+ // TODO "Print" suggestion in textarea
264
+ }
220
265
} ) ;
221
266
222
267
return ( ) => {
223
268
listenerCollection . removeAll ( ) ;
224
269
} ;
225
- } , [ atMentionsEnabled , popoverOpen , textareaRef ] ) ;
270
+ } , [ atMentionsEnabled , popoverOpen , suggestions . length , textareaRef ] ) ;
226
271
227
272
return (
228
273
< div className = "relative" >
@@ -241,9 +286,30 @@ function TextArea({
241
286
open = { popoverOpen }
242
287
onClose = { ( ) => setPopoverOpen ( false ) }
243
288
anchorElementRef = { textareaRef }
244
- classes = "p-2 "
289
+ classes = "p-1 "
245
290
>
246
- Suggestions
291
+ < ul className = "flex-col gap-y-0.5" >
292
+ { suggestions . map ( ( s , index ) => (
293
+ < li
294
+ key = { s . user }
295
+ className = { classnames (
296
+ 'flex justify-between items-center' ,
297
+ 'rounded p-2 hover:bg-grey-2' ,
298
+ {
299
+ 'bg-grey-2' : highlightedSuggestion === index ,
300
+ } ,
301
+ ) }
302
+ >
303
+ < span className = "truncate" > { s . user } </ span >
304
+ < span className = "text-color-text-light" > { s . displayName } </ span >
305
+ </ li >
306
+ ) ) }
307
+ { suggestions . length === 0 && (
308
+ < li className = "italic p-2" >
309
+ No matches. You can still write the username
310
+ </ li >
311
+ ) }
312
+ </ ul >
247
313
</ Popover >
248
314
) }
249
315
</ div >
@@ -391,6 +457,13 @@ export type MarkdownEditorProps = {
391
457
text : string ;
392
458
393
459
onEditText ?: ( text : string ) => void ;
460
+
461
+ /**
462
+ * A function that returns a list of users which username or display name
463
+ * match provided term.
464
+ * This is used only if `atMentionsEnabled` is `true`.
465
+ */
466
+ usersMatchingTerm : ( term : string ) => UserItem [ ] ;
394
467
} ;
395
468
396
469
/**
@@ -402,6 +475,7 @@ export default function MarkdownEditor({
402
475
onEditText = ( ) => { } ,
403
476
text,
404
477
textStyle = { } ,
478
+ usersMatchingTerm,
405
479
} : MarkdownEditorProps ) {
406
480
// Whether the preview mode is currently active.
407
481
const [ preview , setPreview ] = useState ( false ) ;
@@ -472,6 +546,7 @@ export default function MarkdownEditor({
472
546
value = { text }
473
547
style = { textStyle }
474
548
atMentionsEnabled = { atMentionsEnabled }
549
+ usersMatchingTerm = { usersMatchingTerm }
475
550
/>
476
551
) }
477
552
</ div >
0 commit comments