@cosxai/ui 0.25.0 → 1.0.0-alpha.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.
Files changed (133) hide show
  1. package/CHANGELOG.md +577 -0
  2. package/NOTICE +11 -0
  3. package/README.md +52 -0
  4. package/package.json +33 -7
  5. package/src/fonts.css +20 -0
  6. package/src/index.ts +8 -25
  7. package/src/lib/cn.ts +23 -6
  8. package/src/styles.css +17 -0
  9. package/src/theme.css +132 -0
  10. package/src/tokens/colors.css +112 -0
  11. package/src/tokens/compat.css +36 -0
  12. package/src/tokens/elevation.css +14 -0
  13. package/src/tokens/motion.css +42 -0
  14. package/src/tokens/spacing.css +55 -0
  15. package/src/tokens/typography-cjk.css +42 -0
  16. package/src/tokens/typography.css +81 -0
  17. package/src/actionbar/ActionBar.tsx +0 -837
  18. package/src/actionbar/ActionBarButton.tsx +0 -111
  19. package/src/actionbar/ActionBarMenuGroup.tsx +0 -116
  20. package/src/actionbar/ActionBarModeHandle.tsx +0 -171
  21. package/src/actionbar/ActionBarProvider.tsx +0 -91
  22. package/src/actionbar/actionbar-context.ts +0 -45
  23. package/src/actionbar/index.ts +0 -28
  24. package/src/actionbar/types.ts +0 -147
  25. package/src/actionbar/useActionBarItems.ts +0 -60
  26. package/src/actionbar/useActionBarMode.ts +0 -144
  27. package/src/actionbar/useActionBarPanel.ts +0 -56
  28. package/src/actionbar/useActionBarStatusDot.ts +0 -49
  29. package/src/actionbar/useActionBarToast.ts +0 -33
  30. package/src/ambient/AmbientBackdrop.tsx +0 -74
  31. package/src/ambient/CommandInput.tsx +0 -107
  32. package/src/ambient/SuperbarStrip.tsx +0 -36
  33. package/src/ambient/index.ts +0 -6
  34. package/src/bento/BentoCell.tsx +0 -66
  35. package/src/bento/BentoGrid.tsx +0 -42
  36. package/src/bento/index.ts +0 -2
  37. package/src/command/CommandPalette.tsx +0 -329
  38. package/src/command/CommandProvider.tsx +0 -57
  39. package/src/command/command-context.ts +0 -12
  40. package/src/command/index.ts +0 -6
  41. package/src/command/rank.ts +0 -50
  42. package/src/command/types.ts +0 -37
  43. package/src/command/useCommandSource.ts +0 -37
  44. package/src/dialogs/DialogsProvider.tsx +0 -216
  45. package/src/dialogs/Modal.tsx +0 -260
  46. package/src/dialogs/Toast.tsx +0 -85
  47. package/src/dialogs/dialogs-context.ts +0 -6
  48. package/src/dialogs/index.ts +0 -10
  49. package/src/dialogs/types.ts +0 -37
  50. package/src/dialogs/useDialogs.ts +0 -8
  51. package/src/editorial/EditorialSpotlight.tsx +0 -63
  52. package/src/editorial/Folio.tsx +0 -52
  53. package/src/editorial/PlateMarker.tsx +0 -33
  54. package/src/editorial/RomanSection.tsx +0 -65
  55. package/src/editorial/RunningMarginalia.tsx +0 -65
  56. package/src/editorial/index.ts +0 -10
  57. package/src/hooks/index.ts +0 -5
  58. package/src/hooks/useKeyboardHotkey.ts +0 -93
  59. package/src/hooks/useReducedMotion.ts +0 -20
  60. package/src/hooks/useViewport.ts +0 -61
  61. package/src/layout/Breadcrumb.tsx +0 -74
  62. package/src/layout/LeftNavRail.tsx +0 -139
  63. package/src/layout/MobileTabBar.tsx +0 -101
  64. package/src/layout/NavItem.tsx +0 -128
  65. package/src/layout/NavSearchTrigger.tsx +0 -88
  66. package/src/layout/NavSection.tsx +0 -40
  67. package/src/layout/RightSidebarPanel.tsx +0 -111
  68. package/src/layout/Shell.tsx +0 -91
  69. package/src/layout/SidePanel.tsx +0 -316
  70. package/src/layout/StickyBanner.tsx +0 -83
  71. package/src/layout/Topbar.tsx +0 -68
  72. package/src/layout/index.ts +0 -24
  73. package/src/layout/useNavRailState.ts +0 -69
  74. package/src/lib/time-utils.ts +0 -44
  75. package/src/neobrutalism/Marquee.tsx +0 -81
  76. package/src/neobrutalism/Sticker.tsx +0 -71
  77. package/src/neobrutalism/index.ts +0 -4
  78. package/src/primitives/Avatar.tsx +0 -53
  79. package/src/primitives/Button.tsx +0 -67
  80. package/src/primitives/Card.tsx +0 -41
  81. package/src/primitives/Checkbox.tsx +0 -87
  82. package/src/primitives/Chip.tsx +0 -191
  83. package/src/primitives/Combobox.tsx +0 -507
  84. package/src/primitives/CopyField.tsx +0 -139
  85. package/src/primitives/CountBadge.tsx +0 -50
  86. package/src/primitives/Input.tsx +0 -204
  87. package/src/primitives/Kbd.tsx +0 -45
  88. package/src/primitives/MentionCombobox.tsx +0 -612
  89. package/src/primitives/MultiCombobox.tsx +0 -327
  90. package/src/primitives/PageHeader.tsx +0 -77
  91. package/src/primitives/Radio.tsx +0 -131
  92. package/src/primitives/SegmentedControl.tsx +0 -135
  93. package/src/primitives/Select.tsx +0 -779
  94. package/src/primitives/SignaturePad.tsx +0 -461
  95. package/src/primitives/Tag.tsx +0 -56
  96. package/src/primitives/Textarea.tsx +0 -62
  97. package/src/primitives/ToggleSwitch.tsx +0 -79
  98. package/src/primitives/Tooltip.tsx +0 -213
  99. package/src/primitives/index.ts +0 -53
  100. package/src/pwa/InstallPromptBanner.tsx +0 -132
  101. package/src/pwa/index.ts +0 -4
  102. package/src/pwa/manifest.template.json +0 -20
  103. package/src/pwa/registerSW.ts +0 -55
  104. package/src/riso/Halftone.tsx +0 -85
  105. package/src/riso/Misregister.tsx +0 -63
  106. package/src/riso/RisoStamp.tsx +0 -76
  107. package/src/riso/index.ts +0 -3
  108. package/src/sketch/HandUnderline.tsx +0 -53
  109. package/src/sketch/RoughArrow.tsx +0 -91
  110. package/src/sketch/RoughBox.tsx +0 -73
  111. package/src/sketch/StickyNote.tsx +0 -56
  112. package/src/sketch/index.ts +0 -4
  113. package/src/styles/base.css +0 -186
  114. package/src/styles/chrome-ambient.css +0 -235
  115. package/src/styles/chrome-bento.css +0 -192
  116. package/src/styles/chrome-editorial.css +0 -168
  117. package/src/styles/chrome-neobrutalism.css +0 -326
  118. package/src/styles/chrome-riso.css +0 -339
  119. package/src/styles/chrome-sketch.css +0 -362
  120. package/src/styles/chrome-swiss.css +0 -265
  121. package/src/styles/chrome-terminal.css +0 -272
  122. package/src/styles/fonts.css +0 -1034
  123. package/src/styles/index.css +0 -343
  124. package/src/styles/tokens.css +0 -941
  125. package/src/terminal/AsciiBox.tsx +0 -65
  126. package/src/terminal/BrailleSpinner.tsx +0 -46
  127. package/src/terminal/index.ts +0 -4
  128. package/src/theme/ThemeProvider.tsx +0 -99
  129. package/src/theme/index.ts +0 -6
  130. package/src/theme/inline-script.ts +0 -40
  131. package/src/theme/theme-context.ts +0 -7
  132. package/src/theme/types.ts +0 -39
  133. package/src/theme/useTheme.ts +0 -8
