@cosxai/ui 0.24.0 → 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.24.0",
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",
@@ -1,4 +1,10 @@
1
- import { forwardRef, type CSSProperties, type HTMLAttributes, type ReactNode } from "react";
1
+ import {
2
+ forwardRef,
3
+ type CSSProperties,
4
+ type HTMLAttributes,
5
+ type MouseEvent,
6
+ type ReactNode,
7
+ } from "react";
2
8
 
3
9
  import { cn } from "../lib/cn";
4
10
 
@@ -99,6 +105,12 @@ export const Chip = forwardRef<HTMLSpanElement, ChipProps>(function Chip(
99
105
  ref,
100
106
  ) {
101
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();
102
114
  return (
103
115
  <span
104
116
  ref={ref}
@@ -131,6 +143,7 @@ export const Chip = forwardRef<HTMLSpanElement, ChipProps>(function Chip(
131
143
  <button
132
144
  type="button"
133
145
  className="ck-chip-label"
146
+ onMouseDown={keepFocus}
134
147
  onClick={onClick}
135
148
  disabled={disabled}
136
149
  style={{ ...LABEL_STYLE, cursor: clickable ? "pointer" : "default" }}
@@ -147,6 +160,7 @@ export const Chip = forwardRef<HTMLSpanElement, ChipProps>(function Chip(
147
160
  type="button"
148
161
  className="ck-chip-remove"
149
162
  aria-label={removeLabel ?? "Remove"}
163
+ onMouseDown={keepFocus}
150
164
  onClick={onRemove}
151
165
  disabled={disabled}
152
166
  style={{
@@ -104,9 +104,25 @@ export interface ComboboxProps {
104
104
  // and typically passes `committed={null}`. Default false — the
105
105
  // single-value echo-into-input behaviour is unchanged.
106
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;
107
121
  testid?: string | undefined;
108
122
  }
109
123
 
124
+ export const COMBOBOX_DEFAULT_INVALID_HINT = "Pick from the list to continue.";
125
+
110
126
  // Imperative handle — `focus()` returns focus to the input (e.g. after
111
127
  // an inline editor closes); `clear()` wipes the typed text + dropdown
112
128
  // (e.g. a parent rejecting a duplicate commit).
@@ -144,6 +160,9 @@ export const Combobox = forwardRef<ComboboxHandle, ComboboxProps>(function Combo
144
160
  onCommit,
145
161
  onUncommit,
146
162
  clearOnCommit = false,
163
+ bare = false,
164
+ inputId,
165
+ inputDescribedBy,
147
166
  testid = "combobox",
148
167
  },
149
168
  ref,
@@ -252,6 +271,10 @@ export const Combobox = forwardRef<ComboboxHandle, ComboboxProps>(function Combo
252
271
  // blurs first) still lands on the row handler.
253
272
  const handleBlur = useCallback(() => {
254
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;
255
278
  setFocused(false);
256
279
  if (isCommitted) return;
257
280
  const raw = trimmed;
@@ -322,9 +345,18 @@ export const Combobox = forwardRef<ComboboxHandle, ComboboxProps>(function Combo
322
345
  focused && !isCommitted && (results.length > 0 || offerFree || (hasNoResults && !!emptyHint));
323
346
 
324
347
  return (
325
- <div style={{ position: "relative" }}>
348
+ <div
349
+ style={
350
+ bare
351
+ ? { position: "static", flex: "1 1 140px", minWidth: 140 }
352
+ : { position: "relative" }
353
+ }
354
+ >
326
355
  <Input
327
356
  ref={inputRef}
357
+ id={inputId}
358
+ aria-describedby={inputDescribedBy}
359
+ bare={bare}
328
360
  label={label}
329
361
  data-testid={`${testid}-input`}
330
362
  value={query}
@@ -339,7 +371,7 @@ export const Combobox = forwardRef<ComboboxHandle, ComboboxProps>(function Combo
339
371
  placeholder={placeholder}
340
372
  autoFocus={autoFocus}
341
373
  autoComplete="off"
342
- error={invalid ? (invalidHint ?? "Pick from the list to continue.") : null}
374
+ error={invalid ? (invalidHint ?? COMBOBOX_DEFAULT_INVALID_HINT) : null}
343
375
  />
344
376
 
345
377
  {showDropdown ? (
@@ -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,
@@ -1,7 +1,21 @@
1
- import { forwardRef, useRef, type KeyboardEvent, type ReactNode } from "react";
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";
2
11
 
3
12
  import { Chip, type ChipTone } from "./Chip";
4
- import { Combobox, type ComboboxHandle, type ComboboxProps } from "./Combobox";
13
+ import {
14
+ COMBOBOX_DEFAULT_INVALID_HINT,
15
+ Combobox,
16
+ type ComboboxHandle,
17
+ type ComboboxProps,
18
+ } from "./Combobox";
5
19
 
6
20
  /**
7
21
  * MultiCombobox — a Combobox that collects MANY values. The embedded
@@ -30,6 +44,14 @@ import { Combobox, type ComboboxHandle, type ComboboxProps } from "./Combobox";
30
44
  * to the embedded Combobox's input, so Backspace inside an entry
31
45
  * editor never removes anything.
32
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
+ *
33
55
  * Compose with: Chip (entries), Input (inside `renderEntryEditor`),
34
56
  * dialogs collecting recipients.
35
57
  *
@@ -59,11 +81,22 @@ export type MultiComboboxEntry = {
59
81
 
60
82
  export type MultiComboboxHandle = ComboboxHandle;
61
83
 
84
+ export type MultiComboboxLayout = "stacked" | "inline";
85
+
62
86
  export type MultiComboboxProps = Omit<
63
87
  ComboboxProps,
64
- "committed" | "committedExtra" | "onUncommit" | "clearOnCommit"
88
+ | "committed"
89
+ | "committedExtra"
90
+ | "onUncommit"
91
+ | "clearOnCommit"
92
+ | "bare"
93
+ | "inputId"
94
+ | "inputDescribedBy"
65
95
  > & {
66
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;
67
100
  onRemoveEntry: (key: string) => void;
68
101
  // When set, chip labels become buttons (e.g. click-to-edit).
69
102
  onEntryClick?: ((key: string) => void) | undefined;
@@ -80,6 +113,42 @@ export type MultiComboboxProps = Omit<
80
113
  hint?: ReactNode | undefined;
81
114
  };
82
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
+
83
152
  export const MultiCombobox = forwardRef<MultiComboboxHandle, MultiComboboxProps>(
84
153
  function MultiCombobox(
85
154
  {
@@ -91,14 +160,29 @@ export const MultiCombobox = forwardRef<MultiComboboxHandle, MultiComboboxProps>
91
160
  removeLabel,
92
161
  backspaceRemovesLast = true,
93
162
  hint,
163
+ layout = "stacked",
94
164
  testid = "multi-combobox",
165
+ label,
95
166
  ...comboboxProps
96
167
  },
97
168
  ref,
98
169
  ) {
99
170
  // Wraps ONLY the embedded Combobox so the Backspace handler can
100
171
  // tell its input apart from any input inside `renderEntryEditor`.
101
- const comboboxWrapRef = useRef<HTMLDivElement | null>(null);
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";
102
186
  const editing = entries.find((entry) => entry.editing);
103
187
 
104
188
  const handleKeyDownCapture = (e: KeyboardEvent<HTMLDivElement>): void => {
@@ -112,48 +196,112 @@ export const MultiCombobox = forwardRef<MultiComboboxHandle, MultiComboboxProps>
112
196
  onRemoveEntry(last.key);
113
197
  };
114
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
+
115
266
  return (
116
267
  <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)}
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 }}
151
275
  >
152
- {entry.title}
153
- </Chip>
154
- </li>
155
- ))}
156
- </ul>
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
+ )}
157
305
 
158
306
  {editing && renderEntryEditor ? (
159
307
  <div data-testid={`${testid}-editor`} style={{ marginTop: 8 }}>
@@ -25,7 +25,12 @@ export type {
25
25
  export { Chip } from "./Chip";
26
26
  export type { ChipProps, ChipTone } from "./Chip";
27
27
  export { MultiCombobox } from "./MultiCombobox";
28
- export type { MultiComboboxProps, MultiComboboxEntry, MultiComboboxHandle } from "./MultiCombobox";
28
+ export type {
29
+ MultiComboboxProps,
30
+ MultiComboboxEntry,
31
+ MultiComboboxHandle,
32
+ MultiComboboxLayout,
33
+ } from "./MultiCombobox";
29
34
  export { Textarea } from "./Textarea";
30
35
  export type { TextareaProps } from "./Textarea";
31
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
+ }