@actuate-media/cms-admin 0.12.1 → 0.14.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.
Files changed (51) hide show
  1. package/dist/__tests__/components/comment-side-panel.test.js +93 -0
  2. package/dist/__tests__/components/comment-side-panel.test.js.map +1 -1
  3. package/dist/__tests__/components/mentionable-textarea.test.d.ts +2 -0
  4. package/dist/__tests__/components/mentionable-textarea.test.d.ts.map +1 -0
  5. package/dist/__tests__/components/mentionable-textarea.test.js +190 -0
  6. package/dist/__tests__/components/mentionable-textarea.test.js.map +1 -0
  7. package/dist/__tests__/components/notification-bell.test.js +146 -0
  8. package/dist/__tests__/components/notification-bell.test.js.map +1 -1
  9. package/dist/__tests__/lib/mention-picker.test.d.ts +2 -0
  10. package/dist/__tests__/lib/mention-picker.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/mention-picker.test.js +122 -0
  12. package/dist/__tests__/lib/mention-picker.test.js.map +1 -0
  13. package/dist/__tests__/lib/notifications-client.test.js +129 -1
  14. package/dist/__tests__/lib/notifications-client.test.js.map +1 -1
  15. package/dist/actuate-admin.css +1 -1
  16. package/dist/components/CommentSidePanel.d.ts +9 -1
  17. package/dist/components/CommentSidePanel.d.ts.map +1 -1
  18. package/dist/components/CommentSidePanel.js +8 -7
  19. package/dist/components/CommentSidePanel.js.map +1 -1
  20. package/dist/components/MentionableTextarea.d.ts +68 -0
  21. package/dist/components/MentionableTextarea.d.ts.map +1 -0
  22. package/dist/components/MentionableTextarea.js +181 -0
  23. package/dist/components/MentionableTextarea.js.map +1 -0
  24. package/dist/components/NotificationBell.d.ts +30 -9
  25. package/dist/components/NotificationBell.d.ts.map +1 -1
  26. package/dist/components/NotificationBell.js +57 -5
  27. package/dist/components/NotificationBell.js.map +1 -1
  28. package/dist/lib/api.d.ts +8 -0
  29. package/dist/lib/api.d.ts.map +1 -1
  30. package/dist/lib/api.js +10 -0
  31. package/dist/lib/api.js.map +1 -1
  32. package/dist/lib/mention-picker.d.ts +69 -0
  33. package/dist/lib/mention-picker.d.ts.map +1 -0
  34. package/dist/lib/mention-picker.js +138 -0
  35. package/dist/lib/mention-picker.js.map +1 -0
  36. package/dist/lib/notifications-client.d.ts +53 -0
  37. package/dist/lib/notifications-client.d.ts.map +1 -1
  38. package/dist/lib/notifications-client.js +89 -1
  39. package/dist/lib/notifications-client.js.map +1 -1
  40. package/package.json +2 -2
  41. package/src/__tests__/components/comment-side-panel.test.tsx +134 -0
  42. package/src/__tests__/components/mentionable-textarea.test.tsx +226 -0
  43. package/src/__tests__/components/notification-bell.test.tsx +167 -1
  44. package/src/__tests__/lib/mention-picker.test.ts +145 -0
  45. package/src/__tests__/lib/notifications-client.test.ts +145 -1
  46. package/src/components/CommentSidePanel.tsx +37 -14
  47. package/src/components/MentionableTextarea.tsx +328 -0
  48. package/src/components/NotificationBell.tsx +84 -12
  49. package/src/lib/api.ts +11 -0
  50. package/src/lib/mention-picker.ts +180 -0
  51. package/src/lib/notifications-client.ts +147 -1
@@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
3
  const cmsApiMock = vi.fn()
4
4
  vi.mock('../../lib/api.js', () => ({
5
5
  cmsApi: (...args: unknown[]) => cmsApiMock(...args),
6
+ // SSE uses EventSource, which doesn't go through cmsApi. The
7
+ // helper resolves the URL via getApiBase() — return the production
8
+ // default so tests don't need to know the prefix.
9
+ getApiBase: () => '/api/cms',
6
10
  }))
7
11
 
