@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.
- package/CHANGELOG.md +577 -0
- package/NOTICE +11 -0
- package/README.md +52 -0
- package/package.json +33 -7
- package/src/fonts.css +20 -0
- package/src/index.ts +8 -25
- package/src/lib/cn.ts +23 -6
- package/src/styles.css +17 -0
- package/src/theme.css +132 -0
- package/src/tokens/colors.css +112 -0
- package/src/tokens/compat.css +36 -0
- package/src/tokens/elevation.css +14 -0
- package/src/tokens/motion.css +42 -0
- package/src/tokens/spacing.css +55 -0
- package/src/tokens/typography-cjk.css +42 -0
- package/src/tokens/typography.css +81 -0
- package/src/actionbar/ActionBar.tsx +0 -837
- package/src/actionbar/ActionBarButton.tsx +0 -111
- package/src/actionbar/ActionBarMenuGroup.tsx +0 -116
- package/src/actionbar/ActionBarModeHandle.tsx +0 -171
- package/src/actionbar/ActionBarProvider.tsx +0 -91
- package/src/actionbar/actionbar-context.ts +0 -45
- package/src/actionbar/index.ts +0 -28
- package/src/actionbar/types.ts +0 -147
- package/src/actionbar/useActionBarItems.ts +0 -60
- package/src/actionbar/useActionBarMode.ts +0 -144
- package/src/actionbar/useActionBarPanel.ts +0 -56
- package/src/actionbar/useActionBarStatusDot.ts +0 -49
- package/src/actionbar/useActionBarToast.ts +0 -33
- package/src/ambient/AmbientBackdrop.tsx +0 -74
- package/src/ambient/CommandInput.tsx +0 -107
- package/src/ambient/SuperbarStrip.tsx +0 -36
- package/src/ambient/index.ts +0 -6
- package/src/bento/BentoCell.tsx +0 -66
- package/src/bento/BentoGrid.tsx +0 -42
- package/src/bento/index.ts +0 -2
- package/src/command/CommandPalette.tsx +0 -329
- package/src/command/CommandProvider.tsx +0 -57
- package/src/command/command-context.ts +0 -12
- package/src/command/index.ts +0 -6
- package/src/command/rank.ts +0 -50
- package/src/command/types.ts +0 -37
- package/src/command/useCommandSource.ts +0 -37
- package/src/dialogs/DialogsProvider.tsx +0 -216
- package/src/dialogs/Modal.tsx +0 -260
- package/src/dialogs/Toast.tsx +0 -85
- package/src/dialogs/dialogs-context.ts +0 -6
- package/src/dialogs/index.ts +0 -10
- package/src/dialogs/types.ts +0 -37
- package/src/dialogs/useDialogs.ts +0 -8
- package/src/editorial/EditorialSpotlight.tsx +0 -63
- package/src/editorial/Folio.tsx +0 -52
- package/src/editorial/PlateMarker.tsx +0 -33
- package/src/editorial/RomanSection.tsx +0 -65
- package/src/editorial/RunningMarginalia.tsx +0 -65
- package/src/editorial/index.ts +0 -10
- package/src/hooks/index.ts +0 -5
- package/src/hooks/useKeyboardHotkey.ts +0 -93
- package/src/hooks/useReducedMotion.ts +0 -20
- package/src/hooks/useViewport.ts +0 -61
- package/src/layout/Breadcrumb.tsx +0 -74
- package/src/layout/LeftNavRail.tsx +0 -139
- package/src/layout/MobileTabBar.tsx +0 -101
- package/src/layout/NavItem.tsx +0 -128
- package/src/layout/NavSearchTrigger.tsx +0 -88
- package/src/layout/NavSection.tsx +0 -40
- package/src/layout/RightSidebarPanel.tsx +0 -111
- package/src/layout/Shell.tsx +0 -91
- package/src/layout/SidePanel.tsx +0 -316
- package/src/layout/StickyBanner.tsx +0 -83
- package/src/layout/Topbar.tsx +0 -68
- package/src/layout/index.ts +0 -24
- package/src/layout/useNavRailState.ts +0 -69
- package/src/lib/time-utils.ts +0 -44
- package/src/neobrutalism/Marquee.tsx +0 -81
- package/src/neobrutalism/Sticker.tsx +0 -71
- package/src/neobrutalism/index.ts +0 -4
- package/src/primitives/Avatar.tsx +0 -53
- package/src/primitives/Button.tsx +0 -67
- package/src/primitives/Card.tsx +0 -41
- package/src/primitives/Checkbox.tsx +0 -87
- package/src/primitives/Chip.tsx +0 -177
- package/src/primitives/Combobox.tsx +0 -475
- package/src/primitives/CopyField.tsx +0 -139
- package/src/primitives/CountBadge.tsx +0 -50
- package/src/primitives/Input.tsx +0 -161
- package/src/primitives/Kbd.tsx +0 -45
- package/src/primitives/MentionCombobox.tsx +0 -612
- package/src/primitives/MultiCombobox.tsx +0 -179
- package/src/primitives/PageHeader.tsx +0 -77
- package/src/primitives/Radio.tsx +0 -131
- package/src/primitives/SegmentedControl.tsx +0 -135
- package/src/primitives/Select.tsx +0 -779
- package/src/primitives/SignaturePad.tsx +0 -461
- package/src/primitives/Tag.tsx +0 -56
- package/src/primitives/Textarea.tsx +0 -62
- package/src/primitives/ToggleSwitch.tsx +0 -79
- package/src/primitives/Tooltip.tsx +0 -213
- package/src/primitives/index.ts +0 -48
- package/src/pwa/InstallPromptBanner.tsx +0 -132
- package/src/pwa/index.ts +0 -4
- package/src/pwa/manifest.template.json +0 -20
- package/src/pwa/registerSW.ts +0 -55
- package/src/riso/Halftone.tsx +0 -85
- package/src/riso/Misregister.tsx +0 -63
- package/src/riso/RisoStamp.tsx +0 -76
- package/src/riso/index.ts +0 -3
- package/src/sketch/HandUnderline.tsx +0 -53
- package/src/sketch/RoughArrow.tsx +0 -91
- package/src/sketch/RoughBox.tsx +0 -73
- package/src/sketch/StickyNote.tsx +0 -56
- package/src/sketch/index.ts +0 -4
- package/src/styles/base.css +0 -186
- package/src/styles/chrome-ambient.css +0 -235
- package/src/styles/chrome-bento.css +0 -192
- package/src/styles/chrome-editorial.css +0 -168
- package/src/styles/chrome-neobrutalism.css +0 -326
- package/src/styles/chrome-riso.css +0 -339
- package/src/styles/chrome-sketch.css +0 -362
- package/src/styles/chrome-swiss.css +0 -265
- package/src/styles/chrome-terminal.css +0 -272
- package/src/styles/fonts.css +0 -1034
- package/src/styles/index.css +0 -306
- package/src/styles/tokens.css +0 -941
- package/src/terminal/AsciiBox.tsx +0 -65
- package/src/terminal/BrailleSpinner.tsx +0 -46
- package/src/terminal/index.ts +0 -4
- package/src/theme/ThemeProvider.tsx +0 -99
- package/src/theme/index.ts +0 -6
- package/src/theme/inline-script.ts +0 -40
- package/src/theme/theme-context.ts +0 -7
- package/src/theme/types.ts +0 -39
- 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
|
-
}
|