@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
|
@@ -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
|
+
});
|
package/src/primitives/index.ts
CHANGED
|
@@ -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";
|