@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.
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-B9qGgair.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-Co2AuBAx.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
@@ -5,8 +5,8 @@ import type {
5
5
  RowSelectionState,
6
6
  SortingState,
7
7
  } from "@tanstack/react-table";
8
- import { fireEvent, render, screen, within } from "@testing-library/react";
9
- import { describe, expect, it, vi } from "vitest";
8
+ import { act, fireEvent, render, screen, within } from "@testing-library/react";
9
+ import { afterEach, describe, expect, it, vi } from "vitest";
10
10
  import { TooltipProvider } from "@/components/ui/tooltip";
11
11
  import { DataTable } from "../data-table";
12
12
 
@@ -16,6 +16,12 @@ interface TestData {
16
16
  }
17
17
 
18
18
  describe("DataTable", () => {
19
+ // Restore real timers unconditionally so a failed assertion in a
20
+ // fake-timer test can't leak fake timers into later tests.
21
+ afterEach(() => {
22
+ vi.useRealTimers();
23
+ });
24
+
19
25
  it("should maintain selection state when remounted", () => {
20
26
  const mockOnRowSelectionChange = vi.fn();
21
27
  const testData: TestData[] = [
@@ -63,17 +69,15 @@ describe("DataTable", () => {
63
69
  expect(commonProps.rowSelection).toEqual(initialRowSelection);
64
70
  });
65
71
 
66
- it("applies hoverTemplate to the row title using row values", () => {
72
+ it("shows the hoverTemplate text as a styled tooltip on hover", async () => {
73
+ vi.useFakeTimers();
67
74
  interface RowData {
68
75
  id: number;
69
76
  first: string;
70
77
  last: string;
71
78
  }
72
79
 
73
- const testData: RowData[] = [
74
- { id: 1, first: "Michael", last: "Scott" },
75
- { id: 2, first: "Jim", last: "Halpert" },
76
- ];
80
+ const testData: RowData[] = [{ id: 1, first: "Michael", last: "Scott" }];
77
81
 
78
82
  const columns: ColumnDef<RowData>[] = [
79
83
  { accessorKey: "first", header: "First" },
@@ -86,7 +90,7 @@ describe("DataTable", () => {
86
90
  data={testData}
87
91
  columns={columns}
88
92
  selection={null}
89
- totalRows={2}
93
+ totalRows={1}
90
94
  totalColumns={2}
91
95
  pagination={false}
92
96
  hoverTemplate={"{{first}} {{last}}"}
@@ -94,11 +98,149 @@ describe("DataTable", () => {
94
98
  </TooltipProvider>,
95
99
  );
96
100
 
97
- // Grab all rows and assert title attribute computed from template
98
101
  const rows = screen.getAllByRole("row");
99
- // The first row is header; subsequent rows correspond to data
100
- expect(rows[1]).toHaveAttribute("title", "Michael Scott");
101
- expect(rows[2]).toHaveAttribute("title", "Jim Halpert");
102
+ // Native title is gone; hover text now comes from the styled tooltip.
103
+ expect(rows[1]).not.toHaveAttribute("title");
104
+
105
+ const cell = within(rows[1]).getAllByRole("cell")[0];
106
+ fireEvent.mouseOver(cell, { buttons: 0 });
107
+ act(() => {
108
+ vi.advanceTimersByTime(400);
109
+ });
110
+
111
+ // Radix renders the content twice (visible + a11y-hidden), so match all.
112
+ expect(screen.getAllByText("Michael Scott").length).toBeGreaterThan(0);
113
+ });
114
+
115
+ it("shows per-cell hover text from cellHoverTexts on hover", () => {
116
+ vi.useFakeTimers();
117
+ const testData: TestData[] = [{ id: 1, name: "Test 1" }];
118
+ const columns: ColumnDef<TestData>[] = [
119
+ { id: "name", accessorKey: "name", header: "Name" },
120
+ ];
121
+
122
+ render(
123
+ <TooltipProvider>
124
+ <DataTable
125
+ data={testData}
126
+ columns={columns}
127
+ selection={null}
128
+ totalRows={1}
129
+ totalColumns={1}
130
+ pagination={false}
131
+ cellHoverTexts={{ "0": { name: "per-cell tip" } }}
132
+ />
133
+ </TooltipProvider>,
134
+ );
135
+
136
+ const cell = within(screen.getAllByRole("row")[1]).getByRole("cell");
137
+ fireEvent.mouseOver(cell, { buttons: 0 });
138
+ act(() => {
139
+ vi.advanceTimersByTime(400);
140
+ });
141
+
142
+ expect(screen.getAllByText("per-cell tip").length).toBeGreaterThan(0);
143
+ });
144
+
145
+ it("links the focused cell to the tooltip content for assistive tech", () => {
146
+ const testData: TestData[] = [{ id: 1, name: "Test 1" }];
147
+ const columns: ColumnDef<TestData>[] = [
148
+ { id: "name", accessorKey: "name", header: "Name" },
149
+ ];
150
+
151
+ render(
152
+ <TooltipProvider>
153
+ <DataTable
154
+ data={testData}
155
+ columns={columns}
156
+ selection={null}
157
+ totalRows={1}
158
+ totalColumns={1}
159
+ pagination={false}
160
+ cellHoverTexts={{ "0": { name: "focus tip" } }}
161
+ />
162
+ </TooltipProvider>,
163
+ );
164
+
165
+ const cell = within(screen.getAllByRole("row")[1]).getByRole("cell");
166
+ fireEvent.focus(cell);
167
+
168
+ const describedBy = cell.getAttribute("aria-describedby");
169
+ expect(describedBy).toBeTruthy();
170
+ expect(document.getElementById(describedBy as string)).toHaveTextContent(
171
+ "focus tip",
172
+ );
173
+
174
+ fireEvent.blur(cell);
175
+ expect(cell).not.toHaveAttribute("aria-describedby");
176
+ });
177
+
178
+ it("does not show a tooltip on pointer-induced focus", () => {
179
+ const testData: TestData[] = [{ id: 1, name: "Test 1" }];
180
+ const columns: ColumnDef<TestData>[] = [
181
+ { id: "name", accessorKey: "name", header: "Name" },
182
+ ];
183
+
184
+ render(
185
+ <TooltipProvider>
186
+ <DataTable
187
+ data={testData}
188
+ columns={columns}
189
+ selection={null}
190
+ totalRows={1}
191
+ totalColumns={1}
192
+ pagination={false}
193
+ cellHoverTexts={{ "0": { name: "click tip" } }}
194
+ />
195
+ </TooltipProvider>,
196
+ );
197
+
198
+ const cell = within(screen.getAllByRole("row")[1]).getByRole("cell");
199
+ // A click focuses the cell; the resulting focus must not show a tooltip.
200
+ fireEvent.mouseDown(cell);
201
+ fireEvent.focus(cell);
202
+
203
+ expect(cell).not.toHaveAttribute("aria-describedby");
204
+ expect(screen.queryByText("click tip")).toBeNull();
205
+ });
206
+
207
+ it("does not let a pending hover timer overwrite a focus tooltip", () => {
208
+ vi.useFakeTimers();
209
+ interface RowData {
210
+ id: number;
211
+ a: string;
212
+ b: string;
213
+ }
214
+ const testData: RowData[] = [{ id: 1, a: "a", b: "b" }];
215
+ const columns: ColumnDef<RowData>[] = [
216
+ { id: "a", accessorKey: "a", header: "A" },
217
+ { id: "b", accessorKey: "b", header: "B" },
218
+ ];
219
+
220
+ render(
221
+ <TooltipProvider>
222
+ <DataTable
223
+ data={testData}
224
+ columns={columns}
225
+ selection={null}
226
+ totalRows={1}
227
+ totalColumns={2}
228
+ pagination={false}
229
+ cellHoverTexts={{ "0": { a: "hover A", b: "focus B" } }}
230
+ />
231
+ </TooltipProvider>,
232
+ );
233
+
234
+ const cells = within(screen.getAllByRole("row")[1]).getAllByRole("cell");
235
+ // Start a pending hover-show on cell A, then focus cell B before it fires.
236
+ fireEvent.mouseOver(cells[0], { buttons: 0 });
237
+ fireEvent.focus(cells[1]);
238
+ act(() => {
239
+ vi.advanceTimersByTime(400);
240
+ });
241
+
242
+ expect(screen.getAllByText("focus B").length).toBeGreaterThan(0);
243
+ expect(screen.queryByText("hover A")).toBeNull();
102
244
  });
103
245
 
104
246
  it("does not virtualize small datasets without pagination", () => {
@@ -0,0 +1,204 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ "use no memo";
3
+
4
+ // tanstack/table is not compatible with React compiler
5
+ // https://github.com/TanStack/table/issues/5567
6
+
7
+ import type { Table } from "@tanstack/react-table";
8
+ import { Columns3Icon, EyeIcon, EyeOffIcon } from "lucide-react";
9
+ import React from "react";
10
+ import { ColumnName } from "@/components/datasources/components";
11
+ import { Button } from "@/components/ui/button";
12
+ import {
13
+ Command,
14
+ CommandEmpty,
15
+ CommandInput,
16
+ CommandItem,
17
+ CommandList,
18
+ CommandSeparator,
19
+ } from "@/components/ui/command";
20
+ import {
21
+ Popover,
22
+ PopoverContent,
23
+ PopoverTrigger,
24
+ } from "@/components/ui/popover";
25
+ import { type BulkAction, useSelectList } from "@/components/ui/select-core";
26
+ import type { DataType } from "@/core/kernel/messages";
27
+ import { cn } from "@/utils/cn";
28
+ import { Events } from "@/utils/events";
29
+ import { smartMatchFilter } from "@/utils/smartMatch";
30
+ import { NAMELESS_COLUMN_PREFIX } from "./columns";
31
+ import { INDEX_COLUMN_NAME, SELECT_COLUMN_ID } from "./types";
32
+
33
+ function getUserColumns<TData>(table: Table<TData>) {
34
+ return table
35
+ .getAllLeafColumns()
36
+ .filter(
37
+ (column) =>
38
+ column.id !== SELECT_COLUMN_ID &&
39
+ column.id !== INDEX_COLUMN_NAME &&
40
+ !column.id.startsWith(NAMELESS_COLUMN_PREFIX),
41
+ );
42
+ }
43
+
44
+ export const ColumnVisibilityDropdown = <TData,>({
45
+ table,
46
+ }: {
47
+ table: Table<TData>;
48
+ }) => {
49
+ const userColumns = getUserColumns(table);
50
+ const options = userColumns.map((column) => ({
51
+ value: column.id,
52
+ label: column.id,
53
+ disabled: !column.getCanHide(),
54
+ data: { dataType: column.columnDef.meta?.dataType },
55
+ }));
56
+ // Modeled as a select list over hidden columns: "selected" means hidden, so
57
+ // the hook's pinning floats hidden columns to the top and freezes that order
58
+ // while the menu is open.
59
+ const hiddenIds = userColumns
60
+ .filter((column) => !column.getIsVisible())
61
+ .map((column) => column.id);
62
+
63
+ const applyHidden = (next: string[] | string | null) => {
64
+ const hidden = new Set(Array.isArray(next) ? next : []);
65
+ table.setColumnVisibility((previous) => ({
66
+ ...previous,
67
+ ...Object.fromEntries(
68
+ userColumns.map((column) => [column.id, !hidden.has(column.id)]),
69
+ ),
70
+ }));
71
+ };
72
+
73
+ const list = useSelectList<string>({
74
+ options,
75
+ value: hiddenIds,
76
+ onChange: applyHidden,
77
+ multiple: true,
78
+ filterFn: smartMatchFilter,
79
+ pinSelected: true,
80
+ });
81
+ // With selection modeling hidden columns, select-matching hides the visible
82
+ // matches and deselect-matching shows the hidden ones.
83
+ const matchingActions = list.bulkActions.filter(
84
+ (
85
+ action,
86
+ ): action is Extract<
87
+ BulkAction<string>,
88
+ { kind: "select-matching" | "deselect-matching" }
89
+ > =>
90
+ action.kind === "select-matching" || action.kind === "deselect-matching",
91
+ );
92
+
93
+ return (
94
+ <Popover open={list.open} onOpenChange={list.setOpen}>
95
+ <PopoverTrigger asChild={true}>
96
+ <Button
97
+ variant="text"
98
+ size="xs"
99
+ data-testid="column-visibility-trigger"
100
+ onMouseDown={Events.preventFocus}
101
+ className={cn(
102
+ "print:hidden text-xs gap-1",
103
+ list.open ? "text-primary" : "text-muted-foreground",
104
+ )}
105
+ >
106
+ <Columns3Icon className="w-3.5 h-3.5" />
107
+ Columns
108
+ </Button>
109
+ </PopoverTrigger>
110
+ <PopoverContent className="w-64 p-0" align="end">
111
+ <Command shouldFilter={false}>
112
+ <CommandInput
113
+ placeholder="Search columns..."
114
+ value={list.searchQuery}
115
+ onValueChange={list.setSearchQuery}
116
+ />
117
+ <CommandList>
118
+ <CommandEmpty>No results.</CommandEmpty>
119
+ {list.searchQuery === "" ? (
120
+ <>
121
+ <CommandItem
122
+ value="__show_all__"
123
+ disabled={hiddenIds.length === 0}
124
+ onSelect={() => applyHidden([])}
125
+ className="cursor-pointer"
126
+ >
127
+ <EyeIcon className="w-3 h-3 mr-1.5" />
128
+ Show all
129
+ </CommandItem>
130
+ <CommandSeparator />
131
+ </>
132
+ ) : (
133
+ matchingActions.length > 0 && (
134
+ <>
135
+ {matchingActions.map((action) => (
136
+ <CommandItem
137
+ key={action.kind}
138
+ value={`__bulk_${action.kind}`}
139
+ onSelect={action.run}
140
+ className="cursor-pointer"
141
+ >
142
+ {action.kind === "select-matching" ? (
143
+ <EyeOffIcon className="w-3 h-3 mr-1.5" />
144
+ ) : (
145
+ <EyeIcon className="w-3 h-3 mr-1.5" />
146
+ )}
147
+ {action.kind === "select-matching" ? "Hide" : "Show"}{" "}
148
+ {action.items.length} matching
149
+ </CommandItem>
150
+ ))}
151
+ <CommandSeparator />
152
+ </>
153
+ )
154
+ )}
155
+ {list.visibleOptions.map((option, index) => {
156
+ const hidden = list.isChecked(option.value);
157
+ const { dataType } = option.data as {
158
+ dataType: DataType | undefined;
159
+ };
160
+ const isSectionBoundary =
161
+ index === list.pinnedCount &&
162
+ list.pinnedCount > 0 &&
163
+ list.pinnedCount < list.visibleOptions.length;
164
+ return (
165
+ <React.Fragment key={option.value}>
166
+ {isSectionBoundary && <CommandSeparator />}
167
+ <CommandItem
168
+ value={option.value}
169
+ disabled={option.disabled}
170
+ onSelect={() => list.toggle(option.value)}
171
+ className="flex items-center gap-1.5 cursor-pointer"
172
+ >
173
+ {dataType === undefined ? (
174
+ <span>{option.label}</span>
175
+ ) : (
176
+ <ColumnName
177
+ columnName={option.label}
178
+ dataType={dataType}
179
+ />
180
+ )}
181
+ {!option.disabled && (
182
+ <span
183
+ className={cn(
184
+ "ml-auto",
185
+ hidden ? "text-primary" : "text-muted-foreground",
186
+ )}
187
+ >
188
+ {hidden ? (
189
+ <EyeOffIcon className="w-3 h-3" />
190
+ ) : (
191
+ <EyeIcon className="w-3 h-3" />
192
+ )}
193
+ </span>
194
+ )}
195
+ </CommandItem>
196
+ </React.Fragment>
197
+ );
198
+ })}
199
+ </CommandList>
200
+ </Command>
201
+ </PopoverContent>
202
+ </Popover>
203
+ );
204
+ };
@@ -356,6 +356,7 @@ const DataTableInternal = <TData,>({
356
356
  className={cn(className || "rounded-md border overflow-hidden")}
357
357
  >
358
358
  <TableTopBar
359
+ table={table}
359
360
  enableSearch={enableSearch}
360
361
  searchQuery={searchQuery}
361
362
  onSearchQueryChange={onSearchQueryChange}
@@ -413,7 +414,6 @@ const DataTableInternal = <TData,>({
413
414
  getRowIds={getRowIds}
414
415
  showPageSizeSelector={showPageSizeSelector}
415
416
  tableLoading={reloading}
416
- togglePanel={togglePanel}
417
417
  />
418
418
  </div>
419
419
  </CellSelectionProvider>
@@ -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;
@@ -0,0 +1,60 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import type { Cell } from "@tanstack/react-table";
3
+ import { describe, expect, it } from "vitest";
4
+ import { applyHoverTemplate, computeCellTooltipContent } from "../content";
5
+
6
+ function fakeCell(columnId: string, value: unknown, hoverTitle?: string) {
7
+ return {
8
+ column: { id: columnId },
9
+ getValue: () => value,
10
+ getHoverTitle: () => hoverTitle,
11
+ } as unknown as Cell<unknown, unknown>;
12
+ }
13
+
14
+ describe("applyHoverTemplate", () => {
15
+ it("substitutes column placeholders", () => {
16
+ const cells = [fakeCell("first", "Michael"), fakeCell("last", "Scott")];
17
+ expect(applyHoverTemplate("{{first}} {{last}}", cells)).toBe(
18
+ "Michael Scott",
19
+ );
20
+ });
21
+
22
+ it("renders nulls as empty strings", () => {
23
+ const cells = [fakeCell("a", null)];
24
+ expect(applyHoverTemplate("[{{a}}]", cells)).toBe("[]");
25
+ });
26
+
27
+ it("leaves unknown placeholders intact", () => {
28
+ expect(applyHoverTemplate("{{missing}}", [fakeCell("a", 1)])).toBe(
29
+ "{{missing}}",
30
+ );
31
+ });
32
+ });
33
+
34
+ describe("computeCellTooltipContent", () => {
35
+ it("prefers cell-level hover title", () => {
36
+ const cell = fakeCell("a", 1, "cell text");
37
+ expect(computeCellTooltipContent(cell, "{{a}}")).toBe("cell text");
38
+ });
39
+
40
+ it("falls back to row template when no cell title", () => {
41
+ const cell = {
42
+ column: { id: "first" },
43
+ getValue: () => "X",
44
+ getHoverTitle: () => undefined,
45
+ row: {
46
+ getVisibleCells: () => [
47
+ fakeCell("first", "Jim"),
48
+ fakeCell("last", "Halpert"),
49
+ ],
50
+ },
51
+ } as unknown as Cell<unknown, unknown>;
52
+ expect(computeCellTooltipContent(cell, "{{first}} {{last}}")).toBe(
53
+ "Jim Halpert",
54
+ );
55
+ });
56
+
57
+ it("returns undefined with no title and no template", () => {
58
+ expect(computeCellTooltipContent(fakeCell("a", 1), null)).toBeUndefined();
59
+ });
60
+ });
@@ -0,0 +1,44 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import type { Cell, RowData } from "@tanstack/react-table";
3
+ import type { ReactNode } from "react";
4
+ import { stringifyUnknownValue } from "../utils";
5
+
6
+ export function applyHoverTemplate<TData extends RowData>(
7
+ template: string,
8
+ cells: Cell<TData, unknown>[],
9
+ ): string {
10
+ const variableRegex = /{{(\w+)}}/g;
11
+ const idToValue = new Map<string, string>();
12
+ for (const c of cells) {
13
+ const s = stringifyUnknownValue({
14
+ value: c.getValue(),
15
+ nullAsEmptyString: true,
16
+ });
17
+ idToValue.set(c.column.id, s);
18
+ }
19
+ return template.replaceAll(variableRegex, (_substr, varName: string) => {
20
+ const val = idToValue.get(varName);
21
+ return val === undefined ? `{{${varName}}}` : val;
22
+ });
23
+ }
24
+
25
+ /**
26
+ * Resolve the tooltip content for a hovered cell.
27
+ *
28
+ * Cell-level (callable `hover_template`) takes precedence; otherwise the
29
+ * row-level string template is rendered against the row's visible cells.
30
+ * Returns `undefined` when there is nothing to show.
31
+ */
32
+ export function computeCellTooltipContent<TData extends RowData>(
33
+ cell: Cell<TData, unknown>,
34
+ hoverTemplate: string | null,
35
+ ): ReactNode {
36
+ const cellTitle = cell.getHoverTitle?.();
37
+ if (cellTitle != null && cellTitle !== "") {
38
+ return cellTitle;
39
+ }
40
+ if (hoverTemplate) {
41
+ return applyHoverTemplate(hoverTemplate, cell.row.getVisibleCells());
42
+ }
43
+ return undefined;
44
+ }
@@ -0,0 +1,55 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import {
3
+ TooltipContent,
4
+ TooltipPortal,
5
+ TooltipRoot,
6
+ TooltipTrigger,
7
+ } from "@/components/ui/tooltip";
8
+ import type { HoverTooltipState } from "./use-table-hover-tooltip";
9
+
10
+ interface HoverTooltipProps {
11
+ state: HoverTooltipState | null;
12
+ contentId: string;
13
+ onClose: () => void;
14
+ }
15
+
16
+ /**
17
+ * A single radix tooltip whose anchor is repositioned to the hovered cell.
18
+ * Rendering one instance per table (instead of one per cell) keeps the cost
19
+ * constant regardless of how many cells are on screen.
20
+ */
21
+ export const HoverTooltip = ({
22
+ state,
23
+ contentId,
24
+ onClose,
25
+ }: HoverTooltipProps) => {
26
+ return (
27
+ <TooltipRoot
28
+ open={state != null}
29
+ onOpenChange={(open) => {
30
+ if (!open) {
31
+ onClose();
32
+ }
33
+ }}
34
+ delayDuration={0}
35
+ disableHoverableContent={true}
36
+ >
37
+ <TooltipTrigger asChild={true}>
38
+ <div
39
+ aria-hidden={true}
40
+ style={{
41
+ position: "fixed",
42
+ top: state?.rect.top ?? 0,
43
+ left: state?.rect.left ?? 0,
44
+ width: state?.rect.width ?? 0,
45
+ height: state?.rect.height ?? 0,
46
+ pointerEvents: "none",
47
+ }}
48
+ />
49
+ </TooltipTrigger>
50
+ <TooltipPortal>
51
+ <TooltipContent id={contentId}>{state?.content}</TooltipContent>
52
+ </TooltipPortal>
53
+ </TooltipRoot>
54
+ );
55
+ };