@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,325 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import type {
3
+ CalendarDate,
4
+ CalendarDateTime,
5
+ Time,
6
+ } from "@internationalized/date";
7
+ import { parseDate, parseDateTime, parseTime } from "@internationalized/date";
8
+ import { MinusIcon } from "lucide-react";
9
+ import { useState } from "react";
10
+ import type { DateValue, TimeValue } from "react-aria-components";
11
+ import { TimeField } from "@/components/ui/date-input";
12
+ import { DatePicker, DateRangePicker } from "@/components/ui/date-picker";
13
+ import {
14
+ dateToISODate,
15
+ dateToISODateTime,
16
+ dateToISOTime,
17
+ type FilterType,
18
+ } from "./filters";
19
+
20
+ export type DateLikeFilterType = Extract<
21
+ FilterType,
22
+ "date" | "datetime" | "time"
23
+ >;
24
+
25
+ function dateToAria(
26
+ filterType: DateLikeFilterType,
27
+ d: Date,
28
+ ): DateValue | TimeValue {
29
+ switch (filterType) {
30
+ case "date":
31
+ return parseDate(dateToISODate(d));
32
+ case "datetime":
33
+ return parseDateTime(dateToISODateTime(d));
34
+ case "time":
35
+ return parseTime(dateToISOTime(d));
36
+ }
37
+ }
38
+
39
+ function ariaToDate(
40
+ filterType: DateLikeFilterType,
41
+ aria: DateValue | TimeValue,
42
+ ): Date {
43
+ if (filterType === "time") {
44
+ const t = aria as Time;
45
+ return new Date(1970, 0, 1, t.hour, t.minute, t.second, t.millisecond);
46
+ }
47
+ if (filterType === "date") {
48
+ const c = aria as CalendarDate;
49
+ return new Date(c.year, c.month - 1, c.day);
50
+ }
51
+ const c = aria as Partial<CalendarDateTime> & CalendarDate;
52
+ return new Date(
53
+ c.year,
54
+ c.month - 1,
55
+ c.day,
56
+ c.hour ?? 0,
57
+ c.minute ?? 0,
58
+ c.second ?? 0,
59
+ c.millisecond ?? 0,
60
+ );
61
+ }
62
+
63
+ // Parses a pasted string into a Date appropriate for the filter type.
64
+ // Accepts ISO, US, RFC formats via the Date constructor; time-only strings
65
+ // (HH:MM[:SS]) are handled explicitly since `new Date("12:30")` is invalid.
66
+ export function parsePastedDate(
67
+ filterType: DateLikeFilterType,
68
+ text: string,
69
+ ): Date | undefined {
70
+ const trimmed = text.trim();
71
+ if (!trimmed) {
72
+ return undefined;
73
+ }
74
+
75
+ const timeMatch =
76
+ filterType === "time"
77
+ ? trimmed.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(am|pm)?$/i)
78
+ : null;
79
+ if (timeMatch) {
80
+ const [, hStr, mStr, sStr, ampm] = timeMatch;
81
+ let hour = Number.parseInt(hStr, 10);
82
+ const minute = Number.parseInt(mStr, 10);
83
+ const second = sStr ? Number.parseInt(sStr, 10) : 0;
84
+ if (ampm) {
85
+ const isPm = ampm.toLowerCase() === "pm";
86
+ if (hour === 12) {
87
+ hour = isPm ? 12 : 0;
88
+ } else if (isPm) {
89
+ hour += 12;
90
+ }
91
+ }
92
+ return new Date(1970, 0, 1, hour, minute, second);
93
+ }
94
+
95
+ const dateOnlyMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/);
96
+ if (dateOnlyMatch) {
97
+ const [, y, m, d] = dateOnlyMatch;
98
+ return new Date(
99
+ Number.parseInt(y, 10),
100
+ Number.parseInt(m, 10) - 1,
101
+ Number.parseInt(d, 10),
102
+ );
103
+ }
104
+
105
+ // Parse ISO datetimes as wall-clock to stay consistent with the picker's
106
+ // local-time basis. Trailing `Z` or offsets are stripped so that pasting
107
+ // `2024-01-15T08:30:00Z` displays `08:30` instead of being shifted by the
108
+ // viewer's timezone.
109
+ const isoMatch = trimmed.match(
110
+ /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
111
+ );
112
+ if (isoMatch) {
113
+ const [, y, mo, d, h, mi, s] = isoMatch;
114
+ return new Date(
115
+ Number.parseInt(y, 10),
116
+ Number.parseInt(mo, 10) - 1,
117
+ Number.parseInt(d, 10),
118
+ Number.parseInt(h, 10),
119
+ Number.parseInt(mi, 10),
120
+ s ? Number.parseInt(s, 10) : 0,
121
+ );
122
+ }
123
+
124
+ const parsed = new Date(trimmed);
125
+ if (Number.isNaN(parsed.getTime())) {
126
+ return undefined;
127
+ }
128
+ return parsed;
129
+ }
130
+
131
+ function parsePastedRange(
132
+ filterType: DateLikeFilterType,
133
+ text: string,
134
+ ): { min: Date; max: Date } | undefined {
135
+ const parts = text.split(/\s+(?:-|–|—|to)\s+/i);
136
+ if (parts.length === 2) {
137
+ const min = parsePastedDate(filterType, parts[0]);
138
+ const max = parsePastedDate(filterType, parts[1]);
139
+ if (min && max) {
140
+ return { min, max };
141
+ }
142
+ }
143
+ const single = parsePastedDate(filterType, text);
144
+ if (single) {
145
+ return { min: single, max: single };
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ interface DateLikeInputProps {
151
+ filterType: DateLikeFilterType;
152
+ value: Date | undefined;
153
+ onChange: (value: Date | undefined) => void;
154
+ "aria-label"?: string;
155
+ className?: string;
156
+ }
157
+
158
+ export const DateLikeInput = ({
159
+ filterType,
160
+ value,
161
+ onChange,
162
+ "aria-label": ariaLabel,
163
+ className,
164
+ }: DateLikeInputProps) => {
165
+ const [seedKey, setSeedKey] = useState(0);
166
+ const [seed, setSeed] = useState(value);
167
+
168
+ const handleChange = (next: DateValue | TimeValue | null) => {
169
+ if (next === null) {
170
+ return;
171
+ }
172
+ onChange(ariaToDate(filterType, next));
173
+ };
174
+
175
+ const handlePaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
176
+ const text = e.clipboardData.getData("text");
177
+ const parsed = parsePastedDate(filterType, text);
178
+ if (!parsed) {
179
+ return;
180
+ }
181
+ e.preventDefault();
182
+ onChange(parsed);
183
+ setSeed(parsed);
184
+ setSeedKey((k) => k + 1);
185
+ };
186
+
187
+ const seedValue =
188
+ seed === undefined ? undefined : dateToAria(filterType, seed);
189
+
190
+ return (
191
+ <div onPasteCapture={handlePaste} className="contents">
192
+ {filterType === "time" ? (
193
+ <TimeField<Time>
194
+ key={seedKey}
195
+ aria-label={ariaLabel}
196
+ defaultValue={seedValue as Time | undefined}
197
+ onChange={handleChange}
198
+ className={className}
199
+ />
200
+ ) : filterType === "date" ? (
201
+ <DatePicker<CalendarDate>
202
+ key={seedKey}
203
+ aria-label={ariaLabel}
204
+ defaultValue={seedValue as CalendarDate | undefined}
205
+ onChange={handleChange}
206
+ className={className}
207
+ />
208
+ ) : (
209
+ <DatePicker<CalendarDateTime>
210
+ key={seedKey}
211
+ aria-label={ariaLabel}
212
+ defaultValue={seedValue as CalendarDateTime | undefined}
213
+ granularity="second"
214
+ onChange={handleChange}
215
+ className={className}
216
+ />
217
+ )}
218
+ </div>
219
+ );
220
+ };
221
+
222
+ interface DateLikeRangeInputProps {
223
+ filterType: DateLikeFilterType;
224
+ min: Date | undefined;
225
+ max: Date | undefined;
226
+ onRangeChange: (min: Date | undefined, max: Date | undefined) => void;
227
+ className?: string;
228
+ }
229
+
230
+ export const DateLikeRangeInput = ({
231
+ filterType,
232
+ min,
233
+ max,
234
+ onRangeChange,
235
+ className,
236
+ }: DateLikeRangeInputProps) => {
237
+ const [seedKey, setSeedKey] = useState(0);
238
+ const [seedMin, setSeedMin] = useState(min);
239
+ const [seedMax, setSeedMax] = useState(max);
240
+
241
+ const handlePaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
242
+ const text = e.clipboardData.getData("text");
243
+ const parsed = parsePastedRange(filterType, text);
244
+ if (!parsed) {
245
+ return;
246
+ }
247
+ e.preventDefault();
248
+ e.stopPropagation();
249
+ onRangeChange(parsed.min, parsed.max);
250
+ setSeedMin(parsed.min);
251
+ setSeedMax(parsed.max);
252
+ setSeedKey((k) => k + 1);
253
+ };
254
+
255
+ if (filterType === "time") {
256
+ return (
257
+ <div onPasteCapture={handlePaste} className="flex gap-1 items-center">
258
+ <DateLikeInput
259
+ key={`min-${seedKey}`}
260
+ filterType="time"
261
+ value={seedMin}
262
+ onChange={(nextMin) => onRangeChange(nextMin, max)}
263
+ aria-label="min"
264
+ className={className}
265
+ />
266
+ <MinusIcon className="h-5 w-5 text-muted-foreground" />
267
+ <DateLikeInput
268
+ key={`max-${seedKey}`}
269
+ filterType="time"
270
+ value={seedMax}
271
+ onChange={(nextMax) => onRangeChange(min, nextMax)}
272
+ aria-label="max"
273
+ className={className}
274
+ />
275
+ </div>
276
+ );
277
+ }
278
+
279
+ const handleChange = (next: { start: DateValue; end: DateValue } | null) => {
280
+ if (next === null) {
281
+ return;
282
+ }
283
+ onRangeChange(
284
+ ariaToDate(filterType, next.start),
285
+ ariaToDate(filterType, next.end),
286
+ );
287
+ };
288
+
289
+ const seedRange =
290
+ seedMin === undefined || seedMax === undefined
291
+ ? undefined
292
+ : {
293
+ start: dateToAria(filterType, seedMin),
294
+ end: dateToAria(filterType, seedMax),
295
+ };
296
+
297
+ return (
298
+ <div onPasteCapture={handlePaste} className="contents">
299
+ {filterType === "date" ? (
300
+ <DateRangePicker<CalendarDate>
301
+ key={seedKey}
302
+ aria-label="range"
303
+ defaultValue={
304
+ seedRange as { start: CalendarDate; end: CalendarDate } | undefined
305
+ }
306
+ onChange={handleChange}
307
+ className={className}
308
+ />
309
+ ) : (
310
+ <DateRangePicker<CalendarDateTime>
311
+ key={seedKey}
312
+ aria-label="range"
313
+ defaultValue={
314
+ seedRange as
315
+ | { start: CalendarDateTime; end: CalendarDateTime }
316
+ | undefined
317
+ }
318
+ granularity="second"
319
+ onChange={handleChange}
320
+ className={className}
321
+ />
322
+ )}
323
+ </div>
324
+ );
325
+ };
@@ -32,6 +32,7 @@ interface Props<TData, TValue> {
32
32
  calculateTopKRows?: CalculateTopKRows;
33
33
  chosenValues: unknown[];
34
34
  onChange: (values: unknown[]) => void;
35
+ creatable?: boolean;
35
36
  }
