@marimo-team/islands 0.23.10-dev26 → 0.23.10-dev28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@ import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
9
9
  import { lt as kioskModeAtom } from "./html-to-image-UEH5lFDZ.js";
10
10
  import "./chunk-5FQGJX7Z-BNjes6Yx.js";
11
11
  import { u as createLucideIcon } from "./dist-Dk6PV_d3.js";
12
- import { $ as Panel, Xt as EyeOff, Zt as Expand, a as DEFAULT_SLIDE_TYPE, c as Slide, en as Code, et as PanelGroup, i as DEFAULT_DECK_TRANSITION, s as SlideSidebar, t as useNotebookCodeAvailable, tt as PanelResizeHandle } from "./code-visibility-Rcdlclvw.js";
12
+ import { $t as Expand, Qt as EyeOff, a as DEFAULT_SLIDE_TYPE, c as Slide, i as DEFAULT_DECK_TRANSITION, nn as Code, nt as PanelGroup, rt as PanelResizeHandle, s as SlideSidebar, t as useNotebookCodeAvailable, tt as Panel } from "./code-visibility-Dy2BhYTf.js";
13
13
  import { J as useDebouncedCallback } from "./input-CMYy4hzj.js";
14
14
  import "./toDate-d8RCRrRd.js";
15
15
  import "./react-dom-BTJzcVJ9.js";
@@ -7249,8 +7249,8 @@ var SubslideView = (e5) => {
7249
7249
  if (t[0] !== i || t[1] !== r || t[2] !== a || t[3] !== n) {
7250
7250
  let { slideLevel: e6, cumulativeByBlock: ro2 } = buildSubslideNotes(n, a);
7251
7251
  s = e6;
7252
- let io2 = n.blocks.some((e7) => e7.cells.some((e8) => r(e8.id)));
7253
- o = w, f = "h-full w-full overflow-auto flex", c = io2 ? "mo-slide-content flex flex-col gap-3" : "mo-slide-content", t[10] === /* @__PURE__ */ Symbol.for("react.memo_cache_sentinel") ? (l = { margin: "auto 20px" }, t[10] = l) : l = t[10], u = n.blocks.map((e7, t2) => {
7252
+ let p2 = n.blocks.some((e7) => e7.cells.some((e8) => r(e8.id)));
7253
+ o = w, f = "h-full w-full overflow-auto flex", c = p2 ? "mo-slide-content flex flex-col gap-3" : "mo-slide-content", t[10] === /* @__PURE__ */ Symbol.for("react.memo_cache_sentinel") ? (l = { margin: "auto 20px" }, t[10] = l) : l = t[10], u = n.blocks.map((e7, t2) => {
7254
7254
  let n2 = e7.cells.map((e8) => r(e8.id) ? i ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SlideCellView, { cell: e8 }, e8.id) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SlideCellReadOnlyView, { cell: e8 }, e8.id) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Slide, {
7255
7255
  cellId: e8.id,
7256
7256
  status: e8.status,
@@ -7272,15 +7272,15 @@ var SubslideView = (e5) => {
7272
7272
  style: l,
7273
7273
  children: u
7274
7274
  }), t[11] = c, t[12] = l, t[13] = u, t[14] = ro) : ro = t[14];
7275
- let io;
7276
- t[15] !== f || t[16] !== ro ? (io = /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
7275
+ let p;
7276
+ t[15] !== f || t[16] !== ro ? (p = /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
7277
7277
  className: f,
7278
7278
  children: ro
7279
- }), t[15] = f, t[16] = ro, t[17] = io) : io = t[17];
7279
+ }), t[15] = f, t[16] = ro, t[17] = p) : p = t[17];
7280
7280
  let ao;
7281
7281
  t[18] === s ? ao = t[19] : (ao = s && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NotesAside, { text: s }), t[18] = s, t[19] = ao);
7282
7282
  let oo;
7283
- return t[20] !== o || t[21] !== io || t[22] !== ao ? (oo = /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(o, { children: [io, ao] }), t[20] = o, t[21] = io, t[22] = ao, t[23] = oo) : oo = t[23], oo;
7283
+ return t[20] !== o || t[21] !== p || t[22] !== ao ? (oo = /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(o, { children: [p, ao] }), t[20] = o, t[21] = p, t[22] = ao, t[23] = oo) : oo = t[23], oo;
7284
7284
  };
