@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 +1 -1
- package/src/layout/LeftNavRail.tsx +13 -0
- package/src/primitives/Chip.tsx +191 -0
- package/src/primitives/Combobox.tsx +78 -10
- package/src/primitives/Input.tsx +47 -4
- package/src/primitives/MultiCombobox.tsx +327 -0
- package/src/primitives/index.ts +10 -0
- package/src/styles/index.css +37 -0
package/package.json
CHANGED
|
@@ -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
|
-
}
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
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
|
+
});
|
package/src/primitives/Input.tsx
CHANGED
|
@@ -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
|
+
);
|
package/src/primitives/index.ts
CHANGED
|
@@ -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";
|
package/src/styles/index.css
CHANGED
|
@@ -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
|
+
}
|