@cosxai/ui 0.15.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/actionbar/ActionBar.tsx +76 -1
- package/src/actionbar/ActionBarProvider.tsx +8 -2
- package/src/actionbar/actionbar-context.ts +9 -1
- package/src/actionbar/index.ts +3 -0
- package/src/actionbar/types.ts +19 -0
- package/src/actionbar/useActionBarPanel.ts +46 -0
- package/src/actionbar/useActionBarToast.ts +23 -0
- package/src/primitives/Combobox.tsx +430 -0
- package/src/primitives/index.ts +7 -0
- package/src/styles/base.css +27 -0
- package/src/styles/chrome-ambient.css +10 -0
- package/src/styles/chrome-bento.css +8 -0
- package/src/styles/chrome-editorial.css +8 -0
- package/src/styles/chrome-neobrutalism.css +8 -0
- package/src/styles/chrome-riso.css +8 -0
- package/src/styles/chrome-sketch.css +8 -0
- package/src/styles/chrome-swiss.css +8 -0
- package/src/styles/chrome-terminal.css +9 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosxai/ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "COSX design system
|
|
3
|
+
"version": "0.17.0",
|
|
4
|
+
"description": "COSX design system \u2014 React 19 component primitives shared across product-meta and other consumers",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.ts",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
useCallback,
|
|
3
3
|
useContext,
|
|
4
|
+
useLayoutEffect,
|
|
4
5
|
useEffect,
|
|
5
6
|
useMemo,
|
|
6
7
|
useRef,
|
|
@@ -154,7 +155,7 @@ export function ActionBar({
|
|
|
154
155
|
if (!ctx) {
|
|
155
156
|
throw new Error("<ActionBar> must be inside <ActionBarProvider>");
|
|
156
157
|
}
|
|
157
|
-
const { items, categories, expandedKey, setExpandedKey, statusDot } = ctx;
|
|
158
|
+
const { items, categories, expandedKey, setExpandedKey, statusDot, panel, toast } = ctx;
|
|
158
159
|
const vp = useViewport();
|
|
159
160
|
const isPhone = vp.isPhone;
|
|
160
161
|
|
|
@@ -382,6 +383,42 @@ export function ActionBar({
|
|
|
382
383
|
);
|
|
383
384
|
}
|
|
384
385
|
|
|
386
|
+
// ----- Panel slot (design#13): outside-click + Escape close. -----
|
|
387
|
+
const panelOpen = Boolean(panel?.open);
|
|
388
|
+
const panelClose = panel?.onOpenChange;
|
|
389
|
+
useEffect(() => {
|
|
390
|
+
if (!panelOpen || !panelClose) return;
|
|
391
|
+
const onDown = (e: MouseEvent) => {
|
|
392
|
+
const t = e.target as Node | null;
|
|
393
|
+
if (t && barRef.current?.contains(t)) return;
|
|
394
|
+
panelClose(false);
|
|
395
|
+
};
|
|
396
|
+
const onKey = (e: KeyboardEvent) => {
|
|
397
|
+
if (e.key === "Escape") panelClose(false);
|
|
398
|
+
};
|
|
399
|
+
window.addEventListener("mousedown", onDown);
|
|
400
|
+
window.addEventListener("keydown", onKey);
|
|
401
|
+
return () => {
|
|
402
|
+
window.removeEventListener("mousedown", onDown);
|
|
403
|
+
window.removeEventListener("keydown", onKey);
|
|
404
|
+
};
|
|
405
|
+
}, [panelOpen, panelClose]);
|
|
406
|
+
|
|
407
|
+
// Horizontal clamp: centred on the bar unless that would push the
|
|
408
|
+
// panel past a viewport edge — then shift just enough to fit.
|
|
409
|
+
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
410
|
+
useLayoutEffect(() => {
|
|
411
|
+
if (!panelOpen) return;
|
|
412
|
+
const el = panelRef.current;
|
|
413
|
+
if (!el) return;
|
|
414
|
+
el.style.marginLeft = "0px";
|
|
415
|
+
const rect = el.getBoundingClientRect();
|
|
416
|
+
let shift = 0;
|
|
417
|
+
if (rect.left < 8) shift = 8 - rect.left;
|
|
418
|
+
else if (rect.right > window.innerWidth - 8) shift = window.innerWidth - 8 - rect.right;
|
|
419
|
+
if (shift !== 0) el.style.marginLeft = `${Math.round(shift)}px`;
|
|
420
|
+
}, [panelOpen, pos]);
|
|
421
|
+
|
|
385
422
|
// ----- Full bar -----
|
|
386
423
|
const isDefault = pos.type === "default";
|
|
387
424
|
return (
|
|
@@ -437,6 +474,44 @@ export function ActionBar({
|
|
|
437
474
|
...style,
|
|
438
475
|
}}
|
|
439
476
|
>
|
|
477
|
+
{/* Panel slot (design#13) — the bar positions + the chrome
|
|
478
|
+
themes style it (.ck-actionbar-panel); consumers own only
|
|
479
|
+
the content. Rendered inside the bar root so it follows
|
|
480
|
+
drags natively. */}
|
|
481
|
+
{panel?.open && (
|
|
482
|
+
<div
|
|
483
|
+
ref={panelRef}
|
|
484
|
+
className="ck-actionbar-panel"
|
|
485
|
+
role="region"
|
|
486
|
+
aria-label={panel.ariaLabel ?? "Panel"}
|
|
487
|
+
style={{
|
|
488
|
+
position: "absolute",
|
|
489
|
+
bottom: "calc(100% + 8px)",
|
|
490
|
+
left: "50%",
|
|
491
|
+
transform: "translateX(-50%)",
|
|
492
|
+
width: panel.width ?? 320,
|
|
493
|
+
maxWidth: "calc(100vw - 16px)",
|
|
494
|
+
cursor: "default",
|
|
495
|
+
}}
|
|
496
|
+
>
|
|
497
|
+
{panel.content}
|
|
498
|
+
</div>
|
|
499
|
+
)}
|
|
500
|
+
{toast != null && !panel?.open && (
|
|
501
|
+
<div
|
|
502
|
+
className="ck-actionbar-toast"
|
|
503
|
+
role="status"
|
|
504
|
+
style={{
|
|
505
|
+
position: "absolute",
|
|
506
|
+
bottom: "calc(100% + 8px)",
|
|
507
|
+
left: "50%",
|
|
508
|
+
transform: "translateX(-50%)",
|
|
509
|
+
whiteSpace: "nowrap",
|
|
510
|
+
}}
|
|
511
|
+
>
|
|
512
|
+
{toast}
|
|
513
|
+
</div>
|
|
514
|
+
)}
|
|
440
515
|
{/* Left-edge button. Phone = collapse; desktop = drag grip. */}
|
|
441
516
|
{isPhone && collapsibleOnPhone ? (
|
|
442
517
|
<button
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
|
2
2
|
import { ActionBarContext } from "./actionbar-context";
|
|
3
|
-
import type { ActionBarItem, ActionBarCategories, ActionBarStatusDot } from "./types";
|
|
3
|
+
import type { ActionBarItem, ActionBarCategories, ActionBarStatusDot, ActionBarPanel } from "./types";
|
|
4
4
|
|
|
5
5
|
// Provider for the action-bar registry + expansion state +
|
|
6
6
|
// category catalog. Mount once near the app root, inside the
|
|
@@ -24,6 +24,8 @@ export function ActionBarProvider({
|
|
|
24
24
|
const [sources, setSources] = useState<Record<string, ActionBarItem[]>>({});
|
|
25
25
|
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
|
26
26
|
const [statusDot, setStatusDot] = useState<ActionBarStatusDot | null>(null);
|
|
27
|
+
const [panel, setPanel] = useState<ActionBarPanel | null>(null);
|
|
28
|
+
const [toast, setToast] = useState<ReactNode | null>(null);
|
|
27
29
|
|
|
28
30
|
const register = useCallback((sourceKey: string, items: ActionBarItem[]) => {
|
|
29
31
|
setSources((prev) => {
|
|
@@ -67,8 +69,12 @@ export function ActionBarProvider({
|
|
|
67
69
|
setExpandedKey,
|
|
68
70
|
statusDot,
|
|
69
71
|
setStatusDot,
|
|
72
|
+
panel,
|
|
73
|
+
setPanel,
|
|
74
|
+
toast,
|
|
75
|
+
setToast,
|
|
70
76
|
}),
|
|
71
|
-
[register, unregister, items, categories, expandedKey, statusDot],
|
|
77
|
+
[register, unregister, items, categories, expandedKey, statusDot, panel, toast],
|
|
72
78
|
);
|
|
73
79
|
|
|
74
80
|
return (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createContext } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { ActionBarItem, ActionBarCategories, ActionBarStatusDot, ActionBarPanel } from "./types";
|
|
3
4
|
|
|
4
5
|
// Registry + expansion + category catalog + status-dot slot. Items are
|
|
5
6
|
// pushed by pages through useActionBarItems(); the status dot is
|
|
@@ -23,6 +24,13 @@ export interface ActionBarContextValue {
|
|
|
23
24
|
// has registered one. Last call to setStatusDot wins.
|
|
24
25
|
statusDot: ActionBarStatusDot | null;
|
|
25
26
|
setStatusDot: (dot: ActionBarStatusDot | null) => void;
|
|
27
|
+
// Bar-intrinsic popover slot (design#13). Registered by
|
|
28
|
+
// useActionBarPanel; the bar positions + themes it.
|
|
29
|
+
panel: ActionBarPanel | null;
|
|
30
|
+
setPanel: (panel: ActionBarPanel | null) => void;
|
|
31
|
+
// Transient bubble above the bar (completion toasts etc.).
|
|
32
|
+
toast: ReactNode | null;
|
|
33
|
+
setToast: (toast: ReactNode | null) => void;
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
export const ActionBarContext = createContext<ActionBarContextValue | null>(null);
|
package/src/actionbar/index.ts
CHANGED
|
@@ -6,6 +6,8 @@ export { ActionBarButton } from "./ActionBarButton";
|
|
|
6
6
|
export type { ActionBarButtonProps } from "./ActionBarButton";
|
|
7
7
|
export { useActionBarItems, useActionBarItemsContext } from "./useActionBarItems";
|
|
8
8
|
export { useActionBarStatusDot } from "./useActionBarStatusDot";
|
|
9
|
+
export { useActionBarPanel } from "./useActionBarPanel";
|
|
10
|
+
export { useActionBarToast } from "./useActionBarToast";
|
|
9
11
|
export { ActionBarModeHandle } from "./ActionBarModeHandle";
|
|
10
12
|
export type { ActionBarModeHandleProps } from "./ActionBarModeHandle";
|
|
11
13
|
export { useActionBarMode } from "./useActionBarMode";
|
|
@@ -19,6 +21,7 @@ export type {
|
|
|
19
21
|
ActionBarItemVariant,
|
|
20
22
|
ActionBarItemSlot,
|
|
21
23
|
ActionBarStatusDot,
|
|
24
|
+
ActionBarPanel,
|
|
22
25
|
ActionBarCategory,
|
|
23
26
|
ActionBarCategories,
|
|
24
27
|
} from "./types";
|
package/src/actionbar/types.ts
CHANGED
|
@@ -117,3 +117,22 @@ export interface ActionBarStatusDot {
|
|
|
117
117
|
// pulsing reads as attention-grabbing.
|
|
118
118
|
pulse?: boolean;
|
|
119
119
|
}
|
|
120
|
+
|
|
121
|
+
// Registered via `useActionBarPanel()` — the bar-intrinsic popover
|
|
122
|
+
// slot (design#13). The BAR owns positioning (docked above itself,
|
|
123
|
+
// following drag natively) and the chrome themes own the panel's
|
|
124
|
+
// visual identity via `.ck-actionbar-panel`; the consumer owns only
|
|
125
|
+
// the content subtree. One panel at a time (last call wins).
|
|
126
|
+
export interface ActionBarPanel {
|
|
127
|
+
// Controlled visibility. The bar calls onOpenChange(false) on
|
|
128
|
+
// outside-click and Escape; the consumer toggles open from its
|
|
129
|
+
// own affordance (typically the status dot's onClick).
|
|
130
|
+
open: boolean;
|
|
131
|
+
onOpenChange: (open: boolean) => void;
|
|
132
|
+
// Content subtree. Rendered inside the themed panel container.
|
|
133
|
+
content: ReactNode;
|
|
134
|
+
// Panel width in px. Default 320; always viewport-clamped.
|
|
135
|
+
width?: number;
|
|
136
|
+
// aria-label for the panel region. Default "Panel".
|
|
137
|
+
ariaLabel?: string;
|
|
138
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useContext, useEffect } from "react";
|
|
2
|
+
import { ActionBarContext } from "./actionbar-context";
|
|
3
|
+
import type { ActionBarPanel } from "./types";
|
|
4
|
+
|
|
5
|
+
// Push a panel config into the action bar's popover slot (design#13).
|
|
6
|
+
//
|
|
7
|
+
// The BAR owns positioning: docked above itself, centered, following
|
|
8
|
+
// drag natively (it renders the panel inside its own fixed-position
|
|
9
|
+
// root — no polling, no rect maths in the consumer) and viewport-
|
|
10
|
+
// clamped at the edges. The chrome themes own the panel's visual
|
|
11
|
+
// identity via `.ck-actionbar-panel`; consumers must NOT paint their
|
|
12
|
+
// own background/border/shadow. The consumer owns only the content
|
|
13
|
+
// subtree and the `open` state.
|
|
14
|
+
//
|
|
15
|
+
// The bar calls `onOpenChange(false)` on outside-click and Escape.
|
|
16
|
+
// Pass `null` to clear (also cleared automatically on unmount).
|
|
17
|
+
// Last call wins — one panel at a time, same contract as the status
|
|
18
|
+
// dot.
|
|
19
|
+
export function useActionBarPanel(config: ActionBarPanel | null) {
|
|
20
|
+
const ctx = useContext(ActionBarContext);
|
|
21
|
+
if (!ctx) {
|
|
22
|
+
throw new Error("useActionBarPanel must be used within <ActionBarProvider>");
|
|
23
|
+
}
|
|
24
|
+
const { setPanel } = ctx;
|
|
25
|
+
const open = config?.open;
|
|
26
|
+
const onOpenChange = config?.onOpenChange;
|
|
27
|
+
const content = config?.content;
|
|
28
|
+
const width = config?.width;
|
|
29
|
+
const ariaLabel = config?.ariaLabel;
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (open === undefined || onOpenChange === undefined) {
|
|
32
|
+
setPanel(null);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
setPanel({
|
|
36
|
+
open,
|
|
37
|
+
onOpenChange,
|
|
38
|
+
content,
|
|
39
|
+
...(width !== undefined ? { width } : {}),
|
|
40
|
+
...(ariaLabel !== undefined ? { ariaLabel } : {}),
|
|
41
|
+
});
|
|
42
|
+
return () => {
|
|
43
|
+
setPanel(null);
|
|
44
|
+
};
|
|
45
|
+
}, [setPanel, open, onOpenChange, content, width, ariaLabel]);
|
|
46
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useContext, useEffect } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { ActionBarContext } from "./actionbar-context";
|
|
4
|
+
|
|
5
|
+
// Push a transient bubble into the bar's toast slot — rendered above
|
|
6
|
+
// the bar (following it when dragged), themed via
|
|
7
|
+
// `.ck-actionbar-toast`. The CONSUMER owns the lifetime: pass the
|
|
8
|
+
// node to show, `null` to hide (typically driven by a setTimeout in
|
|
9
|
+
// the consumer). Pairs with useActionBarPanel for the notification-
|
|
10
|
+
// center pattern (design#13).
|
|
11
|
+
export function useActionBarToast(content: ReactNode | null) {
|
|
12
|
+
const ctx = useContext(ActionBarContext);
|
|
13
|
+
if (!ctx) {
|
|
14
|
+
throw new Error("useActionBarToast must be used within <ActionBarProvider>");
|
|
15
|
+
}
|
|
16
|
+
const { setToast } = ctx;
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
setToast(content ?? null);
|
|
19
|
+
return () => {
|
|
20
|
+
setToast(null);
|
|
21
|
+
};
|
|
22
|
+
}, [setToast, content]);
|
|
23
|
+
}
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type KeyboardEvent,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
|
|
10
|
+
import { Input } from "./Input";
|
|
11
|
+
|
|
12
|
+
// Combobox — async-search commit picker (recipient fields, entity
|
|
13
|
+
// lookups). Extracted from product-meta's CustomerPicker so every
|
|
14
|
+
// product surface shares one commit model (QA 2026-07-21: typed-but-
|
|
15
|
+
// uncommitted text was visually identical to a committed pick, and on
|
|
16
|
+
// slow networks the dropdown never even appeared before the user
|
|
17
|
+
// moved on).
|
|
18
|
+
//
|
|
19
|
+
// The model has exactly two visible states, both always unambiguous:
|
|
20
|
+
//
|
|
21
|
+
// · The INPUT is always rendered and always editable — it carries
|
|
22
|
+
// the "in progress" state (typing / searching). Editing it after
|
|
23
|
+
// a commit clears the commit (via onUncommit) and resumes search.
|
|
24
|
+
// · The COMMITTED CARD below the input carries the "confirmed"
|
|
25
|
+
// state — title + subtitle + × to clear + an optional extra slot
|
|
26
|
+
// (e.g. a display-name input for brand-new entities).
|
|
27
|
+
//
|
|
28
|
+
// Commit paths:
|
|
29
|
+
// · click / Enter on a dropdown row → onCommit(option)
|
|
30
|
+
// · click / Enter on the free-entry row → onCommit(free)
|
|
31
|
+
// · BLUR with committable text (auto-commit) → exact option match
|
|
32
|
+
// when the search already returned one, else free entry when
|
|
33
|
+
// `allowFreeEntry(raw)` passes. Deliberately independent of the
|
|
34
|
+
// dropdown having rendered — on a slow network the free-entry
|
|
35
|
+
// path still commits from the local validity check alone.
|
|
36
|
+
// · blur with non-committable text → invalid state
|
|
37
|
+
// (error border + `invalidHint`), never a silent look-alike.
|
|
38
|
+
//
|
|
39
|
+
// The committed value is PARENT-OWNED (controlled): the parent maps
|
|
40
|
+
// options / free entries onto its own domain state and passes back
|
|
41
|
+
// `committed` for display. This keeps the primitive product-agnostic.
|
|
42
|
+
|
|
43
|
+
export interface ComboboxOption {
|
|
44
|
+
// Stable identity for list keys + commit payloads.
|
|
45
|
+
key: string;
|
|
46
|
+
// Primary row line (e.g. a person's name).
|
|
47
|
+
title: string;
|
|
48
|
+
// Secondary row line (e.g. their email). Also used by the default
|
|
49
|
+
// auto-commit matcher, so put the canonical typed value here.
|
|
50
|
+
subtitle?: string | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ComboboxCommit =
|
|
54
|
+
| { kind: "option"; option: ComboboxOption }
|
|
55
|
+
| { kind: "free"; raw: string };
|
|
56
|
+
|
|
57
|
+
export interface ComboboxCommitted {
|
|
58
|
+
title: ReactNode;
|
|
59
|
+
subtitle?: ReactNode | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ComboboxProps {
|
|
63
|
+
label?: ReactNode | undefined;
|
|
64
|
+
placeholder?: string | undefined;
|
|
65
|
+
autoFocus?: boolean | undefined;
|
|
66
|
+
disabled?: boolean | undefined;
|
|
67
|
+
// Async search. Called after `debounceMs` of quiet with a non-empty
|
|
68
|
+
// trimmed query; the AbortSignal cancels superseded requests.
|
|
69
|
+
search: (query: string, signal: AbortSignal) => Promise<ComboboxOption[]>;
|
|
70
|
+
debounceMs?: number | undefined;
|
|
71
|
+
// Free-entry gate: return true when the raw text is committable on
|
|
72
|
+
// its own (e.g. a syntactically valid email). Omit to disable free
|
|
73
|
+
// entry entirely (only listed options commit).
|
|
74
|
+
allowFreeEntry?: ((raw: string) => boolean) | undefined;
|
|
75
|
+
// Dropdown row copy for the free-entry affordance.
|
|
76
|
+
freeEntryLabel?: ((raw: string) => ReactNode) | undefined;
|
|
77
|
+
// Rendered in the dropdown when a search returned nothing and free
|
|
78
|
+
// entry doesn't apply.
|
|
79
|
+
emptyHint?: ReactNode | undefined;
|
|
80
|
+
// Error copy for "blurred with text that can't commit".
|
|
81
|
+
invalidHint?: string | undefined;
|
|
82
|
+
// Matcher backing blur auto-commit against loaded options. Default:
|
|
83
|
+
// case-insensitive equality on subtitle, then title.
|
|
84
|
+
matchOption?: ((raw: string, option: ComboboxOption) => boolean) | undefined;
|
|
85
|
+
// Parent-owned committed state; null/undefined = nothing committed.
|
|
86
|
+
committed?: ComboboxCommitted | null | undefined;
|
|
87
|
+
// Extra content inside the committed card (below subtitle).
|
|
88
|
+
committedExtra?: ReactNode | undefined;
|
|
89
|
+
onCommit: (commit: ComboboxCommit) => void;
|
|
90
|
+
// Fired when the user edits the input after a commit, or clicks the
|
|
91
|
+
// card's ×. Parent clears its committed state.
|
|
92
|
+
onUncommit?: (() => void) | undefined;
|
|
93
|
+
testid?: string | undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const DEFAULT_DEBOUNCE_MS = 250;
|
|
97
|
+
|
|
98
|
+
function defaultMatch(raw: string, option: ComboboxOption): boolean {
|
|
99
|
+
const norm = raw.trim().toLowerCase();
|
|
100
|
+
return (
|
|
101
|
+
(option.subtitle ?? "").trim().toLowerCase() === norm ||
|
|
102
|
+
option.title.trim().toLowerCase() === norm
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function Combobox({
|
|
107
|
+
label,
|
|
108
|
+
placeholder,
|
|
109
|
+
autoFocus,
|
|
110
|
+
disabled,
|
|
111
|
+
search,
|
|
112
|
+
debounceMs = DEFAULT_DEBOUNCE_MS,
|
|
113
|
+
allowFreeEntry,
|
|
114
|
+
freeEntryLabel,
|
|
115
|
+
emptyHint,
|
|
116
|
+
invalidHint,
|
|
117
|
+
matchOption = defaultMatch,
|
|
118
|
+
committed,
|
|
119
|
+
committedExtra,
|
|
120
|
+
onCommit,
|
|
121
|
+
onUncommit,
|
|
122
|
+
testid = "combobox",
|
|
123
|
+
}: ComboboxProps): ReactNode {
|
|
124
|
+
const [query, setQuery] = useState("");
|
|
125
|
+
const [results, setResults] = useState<ComboboxOption[]>([]);
|
|
126
|
+
const [loading, setLoading] = useState(false);
|
|
127
|
+
const [focused, setFocused] = useState(false);
|
|
128
|
+
const [highlight, setHighlight] = useState(0);
|
|
129
|
+
const [invalid, setInvalid] = useState(false);
|
|
130
|
+
|
|
131
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
132
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
133
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
134
|
+
|
|
135
|
+
const isCommitted = !!committed;
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
139
|
+
// Search is suspended while a commit is displayed — the input
|
|
140
|
+
// still holds the committed text but shouldn't re-open the list.
|
|
141
|
+
if (!query.trim() || isCommitted) {
|
|
142
|
+
setResults([]);
|
|
143
|
+
setLoading(false);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
debounceRef.current = setTimeout(() => {
|
|
147
|
+
abortRef.current?.abort();
|
|
148
|
+
const ac = new AbortController();
|
|
149
|
+
abortRef.current = ac;
|
|
150
|
+
setLoading(true);
|
|
151
|
+
search(query.trim(), ac.signal)
|
|
152
|
+
.then((rows) => {
|
|
153
|
+
if (ac.signal.aborted) return;
|
|
154
|
+
setResults(rows);
|
|
155
|
+
setHighlight(0);
|
|
156
|
+
setLoading(false);
|
|
157
|
+
})
|
|
158
|
+
.catch(() => {
|
|
159
|
+
if (ac.signal.aborted) return;
|
|
160
|
+
// Search failure degrades to "no suggestions" — free-entry
|
|
161
|
+
// auto-commit still works, which is the whole point on a
|
|
162
|
+
// flaky network.
|
|
163
|
+
setResults([]);
|
|
164
|
+
setLoading(false);
|
|
165
|
+
});
|
|
166
|
+
}, debounceMs);
|
|
167
|
+
return () => {
|
|
168
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
169
|
+
abortRef.current?.abort();
|
|
170
|
+
};
|
|
171
|
+
}, [query, isCommitted, search, debounceMs]);
|
|
172
|
+
|
|
173
|
+
const trimmed = query.trim();
|
|
174
|
+
const freeEntryOk = !!allowFreeEntry && trimmed.length > 0 && allowFreeEntry(trimmed);
|
|
175
|
+
const hasNoResults = !loading && trimmed.length > 0 && results.length === 0;
|
|
176
|
+
const offerFree = !isCommitted && freeEntryOk && !results.some((r) => matchOption(trimmed, r));
|
|
177
|
+
const totalEntries = results.length + (offerFree ? 1 : 0);
|
|
178
|
+
|
|
179
|
+
const commitOption = useCallback(
|
|
180
|
+
(option: ComboboxOption) => {
|
|
181
|
+
setInvalid(false);
|
|
182
|
+
setResults([]);
|
|
183
|
+
setHighlight(0);
|
|
184
|
+
// Reflect the commit in the input so a half-typed query never
|
|
185
|
+
// sits next to a card naming someone else.
|
|
186
|
+
setQuery(option.subtitle ?? option.title);
|
|
187
|
+
onCommit({ kind: "option", option });
|
|
188
|
+
},
|
|
189
|
+
[onCommit],
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const commitFree = useCallback(
|
|
193
|
+
(raw: string) => {
|
|
194
|
+
setInvalid(false);
|
|
195
|
+
setResults([]);
|
|
196
|
+
setHighlight(0);
|
|
197
|
+
setQuery(raw);
|
|
198
|
+
onCommit({ kind: "free", raw });
|
|
199
|
+
},
|
|
200
|
+
[onCommit],
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Blur auto-commit — the fix for "typed it, tabbed on, looked
|
|
204
|
+
// committed but wasn't". Exact option match wins (brings the
|
|
205
|
+
// entity's identity along); otherwise free entry; otherwise flag
|
|
206
|
+
// invalid. Runs on a short delay so a dropdown row click (which
|
|
207
|
+
// blurs first) still lands on the row handler.
|
|
208
|
+
const handleBlur = useCallback(() => {
|
|
209
|
+
setTimeout(() => {
|
|
210
|
+
setFocused(false);
|
|
211
|
+
if (isCommitted) return;
|
|
212
|
+
const raw = trimmed;
|
|
213
|
+
if (!raw) {
|
|
214
|
+
setInvalid(false);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const exact = results.find((r) => matchOption(raw, r));
|
|
218
|
+
if (exact) {
|
|
219
|
+
commitOption(exact);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (allowFreeEntry?.(raw)) {
|
|
223
|
+
commitFree(raw);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
setInvalid(true);
|
|
227
|
+
}, 150);
|
|
228
|
+
}, [isCommitted, trimmed, results, matchOption, allowFreeEntry, commitOption, commitFree]);
|
|
229
|
+
|
|
230
|
+
const handleQueryChange = useCallback(
|
|
231
|
+
(next: string) => {
|
|
232
|
+
setQuery(next);
|
|
233
|
+
setInvalid(false);
|
|
234
|
+
if (isCommitted) onUncommit?.();
|
|
235
|
+
},
|
|
236
|
+
[isCommitted, onUncommit],
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const clearCommitted = useCallback(() => {
|
|
240
|
+
onUncommit?.();
|
|
241
|
+
// Keep the committed text in the input for editing; focus so the
|
|
242
|
+
// user can immediately type.
|
|
243
|
+
inputRef.current?.focus();
|
|
244
|
+
}, [onUncommit]);
|
|
245
|
+
|
|
246
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
|
247
|
+
if (isCommitted) return;
|
|
248
|
+
if (e.key === "ArrowDown") {
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
setHighlight((h) => Math.min(h + 1, Math.max(totalEntries - 1, 0)));
|
|
251
|
+
} else if (e.key === "ArrowUp") {
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
setHighlight((h) => Math.max(h - 1, 0));
|
|
254
|
+
} else if (e.key === "Enter") {
|
|
255
|
+
if (totalEntries > 0) {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
if (highlight < results.length) {
|
|
258
|
+
const chosen = results[highlight];
|
|
259
|
+
if (chosen) commitOption(chosen);
|
|
260
|
+
} else if (offerFree) {
|
|
261
|
+
commitFree(trimmed);
|
|
262
|
+
}
|
|
263
|
+
} else if (freeEntryOk) {
|
|
264
|
+
// No dropdown (e.g. search still in flight on a slow
|
|
265
|
+
// network) — Enter on a valid free entry commits directly.
|
|
266
|
+
e.preventDefault();
|
|
267
|
+
commitFree(trimmed);
|
|
268
|
+
}
|
|
269
|
+
} else if (e.key === "Escape") {
|
|
270
|
+
setQuery("");
|
|
271
|
+
setResults([]);
|
|
272
|
+
setInvalid(false);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const showDropdown =
|
|
277
|
+
focused && !isCommitted && (results.length > 0 || offerFree || (hasNoResults && !!emptyHint));
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<div style={{ position: "relative" }}>
|
|
281
|
+
<Input
|
|
282
|
+
ref={inputRef}
|
|
283
|
+
label={label}
|
|
284
|
+
data-testid={`${testid}-input`}
|
|
285
|
+
value={query}
|
|
286
|
+
disabled={disabled}
|
|
287
|
+
onChange={(e) => handleQueryChange(e.target.value)}
|
|
288
|
+
onFocus={() => {
|
|
289
|
+
setFocused(true);
|
|
290
|
+
setInvalid(false);
|
|
291
|
+
}}
|
|
292
|
+
onBlur={handleBlur}
|
|
293
|
+
onKeyDown={handleKeyDown}
|
|
294
|
+
placeholder={placeholder}
|
|
295
|
+
autoFocus={autoFocus}
|
|
296
|
+
autoComplete="off"
|
|
297
|
+
error={invalid ? (invalidHint ?? "Pick from the list to continue.") : null}
|
|
298
|
+
/>
|
|
299
|
+
|
|
300
|
+
{showDropdown ? (
|
|
301
|
+
<div
|
|
302
|
+
data-testid={`${testid}-dropdown`}
|
|
303
|
+
style={{
|
|
304
|
+
position: "absolute",
|
|
305
|
+
top: "100%",
|
|
306
|
+
marginTop: 4,
|
|
307
|
+
left: 0,
|
|
308
|
+
right: 0,
|
|
309
|
+
background: "var(--ck-bg-surface)",
|
|
310
|
+
border: "1px solid var(--ck-border-strong)",
|
|
311
|
+
borderRadius: "var(--ck-radius-sm)",
|
|
312
|
+
boxShadow: "var(--ck-shadow-menu, 0 4px 12px rgba(0,0,0,0.08))",
|
|
313
|
+
zIndex: 10,
|
|
314
|
+
overflow: "hidden",
|
|
315
|
+
}}
|
|
316
|
+
>
|
|
317
|
+
{results.map((row, i) => (
|
|
318
|
+
<button
|
|
319
|
+
key={row.key}
|
|
320
|
+
type="button"
|
|
321
|
+
onMouseDown={(e) => {
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
commitOption(row);
|
|
324
|
+
}}
|
|
325
|
+
onMouseEnter={() => setHighlight(i)}
|
|
326
|
+
style={{
|
|
327
|
+
display: "block",
|
|
328
|
+
width: "100%",
|
|
329
|
+
textAlign: "left",
|
|
330
|
+
padding: "8px 12px",
|
|
331
|
+
border: "none",
|
|
332
|
+
background: i === highlight ? "var(--ck-bg-muted)" : "transparent",
|
|
333
|
+
cursor: "pointer",
|
|
334
|
+
font: "inherit",
|
|
335
|
+
fontSize: 13,
|
|
336
|
+
color: "var(--ck-text-primary)",
|
|
337
|
+
}}
|
|
338
|
+
>
|
|
339
|
+
<div style={{ fontWeight: 500 }}>{row.title}</div>
|
|
340
|
+
{row.subtitle ? (
|
|
341
|
+
<div style={{ fontSize: 12, color: "var(--ck-text-secondary)" }}>
|
|
342
|
+
{row.subtitle}
|
|
343
|
+
</div>
|
|
344
|
+
) : null}
|
|
345
|
+
</button>
|
|
346
|
+
))}
|
|
347
|
+
{offerFree ? (
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
350
|
+
data-testid={`${testid}-free-entry`}
|
|
351
|
+
onMouseDown={(e) => {
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
commitFree(trimmed);
|
|
354
|
+
}}
|
|
355
|
+
onMouseEnter={() => setHighlight(results.length)}
|
|
356
|
+
style={{
|
|
357
|
+
display: "block",
|
|
358
|
+
width: "100%",
|
|
359
|
+
textAlign: "left",
|
|
360
|
+
padding: "8px 12px",
|
|
361
|
+
border: "none",
|
|
362
|
+
background:
|
|
363
|
+
highlight === results.length ? "var(--ck-bg-muted)" : "transparent",
|
|
364
|
+
borderTop: results.length > 0 ? "1px solid var(--ck-border-subtle)" : "none",
|
|
365
|
+
cursor: "pointer",
|
|
366
|
+
font: "inherit",
|
|
367
|
+
fontSize: 13,
|
|
368
|
+
color: "var(--ck-accent)",
|
|
369
|
+
}}
|
|
370
|
+
>
|
|
371
|
+
{freeEntryLabel ? freeEntryLabel(trimmed) : <>Use “{trimmed}”</>}
|
|
372
|
+
</button>
|
|
373
|
+
) : hasNoResults && emptyHint ? (
|
|
374
|
+
<div style={{ padding: "8px 12px", fontSize: 12, color: "var(--ck-text-secondary)" }}>
|
|
375
|
+
{emptyHint}
|
|
376
|
+
</div>
|
|
377
|
+
) : null}
|
|
378
|
+
</div>
|
|
379
|
+
) : null}
|
|
380
|
+
|
|
381
|
+
{committed ? (
|
|
382
|
+
<div
|
|
383
|
+
data-testid={`${testid}-committed`}
|
|
384
|
+
style={{
|
|
385
|
+
marginTop: 8,
|
|
386
|
+
padding: "10px 12px",
|
|
387
|
+
background: "var(--ck-bg-muted)",
|
|
388
|
+
border: "1px solid var(--ck-border-subtle)",
|
|
389
|
+
borderRadius: "var(--ck-radius-sm)",
|
|
390
|
+
display: "flex",
|
|
391
|
+
alignItems: "flex-start",
|
|
392
|
+
gap: 8,
|
|
393
|
+
}}
|
|
394
|
+
>
|
|
395
|
+
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 2 }}>
|
|
396
|
+
<div style={{ fontSize: 14, fontWeight: 500, color: "var(--ck-text-primary)" }}>
|
|
397
|
+
{committed.title}
|
|
398
|
+
</div>
|
|
399
|
+
{committed.subtitle ? (
|
|
400
|
+
<div style={{ fontSize: 12, color: "var(--ck-text-secondary)" }}>
|
|
401
|
+
{committed.subtitle}
|
|
402
|
+
</div>
|
|
403
|
+
) : null}
|
|
404
|
+
{committedExtra ? <div style={{ marginTop: 6 }}>{committedExtra}</div> : null}
|
|
405
|
+
</div>
|
|
406
|
+
{onUncommit ? (
|
|
407
|
+
<button
|
|
408
|
+
type="button"
|
|
409
|
+
aria-label="Clear selection"
|
|
410
|
+
data-testid={`${testid}-clear`}
|
|
411
|
+
onClick={clearCommitted}
|
|
412
|
+
style={{
|
|
413
|
+
border: "none",
|
|
414
|
+
background: "none",
|
|
415
|
+
cursor: "pointer",
|
|
416
|
+
color: "var(--ck-text-secondary)",
|
|
417
|
+
fontSize: 14,
|
|
418
|
+
lineHeight: 1,
|
|
419
|
+
padding: 2,
|
|
420
|
+
flexShrink: 0,
|
|
421
|
+
}}
|
|
422
|
+
>
|
|
423
|
+
×
|
|
424
|
+
</button>
|
|
425
|
+
) : null}
|
|
426
|
+
</div>
|
|
427
|
+
) : null}
|
|
428
|
+
</div>
|
|
429
|
+
);
|
|
430
|
+
}
|
package/src/primitives/index.ts
CHANGED
|
@@ -14,6 +14,13 @@ export { Input } from "./Input";
|
|
|
14
14
|
export type { InputProps } from "./Input";
|
|
15
15
|
export { Select } from "./Select";
|
|
16
16
|
export type { SelectProps, SelectOption } from "./Select";
|
|
17
|
+
export { Combobox } from "./Combobox";
|
|
18
|
+
export type {
|
|
19
|
+
ComboboxProps,
|
|
20
|
+
ComboboxOption,
|
|
21
|
+
ComboboxCommit,
|
|
22
|
+
ComboboxCommitted,
|
|
23
|
+
} from "./Combobox";
|
|
17
24
|
export { Textarea } from "./Textarea";
|
|
18
25
|
export type { TextareaProps } from "./Textarea";
|
|
19
26
|
export { Checkbox } from "./Checkbox";
|
package/src/styles/base.css
CHANGED
|
@@ -122,3 +122,30 @@ a:visited { color: var(--ck-accent); }
|
|
|
122
122
|
:root {
|
|
123
123
|
scrollbar-color: var(--ck-border-strong, rgba(0, 0, 0, 0.25)) transparent;
|
|
124
124
|
}
|
|
125
|
+
|
|
126
|
+
/* ── ActionBar panel + toast slots (design#13) ─────────────────────
|
|
127
|
+
Base chrome for the bar-intrinsic popover (useActionBarPanel) and
|
|
128
|
+
transient bubble (useActionBarToast). Token-driven so every theme
|
|
129
|
+
reads correctly by default; chrome-*.css sharpens identity themes
|
|
130
|
+
(neobrutalism ink shadows, sketch wobble, terminal flatness). */
|
|
131
|
+
.ck-actionbar-panel {
|
|
132
|
+
background: var(--ck-bg-surface, #fff);
|
|
133
|
+
border: 1px solid var(--ck-border-subtle, #e5e7eb);
|
|
134
|
+
border-radius: 12px;
|
|
135
|
+
box-shadow: var(--ck-shadow-3, 0 16px 48px rgba(0, 0, 0, 0.12));
|
|
136
|
+
padding: 16px;
|
|
137
|
+
font-family: var(--ck-font-sans, system-ui, sans-serif);
|
|
138
|
+
color: var(--ck-text-primary);
|
|
139
|
+
user-select: none;
|
|
140
|
+
}
|
|
141
|
+
.ck-actionbar-toast {
|
|
142
|
+
background: var(--ck-bg-surface, #fff);
|
|
143
|
+
border: 1px solid var(--ck-border-subtle, #e5e7eb);
|
|
144
|
+
border-radius: 10px;
|
|
145
|
+
box-shadow: var(--ck-shadow-3, 0 16px 48px rgba(0, 0, 0, 0.12));
|
|
146
|
+
padding: 10px 14px;
|
|
147
|
+
font-size: 12px;
|
|
148
|
+
font-family: var(--ck-font-sans, system-ui, sans-serif);
|
|
149
|
+
color: var(--ck-text-primary);
|
|
150
|
+
user-select: none;
|
|
151
|
+
}
|
|
@@ -223,3 +223,13 @@ html[data-ck-chrome="ambient"] [role="dialog"] > div > div {
|
|
|
223
223
|
transition: none !important;
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
|
+
|
|
227
|
+
html[data-ck-chrome="ambient"] .ck-actionbar-panel,
|
|
228
|
+
html[data-ck-chrome="ambient"] .ck-actionbar-toast {
|
|
229
|
+
background: var(--ck-bg-sidebar);
|
|
230
|
+
backdrop-filter: var(--ck-blur-overlay);
|
|
231
|
+
-webkit-backdrop-filter: var(--ck-blur-overlay);
|
|
232
|
+
border: 1px solid var(--ck-border-subtle);
|
|
233
|
+
border-radius: 20px;
|
|
234
|
+
box-shadow: var(--ck-shadow-2);
|
|
235
|
+
}
|
|
@@ -182,3 +182,11 @@ html[data-ck-chrome="bento"] [data-ck-bento-cell][data-interactive="true"]:hover
|
|
|
182
182
|
transform: translateY(-3px);
|
|
183
183
|
box-shadow: var(--ck-shadow-3);
|
|
184
184
|
}
|
|
185
|
+
|
|
186
|
+
html[data-ck-chrome="bento"] .ck-actionbar-panel,
|
|
187
|
+
html[data-ck-chrome="bento"] .ck-actionbar-toast {
|
|
188
|
+
background: var(--ck-bg-surface);
|
|
189
|
+
border: 1px solid var(--ck-border-subtle);
|
|
190
|
+
border-radius: 16px;
|
|
191
|
+
box-shadow: var(--ck-shadow-2);
|
|
192
|
+
}
|
|
@@ -158,3 +158,11 @@ html[data-ck-chrome="editorial"] [role="dialog"] > div:first-child {
|
|
|
158
158
|
[data-ck-spotlight] .ck-card .ck-tag {
|
|
159
159
|
color: var(--ck-text-secondary);
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
/* ActionBar panel/toast — flat paper, hairline, no drop glow. */
|
|
163
|
+
html[data-ck-chrome="editorial"] .ck-actionbar-panel,
|
|
164
|
+
html[data-ck-chrome="editorial"] .ck-actionbar-toast {
|
|
165
|
+
background: var(--ck-bg-surface);
|
|
166
|
+
border-color: var(--ck-border-subtle);
|
|
167
|
+
box-shadow: var(--ck-shadow-1, 0 2px 8px rgba(0, 0, 0, 0.06));
|
|
168
|
+
}
|
|
@@ -316,3 +316,11 @@ html[data-ck-chrome="neobrutalism"] [role="switch"] {
|
|
|
316
316
|
box-shadow: 4px 4px 0 0 var(--neobrut-ink);
|
|
317
317
|
}
|
|
318
318
|
}
|
|
319
|
+
|
|
320
|
+
html[data-ck-chrome="neobrutalism"] .ck-actionbar-panel,
|
|
321
|
+
html[data-ck-chrome="neobrutalism"] .ck-actionbar-toast {
|
|
322
|
+
border: 2px solid var(--neobrut-ink);
|
|
323
|
+
border-radius: 5px;
|
|
324
|
+
box-shadow: 4px 4px 0 0 var(--neobrut-ink);
|
|
325
|
+
background: var(--ck-bg-surface);
|
|
326
|
+
}
|
|
@@ -329,3 +329,11 @@ html[data-ck-chrome="riso"] hr {
|
|
|
329
329
|
}
|
|
330
330
|
.ck-riso-marks::before { top: 4px; left: 8px; }
|
|
331
331
|
.ck-riso-marks::after { bottom: 4px; right: 8px; }
|
|
332
|
+
|
|
333
|
+
html[data-ck-chrome="riso"] .ck-actionbar-panel,
|
|
334
|
+
html[data-ck-chrome="riso"] .ck-actionbar-toast {
|
|
335
|
+
background: var(--ck-bg-surface);
|
|
336
|
+
border: 2px solid var(--ck-text-primary);
|
|
337
|
+
border-radius: 4px;
|
|
338
|
+
box-shadow: 5px 5px 0 var(--ck-riso-blue);
|
|
339
|
+
}
|
|
@@ -352,3 +352,11 @@ html[data-ck-chrome="sketch"] hr {
|
|
|
352
352
|
stroke-dasharray: none;
|
|
353
353
|
}
|
|
354
354
|
}
|
|
355
|
+
|
|
356
|
+
html[data-ck-chrome="sketch"] .ck-actionbar-panel,
|
|
357
|
+
html[data-ck-chrome="sketch"] .ck-actionbar-toast {
|
|
358
|
+
background: var(--ck-bg-surface);
|
|
359
|
+
border: 2px solid var(--ck-text-primary);
|
|
360
|
+
border-radius: 14px 18px 16px 20px / 18px 14px 20px 16px;
|
|
361
|
+
box-shadow: 4px 5px 0 var(--ck-text-primary);
|
|
362
|
+
}
|
|
@@ -255,3 +255,11 @@ html[data-ck-chrome="swiss"] a {
|
|
|
255
255
|
text-underline-offset: 3px;
|
|
256
256
|
text-decoration-thickness: 1px;
|
|
257
257
|
}
|
|
258
|
+
|
|
259
|
+
html[data-ck-chrome="swiss"] .ck-actionbar-panel,
|
|
260
|
+
html[data-ck-chrome="swiss"] .ck-actionbar-toast {
|
|
261
|
+
background: var(--ck-bg-canvas);
|
|
262
|
+
border: 1px solid var(--ck-border-subtle);
|
|
263
|
+
border-radius: 0;
|
|
264
|
+
box-shadow: var(--ck-shadow-1, 0 2px 8px rgba(0, 0, 0, 0.08));
|
|
265
|
+
}
|
|
@@ -261,3 +261,12 @@ html[data-ck-chrome="terminal"] [role="dialog"] > div > div {
|
|
|
261
261
|
box-shadow: none;
|
|
262
262
|
font-family: var(--ck-font-mono);
|
|
263
263
|
}
|
|
264
|
+
|
|
265
|
+
html[data-ck-chrome="terminal"] .ck-actionbar-panel,
|
|
266
|
+
html[data-ck-chrome="terminal"] .ck-actionbar-toast {
|
|
267
|
+
background: var(--ck-bg-surface);
|
|
268
|
+
border: 1px solid var(--ck-border-subtle);
|
|
269
|
+
border-radius: 2px;
|
|
270
|
+
box-shadow: none;
|
|
271
|
+
font-family: var(--ck-font-mono);
|
|
272
|
+
}
|