@@ -1,507 +0,0 @@
1
- import {
2
- forwardRef,
3
- useCallback,
4
- useEffect,
5
- useImperativeHandle,
6
- useRef,
7
- useState,
8
- type KeyboardEvent,
9
- type ReactNode,
10
- } from "react";
11
-
12
- import { Input } from "./Input";
13
-
14
- // Combobox — async-search commit picker (recipient fields, entity
15
- // lookups). Extracted from product-meta's CustomerPicker so every
16
- // product surface shares one commit model (QA 2026-07-21: typed-but-
17
- // uncommitted text was visually identical to a committed pick, and on
18
- // slow networks the dropdown never even appeared before the user
19
- // moved on).
20
- //
21
- // The model has exactly two visible states, both always unambiguous:
22
- //
23
- // · The INPUT is always rendered and always editable — it carries
24
- // the "in progress" state (typing / searching). Editing it after
25
- // a commit clears the commit (via onUncommit) and resumes search.
26
- // · The COMMITTED CARD below the input carries the "confirmed"
27
- // state — title + subtitle + × to clear + an optional extra slot
28
- // (e.g. a display-name input for brand-new entities).
29
- //
30
- // Commit paths:
31
- // · click / Enter on a dropdown row → onCommit(option)
32
- // · click / Enter on the free-entry row → onCommit(free)
33
- // · BLUR with committable text (auto-commit) → exact option match
34
- // when the search already returned one, else free entry when
35
- // `allowFreeEntry(raw)` passes. Deliberately independent of the
36
- // dropdown having rendered — on a slow network the free-entry
37
- // path still commits from the local validity check alone.
38
- // · blur with non-committable text → invalid state
39
- // (error border + `invalidHint`), never a silent look-alike.
40
- //
41
- // The committed value is PARENT-OWNED (controlled): the parent maps
42
- // options / free entries onto its own domain state and passes back
43
- // `committed` for display. This keeps the primitive product-agnostic.
44
-
45
- export interface ComboboxOption {
46
- // Stable identity for list keys + commit payloads.
47
- key: string;
48
- // Primary row line (e.g. a person's name).
49
- title: string;
50
- // Secondary row line (e.g. their email). Also used by the default
51
- // auto-commit matcher, so put the canonical typed value here.
52
- subtitle?: string | undefined;
53
- }
54
-
55
- export type ComboboxCommit =
56
- | { kind: "option"; option: ComboboxOption }
57
- | { kind: "free"; raw: string };
58
-
59
- export interface ComboboxCommitted {
60
- title: ReactNode;
61
- subtitle?: ReactNode | undefined;
62
- }
63
-
64
- export interface ComboboxProps {
65
- label?: ReactNode | undefined;
66
- placeholder?: string | undefined;
67
- autoFocus?: boolean | undefined;
68
- disabled?: boolean | undefined;
69
- // Async search. Called after `debounceMs` of quiet with a non-empty
70
- // trimmed query; the AbortSignal cancels superseded requests.
71
- search: (query: string, signal: AbortSignal) => Promise<ComboboxOption[]>;
72
- // Select-with-search mode: run `search("")` the moment the input
73
- // focuses, so clicking the field presents the option list before
74
- // any typing — the picker pattern for small, known sets. Off by
75
- // default (async person/document searches would fire on every
76
- // focus for no gain).
77
- searchOnFocus?: boolean | undefined;
78
- debounceMs?: number | undefined;
79
- // Free-entry gate: return true when the raw text is committable on
80
- // its own (e.g. a syntactically valid email). Omit to disable free
81
- // entry entirely (only listed options commit).
82
- allowFreeEntry?: ((raw: string) => boolean) | undefined;
83
- // Dropdown row copy for the free-entry affordance.
84
- freeEntryLabel?: ((raw: string) => ReactNode) | undefined;
85
- // Rendered in the dropdown when a search returned nothing and free
86
- // entry doesn't apply.
87
- emptyHint?: ReactNode | undefined;
88
- // Error copy for "blurred with text that can't commit".
89
- invalidHint?: string | undefined;
90
- // Matcher backing blur auto-commit against loaded options. Default:
91
- // case-insensitive equality on subtitle, then title.
92
- matchOption?: ((raw: string, option: ComboboxOption) => boolean) | undefined;
93
- // Parent-owned committed state; null/undefined = nothing committed.
94
- committed?: ComboboxCommitted | null | undefined;
95
- // Extra content inside the committed card (below subtitle).
96
- committedExtra?: ReactNode | undefined;
97
- onCommit: (commit: ComboboxCommit) => void;
98
- // Fired when the user edits the input after a commit, or clicks the
99
- // card's ×. Parent clears its committed state.
100
- onUncommit?: (() => void) | undefined;
101
- // Multi-value mode: after `onCommit` fires, reset the input to ""
102
- // and close the dropdown instead of echoing the committed text. The
103
- // parent renders the committed values itself (see MultiCombobox)
104
- // and typically passes `committed={null}`. Default false — the
105
- // single-value echo-into-input behaviour is unchanged.
106
- clearOnCommit?: boolean | undefined;
107
- // Bare mode: the input renders without its own field chrome (see
108
- // Input `bare`) and the root wrapper is `position: static` +
109
- // `flex: 1 1 140px`, so a host that draws the field (MultiCombobox
110
- // inline layout) can place this after its chips and the dropdown
111
- // anchors to the host's `position: relative` container. `label`
112
- // and the inline error text are not rendered (the host draws them;
113
- // `aria-invalid` is still set on the input). Default false.
114
- bare?: boolean | undefined;
115
- // id for the <input>, so a host-drawn <label htmlFor> can target it.
116
- inputId?: string | undefined;
117
- // Extra aria-describedby id(s) for the <input> (e.g. a host-drawn
118
- // invalid hint in bare mode). Merged with Input's own error/helper
119
- // association, never replacing it.
120
- inputDescribedBy?: string | undefined;
121
- testid?: string | undefined;
122
- }
123
-
124
- export const COMBOBOX_DEFAULT_INVALID_HINT = "Pick from the list to continue.";
125
-
126
- // Imperative handle — `focus()` returns focus to the input (e.g. after
127
- // an inline editor closes); `clear()` wipes the typed text + dropdown
128
- // (e.g. a parent rejecting a duplicate commit).
129
- export type ComboboxHandle = {
130
- focus: () => void;
131
- clear: () => void;
132
- };
133
-
134
- const DEFAULT_DEBOUNCE_MS = 250;
135
-
136
- function defaultMatch(raw: string, option: ComboboxOption): boolean {
137
- const norm = raw.trim().toLowerCase();
138
- return (
139
- (option.subtitle ?? "").trim().toLowerCase() === norm ||
140
- option.title.trim().toLowerCase() === norm
141
- );
142
- }
143
-
144
- export const Combobox = forwardRef<ComboboxHandle, ComboboxProps>(function Combobox(
145
- {
146
- label,
147
- placeholder,
148
- autoFocus,
149
- disabled,
150
- search,
151
- searchOnFocus,
152
- debounceMs = DEFAULT_DEBOUNCE_MS,
153
- allowFreeEntry,
154
- freeEntryLabel,
155
- emptyHint,
156
- invalidHint,
157
- matchOption = defaultMatch,
158
- committed,
159
- committedExtra,
160
- onCommit,
161
- onUncommit,
162
- clearOnCommit = false,
163
- bare = false,
164
- inputId,
165
- inputDescribedBy,
166
- testid = "combobox",
167
- },
168
- ref,
169
- ) {
170
- const [query, setQuery] = useState("");
171
- const [results, setResults] = useState<ComboboxOption[]>([]);
172
- const [loading, setLoading] = useState(false);
173
- const [focused, setFocused] = useState(false);
174
- const [highlight, setHighlight] = useState(0);
175
- const [invalid, setInvalid] = useState(false);
176
-
177
- const abortRef = useRef<AbortController | null>(null);
178
- const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
179
- const inputRef = useRef<HTMLInputElement | null>(null);
180
-
181
- const isCommitted = !!committed;
182
-
183
- useImperativeHandle(
184
- ref,
185
- () => ({
186
- focus: () => inputRef.current?.focus(),
187
- clear: () => {
188
- setQuery("");
189
- setResults([]);
190
- setHighlight(0);
191
- setInvalid(false);
192
- },
193
- }),
194
- [],
195
- );
196
-
197
- useEffect(() => {
198
- if (debounceRef.current) clearTimeout(debounceRef.current);
199
- // Search is suspended while a commit is displayed — the input
200
- // still holds the committed text but shouldn't re-open the list.
201
- // searchOnFocus lifts the non-empty-query requirement while the
202
- // field is focused: clicking presents the list, typing narrows.
203
- if (isCommitted || (!query.trim() && !(searchOnFocus && focused))) {
204
- setResults([]);
205
- setLoading(false);
206
- return;
207
- }
208
- debounceRef.current = setTimeout(() => {
209
- abortRef.current?.abort();
210
- const ac = new AbortController();
211
- abortRef.current = ac;
212
- setLoading(true);
213
- search(query.trim(), ac.signal)
214
- .then((rows) => {
215
- if (ac.signal.aborted) return;
216
- setResults(rows);
217
- setHighlight(0);
218
- setLoading(false);
219
- })
220
- .catch(() => {
221
- if (ac.signal.aborted) return;
222
- // Search failure degrades to "no suggestions" — free-entry
223
- // auto-commit still works, which is the whole point on a
224
- // flaky network.
225
- setResults([]);
226
- setLoading(false);
227
- });
228
- }, debounceMs);
229
- return () => {
230
- if (debounceRef.current) clearTimeout(debounceRef.current);
231
- abortRef.current?.abort();
232
- };
233
- }, [query, isCommitted, search, debounceMs, searchOnFocus, focused]);
234
-
235
- const trimmed = query.trim();
236
- const freeEntryOk = !!allowFreeEntry && trimmed.length > 0 && allowFreeEntry(trimmed);
237
- const hasNoResults = !loading && trimmed.length > 0 && results.length === 0;
238
- const offerFree = !isCommitted && freeEntryOk && !results.some((r) => matchOption(trimmed, r));
239
- const totalEntries = results.length + (offerFree ? 1 : 0);
240
-
241
- const commitOption = useCallback(
242
- (option: ComboboxOption) => {
243
- setInvalid(false);
244
- setResults([]);
245
- setHighlight(0);
246
- // Reflect the commit in the input so a half-typed query never
247
- // sits next to a card naming someone else — unless the parent
248
- // collects values (clearOnCommit), where the input resets for
249
- // the next entry.
250
- setQuery(clearOnCommit ? "" : (option.subtitle ?? option.title));
251
- onCommit({ kind: "option", option });
252
- },
253
- [onCommit, clearOnCommit],
254
- );
255
-
256
- const commitFree = useCallback(
257
- (raw: string) => {
258
- setInvalid(false);
259
- setResults([]);
260
- setHighlight(0);
261
- setQuery(clearOnCommit ? "" : raw);
262
- onCommit({ kind: "free", raw });
263
- },
264
- [onCommit, clearOnCommit],
265
- );
266
-
267
- // Blur auto-commit — the fix for "typed it, tabbed on, looked
268
- // committed but wasn't". Exact option match wins (brings the
269
- // entity's identity along); otherwise free entry; otherwise flag
270
- // invalid. Runs on a short delay so a dropdown row click (which
271
- // blurs first) still lands on the row handler.
272
- const handleBlur = useCallback(() => {
273
- setTimeout(() => {
274
- // Re-focused within the delay (e.g. a host frame click handing
275
- // focus straight back, MultiCombobox inline): still editing —
276
- // neither close the list nor auto-commit.
277
- if (inputRef.current && document.activeElement === inputRef.current) return;
278
- setFocused(false);
279
- if (isCommitted) return;
280
- const raw = trimmed;
281
- if (!raw) {
282
- setInvalid(false);
283
- return;
284
- }
285
- const exact = results.find((r) => matchOption(raw, r));
286
- if (exact) {
287
- commitOption(exact);
288
- return;
289
- }
290
- if (allowFreeEntry?.(raw)) {
291
- commitFree(raw);
292
- return;
293
- }
294
- setInvalid(true);
295
- }, 150);
296
- }, [isCommitted, trimmed, results, matchOption, allowFreeEntry, commitOption, commitFree]);
297
-
298
- const handleQueryChange = useCallback(
299
- (next: string) => {
300
- setQuery(next);
301
- setInvalid(false);
302
- if (isCommitted) onUncommit?.();
303
- },
304
- [isCommitted, onUncommit],
305
- );
306
-
307
- const clearCommitted = useCallback(() => {
308
- onUncommit?.();
309
- // Keep the committed text in the input for editing; focus so the
310
- // user can immediately type.
311
- inputRef.current?.focus();
312
- }, [onUncommit]);
313
-
314
- const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
315
- if (isCommitted) return;
316
- if (e.key === "ArrowDown") {
317
- e.preventDefault();
318
- setHighlight((h) => Math.min(h + 1, Math.max(totalEntries - 1, 0)));
319
- } else if (e.key === "ArrowUp") {
320
- e.preventDefault();
321
- setHighlight((h) => Math.max(h - 1, 0));
322
- } else if (e.key === "Enter") {
323
- if (totalEntries > 0) {
324
- e.preventDefault();
325
- if (highlight < results.length) {
326
- const chosen = results[highlight];
327
- if (chosen) commitOption(chosen);
328
- } else if (offerFree) {
329
- commitFree(trimmed);
330
- }
331
- } else if (freeEntryOk) {
332
- // No dropdown (e.g. search still in flight on a slow
333
- // network) — Enter on a valid free entry commits directly.
334
- e.preventDefault();
335
- commitFree(trimmed);
336
- }
337
- } else if (e.key === "Escape") {
338
- setQuery("");
339
- setResults([]);
340
- setInvalid(false);
341
- }
342
- };
343
-
344
- const showDropdown =
345
- focused && !isCommitted && (results.length > 0 || offerFree || (hasNoResults && !!emptyHint));
346
-
347
- return (
348
- <div
349
- style={
350
- bare
351
- ? { position: "static", flex: "1 1 140px", minWidth: 140 }
352
- : { position: "relative" }
353
- }
354
- >
355
- <Input
356
- ref={inputRef}
357
- id={inputId}
358
- aria-describedby={inputDescribedBy}
359
- bare={bare}
360
- label={label}
361
- data-testid={`${testid}-input`}
362
- value={query}
363
- disabled={disabled}
364
- onChange={(e) => handleQueryChange(e.target.value)}
365
- onFocus={() => {
366
- setFocused(true);
367
- setInvalid(false);
368
- }}
369
- onBlur={handleBlur}
370
- onKeyDown={handleKeyDown}
371
- placeholder={placeholder}
372
- autoFocus={autoFocus}
373
- autoComplete="off"
374
- error={invalid ? (invalidHint ?? COMBOBOX_DEFAULT_INVALID_HINT) : null}
375
- />
376
-
377
- {showDropdown ? (
378
- <div
379
- data-testid={`${testid}-dropdown`}
380
- style={{
381
- position: "absolute",
382
- top: "100%",
383
- marginTop: 4,
384
- left: 0,
385
- right: 0,
386
- background: "var(--ck-bg-surface)",
387
- border: "1px solid var(--ck-border-strong)",
388
- borderRadius: "var(--ck-radius-sm)",
389
- boxShadow: "var(--ck-shadow-menu, 0 4px 12px rgba(0,0,0,0.08))",
390
- zIndex: 10,
391
- overflow: "hidden",
392
- }}
393
- >
394
- {results.map((row, i) => (
395
- <button
396
- key={row.key}
397
- type="button"
398
- onMouseDown={(e) => {
399
- e.preventDefault();
400
- commitOption(row);
401
- }}
402
- onMouseEnter={() => setHighlight(i)}
403
- style={{
404
- display: "block",
405
- width: "100%",
406
- textAlign: "left",
407
- padding: "8px 12px",
408
- border: "none",
409
- background: i === highlight ? "var(--ck-bg-muted)" : "transparent",
410
- cursor: "pointer",
411
- font: "inherit",
412
- fontSize: 13,
413
- color: "var(--ck-text-primary)",
414
- }}
415
- >
416
- <div style={{ fontWeight: 500 }}>{row.title}</div>
417
- {row.subtitle ? (
418
- <div style={{ fontSize: 12, color: "var(--ck-text-secondary)" }}>
419
- {row.subtitle}
420
- </div>
421
- ) : null}
422
- </button>
423
- ))}
424
- {offerFree ? (
425
- <button
426
- type="button"
427
- data-testid={`${testid}-free-entry`}
428
- onMouseDown={(e) => {
429
- e.preventDefault();
430
- commitFree(trimmed);
431
- }}
432
- onMouseEnter={() => setHighlight(results.length)}
433
- style={{
434
- display: "block",
435
- width: "100%",
436
- textAlign: "left",
437
- padding: "8px 12px",
438
- border: "none",
439
- background:
440
- highlight === results.length ? "var(--ck-bg-muted)" : "transparent",
441
- borderTop: results.length > 0 ? "1px solid var(--ck-border-subtle)" : "none",
442
- cursor: "pointer",
443
- font: "inherit",
444
- fontSize: 13,
445
- color: "var(--ck-accent)",
446
- }}
447
- >
448
- {freeEntryLabel ? freeEntryLabel(trimmed) : <>Use “{trimmed}”</>}
449
- </button>
450
- ) : hasNoResults && emptyHint ? (
451
- <div style={{ padding: "8px 12px", fontSize: 12, color: "var(--ck-text-secondary)" }}>
452
- {emptyHint}
453
- </div>
454
- ) : null}
455
- </div>
456
- ) : null}
457
-
458
- {committed ? (
459
- <div
460
- data-testid={`${testid}-committed`}
461
- style={{
462
- marginTop: 8,
463
- padding: "10px 12px",
464
- background: "var(--ck-bg-muted)",
465
- border: "1px solid var(--ck-border-subtle)",
466
- borderRadius: "var(--ck-radius-sm)",
467
- display: "flex",
468
- alignItems: "flex-start",
469
- gap: 8,
470
- }}
471
- >
472
- <div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 2 }}>
473
- <div style={{ fontSize: 14, fontWeight: 500, color: "var(--ck-text-primary)" }}>
474
- {committed.title}
475
- </div>
476
- {committed.subtitle ? (
477
- <div style={{ fontSize: 12, color: "var(--ck-text-secondary)" }}>
478
- {committed.subtitle}
479
- </div>
480
- ) : null}
481
- {committedExtra ? <div style={{ marginTop: 6 }}>{committedExtra}</div> : null}
482
- </div>
483
- {onUncommit ? (
484
- <button
485
- type="button"
486
- aria-label="Clear selection"
487
- data-testid={`${testid}-clear`}
488
- onClick={clearCommitted}
489
- style={{
490
- border: "none",
491
- background: "none",
492
- cursor: "pointer",
493
- color: "var(--ck-text-secondary)",
494
- fontSize: 14,
495
- lineHeight: 1,
496
- padding: 2,
497
- flexShrink: 0,
498
- }}
499
- >
500
- ×
501
- </button>
502
- ) : null}
503
- </div>
504
- ) : null}
505
- </div>
506
- );
507
- });
@@ -1,139 +0,0 @@
1
- import { forwardRef, useCallback, useRef, useState, type HTMLAttributes, type ReactNode } from "react";
2
- import { cn } from "../lib/cn";
3
-
4
- // CopyField — read-only value field with an embedded Copy button.
5
- //
6
- // One bordered frame (same 36px field chrome as Input-with-addon):
7
- // the value renders mono + ellipsized on the left, the Copy button
8
- // sits INSIDE the frame flush right on a muted slab (mirrors Input's
9
- // suffix addon). Clicking copies to the clipboard and flips the
10
- // label to "Copied ✓" for a beat; clicking the value text selects it
11
- // (manual-copy fallback for non-secure origins where the Clipboard
12
- // API is unavailable).
13
- //
14
- // Use for share links, signing URLs, tokens, API keys — anywhere the
15
- // user's next action is overwhelmingly "copy this". Compose with:
16
- // dialog bodies (share link rows), sidebar token reveals, settings
17
- // pages (API-key display).
18
- //
19
- // Forwards ref to the copy <button>. Spreads `...rest` on the frame
20
- // so consumers can pass data-* / aria-* / style / className.
21
-
22
- export interface CopyFieldProps extends Omit<HTMLAttributes<HTMLDivElement>, "onCopy"> {
23
- // The string shown + copied. Rendered mono, single-line, ellipsized.
24
- value: string;
25
- // Copy-button label; flips to `copiedLabel` after a click.
26
- copyLabel?: ReactNode;
27
- copiedLabel?: ReactNode;
28
- // How long the copied state lingers before flipping back (ms).
29
- copiedForMs?: number;
30
- // Notified after a successful clipboard write. Clipboard failures
31
- // are silent by design — the value stays selectable in the field.
32
- onCopied?: (value: string) => void;
33
- }
34
-
35
- export const CopyField = forwardRef<HTMLButtonElement, CopyFieldProps>(function CopyField(
36
- {
37
- value,
38
- copyLabel = "Copy",
39
- copiedLabel = "Copied ✓",
40
- copiedForMs = 1500,
41
- onCopied,
42
- className,
43
- style,
44
- ...rest
45
- },
46
- ref,
47
- ) {
48
- const [copied, setCopied] = useState(false);
49
- const valueRef = useRef<HTMLElement | null>(null);
50
-
51
- const handleCopy = useCallback(() => {
52
- void navigator.clipboard
53
- ?.writeText(value)
54
- .then(() => onCopied?.(value))
55
- .catch(() => {
56
- // Clipboard rejected (permissions / non-secure origin). The
57
- // value stays visible + selectable in the field — no error UI.
58
- });
59
- setCopied(true);
60
- window.setTimeout(() => setCopied(false), copiedForMs);
61
- }, [value, copiedForMs, onCopied]);
62
-
63
- // Click-to-select on the value — the manual-copy fallback.
64
- const handleSelectAll = useCallback(() => {
65
- const node = valueRef.current;
66
- if (!node) return;
67
- const range = document.createRange();
68
- range.selectNodeContents(node);
69
- const sel = window.getSelection();
70
- sel?.removeAllRanges();
71
- sel?.addRange(range);
72
- }, []);
73
-
74
- return (
75
- <div
76
- {...rest}
77
- className={cn("ck-copy-field", className)}
78
- style={{
79
- display: "flex",
80
- alignItems: "stretch",
81
- height: 36,
82
- background: "var(--ck-bg-surface)",
83
- border: "1px solid var(--ck-border-strong)",
84
- borderRadius: "var(--ck-radius-sm)",
85
- overflow: "hidden",
86
- minWidth: 0,
87
- ...(style ?? {}),
88
- }}
89
- >
90
- <code
91
- ref={valueRef}
92
- title={value}
93
- onClick={handleSelectAll}
94
- style={{
95
- flex: 1,
96
- minWidth: 0,
97
- alignSelf: "center",
98
- padding: "0 12px",
99
- font: "400 12px/1.5 var(--ck-font-mono, ui-monospace, monospace)",
100
- color: "var(--ck-text-primary)",
101
- whiteSpace: "nowrap",
102
- overflow: "hidden",
103
- textOverflow: "ellipsis",
104
- cursor: "text",
105
- }}
106
- >
107
- {value}
108
- </code>
109
- <button
110
- ref={ref}
111
- type="button"
112
- onClick={handleCopy}
113
- disabled={copied}
114
- className="ck-copy-field-btn"
115
- style={{
116
- flex: "0 0 auto",
117
- display: "inline-flex",
118
- alignItems: "center",
119
- justifyContent: "center",
120
- padding: "0 14px",
121
- // Reserve the wider copied-label footprint so the value
122
- // column doesn't shift mid-flash.
123
- minWidth: 84,
124
- border: "none",
125
- borderLeft: "1px solid var(--ck-border-strong)",
126
- background: "var(--ck-bg-muted)",
127
- color: copied ? "var(--ck-text-secondary)" : "var(--ck-text-primary)",
128
- font: "500 11px/1 var(--ck-font-sans)",
129
- letterSpacing: "0.04em",
130
- textTransform: "uppercase",
131
- cursor: copied ? "default" : "pointer",
132
- transition: "color var(--ck-dur-fast) var(--ck-ease)",
133
- }}
134
- >
135
- {copied ? copiedLabel : copyLabel}
136
- </button>
137
- </div>
138
- );
139
- });
@@ -1,50 +0,0 @@
1
- import { cn } from "../lib/cn";
2
-
3
- // Small filled pill showing an integer count. Caps at 99+ to keep
4
- // the visual mass tight. Returns null at 0 — call sites can
5
- // unconditionally render it inside a label without an `if (n > 0)`.
6
-
7
- export interface CountBadgeProps {
8
- count: number;
9
- size?: "sm" | "md";
10
- // Tone — default accent. Use "critical" for notifications.
11
- tone?: "accent" | "critical" | "neutral";
12
- className?: string;
13
- }
14
-
15
- const TONE_BG: Record<NonNullable<CountBadgeProps["tone"]>, string> = {
16
- accent: "var(--ck-accent)",
17
- critical: "var(--ck-critical)",
18
- neutral: "var(--ck-text-tertiary)",
19
- };
20
-
21
- export function CountBadge({
22
- count,
23
- size = "md",
24
- tone = "accent",
25
- className,
26
- }: CountBadgeProps) {
27
- if (count <= 0) return null;
28
- const display = count > 99 ? "99+" : String(count);
29
- const isSm = size === "sm";
30
- return (
31
- <span
32
- className={cn("ck-count-badge", className)}
33
- style={{
34
- display: "inline-flex",
35
- alignItems: "center",
36
- justifyContent: "center",
37
- minWidth: isSm ? 14 : 18,
38
- height: isSm ? 14 : 18,
39
- padding: "0 5px",
40
- background: TONE_BG[tone],
41
- color: "var(--ck-text-inverse)",
42
- font: `500 ${isSm ? 10 : 11}px/1 var(--ck-font-mono)`,
43
- borderRadius: 999,
44
- fontVariantNumeric: "tabular-nums",
45
- }}
46
- >
47
- {display}
48
- </span>
49
- );
50
- }