@cosxai/ui 0.23.3 → 0.25.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.3",
3
+ "version": "0.25.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,191 @@
1
+ import {
2
+ forwardRef,
3
+ type CSSProperties,
4
+ type HTMLAttributes,
5
+ type MouseEvent,
6
+ type ReactNode,
7
+ } from "react";
8
+
9
+ import { cn } from "../lib/cn";
10
+
11
+ /**
12
+ * Chip — a compact pill representing one selected value (a recipient,
13
+ * a filter, a tag the user picked). Exists so multi-value inputs
14
+ * (MultiCombobox) and token lists share one removable-item shape
15
+ * instead of each product hand-rolling a "name ×" span.
16
+ *
17
+ * Distinct from `Tag` (uppercase mono STATUS label — "PENDING",
18
+ * "LIVE"): a Chip is sentence-case body text (13px sans), a pill on a
19
+ * muted slab, and is interactive — optional `onClick` turns the label
20
+ * into a button (e.g. "edit this entry"), optional `onRemove` adds
21
+ * the × button.
22
+ *
23
+ * Tones: `neutral` (default), `accent`, `warning` (e.g. "needs a
24
+ * name"), `critical`. `selected` outlines the chip in the accent
25
+ * colour (e.g. the entry currently being edited). `disabled` dims the
26
+ * chip and disables both buttons.
27
+ *
28
+ * Compose with: MultiCombobox (entry row), filter bars, recipient
29
+ * lists on detail pages.
30
+ *
31
+ * Visual: see docs/components/multi-combobox.
32
+ *
33
+ * Forwards ref to the root <span>. Spreads `...rest` onto it so
34
+ * consumers can pass data-* / aria-* / title / style. Chrome presets
35
+ * can restyle via the `[data-ck-chip]` / `data-tone` / `data-selected`
36
+ * hooks — 0.24.0 ships no per-chrome CSS for it.
37
+ */
38
+
39
+ export type ChipTone = "neutral" | "accent" | "warning" | "critical";
40
+
41
+ export type ChipProps = Omit<HTMLAttributes<HTMLSpanElement>, "onClick"> & {
42
+ children: ReactNode;
43
+ tone?: ChipTone | undefined;
44
+ // Outline the chip in the accent colour (e.g. "being edited").
45
+ selected?: boolean | undefined;
46
+ // Dim + disable both the label button and the remove button.
47
+ disabled?: boolean | undefined;
48
+ // When set, the label renders as a <button> (e.g. click-to-edit).
49
+ onClick?: (() => void) | undefined;
50
+ // When set, a × button renders after the label.
51
+ onRemove?: (() => void) | undefined;
52
+ // aria-label for the × button. Default "Remove".
53
+ removeLabel?: string | undefined;
54
+ className?: string | undefined;
55
+ };
56
+
57
+ const TONE_FG: Record<ChipTone, string> = {
58
+ neutral: "var(--ck-text-primary, #111)",
59
+ accent: "var(--ck-accent, #4f46e5)",
60
+ warning: "var(--ck-warning, #D97706)",
61
+ critical: "var(--ck-critical, #DC2626)",
62
+ };
63
+ const TONE_BG: Record<ChipTone, string> = {
64
+ neutral: "var(--ck-bg-muted, #E5E8EE)",
65
+ accent: "var(--ck-accent-muted, rgba(79, 70, 229, 0.08))",
66
+ warning: "var(--ck-warning-muted, #FEF3E7)",
67
+ critical: "var(--ck-critical-muted, #FCEAEA)",
68
+ };
69
+ const TONE_BORDER: Record<ChipTone, string> = {
70
+ neutral: "var(--ck-border-subtle, #DDE0E6)",
71
+ accent: "var(--ck-accent-border, rgba(79, 70, 229, 0.35))",
72
+ warning: "color-mix(in oklab, var(--ck-warning, #D97706) 35%, transparent)",
73
+ critical: "color-mix(in oklab, var(--ck-critical, #DC2626) 35%, transparent)",
74
+ };
75
+
76
+ // Label text styles — shared by the <span> and <button> renderings so
77
+ // the chip looks identical whether or not it is clickable.
78
+ const LABEL_STYLE: CSSProperties = {
79
+ minWidth: 0,
80
+ overflow: "hidden",
81
+ textOverflow: "ellipsis",
82
+ whiteSpace: "nowrap",
83
+ font: "inherit",
84
+ color: "inherit",
85
+ background: "none",
86
+ border: "none",
87
+ padding: 0,
88
+ margin: 0,
89
+ textAlign: "left",
90
+ };
91
+
92
+ export const Chip = forwardRef<HTMLSpanElement, ChipProps>(function Chip(
93
+ {
94
+ children,
95
+ tone = "neutral",
96
+ selected = false,
97
+ disabled = false,
98
+ onClick,
99
+ onRemove,
100
+ removeLabel,
101
+ className,
102
+ style,
103
+ ...rest
104
+ },
105
+ ref,
106
+ ) {
107
+ const clickable = !!onClick && !disabled;
108
+ // Chip buttons act on click but must NOT take focus on mousedown:
109
+ // when a host has an inline editor open for another chip, the
110
+ // editor's blur (commit + unmount) would otherwise run before this
111
+ // click lands and the click is lost. Keyboard focus via Tab is
112
+ // unaffected.
113
+ const keepFocus = (e: MouseEvent<HTMLButtonElement>): void => e.preventDefault();
114
+ return (
115
+ <span
116
+ ref={ref}
117
+ className={cn("ck-chip", className)}
118
+ data-ck-chip
119
+ data-tone={tone}
120
+ data-selected={selected ? "true" : undefined}
121
+ data-disabled={disabled ? "true" : undefined}
122
+ style={{
123
+ display: "inline-flex",
124
+ alignItems: "center",
125
+ gap: 4,
126
+ maxWidth: "100%",
127
+ boxSizing: "border-box",
128
+ padding: onRemove ? "3px 6px 3px 10px" : "3px 10px",
129
+ borderRadius: 999,
130
+ background: TONE_BG[tone],
131
+ border: `1px solid ${selected ? "var(--ck-accent, #4f46e5)" : TONE_BORDER[tone]}`,
132
+ boxShadow: selected ? "0 0 0 2px var(--ck-accent-muted, rgba(79, 70, 229, 0.12))" : "none",
133
+ font: "500 13px/1.3 var(--ck-font-sans, system-ui, sans-serif)",
134
+ color: TONE_FG[tone],
135
+ opacity: disabled ? 0.5 : 1,
136
+ cursor: disabled ? "default" : undefined,
137
+ transition: "border-color var(--ck-dur-fast, 120ms) var(--ck-ease, ease)",
138
+ ...style,
139
+ }}
140
+ {...rest}
141
+ >
142
+ {onClick ? (
143
+ <button
144
+ type="button"
145
+ className="ck-chip-label"
146
+ onMouseDown={keepFocus}
147
+ onClick={onClick}
148
+ disabled={disabled}
149
+ style={{ ...LABEL_STYLE, cursor: clickable ? "pointer" : "default" }}
150
+ >
151
+ {children}
152
+ </button>
153
+ ) : (
154
+ <span className="ck-chip-label" style={LABEL_STYLE}>
155
+ {children}
156
+ </span>
157
+ )}
158
+ {onRemove ? (
159
+ <button
160
+ type="button"
161
+ className="ck-chip-remove"
162
+ aria-label={removeLabel ?? "Remove"}
163
+ onMouseDown={keepFocus}
164
+ onClick={onRemove}
165
+ disabled={disabled}
166
+ style={{
167
+ display: "inline-flex",
168
+ alignItems: "center",
169
+ justifyContent: "center",
170
+ width: 16,
171
+ height: 16,
172
+ flexShrink: 0,
173
+ padding: 0,
174
+ margin: 0,
175
+ border: "none",
176
+ borderRadius: 999,
177
+ background: "none",
178
+ color: "inherit",
179
+ opacity: 0.7,
180
+ font: "inherit",
181
+ fontSize: 14,
182
+ lineHeight: 1,
183
+ cursor: disabled ? "default" : "pointer",
184
+ }}
185
+ >
186
+ ×
187
+ </button>
188
+ ) : null}
189
+ </span>
190
+ );
191
+ });
@@ -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,39 @@ 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;
107
+ // Bare mode: the input renders without its own field chrome (see
108
+ // Input `bare`) and the root wrapper is `position: static` +
109
+ // `flex: 1 1 140px`, so a host that draws the field (MultiCombobox
110
+ // inline layout) can place this after its chips and the dropdown
111
+ // anchors to the host's `position: relative` container. `label`
112
+ // and the inline error text are not rendered (the host draws them;
113
+ // `aria-invalid` is still set on the input). Default false.
114
+ bare?: boolean | undefined;
115
+ // id for the <input>, so a host-drawn <label htmlFor> can target it.
116
+ inputId?: string | undefined;
117
+ // Extra aria-describedby id(s) for the <input> (e.g. a host-drawn
118
+ // invalid hint in bare mode). Merged with Input's own error/helper
119
+ // association, never replacing it.
120
+ inputDescribedBy?: string | undefined;
99
121
  testid?: string | undefined;
