@cosxai/ui 0.5.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.5.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",
@@ -0,0 +1,605 @@
1
+ /**
2
+ * MentionCombobox — headless `@`-trigger combobox with a textarea +
3
+ * async-loaded dropdown. Consumer-agnostic: the dropdown's rows and
4
+ * the data source are slotted in via props, so the same primitive
5
+ * powers comment composers, share-invitation forms, DM autocomplete,
6
+ * and anything else that needs `@<thing>` selection.
7
+ *
8
+ * Behavior:
9
+ * - User types `@` (or a custom `trigger`) → the component captures
10
+ * the cursor position + opens the dropdown
11
+ * - Whitespace after the trigger closes the dropdown without
12
+ * inserting (allows typing an `@email@host` verbatim)
13
+ * - The substring from trigger to next whitespace is the query;
14
+ * debounced (~150ms) before firing `loadCandidates(query, signal)`
15
+ * - Previous in-flight request is aborted on every keystroke
16
+ * - ArrowUp / ArrowDown move the highlight; Enter inserts the
17
+ * selected candidate's `getInsertionText(item)` back into the
18
+ * textarea, replacing the `@<query>` substring; Esc closes
19
+ *
20
+ * Positioning:
21
+ * - Dropdown sits absolutely positioned beneath the textarea — make
22
+ * sure the parent isn't `overflow: hidden` if the dropdown would
23
+ * extend past it. For embedded-in-modal usage, wrap consumers in
24
+ * a positioned ancestor with enough room or stamp `overflow:
25
+ * visible`.
26
+ *
27
+ * Public API (stable v0.6+):
28
+ * - generic `MentionComboboxProps<T>`
29
+ * - `MentionComboboxHandle.focus()`
30
+ * - `findActiveMentionStart()`, `extractQuery()` helpers (re-exported
31
+ * for consumers that need to inspect the textarea state themselves)
32
+ */
33
+
34
+ import {
35
+ forwardRef,
36
+ useCallback,
37
+ useEffect,
38
+ useImperativeHandle,
39
+ useMemo,
40
+ useRef,
41
+ useState,
42
+ type ChangeEvent,
43
+ type ForwardedRef,
44
+ type KeyboardEvent,
45
+ type ReactElement,
46
+ type ReactNode,
47
+ } from "react";
48
+
49
+ export interface MentionComboboxProps<T> {
50
+ value: string;
51
+ onChange: (next: string) => void;
52
+
53
+ // Data source. Called every time the debounced query changes.
54
+ // The signal aborts when the next keystroke or unmount kicks in;
55
+ // the loader is responsible for forwarding it to fetch().
56
+ loadCandidates: (query: string, signal: AbortSignal) => Promise<T[]>;
57
+
58
+ // React-key extractor for the dropdown.
59
+ getItemKey: (item: T) => string;
60
+
61
+ // The text that gets dropped into the textarea on selection,
62
+ // INCLUDING the trigger rune. Consumer decides the canonical form
63
+ // (`@email@host`, `@slug`, `@<display-name>`, etc.).
64
+ getInsertionText: (item: T) => string;
65
+
66
+ // Row renderer. The primitive owns the listbox shell + the active
67
+ // background; the consumer just describes the row's contents.
68
+ renderItem: (item: T, ctx: { highlighted: boolean }) => ReactNode;
69
+
70
+ // Trigger character. Defaults to `@`.
71
+ trigger?: string | undefined;
72
+
73
+ // Debounce window before firing loadCandidates. Defaults to 150ms.
74
+ debounceMs?: number | undefined;
75
+
76
+ // Hard cap on rendered candidates (the dropdown shell). The loader
77
+ // can return more; the primitive truncates. Defaults to 8.
78
+ maxResults?: number | undefined;
79
+
80
+ // Standard textarea props.
81
+ placeholder?: string | undefined;
82
+ disabled?: boolean | undefined;
83
+ rows?: number | undefined;
84
+ ariaLabel: string;
85
+ "data-testid"?: string | undefined;
86
+
87
+ // Optional error sink. Receives anything that's NOT an abort error.
88
+ // Defaults to `console.warn` so a quiet swallow isn't the default
89
+ // behaviour; consumers wanting Sentry plumb their own logger here.
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;
109
+ }
110
+
111
+ export interface MentionComboboxHandle {
112
+ focus: () => void;
113
+ }
114
+
115
+ const DEFAULT_DEBOUNCE_MS = 150;
116
+ const DEFAULT_MAX_RESULTS = 8;
117
+ const DEFAULT_TRIGGER = "@";
118
+ const ABORTED_NAME = "AbortError";
119
+
120
+ // React.forwardRef erases generics; we cast back to a generic-aware
121
+ // signature so consumers get `MentionCombobox<T>` ergonomics.
122
+ function MentionComboboxInner<T>(
123
+ props: MentionComboboxProps<T>,
124
+ ref: ForwardedRef<MentionComboboxHandle>,
125
+ ): ReactElement {
126
+ const {
127
+ value,
128
+ onChange,
129
+ loadCandidates,
130
+ getItemKey,
131
+ getInsertionText,
132
+ renderItem,
133
+ trigger = DEFAULT_TRIGGER,
134
+ debounceMs = DEFAULT_DEBOUNCE_MS,
135
+ maxResults = DEFAULT_MAX_RESULTS,
136
+ placeholder,
137
+ disabled = false,
138
+ rows = 3,
139
+ ariaLabel,
140
+ onLoadError,
141
+ mentionNames,
142
+ "data-testid": testid,
143
+ } = props;
144
+
145
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
146
+ const overlayRef = useRef<HTMLDivElement | null>(null);
147
+ const showOverlay = mentionNames !== undefined && mentionNames.length > 0;
148
+
149
+ // Imperative focus — consumer composers' mount-on-open flow needs it.
150
+ useImperativeHandle(ref, () => ({
151
+ focus: () => textareaRef.current?.focus(),
152
+ }));
153
+
154
+ // Mention state — the active trigger position is the index of the
155
+ // triggering rune (NOT the rune after it). null = no active mention.
156
+ const [mentionStart, setMentionStart] = useState<number | null>(null);
157
+ const [highlightIdx, setHighlightIdx] = useState(0);
158
+ const [candidates, setCandidates] = useState<T[]>([]);
159
+
160
+ // Derive the active query from the value + mentionStart.
161
+ const activeQuery = useMemo(() => {
162
+ if (mentionStart === null) return "";
163
+ return extractQuery(value, mentionStart);
164
+ }, [value, mentionStart]);
165
+
166
+ // Reset highlight whenever the candidate list changes.
167
+ useEffect(() => {
168
+ setHighlightIdx(0);
169
+ }, [candidates]);
170
+
171
+ // Debounced search with abort. We deliberately do NOT render a
172
+ // "Searching…" spinner — the typical fetch round-trip after the
173
+ // debounce is too short for one to be useful, and it ends up
174
+ // flashing in/out per keystroke.
175
+ useEffect(() => {
176
+ if (mentionStart === null) {
177
+ setCandidates([]);
178
+ return;
179
+ }
180
+ const controller = new AbortController();
181
+ const timer = setTimeout(() => {
182
+ loadCandidates(activeQuery, controller.signal).then(
183
+ (results) => {
184
+ setCandidates(results.slice(0, maxResults));
185
+ },
186
+ (err) => {
187
+ // Aborts are expected control flow; swallow silently.
188
+ if (isAbortError(err)) return;
189
+ setCandidates([]);
190
+ if (onLoadError) onLoadError(err);
191
+ else if (typeof console !== "undefined") {
192
+ // eslint-disable-next-line no-console
193
+ console.warn("MentionCombobox loadCandidates failed", err);
194
+ }
195
+ },
196
+ );
197
+ }, debounceMs);
198
+ return () => {
199
+ clearTimeout(timer);
200
+ controller.abort();
201
+ };
202
+ // loadCandidates intentionally not in deps — most consumers
203
+ // inline a fresh function each render and we'd thrash the debounce
204
+ // on every keystroke. Consumers that want to reactively change
205
+ // the data source can change `mentionStart` (close + reopen).
206
+ // eslint-disable-next-line react-hooks/exhaustive-deps
207
+ }, [activeQuery, mentionStart, debounceMs, maxResults]);
208
+
209
+ const onValueChange = useCallback(
210
+ (e: ChangeEvent<HTMLTextAreaElement>) => {
211
+ const next = e.target.value;
212
+ onChange(next);
213
+ const cursor = e.target.selectionStart ?? next.length;
214
+ setMentionStart(findActiveMentionStart(next, cursor, trigger));
215
+ },
216
+ [onChange, trigger],
217
+ );
218
+
219
+ // Re-derive mentionStart whenever the caret moves WITHOUT a value
220
+ // change — click, arrow keys, home/end. Without this the dropdown
221
+ // can linger after the user clicks away from the active trigger.
222
+ const syncMentionFromCaret = useCallback(() => {
223
+ const el = textareaRef.current;
224
+ if (!el) return;
225
+ const cursor = el.selectionStart ?? el.value.length;
226
+ setMentionStart(findActiveMentionStart(el.value, cursor, trigger));
227
+ }, [trigger]);
228
+
229
+ // Close on blur so a click outside the textarea doesn't orphan
230
+ // the dropdown above other UI.
231
+ const onBlur = useCallback(() => {
232
+ setMentionStart(null);
233
+ }, []);
234
+
235
+ const onSelect = useCallback(
236
+ (item: T) => {
237
+ if (mentionStart === null) return;
238
+ const before = value.slice(0, mentionStart);
239
+ const after = value.slice(mentionStart + 1 + activeQuery.length);
240
+ const insert = getInsertionText(item);
241
+ const trailing = after.startsWith(" ") ? "" : " ";
242
+ const next = `${before}${insert}${trailing}${after}`;
243
+ onChange(next);
244
+ setMentionStart(null);
245
+ const cursorAt = before.length + insert.length + trailing.length;
246
+ queueMicrotask(() => {
247
+ const el = textareaRef.current;
248
+ if (!el) return;
249
+ el.focus();
250
+ el.setSelectionRange(cursorAt, cursorAt);
251
+ });
252
+ },
253
+ [activeQuery.length, getInsertionText, mentionStart, onChange, value],
254
+ );
255
+
256
+ const onKeyDown = useCallback(
257
+ (e: KeyboardEvent<HTMLTextAreaElement>) => {
258
+ if (mentionStart === null || candidates.length === 0) return;
259
+ if (e.key === "ArrowDown") {
260
+ e.preventDefault();
261
+ setHighlightIdx((i) => (i + 1) % candidates.length);
262
+ } else if (e.key === "ArrowUp") {
263
+ e.preventDefault();
264
+ setHighlightIdx((i) => (i - 1 + candidates.length) % candidates.length);
265
+ } else if (e.key === "Enter") {
266
+ e.preventDefault();
267
+ const candidate = candidates[highlightIdx];
268
+ if (candidate !== undefined) onSelect(candidate);
269
+ } else if (e.key === "Escape") {
270
+ e.preventDefault();
271
+ setMentionStart(null);
272
+ }
273
+ },
274
+ [candidates, highlightIdx, mentionStart, onSelect],
275
+ );
276
+
277
+ const open = mentionStart !== null && candidates.length > 0;
278
+ const listboxId = "ck-mention-combobox-listbox";
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
+
289
+ return (
290
+ <div style={containerStyle}>
291
+ {showOverlay ? (
292
+ <MentionHighlightOverlay
293
+ ref={overlayRef}
294
+ value={value}
295
+ mentionNames={mentionNames}
296
+ />
297
+ ) : null}
298
+ <textarea
299
+ ref={textareaRef}
300
+ value={value}
301
+ onChange={onValueChange}
302
+ onKeyDown={onKeyDown}
303
+ onSelect={syncMentionFromCaret}
304
+ onClick={syncMentionFromCaret}
305
+ onBlur={onBlur}
306
+ onScroll={showOverlay ? onTextareaScroll : undefined}
307
+ placeholder={placeholder}
308
+ aria-label={ariaLabel}
309
+ aria-autocomplete="list"
310
+ aria-expanded={open}
311
+ aria-controls={open ? listboxId : undefined}
312
+ rows={rows}
313
+ disabled={disabled}
314
+ data-testid={testid}
315
+ style={showOverlay ? textareaTransparentStyle : textareaStyle}
316
+ />
317
+ {open ? (
318
+ <ul
319
+ id={listboxId}
320
+ role="listbox"
321
+ data-testid="ck-mention-combobox-listbox"
322
+ aria-label="Mention suggestions"
323
+ style={listboxStyle}
324
+ >
325
+ {candidates.map((item, i) => {
326
+ const highlighted = i === highlightIdx;
327
+ return (
328
+ <li
329
+ key={getItemKey(item)}
330
+ role="option"
331
+ aria-selected={highlighted}
332
+ onMouseDown={(e) => {
333
+ // mousedown not click — onClick fires after
334
+ // textarea blurs, which would close the dropdown
335
+ // before the selection runs.
336
+ e.preventDefault();
337
+ onSelect(item);
338
+ }}
339
+ onMouseEnter={() => setHighlightIdx(i)}
340
+ data-testid={`ck-mention-combobox-option-${getItemKey(item)}`}
341
+ style={optionStyle(highlighted)}
342
+ >
343
+ {renderItem(item, { highlighted })}
344
+ </li>
345
+ );
346
+ })}
347
+ </ul>
348
+ ) : null}
349
+ </div>
350
+ );
351
+ }
352
+
353
+ // forwardRef erases generics; re-assert the generic signature so
354
+ // consumers get T-aware ergonomics.
355
+ export const MentionCombobox = forwardRef(MentionComboboxInner) as <T>(
356
+ props: MentionComboboxProps<T> & { ref?: ForwardedRef<MentionComboboxHandle> },
357
+ ) => ReactElement;
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
+
446
+ // ────────────────────────────────────────────────────────────────────
447
+ // Helpers — exported for consumers that need to inspect textarea
448
+ // state themselves (e.g. computing whether an existing mention is
449
+ // already inserted before re-firing the picker).
450
+ // ────────────────────────────────────────────────────────────────────
451
+
452
+ /**
453
+ * Find the index of the trigger rune that starts the mention
454
+ * containing `cursor`, or null when the cursor isn't inside a mention.
455
+ *
456
+ * Rules:
457
+ * - The rune at cursor-1 must be the trigger, OR every rune walking
458
+ * back from cursor-1 must be a non-whitespace non-trigger rune
459
+ * until we hit a trigger preceded by whitespace / start-of-input.
460
+ * - The look-behind on whitespace means an embedded `email@host`
461
+ * in prose does NOT open the dropdown — only a trigger that
462
+ * follows whitespace (or is at position 0) counts.
463
+ */
464
+ export function findActiveMentionStart(
465
+ value: string,
466
+ cursor: number,
467
+ trigger: string = DEFAULT_TRIGGER,
468
+ ): number | null {
469
+ let i = cursor - 1;
470
+ while (i >= 0) {
471
+ const ch = value[i];
472
+ if (ch === undefined) return null;
473
+ if (/\s/.test(ch)) return null;
474
+ if (ch === trigger) {
475
+ if (i === 0) return i;
476
+ const prev = value[i - 1];
477
+ if (prev !== undefined && /\s/.test(prev)) return i;
478
+ return null;
479
+ }
480
+ i--;
481
+ }
482
+ return null;
483
+ }
484
+
485
+ /**
486
+ * Read the active mention's query — the characters from
487
+ * mentionStart+1 up to the next whitespace (or end of value).
488
+ */
489
+ export function extractQuery(value: string, mentionStart: number): string {
490
+ const after = value.slice(mentionStart + 1);
491
+ const m = after.match(/^\S*/);
492
+ return m ? m[0] : "";
493
+ }
494
+
495
+ function isAbortError(err: unknown): boolean {
496
+ if (typeof DOMException !== "undefined" && err instanceof DOMException && err.name === ABORTED_NAME) return true;
497
+ if (err instanceof Error && err.name === ABORTED_NAME) return true;
498
+ return false;
499
+ }
500
+
501
+ // ────────────────────────────────────────────────────────────────────
502
+ // Styles
503
+ // ────────────────────────────────────────────────────────────────────
504
+
505
+ const containerStyle: React.CSSProperties = {
506
+ position: "relative",
507
+ };
508
+
509
+ const textareaStyle: React.CSSProperties = {
510
+ width: "100%",
511
+ minHeight: 60,
512
+ resize: "vertical",
513
+ padding: "0.5rem 0.625rem",
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,
557
+ border: "1px solid var(--ck-border-subtle)",
558
+ borderRadius: "var(--ck-radius-md)",
559
+ background: "var(--ck-bg-canvas)",
560
+ color: "var(--ck-text-primary)",
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,
577
+ };
578
+
579
+ const listboxStyle: React.CSSProperties = {
580
+ position: "absolute",
581
+ top: "calc(100% + 4px)",
582
+ left: 0,
583
+ right: 0,
584
+ zIndex: 10,
585
+ listStyle: "none",
586
+ margin: 0,
587
+ padding: 4,
588
+ background: "var(--ck-bg-surface, #0f172a)",
589
+ border: "1px solid var(--ck-border-subtle, #374151)",
590
+ borderRadius: 6,
591
+ boxShadow: "0 6px 18px rgba(0, 0, 0, 0.18)",
592
+ maxHeight: 220,
593
+ overflowY: "auto",
594
+ };
595
+
596
+ const optionStyle = (active: boolean): React.CSSProperties => ({
597
+ display: "flex",
598
+ alignItems: "center",
599
+ gap: 8,
600
+ padding: "6px 8px",
601
+ borderRadius: 4,
602
+ background: active ? "var(--ck-bg-muted)" : "transparent",
603
+ fontSize: 12.5,
604
+ cursor: "pointer",
605
+ });
@@ -24,3 +24,5 @@ export { Tooltip } from "./Tooltip";
24
24
  export type { TooltipProps } from "./Tooltip";
25
25
  export { PageHeader } from "./PageHeader";
26
26
  export type { PageHeaderProps } from "./PageHeader";
27
+ export { MentionCombobox, findActiveMentionStart, extractQuery } from "./MentionCombobox";
28
+ export type { MentionComboboxProps, MentionComboboxHandle } from "./MentionCombobox";