@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosxai/ui",
3
- "version": "0.4.11",
3
+ "version": "0.6.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",
@@ -1,30 +1,67 @@
1
- import type { ButtonHTMLAttributes, ReactNode } from "react";
1
+ import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
2
2
  import { cn } from "../lib/cn";
3
3
 
4
- // Minimal Phase-0 stub so the docs app has something concrete to
5
- // importproves the workspace bridge + hot-reload. Phase 3
6
- // replaces this with the full variant + size + loading-state
7
- // implementation backed by the .rd-btn styles.
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 function Button({
17
- variant = "primary",
18
- className,
19
- children,
20
- ...rest
21
- }: ButtonProps) {
22
- return (
23
- <button
24
- {...rest}
25
- className={cn("ck-btn", `ck-btn--${variant}`, className)}
26
- >
27
- {children}
28
- </button>
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
+ });
@@ -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";
@@ -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);