@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
@@ -0,0 +1,53 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { describe, expect, it } from "vitest";
3
+ import type { StorageEntry } from "@/core/storage/types";
4
+ import { storageEntryKey } from "../storage-inspector";
5
+
6
+ function makeEntry(
7
+ overrides: Partial<StorageEntry> & { path: string },
8
+ ): StorageEntry {
9
+ return {
10
+ kind: overrides.kind ?? "file",
11
+ lastModified: overrides.lastModified ?? null,
12
+ metadata: overrides.metadata ?? {},
13
+ path: overrides.path,
14
+ size: overrides.size ?? 0,
15
+ };
16
+ }
17
+
18
+ describe("storageEntryKey", () => {
19
+ it("prefers the backend id when present (e.g. Google Drive)", () => {
20
+ const entry = makeEntry({
21
+ path: "Data Resume.pdf",
22
+ metadata: { id: "drive-file-id-123" },
23
+ });
24
+ // Two files can share a path on Drive; the id keeps keys unique.
25
+ expect(storageEntryKey(entry, 0)).toBe("drive-file-id-123");
26
+ expect(storageEntryKey(entry, 4)).toBe("drive-file-id-123");
27
+ });
28
+
29
+ it("falls back to path + index when there is no id", () => {
30
+ const entry = makeEntry({ path: "Data Resume.pdf" });
31
+ expect(storageEntryKey(entry, 0)).toBe("Data Resume.pdf::0");
32
+ expect(storageEntryKey(entry, 4)).toBe("Data Resume.pdf::4");
33
+ });
34
+
35
+ it("keeps duplicate-path entries unique via the index fallback", () => {
36
+ const entries = [
37
+ makeEntry({ path: "Data Resume.pdf" }),
38
+ makeEntry({ path: "Data Resume.pdf" }),
39
+ makeEntry({ path: "Data Resume.pdf" }),
40
+ ];
41
+ const keys = entries.map((entry, index) => storageEntryKey(entry, index));
42
+ expect(new Set(keys).size).toBe(entries.length);
43
+ });
44
+
45
+ it("ignores a non-string or empty id", () => {
46
+ expect(
47
+ storageEntryKey(makeEntry({ path: "a.pdf", metadata: { id: "" } }), 2),
48
+ ).toBe("a.pdf::2");
49
+ expect(
50
+ storageEntryKey(makeEntry({ path: "a.pdf", metadata: { id: 5 } }), 2),
51
+ ).toBe("a.pdf::2");
52
+ });
53
+ });
@@ -94,15 +94,31 @@ function displayName(path: string): string {
94
94
  return parts[parts.length - 1] || trimmed;
95
95
  }
96
96
 
97
+ /**
98
+ * Stable, unique identity for an entry row. Prefer the
99
+ * backend's stable id when present and fall back to the list index
100
+ */
101
+ export function storageEntryKey(entry: StorageEntry, index: number): string {
102
+ const id = entry.metadata?.id;
103
+ if (typeof id === "string" && id.length > 0) {
104
+ return id;
105
+ }
106
+ return `${entry.path}::${index}`;
107
+ }
108
+
109
+ interface SearchContext {
110
+ namespace: string;
111
+ searchValue: string;
112
+ entriesByPath: ReadonlyMap<StoragePathKey, StorageEntry[]>;
113
+ }
114
+
97
115
  /**
98
116
  * Recursively check whether an entry (or any of its loaded descendants)
99
117
  * matches the search query.
100
118
  */
