@actuate-media/cms-admin 0.13.0 → 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 (30) 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__/lib/mention-picker.test.d.ts +2 -0
  8. package/dist/__tests__/lib/mention-picker.test.d.ts.map +1 -0
  9. package/dist/__tests__/lib/mention-picker.test.js +122 -0
  10. package/dist/__tests__/lib/mention-picker.test.js.map +1 -0
  11. package/dist/actuate-admin.css +1 -1
  12. package/dist/components/CommentSidePanel.d.ts +9 -1
  13. package/dist/components/CommentSidePanel.d.ts.map +1 -1
  14. package/dist/components/CommentSidePanel.js +8 -7
  15. package/dist/components/CommentSidePanel.js.map +1 -1
  16. package/dist/components/MentionableTextarea.d.ts +68 -0
  17. package/dist/components/MentionableTextarea.d.ts.map +1 -0
  18. package/dist/components/MentionableTextarea.js +181 -0
  19. package/dist/components/MentionableTextarea.js.map +1 -0
  20. package/dist/lib/mention-picker.d.ts +69 -0
  21. package/dist/lib/mention-picker.d.ts.map +1 -0
  22. package/dist/lib/mention-picker.js +138 -0
  23. package/dist/lib/mention-picker.js.map +1 -0
  24. package/package.json +2 -2
  25. package/src/__tests__/components/comment-side-panel.test.tsx +134 -0
  26. package/src/__tests__/components/mentionable-textarea.test.tsx +226 -0
  27. package/src/__tests__/lib/mention-picker.test.ts +145 -0
  28. package/src/components/CommentSidePanel.tsx +37 -14
  29. package/src/components/MentionableTextarea.tsx +328 -0
  30. package/src/lib/mention-picker.ts +180 -0
