@marimo-team/islands 0.23.10-dev25 → 0.23.10-dev27

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.
Files changed (29) hide show
  1. package/dist/{code-visibility-CXkMXcdB.js → code-visibility-DI2QSiFC.js} +1646 -1179
  2. package/dist/main.js +7312 -7424
  3. package/dist/{reveal-component-dIolR_34.js → reveal-component-BA7HaWOX.js} +410 -333
  4. package/package.json +1 -1
  5. package/src/components/data-table/filter-by-values-picker.tsx +39 -17
  6. package/src/components/data-table/filter-pills.tsx +1 -1
  7. package/src/components/editor/cell/code/language-toggle.tsx +7 -1
  8. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +20 -0
  9. package/src/components/editor/renderers/slides-layout/types.ts +1 -0
  10. package/src/components/slides/__tests__/reveal-component.test.ts +425 -0
  11. package/src/components/slides/reveal-component.tsx +283 -61
  12. package/src/components/slides/slide-cell-view.tsx +26 -2
  13. package/src/components/slides/slide-form.tsx +26 -4
  14. package/src/components/ui/combobox.tsx +51 -32
  15. package/src/components/ui/select-core/__tests__/use-select-list.test.ts +294 -0
  16. package/src/components/ui/select-core/__tests__/utils.test.ts +222 -0
  17. package/src/components/ui/select-core/index.ts +16 -0
  18. package/src/components/ui/select-core/option-row.tsx +33 -0
  19. package/src/components/ui/select-core/render-slot.ts +20 -0
  20. package/src/components/ui/select-core/select-list.tsx +248 -0
  21. package/src/components/ui/select-core/types.ts +44 -0
  22. package/src/components/ui/select-core/use-select-list.ts +347 -0
  23. package/src/components/ui/select-core/utils.ts +121 -0
  24. package/src/plugins/impl/MultiselectPlugin.tsx +19 -142
  25. package/src/plugins/impl/SearchableSelect.tsx +16 -97
  26. package/src/plugins/impl/__tests__/DropdownPlugin.test.tsx +5 -2
  27. package/src/plugins/impl/__tests__/MultiSelectPlugin.test.ts +1 -1
  28. package/src/plugins/impl/multiselectFilterFn.tsx +0 -22
  29. /package/src/components/{data-table → ui}/value-chips.tsx +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useControllableState } from "@radix-ui/react-use-controllable-state";
4
4
  import { Check, ChevronDownIcon, XCircle } from "lucide-react";
5
- import React, { createContext } from "react";
5
+ import React, { createContext, useCallback, useMemo } from "react";
6
6
  import { cn } from "../../utils/cn";
7
7
  import { Functions } from "../../utils/functions";
8
8
  import { Badge } from "./badge";
@@ -40,6 +40,8 @@ interface ComboboxCommonProps<TValue> {
40
40
  id?: string;
41
41
  keepPopoverOpenOnSelect?: boolean;
42
42
  disabled?: boolean;
43
+ /** Override the trigger contents with a node (e.g. a compact chip summary). */
44
+ renderValue?: (value: TValue[] | TValue | null) => React.ReactNode;
43
45
  }
44
46
 
45
47
  type ComboboxFilterProps =