100
122
  }
101
123
 
124
+ export const COMBOBOX_DEFAULT_INVALID_HINT = "Pick from the list to continue.";
125
+
126
+ // Imperative handle — `focus()` returns focus to the input (e.g. after
127
+ // an inline editor closes); `clear()` wipes the typed text + dropdown
128
+ // (e.g. a parent rejecting a duplicate commit).
129
+ export type ComboboxHandle = {
130
+ focus: () => void;
131
+ clear: () => void;
132
+ };
133
+
102
134
  const DEFAULT_DEBOUNCE_MS = 250;
103
135
 
104
136
  function defaultMatch(raw: string, option: ComboboxOption): boolean {
@@ -109,7 +141,8 @@ function defaultMatch(raw: string, option: ComboboxOption): boolean {
109
141
  );
110
142
  }
111
143
 
112
- export function Combobox({
144
+ export const Combobox = forwardRef<ComboboxHandle, ComboboxProps>(function Combobox(
145
+ {
113
146
  label,
114
147
  placeholder,
115
148
  autoFocus,
@@ -126,8 +159,14 @@ export function Combobox({
126
159
  committedExtra,
127
160
  onCommit,
128
161
  onUncommit,
162
+ clearOnCommit = false,
163
+ bare = false,
164
+ inputId,
165
+ inputDescribedBy,
129
166
  testid = "combobox",
130
- }: ComboboxProps): ReactNode {
167
+ },
168
+ ref,
169
+ ) {
131
170
  const [query, setQuery] = useState("");
132
171
  const [results, setResults] = useState<ComboboxOption[]>([]);
133
172
  const [loading, setLoading] = useState(false);
@@ -141,6 +180,20 @@ export function Combobox({
141
180
 
142
181
  const isCommitted = !!committed;
143
182
 
183
+ useImperativeHandle(
184
+ ref,
185
+ () => ({
186
+ focus: () => inputRef.current?.focus(),
187
+ clear: () => {
188
+ setQuery("");
189
+ setResults([]);
190
+ setHighlight(0);
191
+ setInvalid(false);
192
+ },
193
+ }),
194
+ [],
195
+ );
196
+
144
197
  useEffect(() => {
145
198
  if (debounceRef.current) clearTimeout(debounceRef.current);
146
199
  // Search is suspended while a commit is displayed — the input
@@ -191,11 +244,13 @@ export function Combobox({
191
244
  setResults([]);
192
245
  setHighlight(0);
193
246
  // 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);
247
+ // sits next to a card naming someone else — unless the parent
248
+ // collects values (clearOnCommit), where the input resets for
249
+ // the next entry.
250
+ setQuery(clearOnCommit ? "" : (option.subtitle ?? option.title));
196
251
  onCommit({ kind: "option", option });
197
252
  },
198
- [onCommit],
253
+ [onCommit, clearOnCommit],
199
254
  );
200
255
 
201
256
  const commitFree = useCallback(
@@ -203,10 +258,10 @@ export function Combobox({
203
258
  setInvalid(false);
204
259
  setResults([]);
205
260
  setHighlight(0);
206
- setQuery(raw);
261
+ setQuery(clearOnCommit ? "" : raw);
207
262
  onCommit({ kind: "free", raw });
208
263
  },
209
- [onCommit],
264
+ [onCommit, clearOnCommit],
210
265
  );
211
266
 
212
267
  // Blur auto-commit — the fix for "typed it, tabbed on, looked
@@ -216,6 +271,10 @@ export function Combobox({
216
271
  // blurs first) still lands on the row handler.
217
272
  const handleBlur = useCallback(() => {
218
273
  setTimeout(() => {
274
+ // Re-focused within the delay (e.g. a host frame click handing
275
+ // focus straight back, MultiCombobox inline): still editing —
276
+ // neither close the list nor auto-commit.
277
+ if (inputRef.current && document.activeElement === inputRef.current) return;
219
278
  setFocused(false);
220
279
  if (isCommitted) return;
221
280
  const raw = trimmed;
@@ -286,9 +345,18 @@ export function Combobox({
286
345
  focused && !isCommitted && (results.length > 0 || offerFree || (hasNoResults && !!emptyHint));
287
346
 
288
347
  return (
289
- <div style={{ position: "relative" }}>
348
+ <div
349
+ style={
350
+ bare
351
+ ? { position: "static", flex: "1 1 140px", minWidth: 140 }
352
+ : { position: "relative" }
353
+ }
354
+ >
290
355
  <Input
291
356
  ref={inputRef}
357
+ id={inputId}
358
+ aria-describedby={inputDescribedBy}
359
+ bare={bare}
292
360
  label={label}
293
361
  data-testid={`${testid}-input`}
294
362
  value={query}
@@ -303,7 +371,7 @@ export function Combobox({
303
371
  placeholder={placeholder}
304
372
  autoFocus={autoFocus}
305
373
  autoComplete="off"
306
- error={invalid ? (invalidHint ?? "Pick from the list to continue.") : null}
374
+ error={invalid ? (invalidHint ?? COMBOBOX_DEFAULT_INVALID_HINT) : null}
307
375
  />
308
376
 
309
377
  {showDropdown ? (
@@ -436,4 +504,4 @@ export function Combobox({
436
504
  ) : null}
437
505
  </div>
438
506
  );
439
- }
507
+ });
@@ -33,6 +33,14 @@ export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>,
33
33
  // currency symbols, unit labels, search icons, etc.
34
34
  prefix?: ReactNode;
35
35
  suffix?: ReactNode;
36
+ // Bare mode: render ONLY the <input> — no wrapper, label, helper,
37
+ // error text, or field chrome (border / background / height). For
38
+ // hosts that draw the field themselves (MultiCombobox's inline
39
+ // layout, where chips and the input share one frame). `label`,
40
+ // `helper`, `error` text, `prefix`, `suffix` and `fit` are ignored;
41
+ // `aria-invalid` is still set from `error` so the host can react.
42
+ // Default false.
43
+ bare?: boolean;
36
44
  }
37
45
 
38
46
  const ADDON_STYLE = {
@@ -52,12 +60,47 @@ const ADDON_STYLE = {
52
60
  };
53
61
 
54
62
  export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
55
- { label, helper, error, fit = "full", prefix, suffix, className, id, style, ...rest },
63
+ { label, helper, error, fit = "full", prefix, suffix, bare = false, className, id, style, ...rest },
56
64
  ref,
57
65
  ) {
58
66
  const autoId = useId();
59
67
  const inputId = id ?? autoId;
60
- const hasAddon = prefix != null || suffix != null;
68
+ const hasAddon = !bare && (prefix != null || suffix != null);
69
+ // `...rest` is spread FIRST so the `error`-derived aria-invalid (and,
70
+ // non-bare, the helper/error text association) stays authoritative;
71
+ // a consumer aria-describedby is merged with ours, not replaced.
72
+ const ownDescribedBy = !bare && (helper || error) ? `${inputId}-helper` : undefined;
73
+ const describedBy =
74
+ [rest["aria-describedby"], ownDescribedBy].filter(Boolean).join(" ") || undefined;
75
+
76
+ if (bare) {
77
+ // Chrome presets restyle .ck-input with !important; the matching
78
+ // .ck-input--bare rule in styles/index.css strips that again.
79
+ return (
80
+ <input
81
+ ref={ref}
82
+ id={inputId}
83
+ {...rest}
84
+ aria-invalid={error ? true : undefined}
85
+ aria-describedby={describedBy}
86
+ className={cn("ck-input", "ck-input--bare", className)}
87
+ style={{
88
+ minWidth: 0,
89
+ width: "100%",
90
+ height: 28,
91
+ padding: "0 4px",
92
+ font: "400 13px/1 var(--ck-font-sans)",
93
+ background: "transparent",
94
+ color: "var(--ck-text-primary)",
95
+ border: "none",
96
+ borderRadius: 0,
97
+ boxShadow: "none",
98
+ outline: "none",
99
+ ...(style ?? {}),
100
+ }}
101
+ />
102
+ );
103
+ }
61
104
 
62
105
  // Border / radius live on the WRAPPER when there's an addon so the
63
106
  // addon + input share one continuous frame. Otherwise the input
@@ -69,9 +112,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
69
112
  <input
70
113
  ref={ref}
71
114
  id={inputId}
72
- aria-invalid={error ? true : undefined}
73
- aria-describedby={(helper || error) ? `${inputId}-helper` : undefined}
74
115
  {...rest}
116
+ aria-invalid={error ? true : undefined}
117
+ aria-describedby={describedBy}
75
118
  className={cn("ck-input", hasAddon ? "ck-input--with-addon" : undefined)}
76
119
  style={{
77
120
  flex: hasAddon ? "1 1 auto" : undefined,
@@ -0,0 +1,327 @@
1
+ import {
2
+ forwardRef,
3
+ useId,
4
+ useImperativeHandle,
5
+ useRef,
6
+ type CSSProperties,
7
+ type KeyboardEvent,
8
+ type MouseEvent,
9
+ type ReactNode,
10
+ } from "react";
11
+
12
+ import { Chip, type ChipTone } from "./Chip";
13
+ import {
14
+ COMBOBOX_DEFAULT_INVALID_HINT,
15
+ Combobox,
16
+ type ComboboxHandle,
17
+ type ComboboxProps,
18
+ } from "./Combobox";
19
+
20
+ /**
21
+ * MultiCombobox — a Combobox that collects MANY values. The embedded
22
+ * search input clears after every commit (`clearOnCommit`) and the
23
+ * parent renders what it collected as a row of removable Chips below
24
+ * the field. Exists so recipient lists (group share links: one URL,
25
+ * N emails) and similar "pick several people / things" fields share
26
+ * one interaction model with the single-value Combobox — same async
27
+ * search, same free-entry gate, same blur auto-commit.
28
+ *
29
+ * Like Combobox, the value is PARENT-OWNED: `onCommit` hands the
30
+ * parent each pick / free entry, the parent maps it onto its own
31
+ * domain state and passes back `entries` for display. Splitting a
32
+ * pasted "a@x.co, b@x.co" into several entries is the parent's job
33
+ * inside `onCommit` (`allowFreeEntry` sees the whole raw string).
34
+ *
35
+ * Per entry: `title` (chip text), `subtitle` (native tooltip),
36
+ * `tone` (e.g. `warning` for "still needs a name"), `editing` — the
37
+ * one entry currently being edited renders `selected` and, when
38
+ * `renderEntryEditor` is given, its editor mounts under the chip row
39
+ * (the parent owns the editor's state; use the forwarded handle's
40
+ * `focus()` to hand focus back to the search input when it closes).
41
+ *
42
+ * Keyboard: Backspace in the EMPTY search input removes the last
43
+ * entry (`backspaceRemovesLast`, default true). The check is scoped
44
+ * to the embedded Combobox's input, so Backspace inside an entry
45
+ * editor never removes anything.
46
+ *
47
+ * Layouts (`layout`): `stacked` (default) — the search field, then
48
+ * the chip row underneath. `inline` — a mail-client "To" field: one
49
+ * Input-like frame (`.ck-multi-combobox-field`) whose `<ul>` is the
50
+ * flex-wrap row: chips as `<li>`s and the bare search input in a last
51
+ * presentational `<li>`, so typing continues after the last chip and
52
+ * wraps with it. The label renders above as an eyebrow; the invalid
53
+ * hint (aria-describedby on the input), editor and hint stay below.
54
+ *
55
+ * Compose with: Chip (entries), Input (inside `renderEntryEditor`),
56
+ * dialogs collecting recipients.
57
+ *
58
+ * Visual: see docs/components/multi-combobox.
59
+ *
60
+ * Forwards a `MultiComboboxHandle` (= `ComboboxHandle`): `focus()`
61
+ * the search input, `clear()` its text (e.g. after rejecting a
62
+ * duplicate). Testids (from `testid`, default "multi-combobox"):
63
+ * `${testid}` wrapper, `${testid}-combobox-input` / `-dropdown` /
64
+ * `-free-entry` (the embedded Combobox), `${testid}-entries` (the
65
+ * list), `${testid}-entry-${key}` (each chip), `${testid}-editor`.
66
+ */
67
+
68
+ export type MultiComboboxEntry = {
69
+ // Stable identity — list key, testid suffix, and the argument to
70
+ // `onRemoveEntry` / `onEntryClick`.
71
+ key: string;
72
+ // Chip text.
73
+ title: ReactNode;
74
+ // Native tooltip on the chip (e.g. the email behind a name).
75
+ subtitle?: string | undefined;
76
+ tone?: ChipTone | undefined;
77
+ // Marks the entry being edited: chip renders `selected`, and
78
+ // `renderEntryEditor(entry)` mounts below the chip row.
79
+ editing?: boolean | undefined;
80
+ };
81
+
82
+ export type MultiComboboxHandle = ComboboxHandle;
83
+
84
+ export type MultiComboboxLayout = "stacked" | "inline";
85
+
86
+ export type MultiComboboxProps = Omit<
87
+ ComboboxProps,
88
+ | "committed"
89
+ | "committedExtra"
90
+ | "onUncommit"
91
+ | "clearOnCommit"
92
+ | "bare"
93
+ | "inputId"
94
+ | "inputDescribedBy"
95
+ > & {
96
+ entries: MultiComboboxEntry[];
97
+ // `stacked` (default): field, then chips underneath. `inline`:
98
+ // chips inside the field, input after the last chip.
99
+ layout?: MultiComboboxLayout | undefined;
100
+ onRemoveEntry: (key: string) => void;
101
+ // When set, chip labels become buttons (e.g. click-to-edit).
102
+ onEntryClick?: ((key: string) => void) | undefined;
103
+ // Inline editor for the entry with `editing: true`.
104
+ renderEntryEditor?: ((entry: MultiComboboxEntry) => ReactNode) | undefined;
105
+ // aria-label of the entries list. Default "Selected items".
106
+ entriesLabel?: string | undefined;
107
+ // Per-entry aria-label for the chip's × button. Default "Remove".
108
+ removeLabel?: ((entry: MultiComboboxEntry) => string) | undefined;
109
+ // Backspace in the empty search input removes the last entry.
110
+ // Default true.
111
+ backspaceRemovesLast?: boolean | undefined;
112
+ // Small tertiary line under the entries (counts, duplicate notices).
113
+ hint?: ReactNode | undefined;
114
+ };
115
+
116
+ // Inline layout: the host-drawn field mirrors Input's chrome. Focus
117
+ // ring / invalid border / disabled dim come from styles/index.css
118
+ // (`.ck-multi-combobox-field`).
119
+ const INLINE_FIELD_STYLE: CSSProperties = {
120
+ position: "relative",
121
+ display: "flex",
122
+ flexWrap: "wrap",
123
+ alignItems: "center",
124
+ gap: 6,
125
+ minHeight: 36,
126
+ padding: "4px 8px",
127
+ boxSizing: "border-box",
128
+ background: "var(--ck-bg-surface, #fff)",
129
+ border: "1px solid var(--ck-border-strong, #C5C9D2)",
130
+ borderRadius: "var(--ck-radius-sm, 6px)",
131
+ transition: "border-color var(--ck-dur-fast, 120ms) var(--ck-ease, ease)",
132
+ };
133
+ // The `<ul>` inside the frame is the actual flex-wrap row.
134
+ const INLINE_LIST_STYLE: CSSProperties = {
135
+ listStyle: "none",
136
+ margin: 0,
137
+ padding: 0,
138
+ width: "100%",
139
+ display: "flex",
140
+ flexWrap: "wrap",
141
+ alignItems: "center",
142
+ gap: 6,
143
+ };
144
+ // Last <li>: holds the bare Combobox (its root is `position: static`,
145
+ // so the dropdown anchors to the frame) and takes the remaining width.
146
+ const INLINE_COMBOBOX_ITEM: CSSProperties = {
147
+ flex: "1 1 140px",
148
+ minWidth: 140,
149
+ display: "flex",
150
+ };
151
+
152
+ export const MultiCombobox = forwardRef<MultiComboboxHandle, MultiComboboxProps>(
153
+ function MultiCombobox(
154
+ {
155
+ entries,
156
+ onRemoveEntry,
157
+ onEntryClick,
158
+ renderEntryEditor,
159
+ entriesLabel,
160
+ removeLabel,
161
+ backspaceRemovesLast = true,
162
+ hint,
163
+ layout = "stacked",
164
+ testid = "multi-combobox",
165
+ label,
166
+ ...comboboxProps
167
+ },
168
+ ref,
169
+ ) {
170
+ // Wraps ONLY the embedded Combobox so the Backspace handler can
171
+ // tell its input apart from any input inside `renderEntryEditor`.
172
+ // A <div> in stacked mode, the last <li> in inline mode.
173
+ const comboboxWrapRef = useRef<HTMLElement | null>(null);
174
+ const setComboboxWrap = (el: HTMLElement | null): void => {
175
+ comboboxWrapRef.current = el;
176
+ };
177
+ const listRef = useRef<HTMLUListElement | null>(null);
178
+ const handleRef = useRef<ComboboxHandle | null>(null);
179
+ useImperativeHandle(ref, () => ({
180
+ focus: () => handleRef.current?.focus(),
181
+ clear: () => handleRef.current?.clear(),
182
+ }));
183
+ const inputId = useId();
184
+ const hintId = useId();
185
+ const inline = layout === "inline";
186
+ const editing = entries.find((entry) => entry.editing);
187
+
188
+ const handleKeyDownCapture = (e: KeyboardEvent<HTMLDivElement>): void => {
189
+ if (!backspaceRemovesLast || e.key !== "Backspace") return;
190
+ const target = e.target;
191
+ if (!(target instanceof HTMLInputElement)) return;
192
+ if (!comboboxWrapRef.current?.contains(target)) return;
193
+ if (target.value !== "") return;
194
+ const last = entries[entries.length - 1];
195
+ if (!last) return;
196
+ onRemoveEntry(last.key);
197
+ };
198
+
199
+ // Clicking the field's empty area (inline) focuses the input, like
200
+ // a mail client's To field. Chip / input clicks handle themselves.
201
+ const handleFieldMouseDown = (e: MouseEvent<HTMLDivElement>): void => {
202
+ if (e.target !== e.currentTarget && e.target !== listRef.current) return;
203
+ e.preventDefault();
204
+ handleRef.current?.focus();
205
+ };
206
+
207
+ const comboboxEl = (
208
+ <Combobox
209
+ ref={handleRef}
210
+ {...comboboxProps}
211
+ label={inline ? undefined : label}
212
+ bare={inline}
213
+ inputId={inline ? inputId : undefined}
214
+ inputDescribedBy={inline ? hintId : undefined}
215
+ clearOnCommit
216
+ committed={null}
217
+ testid={`${testid}-combobox`}
218
+ />
219
+ );
220
+
221
+ const entriesList = (
222
+ <ul
223
+ ref={listRef}
224
+ role="list"
225
+ aria-label={entriesLabel ?? "Selected items"}
226
+ data-testid={`${testid}-entries`}
227
+ style={
228
+ inline
229
+ ? INLINE_LIST_STYLE
230
+ : {
231
+ listStyle: "none",
232
+ margin: entries.length > 0 ? "8px 0 0" : 0,
233
+ padding: 0,
234
+ display: "flex",
235
+ flexWrap: "wrap",
236
+ gap: 6,
237
+ }
238
+ }
239
+ >
240
+ {entries.map((entry) => (
241
+ <li key={entry.key} style={{ minWidth: 0, maxWidth: "100%" }}>
242
+ <Chip
243
+ data-testid={`${testid}-entry-${entry.key}`}
244
+ title={entry.subtitle}
245
+ tone={entry.tone}
246
+ selected={entry.editing}
247
+ disabled={comboboxProps.disabled}
248
+ onClick={onEntryClick ? () => onEntryClick(entry.key) : undefined}
249
+ onRemove={() => onRemoveEntry(entry.key)}
250
+ removeLabel={removeLabel?.(entry)}
251
+ >
252
+ {entry.title}
253
+ </Chip>
254
+ </li>
255
+ ))}
256
+ {inline ? (
257
+ // Not an entry: presentational so the list still counts N
258
+ // chips for assistive tech.
259
+ <li ref={setComboboxWrap} role="presentation" style={INLINE_COMBOBOX_ITEM}>
260
+ {comboboxEl}
261
+ </li>
262
+ ) : null}
263
+ </ul>
264
+ );
265
+
266
+ return (
267
+ <div data-testid={testid} onKeyDownCapture={handleKeyDownCapture}>
268
+ {inline ? (
269
+ <>
270
+ {label ? (
271
+ <label
272
+ htmlFor={inputId}
273
+ className="ck-eyebrow"
274
+ style={{ color: "var(--ck-text-secondary)", display: "block", marginBottom: 6 }}
275
+ >
276
+ {label}
277
+ </label>
278
+ ) : null}
279
+ <div
280
+ className="ck-multi-combobox-field"
281
+ data-testid={`${testid}-field`}
282
+ style={INLINE_FIELD_STYLE}
283
+ onMouseDown={handleFieldMouseDown}
284
+ >
285
+ {entriesList}
286
+ </div>
287
+ <div
288
+ id={hintId}
289
+ className="ck-multi-combobox-invalid-hint"
290
+ style={{
291
+ marginTop: 6,
292
+ font: "400 11px/1.4 var(--ck-font-sans)",
293
+ color: "var(--ck-critical)",
294
+ }}
295
+ >
296
+ {comboboxProps.invalidHint ?? COMBOBOX_DEFAULT_INVALID_HINT}
297
+ </div>
298
+ </>
299
+ ) : (
300
+ <>
301
+ <div ref={setComboboxWrap}>{comboboxEl}</div>
302
+ {entriesList}
303
+ </>
304
+ )}
305
+
306
+ {editing && renderEntryEditor ? (
307
+ <div data-testid={`${testid}-editor`} style={{ marginTop: 8 }}>
308
+ {renderEntryEditor(editing)}
309
+ </div>
310
+ ) : null}
311
+
312
+ {hint ? (
313
+ <div
314
+ style={{
315
+ marginTop: 6,
316
+ fontSize: 11,
317
+ lineHeight: 1.4,
318
+ color: "var(--ck-text-tertiary, #8B92A3)",
319
+ }}
320
+ >
321
+ {hint}
322
+ </div>
323
+ ) : null}
324
+ </div>
325
+ );
326
+ },
327
+ );
@@ -20,7 +20,17 @@ 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 {
29
+ MultiComboboxProps,
30
+ MultiComboboxEntry,
31
+ MultiComboboxHandle,
32
+ MultiComboboxLayout,
33
+ } from "./MultiCombobox";
24
34
  export { Textarea } from "./Textarea";
25
35
  export type { TextareaProps } from "./Textarea";
26
36
  export { Checkbox } from "./Checkbox";
@@ -304,3 +304,40 @@ html[data-ck-chrome="seamless"] .ck-card__foot {
304
304
  animation: none;
305
305
  }
306
306
  }
307
+
308
+ /* ---------- Input bare mode + MultiCombobox inline field ----------
309
+ `.ck-input--bare` is an <input> whose FIELD is drawn by its host
310
+ (MultiCombobox inline layout: chips + input in one frame). Chrome
311
+ presets restyle `.ck-input` with !important at up to (0,5,1)
312
+ specificity, so this rule uses `:not(#\#)` to out-rank them and
313
+ strip every trace of field chrome back off. */
314
+ .ck-input.ck-input--bare:not(#\#) {
315
+ background: transparent !important;
316
+ border: none !important;
317
+ border-radius: 0 !important;
318
+ box-shadow: none !important;
319
+ outline: none !important;
320
+ height: 28px !important;
321
+ padding: 0 4px !important;
322
+ }
323
+
324
+ /* The host-drawn field: looks like .ck-input, carries the focus ring
325
+ via :focus-within, the invalid border via :has([aria-invalid]) and
326
+ the disabled dim via :has(:disabled). */
327
+ .ck-multi-combobox-field:focus-within {
328
+ border-color: var(--ck-accent) !important;
329
+ box-shadow: 0 0 0 3px var(--ck-accent-muted);
330
+ }
331
+ .ck-multi-combobox-field:has(.ck-input[aria-invalid="true"]) {
332
+ border-color: var(--ck-critical) !important;
333
+ }
334
+ .ck-multi-combobox-field:has(.ck-input:disabled) {
335
+ opacity: 0.55;
336
+ cursor: not-allowed;
337
+ }
338
+ .ck-multi-combobox-invalid-hint {
339
+ display: none;
340
+ }
341
+ .ck-multi-combobox-field:has(.ck-input[aria-invalid="true"]) + .ck-multi-combobox-invalid-hint {
342
+ display: block;
343
+ }