@marimo-team/islands 0.23.10-dev3 → 0.23.10-dev30

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 (165) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CyV83R2m.js → ConnectedDataExplorerComponent-DmBropAy.js} +31 -31
  2. package/dist/{ErrorBoundary-rULOrC_p.js → ErrorBoundary-DpbaKVv7.js} +1 -1
  3. package/dist/{any-language-editor-DfdpyDv_.js → any-language-editor-DNmoSiWL.js} +20 -20
  4. package/dist/assets/__vite-browser-external-eshhtsgZ.js +1 -0
  5. package/dist/assets/worker-CC0Oul9k.js +73 -0
  6. package/dist/{chat-ui-C1tL1pML.js → chat-ui-D6oraHT2.js} +76 -76
  7. package/dist/{check-DTbrK0zt.js → check-BCaJeT-J.js} +1 -1
  8. package/dist/{code-visibility-DfnO0DcH.js → code-visibility-wR7WSQ4c.js} +2166 -1292
  9. package/dist/{copy-BuQpJEzp.js → copy-UqRYxiOg.js} +33 -33
  10. package/dist/dist-7QfXoMdB.js +5 -0
  11. package/dist/{dist-DgnE8F-r.js → dist-A2846XWO.js} +1 -1
  12. package/dist/dist-BEXXyZig.js +5 -0
  13. package/dist/{dist-B3pZ0Ab6.js → dist-BR_gyG9L.js} +3 -3
  14. package/dist/{dist-CcXxepx6.js → dist-BSAt6RhH.js} +27 -27
  15. package/dist/{dist-Bde4a2kU.js → dist-BY018Paw.js} +8 -8
  16. package/dist/dist-BYj57OV4.js +5 -0
  17. package/dist/{dist-CUCNs1ja.js → dist-BaoDKvdy.js} +2 -2
  18. package/dist/{dist-Cy1WxgBD.js → dist-Bf7SHuNp.js} +5 -5
  19. package/dist/{dist-Bz_sYWbr.js → dist-Bk75fBZA.js} +2 -2
  20. package/dist/dist-BlSvQzNr.js +5 -0
  21. package/dist/{dist-C5VC_yzu.js → dist-BzEzfugY.js} +1 -1
  22. package/dist/dist-CCBlxAgS.js +8 -0
  23. package/dist/dist-CIDTVIUf.js +5 -0
  24. package/dist/{dist-CLUtPrdy.js → dist-CIYBwstr.js} +1 -1
  25. package/dist/{dist-BotSqB48.js → dist-C_Y3oV3C.js} +12 -12
  26. package/dist/{dist-BTfv03uy.js → dist-CcWX6tmx.js} +2 -2
  27. package/dist/{dist-BhM8gdSO.js → dist-CoXAujgg.js} +4 -4
  28. package/dist/{dist-4j4c7bjm.js → dist-CpxNdDkw.js} +3 -3
  29. package/dist/dist-CqQyhAM8.js +8 -0
  30. package/dist/dist-CwRu2Xzh.js +5 -0
  31. package/dist/{dist-BcuoonNH.js → dist-CxJDU6Bh.js} +9 -9
  32. package/dist/{dist-DxvORzUR.js → dist-D-W5ny5a.js} +8 -8
  33. package/dist/dist-D8CDTVgf.js +6 -0
  34. package/dist/dist-D8DNB0nO.js +8 -0
  35. package/dist/dist-DL6N_q-A.js +5 -0
  36. package/dist/{dist-BbbIBDiQ.js → dist-DMjWuVs8.js} +1 -1
  37. package/dist/dist-DOFbNV_b.js +8 -0
  38. package/dist/dist-DPrYzMY0.js +6 -0
  39. package/dist/{dist-h2c8sZvT.js → dist-DZORgqKY.js} +1 -1
  40. package/dist/{dist-B3P2fFpz.js → dist-DZo4nSS0.js} +14 -14
  41. package/dist/{dist-D4CewLk6.js → dist-Dax--nl9.js} +1 -1
  42. package/dist/{dist-DRfcqpxJ.js → dist-DgGbNavJ.js} +2 -2
  43. package/dist/{dist-C1BYNeCR.js → dist-Dk6PV_d3.js} +10 -10
  44. package/dist/{dist-fQ0ViXGs.js → dist-Dv_Y15yk.js} +107 -107
  45. package/dist/{dist-Bfwsv11D.js → dist-DyyjKEYf.js} +2 -2
  46. package/dist/{dist-p2qyWijU.js → dist-GZXUmt0b.js} +2 -2
  47. package/dist/{dist-CLJWPTX2.js → dist-LTU8Hdvn.js} +3 -3
  48. package/dist/{dist-DqAWR3CS.js → dist-M9Vag9Y0.js} +20 -20
  49. package/dist/{dist-DNdhYsgW.js → dist-U4F-tbMs.js} +79 -62
  50. package/dist/{dist-RqXTaiir.js → dist-abid3KgM.js} +11 -11
  51. package/dist/dist-cdmMjgsn.js +5 -0
  52. package/dist/dist-hT4QzYX-.js +1247 -0
  53. package/dist/{dist-luvabDEB.js → dist-t9Kf7xqC.js} +2 -2
  54. package/dist/{error-banner-5bz0L9hS.js → error-banner-Cc0I3C9e.js} +1 -1
  55. package/dist/esm-BaH2eg5-.js +1171 -0
  56. package/dist/{esm-Duie8iU-.js → esm-ga2Bf3O2.js} +43 -43
  57. package/dist/{extends-BgdxCfYu.js → extends-D_hDsj6R.js} +4 -4
  58. package/dist/{formats-DHxc-FdY.js → formats-C4wO47tk.js} +1 -1
  59. package/dist/{glide-data-editor-BOmK9ETQ.js → glide-data-editor-Qhu8oCX-.js} +12 -12
  60. package/dist/{html-to-image-CNa5ok96.js → html-to-image-UEH5lFDZ.js} +2318 -2275
  61. package/dist/{input-_2sjvfne.js → input-CMYy4hzj.js} +187 -185
  62. package/dist/{label-LWtdw5i8.js → label-CC4ytI1X.js} +1 -1
  63. package/dist/main.js +6941 -6913
  64. package/dist/{mermaid-lXOw5Py9.js → mermaid-zuLgJ8J8.js} +4 -4
  65. package/dist/{process-output-DKr4f1di.js → process-output-CyMLTogj.js} +3 -3
  66. package/dist/{reveal-component-UdMnCK5U.js → reveal-component-BjnkUAZ9.js} +697 -619
  67. package/dist/{spec-B96zNUEA.js → spec-X7FwLJni.js} +4 -4
  68. package/dist/{strings-Bu3vlb6W.js → strings-J57tzLr3.js} +47 -46
  69. package/dist/style.css +1 -1
  70. package/dist/{toDate-x-WRDCH7.js → toDate-d8RCRrRd.js} +2 -2
  71. package/dist/{tooltip-C5FYOpQc.js → tooltip-DpcyNkQ2.js} +2 -2
  72. package/dist/{types-CVvp1fKr.js → types-ChtMFmZ2.js} +1 -1
  73. package/dist/{useAsyncData-iRgKDT5s.js → useAsyncData-PonK__yh.js} +1 -1
  74. package/dist/{useDateFormatter-BA4FCquG.js → useDateFormatter-QB-3MpYr.js} +2 -2
  75. package/dist/{useDeepCompareMemoize-CkQ57VS2.js → useDeepCompareMemoize-D3NGWke6.js} +1 -1
  76. package/dist/{useLifecycle-BBO9PIph.js → useLifecycle-00mO3OSS.js} +2 -2
  77. package/dist/{useTheme-DHIrRQOe.js → useTheme-DEhDzATN.js} +1 -1
  78. package/dist/{vega-component-Dq-SH463.js → vega-component-9h1ACS78.js} +8 -8
  79. package/dist/{zod-CoBiJ5v4.js → zod-aLSua2NL.js} +24 -23
  80. package/package.json +3 -3
  81. package/src/components/data-table/TableBottomBar.tsx +1 -15
  82. package/src/components/data-table/TableTopBar.tsx +8 -13
  83. package/src/components/data-table/__tests__/TableBottomBar.test.tsx +6 -12
  84. package/src/components/data-table/__tests__/column-visibility-dropdown.test.tsx +227 -0
  85. package/src/components/data-table/__tests__/data-table.test.tsx +154 -12
  86. package/src/components/data-table/column-visibility-dropdown.tsx +204 -0
  87. package/src/components/data-table/data-table.tsx +1 -1
  88. package/src/components/data-table/filter-by-values-picker.tsx +39 -17
  89. package/src/components/data-table/filter-pills.tsx +1 -1
  90. package/src/components/data-table/hover-tooltip/__tests__/content.test.ts +60 -0
  91. package/src/components/data-table/hover-tooltip/content.ts +44 -0
  92. package/src/components/data-table/hover-tooltip/hover-tooltip.tsx +55 -0
  93. package/src/components/data-table/hover-tooltip/use-table-hover-tooltip.ts +159 -0
  94. package/src/components/data-table/renderers.tsx +27 -43
  95. package/src/components/datasources/__tests__/filter-empty.test.ts +183 -0
  96. package/src/components/datasources/datasources.tsx +92 -3
  97. package/src/components/editor/cell/cell-context-menu.tsx +15 -2
  98. package/src/components/editor/cell/code/language-toggle.tsx +7 -1
  99. package/src/components/editor/chrome/wrapper/app-chrome.tsx +97 -52
  100. package/src/components/editor/chrome/wrapper/lazy-panels.ts +91 -0
  101. package/src/components/editor/chrome/wrapper/sidebar.tsx +2 -0
  102. package/src/components/editor/documentation.css +35 -0
  103. package/src/components/editor/file-tree/file-explorer.tsx +8 -18
  104. package/src/components/editor/file-tree/tree-actions.tsx +46 -1
  105. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +20 -0
  106. package/src/components/editor/renderers/slides-layout/types.ts +1 -0
  107. package/src/components/slides/__tests__/minimap-actions.test.tsx +166 -0
  108. package/src/components/slides/__tests__/reveal-component.test.ts +425 -0
  109. package/src/components/slides/minimap.tsx +127 -10
  110. package/src/components/slides/reveal-component.tsx +287 -61
  111. package/src/components/slides/slide-cell-view.tsx +26 -2
  112. package/src/components/slides/slide-form.tsx +26 -4
  113. package/src/components/storage/__tests__/storage-inspector.test.ts +53 -0
  114. package/src/components/storage/storage-inspector.tsx +68 -48
  115. package/src/components/ui/__tests__/use-toast.test.ts +75 -0
  116. package/src/components/ui/combobox.tsx +51 -32
  117. package/src/components/ui/reorderable-list.tsx +13 -0
  118. package/src/components/ui/select-core/__tests__/use-select-list.test.ts +294 -0
  119. package/src/components/ui/select-core/__tests__/utils.test.ts +222 -0
  120. package/src/components/ui/select-core/index.ts +16 -0
  121. package/src/components/ui/select-core/option-row.tsx +33 -0
  122. package/src/components/ui/select-core/render-slot.ts +20 -0
  123. package/src/components/ui/select-core/select-list.tsx +248 -0
  124. package/src/components/ui/select-core/types.ts +44 -0
  125. package/src/components/ui/select-core/use-select-list.ts +347 -0
  126. package/src/components/ui/select-core/utils.ts +121 -0
  127. package/src/components/ui/use-toast.ts +33 -13
  128. package/src/core/cells/__tests__/__snapshots__/cells.test.ts.snap +0 -28
  129. package/src/core/cells/__tests__/cell.test.ts +29 -2
  130. package/src/core/cells/cell.ts +5 -1
  131. package/src/core/codemirror/go-to-definition/commands.ts +4 -3
  132. package/src/core/codemirror/language/languages/python.ts +2 -0
  133. package/src/core/codemirror/language/languages/sql/utils.ts +3 -1
  134. package/src/core/codemirror/lsp/__tests__/markdown-renderer.test.ts +41 -0
  135. package/src/core/codemirror/lsp/markdown-renderer.ts +59 -0
  136. package/src/core/datasets/data-source-connections.ts +2 -0
  137. package/src/core/network/__tests__/requests-static.test.ts +30 -0
  138. package/src/core/network/requests-static.ts +14 -10
  139. package/src/core/wasm/worker/bootstrap.ts +12 -4
  140. package/src/plugins/impl/MultiselectPlugin.tsx +19 -142
  141. package/src/plugins/impl/SearchableSelect.tsx +16 -97
  142. package/src/plugins/impl/__tests__/DropdownPlugin.test.tsx +5 -2
  143. package/src/plugins/impl/__tests__/MultiSelectPlugin.test.ts +1 -1
  144. package/src/plugins/layout/DownloadPlugin.tsx +1 -1
  145. package/src/utils/lazy.ts +6 -1
  146. package/dist/assets/__vite-browser-external-Ci2ZQfXU.js +0 -1
  147. package/dist/assets/worker-ip3AI_sN.js +0 -73
  148. package/dist/dist-0Fif7jnk.js +0 -5
  149. package/dist/dist-B5h_9sHB.js +0 -6
  150. package/dist/dist-B9M6R5ye.js +0 -5
  151. package/dist/dist-BCt3tnck.js +0 -8
  152. package/dist/dist-BUIJwMwn.js +0 -8
  153. package/dist/dist-BpquMd3k.js +0 -5
  154. package/dist/dist-BzJsqYfz.js +0 -5
  155. package/dist/dist-CA5ELXAf.js +0 -6
  156. package/dist/dist-CLBRs6Uv.js +0 -5
  157. package/dist/dist-CStVCMbq.js +0 -5
  158. package/dist/dist-CZRIEY3Y.js +0 -8
  159. package/dist/dist-CuUHbFD0.js +0 -5
  160. package/dist/dist-DV7Iabxb.js +0 -8
  161. package/dist/dist-DhHh0jLg.js +0 -1247
  162. package/dist/dist-DuEeHMvL.js +0 -5
  163. package/dist/esm-BfhQmZjp.js +0 -1171
  164. package/src/plugins/impl/multiselectFilterFn.tsx +0 -22
  165. /package/src/components/{data-table → ui}/value-chips.tsx +0 -0
