@cosxai/ui 0.6.0 → 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosxai/ui",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "COSX design system — React 19 component primitives shared across product-meta and other consumers",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -88,6 +88,24 @@ export interface MentionComboboxProps<T> {
88
88
  // Defaults to `console.warn` so a quiet swallow isn't the default
89
89
  // behaviour; consumers wanting Sentry plumb their own logger here.
90
90
  onLoadError?: ((err: unknown) => void) | undefined;
91
+
92
+ // Optional list of already-inserted mention display names. When
93
+ // provided and non-empty, the primitive draws a mirrored overlay
94
+ // behind the textarea that renders each `@Name` occurrence
95
+ // (matched longest-first, whitespace-bounded) as an accent-tinted
96
+ // chip while the user is still composing. Textarea's own glyphs
97
+ // are hidden with `color: transparent` + `caret-color: currentColor`
98
+ // so the cursor stays visible but the overlay is what the reader
99
+ // actually sees.
100
+ //
101
+ // Consumers typically derive this from their captured
102
+ // pick-list — see product-meta's CommentComposer which stores
103
+ // `PickedMention[]` on submit for the bracket-form wire
104
+ // serializer and passes `picks.map(p => p.name)` here.
105
+ //
106
+ // Omit or pass an empty array to opt out — the primitive renders
107
+ // exactly as before with zero overlay overhead.
108
+ mentionNames?: readonly string[] | undefined;
91
109
  }
92
110
 