8
12
  const {
@@ -10,9 +14,14 @@ const {
10
14
  markAllNotificationsRead,
11
15
  markNotificationRead,
12
16
  notificationSummary,
17
+ subscribeNotificationStream,
13
18
  unreadCount,
14
19
  } = await import('../../lib/notifications-client.js')
15
- import type { Notification } from '../../lib/notifications-client.js'
20
+ import type {
21
+ EventSourceLike,
22
+ Notification,
23
+ NotificationStreamOptions,
24
+ } from '../../lib/notifications-client.js'
16
25
 
17
26
  function row(overrides: Partial<Notification> = {}): Notification {
18
27
  return {
@@ -193,3 +202,138 @@ describe('notificationSummary', () => {
193
202
  ).toMatch(/^Someone replied/)
194
203
  })
195
204
  })
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // SSE push channel — subscribeNotificationStream
208
+ // ---------------------------------------------------------------------------
209
+
210
+ /**
211
+ * In-memory `EventSource` double. Tests drive the wire by invoking
212
+ * `emit(event, payload)`; the subscriber dispatches the right
213
+ * listener. We deliberately keep the shape minimal — `EventSource`
214
+ * has dozens of properties our admin doesn't touch.
215
+ */
216
+ function makeFakeSource() {
217
+ const listeners = new Map<string, Set<(e: MessageEvent | Event) => void>>()
218
+ let closed = false
219
+ const source: EventSourceLike = {
220
+ addEventListener: (type, listener) => {
221
+ if (closed) return
222
+ let set = listeners.get(type)
223
+ if (!set) {
224
+ set = new Set()
225
+ listeners.set(type, set)
226
+ }
227
+ set.add(listener)
228
+ },
229
+ removeEventListener: (type, listener) => {
230
+ listeners.get(type)?.delete(listener)
231
+ },
232
+ close: () => {
233
+ closed = true
234
+ listeners.clear()
235
+ },
236
+ }
237
+ return {
238
+ source,
239
+ listenerCount: (type: string) => listeners.get(type)?.size ?? 0,
240
+ isClosed: () => closed,
241
+ emit(type: string, payload: unknown) {
242
+ const set = listeners.get(type)
243
+ if (!set) return
244
+ const event =
245
+ typeof payload === 'string'
246
+ ? ({ data: payload } as MessageEvent)
247
+ : payload === undefined
248
+ ? (new Event(type) as Event)
249
+ : ({ data: JSON.stringify(payload) } as MessageEvent)
250
+ for (const l of [...set]) l(event)
251
+ },
252
+ }
253
+ }
254
+
255
+ function startStream(
256
+ fake: ReturnType<typeof makeFakeSource>,
257
+ overrides: Partial<NotificationStreamOptions> = {},
258
+ ) {
259
+ return subscribeNotificationStream({
260
+ onNotification: () => undefined,
261
+ factory: () => fake.source,
262
+ ...overrides,
263
+ })
264
+ }
265
+
266
+ describe('subscribeNotificationStream', () => {
267
+ it('dispatches `notification` frames as Notification objects', () => {
268
+ const fake = makeFakeSource()
269
+ const seen: Notification[] = []
270
+ startStream(fake, { onNotification: (n) => seen.push(n) })
271
+ fake.emit('notification', row({ id: 'sse-1' }))
272
+ expect(seen.map((n) => n.id)).toEqual(['sse-1'])
273
+ })
274
+
275
+ it('dispatches `ready` frames once via onReady', () => {
276
+ const fake = makeFakeSource()
277
+ const readys: Array<{ userId: string }> = []
278
+ startStream(fake, { onReady: (p) => readys.push(p) })
279
+ fake.emit('ready', { userId: 'user-1' })
280
+ expect(readys).toEqual([{ userId: 'user-1' }])
281
+ })
282
+
283
+ it('ignores malformed payloads (defence against wire drift)', () => {
284
+ const fake = makeFakeSource()
285
+ const seen: Notification[] = []
286
+ startStream(fake, { onNotification: (n) => seen.push(n) })
287
+ // Missing id + payload — must not throw, must not deliver.
288
+ fake.emit('notification', { userId: 'user-1', createdAt: 'now' })
289
+ fake.emit('notification', 'not json')
290
+ expect(seen).toEqual([])
291
+ })
292
+
293
+ it('removes its listeners and closes the underlying source on close()', () => {
294
+ const fake = makeFakeSource()
295
+ const handle = startStream(fake)
296
+ expect(fake.listenerCount('notification')).toBe(1)
297
+ expect(fake.listenerCount('ready')).toBe(1)
298
+ expect(fake.listenerCount('error')).toBe(1)
299
+ handle.close()
300
+ expect(fake.isClosed()).toBe(true)
301
+ // Second close() is a no-op — guards against double-unmount loops.
302
+ handle.close()
303
+ expect(fake.isClosed()).toBe(true)
304
+ })
305
+
306
+ it('returns a no-op handle when no EventSource is available', () => {
307
+ // No factory + no global EventSource (vitest jsdom env doesn't
308
+ // ship one). The subscriber must NOT throw — admin code must
309
+ // remain mountable in SSR / Node environments.
310
+ const handle = subscribeNotificationStream({
311
+ onNotification: () => undefined,
312
+ factory: () => null,
313
+ })
314
+ expect(() => handle.close()).not.toThrow()
315
+ })
316
+
317
+ it('forwards error events to onError without crashing the page', () => {
318
+ const fake = makeFakeSource()
319
+ const errors: Event[] = []
320
+ startStream(fake, { onError: (e) => errors.push(e) })
321
+ fake.emit('error', undefined)
322
+ expect(errors).toHaveLength(1)
323
+ })
324
+
325
+ it('builds the stream URL from getApiBase() so consumers with a custom prefix connect to the right host', () => {
326
+ // Custom factory inspects the URL it's handed. Verifies the
327
+ // subscriber composes /<base>/notifications/stream rather than
328
+ // hardcoding the production prefix.
329
+ const seen: string[] = []
330
+ subscribeNotificationStream({
331
+ onNotification: () => undefined,
332
+ factory: (url) => {
333
+ seen.push(url)
334
+ return null
335
+ },
336
+ })
337
+ expect(seen).toEqual(['/api/cms/notifications/stream'])
338
+ })
339
+ })
@@ -15,6 +15,8 @@ import {
15
15
  type CommentThread,
16
16
  type CommentOutcome,
17
17
  } from '../lib/comments-client.js'
