@marimo-team/islands 0.23.10-dev26 → 0.23.10-dev28
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/dist/{code-visibility-Rcdlclvw.js → code-visibility-Dy2BhYTf.js} +1334 -1044
- package/dist/main.js +7143 -7119
- package/dist/{reveal-component-xsFYQVKT.js → reveal-component-B5af53Y1.js} +16 -16
- package/package.json +1 -1
- package/src/components/data-table/filter-by-values-picker.tsx +39 -17
- package/src/components/data-table/filter-pills.tsx +1 -1
- package/src/components/ui/combobox.tsx +51 -32
- package/src/components/ui/select-core/__tests__/use-select-list.test.ts +294 -0
- package/src/components/ui/select-core/__tests__/utils.test.ts +222 -0
- package/src/components/ui/select-core/index.ts +16 -0
- package/src/components/ui/select-core/option-row.tsx +33 -0
- package/src/components/ui/select-core/render-slot.ts +20 -0
- package/src/components/ui/select-core/select-list.tsx +248 -0
- package/src/components/ui/select-core/types.ts +44 -0
- package/src/components/ui/select-core/use-select-list.ts +347 -0
- package/src/components/ui/select-core/utils.ts +121 -0
- package/src/plugins/impl/MultiselectPlugin.tsx +19 -142
- package/src/plugins/impl/SearchableSelect.tsx +16 -97
- package/src/plugins/impl/__tests__/DropdownPlugin.test.tsx +5 -2
- package/src/plugins/impl/__tests__/MultiSelectPlugin.test.ts +1 -1
- package/src/plugins/impl/multiselectFilterFn.tsx +0 -22
- /package/src/components/{data-table → ui}/value-chips.tsx +0 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { Option } from "../types";
|
|
4
|
+
import {
|
|
5
|
+
deselectMatching,
|
|
6
|
+
getBulkActions,
|
|
7
|
+
getVisibleOptions,
|
|
8
|
+
multiselectFilterFn,
|
|
9
|
+
selectMatching,
|
|
10
|
+
} from "../utils";
|
|
11
|
+
|
|
12
|
+
describe("multiselectFilterFn", () => {
|
|
13
|
+
it("matches when all query words appear in the option (any order)", () => {
|
|
14
|
+
expect(multiselectFilterFn("foo bar", "bar foo")).toBe(1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("does not match a partial word", () => {
|
|
18
|
+
expect(multiselectFilterFn("foo bar", "foob")).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("selectMatching", () => {
|
|
23
|
+
it("adds only unselected matches, existing first then additions", () => {
|
|
24
|
+
expect(selectMatching(["a"], ["a", "b", "c"])).toEqual(["a", "b", "c"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("keeps selections outside the filter untouched", () => {
|
|
28
|
+
expect(selectMatching(["x", "y"], ["a", "b"])).toEqual([
|
|
29
|
+
"x",
|
|
30
|
+
"y",
|
|
31
|
+
"a",
|
|
32
|
+
"b",
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("is generic over the value type", () => {
|
|
37
|
+
expect(selectMatching<number>([1], [1, 2])).toEqual([1, 2]);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("deselectMatching", () => {
|
|
42
|
+
it("removes only the matching items", () => {
|
|
43
|
+
expect(deselectMatching(["a", "b", "c"], ["a", "c"])).toEqual(["b"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("leaves out-of-filter selections intact", () => {
|
|
47
|
+
expect(deselectMatching(["x", "a"], ["a", "b"])).toEqual(["x"]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const opt = (value: string): Option<string> => ({ value, label: value });
|
|
52
|
+
|
|
53
|
+
describe("getVisibleOptions", () => {
|
|
54
|
+
it("pins selected options first, both groups in option order", () => {
|
|
55
|
+
const options = ["a", "b", "c", "d"].map(opt);
|
|
56
|
+
const { visibleOptions, pinnedCount } = getVisibleOptions(
|
|
57
|
+
options,
|
|
58
|
+
new Set(["b", "d"]),
|
|
59
|
+
);
|
|
60
|
+
expect(visibleOptions.map((o) => o.value)).toEqual(["b", "d", "a", "c"]);
|
|
61
|
+
expect(pinnedCount).toBe(2);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns options unchanged when nothing is pinned", () => {
|
|
65
|
+
const options = ["a", "b", "c"].map(opt);
|
|
66
|
+
const { visibleOptions, pinnedCount } = getVisibleOptions(
|
|
67
|
+
options,
|
|
68
|
+
new Set(),
|
|
69
|
+
);
|
|
70
|
+
expect(visibleOptions.map((o) => o.value)).toEqual(["a", "b", "c"]);
|
|
71
|
+
expect(pinnedCount).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("ignores pinned values that are not in options", () => {
|
|
75
|
+
const options = ["a", "b"].map(opt);
|
|
76
|
+
const { visibleOptions, pinnedCount } = getVisibleOptions(
|
|
77
|
+
options,
|
|
78
|
+
new Set(["b", "ghost"]),
|
|
79
|
+
);
|
|
80
|
+
expect(visibleOptions.map((o) => o.value)).toEqual(["b", "a"]);
|
|
81
|
+
expect(pinnedCount).toBe(1);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const bulkBase = {
|
|
86
|
+
options: ["a", "b", "c", "d"].map(opt),
|
|
87
|
+
value: [] as string[],
|
|
88
|
+
searchQuery: "",
|
|
89
|
+
maxSelections: undefined as number | undefined,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
describe("getBulkActions", () => {
|
|
93
|
+
it("returns no specs for 2 or fewer options", () => {
|
|
94
|
+
expect(
|
|
95
|
+
getBulkActions({
|
|
96
|
+
...bulkBase,
|
|
97
|
+
options: ["a", "b"].map(opt),
|
|
98
|
+
filteredOptions: ["a", "b"].map(opt),
|
|
99
|
+
}),
|
|
100
|
+
).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns no specs when maxSelections is 1", () => {
|
|
104
|
+
expect(
|
|
105
|
+
getBulkActions({
|
|
106
|
+
...bulkBase,
|
|
107
|
+
filteredOptions: bulkBase.options,
|
|
108
|
+
maxSelections: 1,
|
|
109
|
+
}),
|
|
110
|
+
).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("idle: select-all then deselect-all, both enabled", () => {
|
|
114
|
+
expect(
|
|
115
|
+
getBulkActions({
|
|
116
|
+
...bulkBase,
|
|
117
|
+
value: ["a"],
|
|
118
|
+
filteredOptions: bulkBase.options,
|
|
119
|
+
}),
|
|
120
|
+
).toEqual([
|
|
121
|
+
{ kind: "select-all", enabled: true },
|
|
122
|
+
{ kind: "deselect-all", enabled: true },
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("idle: select-all disabled when everything is selected", () => {
|
|
127
|
+
expect(
|
|
128
|
+
getBulkActions({
|
|
129
|
+
...bulkBase,
|
|
130
|
+
value: ["a", "b", "c", "d"],
|
|
131
|
+
filteredOptions: bulkBase.options,
|
|
132
|
+
}),
|
|
133
|
+
).toEqual([
|
|
134
|
+
{ kind: "select-all", enabled: false },
|
|
135
|
+
{ kind: "deselect-all", enabled: true },
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("searching: items reflect unselected vs selected matches", () => {
|
|
140
|
+
expect(
|
|
141
|
+
getBulkActions({
|
|
142
|
+
...bulkBase,
|
|
143
|
+
value: ["a"],
|
|
144
|
+
searchQuery: "x",
|
|
145
|
+
filteredOptions: ["a", "b", "c"].map(opt),
|
|
146
|
+
}),
|
|
147
|
+
).toEqual([
|
|
148
|
+
{ kind: "select-matching", items: ["b", "c"].map(opt) },
|
|
149
|
+
{ kind: "deselect-matching", items: ["a"].map(opt) },
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("searching with no matches: no specs", () => {
|
|
154
|
+
expect(
|
|
155
|
+
getBulkActions({ ...bulkBase, searchQuery: "zzz", filteredOptions: [] }),
|
|
156
|
+
).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("searching: omits select-matching when all matches are already selected", () => {
|
|
160
|
+
expect(
|
|
161
|
+
getBulkActions({
|
|
162
|
+
...bulkBase,
|
|
163
|
+
value: ["a", "b"],
|
|
164
|
+
searchQuery: "x",
|
|
165
|
+
filteredOptions: ["a", "b"].map(opt),
|
|
166
|
+
}),
|
|
167
|
+
).toEqual([{ kind: "deselect-matching", items: ["a", "b"].map(opt) }]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("maxSelections > 1: deselect-side only, idle and searching", () => {
|
|
171
|
+
expect(
|
|
172
|
+
getBulkActions({
|
|
173
|
+
...bulkBase,
|
|
174
|
+
value: ["a"],
|
|
175
|
+
filteredOptions: bulkBase.options,
|
|
176
|
+
maxSelections: 3,
|
|
177
|
+
}),
|
|
178
|
+
).toEqual([{ kind: "deselect-all", enabled: true }]);
|
|
179
|
+
|
|
180
|
+
expect(
|
|
181
|
+
getBulkActions({
|
|
182
|
+
...bulkBase,
|
|
183
|
+
value: ["a"],
|
|
184
|
+
searchQuery: "x",
|
|
185
|
+
filteredOptions: ["a", "b"].map(opt),
|
|
186
|
+
maxSelections: 3,
|
|
187
|
+
}),
|
|
188
|
+
).toEqual([{ kind: "deselect-matching", items: ["a"].map(opt) }]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("idle: select-all is disabled when only disabled options remain unselected", () => {
|
|
192
|
+
const options = [opt("a"), { value: "b", label: "b", disabled: true }];
|
|
193
|
+
expect(
|
|
194
|
+
getBulkActions({
|
|
195
|
+
...bulkBase,
|
|
196
|
+
options: [...options, opt("c")],
|
|
197
|
+
value: ["a", "c"],
|
|
198
|
+
filteredOptions: [...options, opt("c")],
|
|
199
|
+
}),
|
|
200
|
+
).toEqual([
|
|
201
|
+
{ kind: "select-all", enabled: false },
|
|
202
|
+
{ kind: "deselect-all", enabled: true },
|
|
203
|
+
]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("searching: select-matching omits disabled matches", () => {
|
|
207
|
+
const filteredOptions = [
|
|
208
|
+
opt("a"),
|
|
209
|
+
{ value: "b", label: "b", disabled: true },
|
|
210
|
+
opt("c"),
|
|
211
|
+
];
|
|
212
|
+
expect(
|
|
213
|
+
getBulkActions({
|
|
214
|
+
...bulkBase,
|
|
215
|
+
options: filteredOptions,
|
|
216
|
+
value: [],
|
|
217
|
+
searchQuery: "x",
|
|
218
|
+
filteredOptions,
|
|
219
|
+
}),
|
|
220
|
+
).toEqual([{ kind: "select-matching", items: ["a", "c"].map(opt) }]);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
export {
|
|
3
|
+
SelectList,
|
|
4
|
+
VIRTUALIZE_THRESHOLD,
|
|
5
|
+
VIRTUALIZED_LIST_HEIGHT,
|
|
6
|
+
} from "./select-list";
|
|
7
|
+
export { useSelectList } from "./use-select-list";
|
|
8
|
+
export { renderSlot, type Slot } from "./render-slot";
|
|
9
|
+
export type { BulkAction, BulkActionSpec, Option, OptionState } from "./types";
|
|
10
|
+
export {
|
|
11
|
+
deselectMatching,
|
|
12
|
+
getBulkActions,
|
|
13
|
+
getVisibleOptions,
|
|
14
|
+
multiselectFilterFn,
|
|
15
|
+
selectMatching,
|
|
16
|
+
} from "./utils";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { ComboboxItem } from "../combobox";
|
|
4
|
+
import type { Option, OptionState } from "./types";
|
|
5
|
+
|
|
6
|
+
interface OptionRowProps<V> {
|
|
7
|
+
option: Option<V>;
|
|
8
|
+
checked: boolean;
|
|
9
|
+
renderOption?: (option: Option<V>, state: OptionState) => React.ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function OptionRowImpl<V>({
|
|
13
|
+
option,
|
|
14
|
+
checked,
|
|
15
|
+
renderOption,
|
|
16
|
+
}: OptionRowProps<V>): React.JSX.Element {
|
|
17
|
+
return (
|
|
18
|
+
<ComboboxItem
|
|
19
|
+
data-slot="select-option"
|
|
20
|
+
data-checked={checked || undefined}
|
|
21
|
+
// Selection identity, not the display string: the Combobox tracks
|
|
22
|
+
// `isSelected`/`onSelect` by this value and echoes it back through
|
|
23
|
+
// `onValueChange`. The cast bridges the unconstrained option type to
|
|
24
|
+
// ComboboxItem's stringifiable-value bound.
|
|
25
|
+
value={option.value as string | number}
|
|
26
|
+
disabled={option.disabled}
|
|
27
|
+
>
|
|
28
|
+
{renderOption ? renderOption(option, { checked }) : option.label}
|
|
29
|
+
</ComboboxItem>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const OptionRow = React.memo(OptionRowImpl) as typeof OptionRowImpl;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type React from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A customizable piece of UI: either fixed content, or a function that builds it
|
|
7
|
+
* from `A`. The thunk form lets callers defer work (or read render-time args)
|
|
8
|
+
* until the slot is actually shown.
|
|
9
|
+
*/
|
|
10
|
+
export type Slot<A extends unknown[] = []> =
|
|
11
|
+
| React.ReactNode
|
|
12
|
+
| ((...args: A) => React.ReactNode);
|
|
13
|
+
|
|
14
|
+
/** Resolve a {@link Slot} to its node, calling the thunk form with `args`. */
|
|
15
|
+
export function renderSlot<A extends unknown[]>(
|
|
16
|
+
slot: Slot<A>,
|
|
17
|
+
...args: A
|
|
18
|
+
): React.ReactNode {
|
|
19
|
+
return typeof slot === "function" ? slot(...args) : slot;
|
|
20
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import type * as React from "react";
|
|
3
|
+
import { Virtuoso } from "react-virtuoso";
|
|
4
|
+
import { CompactChipRow } from "@/components/ui/value-chips";
|
|
5
|
+
import { cn } from "@/utils/cn";
|
|
6
|
+
import { Combobox } from "../combobox";
|
|
7
|
+
import { CommandItem, CommandSeparator } from "../command";
|
|
8
|
+
import { OptionRow } from "./option-row";
|
|
9
|
+
import { renderSlot, type Slot } from "./render-slot";
|
|
10
|
+
import type { BulkAction, Option, OptionState } from "./types";
|
|
11
|
+
import { useSelectList } from "./use-select-list";
|
|
12
|
+
|
|
13
|
+
/** Above this many options the list virtualizes. */
|
|
14
|
+
export const VIRTUALIZE_THRESHOLD = 200;
|
|
15
|
+
|
|
16
|
+
/** Fixed pixel height of the virtualized viewport (Virtuoso requires one). */
|
|
17
|
+
export const VIRTUALIZED_LIST_HEIGHT = 200;
|
|
18
|
+
|
|
19
|
+
function bulkActionLabel<V>(action: BulkAction<V>): string {
|
|
20
|
+
switch (action.kind) {
|
|
21
|
+
case "select-all":
|
|
22
|
+
return "Select all";
|
|
23
|
+
case "deselect-all":
|
|
24
|
+
return "Deselect all";
|
|
25
|
+
case "select-matching":
|
|
26
|
+
return `Select ${action.items.length} matching`;
|
|
27
|
+
case "deselect-matching":
|
|
28
|
+
return `Deselect ${action.items.length} matching`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SelectListProps<V> {
|
|
33
|
+
options: Array<Option<V>>;
|
|
34
|
+
/** Current selection: an array when `multiple`, otherwise a single value or null. */
|
|
35
|
+
value: V[] | V | null;
|
|
36
|
+
onChange: (next: V[] | V | null) => void;
|
|
37
|
+
/** Multi-select when true; single-select (replace-on-pick) when false. */
|
|
38
|
+
multiple: boolean;
|
|
39
|
+
/** Cap on multi-select size. At the cap, picking another drops the oldest. */
|
|
40
|
+
maxSelections?: number;
|
|
41
|
+
/** Single-select only: re-picking the current value clears it to null. */
|
|
42
|
+
allowSelectNone?: boolean;
|
|
43
|
+
/** Float the (frozen) selection to the top of the idle menu, with a separator. */
|
|
44
|
+
pinSelected?: boolean;
|
|
45
|
+
/** Summarize the selection in the trigger as a compact chip row instead of "N selected". */
|
|
46
|
+
compactChipTrigger?: boolean;
|
|
47
|
+
placeholder?: string;
|
|
48
|
+
disabled?: boolean;
|
|
49
|
+
fullWidth?: boolean;
|
|
50
|
+
className?: string;
|
|
51
|
+
id?: string;
|
|
52
|
+
"data-testid"?: string;
|
|
53
|
+
/** Renders the row content; the core owns the interactive container. */
|
|
54
|
+
renderOption?: (option: Option<V>, state: OptionState) => React.ReactNode;
|
|
55
|
+
/** Shown when no option matches the current query. */
|
|
56
|
+
renderEmpty?: Slot;
|
|
57
|
+
/**
|
|
58
|
+
* Virtualize once the visible option count exceeds this. Lower it when
|
|
59
|
+
* `renderOption` produces expensive rows so they virtualize sooner.
|
|
60
|
+
*/
|
|
61
|
+
virtualizeThreshold?: number;
|
|
62
|
+
/** Fixed pixel height of the virtualized viewport. */
|
|
63
|
+
virtualizedHeight?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function SelectList<V>(props: SelectListProps<V>): React.JSX.Element {
|
|
67
|
+
const {
|
|
68
|
+
options,
|
|
69
|
+
value,
|
|
70
|
+
onChange,
|
|
71
|
+
multiple,
|
|
72
|
+
maxSelections,
|
|
73
|
+
allowSelectNone,
|
|
74
|
+
pinSelected = false,
|
|
75
|
+
compactChipTrigger = false,
|
|
76
|
+
placeholder = "Select...",
|
|
77
|
+
disabled = false,
|
|
78
|
+
fullWidth = false,
|
|
79
|
+
className,
|
|
80
|
+
id,
|
|
81
|
+
renderOption,
|
|
82
|
+
renderEmpty = "Nothing found.",
|
|
83
|
+
virtualizeThreshold = VIRTUALIZE_THRESHOLD,
|
|
84
|
+
virtualizedHeight = VIRTUALIZED_LIST_HEIGHT,
|
|
85
|
+
} = props;
|
|
86
|
+
|
|
87
|
+
const list = useSelectList<V>({
|
|
88
|
+
options,
|
|
89
|
+
value,
|
|
90
|
+
onChange,
|
|
91
|
+
multiple,
|
|
92
|
+
maxSelections,
|
|
93
|
+
allowSelectNone,
|
|
94
|
+
pinSelected,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const handleComboChange = (next: V[] | V | null): void => {
|
|
98
|
+
if (!multiple) {
|
|
99
|
+
if (next == null && !allowSelectNone) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
onChange(next);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
let arr = Array.isArray(next) ? next : [];
|
|
106
|
+
if (maxSelections != null && arr.length > maxSelections) {
|
|
107
|
+
arr = arr.slice(-maxSelections);
|
|
108
|
+
}
|
|
109
|
+
onChange(arr);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Bulk rows render as raw CommandItem (not ComboboxItem) so Combobox's per-item
|
|
113
|
+
// toggle doesn't intercept them — only the action's own `run` fires on select.
|
|
114
|
+
const bulkRows: React.ReactNode[] = list.bulkActions.map((action) => {
|
|
115
|
+
const disabled =
|
|
116
|
+
"enabled" in action ? !action.enabled : action.items.length === 0;
|
|
117
|
+
return (
|
|
118
|
+
<CommandItem
|
|
119
|
+
key={action.kind}
|
|
120
|
+
data-slot="select-bulk"
|
|
121
|
+
className="pl-6 m-1 py-1"
|
|
122
|
+
value={`__bulk_${action.kind}`}
|
|
123
|
+
disabled={disabled}
|
|
124
|
+
onSelect={() => {
|
|
125
|
+
if (!disabled) {
|
|
126
|
+
action.run();
|
|
127
|
+
}
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{bulkActionLabel(action)}
|
|
131
|
+
</CommandItem>
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
if (bulkRows.length > 0) {
|
|
135
|
+
bulkRows.push(
|
|
136
|
+
<CommandSeparator key="_bulk_separator" data-slot="select-separator" />,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Rendered at the start of the first unpinned row, not after the last pinned row,
|
|
141
|
+
// so the menu-separator's `last:hidden` Tailwind variant doesn't hide it inside
|
|
142
|
+
// Virtuoso's per-item wrapper.
|
|
143
|
+
const pinnedSeparator = (index: number): React.ReactNode =>
|
|
144
|
+
list.pinnedCount > 0 &&
|
|
145
|
+
index === list.pinnedCount &&
|
|
146
|
+
list.pinnedCount < list.visibleOptions.length ? (
|
|
147
|
+
<CommandSeparator key="_pinned_separator" data-slot="select-separator" />
|
|
148
|
+
) : null;
|
|
149
|
+
|
|
150
|
+
const renderItems = () => {
|
|
151
|
+
if (list.visibleOptions.length > virtualizeThreshold) {
|
|
152
|
+
return (
|
|
153
|
+
<Virtuoso
|
|
154
|
+
data-slot="select-list"
|
|
155
|
+
style={{ height: virtualizedHeight }}
|
|
156
|
+
totalCount={list.visibleOptions.length}
|
|
157
|
+
overscan={50}
|
|
158
|
+
itemContent={(i: number) => {
|
|
159
|
+
const option = list.visibleOptions[i];
|
|
160
|
+
return (
|
|
161
|
+
<>
|
|
162
|
+
{i === 0 && bulkRows}
|
|
163
|
+
{pinnedSeparator(i)}
|
|
164
|
+
<OptionRow
|
|
165
|
+
option={option}
|
|
166
|
+
checked={list.isChecked(option.value)}
|
|
167
|
+
renderOption={renderOption}
|
|
168
|
+
/>
|
|
169
|
+
</>
|
|
170
|
+
);
|
|
171
|
+
}}
|
|
172
|
+
/>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const rows = list.visibleOptions.flatMap((option, i) => {
|
|
177
|
+
const separator = pinnedSeparator(i);
|
|
178
|
+
const row = (
|
|
179
|
+
<OptionRow
|
|
180
|
+
key={String(option.value)}
|
|
181
|
+
option={option}
|
|
182
|
+
checked={list.isChecked(option.value)}
|
|
183
|
+
renderOption={renderOption}
|
|
184
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
return separator ? [separator, row] : [row];
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<>
|
|
191
|
+
{bulkRows}
|
|
192
|
+
{rows}
|
|
193
|
+
</>
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const renderTriggerValue = (current: V[] | V | null): React.ReactNode => {
|
|
198
|
+
const items = Array.isArray(current)
|
|
199
|
+
? current
|
|
200
|
+
: current != null
|
|
201
|
+
? [current]
|
|
202
|
+
: [];
|
|
203
|
+
if (items.length === 0) {
|
|
204
|
+
return <span className="text-muted-foreground">{placeholder}</span>;
|
|
205
|
+
}
|
|
206
|
+
return <CompactChipRow items={items.map(list.labelOf)} max={3} />;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Props shared by both branches. `multiple`/`value`/`onValueChange` are the
|
|
210
|
+
// Combobox's discriminant trio, so they're set per-branch below with the
|
|
211
|
+
// literal `multiple` that matches each value/handler shape.
|
|
212
|
+
const comboboxProps = {
|
|
213
|
+
"data-slot": "select-root",
|
|
214
|
+
"data-testid": props["data-testid"],
|
|
215
|
+
displayValue: (option: V) => list.labelOf(option),
|
|
216
|
+
renderValue: compactChipTrigger ? renderTriggerValue : undefined,
|
|
217
|
+
placeholder,
|
|
218
|
+
className: cn({ "w-full": fullWidth }, className),
|
|
219
|
+
shouldFilter: false as const,
|
|
220
|
+
search: list.searchQuery,
|
|
221
|
+
onSearchChange: list.setSearchQuery,
|
|
222
|
+
open: list.open,
|
|
223
|
+
onOpenChange: list.setOpen,
|
|
224
|
+
emptyState: renderSlot(renderEmpty),
|
|
225
|
+
disabled,
|
|
226
|
+
id,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return multiple ? (
|
|
230
|
+
<Combobox<V>
|
|
231
|
+
{...comboboxProps}
|
|
232
|
+
multiple={true}
|
|
233
|
+
value={value as V[] | null}
|
|
234
|
+
onValueChange={handleComboChange}
|
|
235
|
+
>
|
|
236
|
+
{renderItems()}
|
|
237
|
+
</Combobox>
|
|
238
|
+
) : (
|
|
239
|
+
<Combobox<V>
|
|
240
|
+
{...comboboxProps}
|
|
241
|
+
multiple={false}
|
|
242
|
+
value={value as V | null}
|
|
243
|
+
onValueChange={handleComboChange}
|
|
244
|
+
>
|
|
245
|
+
{renderItems()}
|
|
246
|
+
</Combobox>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
/** A single selectable item. */
|
|
4
|
+
export interface Option<V = string> {
|
|
5
|
+
/** Selection identity; what the adapter serializes. */
|
|
6
|
+
value: V;
|
|
7
|
+
/** Human-readable string used for display, filtering, and chip text. */
|
|
8
|
+
label: string;
|
|
9
|
+
/** Whether the option can be selected. */
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
/** Arbitrary per-row payload (e.g. `{ count }`) read by render slots. */
|
|
12
|
+
data?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Live state of an option, passed to `renderOption` so custom rows can reflect
|
|
17
|
+
* selection. Keyboard highlight is exposed on the row element as
|
|
18
|
+
* `aria-selected` / `data-selected`, so custom rows style it via CSS.
|
|
19
|
+
*/
|
|
20
|
+
export interface OptionState {
|
|
21
|
+
/** Whether the option is currently selected. */
|
|
22
|
+
checked: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Pure-data description of one bulk row to render above the option list. The
|
|
27
|
+
* hook decides which specs exist for the current search/cap state; the facade
|
|
28
|
+
* decides labels and markup.
|
|
29
|
+
*
|
|
30
|
+
* - `select-all` / `deselect-all` act on the whole option list — the facade
|
|
31
|
+
* already has it as a prop, so the spec just carries `enabled` for the
|
|
32
|
+
* disabled-but-visible state (e.g. everything already picked).
|
|
33
|
+
* - `select-matching` / `deselect-matching` act on the search-filtered subset,
|
|
34
|
+
* which the facade can't see; `items` carries that subset so the facade can
|
|
35
|
+
* label the row ("Select N matching") and the slot is omitted when empty.
|
|
36
|
+
*/
|
|
37
|
+
export type BulkActionSpec<V> =
|
|
38
|
+
| { kind: "select-all"; enabled: boolean }
|
|
39
|
+
| { kind: "deselect-all"; enabled: boolean }
|
|
40
|
+
| { kind: "select-matching"; items: Array<Option<V>> }
|
|
41
|
+
| { kind: "deselect-matching"; items: Array<Option<V>> };
|
|
42
|
+
|
|
43
|
+
/** A renderable bulk action: spec + the closure that applies it on click. */
|
|
44
|
+
export type BulkAction<V> = BulkActionSpec<V> & { run: () => void };
|