93
111
  export interface MentionComboboxHandle {
@@ -120,10 +138,13 @@ function MentionComboboxInner<T>(
120
138
  rows = 3,
121
139
  ariaLabel,
122
140
  onLoadError,
141
+ mentionNames,
123
142
  "data-testid": testid,
124
143
  } = props;
125
144
 
126
145
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
146
+ const overlayRef = useRef<HTMLDivElement | null>(null);
147
+ const showOverlay = mentionNames !== undefined && mentionNames.length > 0;
127
148
 
128
149
  // Imperative focus — consumer composers' mount-on-open flow needs it.
129
150
  useImperativeHandle(ref, () => ({
@@ -256,8 +277,24 @@ function MentionComboboxInner<T>(
256
277
  const open = mentionStart !== null && candidates.length > 0;
257
278
  const listboxId = "ck-mention-combobox-listbox";
258
279
 
280
+ // Sync the overlay's scroll position when the textarea scrolls.
281
+ // Long comments overflow vertically; the overlay must scroll in
282
+ // lockstep so highlighted spans stay aligned with the caret.
283
+ const onTextareaScroll = useCallback(() => {
284
+ const ta = textareaRef.current;
285
+ const ov = overlayRef.current;
286
+ if (ta && ov) ov.scrollTop = ta.scrollTop;
287
+ }, []);
288
+
259
289
  return (
260
290
  <div style={containerStyle}>
291
+ {showOverlay ? (
292
+ <MentionHighlightOverlay
293
+ ref={overlayRef}
294
+ value={value}
295
+ mentionNames={mentionNames}
296
+ />
297
+ ) : null}
261
298
  <textarea
262
299
  ref={textareaRef}
263
300
  value={value}
@@ -266,6 +303,7 @@ function MentionComboboxInner<T>(
266
303
  onSelect={syncMentionFromCaret}
267
304
  onClick={syncMentionFromCaret}
268
305
  onBlur={onBlur}
306
+ onScroll={showOverlay ? onTextareaScroll : undefined}
269
307
  placeholder={placeholder}
270
308
  aria-label={ariaLabel}
271
309
  aria-autocomplete="list"
@@ -274,7 +312,7 @@ function MentionComboboxInner<T>(
274
312
  rows={rows}
275
313
  disabled={disabled}
276
314
  data-testid={testid}
277
- style={textareaStyle}
315
+ style={showOverlay ? textareaTransparentStyle : textareaStyle}
278
316
  />
279
317
  {open ? (
280
318
  <ul
@@ -318,6 +356,93 @@ export const MentionCombobox = forwardRef(MentionComboboxInner) as <T>(
318
356
  props: MentionComboboxProps<T> & { ref?: ForwardedRef<MentionComboboxHandle> },
319
357
  ) => ReactElement;
320
358
 
359
+ // ────────────────────────────────────────────────────────────────────
360
+ // Mention highlight overlay
361
+ // ────────────────────────────────────────────────────────────────────
362
+
363
+ // MentionHighlightOverlay renders a mirrored view of the composer
364
+ // text with each `@Name` (where Name matches an entry in
365
+ // `mentionNames`) wrapped in an accent-tinted chip. Sits behind the
366
+ // transparent-glyph textarea; scroll is synced by the parent when
367
+ // the textarea overflows.
368
+ //
369
+ // forwardRef so the parent can drive scrollTop on `onScroll`.
370
+ const MentionHighlightOverlay = forwardRef<
371
+ HTMLDivElement,
372
+ { value: string; mentionNames: readonly string[] }
373
+ >(function MentionHighlightOverlay({ value, mentionNames }, ref) {
374
+ const segments = splitByMentionNames(value, mentionNames);
375
+ return (
376
+ <div
377
+ ref={ref}
378
+ aria-hidden
379
+ data-testid="ck-mention-combobox-overlay"
380
+ style={overlayStyle}
381
+ >
382
+ {segments.map((seg, i) =>
383
+ seg.kind === "mention" ? (
384
+ <span key={i} style={chipStyle}>
385
+ {seg.text}
386
+ </span>
387
+ ) : (
388
+ <span key={i}>{seg.text}</span>
389
+ ),
390
+ )}
391
+ {/* Trailing zero-width character keeps a trailing newline
392
+ visible in the overlay (browsers collapse a lone final \n
393
+ in a block, but the textarea shows the empty line — the
394
+ zero-width forces the overlay to match). */}
395
+ {value.endsWith("\n") ? "​" : null}
396
+ </div>
397
+ );
398
+ });
399
+
400
+ // splitByMentionNames walks the composer text and produces an
401
+ // alternating sequence of plain-text + mention-chip segments,
402
+ // scanning left-to-right and matching known names longest-first so
403
+ // `@Ben Zhang` beats a shorter `@Ben`. Boundary rule: the char
404
+ // before `@` must be whitespace or start-of-input; this keeps an
405
+ // embedded email like `user@host` from mistakenly matching a
406
+ // zero-length prefix.
407
+ export function splitByMentionNames(
408
+ text: string,
409
+ names: readonly string[],
410
+ ): Array<{ kind: "text" | "mention"; text: string }> {
411
+ if (!text || names.length === 0) return [{ kind: "text", text }];
412
+ const tokens = [...names].sort((a, b) => b.length - a.length).map((n) => `@${n}`);
413
+ const out: Array<{ kind: "text" | "mention"; text: string }> = [];
414
+ let i = 0;
415
+ while (i < text.length) {
416
+ let matched = false;
417
+ for (const token of tokens) {
418
+ if (!text.startsWith(token, i)) continue;
419
+ if (i > 0) {
420
+ const prev = text[i - 1];
421
+ if (prev !== undefined && /\S/.test(prev)) continue;
422
+ }
423
+ out.push({ kind: "mention", text: token });
424
+ i += token.length;
425
+ matched = true;
426
+ break;
427
+ }
428
+ if (!matched) {
429
+ const last = out[out.length - 1];
430
+ const ch = text[i];
431
+ if (ch === undefined) {
432
+ i++;
433
+ continue;
434
+ }
435
+ if (last && last.kind === "text") {
436
+ last.text += ch;
437
+ } else {
438
+ out.push({ kind: "text", text: ch });
439
+ }
440
+ i++;
441
+ }
442
+ }
443
+ return out;
444
+ }
445
+
321
446
  // ────────────────────────────────────────────────────────────────────
322
447
  // Helpers — exported for consumers that need to inspect textarea
323
448
  // state themselves (e.g. computing whether an existing mention is
@@ -387,11 +512,68 @@ const textareaStyle: React.CSSProperties = {
387
512
  resize: "vertical",
388
513
  padding: "0.5rem 0.625rem",
389
514
  fontSize: "0.875rem",
515
+ lineHeight: 1.5,
516
+ border: "1px solid var(--ck-border-subtle)",
517
+ borderRadius: "var(--ck-radius-md)",
518
+ background: "var(--ck-bg-canvas)",
519
+ color: "var(--ck-text-primary)",
520
+ fontFamily: "inherit",
521
+ };
522
+
523
+ // textareaTransparentStyle is textareaStyle with:
524
+ // - color: transparent → hide the textarea's own glyphs
525
+ // - -webkit-text-fill-color: transparent → Safari counterpart
526
+ // - caretColor: currentColor via inherit chain (the outer element
527
+ // keeps its `--ck-text-primary` colour) so the cursor stays
528
+ // visible even though text glyphs don't render
529
+ // - background: transparent → let the overlay behind be visible
530
+ // - position: relative + zIndex: 1 → sit on top of the absolutely-
531
+ // positioned overlay so caret / selection are drawn above the
532
+ // highlight chips
533
+ // Rest of the box model matches textareaStyle exactly so the
534
+ // overlay lines up character-for-character.
535
+ const textareaTransparentStyle: React.CSSProperties = {
536
+ ...textareaStyle,
537
+ position: "relative",
538
+ zIndex: 1,
539
+ background: "transparent",
540
+ color: "transparent",
541
+ WebkitTextFillColor: "transparent",
542
+ caretColor: "var(--ck-text-primary, #111827)",
543
+ };
544
+
545
+ // overlayStyle is the mirrored render layer that sits BEHIND the
546
+ // transparent-glyph textarea. It matches textareaStyle's box model
547
+ // (padding, font, border) so text positions coincide with the
548
+ // textarea's positions on a character-for-character basis. The
549
+ // border here is visible so the composer keeps its box outline
550
+ // even though the textarea's own border is transparent.
551
+ const overlayStyle: React.CSSProperties = {
552
+ position: "absolute",
553
+ inset: 0,
554
+ padding: "0.5rem 0.625rem",
555
+ fontSize: "0.875rem",
556
+ lineHeight: 1.5,
390
557
  border: "1px solid var(--ck-border-subtle)",
391
558
  borderRadius: "var(--ck-radius-md)",
392
559
  background: "var(--ck-bg-canvas)",
393
560
  color: "var(--ck-text-primary)",
394
561
  fontFamily: "inherit",
562
+ whiteSpace: "pre-wrap",
563
+ wordBreak: "break-word",
564
+ overflowY: "auto",
565
+ overflowX: "hidden",
566
+ pointerEvents: "none",
567
+ boxSizing: "border-box",
568
+ zIndex: 0,
569
+ };
570
+
571
+ const chipStyle: React.CSSProperties = {
572
+ padding: "0 4px",
573
+ borderRadius: 4,
574
+ background: "var(--ck-accent-soft, rgba(37, 99, 235, 0.12))",
575
+ color: "var(--ck-accent, #2563eb)",
576
+ fontWeight: 500,
395
577
  };
396
578
 
397
579
  const listboxStyle: React.CSSProperties = {