18
+ import type { MentionUser } from '../lib/mention-picker.js'
19
+ import { MentionableTextarea } from './MentionableTextarea.js'
18
20
 
19
21
  /**
20
22
  * Side-panel view of the slice-4 comments API. Renders threaded comments
@@ -69,6 +71,13 @@ export interface CommentSidePanelProps {
69
71
  * should leave this unset.
70
72
  */
71
73
  api?: CommentsApi
74
+ /**
75
+ * Override the mention search — tests pass a synchronous fake so
76
+ * the picker is deterministic without HTTP mocking. Production
77
+ * callers leave this unset and the textarea uses the real
78
+ * `/users/search` REST helper.
79
+ */
80
+ mentionSearch?: (query: string) => Promise<MentionUser[]>
72
81
  className?: string
73
82
  }
74
83
 
@@ -111,6 +120,7 @@ export function CommentSidePanel({
111
120
  onActiveCommentChange,
112
121
  onError,
113
122
  api = defaultApi,
123
+ mentionSearch,
114
124
  className,
115
125
  }: CommentSidePanelProps): React.JSX.Element {
116
126
  const [comments, setComments] = useState<Comment[]>([])
@@ -236,13 +246,14 @@ export function CommentSidePanel({
236
246
  </header>
237
247
 
238
248
  <section className="flex flex-col gap-1.5">
239
- <textarea
249
+ <MentionableTextarea
240
250
  className="w-full resize-none rounded border border-gray-300 p-2 text-sm focus:border-blue-500 focus:outline-none"
241
251
  rows={3}
242
- placeholder="Write a comment…"
252
+ placeholder="Write a comment… Type @ to mention a teammate."
243
253
  value={draft}
244
- onChange={(e) => setDraft(e.target.value)}
254
+ onChange={setDraft}
245
255
  data-testid="comment-composer"
256
+ search={mentionSearch}
246
257
  />
247
258
  <div className="flex justify-end">
248
259
  <button
@@ -276,6 +287,7 @@ export function CommentSidePanel({
276
287
  onEdit={(id, body) => handleEdit(id, body)}
277
288
  onToggleResolve={(id, alreadyResolved) => handleResolve(id, alreadyResolved)}
278
289
  onDelete={(id) => handleDelete(id)}
290
+ mentionSearch={mentionSearch}
279
291
  />
280
292
  ))}
281
293
  </ul>
@@ -295,6 +307,7 @@ interface CommentThreadViewProps {
295
307
  onEdit: (id: string, body: string) => Promise<boolean>
296
308
  onToggleResolve: (id: string, alreadyResolved: boolean) => void
297
309
  onDelete: (id: string) => void
310
+ mentionSearch?: (query: string) => Promise<MentionUser[]>
298
311
  }
299
312
 
300
313
  function CommentThreadView({
@@ -307,6 +320,7 @@ function CommentThreadView({
307
320
  onEdit,
308
321
  onToggleResolve,
309
322
  onDelete,
323
+ mentionSearch,
310
324
  }: CommentThreadViewProps): React.JSX.Element {
311
325
  const [replyDraft, setReplyDraft] = useState('')
312
326
  const cardClass = `rounded border bg-gray-50 p-2 ${
@@ -327,6 +341,7 @@ function CommentThreadView({
327
341
  onEdit={onEdit}
328
342
  onToggleResolve={() => onToggleResolve(thread.root.id, thread.root.isResolved)}
329
343
  onDelete={() => onDelete(thread.root.id)}
344
+ mentionSearch={mentionSearch}
330
345
  />
331
346
  {thread.replies.length > 0 && (
332
347
  <ul className="mt-2 flex flex-col gap-1 border-l-2 border-gray-200 pl-2">
@@ -341,20 +356,25 @@ function CommentThreadView({
341
356
  // expose delete here.
342
357
  onToggleResolve={null}
343
358
  onDelete={() => onDelete(reply.id)}
359
+ mentionSearch={mentionSearch}
344
360
  />
345
361
  </li>
346
362
  ))}
347
363
  </ul>
348
364
  )}
349
- <div className="mt-2 flex gap-1">
350
- <input
351
- className="flex-1 rounded border border-gray-300 px-2 py-1 text-xs focus:border-blue-500 focus:outline-none"
352
- placeholder="Reply…"
353
- value={replyDraft}
354
- onChange={(e) => setReplyDraft(e.target.value)}
355
- data-testid={`reply-input-${thread.root.id}`}
356
- onClick={(e) => e.stopPropagation()}
357
- />
365
+ <div className="mt-2 flex items-start gap-1">
366
+ <div className="flex-1">
367
+ <MentionableTextarea
368
+ className="w-full resize-none rounded border border-gray-300 px-2 py-1 text-xs focus:border-blue-500 focus:outline-none"
369
+ placeholder="Reply… Type @ to mention."
370
+ rows={1}
371
+ value={replyDraft}
372
+ onChange={setReplyDraft}
373
+ data-testid={`reply-input-${thread.root.id}`}
374
+ onClick={(e) => e.stopPropagation()}
375
+ search={mentionSearch}
376
+ />
377
+ </div>
358
378
  <button
359
379
  type="button"
360
380
  className="rounded bg-gray-700 px-2 py-1 text-xs font-medium text-white hover:bg-gray-600 disabled:opacity-50"
@@ -380,6 +400,7 @@ interface CommentRowProps {
380
400
  onEdit: (id: string, body: string) => Promise<boolean>
381
401
  onToggleResolve: null | (() => void)
382
402
  onDelete: () => void
403
+ mentionSearch?: (query: string) => Promise<MentionUser[]>
383
404
  }
384
405
 
385
406
  function CommentRow({
@@ -389,6 +410,7 @@ function CommentRow({
389
410
  onEdit,
390
411
  onToggleResolve,
391
412
  onDelete,
413
+ mentionSearch,
392
414
  }: CommentRowProps): React.JSX.Element {
393
415
  const [editing, setEditing] = useState(false)
394
416
  const [draft, setDraft] = useState(comment.body)
@@ -409,13 +431,14 @@ function CommentRow({
409
431
  {meta}
410
432
  {editing ? (
411
433
  <>
412
- <textarea
434
+ <MentionableTextarea
413
435
  className="w-full resize-none rounded border border-gray-300 p-1 text-sm"
414
436
  rows={3}
415
437
  value={draft}
416
- onChange={(e) => setDraft(e.target.value)}
438
+ onChange={setDraft}
417
439
  data-testid={`edit-textarea-${comment.id}`}
418
440
  onClick={(e) => e.stopPropagation()}
441
+ search={mentionSearch}
419
442
  />
420
443
  <div className="flex gap-1">
421
444
  <button
@@ -0,0 +1,328 @@
1
+ 'use client'
2
+
3
+ import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
4
+
5
+ import {
6
+ type MentionUser,
7
+ detectMentionContext,
8
+ insertMentionToken,
9
+ searchMentionUsers,
10
+ } from '../lib/mention-picker.js'
11
+
12
+ /**
13
+ * `<MentionableTextarea />` — drop-in replacement for `<textarea>` /
14
+ * `<input type="text">` that opens a Radix-styled mention picker
15
+ * whenever the user types `@`.
16
+ *
17
+ * Behaviour
18
+ * ─────────
19
+ * - The picker appears anchored to the textarea, below the input.
20
+ * We intentionally do NOT track the caret pixel position (that
21
+ * requires a textarea-mirror trick which doesn't add enough UX to
22
+ * justify the complexity); GitHub, Linear, and Asana all anchor
23
+ * their pickers this way on simple textareas.
24
+ * - Arrow Up / Arrow Down move the highlight. Enter / Tab commit the
25
+ * selection. Escape closes the picker without inserting anything.
26
+ * Mouse click on a row also commits.
27
+ * - The picker only fires server queries while a real mention is
28
+ * being typed; it short-circuits an empty `query` so the dropdown
29
+ * shows up immediately after `@` is typed (with an empty state)
30
+ * and only flips to "no matches" once the user has typed at least
31
+ * one character.
32
+ * - The textarea value is fully controlled by the parent — we never
33
+ * keep a separate copy. `onChange` is called with the next value
34
+ * on every keystroke AND every mention insertion.
35
+ *
36
+ * Dependency-injection seam
37
+ * ─────────────────────────
38
+ * The `search` prop accepts an override so tests can substitute a
39
+ * synchronous fake. Production callers leave it unset and the
40
+ * component uses the real `searchMentionUsers()` REST helper.
41
+ */
42
+ export interface MentionableTextareaProps {
43
+ value: string
44
+ onChange: (next: string) => void
45
+ /** Forwarded to the underlying textarea. */
46
+ placeholder?: string
47
+ /** Forwarded to the underlying textarea. */
48
+ rows?: number
49
+ className?: string
50
+ /** Forwarded — useful for keyboard shortcuts the parent owns. */
51
+ onKeyDown?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void
52
+ /** Forwarded — useful for click-to-stop-propagation in nested cards. */
53
+ onClick?: (event: React.MouseEvent<HTMLTextAreaElement>) => void
54
+ /** Test hook — required when rendering with React state assertions. */
55
+ 'data-testid'?: string
56
+ /**
57
+ * Override the user search. The default uses the REST helper.
58
+ * Tests inject an in-memory fake so dropdown contents are
59
+ * deterministic without HTTP mocking.
60
+ */
61
+ search?: (query: string) => Promise<MentionUser[]>
62
+ /**
63
+ * Optional fixed id for the picker dropdown — pass when the parent
64
+ * wires up `aria-controls`. We generate one with `useId()` when
65
+ * unset.
66
+ */
67
+ pickerId?: string
68
+ /**
69
+ * Format the displayed token. By default we show `@<name>` while
70
+ * the underlying value stores `@<userId>`. A future slice can swap
71
+ * to a rich-text editor where these diverge; the prop lets that
72
+ * land without changing the picker.
73
+ */
74
+ formatLabel?: (user: MentionUser) => string
75
+ }
76
+
77
+ interface PickerState {
78
+ open: boolean
79
+ query: string
80
+ highlight: number
81
+ users: MentionUser[]
82
+ }
83
+
84
+ const initialPicker: PickerState = {
85
+ open: false,
86
+ query: '',
87
+ highlight: 0,
88
+ users: [],
89
+ }
90
+
91
+ export function MentionableTextarea({
92
+ value,
93
+ onChange,
94
+ placeholder,
95
+ rows = 3,
96
+ className,
97
+ onKeyDown,
98
+ onClick,
99
+ 'data-testid': testId,
100
+ search = (q) => searchMentionUsers(q).then((r) => (r.ok ? r.result : [])),
101
+ pickerId: pickerIdProp,
102
+ formatLabel = (u) => `@${u.name}`,
103
+ }: MentionableTextareaProps): React.JSX.Element {
104
+ const generatedId = useId()
105
+ const pickerId = pickerIdProp ?? `actuate-mention-picker-${generatedId}`
106
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null)
107
+ const [picker, setPicker] = useState<PickerState>(initialPicker)
108
+ // `pickerRef` mirrors `picker` for the keydown handler so the
109
+ // arrow-key / enter listeners don't go stale between renders.
110
+ const pickerRef = useRef(picker)
111
+ useEffect(() => {
112
+ pickerRef.current = picker
113
+ }, [picker])
114
+ const requestSeq = useRef(0)
115
+ const mountedRef = useRef(true)
116
+ useEffect(() => {
117
+ return () => {
118
+ mountedRef.current = false
119
+ }
120
+ }, [])
121
+
122
+ const closePicker = useCallback(() => {
123
+ setPicker((p) => (p.open ? initialPicker : p))
124
+ }, [])
125
+
126
+ // Recompute the active mention context whenever the value or
127
+ // selection changes. Centralising this in one effect keeps the
128
+ // open/close logic in a single place — every textarea event
129
+ // (input, click, keyup) just calls `recompute()`.
130
+ const recompute = useCallback(
131
+ async (nextValue: string, caret: number) => {
132
+ const ctx = detectMentionContext(nextValue, caret)
133
+ if (!ctx) {
134
+ closePicker()
135
+ return
136
+ }
137
+ const id = ++requestSeq.current
138
+ // Open immediately so the user sees a dropdown the moment they
139
+ // type `@`. Clear the `users` array on every new query so the
140
+ // user can never press Enter / Tab and commit a stale row
141
+ // from the previous query while the new request is in flight
142
+ // (PR #159 Copilot finding — type `@a` then `@bo` quickly and
143
+ // hit Enter before the `@bo` results arrive: without this
144
+ // reset, the picker would commit a row from `@a`).
145
+ setPicker((p) => ({
146
+ open: true,
147
+ query: ctx.query,
148
+ highlight: 0,
149
+ users: p.query === ctx.query ? p.users : [],
150
+ }))
151
+ try {
152
+ const users = await search(ctx.query)
153
+ if (!mountedRef.current) return
154
+ // Drop stale responses — only commit if our request is still
155
+ // the latest one. Otherwise the user has typed more and a
156
+ // newer query is in flight.
157
+ if (id !== requestSeq.current) return
158
+ setPicker((p) =>
159
+ // If the picker was closed (Escape) between request and
160
+ // response, don't reopen it.
161
+ p.open ? { ...p, users, highlight: 0 } : p,
162
+ )
163
+ } catch {
164
+ if (!mountedRef.current) return
165
+ if (id !== requestSeq.current) return
166
+ // Surface no rows on failure — the textarea must keep
167
+ // working when the user is offline. The poll loop / native
168
+ // submit path is unaffected.
169
+ setPicker((p) => (p.open ? { ...p, users: [] } : p))
170
+ }
171
+ },
172
+ [closePicker, search],
173
+ )
174
+
175
+ const commitMention = useCallback(
176
+ (user: MentionUser) => {
177
+ const el = textareaRef.current
178
+ if (!el) return
179
+ const caret = el.selectionStart ?? value.length
180
+ const ctx = detectMentionContext(value, caret)
181
+ if (!ctx) {
182
+ closePicker()
183
+ return
184
+ }
185
+ const next = insertMentionToken(value, ctx, user.id)
186
+ onChange(next.value)
187
+ closePicker()
188
+ // Move the caret past the inserted token AFTER React paints
189
+ // the new value, so the selection isn't clobbered by the
190
+ // controlled update.
191
+ requestAnimationFrame(() => {
192
+ const live = textareaRef.current
193
+ if (!live) return
194
+ live.focus()
195
+ live.setSelectionRange(next.caret, next.caret)
196
+ })
197
+ },
198
+ [closePicker, onChange, value],
199
+ )
200
+
201
+ const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
202
+ const next = event.target.value
203
+ const caret = event.target.selectionStart ?? next.length
204
+ onChange(next)
205
+ void recompute(next, caret)
206
+ }
207
+
208
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
209
+ const p = pickerRef.current
210
+ if (p.open && p.users.length > 0) {
211
+ if (event.key === 'ArrowDown') {
212
+ event.preventDefault()
213
+ setPicker((s) => ({ ...s, highlight: (s.highlight + 1) % s.users.length }))
214
+ return
215
+ }
216
+ if (event.key === 'ArrowUp') {
217
+ event.preventDefault()
218
+ setPicker((s) => ({
219
+ ...s,
220
+ highlight: (s.highlight - 1 + s.users.length) % s.users.length,
221
+ }))
222
+ return
223
+ }
224
+ if (event.key === 'Enter' || event.key === 'Tab') {
225
+ event.preventDefault()
226
+ // Defensive: highlight could theoretically point past the
227
+ // end of `users` if state went stale between renders. Guard
228
+ // it so we never `undefined`-bang.
229
+ const target = p.users[p.highlight]
230
+ if (target) commitMention(target)
231
+ return
232
+ }
233
+ }
234
+ if (event.key === 'Escape' && p.open) {
235
+ event.preventDefault()
236
+ closePicker()
237
+ return
238
+ }
239
+ onKeyDown?.(event)
240
+ }
241
+
242
+ const handleClick = (event: React.MouseEvent<HTMLTextAreaElement>) => {
243
+ onClick?.(event)
244
+ const el = textareaRef.current
245
+ if (!el) return
246
+ void recompute(el.value, el.selectionStart ?? el.value.length)
247
+ }
248
+
249
+ // Closing on outside click — when the user clicks anywhere outside
250
+ // the textarea or the picker, the dropdown should disappear so it
251
+ // doesn't sit on top of unrelated UI.
252
+ useEffect(() => {
253
+ if (!picker.open) return
254
+ const onDocMouseDown = (ev: MouseEvent) => {
255
+ const t = ev.target as Node | null
256
+ if (!t) return
257
+ const parent = textareaRef.current?.parentElement
258
+ if (parent && parent.contains(t)) return
259
+ closePicker()
260
+ }
261
+ document.addEventListener('mousedown', onDocMouseDown)
262
+ return () => document.removeEventListener('mousedown', onDocMouseDown)
263
+ }, [picker.open, closePicker])
264
+
265
+ return (
266
+ <div className="relative">
267
+ <textarea
268
+ ref={textareaRef}
269
+ value={value}
270
+ onChange={handleChange}
271
+ onKeyDown={handleKeyDown}
272
+ onClick={handleClick}
273
+ placeholder={placeholder}
274
+ rows={rows}
275
+ className={className}
276
+ aria-autocomplete="list"
277
+ aria-controls={picker.open ? pickerId : undefined}
278
+ aria-expanded={picker.open}
279
+ data-testid={testId}
280
+ />
281
+ {picker.open && (
282
+ <div
283
+ id={pickerId}
284
+ role="listbox"
285
+ aria-label="Mention a teammate"
286
+ className="absolute top-full left-0 z-30 mt-1 max-h-56 w-64 overflow-y-auto rounded border border-gray-200 bg-white shadow-lg"
287
+ data-testid={testId ? `${testId}-picker` : 'mention-picker'}
288
+ >
289
+ {picker.users.length === 0 ? (
290
+ <p className="px-3 py-2 text-xs text-gray-500">
291
+ {picker.query ? 'No matches' : 'Type a name…'}
292
+ </p>
293
+ ) : (
294
+ <ul>
295
+ {picker.users.map((u, idx) => (
296
+ <li key={u.id}>
297
+ <button
298
+ type="button"
299
+ role="option"
300
+ aria-selected={idx === picker.highlight}
301
+ onMouseDown={(e) => {
302
+ // mousedown (not click) — prevents the
303
+ // textarea from losing focus before we
304
+ // commit, which would otherwise hide the
305
+ // selection and feel janky.
306
+ e.preventDefault()
307
+ commitMention(u)
308
+ }}
309
+ onMouseEnter={() => setPicker((s) => ({ ...s, highlight: idx }))}
310
+ className={`block w-full px-3 py-1.5 text-left text-xs ${
311
+ idx === picker.highlight
312
+ ? 'bg-blue-50 text-blue-900'
313
+ : 'text-gray-800 hover:bg-gray-50'
314
+ }`}
315
+ data-testid={testId ? `${testId}-picker-row-${u.id}` : `mention-row-${u.id}`}
316
+ >
317
+ <div className="font-medium">{formatLabel(u)}</div>
318
+ <div className="text-[10px] text-gray-500">{u.email}</div>
319
+ </button>
320
+ </li>
321
+ ))}
322
+ </ul>
323
+ )}
324
+ </div>
325
+ )}
326
+ </div>
327
+ )
328
+ }