@marimo-team/islands 0.23.7-dev9 → 0.23.7

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 (153) hide show
  1. package/dist/{ConnectedDataExplorerComponent-DnRhpPMJ.js → ConnectedDataExplorerComponent-2lBNiUv6.js} +13 -13
  2. package/dist/{ErrorBoundary-Da4UeYxT.js → ErrorBoundary-D3wrPNma.js} +1 -1
  3. package/dist/{any-language-editor-DDubl8YH.js → any-language-editor-VWs_7v27.js} +5 -5
  4. package/dist/assets/__vite-browser-external-CAdMKBac.js +1 -0
  5. package/dist/assets/worker-CpBbwbQo.js +73 -0
  6. package/dist/{button-CA5pI2YF.js → button-Dj4BTre0.js} +5 -0
  7. package/dist/{capabilities-6laDasij.js → capabilities-C9rrYCzf.js} +1 -1
  8. package/dist/{chat-ui-BmWZZ3mE.js → chat-ui-D3XBept8.js} +625 -233
  9. package/dist/{check-CFM2mVDr.js → check-BcUIXnUT.js} +1 -1
  10. package/dist/{code-visibility-CRHzv49w.js → code-visibility-sKGUbHmr.js} +11480 -1992
  11. package/dist/{copy-TGGAUEWp.js → copy-DLf4aN7I.js} +2 -2
  12. package/dist/{dist-ESg7xyoD.js → dist-D3ZI9nhS.js} +2 -2
  13. package/dist/{error-banner-DnBPzEWg.js → error-banner-CVkfBUT3.js} +2 -2
  14. package/dist/{esm-Dd1z1auZ.js → esm-CWp0KQeK.js} +1 -1
  15. package/dist/{extends-CzJgxo2J.js → extends-vAi97cpa.js} +4 -4
  16. package/dist/{formats-CgaK7Gmx.js → formats-Dsy9kkZu.js} +3 -3
  17. package/dist/{glide-data-editor-B-3A3G02.js → glide-data-editor-DucgdjRo.js} +9 -9
  18. package/dist/{html-to-image-BwZL1Pkk.js → html-to-image-CpggM7u1.js} +2667 -2408
  19. package/dist/{input-BAOe64zx.js → input-D4kjoQUB.js} +8 -6
  20. package/dist/{label-BCWi-Oqu.js → label-BLqV33b1.js} +2 -2
  21. package/dist/{loader-BvW0-YWZ.js → loader-Dr8Qem8p.js} +1 -1
  22. package/dist/main.js +1697 -10282
  23. package/dist/{mermaid-cXSZ1pfD.js → mermaid-DO-Daq7u.js} +5 -5
  24. package/dist/{process-output-lpVrk7d5.js → process-output-X8TR20AK.js} +3 -3
  25. package/dist/reveal-component-BBAxPTso.js +7447 -0
  26. package/dist/{spec-DSIuqd3f.js → spec-hVaaZsY5.js} +4 -4
  27. package/dist/{strings-B_FOH6eV.js → strings-BiIhGaI8.js} +4 -4
  28. package/dist/style.css +1 -1
  29. package/dist/{swiper-component-BHs0PWwp.js → swiper-component-DlD2GU2g.js} +2 -2
  30. package/dist/{toDate-CHtl9vts.js → toDate-CIpC_34u.js} +33 -20
  31. package/dist/{tooltip-B0mtKTXm.js → tooltip-DRaMBu06.js} +3 -3
  32. package/dist/{types-DBtDeUKD.js → types-Dzuoc3LN.js} +1 -1
  33. package/dist/{useAsyncData-B6hCGywC.js → useAsyncData-C56Khv_R.js} +1 -1
  34. package/dist/{useDateFormatter-B3mCQMP3.js → useDateFormatter-B_9k85Ex.js} +2 -2
  35. package/dist/{useDeepCompareMemoize-CmwDuYUH.js → useDeepCompareMemoize-Dt98v2ua.js} +1 -1
  36. package/dist/{useIframeCapabilities-DbdLoEDm.js → useIframeCapabilities-BkYHTrss.js} +1 -1
  37. package/dist/{useLifecycle-CjMjllqy.js → useLifecycle-BF6-z62y.js} +3 -3
  38. package/dist/{useTheme-CByZUW0p.js → useTheme-DykuNHR2.js} +2 -2
  39. package/dist/{vega-component-C2BYPkfd.js → vega-component-cSdqoAxe.js} +10 -10
  40. package/dist/{zod-BxdsqRPd.js → zod-BWkcDORu.js} +1 -1
  41. package/package.json +3 -3
  42. package/src/components/chat/chat-components.tsx +47 -0
  43. package/src/components/chat/chat-display.tsx +41 -7
  44. package/src/components/chat/chat-panel.tsx +37 -10
  45. package/src/components/chat/chat-utils.ts +42 -20
  46. package/src/components/chat/reasoning-accordion.tsx +14 -3
  47. package/src/components/chat/tool-call/shared.ts +13 -0
  48. package/src/components/chat/tool-call/tool-approval-card.tsx +62 -0
  49. package/src/components/chat/tool-call/tool-args.tsx +26 -0
  50. package/src/components/chat/tool-call/tool-call-view.tsx +99 -0
  51. package/src/components/chat/tool-call/tool-error-card.tsx +81 -0
  52. package/src/components/chat/tool-call/tool-history-row.tsx +153 -0
  53. package/src/components/chat/tool-call/tool-result.tsx +101 -0
  54. package/src/components/data-table/__tests__/column-header.test.ts +3 -1
  55. package/src/components/data-table/__tests__/column-header.test.tsx +308 -0
  56. package/src/components/data-table/__tests__/filter-by-values-picker.test.tsx +112 -0
  57. package/src/components/data-table/__tests__/filter-pill-editor.test.tsx +261 -0
  58. package/src/components/data-table/__tests__/filters.test.ts +196 -49
  59. package/src/components/data-table/charts/components/form-fields.tsx +1 -0
  60. package/src/components/data-table/column-header.tsx +349 -170
  61. package/src/components/data-table/date-filter-inputs.tsx +325 -0
  62. package/src/components/data-table/filter-by-values-picker.tsx +70 -9
  63. package/src/components/data-table/filter-pill-editor.tsx +410 -156
  64. package/src/components/data-table/filter-pills.tsx +69 -54
  65. package/src/components/data-table/filters.ts +218 -101
  66. package/src/components/data-table/header-items.tsx +8 -1
  67. package/src/components/data-table/operator-labels.ts +25 -0
  68. package/src/components/data-table/regex-input.tsx +61 -0
  69. package/src/components/dependency-graph/minimap-content.tsx +14 -3
  70. package/src/components/editor/actions/pair-with-agent-modal.tsx +140 -49
  71. package/src/components/editor/actions/useNotebookActions.tsx +3 -1
  72. package/src/components/editor/app-container.tsx +7 -1
  73. package/src/components/editor/chrome/panels/context-aware-panel/context-aware-panel.tsx +10 -2
  74. package/src/components/editor/chrome/wrapper/app-chrome.tsx +1 -0
  75. package/src/components/editor/chrome/wrapper/footer-items/backend-status.tsx +1 -1
  76. package/src/components/editor/chrome/wrapper/footer.tsx +4 -1
  77. package/src/components/editor/chrome/wrapper/panels.tsx +4 -1
  78. package/src/components/editor/chrome/wrapper/sidebar.tsx +4 -1
  79. package/src/components/editor/controls/Controls.tsx +11 -3
  80. package/src/components/editor/file-tree/file-explorer.tsx +12 -2
  81. package/src/components/editor/header/__tests__/status.test.tsx +108 -0
  82. package/src/components/editor/header/status.tsx +44 -10
  83. package/src/components/editor/navigation/__tests__/clipboard.test.ts +106 -0
  84. package/src/components/editor/navigation/__tests__/navigation.test.ts +70 -0
  85. package/src/components/editor/navigation/clipboard.ts +99 -25
  86. package/src/components/editor/navigation/navigation.ts +15 -1
  87. package/src/components/editor/notebook-cell.tsx +5 -0
  88. package/src/components/editor/output/console/ConsoleOutput.tsx +23 -5
  89. package/src/components/editor/output/console/__tests__/ConsoleOutput.test.tsx +114 -0
  90. package/src/components/editor/renderers/slides-layout/__tests__/compute-slide-cells.test.ts +5 -4
  91. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +55 -15
  92. package/src/components/editor/renderers/slides-layout/plugin.tsx +8 -25
  93. package/src/components/editor/renderers/slides-layout/slides-layout.tsx +19 -6
  94. package/src/components/editor/renderers/slides-layout/types.ts +40 -31
  95. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +1 -0
  96. package/src/components/home/components.tsx +6 -0
  97. package/src/components/pages/run-page.tsx +4 -1
  98. package/src/components/scratchpad/scratchpad.tsx +1 -0
  99. package/src/components/slides/__tests__/slide-notes.test.ts +131 -0
  100. package/src/components/slides/reveal-component.tsx +252 -147
  101. package/src/components/slides/slide-notes-editor.tsx +127 -0
  102. package/src/components/slides/slide-notes.ts +64 -0
  103. package/src/components/slides/slides.css +14 -0
  104. package/src/components/ui/combobox.tsx +24 -5
  105. package/src/components/ui/number-field.tsx +2 -0
  106. package/src/core/ai/tools/__tests__/registry.test.ts +10 -12
  107. package/src/core/ai/tools/registry.ts +9 -5
  108. package/src/core/cells/__tests__/cells.test.ts +187 -0
  109. package/src/core/cells/__tests__/pending-cut-service.test.tsx +123 -0
  110. package/src/core/cells/cells.ts +102 -17
  111. package/src/core/cells/document-changes.ts +6 -1
  112. package/src/core/cells/pending-cut-service.ts +55 -0
  113. package/src/core/cells/utils.ts +11 -0
  114. package/src/core/codemirror/cells/extensions.ts +10 -0
  115. package/src/core/codemirror/go-to-definition/__tests__/commands.test.ts +152 -0
  116. package/src/core/codemirror/go-to-definition/__tests__/utils.test.ts +99 -0
  117. package/src/core/codemirror/go-to-definition/commands.ts +382 -22
  118. package/src/core/codemirror/go-to-definition/utils.ts +23 -5
  119. package/src/core/edit-app.tsx +3 -2
  120. package/src/core/hotkeys/hotkeys.ts +5 -0
  121. package/src/core/islands/worker/worker.tsx +3 -2
  122. package/src/core/run-app.tsx +2 -1
  123. package/src/core/runtime/__tests__/runtime.test.ts +38 -17
  124. package/src/core/runtime/runtime.ts +57 -34
  125. package/src/core/wasm/__tests__/utils.test.ts +34 -0
  126. package/src/core/wasm/utils.ts +14 -0
  127. package/src/core/wasm/worker/bootstrap.ts +3 -2
  128. package/src/core/wasm/worker/worker.ts +3 -2
  129. package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +156 -0
  130. package/src/core/websocket/__tests__/useMarimoKernelConnection.test.ts +101 -0
  131. package/src/core/websocket/transports/__tests__/ws.test.ts +125 -0
  132. package/src/core/websocket/transports/basic.ts +1 -1
  133. package/src/core/websocket/transports/ws.ts +96 -0
  134. package/src/core/websocket/useMarimoKernelConnection.tsx +133 -54
  135. package/src/core/websocket/useWebSocket.tsx +3 -15
  136. package/src/css/app/Cell.css +10 -0
  137. package/src/plugins/core/__test__/sanitize.test.ts +30 -0
  138. package/src/plugins/impl/DropdownPlugin.tsx +12 -1
  139. package/src/plugins/impl/MultiselectPlugin.tsx +4 -0
  140. package/src/plugins/impl/SearchableSelect.tsx +11 -1
  141. package/src/plugins/impl/TabsPlugin.tsx +35 -7
  142. package/src/plugins/impl/__tests__/DropdownPlugin.test.tsx +56 -0
  143. package/src/plugins/impl/__tests__/TabsPlugin.test.tsx +154 -0
  144. package/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap +48 -36
  145. package/src/plugins/impl/data-frames/schema.ts +4 -1
  146. package/src/plugins/layout/DownloadPlugin.tsx +9 -7
  147. package/src/utils/__tests__/id-tree.test.ts +71 -0
  148. package/src/utils/download.ts +4 -2
  149. package/src/utils/id-tree.tsx +89 -0
  150. package/dist/assets/__vite-browser-external-rrUYDKRl.js +0 -1
  151. package/dist/assets/worker-Bfy15ViQ.js +0 -73
  152. package/dist/reveal-component-C97Ceb7e.js +0 -4863
  153. package/src/components/chat/tool-call-accordion.tsx +0 -247
