@cosxai/ui 0.23.2 → 0.24.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosxai/ui",
3
- "version": "0.23.2",
3
+ "version": "0.24.0",
4
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",
@@ -15,6 +15,13 @@ export interface LeftNavRailProps {
15
15
  brand?: ReactNode;
16
16
  children: ReactNode;
17
17
  footer?: ReactNode;
18
+ /**
19
+ * footerTop — a block pinned BETWEEN the scrolling nav area and the
20
+ * footer rule: always visible, above the divider, immune to a long
21
+ * menu (the nav list scrolls above it). The agent drawer's trigger
22
+ * is the motivating case.
23
+ */
24
+ footerTop?: ReactNode;
18
25
  collapsed?: boolean;
19
26
  // Expanded / collapsed widths in px. Defaults 256 / 56.
20
27
  widthExpanded?: number;
@@ -28,6 +35,7 @@ export function LeftNavRail({
28
35
  brand,
29
36
  children,
30
37
  footer,
38
+ footerTop,
31
39
  collapsed = false,
32
40
  widthExpanded = 256,
33
41
  widthCollapsed = 56,
@@ -111,6 +119,11 @@ export function LeftNavRail({
111
119
  >
112
120
  {children}
113
121
  </div>
122
+ {footerTop && (
123
+ <div style={{ padding: collapsed ? "4px 4px 8px" : "4px 8px 12px", flexShrink: 0 }}>
124
+ {footerTop}
125
+ </div>
126
+ )}
114
127
  {footer && (
115
128
  <div
116
129
  style={{
@@ -0,0 +1,177 @@
1
+ import { forwardRef, type CSSProperties, type HTMLAttributes, type ReactNode } from "react";
2
+
3
+ import { cn } from "../lib/cn";
4
+
5
+ /**
6
+ * Chip — a compact pill representing one selected value (a recipient,
7
+ * a filter, a tag the user picked). Exists so multi-value inputs
8
+ * (MultiCombobox) and token lists share one removable-item shape
9
+ * instead of each product hand-rolling a "name ×" span.
10
+ *
11
+ * Distinct from `Tag` (uppercase mono STATUS label — "PENDING",
12
+ * "LIVE"): a Chip is sentence-case body text (13px sans), a pill on a
13
+ * muted slab, and is interactive — optional `onClick` turns the label
14
+ * into a button (e.g. "edit this entry"), optional `onRemove` adds
15
+ * the × button.
16
+ *
17
+ * Tones: `neutral` (default), `accent`, `warning` (e.g. "needs a
18
+ * name"), `critical`. `selected` outlines the chip in the accent
19
+ * colour (e.g. the entry currently being edited). `disabled` dims the
20
+ * chip and disables both buttons.
21
+ *
22
+ * Compose with: MultiCombobox (entry row), filter bars, recipient
23
+ * lists on detail pages.
24
+ *
25
+ * Visual: see docs/components/multi-combobox.
26
+ *
27
+ * Forwards ref to the root <span>. Spreads `...rest` onto it so
28
+ * consumers can pass data-* / aria-* / title / style. Chrome presets
29
+ * can restyle via the `[data-ck-chip]` / `data-tone` / `data-selected`
30
+ * hooks — 0.24.0 ships no per-chrome CSS for it.
31
+ */
32
+
33
+ export type ChipTone = "neutral" | "accent" | "warning" | "critical";
34
+
35
+ export type ChipProps = Omit<HTMLAttributes<HTMLSpanElement>, "onClick"> & {
36
+ children: ReactNode;
37
+ tone?: ChipTone | undefined;
38
+ // Outline the chip in the accent colour (e.g. "being edited").
39
+ selected?: boolean | undefined;
40
+ // Dim + disable both the label button and the remove button.
41
+ disabled?: boolean | undefined;
42
+ // When set, the label renders as a <button> (e.g. click-to-edit).
43
+ onClick?: (() => void) | undefined;
44
+ // When set, a × button renders after the label.
45
+ onRemove?: (() => void) | undefined;
46
+ // aria-label for the × button. Default "Remove".
47
+ removeLabel?: string | undefined;
48
+ className?: string | undefined;
49
+ };
50
+
51
+ const TONE_FG: Record<ChipTone, string> = {
52
+ neutral: "var(--ck-text-primary, #111)",
53
+ accent: "var(--ck-accent, #4f46e5)",
54
+ warning: "var(--ck-warning, #D97706)",
55
+ critical: "var(--ck-critical, #DC2626)",
56
+ };
57
+ const TONE_BG: Record<ChipTone, string> = {
58
+ neutral: "var(--ck-bg-muted, #E5E8EE)",
59
+ accent: "var(--ck-accent-muted, rgba(79, 70, 229, 0.08))",
60
+ warning: "var(--ck-warning-muted, #FEF3E7)",
61
+ critical: "var(--ck-critical-muted, #FCEAEA)",
62
+ };
63
+ const TONE_BORDER: Record<ChipTone, string> = {
64
+ neutral: "var(--ck-border-subtle, #DDE0E6)",
65
+ accent: "var(--ck-accent-border, rgba(79, 70, 229, 0.35))",
66
+ warning: "color-mix(in oklab, var(--ck-warning, #D97706) 35%, transparent)",
67
+ critical: "color-mix(in oklab, var(--ck-critical, #DC2626) 35%, transparent)",
68
+ };
69
+
70
+ // Label text styles — shared by the <span> and <button> renderings so
71
+ // the chip looks identical whether or not it is clickable.
72
+ const LABEL_STYLE: CSSProperties = {
73
+ minWidth: 0,
74
+ overflow: "hidden",
75
+ textOverflow: "ellipsis",
76
+ whiteSpace: "nowrap",
77
+ font: "inherit",
78
+ color: "inherit",
79
+ background: "none",
80
+ border: "none",
81
+ padding: 0,
82
+ margin: 0,
83
+ textAlign: "left",
84
+ };
85
+
86
+ export const Chip = forwardRef<HTMLSpanElement, ChipProps>(function Chip(
87
+ {
88
+ children,
89
+ tone = "neutral",
90
+ selected = false,
91
+ disabled = false,
92
+ onClick,
93
+ onRemove,
94
+ removeLabel,
95
+ className,
96
+ style,
97
+ ...rest
98
+ },
99
+ ref,
100
+ ) {
101
+ const clickable = !!onClick && !disabled;
102
+ return (
103
+ <span
104
+ ref={ref}
105
+ className={cn("ck-chip", className)}
106
+ data-ck-chip
107
+ data-tone={tone}
108
+ data-selected={selected ? "true" : undefined}
109
+ data-disabled={disabled ? "true" : undefined}
110
+ style={{
111
+ display: "inline-flex",
112
+ alignItems: "center",
113
+ gap: 4,
114
+ maxWidth: "100%",
115
+ boxSizing: "border-box",
116
+ padding: onRemove ? "3px 6px 3px 10px" : "3px 10px",
117
+ borderRadius: 999,
118
+ background: TONE_BG[tone],
119
+ border: `1px solid ${selected ? "var(--ck-accent, #4f46e5)" : TONE_BORDER[tone]}`,
120
+ boxShadow: selected ? "0 0 0 2px var(--ck-accent-muted, rgba(79, 70, 229, 0.12))" : "none",
121
+ font: "500 13px/1.3 var(--ck-font-sans, system-ui, sans-serif)",
122
+ color: TONE_FG[tone],
123
+ opacity: disabled ? 0.5 : 1,
124
+ cursor: disabled ? "default" : undefined,
125
+ transition: "border-color var(--ck-dur-fast, 120ms) var(--ck-ease, ease)",
126
+ ...style,
127
+ }}
128
+ {...rest}
129
+ >
130
+ {onClick ? (
131
+ <button
132
+ type="button"
133
+ className="ck-chip-label"
134
+ onClick={onClick}
135
+ disabled={disabled}
136
+ style={{ ...LABEL_STYLE, cursor: clickable ? "pointer" : "default" }}
137
+ >
138
+ {children}
139
+ </button>
140
+ ) : (
141
+ <span className="ck-chip-label" style={LABEL_STYLE}>
142
+ {children}
143
+ </span>
144
+ )}
145
+ {onRemove ? (
146
+ <button
147
+ type="button"
148
+ className="ck-chip-remove"
149
+ aria-label={removeLabel ?? "Remove"}
150
+ onClick={onRemove}
151
+ disabled={disabled}
152
+ style={{
153
+ display: "inline-flex",
154
+ alignItems: "center",
155
+ justifyContent: "center",
156
+ width: 16,
157
+ height: 16,
158
+ flexShrink: 0,
159
+ padding: 0,
160
+ margin: 0,
161
+ border: "none",
162
+ borderRadius: 999,
163
+ background: "none",
164
+ color: "inherit",
165
+ opacity: 0.7,
166
+ font: "inherit",
167
+ fontSize: 14,
168
+ lineHeight: 1,
169
+ cursor: disabled ? "default" : "pointer",
170
+ }}
171
+ >
172
+ ×
173
+ </button>
174
+ ) : null}
175
+ </span>
176
+ );
177
+ });
@@ -1,6 +1,8 @@
1
1
  import {
2
+ forwardRef,
2
3
  useCallback,
3
4
  useEffect,
5
+ useImperativeHandle,
4
6
  useRef,
5
7
  useState,
6
8
  type KeyboardEvent,
@@ -96,9 +98,23 @@ export interface ComboboxProps {
96
98
  // Fired when the user edits the input after a commit, or clicks the
97
99
  // card's ×. Parent clears its committed state.
98
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;
99
107
  testid?: string | undefined;
100
108
  }
101
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
+
102
118
  const DEFAULT_DEBOUNCE_MS = 250;
103
119
 
104
120
  function defaultMatch(raw: string, option: ComboboxOption): boolean {
@@ -109,7 +125,8 @@ function defaultMatch(raw: string, option: ComboboxOption): boolean {
109
125
  );
110
126
  }
111
127
 
112
- export function Combobox({
128
+ export const Combobox = forwardRef<ComboboxHandle, ComboboxProps>(function Combobox(
129
+ {
113
130
  label,
114
131
  placeholder,
115
132
  autoFocus,
@@ -126,8 +143,11 @@ export function Combobox({
126
143
  committedExtra,
127
144
  onCommit,
128
145
  onUncommit,
146
+ clearOnCommit = false,
129
147
  testid = "combobox",
130
- }: ComboboxProps): ReactNode {
148
+ },
149
+ ref,
150
+ ) {
131
151
  const [query, setQuery] = useState("");
132
152
  const [results, setResults] = useState<ComboboxOption[]>([]);
133
153
  const [loading, setLoading] = useState(false);
@@ -141,6 +161,20 @@ export function Combobox({
141
161
 
142
162
  const isCommitted = !!committed;
143
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
+
144
178
  useEffect(() => {
145
179
  if (debounceRef.current) clearTimeout(debounceRef.current);
146
180
  // Search is suspended while a commit is displayed — the input
@@ -191,11 +225,13 @@ export function Combobox({
191
225
  setResults([]);
192
226
  setHighlight(0);
193
227
  // Reflect the commit in the input so a half-typed query never
194
- // sits next to a card naming someone else.
195
- setQuery(option.subtitle ?? option.title);
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));
196
232
  onCommit({ kind: "option", option });
197
233
  },
198
- [onCommit],
234
+ [onCommit, clearOnCommit],
199
235
  );
200
236
 
201
237
  const commitFree = useCallback(
@@ -203,10 +239,10 @@ export function Combobox({
203
239
  setInvalid(false);
204
240
  setResults([]);
205
241
  setHighlight(0);
206
- setQuery(raw);
242
+ setQuery(clearOnCommit ? "" : raw);
207
243
  onCommit({ kind: "free", raw });
208
244
  },
209
- [onCommit],
245
+ [onCommit, clearOnCommit],
210
246
  );
211
247
 
212
248
  // Blur auto-commit — the fix for "typed it, tabbed on, looked
@@ -436,4 +472,4 @@ export function Combobox({
436
472
  ) : null}
437
473
  </div>
438
474
  );
439
- }
475
+ });
@@ -0,0 +1,179 @@
1
+ import { forwardRef, useRef, type KeyboardEvent, type ReactNode } from "react";
2
+
3
+ import { Chip, type ChipTone } from "./Chip";
4
+ import { Combobox, type ComboboxHandle, type ComboboxProps } from "./Combobox";
5
+
6
+ /**
7
+ * MultiCombobox — a Combobox that collects MANY values. The embedded
8
+ * search input clears after every commit (`clearOnCommit`) and the
9
+ * parent renders what it collected as a row of removable Chips below
10
+ * the field. Exists so recipient lists (group share links: one URL,
11
+ * N emails) and similar "pick several people / things" fields share
12
+ * one interaction model with the single-value Combobox — same async
13
+ * search, same free-entry gate, same blur auto-commit.
14
+ *
15
+ * Like Combobox, the value is PARENT-OWNED: `onCommit` hands the
16
+ * parent each pick / free entry, the parent maps it onto its own
17
+ * domain state and passes back `entries` for display. Splitting a
18
+ * pasted "a@x.co, b@x.co" into several entries is the parent's job
19
+ * inside `onCommit` (`allowFreeEntry` sees the whole raw string).
20
+ *
21
+ * Per entry: `title` (chip text), `subtitle` (native tooltip),
22
+ * `tone` (e.g. `warning` for "still needs a name"), `editing` — the
23
+ * one entry currently being edited renders `selected` and, when
24
+ * `renderEntryEditor` is given, its editor mounts under the chip row
25
+ * (the parent owns the editor's state; use the forwarded handle's
26
+ * `focus()` to hand focus back to the search input when it closes).
27
+ *
28
+ * Keyboard: Backspace in the EMPTY search input removes the last
29
+ * entry (`backspaceRemovesLast`, default true). The check is scoped
30
+ * to the embedded Combobox's input, so Backspace inside an entry
31
+ * editor never removes anything.
32
+ *
33
+ * Compose with: Chip (entries), Input (inside `renderEntryEditor`),
34
+ * dialogs collecting recipients.
35
+ *
36
+ * Visual: see docs/components/multi-combobox.
37
+ *
38
+ * Forwards a `MultiComboboxHandle` (= `ComboboxHandle`): `focus()`
39
+ * the search input, `clear()` its text (e.g. after rejecting a
40
+ * duplicate). Testids (from `testid`, default "multi-combobox"):
41
+ * `${testid}` wrapper, `${testid}-combobox-input` / `-dropdown` /
42
+ * `-free-entry` (the embedded Combobox), `${testid}-entries` (the
43
+ * list), `${testid}-entry-${key}` (each chip), `${testid}-editor`.
44
+ */
45
+
46
+ export type MultiComboboxEntry = {
47
+ // Stable identity — list key, testid suffix, and the argument to
48
+ // `onRemoveEntry` / `onEntryClick`.
49
+ key: string;
50
+ // Chip text.
51
+ title: ReactNode;
52
+ // Native tooltip on the chip (e.g. the email behind a name).
53
+ subtitle?: string | undefined;
54
+ tone?: ChipTone | undefined;
55
+ // Marks the entry being edited: chip renders `selected`, and
56
+ // `renderEntryEditor(entry)` mounts below the chip row.
57
+ editing?: boolean | undefined;
58
+ };
59
+
60
+ export type MultiComboboxHandle = ComboboxHandle;
61
+
62
+ export type MultiComboboxProps = Omit<
63
+ ComboboxProps,
64
+ "committed" | "committedExtra" | "onUncommit" | "clearOnCommit"
65
+ > & {
66
+ entries: MultiComboboxEntry[];
67
+ onRemoveEntry: (key: string) => void;
68
+ // When set, chip labels become buttons (e.g. click-to-edit).
69
+ onEntryClick?: ((key: string) => void) | undefined;
70
+ // Inline editor for the entry with `editing: true`.
71
+ renderEntryEditor?: ((entry: MultiComboboxEntry) => ReactNode) | undefined;
72
+ // aria-label of the entries list. Default "Selected items".
73
+ entriesLabel?: string | undefined;
74
+ // Per-entry aria-label for the chip's × button. Default "Remove".
75
+ removeLabel?: ((entry: MultiComboboxEntry) => string) | undefined;
76
+ // Backspace in the empty search input removes the last entry.
77
+ // Default true.
78
+ backspaceRemovesLast?: boolean | undefined;
79
+ // Small tertiary line under the entries (counts, duplicate notices).
80
+ hint?: ReactNode | undefined;
81
+ };
82
+
83
+ export const MultiCombobox = forwardRef<MultiComboboxHandle, MultiComboboxProps>(
84
+ function MultiCombobox(
85
+ {
86
+ entries,
87
+ onRemoveEntry,
88
+ onEntryClick,
89
+ renderEntryEditor,
90
+ entriesLabel,
91
+ removeLabel,
92
+ backspaceRemovesLast = true,
93
+ hint,
94
+ testid = "multi-combobox",
95
+ ...comboboxProps
96
+ },
97
+ ref,
98
+ ) {
99
+ // Wraps ONLY the embedded Combobox so the Backspace handler can
100
+ // tell its input apart from any input inside `renderEntryEditor`.
101
+ const comboboxWrapRef = useRef<HTMLDivElement | null>(null);
102
+ const editing = entries.find((entry) => entry.editing);
103
+
104
+ const handleKeyDownCapture = (e: KeyboardEvent<HTMLDivElement>): void => {
105
+ if (!backspaceRemovesLast || e.key !== "Backspace") return;
106
+ const target = e.target;
107
+ if (!(target instanceof HTMLInputElement)) return;
108
+ if (!comboboxWrapRef.current?.contains(target)) return;
109
+ if (target.value !== "") return;
110
+ const last = entries[entries.length - 1];
111
+ if (!last) return;
112
+ onRemoveEntry(last.key);
113
+ };
114
+
115
+ return (
116
+ <div data-testid={testid} onKeyDownCapture={handleKeyDownCapture}>
117
+ <div ref={comboboxWrapRef}>
118
+ <Combobox
119
+ ref={ref}
120
+ {...comboboxProps}
121
+ clearOnCommit
122
+ committed={null}
123
+ testid={`${testid}-combobox`}
124
+ />
125
+ </div>
126
+
127
+ <ul
128
+ role="list"
129
+ aria-label={entriesLabel ?? "Selected items"}
130
+ data-testid={`${testid}-entries`}
131
+ style={{
132
+ listStyle: "none",
133
+ margin: entries.length > 0 ? "8px 0 0" : 0,
134
+ padding: 0,
135
+ display: "flex",
136
+ flexWrap: "wrap",
137
+ gap: 6,
138
+ }}
139
+ >
140
+ {entries.map((entry) => (
141
+ <li key={entry.key} style={{ minWidth: 0, maxWidth: "100%" }}>
142
+ <Chip
143
+ data-testid={`${testid}-entry-${entry.key}`}
144
+ title={entry.subtitle}
145
+ tone={entry.tone}
146
+ selected={entry.editing}
147
+ disabled={comboboxProps.disabled}
148
+ onClick={onEntryClick ? () => onEntryClick(entry.key) : undefined}
149
+ onRemove={() => onRemoveEntry(entry.key)}
150
+ removeLabel={removeLabel?.(entry)}
151
+ >
152
+ {entry.title}
153
+ </Chip>
154
+ </li>
155
+ ))}
156
+ </ul>
157
+
158
+ {editing && renderEntryEditor ? (
159
+ <div data-testid={`${testid}-editor`} style={{ marginTop: 8 }}>
160
+ {renderEntryEditor(editing)}
161
+ </div>
162
+ ) : null}
163
+
164
+ {hint ? (
165
+ <div
166
+ style={{
167
+ marginTop: 6,
168
+ fontSize: 11,
169
+ lineHeight: 1.4,
170
+ color: "var(--ck-text-tertiary, #8B92A3)",
171
+ }}
172
+ >
173
+ {hint}
174
+ </div>
175
+ ) : null}
176
+ </div>
177
+ );
178
+ },
179
+ );
@@ -122,6 +122,10 @@ interface PopoverRect {
122
122
  top: number;
123
123
  left: number;
124
124
  width: number;
125
+ /** Set when the menu opens UPWARD (trigger near the viewport
126
+ * bottom): `top` is then the popover's bottom edge expressed as
127
+ * a CSS `bottom` inset. */
128
+ openUp: boolean;
125
129
  }
126
130
 
127
131
  export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select(
@@ -192,12 +196,20 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
192
196
  const el = triggerRef.current;
193
197
  if (!el) return null;
194
198
  const r = el.getBoundingClientRect();
199
+ // Flip upward when the space below can't fit the menu but the
200
+ // space above can (QA 2026-08-20: the last options of a Select
201
+ // at the bottom of a drawer sat under the fixed action bar).
202
+ // Worst-case menu height; the listbox scrolls inside either way.
203
+ const needed = maxOptionsHeight + (searchable ? 48 : 0) + POPOVER_GAP;
204
+ const below = window.innerHeight - r.bottom;
205
+ const openUp = below < needed && r.top > below;
195
206
  return {
196
- top: r.bottom + POPOVER_GAP,
207
+ top: openUp ? window.innerHeight - r.top + POPOVER_GAP : r.bottom + POPOVER_GAP,
197
208
  left: r.left,
198
209
  width: r.width,
210
+ openUp,
199
211
  };
200
- }, []);
212
+ }, [maxOptionsHeight, searchable]);
201
213
 
202
214
  // Position the popover on open + on window resize. Close on
203
215
  // page scroll — keeps the popover from drifting off the trigger
@@ -487,7 +499,7 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
487
499
  className="ck-select-popover"
488
500
  style={{
489
501
  position: "fixed",
490
- top: rect.top,
502
+ ...(rect.openUp ? { bottom: rect.top } : { top: rect.top }),
491
503
  left: rect.left,
492
504
  width: rect.width,
493
505
  zIndex: 1000,
@@ -20,7 +20,12 @@ export type {
20
20
  ComboboxOption,
21
21
  ComboboxCommit,
22
22
  ComboboxCommitted,
23
+ ComboboxHandle,
23
24
  } from "./Combobox";
25
+ export { Chip } from "./Chip";
26
+ export type { ChipProps, ChipTone } from "./Chip";
27
+ export { MultiCombobox } from "./MultiCombobox";
28
+ export type { MultiComboboxProps, MultiComboboxEntry, MultiComboboxHandle } from "./MultiCombobox";
24
29
  export { Textarea } from "./Textarea";
25
30
  export type { TextareaProps } from "./Textarea";
26
31
  export { Checkbox } from "./Checkbox";