101
119
  function entryMatchesSearch(
102
120
  entry: StorageEntry,
103
- namespace: string,
104
- searchValue: string,
105
- entriesByPath: ReadonlyMap<StoragePathKey, StorageEntry[]>,
121
+ { namespace, searchValue, entriesByPath }: SearchContext,
106
122
  ): boolean {
107
123
  const query = searchValue.toLowerCase();
108
124
 
@@ -115,7 +131,7 @@ function entryMatchesSearch(
115
131
  const children = entriesByPath.get(storagePathKey(namespace, entry.path));
116
132
  if (children) {
117
133
  return children.some((child) =>
118
- entryMatchesSearch(child, namespace, searchValue, entriesByPath),
134
+ entryMatchesSearch(child, { namespace, searchValue, entriesByPath }),
119
135
  );
120
136
  }
121
137
  }
@@ -129,16 +145,12 @@ function entryMatchesSearch(
129
145
  */
130
146
  function filterEntries(
131
147
  entries: StorageEntry[],
132
- namespace: string,
133
- searchValue: string,
134
- entriesByPath: ReadonlyMap<StoragePathKey, StorageEntry[]>,
148
+ context: SearchContext,
135
149
  ): StorageEntry[] {
136
- if (!searchValue.trim()) {
150
+ if (!context.searchValue.trim()) {
137
151
  return entries;
138
152
  }
139
- return entries.filter((entry) =>
140
- entryMatchesSearch(entry, namespace, searchValue, entriesByPath),
141
- );
153
+ return entries.filter((entry) => entryMatchesSearch(entry, context));
142
154
  }
143
155
 
144
156
  /**
@@ -204,35 +216,39 @@ const StorageEntryChildren: React.FC<{
204
216
  );
205
217
  }
206
218
 
207
- const filtered = filterEntries(
208
- children,
219
+ const filtered = filterEntries(children, {
209
220
  namespace,
210
221
  searchValue,
211
222
  entriesByPath,
212
- );
223
+ });
213
224
 
214
225
  return (
215
226
  <>
216
- {filtered.map((child) => (
217
- <StorageEntryRow
218
- key={child.path}
219
- entry={child}
220
- namespace={namespace}
221
- protocol={protocol}
222
- rootPath={rootPath}
223
- backendType={backendType}
224
- depth={depth}
225
- locale={locale}
226
- searchValue={searchValue}
227
- onOpenFile={onOpenFile}
228
- />
229
- ))}
227
+ {filtered.map((child) => {
228
+ const rowKey = storageEntryKey(child, children.indexOf(child));
229
+ return (
230
+ <StorageEntryRow
231
+ key={rowKey}
232
+ rowKey={rowKey}
233
+ entry={child}
234
+ namespace={namespace}
235
+ protocol={protocol}
236
+ rootPath={rootPath}
237
+ backendType={backendType}
238
+ depth={depth}
239
+ locale={locale}
240
+ searchValue={searchValue}
241
+ onOpenFile={onOpenFile}
242
+ />
243
+ );
244
+ })}
230
245
  </>
231
246
  );
232
247
  };
233
248
 
234
249
  const StorageEntryRow: React.FC<{
235
250
  entry: StorageEntry;
251
+ rowKey: string;
236
252
  namespace: string;
237
253
  protocol: string;
238
254
  rootPath: string;
@@ -243,6 +259,7 @@ const StorageEntryRow: React.FC<{
243
259
  onOpenFile: (info: OpenFileInfo) => void;
244
260
  }> = ({
245
261
  entry,
262
+ rowKey,
246
263
  namespace,
247
264
  protocol,
248
265
  rootPath,
@@ -271,7 +288,7 @@ const StorageEntryRow: React.FC<{
271
288
  !!entriesByPath
272
289
  .get(storagePathKey(namespace, entry.path))
273
290
  ?.some((child) =>
274
- entryMatchesSearch(child, namespace, searchValue, entriesByPath),
291
+ entryMatchesSearch(child, { namespace, searchValue, entriesByPath }),
275
292
  );
276
293
 
277
294
  // Folder is shown expanded by manual toggle OR by search auto-expand
@@ -312,7 +329,7 @@ const StorageEntryRow: React.FC<{
312
329
  isDir && "font-medium",
313
330
  )}
314
331
  style={indentStyle(depth)}
315
- value={`${namespace}:${entry.path}`}
332
+ value={`${namespace}:${rowKey}`}
316
333
  onSelect={() => {
317
334
  if (isDir) {
318
335
  setIsExpanded(!effectiveExpanded);
@@ -458,12 +475,11 @@ const StorageNamespaceSection: React.FC<{
458
475
 
459
476
  // While loading, fall back to initial entries from the namespace notification
460
477
  const entries = isPending ? namespace.storageEntries : fetchedEntries;
461
- const filtered = filterEntries(
462
- entries,
463
- namespaceName,
478
+ const filtered = filterEntries(entries, {
479
+ namespace: namespaceName,
464
480
  searchValue,
465
481
  entriesByPath,
466
- );
482
+ });
467
483
 
468
484
  return (
469
485
  <>
@@ -525,20 +541,24 @@ const StorageNamespaceSection: React.FC<{
525
541
  No matches
526
542
  </div>
527
543
  )}
528
- {filtered.map((entry) => (
529
- <StorageEntryRow
530
- key={entry.path}
531
- entry={entry}
532
- namespace={namespaceName}
533
- protocol={namespace.protocol}
534
- rootPath={namespace.rootPath}
535
- backendType={namespace.backendType}
536
- depth={1}
537
- locale={locale}
538
- searchValue={searchValue}
539
- onOpenFile={onOpenFile}
540
- />
541
- ))}
544
+ {filtered.map((entry) => {
545
+ const rowKey = storageEntryKey(entry, entries.indexOf(entry));
546
+ return (
547
+ <StorageEntryRow
548
+ key={rowKey}
549
+ rowKey={rowKey}
550
+ entry={entry}
551
+ namespace={namespaceName}
552
+ protocol={namespace.protocol}
553
+ rootPath={namespace.rootPath}
554
+ backendType={namespace.backendType}
555
+ depth={1}
556
+ locale={locale}
557
+ searchValue={searchValue}
558
+ onOpenFile={onOpenFile}
559
+ />
560
+ );
561
+ })}
542
562
  </>
543
563
  )}
544
564
  </>
@@ -0,0 +1,75 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { act, renderHook } from "@testing-library/react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { toast, useToast } from "@/components/ui/use-toast";
5
+
6
+ describe("toast once", () => {
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ });
10
+
11
+ afterEach(() => {
12
+ const { result } = renderHook(() => useToast());
13
+ act(() => {
14
+ result.current.dismiss();
15
+ vi.runAllTimers();
16
+ });
17
+ vi.useRealTimers();
18
+ });
19
+
20
+ it("shows a once-toast a single time per session, even after removal", () => {
21
+ const { result } = renderHook(() => useToast());
22
+
23
+ act(() => {
24
+ toast({ id: "static-notebook", once: true, title: "Static notebook" });
25
+ });
26
+ expect(result.current.toasts).toHaveLength(1);
27
+
28
+ act(() => {
29
+ result.current.dismiss("static-notebook");
30
+ vi.advanceTimersByTime(10_000);
31
+ });
32
+ expect(result.current.toasts).toHaveLength(0);
33
+
34
+ act(() => {
35
+ toast({ id: "static-notebook", once: true, title: "Static notebook" });
36
+ });
37
+ expect(result.current.toasts).toHaveLength(0);
38
+ });
39
+
40
+ it("does not suppress normal (non-once) toasts", () => {
41
+ const { result } = renderHook(() => useToast());
42
+
43
+ act(() => {
44
+ toast({ id: "normal", title: "First" });
45
+ });
46
+ act(() => {
47
+ result.current.dismiss("normal");
48
+ vi.advanceTimersByTime(10_000);
49
+ });
50
+ expect(result.current.toasts).toHaveLength(0);
51
+
52
+ act(() => {
53
+ toast({ id: "normal", title: "First" });
54
+ });
55
+ expect(result.current.toasts).toHaveLength(1);
56
+ });
57
+
58
+ it("does not dedupe a once-toast without a stable id", () => {
59
+ const { result } = renderHook(() => useToast());
60
+
61
+ act(() => {
62
+ toast({ once: true, title: "No id" });
63
+ });
64
+ act(() => {
65
+ result.current.dismiss();
66
+ vi.advanceTimersByTime(10_000);
67
+ });
68
+ expect(result.current.toasts).toHaveLength(0);
69
+
70
+ act(() => {
71
+ toast({ once: true, title: "No id" });
72
+ });
73
+ expect(result.current.toasts).toHaveLength(1);
74
+ });
75
+ });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useControllableState } from "@radix-ui/react-use-controllable-state";
4
4
  import { Check, ChevronDownIcon, XCircle } from "lucide-react";
5
- import React, { createContext } from "react";
5
+ import React, { createContext, useCallback, useMemo } from "react";
6
6
  import { cn } from "../../utils/cn";
7
7
  import { Functions } from "../../utils/functions";
8
8
  import { Badge } from "./badge";
@@ -40,6 +40,8 @@ interface ComboboxCommonProps<TValue> {
40
40
  id?: string;
41
41
  keepPopoverOpenOnSelect?: boolean;
42
42
  disabled?: boolean;
43
+ /** Override the trigger contents with a node (e.g. a compact chip summary). */
44
+ renderValue?: (value: TValue[] | TValue | null) => React.ReactNode;
43
45
  }
44
46
 
45
47
  type ComboboxFilterProps =
@@ -97,6 +99,7 @@ export const Combobox = <TValue,>({
97
99
  keepPopoverOpenOnSelect,
98
100
  id,
99
101
  disabled = false,
102
+ renderValue,
100
103
  ...rest
101
104
  }: ComboboxProps<TValue>) => {
102
105
  const [open = false, setOpen] = useControllableState({
@@ -112,39 +115,45 @@ export const Combobox = <TValue,>({
112
115
  },
113
116
  });
114
117
 
115
- const isSelected = (selectedValue: unknown) => {
116
- if (Array.isArray(value)) {
117
- return value.includes(selectedValue as TValue);
118
- }
119
- return value === selectedValue;
120
- };
118
+ const isSelected = useCallback(
119
+ (selectedValue: unknown) => {
120
+ if (Array.isArray(value)) {
121
+ return value.includes(selectedValue as TValue);
122
+ }
123
+ return value === selectedValue;
124
+ },
125
+ [value],
126
+ );
121
127
 
122
- const handleSelect = (selectedValue: unknown) => {
123
- let newValue: TValue | TValue[] | null = selectedValue as TValue;
128
+ const handleSelect = useCallback(
129
+ (selectedValue: unknown) => {
130
+ let newValue: TValue | TValue[] | null = selectedValue as TValue;
124
131
 
125
- if (multiple) {
126
- if (Array.isArray(value)) {
127
- if (value.includes(newValue)) {
128
- const newArr = value.filter((val) => val !== selectedValue);
129
- newValue = newArr.length > 0 ? newArr : [];
132
+ if (multiple) {
133
+ if (Array.isArray(value)) {
134
+ if (value.includes(newValue)) {
135
+ const newArr = value.filter((val) => val !== selectedValue);
136
+ newValue = newArr.length > 0 ? newArr : [];
137
+ } else {
138
+ newValue = [...value, newValue];
139
+ }
130
140
  } else {
131
- newValue = [...value, newValue];
141
+ newValue = [newValue];
132
142
  }
133
- } else {
134
- newValue = [newValue];
143
+ } else if (value === selectedValue) {
144
+ newValue = null;
135
145
  }
136
- } else if (value === selectedValue) {
137
- newValue = null;
138
- }
139
146
 
140
- setValue(newValue);
141
- const keepOpen = keepPopoverOpenOnSelect ?? multiple;
142
- if (!keepOpen) {
143
- setOpen(false);
144
- }
145
- };
147
+ setValue(newValue);
148
+ const keepOpen = keepPopoverOpenOnSelect ?? multiple;
149
+ if (!keepOpen) {
150
+ setOpen(false);
151
+ }
152
+ },
153
+ [keepPopoverOpenOnSelect, multiple, value, setValue, setOpen],
154
+ );
146
155
 
147
- const renderValue = (): string => {
156
+ const renderValueLabel = (): string => {
148
157
  // If we show chips, we don't want to change the placeholder
149
158
  if (multiple && chips && placeholder) {
150
159
  return placeholder;
@@ -168,6 +177,14 @@ export const Combobox = <TValue,>({
168
177
  return placeholder ?? "--";
169
178
  };
170
179
 
180
+ const comboboxContextValue: ComboboxContextValue = useMemo(
181
+ () => ({
182
+ isSelected,
183
+ onSelect: handleSelect,
184
+ }),
185
+ [isSelected, handleSelect],
186
+ );
187
+
171
188
  return (
172
189
  <div className={cn("relative")} {...rest}>
173
190
  <Popover
@@ -191,7 +208,9 @@ export const Combobox = <TValue,>({
191
208
  aria-expanded={open}
192
209
  aria-disabled={disabled}
193
210
  >
194
- <span className="truncate flex-1 min-w-0">{renderValue()}</span>
211
+ <span className="truncate flex-1 min-w-0">
212
+ {renderValue ? renderValue(value ?? null) : renderValueLabel()}
213
+ </span>
195
214
  <ChevronDownIcon className="ml-3 w-4 h-4 opacity-50 shrink-0" />
196
215
  </button>
197
216
  </PopoverTrigger>
@@ -209,7 +228,7 @@ export const Combobox = <TValue,>({
209
228
  />
210
229
  <CommandList className="max-h-60 py-.5">
211
230
  <CommandEmpty>{emptyState}</CommandEmpty>
212
- <ComboboxContext value={{ isSelected, onSelect: handleSelect }}>
231
+ <ComboboxContext value={comboboxContextValue}>
213
232
  {children}
214
233
  </ComboboxContext>
215
234
  </CommandList>
@@ -277,12 +296,14 @@ export const ComboboxItem = React.forwardRef(
277
296
  ? value.value
278
297
  : String(value);
279
298
  const context = React.use(ComboboxContext);
299
+ const isOptionSelected = context.isSelected(value);
280
300
 
281
301
  return (
282
302
  <CommandItem
283
303
  ref={ref}
284
304
  className={cn("pl-6 m-1 py-1", className)}
285
305
  role="option"
306
+ aria-selected={isOptionSelected}
286
307
  value={valueAsString}
287
308
  disabled={disabled}
288
309
  onSelect={() => {
@@ -290,9 +311,7 @@ export const ComboboxItem = React.forwardRef(
290
311
  onSelect?.(value);
291
312
  }}
292
313
  >
293
- {context.isSelected(value) && (
294
- <Check className="absolute left-1 h-4 w-4" />
295
- )}
314
+ {isOptionSelected && <Check className="absolute left-1 h-4 w-4" />}
296
315
  {children}
297
316
  </CommandItem>
298
317
  );
@@ -57,6 +57,12 @@ export interface ReorderableListProps<T> {
57
57
  * Callback when an item is clicked
58
58
  */
59
59
  onAction?: (item: T) => void;
60
+ /**
61
+ * Fired when an item is hovered or focused. Useful for preloading the
62
+ * resource the item points to before it is activated. Attached to the
63
+ * focusable list item so it works for both pointer and keyboard users.
64
+ */
65
+ onItemPreloadHint?: (item: T) => void;
60
66
  /**
61
67
  * All available items that can be added to the list
62
68
  */
@@ -142,6 +148,7 @@ export const ReorderableList = <T extends object>({
142
148
  ariaLabel = "Reorderable list",
143
149
  className,
144
150
  crossListDrag,
151
+ onItemPreloadHint,
145
152
  }: ReorderableListProps<T>) => {
146
153
  const mimeType = crossListDrag
147
154
  ? getDragMimeType(crossListDrag.dragType)
@@ -294,6 +301,12 @@ export const ReorderableList = <T extends object>({
294
301
  key={getKey(item)}
295
302
  id={getKey(item)}
296
303
  className="active:cursor-grabbing data-[dragging]:opacity-60 outline-none"
304
+ onHoverStart={
305
+ onItemPreloadHint ? () => onItemPreloadHint(item) : undefined
306
+ }
307
+ onFocus={
308
+ onItemPreloadHint ? () => onItemPreloadHint(item) : undefined
309
+ }
297
310
  >
298
311
  {renderItem(item)}
299
312
  </ListBoxItem>