36
37
 
37
38
  export const FilterByValuesPicker = <TData, TValue>({
@@ -39,6 +40,7 @@ export const FilterByValuesPicker = <TData, TValue>({
39
40
  calculateTopKRows,
40
41
  chosenValues,
41
42
  onChange,
43
+ creatable = false,
42
44
  }: Props<TData, TValue>) => {
43
45
  const [open, setOpen] = useState(false);
44
46
 
@@ -58,6 +60,7 @@ export const FilterByValuesPicker = <TData, TValue>({
58
60
  <Popover open={open} onOpenChange={setOpen}>
59
61
  <PopoverTrigger asChild={true}>
60
62
  <Button
63
+ type="button"
61
64
  variant="outline"
62
65
  size="xs"
63
66
  className="h-6 mb-1 w-full justify-between font-normal"
@@ -79,6 +82,7 @@ export const FilterByValuesPicker = <TData, TValue>({
79
82
  calculateTopKRows={calculateTopKRows}
80
83
  chosenValues={chosenValuesSet}
81
84
  onChange={onChange}
85
+ creatable={creatable}
82
86
  />
83
87
  </PopoverContent>
84
88
  </Popover>
@@ -90,6 +94,7 @@ interface FilterByValuesListProps<TData, TValue> {
90
94
  calculateTopKRows?: CalculateTopKRows;
91
95
  chosenValues: Set<unknown>;
92
96
  onChange: (values: unknown[]) => void;
97
+ creatable?: boolean;
93
98
  }
94
99
 
95
100
  /**
@@ -100,6 +105,7 @@ export const FilterByValuesList = <TData, TValue>({
100
105
  calculateTopKRows,
101
106
  chosenValues,
102
107
  onChange,
108
+ creatable = false,
103
109
  }: FilterByValuesListProps<TData, TValue>) => {
104
110
  const [query, setQuery] = useState<string>("");
105
111
 
@@ -133,13 +139,48 @@ export const FilterByValuesList = <TData, TValue>({
133
139
  }
134
140
  }, [data, query]);
135
141
 
142
+ // Surface chosen values that aren't in the top-K so they stay visible/uncheckable.
143
+ // Count is undefined for these rows; the cell renders an em-dash.
144
+ const mergedData = useMemo<Array<[unknown, number | undefined]>>(() => {
145
+ const seen = new Set(filteredData.map(([v]) => v));
146
+ const extras: Array<[unknown, number | undefined]> = [];
147
+ for (const chosen of chosenValues) {
148
+ if (seen.has(chosen)) {
149
+ continue;
150
+ }
151
+ const str = String(chosen);
152
+ const matches =
153
+ query.length === 0 ||
154
+ smartMatch(query, str) ||
155
+ str.toLowerCase().includes(query.toLowerCase());
156
+ if (matches) {
157
+ extras.push([chosen, undefined]);
158
+ }
159
+ }
160
+ return [...filteredData, ...extras];
161
+ }, [filteredData, chosenValues, query]);
162
+
136
163
  const handleToggle = (value: unknown) => {
137
164
  onChange([...Sets.toggle(chosenValues, value)]);
138
165
  };
139
166
 
167
+ const trimmedQuery = query.trim();
168
+ const canCreate =
169
+ creatable &&
170
+ trimmedQuery !== "" &&
171
+ !mergedData.some(([v]) => String(v) === trimmedQuery);
172
+
173
+ const commitCreate = () => {
174
+ if (!canCreate) {
175
+ return;
176
+ }
177
+ onChange([...chosenValues, trimmedQuery]);
178
+ setQuery("");
179
+ };
180
+
140
181
  const allVisibleChecked =
141
- filteredData.length > 0 &&
142
- filteredData.every(([value]) => chosenValues.has(value));
182
+ mergedData.length > 0 &&
183
+ mergedData.every(([value]) => chosenValues.has(value));
143
184
 
144
185
  const selectAllState: boolean | "indeterminate" = allVisibleChecked
145
186
  ? true
@@ -153,11 +194,11 @@ export const FilterByValuesList = <TData, TValue>({
153
194
  }
154
195
  const next = new Set(chosenValues);
155
196
  if (allVisibleChecked) {
156
- for (const [value] of filteredData) {
197
+ for (const [value] of mergedData) {
157
198
  next.delete(value);
158
199
  }
159
200
  } else {
160
- for (const [value] of filteredData) {
201
+ for (const [value] of mergedData) {
161
202
  next.add(value);
162
203
  }
163
204
  }
@@ -183,13 +224,24 @@ export const FilterByValuesList = <TData, TValue>({
183
224
  return (
184
225
  <Command className="text-sm outline-hidden" shouldFilter={false}>
185
226
  <CommandInput
186
- placeholder={`Search among the top ${data.length} values`}
227
+ placeholder={
228
+ creatable
229
+ ? "Search or add a value…"
230
+ : `Search among the top ${data.length} values`
231
+ }
187
232
  autoFocus={true}
188
- onValueChange={(value) => setQuery(value.trim())}
233
+ value={query}
234
+ onValueChange={setQuery}
235
+ onKeyDown={(e) => {
236
+ if (e.key === "Enter" && canCreate) {
237
+ e.preventDefault();
238
+ commitCreate();
239
+ }
240
+ }}
189
241
  />
190
242
  <CommandEmpty>No results found.</CommandEmpty>
191
243
  <CommandList>
192
- {filteredData.length > 0 && (
244
+ {mergedData.length > 0 && (
193
245
  <CommandItem
194
246
  value="__select-all__"
195
247
  className="border-b rounded-none px-3"
@@ -204,7 +256,7 @@ export const FilterByValuesList = <TData, TValue>({
204
256
  <span className="font-bold">Count</span>
205
257
  </CommandItem>
206
258
  )}
207
- {filteredData.map(([value, count]) => {
259
+ {mergedData.map(([value, count]) => {
208
260
  const isSelected = chosenValues.has(value);
209
261
  const valueString = stringifyUnknownValue({ value });
210
262
  const sentinel = detectSentinel(
@@ -226,10 +278,19 @@ export const FilterByValuesList = <TData, TValue>({
226
278
  <span className="flex-1 overflow-hidden max-h-20 line-clamp-3">
227
279
  {sentinel ? <SentinelCell sentinel={sentinel} /> : valueString}
228
280
  </span>
229
- <span className="ml-3">{count}</span>
281
+ <span className="ml-3">{count === undefined ? "—" : count}</span>
230
282
  </CommandItem>
231
283
  );
232
284
  })}
285
+ {canCreate && (
286
+ <CommandItem
287
+ value={`__create__:${trimmedQuery}`}
288
+ className="border-t rounded-none px-3 italic"
289
+ onSelect={commitCreate}
290
+ >
291
+ + Add "{trimmedQuery}"
292
+ </CommandItem>
293
+ )}
233
294
  </CommandList>
234
295
  {data.length === TOP_K_ROWS && (
235
296
  <span className="text-xs text-muted-foreground py-1.5 text-center">