@cosxai/ui 0.24.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 -177
  83. package/src/primitives/Combobox.tsx +0 -475
  84. package/src/primitives/CopyField.tsx +0 -139
  85. package/src/primitives/CountBadge.tsx +0 -50
  86. package/src/primitives/Input.tsx +0 -161
  87. package/src/primitives/Kbd.tsx +0 -45
  88. package/src/primitives/MentionCombobox.tsx +0 -612
  89. package/src/primitives/MultiCombobox.tsx +0 -179
  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 -48
  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 -306
  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,475 +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
- testid?: string | undefined;
108
- }
109
-
110
- // Imperative handle — `focus()` returns focus to the input (e.g. after
111
- // an inline editor closes); `clear()` wipes the typed text + dropdown
112
- // (e.g. a parent rejecting a duplicate commit).
113
- export type ComboboxHandle = {
114
- focus: () => void;
115
- clear: () => void;
116
- };
117
-
118
- const DEFAULT_DEBOUNCE_MS = 250;
119
-
120
- function defaultMatch(raw: string, option: ComboboxOption): boolean {
121
- const norm = raw.trim().toLowerCase();
122
- return (
123
- (option.subtitle ?? "").trim().toLowerCase() === norm ||
124
- option.title.trim().toLowerCase() === norm
125
- );
126
- }
127
-
128
- export const Combobox = forwardRef<ComboboxHandle, ComboboxProps>(function Combobox(
129
- {
130
- label,
131
- placeholder,
132
- autoFocus,
133
- disabled,
134
- search,
135
- searchOnFocus,
136
- debounceMs = DEFAULT_DEBOUNCE_MS,
137
- allowFreeEntry,
138
- freeEntryLabel,
139
- emptyHint,
140
- invalidHint,
141
- matchOption = defaultMatch,
142
- committed,
143
- committedExtra,
144
- onCommit,
145
- onUncommit,
146
- clearOnCommit = false,
147
- testid = "combobox",
148
- },
149
- ref,
150
- ) {
151
- const [query, setQuery] = useState("");
152
- const [results, setResults] = useState<ComboboxOption[]>([]);
153
- const [loading, setLoading] = useState(false);
154
- const [focused, setFocused] = useState(false);
155
- const [highlight, setHighlight] = useState(0);
156
- const [invalid, setInvalid] = useState(false);
157
-
158
- const abortRef = useRef<AbortController | null>(null);
159
- const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
160
- const inputRef = useRef<HTMLInputElement | null>(null);
161
-
162
- const isCommitted = !!committed;
163
-
164
- useImperativeHandle(
165
- ref,
166
- () => ({
167
- focus: () => inputRef.current?.focus(),
168
- clear: () => {
169
- setQuery("");
170
- setResults([]);
171
- setHighlight(0);
172
- setInvalid(false);
173
- },
174
- }),
175
- [],
176
- );
177
-
178
- useEffect(() => {
179
- if (debounceRef.current) clearTimeout(debounceRef.current);
180
- // Search is suspended while a commit is displayed — the input
181
- // still holds the committed text but shouldn't re-open the list.
182
- // searchOnFocus lifts the non-empty-query requirement while the
183
- // field is focused: clicking presents the list, typing narrows.
184
- if (isCommitted || (!query.trim() && !(searchOnFocus && focused))) {
185
- setResults([]);
186
- setLoading(false);
187
- return;
188
- }
189
- debounceRef.current = setTimeout(() => {
190
- abortRef.current?.abort();
191
- const ac = new AbortController();
192
- abortRef.current = ac;
193
- setLoading(true);
194
- search(query.trim(), ac.signal)
195
- .then((rows) => {
196
- if (ac.signal.aborted) return;
197
- setResults(rows);
198
- setHighlight(0);
199
- setLoading(false);
200
- })
201
- .catch(() => {
202
- if (ac.signal.aborted) return;
203
- // Search failure degrades to "no suggestions" — free-entry
204
- // auto-commit still works, which is the whole point on a
205
- // flaky network.
206
- setResults([]);
207
- setLoading(false);
208
- });
209
- }, debounceMs);
210
- return () => {
211
- if (debounceRef.current) clearTimeout(debounceRef.current);
212
- abortRef.current?.abort();
213
- };
214
- }, [query, isCommitted, search, debounceMs, searchOnFocus, focused]);
215
-
216
- const trimmed = query.trim();
217
- const freeEntryOk = !!allowFreeEntry && trimmed.length > 0 && allowFreeEntry(trimmed);
218
- const hasNoResults = !loading && trimmed.length > 0 && results.length === 0;
219
- const offerFree = !isCommitted && freeEntryOk && !results.some((r) => matchOption(trimmed, r));
220
- const totalEntries = results.length + (offerFree ? 1 : 0);
221
-
222
- const commitOption = useCallback(
223
- (option: ComboboxOption) => {
224
- setInvalid(false);
225
- setResults([]);
226
- setHighlight(0);
227
- // Reflect the commit in the input so a half-typed query never
228
- // sits next to a card naming someone else — unless the parent
229
- // collects values (clearOnCommit), where the input resets for
230
- // the next entry.
231
- setQuery(clearOnCommit ? "" : (option.subtitle ?? option.title));
232
- onCommit({ kind: "option", option });
233
- },
234
- [onCommit, clearOnCommit],
235
- );
236
-
237
- const commitFree = useCallback(
238
- (raw: string) => {
239
- setInvalid(false);
240
- setResults([]);
241
- setHighlight(0);
242
- setQuery(clearOnCommit ? "" : raw);
243
- onCommit({ kind: "free", raw });
244
- },
245
- [onCommit, clearOnCommit],
246
- );
247
-
248
- // Blur auto-commit — the fix for "typed it, tabbed on, looked
249
- // committed but wasn't". Exact option match wins (brings the
250
- // entity's identity along); otherwise free entry; otherwise flag
251
- // invalid. Runs on a short delay so a dropdown row click (which
252
- // blurs first) still lands on the row handler.
253
- const handleBlur = useCallback(() => {
254
- setTimeout(() => {
255
- setFocused(false);
256
- if (isCommitted) return;
257
- const raw = trimmed;
258
- if (!raw) {
259
- setInvalid(false);
260
- return;
261
- }
262
- const exact = results.find((r) => matchOption(raw, r));
263
- if (exact) {
264
- commitOption(exact);
265
- return;
266
- }
267
- if (allowFreeEntry?.(raw)) {
268
- commitFree(raw);
269
- return;
270
- }
271
- setInvalid(true);
272
- }, 150);
273
- }, [isCommitted, trimmed, results, matchOption, allowFreeEntry, commitOption, commitFree]);
274
-
275
- const handleQueryChange = useCallback(
276
- (next: string) => {
277
- setQuery(next);
278
- setInvalid(false);
279
- if (isCommitted) onUncommit?.();
280
- },
281
- [isCommitted, onUncommit],
282
- );
283
-
284
- const clearCommitted = useCallback(() => {
285
- onUncommit?.();
286
- // Keep the committed text in the input for editing; focus so the
287
- // user can immediately type.
288
- inputRef.current?.focus();
289
- }, [onUncommit]);
290
-
291
- const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
292
- if (isCommitted) return;
293
- if (e.key === "ArrowDown") {
294
- e.preventDefault();
295
- setHighlight((h) => Math.min(h + 1, Math.max(totalEntries - 1, 0)));
296
- } else if (e.key === "ArrowUp") {
297
- e.preventDefault();
298
- setHighlight((h) => Math.max(h - 1, 0));
299
- } else if (e.key === "Enter") {
300
- if (totalEntries > 0) {
301
- e.preventDefault();
302
- if (highlight < results.length) {
303
- const chosen = results[highlight];
304
- if (chosen) commitOption(chosen);
305
- } else if (offerFree) {
306
- commitFree(trimmed);
307
- }
308
- } else if (freeEntryOk) {
309
- // No dropdown (e.g. search still in flight on a slow
310
- // network) — Enter on a valid free entry commits directly.
311
- e.preventDefault();
312
- commitFree(trimmed);
313
- }
314
- } else if (e.key === "Escape") {
315
- setQuery("");
316
- setResults([]);
317
- setInvalid(false);
318
- }
319
- };
320
-
321
- const showDropdown =
322
- focused && !isCommitted && (results.length > 0 || offerFree || (hasNoResults && !!emptyHint));
323
-
324
- return (
325
- <div style={{ position: "relative" }}>
326
- <Input
327
- ref={inputRef}
328
- label={label}
329
- data-testid={`${testid}-input`}
330
- value={query}
331
- disabled={disabled}
332
- onChange={(e) => handleQueryChange(e.target.value)}
333
- onFocus={() => {
334
- setFocused(true);
335
- setInvalid(false);
336
- }}
337
- onBlur={handleBlur}
338
- onKeyDown={handleKeyDown}
339
- placeholder={placeholder}
340
- autoFocus={autoFocus}
341
- autoComplete="off"
342
- error={invalid ? (invalidHint ?? "Pick from the list to continue.") : null}
343
- />
344
-
345
- {showDropdown ? (
346
- <div
347
- data-testid={`${testid}-dropdown`}
348
- style={{
349
- position: "absolute",
350
- top: "100%",
351
- marginTop: 4,
352
- left: 0,
353
- right: 0,
354
- background: "var(--ck-bg-surface)",
355
- border: "1px solid var(--ck-border-strong)",
356
- borderRadius: "var(--ck-radius-sm)",
357
- boxShadow: "var(--ck-shadow-menu, 0 4px 12px rgba(0,0,0,0.08))",
358
- zIndex: 10,
359
- overflow: "hidden",
360
- }}
361
- >
362
- {results.map((row, i) => (
363
- <button
364
- key={row.key}
365
- type="button"
366
- onMouseDown={(e) => {
367
- e.preventDefault();
368
- commitOption(row);
369
- }}
370
- onMouseEnter={() => setHighlight(i)}
371
- style={{
372
- display: "block",
373
- width: "100%",
374
- textAlign: "left",
375
- padding: "8px 12px",
376
- border: "none",
377
- background: i === highlight ? "var(--ck-bg-muted)" : "transparent",
378
- cursor: "pointer",
379
- font: "inherit",
380
- fontSize: 13,
381
- color: "var(--ck-text-primary)",
382
- }}
383
- >
384
- <div style={{ fontWeight: 500 }}>{row.title}</div>
385
- {row.subtitle ? (
386
- <div style={{ fontSize: 12, color: "var(--ck-text-secondary)" }}>
387
- {row.subtitle}
388
- </div>
389
- ) : null}
390
- </button>
391
- ))}
392
- {offerFree ? (
393
- <button
394
- type="button"
395
- data-testid={`${testid}-free-entry`}
396
- onMouseDown={(e) => {
397
- e.preventDefault();
398
- commitFree(trimmed);
399
- }}
400
- onMouseEnter={() => setHighlight(results.length)}
401
- style={{
402
- display: "block",
403
- width: "100%",
404
- textAlign: "left",
405
- padding: "8px 12px",
406
- border: "none",
407
- background:
408
- highlight === results.length ? "var(--ck-bg-muted)" : "transparent",
409
- borderTop: results.length > 0 ? "1px solid var(--ck-border-subtle)" : "none",
410
- cursor: "pointer",
411
- font: "inherit",
412
- fontSize: 13,
413
- color: "var(--ck-accent)",
414
- }}
415
- >
416
- {freeEntryLabel ? freeEntryLabel(trimmed) : <>Use “{trimmed}”</>}
417
- </button>
418
- ) : hasNoResults && emptyHint ? (
419
- <div style={{ padding: "8px 12px", fontSize: 12, color: "var(--ck-text-secondary)" }}>
420
- {emptyHint}
421
- </div>
422
- ) : null}
423
- </div>
424
- ) : null}
425
-
426
- {committed ? (
427
- <div
428
- data-testid={`${testid}-committed`}
429
- style={{
430
- marginTop: 8,
431
- padding: "10px 12px",
432
- background: "var(--ck-bg-muted)",
433
- border: "1px solid var(--ck-border-subtle)",
434
- borderRadius: "var(--ck-radius-sm)",
435
- display: "flex",
436
- alignItems: "flex-start",
437
- gap: 8,
438
- }}
439
- >
440
- <div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 2 }}>
441
- <div style={{ fontSize: 14, fontWeight: 500, color: "var(--ck-text-primary)" }}>
442
- {committed.title}
443
- </div>
444
- {committed.subtitle ? (
445
- <div style={{ fontSize: 12, color: "var(--ck-text-secondary)" }}>
446
- {committed.subtitle}
447
- </div>
448
- ) : null}
449
- {committedExtra ? <div style={{ marginTop: 6 }}>{committedExtra}</div> : null}
450
- </div>
451
- {onUncommit ? (
452
- <button
453
- type="button"
454
- aria-label="Clear selection"
455
- data-testid={`${testid}-clear`}
456
- onClick={clearCommitted}
457
- style={{
458
- border: "none",
459
- background: "none",
460
- cursor: "pointer",
461
- color: "var(--ck-text-secondary)",
462
- fontSize: 14,
463
- lineHeight: 1,
464
- padding: 2,
465
- flexShrink: 0,
466
- }}
467
- >
468
- ×
469
- </button>
470
- ) : null}
471
- </div>
472
- ) : null}
473
- </div>
474
- );
475
- });
@@ -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
- }