@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
|
@@ -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
|
-
}
|
|
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
|
-
|
|
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,
|
package/src/primitives/index.ts
CHANGED
|
@@ -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";
|