@marimo-team/islands 0.23.10-dev3 → 0.23.10-dev31
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/{ConnectedDataExplorerComponent-CyV83R2m.js → ConnectedDataExplorerComponent-DmBropAy.js} +31 -31
- package/dist/{ErrorBoundary-rULOrC_p.js → ErrorBoundary-DpbaKVv7.js} +1 -1
- package/dist/{any-language-editor-DfdpyDv_.js → any-language-editor-DNmoSiWL.js} +20 -20
- package/dist/assets/__vite-browser-external-eshhtsgZ.js +1 -0
- package/dist/assets/worker-CC0Oul9k.js +73 -0
- package/dist/{chat-ui-C1tL1pML.js → chat-ui-D6oraHT2.js} +76 -76
- package/dist/{check-DTbrK0zt.js → check-BCaJeT-J.js} +1 -1
- package/dist/{code-visibility-DfnO0DcH.js → code-visibility-B9qGgair.js} +2166 -1292
- package/dist/{copy-BuQpJEzp.js → copy-UqRYxiOg.js} +33 -33
- package/dist/dist-7QfXoMdB.js +5 -0
- package/dist/{dist-DgnE8F-r.js → dist-A2846XWO.js} +1 -1
- package/dist/dist-BEXXyZig.js +5 -0
- package/dist/{dist-B3pZ0Ab6.js → dist-BR_gyG9L.js} +3 -3
- package/dist/{dist-CcXxepx6.js → dist-BSAt6RhH.js} +27 -27
- package/dist/{dist-Bde4a2kU.js → dist-BY018Paw.js} +8 -8
- package/dist/dist-BYj57OV4.js +5 -0
- package/dist/{dist-CUCNs1ja.js → dist-BaoDKvdy.js} +2 -2
- package/dist/{dist-Cy1WxgBD.js → dist-Bf7SHuNp.js} +5 -5
- package/dist/{dist-Bz_sYWbr.js → dist-Bk75fBZA.js} +2 -2
- package/dist/dist-BlSvQzNr.js +5 -0
- package/dist/{dist-C5VC_yzu.js → dist-BzEzfugY.js} +1 -1
- package/dist/dist-CCBlxAgS.js +8 -0
- package/dist/dist-CIDTVIUf.js +5 -0
- package/dist/{dist-CLUtPrdy.js → dist-CIYBwstr.js} +1 -1
- package/dist/{dist-BotSqB48.js → dist-C_Y3oV3C.js} +12 -12
- package/dist/{dist-BTfv03uy.js → dist-CcWX6tmx.js} +2 -2
- package/dist/{dist-BhM8gdSO.js → dist-CoXAujgg.js} +4 -4
- package/dist/{dist-4j4c7bjm.js → dist-CpxNdDkw.js} +3 -3
- package/dist/dist-CqQyhAM8.js +8 -0
- package/dist/dist-CwRu2Xzh.js +5 -0
- package/dist/{dist-BcuoonNH.js → dist-CxJDU6Bh.js} +9 -9
- package/dist/{dist-DxvORzUR.js → dist-D-W5ny5a.js} +8 -8
- package/dist/dist-D8CDTVgf.js +6 -0
- package/dist/dist-D8DNB0nO.js +8 -0
- package/dist/dist-DL6N_q-A.js +5 -0
- package/dist/{dist-BbbIBDiQ.js → dist-DMjWuVs8.js} +1 -1
- package/dist/dist-DOFbNV_b.js +8 -0
- package/dist/dist-DPrYzMY0.js +6 -0
- package/dist/{dist-h2c8sZvT.js → dist-DZORgqKY.js} +1 -1
- package/dist/{dist-B3P2fFpz.js → dist-DZo4nSS0.js} +14 -14
- package/dist/{dist-D4CewLk6.js → dist-Dax--nl9.js} +1 -1
- package/dist/{dist-DRfcqpxJ.js → dist-DgGbNavJ.js} +2 -2
- package/dist/{dist-C1BYNeCR.js → dist-Dk6PV_d3.js} +10 -10
- package/dist/{dist-fQ0ViXGs.js → dist-Dv_Y15yk.js} +107 -107
- package/dist/{dist-Bfwsv11D.js → dist-DyyjKEYf.js} +2 -2
- package/dist/{dist-p2qyWijU.js → dist-GZXUmt0b.js} +2 -2
- package/dist/{dist-CLJWPTX2.js → dist-LTU8Hdvn.js} +3 -3
- package/dist/{dist-DqAWR3CS.js → dist-M9Vag9Y0.js} +20 -20
- package/dist/{dist-DNdhYsgW.js → dist-U4F-tbMs.js} +79 -62
- package/dist/{dist-RqXTaiir.js → dist-abid3KgM.js} +11 -11
- package/dist/dist-cdmMjgsn.js +5 -0
- package/dist/dist-hT4QzYX-.js +1247 -0
- package/dist/{dist-luvabDEB.js → dist-t9Kf7xqC.js} +2 -2
- package/dist/{error-banner-5bz0L9hS.js → error-banner-Cc0I3C9e.js} +1 -1
- package/dist/esm-BaH2eg5-.js +1171 -0
- package/dist/{esm-Duie8iU-.js → esm-ga2Bf3O2.js} +43 -43
- package/dist/{extends-BgdxCfYu.js → extends-D_hDsj6R.js} +4 -4
- package/dist/{formats-DHxc-FdY.js → formats-C4wO47tk.js} +1 -1
- package/dist/{glide-data-editor-BOmK9ETQ.js → glide-data-editor-Qhu8oCX-.js} +12 -12
- package/dist/{html-to-image-CNa5ok96.js → html-to-image-UEH5lFDZ.js} +2318 -2275
- package/dist/{input-_2sjvfne.js → input-CMYy4hzj.js} +187 -185
- package/dist/{label-LWtdw5i8.js → label-CC4ytI1X.js} +1 -1
- package/dist/main.js +6941 -6913
- package/dist/{mermaid-lXOw5Py9.js → mermaid-zuLgJ8J8.js} +4 -4
- package/dist/{process-output-DKr4f1di.js → process-output-CyMLTogj.js} +3 -3
- package/dist/{reveal-component-UdMnCK5U.js → reveal-component-Co2AuBAx.js} +697 -619
- package/dist/{spec-B96zNUEA.js → spec-X7FwLJni.js} +4 -4
- package/dist/{strings-Bu3vlb6W.js → strings-J57tzLr3.js} +47 -46
- package/dist/style.css +1 -1
- package/dist/{toDate-x-WRDCH7.js → toDate-d8RCRrRd.js} +2 -2
- package/dist/{tooltip-C5FYOpQc.js → tooltip-DpcyNkQ2.js} +2 -2
- package/dist/{types-CVvp1fKr.js → types-ChtMFmZ2.js} +1 -1
- package/dist/{useAsyncData-iRgKDT5s.js → useAsyncData-PonK__yh.js} +1 -1
- package/dist/{useDateFormatter-BA4FCquG.js → useDateFormatter-QB-3MpYr.js} +2 -2
- package/dist/{useDeepCompareMemoize-CkQ57VS2.js → useDeepCompareMemoize-D3NGWke6.js} +1 -1
- package/dist/{useLifecycle-BBO9PIph.js → useLifecycle-00mO3OSS.js} +2 -2
- package/dist/{useTheme-DHIrRQOe.js → useTheme-DEhDzATN.js} +1 -1
- package/dist/{vega-component-Dq-SH463.js → vega-component-9h1ACS78.js} +8 -8
- package/dist/{zod-CoBiJ5v4.js → zod-aLSua2NL.js} +24 -23
- package/package.json +3 -3
- package/src/components/data-table/TableBottomBar.tsx +1 -15
- package/src/components/data-table/TableTopBar.tsx +8 -13
- package/src/components/data-table/__tests__/TableBottomBar.test.tsx +6 -12
- package/src/components/data-table/__tests__/column-visibility-dropdown.test.tsx +227 -0
- package/src/components/data-table/__tests__/data-table.test.tsx +154 -12
- package/src/components/data-table/column-visibility-dropdown.tsx +204 -0
- package/src/components/data-table/data-table.tsx +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/data-table/hover-tooltip/__tests__/content.test.ts +60 -0
- package/src/components/data-table/hover-tooltip/content.ts +44 -0
- package/src/components/data-table/hover-tooltip/hover-tooltip.tsx +55 -0
- package/src/components/data-table/hover-tooltip/use-table-hover-tooltip.ts +159 -0
- package/src/components/data-table/renderers.tsx +27 -43
- package/src/components/datasources/__tests__/filter-empty.test.ts +183 -0
- package/src/components/datasources/datasources.tsx +92 -3
- package/src/components/editor/cell/cell-context-menu.tsx +15 -2
- package/src/components/editor/cell/code/language-toggle.tsx +7 -1
- package/src/components/editor/chrome/wrapper/app-chrome.tsx +97 -52
- package/src/components/editor/chrome/wrapper/lazy-panels.ts +91 -0
- package/src/components/editor/chrome/wrapper/sidebar.tsx +2 -0
- package/src/components/editor/documentation.css +35 -0
- package/src/components/editor/file-tree/file-explorer.tsx +8 -18
- package/src/components/editor/file-tree/tree-actions.tsx +46 -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__/minimap-actions.test.tsx +166 -0
- package/src/components/slides/__tests__/reveal-component.test.ts +425 -0
- package/src/components/slides/minimap.tsx +127 -10
- package/src/components/slides/reveal-component.tsx +287 -61
- package/src/components/slides/slide-cell-view.tsx +26 -2
- package/src/components/slides/slide-form.tsx +26 -4
- package/src/components/storage/__tests__/storage-inspector.test.ts +53 -0
- package/src/components/storage/storage-inspector.tsx +68 -48
- package/src/components/ui/__tests__/use-toast.test.ts +75 -0
- package/src/components/ui/combobox.tsx +51 -32
- package/src/components/ui/reorderable-list.tsx +13 -0
- 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/components/ui/use-toast.ts +33 -13
- package/src/core/cells/__tests__/__snapshots__/cells.test.ts.snap +0 -28
- package/src/core/cells/__tests__/cell.test.ts +29 -2
- package/src/core/cells/cell.ts +5 -1
- package/src/core/codemirror/go-to-definition/commands.ts +4 -3
- package/src/core/codemirror/language/languages/python.ts +2 -0
- package/src/core/codemirror/language/languages/sql/utils.ts +3 -1
- package/src/core/codemirror/lsp/__tests__/markdown-renderer.test.ts +41 -0
- package/src/core/codemirror/lsp/markdown-renderer.ts +59 -0
- package/src/core/datasets/data-source-connections.ts +2 -0
- package/src/core/network/__tests__/requests-static.test.ts +30 -0
- package/src/core/network/requests-static.ts +14 -10
- package/src/core/wasm/worker/bootstrap.ts +12 -4
- 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/layout/DownloadPlugin.tsx +1 -1
- package/src/utils/lazy.ts +6 -1
- package/dist/assets/__vite-browser-external-Ci2ZQfXU.js +0 -1
- package/dist/assets/worker-ip3AI_sN.js +0 -73
- package/dist/dist-0Fif7jnk.js +0 -5
- package/dist/dist-B5h_9sHB.js +0 -6
- package/dist/dist-B9M6R5ye.js +0 -5
- package/dist/dist-BCt3tnck.js +0 -8
- package/dist/dist-BUIJwMwn.js +0 -8
- package/dist/dist-BpquMd3k.js +0 -5
- package/dist/dist-BzJsqYfz.js +0 -5
- package/dist/dist-CA5ELXAf.js +0 -6
- package/dist/dist-CLBRs6Uv.js +0 -5
- package/dist/dist-CStVCMbq.js +0 -5
- package/dist/dist-CZRIEY3Y.js +0 -8
- package/dist/dist-CuUHbFD0.js +0 -5
- package/dist/dist-DV7Iabxb.js +0 -8
- package/dist/dist-DhHh0jLg.js +0 -1247
- package/dist/dist-DuEeHMvL.js +0 -5
- package/dist/esm-BfhQmZjp.js +0 -1171
- package/src/plugins/impl/multiselectFilterFn.tsx +0 -22
- /package/src/components/{data-table → ui}/value-chips.tsx +0 -0
|
@@ -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";
|
|
@@ -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
|
+
}
|