@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.
- package/dist/{code-visibility-CXkMXcdB.js → code-visibility-DI2QSiFC.js} +1646 -1179
- package/dist/main.js +7312 -7424
- package/dist/{reveal-component-dIolR_34.js → reveal-component-BA7HaWOX.js} +410 -333
- 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/editor/cell/code/language-toggle.tsx +7 -1
- package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +20 -0
- package/src/components/editor/renderers/slides-layout/types.ts +1 -0
- package/src/components/slides/__tests__/reveal-component.test.ts +425 -0
- package/src/components/slides/reveal-component.tsx +283 -61
- package/src/components/slides/slide-cell-view.tsx +26 -2
- package/src/components/slides/slide-form.tsx +26 -4
- 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
|
@@ -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 = (
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 = (
|
|
123
|
-
|
|
128
|
+
const handleSelect = useCallback(
|
|
129
|
+
(selectedValue: unknown) => {
|
|
130
|
+
let newValue: TValue | TValue[] | null = selectedValue as TValue;
|
|
124
131
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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 = [
|
|
141
|
+
newValue = [newValue];
|
|
132
142
|
}
|
|
133
|
-
} else {
|
|
134
|
-
newValue =
|
|
143
|
+
} else if (value === selectedValue) {
|
|
144
|
+
newValue = null;
|
|
135
145
|
}
|
|
136
|
-
} else if (value === selectedValue) {
|
|
137
|
-
newValue = null;
|
|
138
|
-
}
|
|
139
146
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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">
|
|
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={
|
|
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
|
-
{
|
|
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";
|