@@ -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 };
@@ -0,0 +1,347 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { useMemo, useState } from "react";
3
+ import { assertNever } from "@/utils/assertNever";
4
+ import type { BulkAction, Option } from "./types";
5
+ import {
6
+ deselectMatching,
7
+ getBulkActions,
8
+ getVisibleOptions,
9
+ multiselectFilterFn,
10
+ selectMatching,
11
+ } from "./utils";
12
+
13
+ /** cmdk-style relevance score for `(label, query)`; any positive score matches. */
14
+ type FilterFn = (label: string, query: string) => number;
15
+
16
+ interface UseSelectListParams<V> {
17
+ options: Array<Option<V>>;
18
+ /** Current selection: an array when `multiple`, otherwise a single value or null. */
19
+ value: V[] | V | null;
20
+ onChange: (next: V[] | V | null) => void;
21
+ /** Multi-select when true; single-select (replace-on-pick) when false. */
22
+ multiple: boolean;
23
+ /** Cap on multi-select size. At the cap, picking another drops the oldest. */
24
+ maxSelections?: number;
25
+ /** Single-select only: re-picking the current value clears it to null. */
26
+ allowSelectNone?: boolean;
27
+ /** Match predicate over `(label, query)`; defaults to the strict word match. */
28
+ filterFn?: FilterFn;
29
+ /** Float the (frozen) selection to the top of the idle menu. */
30
+ pinSelected?: boolean;
31
+ }
32
+
33
+ interface UseSelectListResult<V> {
34
+ searchQuery: string;
35
+ setSearchQuery: (query: string) => void;
36
+ open: boolean;
37
+ setOpen: (open: boolean) => void;
38
+ /** Filtered, and (when `pinSelected` and idle) selected-first ordered. */
39
+ visibleOptions: Array<Option<V>>;
40
+ /** Count of pinned options at the head of `visibleOptions` (0 unless pinned + idle). */
41
+ pinnedCount: number;
42
+ isChecked: (value: V) => boolean;
43
+ toggle: (value: V) => void;
44
+ /**
45
+ * Renderable bulk rows for the current search/cap state, in display order.
46
+ * Empty for single-select. Each entry carries its data (`enabled` or `items`)
47
+ * and a `run` closure that applies it.
48
+ */
49
+ bulkActions: Array<BulkAction<V>>;
50
+ /** Map a value back to its option label; falls back to `String(value)`. */
51
+ labelOf: (value: V) => string;
52
+ }
53
+
54
+ function asArray<V>(value: V[] | V | null): V[] {
55
+ if (value == null) {
56
+ return [];
57
+ }
58
+ return Array.isArray(value) ? value : [value];
59
+ }
60
+
61
+ interface SearchParams<V> {
62
+ options: Array<Option<V>>;
63
+ filterFn: FilterFn;
64
+ }
65
+
66
+ /** Search query and the options that currently match it. */
67
+ function useSearch<V>({ options, filterFn }: SearchParams<V>): {
68
+ searchQuery: string;
69
+ setSearchQuery: (query: string) => void;
70
+ filteredOptions: Array<Option<V>>;
71
+ } {
72
+ const [searchQuery, setSearchQuery] = useState("");
73
+ const filteredOptions = useMemo(() => {
74
+ if (!searchQuery) {
75
+ return options;
76
+ }
77
+ return options.filter((o) => filterFn(o.label, searchQuery) > 0);
78
+ }, [options, searchQuery, filterFn]);
79
+ return { searchQuery, setSearchQuery, filteredOptions };
80
+ }
81
+
82
+ interface PinningParams<V> {
83
+ value: V[] | V | null;
84
+ pinSelected: boolean;
85
+ options: Array<Option<V>>;
86
+ searchQuery: string;
87
+ filteredOptions: Array<Option<V>>;
88
+ }
89
+
90
+ /**
91
+ * Open state plus the frozen selection snapshot that orders the idle menu. The
92
+ * snapshot is retaken (`repin`) when the menu opens or the search clears, so a row
93
+ * toggled mid-session keeps its place instead of jumping to the top under the cursor.
94
+ */
95
+ function usePinning<V>({
96
+ value,
97
+ pinSelected,
98
+ options,
99
+ searchQuery,
100
+ filteredOptions,
101
+ }: PinningParams<V>): {
102
+ open: boolean;
103
+ setOpen: (open: boolean) => void;
104
+ repin: () => void;
105
+ visibleOptions: Array<Option<V>>;
106
+ pinnedCount: number;
107
+ } {
108
+ const [open, setOpenState] = useState(false);
109
+ const [pinnedSelection, setPinnedSelection] = useState<Set<V>>(
110
+ () => new Set(asArray(value)),
111
+ );
112
+
113
+ const repin = (): void => setPinnedSelection(new Set(asArray(value)));
114
+
115
+ const setOpen = (nextOpen: boolean): void => {
116
+ setOpenState(nextOpen);
117
+ if (nextOpen) {
118
+ repin();
119
+ }
120
+ };
121
+
122
+ const { visibleOptions, pinnedCount } = useMemo(() => {
123
+ if (searchQuery || !pinSelected) {
124
+ return { visibleOptions: filteredOptions, pinnedCount: 0 };
125
+ }
126
+ return getVisibleOptions(options, pinnedSelection);
127
+ }, [searchQuery, pinSelected, filteredOptions, options, pinnedSelection]);
128
+
129
+ return { open, setOpen, repin, visibleOptions, pinnedCount };
130
+ }
131
+
132
+ interface ToggleParams<V> {
133
+ value: V[] | V | null;
134
+ onChange: (next: V[] | V | null) => void;
135
+ multiple: boolean;
136
+ maxSelections: number | undefined;
137
+ allowSelectNone: boolean | undefined;
138
+ selected: ReadonlySet<V>;
139
+ }
140
+
141
+ /** Membership test and the cap/cardinality-aware single-item toggle. */
142
+ function useToggle<V>({
143
+ value,
144
+ onChange,
145
+ multiple,
146
+ maxSelections,
147
+ allowSelectNone,
148
+ selected,
149
+ }: ToggleParams<V>): {
150
+ isChecked: (value: V) => boolean;
151
+ toggle: (value: V) => void;
152
+ } {
153
+ const isChecked = (candidate: V): boolean => selected.has(candidate);
154
+
155
+ const toggle = (candidate: V): void => {
156
+ if (!multiple) {
157
+ if (allowSelectNone && value === candidate) {
158
+ onChange(null);
159
+ return;
160
+ }
161
+ onChange(candidate);
162
+ return;
163
+ }
164
+
165
+ const current = asArray(value);
166
+ if (selected.has(candidate)) {
167
+ onChange(current.filter((v) => v !== candidate));
168
+ return;
169
+ }
170
+
171
+ let next = [...current, candidate];
172
+ if (maxSelections != null && next.length > maxSelections) {
173
+ next = next.slice(-maxSelections);
174
+ }
175
+ onChange(next);
176
+ };
177
+
178
+ return { isChecked, toggle };
179
+ }
180
+
181
+ interface BulkParams<V> {
182
+ value: V[] | V | null;
183
+ onChange: (next: V[] | V | null) => void;
184
+ multiple: boolean;
185
+ options: Array<Option<V>>;
186
+ filteredOptions: Array<Option<V>>;
187
+ searchQuery: string;
188
+ maxSelections: number | undefined;
189
+ }
190
+
191
+ /** Bulk-row specs paired with run closures; inert for single-select. */
192
+ function useBulk<V>({
193
+ value,
194
+ onChange,
195
+ multiple,
196
+ options,
197
+ filteredOptions,
198
+ searchQuery,
199
+ maxSelections,
200
+ }: BulkParams<V>): { bulkActions: Array<BulkAction<V>> } {
201
+ const bulkActions = useMemo<Array<BulkAction<V>>>(() => {
202
+ if (!multiple) {
203
+ return [];
204
+ }
205
+ const specs = getBulkActions({
206
+ options,
207
+ filteredOptions,
208
+ value: asArray(value),
209
+ searchQuery,
210
+ maxSelections,
211
+ });
212
+ return specs.map((spec): BulkAction<V> => {
213
+ switch (spec.kind) {
214
+ case "select-all":
215
+ return {
216
+ ...spec,
217
+ run: () =>
218
+ onChange(
219
+ selectMatching(
220
+ asArray(value),
221
+ options.filter((o) => !o.disabled).map((o) => o.value),
222
+ ),
223
+ ),
224
+ };
225
+ case "deselect-all":
226
+ return { ...spec, run: () => onChange([]) };
227
+ case "select-matching":
228
+ return {
229
+ ...spec,
230
+ run: () =>
231
+ onChange(
232
+ selectMatching(
233
+ asArray(value),
234
+ spec.items.map((o) => o.value),
235
+ ),
236
+ ),
237
+ };
238
+ case "deselect-matching":
239
+ return {
240
+ ...spec,
241
+ run: () =>
242
+ onChange(
243
+ deselectMatching(
244
+ asArray(value),
245
+ spec.items.map((o) => o.value),
246
+ ),
247
+ ),
248
+ };
249
+ default:
250
+ return assertNever(spec);
251
+ }
252
+ });
253
+ }, [
254
+ multiple,
255
+ options,
256
+ filteredOptions,
257
+ value,
258
+ searchQuery,
259
+ maxSelections,
260
+ onChange,
261
+ ]);
262
+
263
+ return { bulkActions };
264
+ }
265
+
266
+ /**
267
+ * Headless state for a searchable select list, shared across the multiselect,
268
+ * dropdown, and top-K filter facades. Composes four focused concerns — search,
269
+ * pinning/freeze, membership/toggle, and bulk actions — behind one entry point.
270
+ * Pinning and bulk rows are opt-in so the single-select and top-K facades share
271
+ * only what they need.
272
+ */
273
+ export function useSelectList<V>({
274
+ options,
275
+ value,
276
+ onChange,
277
+ multiple,
278
+ maxSelections,
279
+ allowSelectNone,
280
+ filterFn = multiselectFilterFn,
281
+ pinSelected = false,
282
+ }: UseSelectListParams<V>): UseSelectListResult<V> {
283
+ const selected = useMemo(() => new Set(asArray(value)), [value]);
284
+
285
+ const labelByValue = useMemo(() => {
286
+ const map = new Map<V, string>();
287
+ for (const option of options) {
288
+ map.set(option.value, option.label);
289
+ }
290
+ return map;
291
+ }, [options]);
292
+ const labelOf = (candidate: V): string =>
293
+ labelByValue.get(candidate) ?? String(candidate);
294
+
295
+ const {
296
+ searchQuery,
297
+ setSearchQuery: setSearchQueryState,
298
+ filteredOptions,
299
+ } = useSearch({ options, filterFn });
300
+
301
+ const { open, setOpen, repin, visibleOptions, pinnedCount } = usePinning({
302
+ value,
303
+ pinSelected,
304
+ options,
305
+ searchQuery,
306
+ filteredOptions,
307
+ });
308
+
309
+ const { isChecked, toggle } = useToggle({
310
+ value,
311
+ onChange,
312
+ multiple,
313
+ maxSelections,
314
+ allowSelectNone,
315
+ selected,
316
+ });
317
+
318
+ const { bulkActions } = useBulk({
319
+ value,
320
+ onChange,
321
+ multiple,
322
+ options,
323
+ filteredOptions,
324
+ searchQuery,
325
+ maxSelections,
326
+ });
327
+
328
+ const setSearchQuery = (query: string): void => {
329
+ setSearchQueryState(query);
330
+ if (query === "") {
331
+ repin();
332
+ }
333
+ };
334
+
335
+ return {
336
+ searchQuery,
337
+ setSearchQuery,
338
+ open,
339
+ setOpen,
340
+ visibleOptions,
341
+ pinnedCount,
342
+ isChecked,
343
+ toggle,
344
+ bulkActions,
345
+ labelOf,
346
+ };
347
+ }