@@ -0,0 +1,308 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import type { Column } from "@tanstack/react-table";
3
+ import { fireEvent, render, screen, within } from "@testing-library/react";
4
+ import { beforeAll, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ DateFilterMenu,
7
+ NumberFilterMenu,
8
+ TextFilterMenu,
9
+ } from "../column-header";
10
+ import { Filter } from "../filters";
11
+
12
+ beforeAll(() => {
13
+ global.HTMLElement.prototype.scrollIntoView = () => {
14
+ // jsdom does not implement scrollIntoView; Radix calls it on open.
15
+ };
16
+ // Radix Select gates pointer interactions on hasPointerCapture; jsdom omits it.
17
+ if (!global.HTMLElement.prototype.hasPointerCapture) {
18
+ global.HTMLElement.prototype.hasPointerCapture = () => false;
19
+ }
20
+ if (!global.HTMLElement.prototype.releasePointerCapture) {
21
+ global.HTMLElement.prototype.releasePointerCapture = () => {
22
+ // no-op
23
+ };
24
+ }
25
+ });
26
+
27
+ function mockColumn(initial?: ReturnType<typeof Filter.number>): Column<
28
+ unknown,
29
+ unknown
30
+ > & {
31
+ setFilterValue: ReturnType<typeof vi.fn>;
32
+ } {
33
+ let filterValue = initial;
34
+ const setFilterValue = vi.fn((next) => {
35
+ filterValue = next;
36
+ });
37
+ return {
38
+ id: "age",
39
+ columnDef: { meta: { dataType: "number", filterType: "number" } },
40
+ getFilterValue: () => filterValue,
41
+ setFilterValue,
42
+ } as unknown as Column<unknown, unknown> & {
43
+ setFilterValue: ReturnType<typeof vi.fn>;
44
+ };
45
+ }
46
+
47
+ describe("NumberFilterMenu", () => {
48
+ it("shows all expected operators in the dropdown", () => {
49
+ const column = mockColumn();
50
+ render(<NumberFilterMenu column={column} />);
51
+ const trigger = screen.getByRole("combobox");
52
+ fireEvent.click(trigger);
53
+ const listbox = screen.getByRole("listbox");
54
+ const labels = within(listbox)
55
+ .getAllByRole("option")
56
+ .map((o) => o.textContent);
57
+ expect(labels).toEqual([
58
+ "Between",
59
+ "Equals",
60
+ "Doesn't equal",
61
+ "Greater than",
62
+ "Greater than or equal",
63
+ "Less than",
64
+ "Less than or equal",
65
+ "Is null",
66
+ "Is not null",
67
+ ]);
68
+ });
69
+
70
+ it("between mode disables Apply until both min and max are defined", () => {
71
+ const column = mockColumn();
72
+ render(<NumberFilterMenu column={column} />);
73
+ const apply = screen.getByRole("button", { name: /apply/i });
74
+ expect(apply).toBeDisabled();
75
+
76
+ const min = screen.getByLabelText("min");
77
+ fireEvent.change(min, { target: { value: "1" } });
78
+ fireEvent.blur(min);
79
+ expect(apply).toBeDisabled();
80
+
81
+ const max = screen.getByLabelText("max");
82
+ fireEvent.change(max, { target: { value: "10" } });
83
+ fireEvent.blur(max);
84
+ expect(apply).not.toBeDisabled();
85
+ });
86
+
87
+ it("comparison mode shows a single value field seeded from current filter", () => {
88
+ const column = mockColumn(Filter.number({ operator: ">", value: 18 }));
89
+ render(<NumberFilterMenu column={column} />);
90
+ const value = screen.getByLabelText("value") as HTMLInputElement;
91
+ expect(value).toBeInTheDocument();
92
+ expect(value.value).toBe("18");
93
+ expect(screen.queryByLabelText("min")).not.toBeInTheDocument();
94
+ expect(screen.queryByLabelText("max")).not.toBeInTheDocument();
95
+ });
96
+
97
+ it("selecting a nullish operator hides value inputs and commits on Apply", () => {
98
+ const column = mockColumn();
99
+ render(<NumberFilterMenu column={column} />);
100
+ fireEvent.click(screen.getByRole("combobox"));
101
+ const listbox = screen.getByRole("listbox");
102
+ fireEvent.click(within(listbox).getByText("Is null"));
103
+ expect(column.setFilterValue).not.toHaveBeenCalled();
104
+ expect(screen.queryByLabelText("min")).not.toBeInTheDocument();
105
+ expect(screen.queryByLabelText("max")).not.toBeInTheDocument();
106
+ expect(screen.queryByLabelText("value")).not.toBeInTheDocument();
107
+ fireEvent.click(screen.getByRole("button", { name: /apply/i }));
108
+ expect(column.setFilterValue).toHaveBeenCalledWith(
109
+ Filter.number({ operator: "is_null" }),
110
+ );
111
+ });
112
+ });
113
+
114
+ function mockTextColumn(initial?: ReturnType<typeof Filter.text>): Column<
115
+ unknown,
116
+ unknown
117
+ > & {
118
+ setFilterValue: ReturnType<typeof vi.fn>;
119
+ } {
120
+ let filterValue = initial;
121
+ const setFilterValue = vi.fn((next) => {
122
+ filterValue = next;
123
+ });
124
+ return {
125
+ id: "name",
126
+ columnDef: { meta: { dataType: "string", filterType: "text" } },
127
+ getFilterValue: () => filterValue,
128
+ setFilterValue,
129
+ } as unknown as Column<unknown, unknown> & {
130
+ setFilterValue: ReturnType<typeof vi.fn>;
131
+ };
132
+ }
133
+
134
+ describe("TextFilterMenu", () => {
135
+ it("shows all 11 text operators in the dropdown", () => {
136
+ const column = mockTextColumn();
137
+ render(<TextFilterMenu column={column} />);
138
+ fireEvent.click(screen.getByRole("combobox"));
139
+ const listbox = screen.getByRole("listbox");
140
+ const labels = within(listbox)
141
+ .getAllByRole("option")
142
+ .map((o) => o.textContent);
143
+ expect(labels).toEqual([
144
+ "Contains",
145
+ "Equals",
146
+ "Doesn't equal",
147
+ "Matches regex",
148
+ "Starts with",
149
+ "Ends with",
150
+ "Is in",
151
+ "Not in",
152
+ "Is empty",
153
+ "Is null",
154
+ "Is not null",
155
+ ]);
156
+ });
157
+
158
+ it("single-string operator renders a text input seeded from current filter", () => {
159
+ const column = mockTextColumn(
160
+ Filter.text({ operator: "equals", text: "alice" }),
161
+ );
162
+ render(<TextFilterMenu column={column} />);
163
+ const input = screen.getByPlaceholderText("Text...") as HTMLInputElement;
164
+ expect(input).toBeInTheDocument();
165
+ expect(input.value).toBe("alice");
166
+ });
167
+
168
+ it("'in' operator renders the creatable values picker", async () => {
169
+ const column = mockTextColumn(
170
+ Filter.text({ operator: "in", values: ["a", "b"] }),
171
+ );
172
+ const calculateTopKRows = vi.fn(async () => ({
173
+ data: [["a", 1] as [unknown, number]],
174
+ }));
175
+ render(
176
+ <TextFilterMenu column={column} calculateTopKRows={calculateTopKRows} />,
177
+ );
178
+ expect(
179
+ await screen.findByPlaceholderText(/Search or add a value/i),
180
+ ).toBeInTheDocument();
181
+ expect(screen.queryByPlaceholderText("Text...")).not.toBeInTheDocument();
182
+ });
183
+
184
+ it("selecting is_empty hides the value UI and commits on Apply", () => {
185
+ const column = mockTextColumn();
186
+ render(<TextFilterMenu column={column} />);
187
+ fireEvent.click(screen.getByRole("combobox"));
188
+ const listbox = screen.getByRole("listbox");
189
+ fireEvent.click(within(listbox).getByText("Is empty"));
190
+ expect(column.setFilterValue).not.toHaveBeenCalled();
191
+ expect(screen.queryByPlaceholderText("Text...")).not.toBeInTheDocument();
192
+ fireEvent.click(screen.getByRole("button", { name: /apply/i }));
193
+ expect(column.setFilterValue).toHaveBeenCalledWith(
194
+ Filter.text({ operator: "is_empty" }),
195
+ );
196
+ });
197
+
198
+ it("apply is disabled when scalar text is empty", () => {
199
+ const column = mockTextColumn();
200
+ render(<TextFilterMenu column={column} />);
201
+ expect(screen.getByRole("button", { name: /apply/i })).toBeDisabled();
202
+ fireEvent.change(screen.getByPlaceholderText("Text..."), {
203
+ target: { value: "x" },
204
+ });
205
+ expect(screen.getByRole("button", { name: /apply/i })).not.toBeDisabled();
206
+ });
207
+ });
208
+
209
+ type DateFilterValue = ReturnType<typeof Filter.date>;
210
+
211
+ function mockDateColumn(
212
+ filterType: "date" | "datetime" | "time" = "date",
213
+ initial?: DateFilterValue,
214
+ ): Column<unknown, unknown> & {
215
+ setFilterValue: ReturnType<typeof vi.fn>;
216
+ } {
217
+ let filterValue = initial;
218
+ const setFilterValue = vi.fn((next) => {
219
+ filterValue = next;
220
+ });
221
+ return {
222
+ id: "when",
223
+ columnDef: { meta: { dataType: filterType, filterType } },
224
+ getFilterValue: () => filterValue,
225
+ setFilterValue,
226
+ } as unknown as Column<unknown, unknown> & {
227
+ setFilterValue: ReturnType<typeof vi.fn>;
228
+ };
229
+ }
230
+
231
+ describe("DateFilterMenu", () => {
232
+ it("shows all expected operators in the dropdown", () => {
233
+ const column = mockDateColumn("date");
234
+ render(<DateFilterMenu column={column} filterType="date" />);
235
+ fireEvent.click(screen.getByRole("combobox"));
236
+ const listbox = screen.getByRole("listbox");
237
+ const labels = within(listbox)
238
+ .getAllByRole("option")
239
+ .map((o) => o.textContent);
240
+ expect(labels).toEqual([
241
+ "Between",
242
+ "Equals",
243
+ "Doesn't equal",
244
+ "Greater than",
245
+ "Greater than or equal",
246
+ "Less than",
247
+ "Less than or equal",
248
+ "Is null",
249
+ "Is not null",
250
+ ]);
251
+ });
252
+
253
+ it("defaults to between mode and disables Apply until both bounds set", () => {
254
+ const column = mockDateColumn("date");
255
+ render(<DateFilterMenu column={column} filterType="date" />);
256
+ expect(screen.getByLabelText("range")).toBeInTheDocument();
257
+ expect(screen.getByRole("button", { name: /apply/i })).toBeDisabled();
258
+ expect(screen.queryByLabelText("value")).not.toBeInTheDocument();
259
+ });
260
+
261
+ it("seeds between min/max from current filter", () => {
262
+ const column = mockDateColumn(
263
+ "date",
264
+ Filter.date({
265
+ operator: "between",
266
+ min: new Date("2024-01-01T00:00:00Z"),
267
+ max: new Date("2024-06-01T00:00:00Z"),
268
+ }),
269
+ );
270
+ render(<DateFilterMenu column={column} filterType="date" />);
271
+ expect(screen.getByLabelText("range")).toBeInTheDocument();
272
+ expect(screen.getByRole("button", { name: /apply/i })).not.toBeDisabled();
273
+ });
274
+
275
+ it("comparison operator swaps range for a single value picker", () => {
276
+ const column = mockDateColumn(
277
+ "date",
278
+ Filter.date({
279
+ operator: ">",
280
+ value: new Date("2024-01-01T00:00:00Z"),
281
+ }),
282
+ );
283
+ render(<DateFilterMenu column={column} filterType="date" />);
284
+ expect(screen.getByLabelText("value")).toBeInTheDocument();
285
+ expect(screen.queryByLabelText("range")).not.toBeInTheDocument();
286
+ });
287
+
288
+ it("time filter type renders two TimeFields for between", () => {
289
+ const column = mockDateColumn("time");
290
+ render(<DateFilterMenu column={column} filterType="time" />);
291
+ expect(screen.getByLabelText("min")).toBeInTheDocument();
292
+ expect(screen.getByLabelText("max")).toBeInTheDocument();
293
+ });
294
+
295
+ it("selecting a nullish operator hides value inputs and commits on Apply", () => {
296
+ const column = mockDateColumn("date");
297
+ render(<DateFilterMenu column={column} filterType="date" />);
298
+ fireEvent.click(screen.getByRole("combobox"));
299
+ const listbox = screen.getByRole("listbox");
300
+ fireEvent.click(within(listbox).getByText("Is null"));
301
+ expect(screen.queryByLabelText("range")).not.toBeInTheDocument();
302
+ expect(screen.queryByLabelText("value")).not.toBeInTheDocument();
303
+ fireEvent.click(screen.getByRole("button", { name: /apply/i }));
304
+ expect(column.setFilterValue).toHaveBeenCalledWith(
305
+ Filter.date({ operator: "is_null" }),
306
+ );
307
+ });
308
+ });
@@ -0,0 +1,112 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import type { Column } from "@tanstack/react-table";
3
+ import { fireEvent, render, screen } from "@testing-library/react";
4
+ import { beforeAll, describe, expect, it, vi } from "vitest";
5
+ import { FilterByValuesList } from "../filter-by-values-picker";
6
+
7
+ beforeAll(() => {
8
+ global.HTMLElement.prototype.scrollIntoView = () => {
9
+ // jsdom does not implement scrollIntoView; cmdk calls it on selection.
10
+ };
11
+ });
12
+
13
+ function mockColumn(): Column<unknown, unknown> {
14
+ return {
15
+ id: "name",
16
+ columnDef: { meta: { dataType: "string" } },
17
+ } as unknown as Column<unknown, unknown>;
18
+ }
19
+
20
+ async function calculateTopK() {
21
+ return {
22
+ data: [
23
+ ["alice", 3],
24
+ ["bob", 1],
25
+ ] as Array<[string, number]>,
26
+ };
27
+ }
28
+
29
+ describe("FilterByValuesList — creatable", () => {
30
+ it("shows '+ Add \"X\"' item when creatable and query is non-empty", async () => {
31
+ const onChange = vi.fn();
32
+ render(
33
+ <FilterByValuesList
34
+ column={mockColumn()}
35
+ calculateTopKRows={calculateTopK}
36
+ chosenValues={new Set()}
37
+ onChange={onChange}
38
+ creatable={true}
39
+ />,
40
+ );
41
+ await screen.findByText("alice");
42
+ const input = screen.getByPlaceholderText(/Search or add/i);
43
+ fireEvent.change(input, { target: { value: "carol" } });
44
+ expect(await screen.findByText(/\+ Add "carol"/)).toBeInTheDocument();
45
+ });
46
+
47
+ it("commits the literal when '+ Add' is selected", async () => {
48
+ const onChange = vi.fn();
49
+ render(
50
+ <FilterByValuesList
51
+ column={mockColumn()}
52
+ calculateTopKRows={calculateTopK}
53
+ chosenValues={new Set()}
54
+ onChange={onChange}
55
+ creatable={true}
56
+ />,
57
+ );
58
+ await screen.findByText("alice");
59
+ const input = screen.getByPlaceholderText(/Search or add/i);
60
+ fireEvent.change(input, { target: { value: "carol" } });
61
+ fireEvent.click(await screen.findByText(/\+ Add "carol"/));
62
+ expect(onChange).toHaveBeenCalledWith(["carol"]);
63
+ });
64
+
65
+ it("Enter key in creatable mode commits the query as a value", async () => {
66
+ const onChange = vi.fn();
67
+ render(
68
+ <FilterByValuesList
69
+ column={mockColumn()}
70
+ calculateTopKRows={calculateTopK}
71
+ chosenValues={new Set()}
72
+ onChange={onChange}
73
+ creatable={true}
74
+ />,
75
+ );
76
+ await screen.findByText("alice");
77
+ const input = screen.getByPlaceholderText(/Search or add/i);
78
+ fireEvent.change(input, { target: { value: "dave" } });
79
+ fireEvent.keyDown(input, { key: "Enter" });
80
+ expect(onChange).toHaveBeenCalledWith(["dave"]);
81
+ });
82
+
83
+ it("does NOT show '+ Add' when creatable is false", async () => {
84
+ render(
85
+ <FilterByValuesList
86
+ column={mockColumn()}
87
+ calculateTopKRows={calculateTopK}
88
+ chosenValues={new Set()}
89
+ onChange={vi.fn()}
90
+ creatable={false}
91
+ />,
92
+ );
93
+ await screen.findByText("alice");
94
+ const input = screen.getByPlaceholderText(/Search among/i);
95
+ fireEvent.change(input, { target: { value: "carol" } });
96
+ expect(screen.queryByText(/\+ Add/)).not.toBeInTheDocument();
97
+ });
98
+
99
+ it("renders chosen values that are not in top-K with — count", async () => {
100
+ render(
101
+ <FilterByValuesList
102
+ column={mockColumn()}
103
+ calculateTopKRows={calculateTopK}
104
+ chosenValues={new Set(["zara"])}
105
+ onChange={vi.fn()}
106
+ />,
107
+ );
108
+ await screen.findByText("alice");
109
+ expect(screen.getByText("zara")).toBeInTheDocument();
110
+ expect(screen.getByText("—")).toBeInTheDocument();
111
+ });
112
+ });
@@ -0,0 +1,261 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import type { Column, Table } from "@tanstack/react-table";
3
+ import { fireEvent, render, screen } from "@testing-library/react";
4
+ import { beforeAll, describe, expect, it, vi } from "vitest";
5
+ import { TooltipProvider } from "@/components/ui/tooltip";
6
+ import { FilterPillEditor } from "../filter-pill-editor";
7
+ import { Filter } from "../filters";
8
+
9
+ const renderWithProviders = (ui: React.ReactElement) =>
10
+ render(<TooltipProvider>{ui}</TooltipProvider>);
11
+
12
+ beforeAll(() => {
13
+ global.HTMLElement.prototype.scrollIntoView = () => {
14
+ // jsdom does not implement scrollIntoView; cmdk calls it on selection.
15
+ };
16
+ // jsdom lacks hasPointerCapture used by radix Select
17
+ if (!global.HTMLElement.prototype.hasPointerCapture) {
18
+ global.HTMLElement.prototype.hasPointerCapture = () => false;
19
+ }
20
+ if (!global.HTMLElement.prototype.scrollTo) {
21
+ global.HTMLElement.prototype.scrollTo = () => {
22
+ // noop for jsdom
23
+ };
24
+ }
25
+ });
26
+
27
+ function makeColumn(
28
+ id: string,
29
+ filterType:
30
+ | "text"
31
+ | "number"
32
+ | "boolean"
33
+ | "select"
34
+ | "date"
35
+ | "datetime"
36
+ | "time",
37
+ ): Column<unknown, unknown> {
38
+ return {
39
+ id,
40
+ columnDef: { meta: { filterType, dataType: "string" } },
41
+ } as unknown as Column<unknown, unknown>;
42
+ }
43
+
44
+ function mockTable(): Table<unknown> {
45
+ const columns = [
46
+ makeColumn("name", "text"),
47
+ makeColumn("age", "number"),
48
+ makeColumn("when", "date"),
49
+ makeColumn("at", "datetime"),
50
+ makeColumn("clock", "time"),
51
+ ];
52
+ return {
53
+ getAllColumns: () => columns,
54
+ getColumn: (id: string) => columns.find((c) => c.id === id),
55
+ setColumnFilters: vi.fn(),
56
+ } as unknown as Table<unknown>;
57
+ }
58
+
59
+ async function calculateTopK() {
60
+ return {
61
+ data: [
62
+ ["alice", 3],
63
+ ["bob", 1],
64
+ ] as Array<[string, number]>,
65
+ };
66
+ }
67
+
68
+ describe("FilterPillEditor — snapshot rehydration", () => {
69
+ it("rehydrates a number > snapshot with seeded value", () => {
70
+ renderWithProviders(
71
+ <FilterPillEditor
72
+ snapshot={{
73
+ columnId: "age",
74
+ value: Filter.number({ operator: ">", value: 18 }),
75
+ }}
76
+ table={mockTable()}
77
+ onClose={vi.fn()}
78
+ />,
79
+ );
80
+ expect(screen.getByDisplayValue("18")).toBeInTheDocument();
81
+ expect(screen.getByLabelText("value")).toBeInTheDocument();
82
+ // No min/max range fields rendered for comparison operator.
83
+ expect(screen.queryByLabelText("min")).not.toBeInTheDocument();
84
+ expect(screen.queryByLabelText("max")).not.toBeInTheDocument();
85
+ });
86
+
87
+ it("rehydrates a number between snapshot with seeded min/max", () => {
88
+ renderWithProviders(
89
+ <FilterPillEditor
90
+ snapshot={{
91
+ columnId: "age",
92
+ value: Filter.number({ operator: "between", min: 1, max: 9 }),
93
+ }}
94
+ table={mockTable()}
95
+ onClose={vi.fn()}
96
+ />,
97
+ );
98
+ expect(screen.getByDisplayValue("1")).toBeInTheDocument();
99
+ expect(screen.getByDisplayValue("9")).toBeInTheDocument();
100
+ });
101
+
102
+ it("rehydrates a text in snapshot seeding the creatable picker", async () => {
103
+ renderWithProviders(
104
+ <FilterPillEditor
105
+ snapshot={{
106
+ columnId: "name",
107
+ value: Filter.text({ operator: "in", values: ["a", "b"] }),
108
+ }}
109
+ table={mockTable()}
110
+ calculateTopKRows={calculateTopK}
111
+ onClose={vi.fn()}
112
+ />,
113
+ );
114
+ expect(await screen.findByText("[a, b]")).toBeInTheDocument();
115
+ });
116
+
117
+ it("rehydrates a text contains snapshot with seeded text", () => {
118
+ renderWithProviders(
119
+ <FilterPillEditor
120
+ snapshot={{
121
+ columnId: "name",
122
+ value: Filter.text({ operator: "contains", text: "hello" }),
123
+ }}
124
+ table={mockTable()}
125
+ onClose={vi.fn()}
126
+ />,
127
+ );
128
+ expect(screen.getByDisplayValue("hello")).toBeInTheDocument();
129
+ });
130
+
131
+ it("hides the value slot for is_null/is_not_null/is_empty", () => {
132
+ const { rerender } = renderWithProviders(
133
+ <FilterPillEditor
134
+ snapshot={{
135
+ columnId: "name",
136
+ value: Filter.text({ operator: "is_null" }),
137
+ }}
138
+ table={mockTable()}
139
+ onClose={vi.fn()}
140
+ />,
141
+ );
142
+ expect(screen.queryByText("Value")).not.toBeInTheDocument();
143
+
144
+ rerender(
145
+ <TooltipProvider>
146
+ <FilterPillEditor
147
+ snapshot={{
148
+ columnId: "name",
149
+ value: Filter.text({ operator: "is_empty" }),
150
+ }}
151
+ table={mockTable()}
152
+ onClose={vi.fn()}
153
+ />
154
+ </TooltipProvider>,
155
+ );
156
+ expect(screen.queryByText("Value")).not.toBeInTheDocument();
157
+ });
158
+ });
159
+
160
+ describe("FilterPillEditor — date/datetime/time", () => {
161
+ it("rehydrates a date between snapshot with the range picker", () => {
162
+ renderWithProviders(
163
+ <FilterPillEditor
164
+ snapshot={{
165
+ columnId: "when",
166
+ value: Filter.date({
167
+ operator: "between",
168
+ min: new Date("2024-01-01T00:00:00Z"),
169
+ max: new Date("2024-06-01T00:00:00Z"),
170
+ }),
171
+ }}
172
+ table={mockTable()}
173
+ onClose={vi.fn()}
174
+ />,
175
+ );
176
+ expect(screen.getByLabelText("range")).toBeInTheDocument();
177
+ expect(screen.queryByLabelText("value")).not.toBeInTheDocument();
178
+ });
179
+
180
+ it("rehydrates a datetime <= snapshot with a single value picker", () => {
181
+ renderWithProviders(
182
+ <FilterPillEditor
183
+ snapshot={{
184
+ columnId: "at",
185
+ value: Filter.datetime({
186
+ operator: "<=",
187
+ value: new Date("2024-06-01T12:00:00Z"),
188
+ }),
189
+ }}
190
+ table={mockTable()}
191
+ onClose={vi.fn()}
192
+ />,
193
+ );
194
+ expect(screen.getByLabelText("value")).toBeInTheDocument();
195
+ expect(screen.queryByLabelText("range")).not.toBeInTheDocument();
196
+ });
197
+
198
+ it("renders min/max TimeFields for time between", () => {
199
+ renderWithProviders(
200
+ <FilterPillEditor
201
+ snapshot={{
202
+ columnId: "clock",
203
+ value: Filter.time({
204
+ operator: "between",
205
+ min: new Date("2024-01-01T08:00:00Z"),
206
+ max: new Date("2024-01-01T17:00:00Z"),
207
+ }),
208
+ }}
209
+ table={mockTable()}
210
+ onClose={vi.fn()}
211
+ />,
212
+ );
213
+ expect(screen.getByLabelText("min")).toBeInTheDocument();
214
+ expect(screen.getByLabelText("max")).toBeInTheDocument();
215
+ });
216
+
217
+ it("hides the value slot for date is_null", () => {
218
+ renderWithProviders(
219
+ <FilterPillEditor
220
+ snapshot={{
221
+ columnId: "when",
222
+ value: Filter.date({ operator: "is_null" }),
223
+ }}
224
+ table={mockTable()}
225
+ onClose={vi.fn()}
226
+ />,
227
+ );
228
+ expect(screen.queryByText("Value")).not.toBeInTheDocument();
229
+ expect(screen.queryByLabelText("range")).not.toBeInTheDocument();
230
+ });
231
+ });
232
+
233
+ describe("FilterPillEditor — apply", () => {
234
+ it("commits a number > filter via setColumnFilters", () => {
235
+ const table = mockTable();
236
+ const onClose = vi.fn();
237
+ renderWithProviders(
238
+ <FilterPillEditor
239
+ snapshot={{
240
+ columnId: "age",
241
+ value: Filter.number({ operator: ">", value: 18 }),
242
+ }}
243
+ table={table}
244
+ onClose={onClose}
245
+ />,
246
+ );
247
+ fireEvent.click(screen.getByLabelText("Apply filter"));
248
+ expect(table.setColumnFilters).toHaveBeenCalledTimes(1);
249
+ expect(onClose).toHaveBeenCalledTimes(1);
250
+
251
+ const updater = (table.setColumnFilters as ReturnType<typeof vi.fn>).mock
252
+ .calls[0][0];
253
+ const next = updater([]);
254
+ expect(next).toEqual([
255
+ {
256
+ id: "age",
257
+ value: { type: "number", operator: ">", value: 18 },
258
+ },
259
+ ]);
260
+ });
261
+ });