7285
7285
  function parkedRendersSource(e5) {
7286
7286
  let { isNoOutputPreview: t, isEditable: n, showCode: r } = e5;
@@ -7302,9 +7302,9 @@ var ParkedPreviewContent = (e5) => {
7302
7302
  status: n.status,
7303
7303
  output: n.output
7304
7304
  }), t[3] = n.id, t[4] = n.output, t[5] = n.status, t[6] = o) : o = t[6], o;
7305
- }, reveal_component_default = ({ slideCells: e5, layout: o, setLayout: s, noOutputIds: l, activeIndex: io, onSlideChange: p, mode: so, configWidth: fo, isEditable: po = false }) => {
7305
+ }, reveal_component_default = ({ slideCells: e5, layout: o, setLayout: s, noOutputIds: l, activeIndex: ro, onSlideChange: io, mode: p, configWidth: fo, isEditable: po = false }) => {
7306
7306
  var _a2, _b2;
7307
- let yo = (0, import_react.useRef)(null), bo = (0, import_react.useRef)(null), { width: So, height: Co } = useSlideDimensions(yo), wo = useFullScreenElement() != null, To = useAtomValue(kioskModeAtom), Eo = (0, import_react.useMemo)(() => To ? [] : [ce], [To]), [Do, Oo] = (0, import_react.useState)(() => /* @__PURE__ */ new Set()), ko = useNotebookCodeAvailable(e5), Ao = !isIslands() && ko, jo = io == null ? void 0 : e5[io], Mo = jo ?? e5.at(0), { parkedPreviewCell: No, isHeldEdit: Po, isNoOutputPreview: Fo, heldEditCellId: Io, heldShowsCode: Lo, toggleHeldShowsCode: Ro } = useParkedPreview({
7307
+ let yo = (0, import_react.useRef)(null), bo = (0, import_react.useRef)(null), { width: So, height: Co } = useSlideDimensions(yo), wo = useFullScreenElement() != null, To = useAtomValue(kioskModeAtom), Eo = (0, import_react.useMemo)(() => To ? [] : [ce], [To]), [Do, Oo] = (0, import_react.useState)(() => /* @__PURE__ */ new Set()), ko = useNotebookCodeAvailable(e5), Ao = !isIslands() && ko, jo = ro == null ? void 0 : e5[ro], Mo = jo ?? e5.at(0), { parkedPreviewCell: No, isHeldEdit: Po, isNoOutputPreview: Fo, heldEditCellId: Io, heldShowsCode: Lo, toggleHeldShowsCode: Ro } = useParkedPreview({
7308
7308
  activeCell: jo,
7309
7309
  slideConfigs: o.cells,
7310
7310
  noOutputIds: l
@@ -7350,7 +7350,7 @@ var ParkedPreviewContent = (e5) => {
7350
7350
  qo
7351
7351
  ]), Yo = useEvent_default((t) => {
7352
7352
  let n = resolveDeckNavigationTarget({
7353
- activeIndex: io,
7353
+ activeIndex: ro,
7354
7354
  cells: e5,
7355
7355
  cellToTarget: Wo,
7356
7356
  getId: (e6) => e6.id
@@ -7361,7 +7361,7 @@ var ParkedPreviewContent = (e5) => {
7361
7361
  let e6 = bo.current;
7362
7362
  e6 != null && Yo(e6);
7363
7363
  }, [
7364
- io,
7364
+ ro,
7365
7365
  Wo,
7366
7366
  e5,
7367
7367
  Yo
@@ -7391,14 +7391,14 @@ var ParkedPreviewContent = (e5) => {
7391
7391
  let e6 = bo.current;
7392
7392
  if (!e6) return;
7393
7393
  let t = resolveActiveCellIndex(Go, e6.getIndices());
7394
- t != null && (p == null ? void 0 : p(t));
7394
+ t != null && (io == null ? void 0 : io(t));
7395
7395
  }), $o = useEvent_default((t) => {
7396
- if (!No || io == null || Events.fromInput(t)) return;
7396
+ if (!No || ro == null || Events.fromInput(t)) return;
7397
7397
  let n = classifyNavKey(t);
7398
7398
  if (n === 0) return;
7399
7399
  t.preventDefault(), t.stopPropagation();
7400
- let i = io + n;
7401
- i < 0 || i >= e5.length || (p == null ? void 0 : p(i));
7400
+ let i = ro + n;
7401
+ i < 0 || i >= e5.length || (io == null ? void 0 : io(i));
7402
7402
  }), ns = useEvent_default(() => {
7403
7403
  Qo(), triggerResize(bo.current);
7404
7404
  });
@@ -7494,7 +7494,7 @@ var ParkedPreviewContent = (e5) => {
7494
7494
  ]
7495
7495
  })
7496
7496
  });
7497
- return so === "read" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
7497
+ return p === "read" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
7498
7498
  className: "flex-1 min-w-0 flex flex-row gap-3",
7499
7499
  children: os
7500
7500
  }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.23.10-dev26",
3
+ "version": "0.23.10-dev28",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -7,6 +7,8 @@ import { useMemo, useState } from "react";
7
7
  import { useAsyncData } from "@/hooks/useAsyncData";
8
8
  import { ErrorBanner } from "@/plugins/impl/common/error-banner";
9
9
  import type { CalculateTopKRows } from "@/plugins/impl/DataTablePlugin";
10
+ import type { Option } from "@/components/ui/select-core";
11
+ import { useSelectList } from "@/components/ui/select-core";
10
12
  import { Logger } from "@/utils/Logger";
11
13
  import { Sets } from "@/utils/sets";
12
14
  import { smartMatch } from "@/utils/smartMatch";
@@ -23,7 +25,7 @@ import {
23
25
  import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
24
26
  import { SentinelCell } from "./sentinel-cell";
25
27
  import { detectSentinel, stringifyUnknownValue } from "./utils";
26
- import { CompactChipRow } from "./value-chips";
28
+ import { CompactChipRow } from "@/components/ui/value-chips";
27
29
 
28
30
  const TOP_K_ROWS = 30;
29
31
 
@@ -101,8 +103,6 @@ export const FilterByValuesList = <TData, TValue>({
101
103
  onChange,
102
104
  creatable = false,
103
105
  }: FilterByValuesListProps<TData, TValue>) => {
104
- const [query, setQuery] = useState<string>("");
105
-
106
106
  const { data, isPending, error } = useAsyncData(async () => {
107
107
  if (!calculateTopKRows) {
108
108
  return null;
@@ -111,27 +111,49 @@ export const FilterByValuesList = <TData, TValue>({
111
111
  return res.data;
112
112
  }, [calculateTopKRows, column.id]);
113
113
 
114
- const filteredData = useMemo(() => {
114
+ const options = useMemo<Array<Option<unknown>>>(() => {
115
115
  if (!data) {
116
116
  return [];
117
117
  }
118
118
  try {
119
- // try to do includes and also smart match for prefixes
120
- return data.filter(([value, _count]) => {
121
- if (value === undefined) {
122
- return false;
123
- }
124
- const str = String(value);
125
- return (
126
- smartMatch(query, str) ||
127
- str.toLowerCase().includes(query.toLowerCase())
128
- );
129
- });
119
+ return data
120
+ .filter(([value]) => value !== undefined)
121
+ .map(([value, count]) => ({
122
+ value,
123
+ label: String(value),
124
+ data: { count },
125
+ }));
130
126
  } catch (error_) {
131
- Logger.error("Error filtering data", error_);
127
+ Logger.error("Error building filter options", error_);
132
128
  return [];
133
129
  }
134
- }, [data, query]);
130
+ }, [data]);
131
+
132
+ const list = useSelectList<unknown>({
133
+ options,
134
+ value: [...chosenValues],
135
+ onChange: (next) => onChange(next as unknown[]),
136
+ multiple: true,
137
+ filterFn: (label, q) =>
138
+ smartMatch(q, label) || label.toLowerCase().includes(q.toLowerCase())
139
+ ? 1
140
+ : 0,
141
+ });
142
+
143
+ const query = list.searchQuery;
144
+ const setQuery = list.setSearchQuery;
145
+
146
+ const filteredData = useMemo<Array<[unknown, number | undefined]>>(
147
+ () =>
148
+ list.visibleOptions.map(
149
+ (o) =>
150
+ [o.value, (o.data as { count: number }).count] as [
151
+ unknown,
152
+ number | undefined,
153
+ ],
154
+ ),
155
+ [list.visibleOptions],
156
+ );
135
157
 
136
158
  // Surface chosen values that aren't in the top-K so they stay visible/uncheckable.
137
159
  // Count is undefined for these rows; the cell renders an em-dash.
@@ -21,7 +21,7 @@ import { AddFilterButton } from "./add-filter-button";
21
21
  import { FilterPillEditor } from "./filter-pill-editor";
22
22
  import { type ColumnFilterValue, formatValue, type Snapshot } from "./filters";
23
23
  import { extractTimezone } from "./types";
24
- import { ChipWithComma, CompactChipRow } from "./value-chips";
24
+ import { ChipWithComma, CompactChipRow } from "@/components/ui/value-chips";
25
25
 
26
26
  interface Props<TData> {
27
27
  filters: ColumnFiltersState | undefined;
@@ -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
+ });