@@ -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
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Mention picker client logic + REST helper.
3
+ *
4
+ * Two responsibilities, deliberately separated so each can be unit
5
+ * tested without rendering React or hitting fetch:
6
+ *
7
+ * 1. `searchMentionUsers(query)` — typed wrapper over the new
8
+ * `GET /api/cms/users/search` endpoint introduced in Phase 7.
9
+ * Returns up to 10 `{ id, name, email }` rows matching the query.
10
+ * 2. `detectMentionContext(value, caret)` — pure parser that scans a
11
+ * text input value backwards from the caret to decide whether the
12
+ * user is currently typing a `@`-mention, and if so what the
13
+ * query fragment is. Returns `null` when no active mention.
14
+ *
15
+ * Wire format note: the picker INSERTS `@<userId>` tokens at the
16
+ * cursor (matching the server-side `extractMentionedUserIds` regex
17
+ * in `cms-core/realtime/notifications.ts`). The user sees the chosen
18
+ * user's *name* in the picker but the textarea always holds the id.
19
+ * Rendering names back in the comment view is a separate slice that
20
+ * can ship later without breaking the wire format.
21
+ */
22
+ import { cmsApi } from './api.js'
23
+
24
+ export interface MentionUser {
25
+ id: string
26
+ name: string
27
+ email: string
28
+ }
29
+
30
+ export type MentionSearchOutcome =
31
+ | { ok: true; result: MentionUser[] }
32
+ | { ok: false; error: string; status: number }
33
+
34
+ export async function searchMentionUsers(query: string): Promise<MentionSearchOutcome> {
35
+ const trimmed = query.trim()
36
+ if (trimmed.length === 0) {
37
+ // Mirror the server's contract: an empty query returns an empty
38
+ // array. We short-circuit here so the picker doesn't spend a
39
+ // round-trip on a guaranteed-empty response.
40
+ return { ok: true, result: [] }
41
+ }
42
+ const res = await cmsApi<MentionUser[]>(`/users/search?q=${encodeURIComponent(trimmed)}`, {
43
+ method: 'GET',
44
+ })
45
+ if (res.error || !res.data) {
46
+ return { ok: false, error: res.error ?? `Request failed (${res.status})`, status: res.status }
47
+ }
48
+ return { ok: true, result: res.data }
49
+ }
50
+
51
+ /**
52
+ * Description of an active `@`-mention being typed in a textarea / input.
53
+ *
54
+ * - `start` and `end` are character offsets into the field value: the
55
+ * `@` itself sits at `start`, and `end` is the caret position
56
+ * (exclusive end of the query slice).
57
+ * - `query` is the substring between the `@` and the caret — exactly
58
+ * what we should pass to `searchMentionUsers()`.
59
+ *
60
+ * Callers replace `value.slice(start, end)` with the chosen token
61
+ * (`@<userId> `, including the trailing space) to commit a selection.
62
+ */
63
+ export interface MentionContext {
64
+ start: number
65
+ end: number
66
+ query: string
67
+ }
68
+
69
+ /**
70
+ * Find an active mention at the caret. Mirrors the server-side
71
+ * `extractMentionedUserIds` regex
72
+ * (`/(^|\s)@[a-zA-Z0-9_-]{3,64}\b/g`) so the picker's intuition
73
+ * about what counts as a mention can never disagree with the
74
+ * server's notification routing.
75
+ *
76
+ * Returns `null` whenever:
77
+ * - the caret is at offset 0,
78
+ * - there is no `@` between the caret and the previous whitespace
79
+ * boundary,
80
+ * - the `@` is preceded by a non-whitespace, non-newline character
81
+ * (so an email address mid-word like `foo@bar` does NOT open
82
+ * the picker), or
83
+ * - the candidate query contains a character outside the mention
84
+ * charset (`[a-zA-Z0-9_-]`).
85
+ *
86
+ * The picker DOES open with a 0–2 character query — that's the
87
+ * "you've started typing a mention" UX. But it can NEVER commit
88
+ * such a token: `insertMentionToken` only fires for a `MentionUser`
89
+ * which always has an id of at least 3 chars, matching the server
90
+ * regex's min-length requirement.
91
+ */
92
+ export function detectMentionContext(value: string, caret: number): MentionContext | null {
93
+ if (typeof value !== 'string') return null
94
+ if (caret <= 0 || caret > value.length) return null
95
+
96
+ // Walk back from the caret looking for a `@`. Stop early on any
97
+ // character that is neither a valid mention char nor `@`. This
98
+ // means a typed space, newline, punctuation, etc. terminates the
99
+ // mention candidate the user was building.
100
+ let i = caret - 1
101
+ while (i >= 0) {
102
+ const ch = value.charCodeAt(i)
103
+ if (ch === 64 /* @ */) break
104
+ if (!isMentionChar(ch)) return null
105
+ i -= 1
106
+ }
107
+ if (i < 0) return null
108
+
109
+ // The `@` must sit at the start of the field or be preceded by
110
+ // whitespace / newline. Otherwise we're mid-word (think `foo@bar`),
111
+ // which is not a mention.
112
+ if (i > 0) {
113
+ const prev = value.charCodeAt(i - 1)
114
+ if (!isBoundary(prev)) return null
115
+ }
116
+
117
+ const query = value.slice(i + 1, caret)
118
+ // Server caps queries at 64 chars; the picker simply stops
119
+ // suggesting once the typed query exceeds that — extra typing
120
+ // closes the picker so the user can complete an email or other
121
+ // long token without staring at a dead dropdown.
122
+ if (query.length > 64) return null
123
+
124
+ return { start: i, end: caret, query }
125
+ }
126
+
127
+ function isMentionChar(code: number): boolean {
128
+ // [a-zA-Z0-9_-] — keep this in lock-step with the regex in
129
+ // `cms-core/realtime/notifications.ts:extractMentionedUserIds`.
130
+ if (code === 95 /* _ */ || code === 45 /* - */) return true
131
+ if (code >= 48 && code <= 57) return true // 0-9
132
+ if (code >= 65 && code <= 90) return true // A-Z
133
+ if (code >= 97 && code <= 122) return true // a-z
134
+ return false
135
+ }
136
+
137
+ function isBoundary(code: number): boolean {
138
+ // ASCII space, tab, line-feed, or carriage-return. We deliberately
139
+ // do NOT widen this to the full Unicode whitespace class — the
140
+ // server-side `extractMentionedUserIds` regex in
141
+ // `cms-core/realtime/notifications.ts` uses `\s` which DOES match
142
+ // Unicode whitespace, but its `^` anchor means the only realistic
143
+ // mid-string boundaries in practice are these four ASCII chars
144
+ // (a CMS comment body containing U+00A0 NBSP before an `@` would
145
+ // be both unusual and not worth opening the picker for).
146
+ return code === 32 || code === 9 || code === 10 || code === 13
147
+ }
148
+
149
+ /**
150
+ * Apply a mention selection to a text value. Returns the new value
151
+ * plus the caret position where the next keystroke should land.
152
+ *
153
+ * Inserts `@<userId> ` (trailing space) so the user can keep typing
154
+ * without manually moving the cursor past the token. If the caret
155
+ * already sits before a space, we don't insert a duplicate space.
156
+ */
157
+ export interface MentionInsertion {
158
+ value: string
159
+ caret: number
160
+ }
161
+
162
+ export function insertMentionToken(
163
+ value: string,
164
+ context: MentionContext,
165
+ userId: string,
166
+ ): MentionInsertion {
167
+ const hasTrailingSpace = value.charAt(context.end) === ' '
168
+ // If the user already has a space immediately after the mention
169
+ // region, reuse it (don't double-space) but still advance the
170
+ // caret past that space so the next keystroke continues naturally
171
+ // rather than landing back in front of the space.
172
+ const trailingSpace = hasTrailingSpace ? '' : ' '
173
+ const token = `@${userId}${trailingSpace}`
174
+ const before = value.slice(0, context.start)
175
+ const after = value.slice(context.end)
176
+ return {
177
+ value: `${before}${token}${after}`,
178
+ caret: context.start + token.length + (hasTrailingSpace ? 1 : 0),
179
+ }
180
+ }