@cosxai/ui 0.4.11 → 0.6.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 +1 -1
- package/src/primitives/Button.tsx +58 -21
- package/src/primitives/MentionCombobox.tsx +423 -0
- package/src/primitives/index.ts +2 -0
- package/src/styles/index.css +24 -0
package/package.json
CHANGED
|
@@ -1,30 +1,67 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
|
2
2
|
import { cn } from "../lib/cn";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Button — primitive interactive element. Variants `primary`
|
|
6
|
+
* (filled) / `secondary` (outlined) / `ghost` (transparent) /
|
|
7
|
+
* `icon` (square 32×32).
|
|
8
|
+
*
|
|
9
|
+
* The `loading` prop is the canonical way to show that an async
|
|
10
|
+
* action triggered by the button is in flight. When true:
|
|
11
|
+
*
|
|
12
|
+
* - a small ring spinner renders before the children (inherits
|
|
13
|
+
* `currentColor` so it reads on every variant),
|
|
14
|
+
* - the button is `disabled` natively so clicks are dropped,
|
|
15
|
+
* - `aria-busy="true"` is set for AT consumers.
|
|
16
|
+
*
|
|
17
|
+
* `loading` and `disabled` are distinct states. `disabled` says
|
|
18
|
+
* "you can't take this action right now" (form invalid, no
|
|
19
|
+
* selection, etc.). `loading` says "we're already taking this
|
|
20
|
+
* action — wait". Callers typically wire both:
|
|
21
|
+
*
|
|
22
|
+
* <Button disabled={!name.trim()} loading={submitting}>
|
|
23
|
+
* {submitting ? "Saving…" : "Save"}
|
|
24
|
+
* </Button>
|
|
25
|
+
*
|
|
26
|
+
* Visual: see docs/components/button.
|
|
27
|
+
*
|
|
28
|
+
* Forwards ref to the underlying <button>. Spreads `...rest` so
|
|
29
|
+
* consumers can pass data-* / aria-* / className.
|
|
30
|
+
*/
|
|
8
31
|
|
|
9
32
|
export type ButtonVariant = "primary" | "secondary" | "ghost" | "icon";
|
|
10
33
|
|
|
11
34
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
12
|
-
variant?: ButtonVariant;
|
|
35
|
+
variant?: ButtonVariant | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* When true, the button renders a leading spinner, sets
|
|
38
|
+
* `aria-busy="true"`, and becomes natively `disabled` so click
|
|
39
|
+
* handlers no longer fire. Use this for any async submit so the
|
|
40
|
+
* user sees the click landed AND can't double-fire the action.
|
|
41
|
+
*/
|
|
42
|
+
loading?: boolean | undefined;
|
|
13
43
|
children?: ReactNode;
|
|
14
44
|
}
|
|
15
45
|
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
46
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
47
|
+
function Button(
|
|
48
|
+
{ variant = "primary", className, children, loading, disabled, ...rest },
|
|
49
|
+
ref,
|
|
50
|
+
) {
|
|
51
|
+
return (
|
|
52
|
+
<button
|
|
53
|
+
ref={ref}
|
|
54
|
+
{...rest}
|
|
55
|
+
className={cn("ck-btn", `ck-btn--${variant}`, className)}
|
|
56
|
+
disabled={disabled || loading}
|
|
57
|
+
aria-busy={loading || undefined}
|
|
58
|
+
data-loading={loading || undefined}
|
|
59
|
+
>
|
|
60
|
+
{loading ? (
|
|
61
|
+
<span className="ck-btn-spinner" aria-hidden="true" />
|
|
62
|
+
) : null}
|
|
63
|
+
{children}
|
|
64
|
+
</button>
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
);
|
|
@@ -0,0 +1,423 @@
|
|
|
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
|
+
|
|
93
|
+
export interface MentionComboboxHandle {
|
|
94
|
+
focus: () => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DEFAULT_DEBOUNCE_MS = 150;
|
|
98
|
+
const DEFAULT_MAX_RESULTS = 8;
|
|
99
|
+
const DEFAULT_TRIGGER = "@";
|
|
100
|
+
const ABORTED_NAME = "AbortError";
|
|
101
|
+
|
|
102
|
+
// React.forwardRef erases generics; we cast back to a generic-aware
|
|
103
|
+
// signature so consumers get `MentionCombobox<T>` ergonomics.
|
|
104
|
+
function MentionComboboxInner<T>(
|
|
105
|
+
props: MentionComboboxProps<T>,
|
|
106
|
+
ref: ForwardedRef<MentionComboboxHandle>,
|
|
107
|
+
): ReactElement {
|
|
108
|
+
const {
|
|
109
|
+
value,
|
|
110
|
+
onChange,
|
|
111
|
+
loadCandidates,
|
|
112
|
+
getItemKey,
|
|
113
|
+
getInsertionText,
|
|
114
|
+
renderItem,
|
|
115
|
+
trigger = DEFAULT_TRIGGER,
|
|
116
|
+
debounceMs = DEFAULT_DEBOUNCE_MS,
|
|
117
|
+
maxResults = DEFAULT_MAX_RESULTS,
|
|
118
|
+
placeholder,
|
|
119
|
+
disabled = false,
|
|
120
|
+
rows = 3,
|
|
121
|
+
ariaLabel,
|
|
122
|
+
onLoadError,
|
|
123
|
+
"data-testid": testid,
|
|
124
|
+
} = props;
|
|
125
|
+
|
|
126
|
+
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
127
|
+
|
|
128
|
+
// Imperative focus — consumer composers' mount-on-open flow needs it.
|
|
129
|
+
useImperativeHandle(ref, () => ({
|
|
130
|
+
focus: () => textareaRef.current?.focus(),
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
// Mention state — the active trigger position is the index of the
|
|
134
|
+
// triggering rune (NOT the rune after it). null = no active mention.
|
|
135
|
+
const [mentionStart, setMentionStart] = useState<number | null>(null);
|
|
136
|
+
const [highlightIdx, setHighlightIdx] = useState(0);
|
|
137
|
+
const [candidates, setCandidates] = useState<T[]>([]);
|
|
138
|
+
|
|
139
|
+
// Derive the active query from the value + mentionStart.
|
|
140
|
+
const activeQuery = useMemo(() => {
|
|
141
|
+
if (mentionStart === null) return "";
|
|
142
|
+
return extractQuery(value, mentionStart);
|
|
143
|
+
}, [value, mentionStart]);
|
|
144
|
+
|
|
145
|
+
// Reset highlight whenever the candidate list changes.
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
setHighlightIdx(0);
|
|
148
|
+
}, [candidates]);
|
|
149
|
+
|
|
150
|
+
// Debounced search with abort. We deliberately do NOT render a
|
|
151
|
+
// "Searching…" spinner — the typical fetch round-trip after the
|
|
152
|
+
// debounce is too short for one to be useful, and it ends up
|
|
153
|
+
// flashing in/out per keystroke.
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (mentionStart === null) {
|
|
156
|
+
setCandidates([]);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const controller = new AbortController();
|
|
160
|
+
const timer = setTimeout(() => {
|
|
161
|
+
loadCandidates(activeQuery, controller.signal).then(
|
|
162
|
+
(results) => {
|
|
163
|
+
setCandidates(results.slice(0, maxResults));
|
|
164
|
+
},
|
|
165
|
+
(err) => {
|
|
166
|
+
// Aborts are expected control flow; swallow silently.
|
|
167
|
+
if (isAbortError(err)) return;
|
|
168
|
+
setCandidates([]);
|
|
169
|
+
if (onLoadError) onLoadError(err);
|
|
170
|
+
else if (typeof console !== "undefined") {
|
|
171
|
+
// eslint-disable-next-line no-console
|
|
172
|
+
console.warn("MentionCombobox loadCandidates failed", err);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
}, debounceMs);
|
|
177
|
+
return () => {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
controller.abort();
|
|
180
|
+
};
|
|
181
|
+
// loadCandidates intentionally not in deps — most consumers
|
|
182
|
+
// inline a fresh function each render and we'd thrash the debounce
|
|
183
|
+
// on every keystroke. Consumers that want to reactively change
|
|
184
|
+
// the data source can change `mentionStart` (close + reopen).
|
|
185
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
186
|
+
}, [activeQuery, mentionStart, debounceMs, maxResults]);
|
|
187
|
+
|
|
188
|
+
const onValueChange = useCallback(
|
|
189
|
+
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
190
|
+
const next = e.target.value;
|
|
191
|
+
onChange(next);
|
|
192
|
+
const cursor = e.target.selectionStart ?? next.length;
|
|
193
|
+
setMentionStart(findActiveMentionStart(next, cursor, trigger));
|
|
194
|
+
},
|
|
195
|
+
[onChange, trigger],
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// Re-derive mentionStart whenever the caret moves WITHOUT a value
|
|
199
|
+
// change — click, arrow keys, home/end. Without this the dropdown
|
|
200
|
+
// can linger after the user clicks away from the active trigger.
|
|
201
|
+
const syncMentionFromCaret = useCallback(() => {
|
|
202
|
+
const el = textareaRef.current;
|
|
203
|
+
if (!el) return;
|
|
204
|
+
const cursor = el.selectionStart ?? el.value.length;
|
|
205
|
+
setMentionStart(findActiveMentionStart(el.value, cursor, trigger));
|
|
206
|
+
}, [trigger]);
|
|
207
|
+
|
|
208
|
+
// Close on blur so a click outside the textarea doesn't orphan
|
|
209
|
+
// the dropdown above other UI.
|
|
210
|
+
const onBlur = useCallback(() => {
|
|
211
|
+
setMentionStart(null);
|
|
212
|
+
}, []);
|
|
213
|
+
|
|
214
|
+
const onSelect = useCallback(
|
|
215
|
+
(item: T) => {
|
|
216
|
+
if (mentionStart === null) return;
|
|
217
|
+
const before = value.slice(0, mentionStart);
|
|
218
|
+
const after = value.slice(mentionStart + 1 + activeQuery.length);
|
|
219
|
+
const insert = getInsertionText(item);
|
|
220
|
+
const trailing = after.startsWith(" ") ? "" : " ";
|
|
221
|
+
const next = `${before}${insert}${trailing}${after}`;
|
|
222
|
+
onChange(next);
|
|
223
|
+
setMentionStart(null);
|
|
224
|
+
const cursorAt = before.length + insert.length + trailing.length;
|
|
225
|
+
queueMicrotask(() => {
|
|
226
|
+
const el = textareaRef.current;
|
|
227
|
+
if (!el) return;
|
|
228
|
+
el.focus();
|
|
229
|
+
el.setSelectionRange(cursorAt, cursorAt);
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
[activeQuery.length, getInsertionText, mentionStart, onChange, value],
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const onKeyDown = useCallback(
|
|
236
|
+
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
237
|
+
if (mentionStart === null || candidates.length === 0) return;
|
|
238
|
+
if (e.key === "ArrowDown") {
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
setHighlightIdx((i) => (i + 1) % candidates.length);
|
|
241
|
+
} else if (e.key === "ArrowUp") {
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
setHighlightIdx((i) => (i - 1 + candidates.length) % candidates.length);
|
|
244
|
+
} else if (e.key === "Enter") {
|
|
245
|
+
e.preventDefault();
|
|
246
|
+
const candidate = candidates[highlightIdx];
|
|
247
|
+
if (candidate !== undefined) onSelect(candidate);
|
|
248
|
+
} else if (e.key === "Escape") {
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
setMentionStart(null);
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
[candidates, highlightIdx, mentionStart, onSelect],
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const open = mentionStart !== null && candidates.length > 0;
|
|
257
|
+
const listboxId = "ck-mention-combobox-listbox";
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<div style={containerStyle}>
|
|
261
|
+
<textarea
|
|
262
|
+
ref={textareaRef}
|
|
263
|
+
value={value}
|
|
264
|
+
onChange={onValueChange}
|
|
265
|
+
onKeyDown={onKeyDown}
|
|
266
|
+
onSelect={syncMentionFromCaret}
|
|
267
|
+
onClick={syncMentionFromCaret}
|
|
268
|
+
onBlur={onBlur}
|
|
269
|
+
placeholder={placeholder}
|
|
270
|
+
aria-label={ariaLabel}
|
|
271
|
+
aria-autocomplete="list"
|
|
272
|
+
aria-expanded={open}
|
|
273
|
+
aria-controls={open ? listboxId : undefined}
|
|
274
|
+
rows={rows}
|
|
275
|
+
disabled={disabled}
|
|
276
|
+
data-testid={testid}
|
|
277
|
+
style={textareaStyle}
|
|
278
|
+
/>
|
|
279
|
+
{open ? (
|
|
280
|
+
<ul
|
|
281
|
+
id={listboxId}
|
|
282
|
+
role="listbox"
|
|
283
|
+
data-testid="ck-mention-combobox-listbox"
|
|
284
|
+
aria-label="Mention suggestions"
|
|
285
|
+
style={listboxStyle}
|
|
286
|
+
>
|
|
287
|
+
{candidates.map((item, i) => {
|
|
288
|
+
const highlighted = i === highlightIdx;
|
|
289
|
+
return (
|
|
290
|
+
<li
|
|
291
|
+
key={getItemKey(item)}
|
|
292
|
+
role="option"
|
|
293
|
+
aria-selected={highlighted}
|
|
294
|
+
onMouseDown={(e) => {
|
|
295
|
+
// mousedown not click — onClick fires after
|
|
296
|
+
// textarea blurs, which would close the dropdown
|
|
297
|
+
// before the selection runs.
|
|
298
|
+
e.preventDefault();
|
|
299
|
+
onSelect(item);
|
|
300
|
+
}}
|
|
301
|
+
onMouseEnter={() => setHighlightIdx(i)}
|
|
302
|
+
data-testid={`ck-mention-combobox-option-${getItemKey(item)}`}
|
|
303
|
+
style={optionStyle(highlighted)}
|
|
304
|
+
>
|
|
305
|
+
{renderItem(item, { highlighted })}
|
|
306
|
+
</li>
|
|
307
|
+
);
|
|
308
|
+
})}
|
|
309
|
+
</ul>
|
|
310
|
+
) : null}
|
|
311
|
+
</div>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// forwardRef erases generics; re-assert the generic signature so
|
|
316
|
+
// consumers get T-aware ergonomics.
|
|
317
|
+
export const MentionCombobox = forwardRef(MentionComboboxInner) as <T>(
|
|
318
|
+
props: MentionComboboxProps<T> & { ref?: ForwardedRef<MentionComboboxHandle> },
|
|
319
|
+
) => ReactElement;
|
|
320
|
+
|
|
321
|
+
// ────────────────────────────────────────────────────────────────────
|
|
322
|
+
// Helpers — exported for consumers that need to inspect textarea
|
|
323
|
+
// state themselves (e.g. computing whether an existing mention is
|
|
324
|
+
// already inserted before re-firing the picker).
|
|
325
|
+
// ────────────────────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Find the index of the trigger rune that starts the mention
|
|
329
|
+
* containing `cursor`, or null when the cursor isn't inside a mention.
|
|
330
|
+
*
|
|
331
|
+
* Rules:
|
|
332
|
+
* - The rune at cursor-1 must be the trigger, OR every rune walking
|
|
333
|
+
* back from cursor-1 must be a non-whitespace non-trigger rune
|
|
334
|
+
* until we hit a trigger preceded by whitespace / start-of-input.
|
|
335
|
+
* - The look-behind on whitespace means an embedded `email@host`
|
|
336
|
+
* in prose does NOT open the dropdown — only a trigger that
|
|
337
|
+
* follows whitespace (or is at position 0) counts.
|
|
338
|
+
*/
|
|
339
|
+
export function findActiveMentionStart(
|
|
340
|
+
value: string,
|
|
341
|
+
cursor: number,
|
|
342
|
+
trigger: string = DEFAULT_TRIGGER,
|
|
343
|
+
): number | null {
|
|
344
|
+
let i = cursor - 1;
|
|
345
|
+
while (i >= 0) {
|
|
346
|
+
const ch = value[i];
|
|
347
|
+
if (ch === undefined) return null;
|
|
348
|
+
if (/\s/.test(ch)) return null;
|
|
349
|
+
if (ch === trigger) {
|
|
350
|
+
if (i === 0) return i;
|
|
351
|
+
const prev = value[i - 1];
|
|
352
|
+
if (prev !== undefined && /\s/.test(prev)) return i;
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
i--;
|
|
356
|
+
}
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Read the active mention's query — the characters from
|
|
362
|
+
* mentionStart+1 up to the next whitespace (or end of value).
|
|
363
|
+
*/
|
|
364
|
+
export function extractQuery(value: string, mentionStart: number): string {
|
|
365
|
+
const after = value.slice(mentionStart + 1);
|
|
366
|
+
const m = after.match(/^\S*/);
|
|
367
|
+
return m ? m[0] : "";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function isAbortError(err: unknown): boolean {
|
|
371
|
+
if (typeof DOMException !== "undefined" && err instanceof DOMException && err.name === ABORTED_NAME) return true;
|
|
372
|
+
if (err instanceof Error && err.name === ABORTED_NAME) return true;
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ────────────────────────────────────────────────────────────────────
|
|
377
|
+
// Styles
|
|
378
|
+
// ────────────────────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
const containerStyle: React.CSSProperties = {
|
|
381
|
+
position: "relative",
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const textareaStyle: React.CSSProperties = {
|
|
385
|
+
width: "100%",
|
|
386
|
+
minHeight: 60,
|
|
387
|
+
resize: "vertical",
|
|
388
|
+
padding: "0.5rem 0.625rem",
|
|
389
|
+
fontSize: "0.875rem",
|
|
390
|
+
border: "1px solid var(--ck-border-subtle)",
|
|
391
|
+
borderRadius: "var(--ck-radius-md)",
|
|
392
|
+
background: "var(--ck-bg-canvas)",
|
|
393
|
+
color: "var(--ck-text-primary)",
|
|
394
|
+
fontFamily: "inherit",
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const listboxStyle: React.CSSProperties = {
|
|
398
|
+
position: "absolute",
|
|
399
|
+
top: "calc(100% + 4px)",
|
|
400
|
+
left: 0,
|
|
401
|
+
right: 0,
|
|
402
|
+
zIndex: 10,
|
|
403
|
+
listStyle: "none",
|
|
404
|
+
margin: 0,
|
|
405
|
+
padding: 4,
|
|
406
|
+
background: "var(--ck-bg-surface, #0f172a)",
|
|
407
|
+
border: "1px solid var(--ck-border-subtle, #374151)",
|
|
408
|
+
borderRadius: 6,
|
|
409
|
+
boxShadow: "0 6px 18px rgba(0, 0, 0, 0.18)",
|
|
410
|
+
maxHeight: 220,
|
|
411
|
+
overflowY: "auto",
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const optionStyle = (active: boolean): React.CSSProperties => ({
|
|
415
|
+
display: "flex",
|
|
416
|
+
alignItems: "center",
|
|
417
|
+
gap: 8,
|
|
418
|
+
padding: "6px 8px",
|
|
419
|
+
borderRadius: 4,
|
|
420
|
+
background: active ? "var(--ck-bg-muted)" : "transparent",
|
|
421
|
+
fontSize: 12.5,
|
|
422
|
+
cursor: "pointer",
|
|
423
|
+
});
|
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";
|
package/src/styles/index.css
CHANGED
|
@@ -39,6 +39,30 @@
|
|
|
39
39
|
|
|
40
40
|
.ck-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
41
41
|
|
|
42
|
+
/* Loading spinner — rendered inside a Button when its `loading`
|
|
43
|
+
prop is true. CSS-only ring (1.5px stroke, 720ms linear spin).
|
|
44
|
+
`currentColor` inherits the variant's foreground so the spinner
|
|
45
|
+
reads on primary / secondary / ghost / icon without per-variant
|
|
46
|
+
overrides. Reduced-motion users get a slower spin instead of a
|
|
47
|
+
frozen ring (which would read as broken). */
|
|
48
|
+
.ck-btn-spinner {
|
|
49
|
+
width: 0.85em;
|
|
50
|
+
height: 0.85em;
|
|
51
|
+
border: 1.5px solid currentColor;
|
|
52
|
+
border-right-color: transparent;
|
|
53
|
+
border-radius: 50%;
|
|
54
|
+
display: inline-block;
|
|
55
|
+
vertical-align: -0.1em;
|
|
56
|
+
flex-shrink: 0;
|
|
57
|
+
animation: ck-btn-spin 720ms linear infinite;
|
|
58
|
+
}
|
|
59
|
+
@keyframes ck-btn-spin {
|
|
60
|
+
to { transform: rotate(360deg); }
|
|
61
|
+
}
|
|
62
|
+
@media (prefers-reduced-motion: reduce) {
|
|
63
|
+
.ck-btn-spinner { animation-duration: 2400ms; }
|
|
64
|
+
}
|
|
65
|
+
|
|
42
66
|
.ck-btn--primary {
|
|
43
67
|
background: var(--ck-accent);
|
|
44
68
|
color: var(--ck-text-inverse);
|