@@ -97,6 +99,7 @@ export const Combobox = <TValue,>({
97
99
  keepPopoverOpenOnSelect,
98
100
  id,
99
101
  disabled = false,
102
+ renderValue,
100
103
  ...rest
101
104
  }: ComboboxProps<TValue>) => {
102
105
  const [open = false, setOpen] = useControllableState({
@@ -112,39 +115,45 @@ export const Combobox = <TValue,>({
112
115
  },
113
116
  });
114
117
 
115
- const isSelected = (selectedValue: unknown) => {
116
- if (Array.isArray(value)) {
117
- return value.includes(selectedValue as TValue);
118
- }
119
- return value === selectedValue;
120
- };
118
+ const isSelected = useCallback(
119
+ (selectedValue: unknown) => {
120
+ if (Array.isArray(value)) {
121
+ return value.includes(selectedValue as TValue);
122
+ }
123
+ return value === selectedValue;
124
+ },
125
+ [value],
126
+ );
121
127
 
122
- const handleSelect = (selectedValue: unknown) => {
123
- let newValue: TValue | TValue[] | null = selectedValue as TValue;
128
+ const handleSelect = useCallback(
129
+ (selectedValue: unknown) => {
130
+ let newValue: TValue | TValue[] | null = selectedValue as TValue;
124
131
 
125
- if (multiple) {
126
- if (Array.isArray(value)) {
127
- if (value.includes(newValue)) {
128
- const newArr = value.filter((val) => val !== selectedValue);
129
- newValue = newArr.length > 0 ? newArr : [];
132
+ if (multiple) {
133
+ if (Array.isArray(value)) {
134
+ if (value.includes(newValue)) {
135
+ const newArr = value.filter((val) => val !== selectedValue);
136
+ newValue = newArr.length > 0 ? newArr : [];
137
+ } else {
138
+ newValue = [...value, newValue];
139
+ }
130
140
  } else {
131
- newValue = [...value, newValue];
141
+ newValue = [newValue];
132
142
  }
133
- } else {
134
- newValue = [newValue];
143
+ } else if (value === selectedValue) {
144
+ newValue = null;
135
145
  }
136
- } else if (value === selectedValue) {
137
- newValue = null;
138
- }
139
146
 
140
- setValue(newValue);
141
- const keepOpen = keepPopoverOpenOnSelect ?? multiple;
142
- if (!keepOpen) {
143
- setOpen(false);
144
- }
145
- };
147
+ setValue(newValue);
148
+ const keepOpen = keepPopoverOpenOnSelect ?? multiple;
149
+ if (!keepOpen) {
150
+ setOpen(false);
151
+ }
152
+ },
153
+ [keepPopoverOpenOnSelect, multiple, value, setValue, setOpen],
154
+ );
146
155
 
147
- const renderValue = (): string => {
156
+ const renderValueLabel = (): string => {
148
157
  // If we show chips, we don't want to change the placeholder
149
158
  if (multiple && chips && placeholder) {
150
159
  return placeholder;
@@ -168,6 +177,14 @@ export const Combobox = <TValue,>({
168
177
  return placeholder ?? "--";
169
178
  };
170
179
 
180
+ const comboboxContextValue: ComboboxContextValue = useMemo(
181
+ () => ({
182
+ isSelected,
183
+ onSelect: handleSelect,
184
+ }),
185
+ [isSelected, handleSelect],
186
+ );
187
+
171
188
  return (
172
189
  <div className={cn("relative")} {...rest}>
173
190
  <Popover
@@ -191,7 +208,9 @@ export const Combobox = <TValue,>({
191
208
  aria-expanded={open}
192
209
  aria-disabled={disabled}
193
210
  >
194
- <span className="truncate flex-1 min-w-0">{renderValue()}</span>
211
+ <span className="truncate flex-1 min-w-0">
212
+ {renderValue ? renderValue(value ?? null) : renderValueLabel()}
213
+ </span>
195
214
  <ChevronDownIcon className="ml-3 w-4 h-4 opacity-50 shrink-0" />
196
215
  </button>
197
216
  </PopoverTrigger>
@@ -209,7 +228,7 @@ export const Combobox = <TValue,>({
209
228
  />
210
229
  <CommandList className="max-h-60 py-.5">
211
230
  <CommandEmpty>{emptyState}</CommandEmpty>
212
- <ComboboxContext value={{ isSelected, onSelect: handleSelect }}>
231
+ <ComboboxContext value={comboboxContextValue}>
213
232
  {children}
214
233
  </ComboboxContext>
215
234
  </CommandList>
@@ -277,12 +296,14 @@ export const ComboboxItem = React.forwardRef(
277
296
  ? value.value
278
297
  : String(value);
279
298
  const context = React.use(ComboboxContext);
299
+ const isOptionSelected = context.isSelected(value);
280
300
 
281
301
  return (
282
302
  <CommandItem
283
303
  ref={ref}
284
304
  className={cn("pl-6 m-1 py-1", className)}
285
305
  role="option"
306
+ aria-selected={isOptionSelected}
286
307
  value={valueAsString}
287
308
  disabled={disabled}
288
309
  onSelect={() => {
@@ -290,9 +311,7 @@ export const ComboboxItem = React.forwardRef(
290
311
  onSelect?.(value);
291
312
  }}
292
313
  >
293
- {context.isSelected(value) && (
294
- <Check className="absolute left-1 h-4 w-4" />
295
- )}
314
+ {isOptionSelected && <Check className="absolute left-1 h-4 w-4" />}
296
315
  {children}
297
316
  </CommandItem>
298
317
  );
@@ -0,0 +1,294 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { act, renderHook } from "@testing-library/react";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import type { BulkAction, Option } from "../types";
5
+ import { useSelectList } from "../use-select-list";
6
+
7
+ const opts: Array<Option<string>> = [
8
+ { value: "a", label: "apple" },
9
+ { value: "b", label: "banana" },
10
+ { value: "c", label: "cherry" },
11
+ ];
12
+
13
+ describe("useSelectList - search", () => {
14
+ it("filters visibleOptions by label using the strict word match", () => {
15
+ const { result } = renderHook(() =>
16
+ useSelectList({
17
+ options: opts,
18
+ value: [],
19
+ onChange: vi.fn(),
20
+ multiple: true,
21
+ }),
22
+ );
23
+ act(() => result.current.setSearchQuery("ban"));
24
+ expect(result.current.visibleOptions.map((o) => o.value)).toEqual(["b"]);
25
+ });
26
+
27
+ it("returns all options when the query is empty", () => {
28
+ const { result } = renderHook(() =>
29
+ useSelectList({
30
+ options: opts,
31
+ value: [],
32
+ onChange: vi.fn(),
33
+ multiple: true,
34
+ }),
35
+ );
36
+ expect(result.current.visibleOptions).toHaveLength(3);
37
+ });
38
+
39
+ it("keeps options for any positive filter score, not just 1", () => {
40
+ const { result } = renderHook(() =>
41
+ useSelectList({
42
+ options: opts,
43
+ value: [],
44
+ onChange: vi.fn(),
45
+ multiple: true,
46
+ filterFn: (label) => (label === "apple" ? 0.5 : 0),
47
+ }),
48
+ );
49
+ act(() => result.current.setSearchQuery("anything"));
50
+ expect(result.current.visibleOptions.map((o) => o.value)).toEqual(["a"]);
51
+ });
52
+ });
53
+
54
+ describe("useSelectList - multi toggle", () => {
55
+ it("adds an unselected value", () => {
56
+ const onChange = vi.fn();
57
+ const { result } = renderHook(() =>
58
+ useSelectList({ options: opts, value: ["a"], onChange, multiple: true }),
59
+ );
60
+ act(() => result.current.toggle("b"));
61
+ expect(onChange).toHaveBeenCalledWith(["a", "b"]);
62
+ });
63
+
64
+ it("removes a selected value", () => {
65
+ const onChange = vi.fn();
66
+ const { result } = renderHook(() =>
67
+ useSelectList({
68
+ options: opts,
69
+ value: ["a", "b"],
70
+ onChange,
71
+ multiple: true,
72
+ }),
73
+ );
74
+ act(() => result.current.toggle("a"));
75
+ expect(onChange).toHaveBeenCalledWith(["b"]);
76
+ });
77
+
78
+ it("drops the oldest selection when maxSelections is exceeded", () => {
79
+ const onChange = vi.fn();
80
+ const { result } = renderHook(() =>
81
+ useSelectList({
82
+ options: opts,
83
+ value: ["a", "b"],
84
+ onChange,
85
+ multiple: true,
86
+ maxSelections: 2,
87
+ }),
88
+ );
89
+ act(() => result.current.toggle("c"));
90
+ expect(onChange).toHaveBeenCalledWith(["b", "c"]);
91
+ });
92
+
93
+ it("isChecked reflects membership", () => {
94
+ const { result } = renderHook(() =>
95
+ useSelectList({
96
+ options: opts,
97
+ value: ["a"],
98
+ onChange: vi.fn(),
99
+ multiple: true,
100
+ }),
101
+ );
102
+ expect(result.current.isChecked("a")).toBe(true);
103
+ expect(result.current.isChecked("b")).toBe(false);
104
+ });
105
+ });
106
+
107
+ describe("useSelectList - single toggle", () => {
108
+ it("replaces the value", () => {
109
+ const onChange = vi.fn();
110
+ const { result } = renderHook(() =>
111
+ useSelectList({ options: opts, value: "a", onChange, multiple: false }),
112
+ );
113
+ act(() => result.current.toggle("b"));
114
+ expect(onChange).toHaveBeenCalledWith("b");
115
+ });
116
+
117
+ it("clears to null when allowSelectNone and the value is re-toggled", () => {
118
+ const onChange = vi.fn();
119
+ const { result } = renderHook(() =>
120
+ useSelectList({
121
+ options: opts,
122
+ value: "a",
123
+ onChange,
124
+ multiple: false,
125
+ allowSelectNone: true,
126
+ }),
127
+ );
128
+ act(() => result.current.toggle("a"));
129
+ expect(onChange).toHaveBeenCalledWith(null);
130
+ });
131
+ });
132
+
133
+ describe("useSelectList - pinning + freeze", () => {
134
+ it("pins selected to the top of visibleOptions when open and idle", () => {
135
+ const { result } = renderHook(() =>
136
+ useSelectList({
137
+ options: opts,
138
+ value: ["c"],
139
+ onChange: vi.fn(),
140
+ multiple: true,
141
+ pinSelected: true,
142
+ }),
143
+ );
144
+ act(() => result.current.setOpen(true));
145
+ expect(result.current.visibleOptions.map((o) => o.value)).toEqual([
146
+ "c",
147
+ "a",
148
+ "b",
149
+ ]);
150
+ expect(result.current.pinnedCount).toBe(1);
151
+ });
152
+
153
+ it("freezes pinned order while open: a newly toggled item does not re-pin", () => {
154
+ const onChange = vi.fn();
155
+ const { result, rerender } = renderHook(
156
+ ({ value }) =>
157
+ useSelectList({
158
+ options: opts,
159
+ value,
160
+ onChange,
161
+ multiple: true,
162
+ pinSelected: true,
163
+ }),
164
+ { initialProps: { value: ["c"] as string[] } },
165
+ );
166
+ act(() => result.current.setOpen(true));
167
+ rerender({ value: ["c", "a"] });
168
+ expect(result.current.visibleOptions.map((o) => o.value)).toEqual([
169
+ "c",
170
+ "a",
171
+ "b",
172
+ ]);
173
+ expect(result.current.isChecked("a")).toBe(true);
174
+ });
175
+
176
+ it("re-pins when the search clears", () => {
177
+ const { result, rerender } = renderHook(
178
+ ({ value }) =>
179
+ useSelectList({
180
+ options: opts,
181
+ value,
182
+ onChange: vi.fn(),
183
+ multiple: true,
184
+ pinSelected: true,
185
+ }),
186
+ { initialProps: { value: ["b"] as string[] } },
187
+ );
188
+ act(() => result.current.setOpen(true));
189
+ act(() => result.current.setSearchQuery("a"));
190
+ rerender({ value: ["b", "a"] });
191
+ act(() => result.current.setSearchQuery(""));
192
+ expect(result.current.visibleOptions.map((o) => o.value)).toEqual([
193
+ "b",
194
+ "a",
195
+ "c",
196
+ ]);
197
+ });
198
+
199
+ it("does not pin when pinSelected is false", () => {
200
+ const { result } = renderHook(() =>
201
+ useSelectList({
202
+ options: opts,
203
+ value: ["c"],
204
+ onChange: vi.fn(),
205
+ multiple: true,
206
+ }),
207
+ );
208
+ act(() => result.current.setOpen(true));
209
+ expect(result.current.visibleOptions.map((o) => o.value)).toEqual([
210
+ "a",
211
+ "b",
212
+ "c",
213
+ ]);
214
+ expect(result.current.pinnedCount).toBe(0);
215
+ });
216
+ });
217
+
218
+ describe("useSelectList - bulk", () => {
219
+ const findAction = <K extends BulkAction<string>["kind"]>(
220
+ actions: ReadonlyArray<BulkAction<string>>,
221
+ kind: K,
222
+ ): Extract<BulkAction<string>, { kind: K }> | undefined =>
223
+ actions.find(
224
+ (a): a is Extract<BulkAction<string>, { kind: K }> => a.kind === kind,
225
+ );
226
+
227
+ it("idle: select-all picks every option; deselect-all clears", () => {
228
+ const onChange = vi.fn();
229
+ const { result } = renderHook(() =>
230
+ useSelectList({ options: opts, value: ["a"], onChange, multiple: true }),
231
+ );
232
+ act(() => findAction(result.current.bulkActions, "select-all")?.run());
233
+ expect(onChange).toHaveBeenCalledWith(["a", "b", "c"]);
234
+ onChange.mockClear();
235
+ act(() => findAction(result.current.bulkActions, "deselect-all")?.run());
236
+ expect(onChange).toHaveBeenCalledWith([]);
237
+ });
238
+
239
+ it("idle: select-all skips disabled options and keeps the existing selection", () => {
240
+ const onChange = vi.fn();
241
+ const withDisabled: Array<Option<string>> = [
242
+ { value: "a", label: "apple" },
243
+ { value: "b", label: "banana", disabled: true },
244
+ { value: "c", label: "cherry" },
245
+ ];
246
+ const { result } = renderHook(() =>
247
+ useSelectList({
248
+ options: withDisabled,
249
+ value: ["c"],
250
+ onChange,
251
+ multiple: true,
252
+ }),
253
+ );
254
+ act(() => findAction(result.current.bulkActions, "select-all")?.run());
255
+ expect(onChange).toHaveBeenCalledWith(["c", "a"]);
256
+ });
257
+
258
+ it("searching: select-matching acts only on the matches (additive)", () => {
259
+ const onChange = vi.fn();
260
+ const { result } = renderHook(() =>
261
+ useSelectList({ options: opts, value: ["a"], onChange, multiple: true }),
262
+ );
263
+ act(() => result.current.setSearchQuery("b"));
264
+ act(() => findAction(result.current.bulkActions, "select-matching")?.run());
265
+ expect(onChange).toHaveBeenCalledWith(["a", "b"]);
266
+ });
267
+
268
+ it("exposes idle bulkActions in select-then-deselect order", () => {
269
+ const { result } = renderHook(() =>
270
+ useSelectList({
271
+ options: opts,
272
+ value: ["a"],
273
+ onChange: vi.fn(),
274
+ multiple: true,
275
+ }),
276
+ );
277
+ const kinds = result.current.bulkActions.map((a) => a.kind);
278
+ expect(kinds).toEqual(["select-all", "deselect-all"]);
279
+ const selectAll = findAction(result.current.bulkActions, "select-all");
280
+ expect(selectAll && "enabled" in selectAll && selectAll.enabled).toBe(true);
281
+ });
282
+
283
+ it("bulkActions is empty for single-select", () => {
284
+ const { result } = renderHook(() =>
285
+ useSelectList({
286
+ options: opts,
287
+ value: "a",
288
+ onChange: vi.fn(),
289
+ multiple: false,
290
+ }),
291
+ );
292
+ expect(result.current.bulkActions).toEqual([]);
293
+ });
294
+ });
@@ -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";