@cosxai/ui 0.6.0 → 0.7.1
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
|
@@ -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,75 @@ 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
|
+
// chipStyle must NOT add any horizontal or vertical space (no
|
|
572
|
+
// padding, no margin, no border) — the textarea's underlying
|
|
573
|
+
// `@Ben Zhang` glyphs occupy the natural text width, and any extra
|
|
574
|
+
// width on the overlay chip would shift every character after it
|
|
575
|
+
// out of alignment with the textarea's caret. Instead we lean on
|
|
576
|
+
// background + color + a tight border-radius to convey "chip"
|
|
577
|
+
// without adding layout. `font-weight` stays at the inherited
|
|
578
|
+
// value for the same reason: heavier weight changes glyph advance
|
|
579
|
+
// widths and drifts the trailing text by a sub-pixel per character.
|
|
580
|
+
const chipStyle: React.CSSProperties = {
|
|
581
|
+
borderRadius: 3,
|
|
582
|
+
background: "var(--ck-accent-soft, rgba(37, 99, 235, 0.12))",
|
|
583
|
+
color: "var(--ck-accent, #2563eb)",
|
|
395
584
|
};
|
|
396
585
|
|
|
397
586
|
const listboxStyle: React.CSSProperties = {
|