@marimo-team/islands 0.23.10-dev4 → 0.23.10-dev41

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 (171) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CyV83R2m.js → ConnectedDataExplorerComponent-Z3RP7Vmm.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-CAgFD4Kd.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-TL2w0gQI.js → chat-ui-CqtCMdc-.js} +76 -76
  7. package/dist/{check-DTbrK0zt.js → check-BCaJeT-J.js} +1 -1
  8. package/dist/{code-visibility-Dly0oW20.js → code-visibility-CtLSFag8.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-CJXYJ6Sb.js} +11 -6
  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-BPkCPs7L.js} +255 -255
  60. package/dist/{html-to-image-DS-GYb0Y.js → html-to-image-BviMxrkm.js} +2316 -2273
  61. package/dist/{input-_2sjvfne.js → input-OdWHkobi.js} +263 -245
  62. package/dist/{label-LWtdw5i8.js → label-CC4ytI1X.js} +1 -1
  63. package/dist/main.js +7630 -7558
  64. package/dist/{mermaid-lXOw5Py9.js → mermaid-BotvIKg9.js} +4 -4
  65. package/dist/{process-output-BdlB3Vhy.js → process-output-Bq3VMBsg.js} +3 -3
  66. package/dist/{reveal-component-Dj7ybE5_.js → reveal-component-ets0P11U.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-DGPUhbDs.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/code/language-toggle.tsx +7 -1
  98. package/src/components/editor/chrome/wrapper/app-chrome.tsx +97 -52
  99. package/src/components/editor/chrome/wrapper/lazy-panels.ts +91 -0
  100. package/src/components/editor/chrome/wrapper/sidebar.tsx +2 -0
  101. package/src/components/editor/documentation.css +35 -0
  102. package/src/components/editor/file-tree/file-explorer.tsx +8 -18
  103. package/src/components/editor/file-tree/tree-actions.tsx +46 -1
  104. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +20 -0
  105. package/src/components/editor/renderers/slides-layout/types.ts +1 -0
  106. package/src/components/slides/__tests__/minimap-actions.test.tsx +166 -0
  107. package/src/components/slides/__tests__/reveal-component.test.ts +425 -0
  108. package/src/components/slides/minimap.tsx +127 -10
  109. package/src/components/slides/reveal-component.tsx +287 -61
  110. package/src/components/slides/slide-cell-view.tsx +26 -2
  111. package/src/components/slides/slide-form.tsx +26 -4
  112. package/src/components/storage/__tests__/storage-inspector.test.ts +53 -0
  113. package/src/components/storage/storage-inspector.tsx +68 -48
  114. package/src/components/ui/__tests__/use-toast.test.ts +75 -0
  115. package/src/components/ui/combobox.tsx +51 -32
  116. package/src/components/ui/number-field.tsx +5 -1
  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/language/languages/python.ts +2 -0
  132. package/src/core/codemirror/language/languages/sql/utils.ts +3 -1
  133. package/src/core/codemirror/lsp/__tests__/markdown-renderer.test.ts +41 -0
  134. package/src/core/codemirror/lsp/markdown-renderer.ts +59 -0
  135. package/src/core/datasets/data-source-connections.ts +2 -0
  136. package/src/core/network/__tests__/requests-static.test.ts +30 -0
  137. package/src/core/network/requests-static.ts +14 -10
  138. package/src/core/wasm/worker/bootstrap.ts +12 -4
  139. package/src/plugins/core/__test__/registerReactComponent.test.ts +96 -0
  140. package/src/plugins/core/registerReactComponent.tsx +42 -10
  141. package/src/plugins/impl/MultiselectPlugin.tsx +19 -142
  142. package/src/plugins/impl/SearchableSelect.tsx +16 -97
  143. package/src/plugins/impl/SliderPlugin.tsx +132 -18
  144. package/src/plugins/impl/__tests__/DropdownPlugin.test.tsx +5 -2
  145. package/src/plugins/impl/__tests__/MultiSelectPlugin.test.ts +1 -1
  146. package/src/plugins/impl/__tests__/SliderPlugin.test.tsx +278 -8
  147. package/src/plugins/layout/DownloadPlugin.tsx +1 -1
  148. package/src/utils/__tests__/numbers.test.ts +20 -0
  149. package/src/utils/errors.ts +9 -0
  150. package/src/utils/lazy.ts +6 -1
  151. package/src/utils/numbers.ts +27 -0
  152. package/dist/assets/__vite-browser-external-Ci2ZQfXU.js +0 -1
  153. package/dist/assets/worker-ip3AI_sN.js +0 -73
  154. package/dist/dist-0Fif7jnk.js +0 -5
  155. package/dist/dist-B5h_9sHB.js +0 -6
  156. package/dist/dist-B9M6R5ye.js +0 -5
  157. package/dist/dist-BCt3tnck.js +0 -8
  158. package/dist/dist-BUIJwMwn.js +0 -8
  159. package/dist/dist-BpquMd3k.js +0 -5
  160. package/dist/dist-BzJsqYfz.js +0 -5
  161. package/dist/dist-CA5ELXAf.js +0 -6
  162. package/dist/dist-CLBRs6Uv.js +0 -5
  163. package/dist/dist-CStVCMbq.js +0 -5
  164. package/dist/dist-CZRIEY3Y.js +0 -8
  165. package/dist/dist-CuUHbFD0.js +0 -5
  166. package/dist/dist-DV7Iabxb.js +0 -8
  167. package/dist/dist-DhHh0jLg.js +0 -1247
  168. package/dist/dist-DuEeHMvL.js +0 -5
  169. package/dist/esm-BfhQmZjp.js +0 -1171
  170. package/src/plugins/impl/multiselectFilterFn.tsx +0 -22
  171. /package/src/components/{data-table → ui}/value-chips.tsx +0 -0
@@ -1,10 +1,15 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- import { type JSX, useEffect, useId, useState } from "react";
2
+ import { type JSX, useEffect, useId, useMemo, useState } from "react";
3
3
  import { useLocale } from "react-aria";
4
4
  import { z } from "zod";
5
5
  import { NumberField } from "@/components/ui/number-field";
6
6
  import { cn } from "@/utils/cn";
7
- import { prettyScientificNumber } from "@/utils/numbers";
7
+ import { clamp } from "@/utils/math";
8
+ import {
9
+ maxFractionDigitsForSteps,
10
+ prettyScientificNumber,
11
+ roundToFractionDigits,
12
+ } from "@/utils/numbers";
8
13
  import { Slider } from "../../components/ui/slider";
9
14
  import type { IPlugin, IPluginProps, Setter } from "../types";
10
15
  import { Labeled } from "./common/labeled";
@@ -46,8 +51,9 @@ export class SliderPlugin implements IPlugin<T, Data> {
46
51
  render(props: IPluginProps<T, Data>): JSX.Element {
47
52
  // Create the valueMap function
48
53
  const valueMap = (sliderValue: number): number => {
49
- if (props.data.steps && props.data.steps.length > 0) {
50
- return props.data.steps[sliderValue];
54
+ const { steps } = props.data;
55
+ if (steps && steps.length > 0) {
56
+ return steps[clamp(sliderValue, 0, steps.length - 1)];
51
57
  }
52
58
  return sliderValue;
53
59
  };
@@ -69,6 +75,66 @@ interface SliderProps extends Data {
69
75
  valueMap: (sliderValue: number) => number;
70
76
  }
71
77
 
78
+ interface StepsConfig {
79
+ steps: T[];
80
+ fractionDigits: number;
81
+ minValue: number;
82
+ maxValue: number;
83
+ inputStep: number;
84
+ formatOptions: {
85
+ minimumFractionDigits: number;
86
+ maximumFractionDigits: number;
87
+ };
88
+ }
89
+
90
+ // Index of the step whose value is closest to `target`.
91
+ function nearestStepIndex(stepValues: T[], target: number): number {
92
+ let bestIndex = 0;
93
+ let bestDistance = Number.POSITIVE_INFINITY;
94
+ for (let i = 0; i < stepValues.length; i++) {
95
+ const distance = Math.abs(stepValues[i] - target);
96
+ if (distance < bestDistance) {
97
+ bestDistance = distance;
98
+ bestIndex = i;
99
+ }
100
+ }
101
+ return bestIndex;
102
+ }
103
+
104
+ // When `steps` are provided, the slider runs in *index* space (start=0,
105
+ // stop=len-1, step=1) while the input must display and accept the *actual*
106
+ // step values. This config maps the input back into value space.
107
+ function computeStepsConfig(steps: T[] | null): StepsConfig | null {
108
+ if (!steps || steps.length === 0) {
109
+ return null;
110
+ }
111
+ const sorted = steps.toSorted((a, b) => a - b);
112
+ // Smallest positive gap between consecutive step values, used as the input's
113
+ // increment/decrement amount. Falls back to 1 when undefined.
114
+ let inputStep = Number.POSITIVE_INFINITY;
115
+ for (let i = 1; i < sorted.length; i++) {
116
+ const gap = sorted[i] - sorted[i - 1];
117
+ if (gap > 0 && gap < inputStep) {
118
+ inputStep = gap;
119
+ }
120
+ }
121
+ if (!Number.isFinite(inputStep) || inputStep <= 0) {
122
+ inputStep = 1;
123
+ }
124
+ const fractionDigits = maxFractionDigitsForSteps(steps, inputStep);
125
+ return {
126
+ steps,
127
+ fractionDigits,
128
+ minValue: roundToFractionDigits(sorted[0], fractionDigits),
129
+ maxValue: roundToFractionDigits(sorted[sorted.length - 1], fractionDigits),
130
+ inputStep: roundToFractionDigits(inputStep, fractionDigits),
131
+ formatOptions: {
132
+ minimumFractionDigits: 0,
133
+ maximumFractionDigits: fractionDigits,
134
+ },
135
+ };
136
+ }
137
+
72
138
  const SliderComponent = ({
73
139
  label,
74
140
  setValue,
@@ -95,6 +161,64 @@ const SliderComponent = ({
95
161
  setInternalValue(value);
96
162
  }, [value]);
97
163
 
164
+ const stepsConfig = useMemo(() => computeStepsConfig(steps), [steps]);
165
+
166
+ const handleInputChange = (nextValue: number | null | undefined): void => {
167
+ if (stepsConfig) {
168
+ const { steps: stepValues, fractionDigits } = stepsConfig;
169
+ // Cleared input -> reset to the first step.
170
+ if (nextValue == null || Number.isNaN(nextValue)) {
171
+ setInternalValue(0);
172
+ setValue(0);
173
+ return;
174
+ }
175
+ const roundedNext = roundToFractionDigits(nextValue, fractionDigits);
176
+ let index = nearestStepIndex(stepValues, roundedNext);
177
+ // If the nearest step is the current one but the value actually moved
178
+ // (e.g. stepper arrows on non-uniform steps), nudge one index in the
179
+ // direction of travel so the input never gets "stuck". Caveat: a typed
180
+ // value closest to the current step but past it nudges too, so on
181
+ // non-uniform steps typing a nearby number can jump to the adjacent step.
182
+ const currentValue = roundToFractionDigits(
183
+ stepValues[internalValue] ?? stepValues[0],
184
+ fractionDigits,
185
+ );
186
+ if (index === internalValue && roundedNext !== currentValue) {
187
+ index =
188
+ roundedNext > currentValue
189
+ ? Math.min(internalValue + 1, stepValues.length - 1)
190
+ : Math.max(internalValue - 1, 0);
191
+ }
192
+ setInternalValue(index);
193
+ setValue(index);
194
+ return;
195
+ }
196
+
197
+ // No steps: the input value is the slider value directly.
198
+ const resolved =
199
+ nextValue == null || Number.isNaN(nextValue) ? Number(start) : nextValue;
200
+ setInternalValue(resolved);
201
+ setValue(resolved);
202
+ };
203
+
204
+ const inputProps = stepsConfig
205
+ ? {
206
+ value: roundToFractionDigits(
207
+ valueMap(internalValue),
208
+ stepsConfig.fractionDigits,
209
+ ),
210
+ minValue: stepsConfig.minValue,
211
+ maxValue: stepsConfig.maxValue,
212
+ step: stepsConfig.inputStep,
213
+ formatOptions: stepsConfig.formatOptions,
214
+ }
215
+ : {
216
+ value: valueMap(internalValue),
217
+ minValue: start,
218
+ maxValue: stop,
219
+ step,
220
+ };
221
+
98
222
  const sliderElement = (
99
223
  <Labeled
100
224
  label={label}
@@ -123,14 +247,14 @@ const SliderComponent = ({
123
247
  step={step}
124
248
  orientation={orientation}
125
249
  // Triggered on all value changes
126
- onValueChange={([nextValue]) => {
250
+ onValueChange={([nextValue]: number[]) => {
127
251
  setInternalValue(nextValue);
128
252
  if (!debounce) {
129
253
  setValue(nextValue);
130
254
  }
131
255
  }}
132
256
  // Triggered on mouse up
133
- onValueCommit={([nextValue]) => {
257
+ onValueCommit={([nextValue]: [number]) => {
134
258
  if (debounce) {
135
259
  setValue(nextValue);
136
260
  }
@@ -145,18 +269,8 @@ const SliderComponent = ({
145
269
  )}
146
270
  {includeInput && (
147
271
  <NumberField
148
- value={valueMap(internalValue)}
149
- onChange={(nextValue) => {
150
- // If nextValue is null/undefined/NaN (input cleared), set to start
151
- if (nextValue == null || Number.isNaN(nextValue)) {
152
- nextValue = Number(start);
153
- }
154
- setInternalValue(nextValue);
155
- setValue(nextValue);
156
- }}
157
- minValue={start}
158
- maxValue={stop}
159
- step={step}
272
+ {...inputProps}
273
+ onChange={handleInputChange}
160
274
  className="w-24"
161
275
  aria-label={`${label || "Slider"} value input`}
162
276
  isDisabled={disabled}
@@ -225,8 +225,11 @@ describe("DropdownPlugin", () => {
225
225
  screen.getByTestId("marimo-plugin-searchable-dropdown").firstChild!,
226
226
  );
227
227
 
228
- // Select none should clear value
229
- fireEvent.click(screen.getByText("--"));
228
+ // Re-picking the current value clears it when allowSelectNone
229
+ const bananaOption = screen
230
+ .getAllByRole("option")
231
+ .find((el) => el.textContent === "Banana");
232
+ fireEvent.click(bananaOption!);
230
233
  expect(setValue).toHaveBeenCalledWith([]);
231
234
  });
232
235
  });
@@ -2,7 +2,7 @@
2
2
  import { beforeEach, expect, it, vi } from "vitest";
3
3
  import { initialModeAtom } from "@/core/mode";
4
4
  import { store } from "@/core/state/jotai";
5
- import { multiselectFilterFn } from "../multiselectFilterFn";
5
+ import { multiselectFilterFn } from "@/components/ui/select-core";
6
6
 
7
7
  function filterOptions(filter: string, items: string[]) {
8
8
  return items.filter((option) => multiselectFilterFn(option, filter));
@@ -50,11 +50,18 @@ describe("SliderPlugin", () => {
50
50
  vi.useRealTimers();
51
51
  });
52
52
 
53
- const createProps = (
54
- debounce: boolean,
55
- includeInput: boolean,
56
- setValue: ReturnType<typeof vi.fn>,
57
- ): IPluginProps<number, z.infer<typeof SliderPlugin.prototype.validator>> => {
53
+ const createProps = ({
54
+ debounce,
55
+ includeInput,
56
+ setValue,
57
+ }: {
58
+ debounce: boolean;
59
+ includeInput: boolean;
60
+ setValue: ReturnType<typeof vi.fn>;
61
+ }): IPluginProps<
62
+ number,
63
+ z.infer<typeof SliderPlugin.prototype.validator>
64
+ > => {
58
65
  return {
59
66
  host: document.createElement("div"),
60
67
  value: 5,
@@ -76,10 +83,57 @@ describe("SliderPlugin", () => {
76
83
  };
77
84
  };
78
85
 
86
+ // When `steps` are provided, the slider works in *index* space: `value`,
87
+ // `start`, `stop` and `step` are all indices into the `steps` array, while
88
+ // the editable input shows/accepts the actual step values.
89
+ const createStepsProps = ({
90
+ steps,
91
+ valueIndex,
92
+ setValue,
93
+ }: {
94
+ steps: number[];
95
+ valueIndex: number;
96
+ setValue: ReturnType<typeof vi.fn>;
97
+ }): IPluginProps<
98
+ number,
99
+ z.infer<typeof SliderPlugin.prototype.validator>
100
+ > => {
101
+ return {
102
+ host: document.createElement("div"),
103
+ value: valueIndex,
104
+ setValue,
105
+ data: {
106
+ initialValue: valueIndex,
107
+ start: 0,
108
+ stop: steps.length - 1,
109
+ step: 1,
110
+ label: "Test Slider",
111
+ debounce: false,
112
+ orientation: "horizontal" as const,
113
+ showValue: false,
114
+ fullWidth: false,
115
+ includeInput: true,
116
+ steps,
117
+ },
118
+ functions: {},
119
+ };
120
+ };
121
+
122
+ const typeAndCommit = (input: HTMLElement, value: string) => {
123
+ act(() => {
124
+ fireEvent.change(input, { target: { value } });
125
+ fireEvent.blur(input);
126
+ });
127
+ };
128
+
79
129
  it("slider triggers setValue immediately when debounce is false", () => {
80
130
  const plugin = new SliderPlugin();
81
131
  const setValue = vi.fn();
82
- const props = createProps(false, false, setValue);
132
+ const props = createProps({
133
+ debounce: false,
134
+ includeInput: false,
135
+ setValue,
136
+ });
83
137
  const { getByRole } = render(plugin.render(props));
84
138
 
85
139
  act(() => {
@@ -98,7 +152,11 @@ describe("SliderPlugin", () => {
98
152
  it("slider waits until commit before calling setValue when debounce is true", () => {
99
153
  const plugin = new SliderPlugin();
100
154
  const setValue = vi.fn();
101
- const props = createProps(true, false, setValue);
155
+ const props = createProps({
156
+ debounce: true,
157
+ includeInput: false,
158
+ setValue,
159
+ });
102
160
  const { getByRole } = render(plugin.render(props));
103
161
 
104
162
  act(() => {
@@ -124,7 +182,7 @@ describe("SliderPlugin", () => {
124
182
  it("editable input triggers setValue immediately even when slider debounce is true", () => {
125
183
  const plugin = new SliderPlugin();
126
184
  const setValue = vi.fn();
127
- const props = createProps(true, true, setValue);
185
+ const props = createProps({ debounce: true, includeInput: true, setValue });
128
186
  const { getByRole } = render(plugin.render(props));
129
187
 
130
188
  act(() => {
@@ -145,4 +203,216 @@ describe("SliderPlugin", () => {
145
203
  // setValue should be called immediately regardless of debounce=true.
146
204
  expect(setValue).toHaveBeenCalledWith(9);
147
205
  });
206
+
207
+ describe("editable input with steps (regression for #9850)", () => {
208
+ it("displays the actual step value, not the index", () => {
209
+ const plugin = new SliderPlugin();
210
+ const setValue = vi.fn();
211
+ // steps[0] === -4, displayed value must be -4 (not the index 0).
212
+ const props = createStepsProps({
213
+ steps: [-4, -3, -2, -1],
214
+ valueIndex: 0,
215
+ setValue,
216
+ });
217
+ const { getByRole } = render(plugin.render(props));
218
+
219
+ act(() => {
220
+ vi.advanceTimersByTime(0);
221
+ });
222
+
223
+ const input = getByRole("textbox") as HTMLInputElement;
224
+ expect(input.value).toBe("-4");
225
+ });
226
+
227
+ it("displays decimal step values without float artifacts", () => {
228
+ const plugin = new SliderPlugin();
229
+ const setValue = vi.fn();
230
+ const props = createStepsProps({
231
+ steps: [0.1, 0.2, 0.3, 0.4],
232
+ valueIndex: 2,
233
+ setValue,
234
+ });
235
+ const { getByRole } = render(plugin.render(props));
236
+
237
+ act(() => {
238
+ vi.advanceTimersByTime(0);
239
+ });
240
+
241
+ const input = getByRole("textbox") as HTMLInputElement;
242
+ expect(input.value).toBe("0.3");
243
+ });
244
+
245
+ it("steps decimal input increment and decrement buttons move between steps", () => {
246
+ const plugin = new SliderPlugin();
247
+ const setValue = vi.fn();
248
+ const props = createStepsProps({
249
+ steps: [0.1, 0.2, 0.3, 0.4],
250
+ valueIndex: 2,
251
+ setValue,
252
+ });
253
+ const { getByRole } = render(plugin.render(props));
254
+
255
+ act(() => {
256
+ vi.advanceTimersByTime(0);
257
+ });
258
+
259
+ const decrement = getByRole("button", {
260
+ name: "Decrease Test Slider value input",
261
+ });
262
+ const increment = getByRole("button", {
263
+ name: "Increase Test Slider value input",
264
+ });
265
+
266
+ expect(decrement).not.toBeDisabled();
267
+
268
+ act(() => {
269
+ fireEvent.click(decrement);
270
+ });
271
+ expect(setValue.mock.calls).toEqual([[1]]);
272
+
273
+ act(() => {
274
+ fireEvent.click(increment);
275
+ });
276
+ expect(setValue).toHaveBeenLastCalledWith(2);
277
+ });
278
+
279
+ it("maps a typed integer step value back to its index", () => {
280
+ const plugin = new SliderPlugin();
281
+ const setValue = vi.fn();
282
+ const props = createStepsProps({
283
+ steps: [1, 2, 3, 4],
284
+ valueIndex: 0,
285
+ setValue,
286
+ });
287
+ const { getByRole } = render(plugin.render(props));
288
+
289
+ act(() => {
290
+ vi.advanceTimersByTime(0);
291
+ });
292
+
293
+ const input = getByRole("textbox");
294
+ // Typing "4" should select the last step (index 3), not index 4.
295
+ typeAndCommit(input, "4");
296
+ expect(setValue).toHaveBeenLastCalledWith(3);
297
+
298
+ // Typing "2" should select index 1, not get "stuck" on 3.
299
+ typeAndCommit(input, "2");
300
+ expect(setValue).toHaveBeenLastCalledWith(1);
301
+ });
302
+
303
+ it("maps fractional step values back to their index", () => {
304
+ const plugin = new SliderPlugin();
305
+ const setValue = vi.fn();
306
+ const props = createStepsProps({
307
+ steps: [0.1, 0.2, 0.3, 0.4],
308
+ valueIndex: 0,
309
+ setValue,
310
+ });
311
+ const { getByRole } = render(plugin.render(props));
312
+
313
+ act(() => {
314
+ vi.advanceTimersByTime(0);
315
+ });
316
+
317
+ const input = getByRole("textbox");
318
+ typeAndCommit(input, "0.3");
319
+ expect(setValue).toHaveBeenLastCalledWith(2);
320
+ });
321
+
322
+ it("maps negative step values back to their index", () => {
323
+ const plugin = new SliderPlugin();
324
+ const setValue = vi.fn();
325
+ const props = createStepsProps({
326
+ steps: [-4, -3, -2, -1],
327
+ valueIndex: 0,
328
+ setValue,
329
+ });
330
+ const { getByRole } = render(plugin.render(props));
331
+
332
+ act(() => {
333
+ vi.advanceTimersByTime(0);
334
+ });
335
+
336
+ const input = getByRole("textbox");
337
+ typeAndCommit(input, "-2");
338
+ expect(setValue).toHaveBeenLastCalledWith(2);
339
+ });
340
+
341
+ it("clamps out-of-range typed values to the nearest step index", () => {
342
+ const plugin = new SliderPlugin();
343
+ const setValue = vi.fn();
344
+ const props = createStepsProps({
345
+ steps: [1, 2, 3, 4],
346
+ valueIndex: 0,
347
+ setValue,
348
+ });
349
+ const { getByRole } = render(plugin.render(props));
350
+
351
+ act(() => {
352
+ vi.advanceTimersByTime(0);
353
+ });
354
+
355
+ const input = getByRole("textbox");
356
+ // Above the max step -> clamps to the last index.
357
+ typeAndCommit(input, "100");
358
+ expect(setValue).toHaveBeenLastCalledWith(3);
359
+ });
360
+
361
+ it("does not crash when steps shrink while the index is temporarily out of range", () => {
362
+ const plugin = new SliderPlugin();
363
+ const setValue = vi.fn();
364
+ // Start at the last index of a 5-element steps array.
365
+ const props = createStepsProps({
366
+ steps: [10, 20, 30, 40, 50],
367
+ valueIndex: 4,
368
+ setValue,
369
+ });
370
+ const { getByRole, rerender } = render(plugin.render(props));
371
+
372
+ act(() => {
373
+ vi.advanceTimersByTime(0);
374
+ });
375
+
376
+ // Shrink `steps` so the held index (4) is now out of range. The index
377
+ // syncs in an effect after this render, so the render must not throw on
378
+ // the stale, out-of-range index.
379
+ const shrunkProps = createStepsProps({
380
+ steps: [10, 20],
381
+ valueIndex: 1,
382
+ setValue,
383
+ });
384
+ expect(() => {
385
+ act(() => {
386
+ rerender(plugin.render(shrunkProps));
387
+ vi.advanceTimersByTime(0);
388
+ });
389
+ }).not.toThrow();
390
+
391
+ const input = getByRole("textbox") as HTMLInputElement;
392
+ expect(input.value).toBe("20");
393
+ });
394
+
395
+ it("nudges to the adjacent step on non-uniform steps even when the current step is nearest (known caveat)", () => {
396
+ const plugin = new SliderPlugin();
397
+ const setValue = vi.fn();
398
+ // Non-uniform steps; start at index 2 (value 10).
399
+ const props = createStepsProps({
400
+ steps: [0, 1, 10, 100],
401
+ valueIndex: 2,
402
+ setValue,
403
+ });
404
+ const { getByRole } = render(plugin.render(props));
405
+
406
+ act(() => {
407
+ vi.advanceTimersByTime(0);
408
+ });
409
+
410
+ const input = getByRole("textbox");
411
+ // 10 is far closer to 12 than 100 is, but because 12 > 10 the input nudges
412
+ // up one index. This documents the stepper-vs-typing limitation: onChange
413
+ // can't distinguish a typed value from a stepper-button increment.
414
+ typeAndCommit(input, "12");
415
+ expect(setValue).toHaveBeenLastCalledWith(3);
416
+ });
417
+ });
148
418
  });
@@ -98,7 +98,7 @@ const DownloadButton = ({
98
98
  try {
99
99
  const loadedData = await load({});
100
100
  downloadByURL(
101
- loadedData.data,
101
+ loadedData.data || data.data,
102
102
  loadedData.filename ?? data.filename ?? undefined,
103
103
  );
104
104
  } catch (error) {
@@ -1,6 +1,8 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import { describe, expect, it } from "vitest";
3
3
  import {
4
+ countFractionDigits,
5
+ maxFractionDigitsForSteps,
4
6
  prettyEngineeringNumber,
5
7
  prettyNumber,
6
8
  prettyScientificNumber,
@@ -86,3 +88,21 @@ describe("prettyEngineeringNumber", () => {
86
88
  expect(prettyEngineeringNumber(-0.000_123_4, locale)).toBe("-123µ");
87
89
  });
88
90
  });
91
+
92
+ describe("countFractionDigits", () => {
93
+ it("counts decimal places without float noise", () => {
94
+ expect(countFractionDigits(1)).toBe(0);
95
+ expect(countFractionDigits(0.1)).toBe(1);
96
+ expect(countFractionDigits(0.3)).toBe(1);
97
+ expect(countFractionDigits(0.000025)).toBe(6);
98
+ expect(countFractionDigits(3.5)).toBe(1);
99
+ });
100
+ });
101
+
102
+ describe("maxFractionDigitsForSteps", () => {
103
+ it("infers precision from steps and gaps", () => {
104
+ expect(maxFractionDigitsForSteps([0.1, 0.2, 0.3, 0.4], 0.1)).toBe(1);
105
+ expect(maxFractionDigitsForSteps([1, 2, 3.5, 4], 0.5)).toBe(1);
106
+ expect(maxFractionDigitsForSteps([1, 2, 3, 4], 1)).toBe(0);
107
+ });
108
+ });
@@ -71,6 +71,15 @@ export class CellNotInitializedError extends Error {
71
71
  }
72
72
  }
73
73
 
74
+ export class FunctionNotFoundError extends Error {
75
+ constructor(
76
+ message = "This UI element could not reach its function on the kernel. Try re-running the cell.",
77
+ ) {
78
+ super(message);
79
+ this.name = "FunctionNotFoundError";
80
+ }
81
+ }
82
+
74
83
  export class NoKernelConnectedError extends Error {
75
84
  constructor(message = "Not yet connected to a kernel.") {
76
85
  super(message);
package/src/utils/lazy.ts CHANGED
@@ -3,7 +3,12 @@
3
3
  import React from "react";
4
4
 
5
5
  interface LazyComponentWithPreload<T> {
6
- preload: () => void;
6
+ /**
7
+ * Eagerly trigger the dynamic import. Returns the import promise so callers
8
+ * can await it or attach error handling; safe to call multiple times (the
9
+ * import is memoized).
10
+ */
11
+ preload: () => Promise<{ default: React.ComponentType<T> }>;
7
12
  Component: React.LazyExoticComponent<React.ComponentType<T>>;
8
13
  }
9
14
 
@@ -23,6 +23,33 @@ export const maxFractionalDigits = memoizeLastValue((locale: string) => {
23
23
  return 0;
24
24
  });
25
25
 
26
+ /** Decimal places needed to display `n` without float noise. */
27
+ export function countFractionDigits(n: number): number {
28
+ if (!Number.isFinite(n) || n === 0) {
29
+ return 0;
30
+ }
31
+ const normalized = n.toFixed(12).replace(/\.?0+$/, "");
32
+ const dot = normalized.indexOf(".");
33
+ return dot === -1 ? 0 : normalized.length - dot - 1;
34
+ }
35
+
36
+ /** Max fraction digits to display a set of step values cleanly. */
37
+ export function maxFractionDigitsForSteps(
38
+ steps: number[],
39
+ minGap: number,
40
+ ): number {
41
+ let max = countFractionDigits(minGap);
42
+ for (const step of steps) {
43
+ max = Math.max(max, countFractionDigits(step));
44
+ }
45
+ return max;
46
+ }
47
+
48
+ /** Round away float noise so step-based inputs stay on a clean decimal grid. */
49
+ export function roundToFractionDigits(n: number, digits: number): number {
50
+ return Number(n.toFixed(digits));
51
+ }
52
+
26
53
  export function prettyNumber(value: unknown, locale: string): string {
27
54
  if (value === undefined || value === null) {
28
55
  return "";
@@ -1 +0,0 @@
1
- import{t as e}from"./worker-ip3AI_sN.js";var t=e(((e,t)=>{t.exports={}}));export default t();