@startsimpli/ui 0.4.143 → 0.4.145

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.
@@ -0,0 +1,697 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * ChangesRail — every open suggestion and comment thread on a field, in
5
+ * document order, with accept / reject / withdraw and reply / resolve / reopen
6
+ * (bd startsim-whwxd.3; design startsim-xyn25 section d).
7
+ *
8
+ * HUMAN-ONLY (Quinn, 2026-10-07). There is no machine-proposal control: the
9
+ * server refuses suggestions and comments from machine actors.
10
+ *
11
+ * WHO SEES WHAT. Accept and reject render only for someone who may edit the
12
+ * field (`canAccept`): anyone who can edit may accept (Q4). Everyone who can
13
+ * read may suggest, comment, reply and resolve (Q5). An author may withdraw
14
+ * their own suggestion.
15
+ *
16
+ * KEYBOARD. Every item is focusable and named in words (author, and whether a
17
+ * person or a machine wrote it, or that it was not recorded). Up/Down move
18
+ * between items; Enter shows the item in the text; A accepts and R rejects
19
+ * the focused suggestion. "Next change" walks the marks in the text itself.
20
+ *
21
+ * MENTIONS. `@` in a comment offers colleagues; a mention is shown in the app
22
+ * and nothing is emailed or sent to anyone (Quinn, Q7).
23
+ *
24
+ * Presentational: every write is a callback, so the host decides the
25
+ * precondition (the version an accept asserts) and folds the result.
26
+ */
27
+ import { useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
28
+
29
+ import { ActorIdentity, actorKindTitle } from '../ActorIdentity';
30
+ import { describeChange, describeSuggestion, describeThread, quoted, who } from './describe';
31
+ import type { CommentThread, RosterMember, SelectionAnchor, Suggestion, TrackActor, TrackComment } from './types';
32
+
33
+ export interface ChangesRailProps {
34
+ /** e.g. "Blog". Used in headings and accessible names. */
35
+ fieldLabel: string;
36
+ suggestions: Suggestion[];
37
+ threads: CommentThread[];
38
+ currentActorSub: string | null;
39
+ /** May accept and reject: the field's edit right. */
40
+ canAccept: boolean;
41
+ /** May comment, reply and resolve. Default true (view-only included). */
42
+ canComment?: boolean;
43
+ activeId?: string | null;
44
+ /** Show an item in the text (select its mark). */
45
+ onFocusItem?: (id: string) => void;
46
+ onNextChange?: () => void;
47
+ onPrevChange?: () => void;
48
+
49
+ onAccept?: (ids: string[]) => Promise<unknown>;
50
+ onReject?: (ids: string[]) => Promise<unknown>;
51
+ onWithdraw?: (id: string) => Promise<unknown>;
52
+ /** A new thread: on `selection` when one is given, else on the field. */
53
+ onComment?: (body: string, mentions: string[], selection: SelectionAnchor | null) => Promise<unknown>;
54
+ onReply?: (threadId: string, body: string, mentions: string[]) => Promise<unknown>;
55
+ onResolve?: (threadId: string) => Promise<unknown>;
56
+ onReopen?: (threadId: string) => Promise<unknown>;
57
+
58
+ /** The text selected in the editor or the Read view, for a span comment. */
59
+ selection?: SelectionAnchor | null;
60
+ /** Colleagues for @mentions (see useTrackChanges `mentionable`). */
61
+ mentionable?: RosterMember[];
62
+ /** Why the colleague list could not be read, if it could not. */
63
+ mentionNotice?: string | null;
64
+ className?: string;
65
+ }
66
+
67
+ function kindWords(a: TrackActor | null | undefined): string {
68
+ if (!a || a.kind === 'unknown') return 'Whether a person or a machine wrote it was not recorded.';
69
+ return a.kind === 'machine' ? 'Written by a machine.' : 'Written by a person.';
70
+ }
71
+
72
+ function Author({ actor }: { actor: TrackActor }) {
73
+ return (
74
+ <ActorIdentity
75
+ actor={{ id: actor.sub, name: actor.label || actor.sub || 'Unknown' }}
76
+ kind={actor.kind}
77
+ kindSource={actor.kindSource}
78
+ showInitials={false}
79
+ />
80
+ );
81
+ }
82
+
83
+ const btn =
84
+ 'rounded border border-border px-2 py-0.5 text-xs font-medium transition-colors hover:bg-muted ' +
85
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50';
86
+ const primaryBtn =
87
+ 'rounded bg-primary px-2 py-0.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 ' +
88
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50';
89
+
90
+ type Item = { kind: 'suggestion'; s: Suggestion; at: number } | { kind: 'thread'; t: CommentThread; at: number };
91
+
92
+ export function ChangesRail(props: ChangesRailProps) {
93
+ const {
94
+ fieldLabel,
95
+ suggestions,
96
+ threads,
97
+ currentActorSub,
98
+ canAccept,
99
+ canComment = true,
100
+ activeId,
101
+ onFocusItem,
102
+ onNextChange,
103
+ onPrevChange,
104
+ onAccept,
105
+ onReject,
106
+ onWithdraw,
107
+ onComment,
108
+ selection = null,
109
+ mentionable = [],
110
+ mentionNotice,
111
+ className,
112
+ } = props;
113
+
114
+ const [busy, setBusy] = useState<string | null>(null);
115
+ const [errors, setErrors] = useState<Record<string, string>>({});
116
+ const listRef = useRef<HTMLOListElement>(null);
117
+
118
+ const open = useMemo(() => {
119
+ const items: Item[] = [];
120
+ for (const s of suggestions) {
121
+ if (s.status === 'open' && s.anchorState !== 'orphaned') items.push({ kind: 'suggestion', s, at: s.from });
122
+ }
123
+ for (const t of threads) {
124
+ if (!t.resolved && t.anchorState !== 'orphaned') items.push({ kind: 'thread', t, at: t.from ?? -1 });
125
+ }
126
+ return items.sort((a, b) => a.at - b.at);
127
+ }, [suggestions, threads]);
128
+ const orphanedSuggestions = suggestions.filter((s) => s.status === 'open' && s.anchorState === 'orphaned');
129
+ const orphanedThreads = threads.filter((t) => !t.resolved && t.anchorState === 'orphaned');
130
+ const resolved = threads.filter((t) => t.resolved);
131
+ const openSuggestionIds = open.flatMap((i) => (i.kind === 'suggestion' ? [i.s.id] : []));
132
+
133
+ /** Resolves true when the write landed, false when it failed (and says why). */
134
+ async function run(key: string, fn: () => Promise<unknown>): Promise<boolean> {
135
+ setBusy(key);
136
+ setErrors((e) => {
137
+ const { [key]: _drop, ...rest } = e;
138
+ return rest;
139
+ });
140
+ try {
141
+ await fn();
142
+ return true;
143
+ } catch (err) {
144
+ setErrors((e) => ({ ...e, [key]: err instanceof Error ? err.message : 'That did not work.' }));
145
+ return false;
146
+ } finally {
147
+ setBusy(null);
148
+ }
149
+ }
150
+
151
+ function moveFocus(from: HTMLElement, delta: 1 | -1) {
152
+ const items = Array.from(listRef.current?.querySelectorAll<HTMLElement>('[data-rail-item]') ?? []);
153
+ const i = items.indexOf(from);
154
+ items[Math.max(0, Math.min(items.length - 1, i + delta))]?.focus();
155
+ }
156
+
157
+ function onItemKey(e: KeyboardEvent<HTMLElement>, item: Item) {
158
+ if (e.target !== e.currentTarget) return; // a button or a reply box inside
159
+ const id = item.kind === 'suggestion' ? item.s.id : item.t.id;
160
+ if (e.key === 'ArrowDown') moveFocus(e.currentTarget, 1);
161
+ else if (e.key === 'ArrowUp') moveFocus(e.currentTarget, -1);
162
+ else if (e.key === 'Enter') onFocusItem?.(id);
163
+ else if (item.kind === 'suggestion' && canAccept && (e.key === 'a' || e.key === 'A') && onAccept)
164
+ void run(id, () => onAccept([id]));
165
+ else if (item.kind === 'suggestion' && canAccept && (e.key === 'r' || e.key === 'R') && onReject)
166
+ void run(id, () => onReject([id]));
167
+ else return;
168
+ e.preventDefault();
169
+ e.stopPropagation();
170
+ }
171
+
172
+ const nSugg = openSuggestionIds.length;
173
+ const nThreads = open.length - nSugg;
174
+
175
+ return (
176
+ <section
177
+ aria-label={`Suggestions and comments on ${fieldLabel}`}
178
+ data-testid="changes-rail"
179
+ className={['@container flex flex-col gap-3 text-sm', className].filter(Boolean).join(' ')}
180
+ >
181
+ <header className="flex flex-col gap-2 @xs:flex-row @xs:flex-wrap @xs:items-center @xs:justify-between">
182
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
183
+ {nSugg} {nSugg === 1 ? 'suggestion' : 'suggestions'} · {nThreads} {nThreads === 1 ? 'comment' : 'comments'}
184
+ </h3>
185
+ <div className="flex flex-wrap gap-1">
186
+ {onPrevChange && (
187
+ <button type="button" className={btn} onClick={onPrevChange} title="Shift+F8 in the text">
188
+ Previous
189
+ </button>
190
+ )}
191
+ {onNextChange && (
192
+ <button type="button" className={btn} onClick={onNextChange} title="F8 in the text">
193
+ Next change
194
+ </button>
195
+ )}
196
+ {canAccept && nSugg > 1 && onAccept && (
197
+ <button
198
+ type="button"
199
+ className={btn}
200
+ disabled={busy !== null}
201
+ onClick={() => void run('all', () => onAccept(openSuggestionIds))}
202
+ >
203
+ Accept all
204
+ </button>
205
+ )}
206
+ {canAccept && nSugg > 1 && onReject && (
207
+ <button
208
+ type="button"
209
+ className={btn}
210
+ disabled={busy !== null}
211
+ onClick={() => void run('all', () => onReject(openSuggestionIds))}
212
+ >
213
+ Reject all
214
+ </button>
215
+ )}
216
+ </div>
217
+ </header>
218
+ {errors.all && (
219
+ <p role="alert" className="text-xs text-destructive">
220
+ {errors.all}
221
+ </p>
222
+ )}
223
+
224
+ {open.length === 0 ? (
225
+ <p className="text-xs text-muted-foreground">No open suggestions or comments on {fieldLabel}.</p>
226
+ ) : (
227
+ <ol ref={listRef} className="flex flex-col gap-2" aria-label="Open suggestions and comments, in document order">
228
+ {open.map((item) => {
229
+ const id = item.kind === 'suggestion' ? item.s.id : item.t.id;
230
+ const active = activeId === id;
231
+ const label =
232
+ item.kind === 'suggestion'
233
+ ? `${describeSuggestion(item.s)}. ${kindWords(item.s.author)}`
234
+ : `${describeThread(item.t)}. ${kindWords(item.t.comments[0]?.author)}`;
235
+ return (
236
+ <li
237
+ key={id}
238
+ data-rail-item=""
239
+ data-item-id={id}
240
+ tabIndex={0}
241
+ aria-label={label}
242
+ aria-current={active ? 'true' : undefined}
243
+ onKeyDown={(e) => onItemKey(e, item)}
244
+ onFocus={(e) => {
245
+ if (e.target === e.currentTarget) onFocusItem?.(id);
246
+ }}
247
+ className={[
248
+ 'rounded-md border p-2 outline-none focus-visible:ring-2 focus-visible:ring-ring',
249
+ active ? 'border-primary bg-primary/5' : 'border-border bg-card',
250
+ ].join(' ')}
251
+ >
252
+ {item.kind === 'suggestion' ? (
253
+ <SuggestionCard
254
+ s={item.s}
255
+ own={!!currentActorSub && item.s.author.sub === currentActorSub}
256
+ canAccept={canAccept}
257
+ busy={busy !== null}
258
+ error={errors[id]}
259
+ onAccept={onAccept && (() => run(id, () => onAccept([id])))}
260
+ onReject={onReject && (() => run(id, () => onReject([id])))}
261
+ onWithdraw={onWithdraw && (() => run(id, () => onWithdraw(id)))}
262
+ />
263
+ ) : (
264
+ <ThreadCard
265
+ t={item.t}
266
+ canComment={canComment}
267
+ busy={busy !== null}
268
+ error={errors[id]}
269
+ mentionable={mentionable}
270
+ run={(fn) => run(id, fn)}
271
+ onReply={props.onReply}
272
+ onResolve={props.onResolve}
273
+ onReopen={props.onReopen}
274
+ />
275
+ )}
276
+ </li>
277
+ );
278
+ })}
279
+ </ol>
280
+ )}
281
+
282
+ {canComment && onComment && (
283
+ <NewComment
284
+ fieldLabel={fieldLabel}
285
+ selection={selection}
286
+ mentionable={mentionable}
287
+ mentionNotice={mentionNotice}
288
+ busy={busy !== null}
289
+ error={errors.new}
290
+ onSubmit={(body, mentions) => run('new', () => onComment(body, mentions, selection))}
291
+ />
292
+ )}
293
+
294
+ {(orphanedSuggestions.length > 0 || orphanedThreads.length > 0) && (
295
+ <details className="rounded-md border border-dashed border-border p-2">
296
+ <summary className="cursor-pointer text-xs font-medium text-muted-foreground">
297
+ Orphaned ({orphanedSuggestions.length + orphanedThreads.length}): the text they were made on is gone
298
+ </summary>
299
+ <ul className="mt-2 flex flex-col gap-2">
300
+ {orphanedSuggestions.map((s) => (
301
+ <li key={s.id} className="text-xs">
302
+ <Author actor={s.author} /> suggested: {describeChange(s.quote, s.replacement)}
303
+ {currentActorSub && s.author.sub === currentActorSub && onWithdraw && (
304
+ <button type="button" className={`${btn} ml-2`} onClick={() => void run(s.id, () => onWithdraw(s.id))}>
305
+ Withdraw
306
+ </button>
307
+ )}
308
+ {errors[s.id] && <span className="block text-destructive">{errors[s.id]}</span>}
309
+ </li>
310
+ ))}
311
+ {orphanedThreads.map((t) => (
312
+ <li key={t.id} className="text-xs">
313
+ <ThreadCard
314
+ t={t}
315
+ canComment={canComment}
316
+ busy={busy !== null}
317
+ error={errors[t.id]}
318
+ mentionable={mentionable}
319
+ run={(fn) => run(t.id, fn)}
320
+ onReply={props.onReply}
321
+ onResolve={props.onResolve}
322
+ onReopen={props.onReopen}
323
+ />
324
+ </li>
325
+ ))}
326
+ </ul>
327
+ </details>
328
+ )}
329
+
330
+ {resolved.length > 0 && (
331
+ <details className="rounded-md border border-border p-2">
332
+ <summary className="cursor-pointer text-xs font-medium text-muted-foreground">Resolved ({resolved.length})</summary>
333
+ <ul className="mt-2 flex flex-col gap-2">
334
+ {resolved.map((t) => (
335
+ <li key={t.id} className="text-xs" aria-label={`Resolved: ${describeThread(t)}`}>
336
+ <ThreadCard
337
+ t={t}
338
+ canComment={canComment}
339
+ busy={busy !== null}
340
+ error={errors[t.id]}
341
+ mentionable={mentionable}
342
+ run={(fn) => run(t.id, fn)}
343
+ onReply={props.onReply}
344
+ onResolve={props.onResolve}
345
+ onReopen={props.onReopen}
346
+ />
347
+ </li>
348
+ ))}
349
+ </ul>
350
+ </details>
351
+ )}
352
+ </section>
353
+ );
354
+ }
355
+
356
+ function SuggestionCard({
357
+ s,
358
+ own,
359
+ canAccept,
360
+ busy,
361
+ error,
362
+ onAccept,
363
+ onReject,
364
+ onWithdraw,
365
+ }: {
366
+ s: Suggestion;
367
+ own: boolean;
368
+ canAccept: boolean;
369
+ busy: boolean;
370
+ error?: string;
371
+ onAccept?: () => void;
372
+ onReject?: () => void;
373
+ onWithdraw?: () => void;
374
+ }) {
375
+ return (
376
+ <div className="flex flex-col gap-1.5" data-testid="rail-suggestion">
377
+ <div className="flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
378
+ <Author actor={s.author} />
379
+ <span>suggests</span>
380
+ {s.anchorState === 'reanchored' && <span>(the text around it changed)</span>}
381
+ </div>
382
+ <p className="break-words text-xs">
383
+ {s.quote && <del className="bg-red-500/10 text-red-700 dark:text-red-300">{s.quote}</del>}
384
+ {s.quote && s.replacement && ' → '}
385
+ {s.replacement && <ins className="bg-green-600/10 text-green-700 no-underline dark:text-green-300">{s.replacement}</ins>}
386
+ </p>
387
+ <div className="flex flex-wrap gap-1">
388
+ {canAccept && onAccept && (
389
+ <button type="button" className={primaryBtn} disabled={busy} onClick={onAccept} aria-keyshortcuts="A">
390
+ Accept
391
+ </button>
392
+ )}
393
+ {canAccept && onReject && (
394
+ <button type="button" className={btn} disabled={busy} onClick={onReject} aria-keyshortcuts="R">
395
+ Reject
396
+ </button>
397
+ )}
398
+ {own && onWithdraw && (
399
+ <button type="button" className={btn} disabled={busy} onClick={onWithdraw}>
400
+ Withdraw
401
+ </button>
402
+ )}
403
+ </div>
404
+ {error && (
405
+ <p role="alert" className="text-xs text-destructive">
406
+ {error}
407
+ </p>
408
+ )}
409
+ </div>
410
+ );
411
+ }
412
+
413
+ function CommentBody({ c, mentionable }: { c: TrackComment; mentionable: RosterMember[] }) {
414
+ if (c.deleted || c.body == null) return <p className="text-xs italic text-muted-foreground">Comment deleted.</p>;
415
+ const names = c.mentions.map((sub) => mentionable.find((m) => m.sub === sub)?.label || sub);
416
+ return (
417
+ <div className="flex flex-col gap-0.5">
418
+ <p className="whitespace-pre-wrap break-words text-xs text-foreground">{c.body}</p>
419
+ {names.length > 0 && (
420
+ <p className="text-[11px] text-muted-foreground">Mentioned: {names.map((n) => `@${n}`).join(', ')}</p>
421
+ )}
422
+ </div>
423
+ );
424
+ }
425
+
426
+ function ThreadCard({
427
+ t,
428
+ canComment,
429
+ busy,
430
+ error,
431
+ mentionable,
432
+ run,
433
+ onReply,
434
+ onResolve,
435
+ onReopen,
436
+ }: {
437
+ t: CommentThread;
438
+ canComment: boolean;
439
+ busy: boolean;
440
+ error?: string;
441
+ mentionable: RosterMember[];
442
+ run: (fn: () => Promise<unknown>) => Promise<boolean>;
443
+ } & Pick<ChangesRailProps, 'onReply' | 'onResolve' | 'onReopen'>) {
444
+ const [replying, setReplying] = useState(false);
445
+ return (
446
+ <div className="flex flex-col gap-1.5" data-testid="rail-thread">
447
+ {t.quote ? (
448
+ <p className="text-xs text-muted-foreground">
449
+ On <mark className="bg-yellow-400/30 text-inherit">{quoted(t.quote)}</mark>
450
+ </p>
451
+ ) : (
452
+ <p className="text-xs text-muted-foreground">{t.field ? 'On the whole field' : 'On the whole record'}</p>
453
+ )}
454
+ <ol className="flex flex-col gap-1.5">
455
+ {t.comments.map((c) => (
456
+ <li key={c.id} className="flex flex-col gap-0.5" title={actorKindTitle(c.author.kind, c.author.kindSource)}>
457
+ <Author actor={c.author} />
458
+ <CommentBody c={c} mentionable={mentionable} />
459
+ </li>
460
+ ))}
461
+ </ol>
462
+ {t.resolved && t.resolution && (
463
+ <p className="text-[11px] text-muted-foreground">Resolved by {who(t.resolution.by)}</p>
464
+ )}
465
+ {canComment && (
466
+ <div className="flex flex-wrap gap-1">
467
+ {!t.resolved && onReply && !replying && (
468
+ <button type="button" className={btn} disabled={busy} onClick={() => setReplying(true)}>
469
+ Reply
470
+ </button>
471
+ )}
472
+ {!t.resolved && onResolve && (
473
+ <button type="button" className={btn} disabled={busy} onClick={() => void run(() => onResolve(t.id))}>
474
+ Resolve
475
+ </button>
476
+ )}
477
+ {t.resolved && onReopen && (
478
+ <button type="button" className={btn} disabled={busy} onClick={() => void run(() => onReopen(t.id))}>
479
+ Reopen
480
+ </button>
481
+ )}
482
+ </div>
483
+ )}
484
+ {replying && onReply && (
485
+ <Composer
486
+ label="Reply"
487
+ submitLabel="Reply"
488
+ mentionable={mentionable}
489
+ busy={busy}
490
+ autoFocus
491
+ onCancel={() => setReplying(false)}
492
+ onSubmit={async (body, mentions) => {
493
+ const ok = await run(() => onReply(t.id, body, mentions));
494
+ if (ok) setReplying(false);
495
+ return ok;
496
+ }}
497
+ />
498
+ )}
499
+ {error && (
500
+ <p role="alert" className="text-xs text-destructive">
501
+ {error}
502
+ </p>
503
+ )}
504
+ </div>
505
+ );
506
+ }
507
+
508
+ function NewComment({
509
+ fieldLabel,
510
+ selection,
511
+ mentionable,
512
+ mentionNotice,
513
+ busy,
514
+ error,
515
+ onSubmit,
516
+ }: {
517
+ fieldLabel: string;
518
+ selection: SelectionAnchor | null;
519
+ mentionable: RosterMember[];
520
+ mentionNotice?: string | null;
521
+ busy: boolean;
522
+ error?: string;
523
+ onSubmit: (body: string, mentions: string[]) => Promise<boolean>;
524
+ }) {
525
+ const on = selection && selection.to > selection.from ? selection : null;
526
+ return (
527
+ <div className="flex flex-col gap-1 rounded-md border border-border p-2" data-testid="new-comment">
528
+ <p className="text-xs text-muted-foreground">
529
+ {on ? (
530
+ <>
531
+ Comment on <mark className="bg-yellow-400/30 text-inherit">{quoted(on.quote)}</mark>
532
+ </>
533
+ ) : (
534
+ `Comment on ${fieldLabel} (select text first to comment on a passage)`
535
+ )}
536
+ </p>
537
+ <Composer label={`New comment on ${fieldLabel}`} submitLabel="Comment" mentionable={mentionable} busy={busy} onSubmit={onSubmit} />
538
+ {mentionNotice && <p className="text-[11px] text-muted-foreground">{mentionNotice}</p>}
539
+ <p className="text-[11px] text-muted-foreground">Type @ to mention a colleague. Mentions show here only; nobody is emailed.</p>
540
+ {error && (
541
+ <p role="alert" className="text-xs text-destructive">
542
+ {error}
543
+ </p>
544
+ )}
545
+ </div>
546
+ );
547
+ }
548
+
549
+ /** The @query being typed just before the caret, or null. */
550
+ export function mentionQuery(text: string, caret: number): { start: number; query: string } | null {
551
+ const m = /(^|\s)@([^\s@]*)$/.exec(text.slice(0, caret));
552
+ if (!m) return null;
553
+ return { start: caret - m[2].length - 1, query: m[2] };
554
+ }
555
+
556
+ /** The subs a body still mentions: picked, and their `@label` still present. */
557
+ export function mentionsIn(body: string, picked: RosterMember[]): string[] {
558
+ const out: string[] = [];
559
+ for (const m of picked) {
560
+ const tag = `@${m.label || m.sub}`;
561
+ if (body.includes(tag) && !out.includes(m.sub)) out.push(m.sub);
562
+ }
563
+ return out;
564
+ }
565
+
566
+ function Composer({
567
+ label,
568
+ submitLabel,
569
+ mentionable,
570
+ busy,
571
+ autoFocus,
572
+ onSubmit,
573
+ onCancel,
574
+ }: {
575
+ label: string;
576
+ submitLabel: string;
577
+ mentionable: RosterMember[];
578
+ busy: boolean;
579
+ autoFocus?: boolean;
580
+ /** Resolves false when the write failed: the text is kept. */
581
+ onSubmit: (body: string, mentions: string[]) => Promise<boolean>;
582
+ onCancel?: () => void;
583
+ }): ReactNode {
584
+ const [body, setBody] = useState('');
585
+ const [picked, setPicked] = useState<RosterMember[]>([]);
586
+ const [q, setQ] = useState<{ start: number; query: string } | null>(null);
587
+ const [hi, setHi] = useState(0);
588
+ const ref = useRef<HTMLTextAreaElement>(null);
589
+ const listId = `mention-${useId()}`;
590
+ // Where the caret goes after a picked mention. Applied in a LAYOUT effect,
591
+ // i.e. synchronously after React writes the new value and before the
592
+ // browser can deliver another keystroke. A requestAnimationFrame here let a
593
+ // fast typist's next characters land at the end of the text and then jump
594
+ // (caught on the deployed gate: "...nly).(sandbox o").
595
+ const caretRef = useRef<number | null>(null);
596
+ useLayoutEffect(() => {
597
+ const at = caretRef.current;
598
+ const el = ref.current;
599
+ if (at == null || !el) return;
600
+ caretRef.current = null;
601
+ el.focus();
602
+ el.setSelectionRange(at, at);
603
+ }, [body]);
604
+
605
+ const matches = useMemo(() => {
606
+ if (!q) return [];
607
+ const needle = q.query.toLowerCase();
608
+ return mentionable.filter((m) => (m.label || m.sub).toLowerCase().includes(needle)).slice(0, 8);
609
+ }, [q, mentionable]);
610
+
611
+ const choose = (m: RosterMember) => {
612
+ if (!q) return;
613
+ const el = ref.current;
614
+ const caret = el?.selectionStart ?? body.length;
615
+ const tag = `@${m.label || m.sub} `;
616
+ const next = body.slice(0, q.start) + tag + body.slice(caret);
617
+ setBody(next);
618
+ setPicked((p) => (p.some((x) => x.sub === m.sub) ? p : [...p, m]));
619
+ setQ(null);
620
+ caretRef.current = q.start + tag.length;
621
+ };
622
+
623
+ const submit = async () => {
624
+ const text = body.trim();
625
+ if (!text) return;
626
+ if (!(await onSubmit(text, mentionsIn(text, picked)))) return;
627
+ setBody('');
628
+ setPicked([]);
629
+ };
630
+
631
+ return (
632
+ <div className="relative flex flex-col gap-1">
633
+ <textarea
634
+ ref={ref}
635
+ aria-label={label}
636
+ value={body}
637
+ rows={2}
638
+ autoFocus={autoFocus}
639
+ role="combobox"
640
+ aria-autocomplete="list"
641
+ aria-expanded={matches.length > 0}
642
+ aria-controls={listId}
643
+ className="w-full resize-y rounded-md border border-input bg-background p-2 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
644
+ onChange={(e) => {
645
+ setBody(e.target.value);
646
+ setQ(mentionQuery(e.target.value, e.target.selectionStart ?? e.target.value.length));
647
+ setHi(0);
648
+ }}
649
+ onKeyDown={(e) => {
650
+ if (matches.length > 0) {
651
+ if (e.key === 'ArrowDown') setHi((h) => (h + 1) % matches.length);
652
+ else if (e.key === 'ArrowUp') setHi((h) => (h - 1 + matches.length) % matches.length);
653
+ else if (e.key === 'Enter' || e.key === 'Tab') choose(matches[hi]);
654
+ else if (e.key === 'Escape') setQ(null);
655
+ else return;
656
+ e.preventDefault();
657
+ return;
658
+ }
659
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
660
+ e.preventDefault();
661
+ void submit();
662
+ }
663
+ if (e.key === 'Escape' && onCancel) onCancel();
664
+ e.stopPropagation();
665
+ }}
666
+ />
667
+ {matches.length > 0 && (
668
+ <ul id={listId} role="listbox" aria-label="Colleagues" className="absolute top-full z-10 mt-1 w-full rounded-md border border-border bg-popover p-1 text-xs shadow-md">
669
+ {matches.map((m, i) => (
670
+ <li
671
+ key={m.sub}
672
+ role="option"
673
+ aria-selected={i === hi}
674
+ className={`cursor-pointer rounded px-2 py-1 ${i === hi ? 'bg-muted' : ''}`}
675
+ onMouseDown={(e) => {
676
+ e.preventDefault();
677
+ choose(m);
678
+ }}
679
+ >
680
+ {m.label || m.sub}
681
+ </li>
682
+ ))}
683
+ </ul>
684
+ )}
685
+ <div className="flex gap-1">
686
+ <button type="button" className={primaryBtn} disabled={busy || !body.trim()} onClick={() => void submit()}>
687
+ {submitLabel}
688
+ </button>
689
+ {onCancel && (
690
+ <button type="button" className={btn} onClick={onCancel}>
691
+ Cancel
692
+ </button>
693
+ )}
694
+ </div>
695
+ </div>
696
+ );
697
+ }