@cosxai/ui 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/primitives/Select.tsx +512 -0
- package/src/primitives/index.ts +2 -0
- package/src/styles/base.css +16 -0
- package/src/styles/chrome-terminal.css +28 -0
- package/src/styles/index.css +25 -0
package/package.json
CHANGED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useId,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type CSSProperties,
|
|
10
|
+
type KeyboardEvent,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from "react";
|
|
13
|
+
|
|
14
|
+
import { cn } from "../lib/cn";
|
|
15
|
+
|
|
16
|
+
// Custom listbox-pattern select. We deliberately do NOT use native
|
|
17
|
+
// <select> — browsers refuse to style the popup, so terminal /
|
|
18
|
+
// editorial / swiss etc. would punch through with macOS/Win blue and
|
|
19
|
+
// break the visual contract. Trade-off: ~200 LOC of state +
|
|
20
|
+
// keyboard handling we'd otherwise get for free.
|
|
21
|
+
//
|
|
22
|
+
// Keyboard model (mirrors ARIA 1.2 combobox-as-listbox spec):
|
|
23
|
+
// Space / Enter on the trigger → open
|
|
24
|
+
// ArrowDown / ArrowUp → open (highlight first / last)
|
|
25
|
+
// ArrowDown / ArrowUp (open) → move highlight
|
|
26
|
+
// Home / End (open) → first / last option
|
|
27
|
+
// Enter (open) → commit highlighted option
|
|
28
|
+
// Escape (open) → close, restore previous value
|
|
29
|
+
// Tab → close + advance focus
|
|
30
|
+
// A-Z / 0-9 (open or closed) → jump to next option whose label
|
|
31
|
+
// starts with that character
|
|
32
|
+
// (typeahead; 500 ms reset window)
|
|
33
|
+
//
|
|
34
|
+
// Layout model:
|
|
35
|
+
// - Trigger is a styled <button> that LOOKS like .ck-input
|
|
36
|
+
// - Popover is absolutely positioned beneath the trigger
|
|
37
|
+
// - We do NOT portal — keeps the markup simple. If a parent has
|
|
38
|
+
// `overflow: hidden` that clips the popover, wrap the Select
|
|
39
|
+
// in a sibling rather than mounting it inside that container.
|
|
40
|
+
|
|
41
|
+
export interface SelectOption {
|
|
42
|
+
value: string;
|
|
43
|
+
label: string;
|
|
44
|
+
// Optional disabled flag — the option renders dimmed and isn't
|
|
45
|
+
// selectable via click / keyboard. Useful for "Coming soon" rows.
|
|
46
|
+
disabled?: boolean | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SelectProps {
|
|
50
|
+
value: string;
|
|
51
|
+
onChange: (value: string) => void;
|
|
52
|
+
options: SelectOption[];
|
|
53
|
+
label?: ReactNode | undefined;
|
|
54
|
+
helper?: ReactNode | undefined;
|
|
55
|
+
error?: string | null | undefined;
|
|
56
|
+
placeholder?: string | undefined;
|
|
57
|
+
// full = stretches to container width; auto = intrinsic.
|
|
58
|
+
fit?: "full" | "auto" | undefined;
|
|
59
|
+
disabled?: boolean | undefined;
|
|
60
|
+
required?: boolean | undefined;
|
|
61
|
+
name?: string | undefined;
|
|
62
|
+
id?: string | undefined;
|
|
63
|
+
className?: string | undefined;
|
|
64
|
+
// Max height of the popover. Long lists scroll inside.
|
|
65
|
+
maxOptionsHeight?: number | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const TRIGGER_BASE_STYLE: CSSProperties = {
|
|
69
|
+
display: "inline-flex",
|
|
70
|
+
alignItems: "center",
|
|
71
|
+
justifyContent: "space-between",
|
|
72
|
+
gap: 8,
|
|
73
|
+
width: "100%",
|
|
74
|
+
height: 36,
|
|
75
|
+
padding: "0 12px",
|
|
76
|
+
font: "400 13px/1 var(--ck-font-sans)",
|
|
77
|
+
background: "var(--ck-bg-surface)",
|
|
78
|
+
color: "var(--ck-text-primary)",
|
|
79
|
+
borderRadius: "var(--ck-radius-sm)",
|
|
80
|
+
outline: "none",
|
|
81
|
+
textAlign: "left",
|
|
82
|
+
cursor: "pointer",
|
|
83
|
+
transition: "border-color var(--ck-dur-fast) var(--ck-ease)",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select(
|
|
87
|
+
{
|
|
88
|
+
value,
|
|
89
|
+
onChange,
|
|
90
|
+
options,
|
|
91
|
+
label,
|
|
92
|
+
helper,
|
|
93
|
+
error,
|
|
94
|
+
placeholder,
|
|
95
|
+
fit = "full",
|
|
96
|
+
disabled,
|
|
97
|
+
required,
|
|
98
|
+
name,
|
|
99
|
+
id,
|
|
100
|
+
className,
|
|
101
|
+
maxOptionsHeight = 280,
|
|
102
|
+
},
|
|
103
|
+
ref,
|
|
104
|
+
) {
|
|
105
|
+
const autoId = useId();
|
|
106
|
+
const triggerId = id ?? `${autoId}-trigger`;
|
|
107
|
+
const listboxId = `${autoId}-listbox`;
|
|
108
|
+
|
|
109
|
+
const [open, setOpen] = useState(false);
|
|
110
|
+
// Highlighted index while the popover is open. -1 = nothing
|
|
111
|
+
// highlighted; first open with no selection lands on 0.
|
|
112
|
+
const [highlight, setHighlight] = useState(-1);
|
|
113
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
114
|
+
const popoverRef = useRef<HTMLDivElement | null>(null);
|
|
115
|
+
const optionRefs = useRef<(HTMLLIElement | null)[]>([]);
|
|
116
|
+
// Typeahead buffer + reset timer for "press a letter to jump".
|
|
117
|
+
const typeaheadRef = useRef<{ buffer: string; resetAt: number }>({
|
|
118
|
+
buffer: "",
|
|
119
|
+
resetAt: 0,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const selectedIndex = useMemo(
|
|
123
|
+
() => options.findIndex((o) => o.value === value),
|
|
124
|
+
[options, value],
|
|
125
|
+
);
|
|
126
|
+
const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : undefined;
|
|
127
|
+
|
|
128
|
+
const setTriggerRef = useCallback(
|
|
129
|
+
(el: HTMLButtonElement | null) => {
|
|
130
|
+
triggerRef.current = el;
|
|
131
|
+
if (typeof ref === "function") ref(el);
|
|
132
|
+
else if (ref) ref.current = el;
|
|
133
|
+
},
|
|
134
|
+
[ref],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Close on outside click + Esc.
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!open) return;
|
|
140
|
+
const onDocClick = (e: MouseEvent) => {
|
|
141
|
+
const t = e.target as Node | null;
|
|
142
|
+
if (!t) return;
|
|
143
|
+
if (triggerRef.current?.contains(t)) return;
|
|
144
|
+
if (popoverRef.current?.contains(t)) return;
|
|
145
|
+
setOpen(false);
|
|
146
|
+
};
|
|
147
|
+
document.addEventListener("mousedown", onDocClick);
|
|
148
|
+
return () => document.removeEventListener("mousedown", onDocClick);
|
|
149
|
+
}, [open]);
|
|
150
|
+
|
|
151
|
+
// Auto-scroll highlighted option into view inside the popover.
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (!open || highlight < 0) return;
|
|
154
|
+
const el = optionRefs.current[highlight];
|
|
155
|
+
if (el) el.scrollIntoView({ block: "nearest" });
|
|
156
|
+
}, [open, highlight]);
|
|
157
|
+
|
|
158
|
+
// When opening, seed the highlight to the currently selected option
|
|
159
|
+
// so the user lands on what they've already chosen.
|
|
160
|
+
const openPopover = useCallback(
|
|
161
|
+
(startHighlight?: number) => {
|
|
162
|
+
if (disabled) return;
|
|
163
|
+
setOpen(true);
|
|
164
|
+
setHighlight(
|
|
165
|
+
startHighlight !== undefined ? startHighlight : selectedIndex >= 0 ? selectedIndex : 0,
|
|
166
|
+
);
|
|
167
|
+
},
|
|
168
|
+
[disabled, selectedIndex],
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const closePopover = useCallback(() => {
|
|
172
|
+
setOpen(false);
|
|
173
|
+
setHighlight(-1);
|
|
174
|
+
// Re-focus the trigger so keyboard users stay on context.
|
|
175
|
+
triggerRef.current?.focus();
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
const commit = useCallback(
|
|
179
|
+
(idx: number) => {
|
|
180
|
+
const opt = options[idx];
|
|
181
|
+
if (!opt || opt.disabled) return;
|
|
182
|
+
onChange(opt.value);
|
|
183
|
+
closePopover();
|
|
184
|
+
},
|
|
185
|
+
[options, onChange, closePopover],
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// Move highlight skipping disabled rows.
|
|
189
|
+
const moveHighlight = useCallback(
|
|
190
|
+
(delta: 1 | -1, from?: number) => {
|
|
191
|
+
const n = options.length;
|
|
192
|
+
if (n === 0) return;
|
|
193
|
+
let i = (from ?? highlight) + delta;
|
|
194
|
+
// Loop max n times to avoid infinite hunt when ALL options
|
|
195
|
+
// are disabled.
|
|
196
|
+
for (let attempt = 0; attempt < n; attempt++) {
|
|
197
|
+
if (i < 0) i = n - 1;
|
|
198
|
+
if (i >= n) i = 0;
|
|
199
|
+
if (!options[i]?.disabled) {
|
|
200
|
+
setHighlight(i);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
i += delta;
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
[options, highlight],
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Typeahead — press a letter to jump to the next option whose
|
|
210
|
+
// label starts with that letter (or the typed prefix, if pressed
|
|
211
|
+
// within the reset window).
|
|
212
|
+
const typeahead = useCallback(
|
|
213
|
+
(char: string) => {
|
|
214
|
+
const now = Date.now();
|
|
215
|
+
const t = typeaheadRef.current;
|
|
216
|
+
if (now > t.resetAt) t.buffer = "";
|
|
217
|
+
t.buffer += char.toLowerCase();
|
|
218
|
+
t.resetAt = now + 500;
|
|
219
|
+
|
|
220
|
+
const start = highlight >= 0 ? highlight : 0;
|
|
221
|
+
const n = options.length;
|
|
222
|
+
for (let off = 1; off <= n; off++) {
|
|
223
|
+
const idx = (start + off) % n;
|
|
224
|
+
const o = options[idx];
|
|
225
|
+
if (o && !o.disabled && o.label.toLowerCase().startsWith(t.buffer)) {
|
|
226
|
+
setHighlight(idx);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// No prefix match — try single-char from current.
|
|
231
|
+
if (t.buffer.length > 1) {
|
|
232
|
+
t.buffer = char.toLowerCase();
|
|
233
|
+
for (let off = 1; off <= n; off++) {
|
|
234
|
+
const idx = (start + off) % n;
|
|
235
|
+
const o = options[idx];
|
|
236
|
+
if (o && !o.disabled && o.label.toLowerCase().startsWith(t.buffer)) {
|
|
237
|
+
setHighlight(idx);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
[options, highlight],
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const onTriggerKey = (e: KeyboardEvent<HTMLButtonElement>) => {
|
|
247
|
+
if (disabled) return;
|
|
248
|
+
if (!open) {
|
|
249
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
openPopover();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (e.key === "ArrowDown") {
|
|
255
|
+
e.preventDefault();
|
|
256
|
+
openPopover(0);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (e.key === "ArrowUp") {
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
openPopover(options.length - 1);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (e.key.length === 1 && /[a-z0-9]/i.test(e.key)) {
|
|
265
|
+
openPopover();
|
|
266
|
+
typeahead(e.key);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// Open
|
|
272
|
+
if (e.key === "Escape") {
|
|
273
|
+
e.preventDefault();
|
|
274
|
+
closePopover();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (e.key === "Tab") {
|
|
278
|
+
// Close but DON'T preventDefault — let focus advance.
|
|
279
|
+
setOpen(false);
|
|
280
|
+
setHighlight(-1);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (e.key === "ArrowDown") {
|
|
284
|
+
e.preventDefault();
|
|
285
|
+
moveHighlight(1);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (e.key === "ArrowUp") {
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
moveHighlight(-1);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (e.key === "Home") {
|
|
294
|
+
e.preventDefault();
|
|
295
|
+
moveHighlight(1, -1);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (e.key === "End") {
|
|
299
|
+
e.preventDefault();
|
|
300
|
+
moveHighlight(-1, options.length);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (e.key === "Enter") {
|
|
304
|
+
e.preventDefault();
|
|
305
|
+
if (highlight >= 0) commit(highlight);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (e.key.length === 1 && /[a-z0-9]/i.test(e.key)) {
|
|
309
|
+
e.preventDefault();
|
|
310
|
+
typeahead(e.key);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const triggerLabel = selectedOption ? selectedOption.label : placeholder ?? "Select…";
|
|
316
|
+
const showPlaceholder = !selectedOption;
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<div
|
|
320
|
+
className={cn("ck-select-field", className)}
|
|
321
|
+
style={{
|
|
322
|
+
display: "flex",
|
|
323
|
+
flexDirection: "column",
|
|
324
|
+
gap: 6,
|
|
325
|
+
width: fit === "full" ? "100%" : undefined,
|
|
326
|
+
position: "relative",
|
|
327
|
+
}}
|
|
328
|
+
>
|
|
329
|
+
{label && (
|
|
330
|
+
<label
|
|
331
|
+
htmlFor={triggerId}
|
|
332
|
+
className="ck-eyebrow"
|
|
333
|
+
style={{ color: "var(--ck-text-secondary)" }}
|
|
334
|
+
>
|
|
335
|
+
{label}
|
|
336
|
+
</label>
|
|
337
|
+
)}
|
|
338
|
+
|
|
339
|
+
{/* Hidden native input mirrors the value so plain <form> POSTs
|
|
340
|
+
still carry the field. Consumers using the controlled value
|
|
341
|
+
directly can ignore this. */}
|
|
342
|
+
{name && <input type="hidden" name={name} value={value} required={required} />}
|
|
343
|
+
|
|
344
|
+
<button
|
|
345
|
+
ref={setTriggerRef}
|
|
346
|
+
id={triggerId}
|
|
347
|
+
type="button"
|
|
348
|
+
role="combobox"
|
|
349
|
+
aria-haspopup="listbox"
|
|
350
|
+
aria-expanded={open}
|
|
351
|
+
aria-controls={listboxId}
|
|
352
|
+
aria-invalid={error ? true : undefined}
|
|
353
|
+
aria-required={required}
|
|
354
|
+
disabled={disabled}
|
|
355
|
+
onClick={() => (open ? closePopover() : openPopover())}
|
|
356
|
+
onKeyDown={onTriggerKey}
|
|
357
|
+
className={cn(
|
|
358
|
+
"ck-select-trigger",
|
|
359
|
+
error && "ck-select-trigger--invalid",
|
|
360
|
+
disabled && "ck-select-trigger--disabled",
|
|
361
|
+
)}
|
|
362
|
+
style={{
|
|
363
|
+
...TRIGGER_BASE_STYLE,
|
|
364
|
+
border: `1px solid ${error ? "var(--ck-critical)" : "var(--ck-border-strong)"}`,
|
|
365
|
+
color: showPlaceholder ? "var(--ck-text-tertiary)" : "var(--ck-text-primary)",
|
|
366
|
+
opacity: disabled ? 0.55 : 1,
|
|
367
|
+
}}
|
|
368
|
+
>
|
|
369
|
+
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
370
|
+
{triggerLabel}
|
|
371
|
+
</span>
|
|
372
|
+
<svg
|
|
373
|
+
width="10"
|
|
374
|
+
height="10"
|
|
375
|
+
viewBox="0 0 24 24"
|
|
376
|
+
fill="none"
|
|
377
|
+
stroke="currentColor"
|
|
378
|
+
strokeWidth="2.2"
|
|
379
|
+
strokeLinecap="round"
|
|
380
|
+
strokeLinejoin="round"
|
|
381
|
+
aria-hidden
|
|
382
|
+
style={{
|
|
383
|
+
flexShrink: 0,
|
|
384
|
+
opacity: 0.7,
|
|
385
|
+
transform: open ? "rotate(180deg)" : "rotate(0deg)",
|
|
386
|
+
transition: "transform 180ms var(--ck-ease)",
|
|
387
|
+
}}
|
|
388
|
+
>
|
|
389
|
+
<polyline points="6 9 12 15 18 9" />
|
|
390
|
+
</svg>
|
|
391
|
+
</button>
|
|
392
|
+
|
|
393
|
+
{open && (
|
|
394
|
+
<div
|
|
395
|
+
ref={popoverRef}
|
|
396
|
+
className="ck-select-popover"
|
|
397
|
+
style={{
|
|
398
|
+
position: "absolute",
|
|
399
|
+
top: "calc(100% + 4px)",
|
|
400
|
+
left: 0,
|
|
401
|
+
right: 0,
|
|
402
|
+
zIndex: 50,
|
|
403
|
+
background: "var(--ck-bg-surface)",
|
|
404
|
+
border: "1px solid var(--ck-border-strong)",
|
|
405
|
+
borderRadius: "var(--ck-radius-sm)",
|
|
406
|
+
boxShadow: "var(--ck-shadow-3, 0 16px 48px rgba(0,0,0,0.12))",
|
|
407
|
+
maxHeight: maxOptionsHeight,
|
|
408
|
+
overflowY: "auto",
|
|
409
|
+
padding: 4,
|
|
410
|
+
}}
|
|
411
|
+
>
|
|
412
|
+
<ul
|
|
413
|
+
id={listboxId}
|
|
414
|
+
role="listbox"
|
|
415
|
+
aria-labelledby={triggerId}
|
|
416
|
+
style={{ listStyle: "none", margin: 0, padding: 0 }}
|
|
417
|
+
>
|
|
418
|
+
{options.map((opt, i) => {
|
|
419
|
+
const selected = opt.value === value;
|
|
420
|
+
const highlighted = i === highlight;
|
|
421
|
+
return (
|
|
422
|
+
<li
|
|
423
|
+
ref={(el) => {
|
|
424
|
+
optionRefs.current[i] = el;
|
|
425
|
+
}}
|
|
426
|
+
key={opt.value}
|
|
427
|
+
role="option"
|
|
428
|
+
aria-selected={selected}
|
|
429
|
+
aria-disabled={opt.disabled}
|
|
430
|
+
onMouseEnter={() => !opt.disabled && setHighlight(i)}
|
|
431
|
+
onMouseDown={(e) => {
|
|
432
|
+
// Prevent the trigger from losing focus before
|
|
433
|
+
// commit (otherwise the popover closes via
|
|
434
|
+
// outside-click before onClick fires).
|
|
435
|
+
e.preventDefault();
|
|
436
|
+
}}
|
|
437
|
+
onClick={() => commit(i)}
|
|
438
|
+
className={cn(
|
|
439
|
+
"ck-select-option",
|
|
440
|
+
selected && "ck-select-option--selected",
|
|
441
|
+
highlighted && "ck-select-option--active",
|
|
442
|
+
opt.disabled && "ck-select-option--disabled",
|
|
443
|
+
)}
|
|
444
|
+
style={{
|
|
445
|
+
padding: "8px 10px",
|
|
446
|
+
borderRadius: "calc(var(--ck-radius-sm) - 2px)",
|
|
447
|
+
cursor: opt.disabled ? "not-allowed" : "pointer",
|
|
448
|
+
color: opt.disabled
|
|
449
|
+
? "var(--ck-text-tertiary)"
|
|
450
|
+
: "var(--ck-text-primary)",
|
|
451
|
+
background: highlighted
|
|
452
|
+
? "var(--ck-bg-muted)"
|
|
453
|
+
: "transparent",
|
|
454
|
+
font: "400 13px/1.2 var(--ck-font-sans)",
|
|
455
|
+
display: "flex",
|
|
456
|
+
alignItems: "center",
|
|
457
|
+
justifyContent: "space-between",
|
|
458
|
+
gap: 8,
|
|
459
|
+
transition: "background var(--ck-dur-fast) var(--ck-ease)",
|
|
460
|
+
}}
|
|
461
|
+
>
|
|
462
|
+
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
463
|
+
{opt.label}
|
|
464
|
+
</span>
|
|
465
|
+
{selected && (
|
|
466
|
+
<svg
|
|
467
|
+
width="12"
|
|
468
|
+
height="12"
|
|
469
|
+
viewBox="0 0 24 24"
|
|
470
|
+
fill="none"
|
|
471
|
+
stroke="currentColor"
|
|
472
|
+
strokeWidth="2.4"
|
|
473
|
+
strokeLinecap="round"
|
|
474
|
+
strokeLinejoin="round"
|
|
475
|
+
aria-hidden
|
|
476
|
+
style={{ color: "var(--ck-accent)", flexShrink: 0 }}
|
|
477
|
+
>
|
|
478
|
+
<polyline points="20 6 9 17 4 12" />
|
|
479
|
+
</svg>
|
|
480
|
+
)}
|
|
481
|
+
</li>
|
|
482
|
+
);
|
|
483
|
+
})}
|
|
484
|
+
{options.length === 0 && (
|
|
485
|
+
<li
|
|
486
|
+
role="presentation"
|
|
487
|
+
style={{
|
|
488
|
+
padding: "8px 10px",
|
|
489
|
+
color: "var(--ck-text-tertiary)",
|
|
490
|
+
font: "400 13px/1.2 var(--ck-font-sans)",
|
|
491
|
+
}}
|
|
492
|
+
>
|
|
493
|
+
No options
|
|
494
|
+
</li>
|
|
495
|
+
)}
|
|
496
|
+
</ul>
|
|
497
|
+
</div>
|
|
498
|
+
)}
|
|
499
|
+
|
|
500
|
+
{(helper || error) && (
|
|
501
|
+
<div
|
|
502
|
+
style={{
|
|
503
|
+
font: "400 11px/1.4 var(--ck-font-sans)",
|
|
504
|
+
color: error ? "var(--ck-critical)" : "var(--ck-text-tertiary)",
|
|
505
|
+
}}
|
|
506
|
+
>
|
|
507
|
+
{error ?? helper}
|
|
508
|
+
</div>
|
|
509
|
+
)}
|
|
510
|
+
</div>
|
|
511
|
+
);
|
|
512
|
+
});
|
package/src/primitives/index.ts
CHANGED
|
@@ -12,6 +12,8 @@ export { Avatar } from "./Avatar";
|
|
|
12
12
|
export type { AvatarProps } from "./Avatar";
|
|
13
13
|
export { Input } from "./Input";
|
|
14
14
|
export type { InputProps } from "./Input";
|
|
15
|
+
export { Select } from "./Select";
|
|
16
|
+
export type { SelectProps, SelectOption } from "./Select";
|
|
15
17
|
export { Textarea } from "./Textarea";
|
|
16
18
|
export type { TextareaProps } from "./Textarea";
|
|
17
19
|
export { Checkbox } from "./Checkbox";
|
package/src/styles/base.css
CHANGED
|
@@ -17,6 +17,22 @@ body {
|
|
|
17
17
|
transition: background-color 200ms var(--ck-ease), color 200ms var(--ck-ease);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/* Anchor — defaults to the active chrome's accent + a clean
|
|
21
|
+
underline. Without this rule, the browser's visited-link
|
|
22
|
+
purple bleeds through any theme (most notably terminal /
|
|
23
|
+
editorial). Components that want different styling (NavItem,
|
|
24
|
+
TopBar nav, breadcrumbs, in-card secondary links) override
|
|
25
|
+
locally with a more-specific selector. */
|
|
26
|
+
a {
|
|
27
|
+
color: var(--ck-accent);
|
|
28
|
+
text-decoration: underline;
|
|
29
|
+
text-decoration-thickness: 1px;
|
|
30
|
+
text-underline-offset: 2px;
|
|
31
|
+
transition: color var(--ck-dur-fast) var(--ck-ease);
|
|
32
|
+
}
|
|
33
|
+
a:hover { color: var(--ck-accent-hover); }
|
|
34
|
+
a:visited { color: var(--ck-accent); }
|
|
35
|
+
|
|
20
36
|
/* ---------- Typography utilities ---------- */
|
|
21
37
|
.ck-h1 { font: 500 32px/1.2 var(--ck-font-sans); letter-spacing: -0.01em; color: var(--ck-text-primary); margin: 0; }
|
|
22
38
|
.ck-h2 { font: 500 24px/1.3 var(--ck-font-sans); letter-spacing: -0.005em; color: var(--ck-text-primary); margin: 0; }
|
|
@@ -107,6 +107,34 @@ html[data-ck-chrome="terminal"] .ck-textarea:focus-visible {
|
|
|
107
107
|
box-shadow: none !important;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/* Select trigger + popover — mirror the input look so a chrome
|
|
111
|
+
feels consistent across form controls. */
|
|
112
|
+
html[data-ck-chrome="terminal"] .ck-select-trigger {
|
|
113
|
+
background: var(--ck-bg-surface-2);
|
|
114
|
+
border: 1px solid var(--ck-border-subtle) !important;
|
|
115
|
+
border-radius: 2px !important;
|
|
116
|
+
font-family: var(--ck-font-mono);
|
|
117
|
+
color: var(--ck-text-primary);
|
|
118
|
+
}
|
|
119
|
+
html[data-ck-chrome="terminal"] .ck-select-trigger:focus-visible {
|
|
120
|
+
outline: none;
|
|
121
|
+
border-color: var(--ck-border-strong) !important;
|
|
122
|
+
border-top-color: var(--ck-accent) !important;
|
|
123
|
+
box-shadow: none !important;
|
|
124
|
+
}
|
|
125
|
+
html[data-ck-chrome="terminal"] .ck-select-popover {
|
|
126
|
+
background: var(--ck-bg-surface-2);
|
|
127
|
+
border: 1px solid var(--ck-border-strong);
|
|
128
|
+
border-radius: 2px;
|
|
129
|
+
font-family: var(--ck-font-mono);
|
|
130
|
+
}
|
|
131
|
+
html[data-ck-chrome="terminal"] .ck-select-option {
|
|
132
|
+
border-radius: 0;
|
|
133
|
+
}
|
|
134
|
+
html[data-ck-chrome="terminal"] .ck-select-option--active {
|
|
135
|
+
background: var(--ck-bg-muted) !important;
|
|
136
|
+
}
|
|
137
|
+
|
|
110
138
|
/* ===== Cards ================================================ */
|
|
111
139
|
|
|
112
140
|
html[data-ck-chrome="terminal"] .ck-card {
|
package/src/styles/index.css
CHANGED
|
@@ -116,6 +116,31 @@ html[data-ck-chrome="seamless"] .ck-card__foot {
|
|
|
116
116
|
color: var(--ck-text-tertiary);
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/* ---------- Select (custom listbox — never native <select>) ----------
|
|
120
|
+
The trigger LOOKS like .ck-input so chrome restyles cascade
|
|
121
|
+
uniformly. Chromes that want a different vibe (terminal squared,
|
|
122
|
+
sketch hand-drawn, swiss underline-only) override at their own
|
|
123
|
+
scope. */
|
|
124
|
+
.ck-select-trigger:focus-visible {
|
|
125
|
+
border-color: var(--ck-accent) !important;
|
|
126
|
+
box-shadow: 0 0 0 3px var(--ck-accent-muted);
|
|
127
|
+
}
|
|
128
|
+
.ck-select-trigger--disabled {
|
|
129
|
+
cursor: not-allowed !important;
|
|
130
|
+
}
|
|
131
|
+
.ck-select-popover {
|
|
132
|
+
animation: ck-popover-enter 140ms var(--ck-ease) both;
|
|
133
|
+
transform-origin: top center;
|
|
134
|
+
}
|
|
135
|
+
.ck-select-option--active {
|
|
136
|
+
/* Inline style already paints --ck-bg-muted; this class hook is
|
|
137
|
+
left for chrome overrides that want to draw a left-border or
|
|
138
|
+
other indicator on the active row. */
|
|
139
|
+
}
|
|
140
|
+
.ck-select-option--selected {
|
|
141
|
+
font-weight: 500;
|
|
142
|
+
}
|
|
143
|
+
|
|
119
144
|
/* ---------- ActionBar — button row inside <ActionBar> ---------- */
|
|
120
145
|
.ck-actionbar-btn {
|
|
121
146
|
display: inline-flex;
|