@marimo-team/frontend 0.25.3-dev134 → 0.25.3-dev136

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 (98) hide show
  1. package/dist/assets/{CellStatus-DC6FFQOg.js → CellStatus-B9hoYYSq.js} +1 -1
  2. package/dist/assets/{ErrorBoundary-Ce-Ujlwj.js → ErrorBoundary-DIoi62xQ.js} +2 -2
  3. package/dist/assets/{JsonOutput-CfEBzaqW.js → JsonOutput-f0zvWw7g.js} +5 -5
  4. package/dist/assets/{MarimoErrorOutput-Bh6o9zle.js → MarimoErrorOutput-DFCWAXPj.js} +4 -4
  5. package/dist/assets/{RenderHTML-COGZsXQV.js → RenderHTML-CJOIsfDF.js} +2 -2
  6. package/dist/assets/{RunButton-DQ51plUv.js → RunButton-BhbOSnBI.js} +1 -1
  7. package/dist/assets/{add-cell-with-ai-D7gtGUsg.js → add-cell-with-ai-xzt8ZIZY.js} +26 -26
  8. package/dist/assets/{add-connection-dialog-CpMCc97S.js → add-connection-dialog-CLcZdzEt.js} +51 -51
  9. package/dist/assets/{agent-panel-BSFYqJ9B.js → agent-panel-x_kvcauj.js} +3 -3
  10. package/dist/assets/ai-model-dropdown-DcOMJlL4.js +13 -0
  11. package/dist/assets/{app-config-button-Cfcg_5Bz.js → app-config-button-gr-9epH3.js} +1 -1
  12. package/dist/assets/{blob-CszMu60N.js → blob-B25C4ORK.js} +1 -1
  13. package/dist/assets/{cache-panel-DuBsA6oO.js → cache-panel-BK4-1NJC.js} +1 -1
  14. package/dist/assets/cell-editor-CO_2xdmY.js +42 -0
  15. package/dist/assets/{cell-link-Cj0gGaMV.js → cell-link-DBP_BcJY.js} +1 -1
  16. package/dist/assets/{cells-SvbLY10y.js → cells-B9RIIuYa.js} +42 -42
  17. package/dist/assets/{chat-display-D43_g3TX.js → chat-display-CvnF2GaH.js} +1 -1
  18. package/dist/assets/{chat-panel-B_HAJMwx.js → chat-panel-CJFHiZ2R.js} +2 -2
  19. package/dist/assets/{chat-ui-BEcYCphu.js → chat-ui-DbPlwime.js} +1 -1
  20. package/dist/assets/{column-preview-CrzaJKk8.js → column-preview-j0kenrsq.js} +1 -1
  21. package/dist/assets/{command-palette-Bm9yIVcD.js → command-palette-CQ1DPGNl.js} +1 -1
  22. package/dist/assets/{common-BJyUxCzv.js → common-C9Gvbmz5.js} +1 -1
  23. package/dist/assets/{components-D1f_Cz4i.js → components-CMj235gS.js} +1 -1
  24. package/dist/assets/{datasource-C8KSVfSD.js → datasource-BsklC6tp.js} +2 -2
  25. package/dist/assets/{dependency-graph-panel-BhmyJbUl.js → dependency-graph-panel-BNqY5al9.js} +3 -3
  26. package/dist/assets/{documentation-panel-DcVG0Q0d.js → documentation-panel-UrRQcsOB.js} +1 -1
  27. package/dist/assets/{download-Bx52VRE4.js → download-BRqw2JzE.js} +1 -1
  28. package/dist/assets/{edit-page-DUrwOHR-.js → edit-page-Dqkmdmfj.js} +6 -6
  29. package/dist/assets/{error-entries-11OuHzUF.js → error-entries-CuIzi5P7.js} +1 -1
  30. package/dist/assets/{error-panel-XJy5S25F.js → error-panel-CFCs0M8e.js} +1 -1
  31. package/dist/assets/{feedback-button-B7jh_B4w.js → feedback-button-DZVOiUEf.js} +2 -2
  32. package/dist/assets/feedback-button-J9hRaX_K.js +1 -0
  33. package/dist/assets/{file-explorer-panel-CnVxbF4A.js → file-explorer-panel-Cqey5tMY.js} +1 -1
  34. package/dist/assets/{file-name-input-BJuqjQqH.js → file-name-input-khW7mtt2.js} +1 -1
  35. package/dist/assets/{floating-outline-8yyZ-MOJ.js → floating-outline-CSwtWNm9.js} +1 -1
  36. package/dist/assets/{formatting-DW2u7HXI.js → formatting-CMgDQsM2.js} +1 -1
  37. package/dist/assets/{gallery-page-DJvGHePf.js → gallery-page-CcG0Msi3.js} +1 -1
  38. package/dist/assets/{glide-data-editor-Cyc8DHZx.js → glide-data-editor-B46-8C6x.js} +1 -1
  39. package/dist/assets/{home-page-tRZJmSU6.js → home-page-C5qcnnak.js} +1 -1
  40. package/dist/assets/{hooks-CxGwybGw.js → hooks-CiNPyjez.js} +1 -1
  41. package/dist/assets/index-D1hW_v1v.js +30 -0
  42. package/dist/assets/{input-DLA5Dhn3.js → input-BQBy_25D.js} +1 -1
  43. package/dist/assets/{layout-DvXJxBiL.js → layout-DSsztKhq.js} +5 -5
  44. package/dist/assets/{logs-panel-KtsY7TrR.js → logs-panel-DZ8zJWNT.js} +1 -1
  45. package/dist/assets/{markdown-renderer-DUgV7_fm.js → markdown-renderer-DszNRkaL.js} +1 -1
  46. package/dist/assets/{name-cell-input-C3-Cj-Ek.js → name-cell-input-BIpvW2kg.js} +1 -1
  47. package/dist/assets/numbers-BB-lSqeY.js +1 -0
  48. package/dist/assets/{outline-panel-BzyY4JA6.js → outline-panel-CHwFhhFo.js} +1 -1
  49. package/dist/assets/{packages-panel-DxosbWko.js → packages-panel-qT6TuHGP.js} +1 -1
  50. package/dist/assets/{panel-accordion-state-BRbTib2B.js → panel-accordion-state-DROYE5RR.js} +1 -1
  51. package/dist/assets/panels-3HImHlAU.js +1 -0
  52. package/dist/assets/{process-output-CSwx7rvo.js → process-output-CPh21qjo.js} +1 -1
  53. package/dist/assets/{readonly-code-display-B9y9423Y.js → readonly-code-display-DKoAsBb-.js} +1 -1
  54. package/dist/assets/{readonly-python-code-Dgw6trlk.js → readonly-python-code-QqiDzhYb.js} +1 -1
  55. package/dist/assets/{reveal-component-B9A4wkG-.js → reveal-component-Dd29QoYx.js} +2 -2
  56. package/dist/assets/{run-page-C1MQ5b_A.js → run-page-J_1gGmPH.js} +1 -1
  57. package/dist/assets/{runtime-details-state-D_hOQL9T.js → runtime-details-state-BlarRoM1.js} +1 -1
  58. package/dist/assets/{scratchpad-panel-CBCsPmMW.js → scratchpad-panel-BPw7VxO6.js} +1 -1
  59. package/dist/assets/{secrets-panel-ConQpchl.js → secrets-panel-j7vcP2r2.js} +1 -1
  60. package/dist/assets/session-panel-BnszHmkW.js +1 -0
  61. package/dist/assets/{snippets-panel-C0VDE_4i.js → snippets-panel-a-A15D0n.js} +1 -1
  62. package/dist/assets/{state-DvU2AiFx.js → state-2U7LSUNv.js} +4 -4
  63. package/dist/assets/{state-DrraMmqz.js → state-B_SxQGvw.js} +1 -1
  64. package/dist/assets/{switch-D2gdC_ep.js → switch-DLzvDOLo.js} +1 -1
  65. package/dist/assets/{tracing-C5d51Nmi.js → tracing-CpbvZwxP.js} +1 -1
  66. package/dist/assets/{tracing-panel-B89R8dWE.js → tracing-panel-Dxtm4-GJ.js} +2 -2
  67. package/dist/assets/{tree-actions-Cph8FVzh.js → tree-actions-CIBdpNC-.js} +1 -1
  68. package/dist/assets/{useAddCell-VPkB72pn.js → useAddCell-B6my967m.js} +1 -1
  69. package/dist/assets/{useCellActionButton--zI3GFnt.js → useCellActionButton-BwBEO8zi.js} +1 -1
  70. package/dist/assets/{useDeleteCell-CjewKeSr.js → useDeleteCell-DYRsSrAa.js} +1 -1
  71. package/dist/assets/{useDependencyPanelTab-Bt_ka-wl.js → useDependencyPanelTab-lNbBYJZs.js} +1 -1
  72. package/dist/assets/{useInstallPackage-BfNKN33w.js → useInstallPackage-Q9agSKO7.js} +1 -1
  73. package/dist/assets/useNotebookActions-CmG2irDr.js +1 -0
  74. package/dist/assets/{useRunCells-BupCZpzk.js → useRunCells-Bl702L84.js} +1 -1
  75. package/dist/assets/{useSplitCell-CaLIR0TS.js → useSplitCell-DQRIXpFK.js} +1 -1
  76. package/dist/assets/{write-secret-modal-CBi2vNK_.js → write-secret-modal-BujVAO67.js} +1 -1
  77. package/dist/index.html +25 -25
  78. package/package.json +1 -1
  79. package/src/components/datasources/__tests__/column-preview.test.tsx +35 -3
  80. package/src/components/datasources/__tests__/datasources-interactions.test.tsx +181 -0
  81. package/src/components/datasources/__tests__/utils.test.ts +57 -6
  82. package/src/components/datasources/column-preview.tsx +5 -2
  83. package/src/components/datasources/datasources.tsx +3 -4
  84. package/src/components/datasources/utils.ts +32 -23
  85. package/src/components/ui/__tests__/range-slider.test.tsx +165 -0
  86. package/src/components/ui/range-slider.tsx +22 -9
  87. package/src/plugins/impl/RangeSliderPlugin.tsx +17 -2
  88. package/src/plugins/impl/__tests__/RangeSliderPlugin.test.tsx +109 -0
  89. package/src/utils/__tests__/numbers.test.ts +57 -0
  90. package/src/utils/numbers.ts +59 -9
  91. package/dist/assets/ai-model-dropdown-D575Vid8.js +0 -13
  92. package/dist/assets/cell-editor-BLpOlLnM.js +0 -42
  93. package/dist/assets/feedback-button-C-eKtxXE.js +0 -1
  94. package/dist/assets/index-CoxbqhGH.js +0 -30
  95. package/dist/assets/numbers-DJK15_4S.js +0 -1
  96. package/dist/assets/panels-Da3071Q4.js +0 -1
  97. package/dist/assets/session-panel-CiHCKPlx.js +0 -1
  98. package/dist/assets/useNotebookActions-DV2S6KaG.js +0 -1
@@ -34,6 +34,37 @@ describe("sqlCode", () => {
34
34
  name: "email" as const,
35
35
  } as DataTableColumn;
36
36
 
37
+ it.each([
38
+ {
39
+ path: ["finance", "reports"],
40
+ expected: '"memory"."finance"."reports"."users"',
41
+ },
42
+ { path: ["finance.reports"], expected: '"finance.reports"."users"' },
43
+ {
44
+ path: ['fi"nance', "reports"],
45
+ expected: '"memory"."fi""nance"."reports"."users"',
46
+ },
47
+ {
48
+ path: ["main", "reports"],
49
+ expected: '"memory"."main"."reports"."users"',
50
+ },
51
+ ])("preserves schema path $path in generated SQL", ({ path, expected }) => {
52
+ const result = sqlCode({
53
+ table: mockTable,
54
+ columnName: "*",
55
+ sqlTableContext: {
56
+ engine: DUCKDB_ENGINE,
57
+ dialect: "duckdb",
58
+ database: "memory",
59
+ defaultDatabase: "memory",
60
+ defaultSchema: "main",
61
+ schema: path.at(-1)!,
62
+ schemaPath: path,
63
+ },
64
+ });
65
+ expect(result).toContain(`FROM ${expected}`);
66
+ });
67
+
37
68
  describe("basic SQL generation", () => {
38
69
  it("should generate basic SQL without sqlTableContext", () => {
39
70
  const result = sqlCode({ table: mockTable, columnName: mockColumn.name });
@@ -484,7 +515,7 @@ describe("tableUniqueId", () => {
484
515
 
485
516
  it("uses database + schema for flat engines", () => {
486
517
  expect(tableUniqueId(ctx({ database: "db", schema: "public" }), "t")).toBe(
487
- "db.public.t",
518
+ '["db","public","t"]',
488
519
  );
489
520
  });
490
521
 
@@ -495,7 +526,7 @@ describe("tableUniqueId", () => {
495
526
  ctx({ database: "top", schema: "nested", schemaPath: ["nested"] }),
496
527
  "t",
497
528
  ),
498
- ).toBe("top.nested.t");
529
+ ).toBe('["top","nested","t"]');
499
530
  });
500
531
 
501
532
  it("includes the full schema path for deeply nested namespaces", () => {
@@ -508,7 +539,7 @@ describe("tableUniqueId", () => {
508
539
  }),
509
540
  "t",
510
541
  ),
511
- ).toBe("top.nested.deep.t");
542
+ ).toBe('["top","nested","deep","t"]');
512
543
  });
513
544
 
514
545
  it("falls back to the flat schema when schemaPath is empty", () => {
@@ -517,16 +548,36 @@ describe("tableUniqueId", () => {
517
548
  ctx({ database: "db", schema: "public", schemaPath: [] }),
518
549
  "t",
519
550
  ),
520
- ).toBe("db.public.t");
551
+ ).toBe('["db","public","t"]');
521
552
  });
522
553
 
523
554
  it("does not emit a double dot for schemaless tables", () => {
524
555
  expect(
525
556
  tableUniqueId(ctx({ database: "db", schema: "", schemaPath: [] }), "t"),
526
- ).toBe("db.t");
557
+ ).toBe('["db","t"]');
527
558
  expect(tableUniqueId(ctx({ database: "db", schema: "" }), "t")).toBe(
528
- "db.t",
559
+ '["db","t"]',
560
+ );
561
+ });
562
+
563
+ it("keeps literal-dot and nested schemas distinct", () => {
564
+ const nested = tableUniqueId(
565
+ ctx({
566
+ database: "memory",
567
+ schema: "reports",
568
+ schemaPath: ["finance", "reports"],
569
+ }),
570
+ "sales",
571
+ );
572
+ const literal = tableUniqueId(
573
+ ctx({
574
+ database: "memory",
575
+ schema: "finance.reports",
576
+ schemaPath: ["finance.reports"],
577
+ }),
578
+ "sales",
529
579
  );
580
+ expect(nested).not.toBe(literal);
530
581
  });
531
582
  });
532
583
 
@@ -29,7 +29,7 @@ import { Button } from "../ui/button";
29
29
  import { Tooltip } from "../ui/tooltip";
30
30
  import { ColumnPreviewContainer } from "./components";
31
31
  import { InstallPackageButton } from "./install-package-button";
32
- import { convertStatsName, sqlCode, tableUniqueId } from "./utils";
32
+ import { convertStatsName, fullyQualifiedTableName, sqlCode } from "./utils";
33
33
 
34
34
  export const DatasetColumnPreview: React.FC<{
35
35
  table: DataTable;
@@ -48,7 +48,10 @@ export const DatasetColumnPreview: React.FC<{
48
48
  tableName: table.name,
49
49
  columnName: column.name,
50
50
  sourceType: table.source_type,
51
- fullyQualifiedTableName: tableUniqueId(sqlTableContext, table.name),
51
+ fullyQualifiedTableName: fullyQualifiedTableName(
52
+ sqlTableContext,
53
+ table.name,
54
+ ),
52
55
  });
53
56
  };
54
57
 
@@ -78,6 +78,7 @@ import {
78
78
  areChildSchemasResolved,
79
79
  areSchemasResolved,
80
80
  areTablesResolved,
81
+ fullyQualifiedTableName,
81
82
  isSchemaless,
82
83
  schemaSubtreeMatchesSearch,
83
84
  shouldExpandDatabaseForSearch,
@@ -637,7 +638,7 @@ const SchemaNode: React.FC<SchemaNodeProps> = (props) => {
637
638
  const [prevSearchValue, setPrevSearchValue] = React.useState(searchValue);
638
639
  const [prevExpandForSearch, setPrevExpandForSearch] =
639
640
  React.useState(expandForSearch);
640
- const uniqueValue = `${databaseName}:${schemaPath.join(".")}`;
641
+ const uniqueValue = JSON.stringify(["schema", databaseName, ...schemaPath]);
641
642
  const childSchemas = schema.child_schemas ?? [];
642
643
 
643
644
  // Re-sync when the query changes or when newly resolved data turns this
@@ -1095,9 +1096,7 @@ const DatasetColumnItem: React.FC<{
1095
1096
  column={column}
1096
1097
  onAddColumnChart={handleAddColumn}
1097
1098
  preview={columnsPreviews.get(
1098
- sqlTableContext
1099
- ? `${sqlTableContext.database}.${sqlTableContext.schema}.${table.name}:${column.name}`
1100
- : `${table.name}:${column.name}`,
1099
+ `${fullyQualifiedTableName(sqlTableContext, table.name)}:${column.name}`,
1101
1100
  )}
1102
1101
  sqlTableContext={sqlTableContext}
1103
1102
  />
@@ -13,26 +13,29 @@ import type {
13
13
  import { logNever } from "@/utils/assertNever";
14
14
  import type { ColumnHeaderStatsKey } from "../data-table/types";
15
15
 
16
- /**
17
- * Stable id for a table node in the datasources tree.
18
- *
19
- * schemaPath already includes the leaf schema for nested namespaces, so use it
20
- * when present and fall back to the flat schema name otherwise (avoids
21
- * duplicating the leaf, e.g. `top.nested.nested.table`).
22
- */
16
+ function tablePath(context: SQLTableContext, tableName: string): string[] {
17
+ const segments = context.schemaPath?.length
18
+ ? context.schemaPath
19
+ : [context.schema];
20
+ return [context.database, ...segments.filter(Boolean), tableName];
21
+ }
22
+
23
23
  export function tableUniqueId(
24
- sqlTableContext: SQLTableContext | undefined,
24
+ context: SQLTableContext | undefined,
25
25
  tableName: string,
26
26
  ): string {
27
- if (!sqlTableContext) {
28
- return tableName;
29
- }
30
- const segments = (
31
- sqlTableContext.schemaPath?.length
32
- ? sqlTableContext.schemaPath
33
- : [sqlTableContext.schema]
34
- ).filter(Boolean);
35
- return [sqlTableContext.database, ...segments, tableName].join(".");
27
+ return context ? JSON.stringify(tablePath(context, tableName)) : tableName;
28
+ }
29
+
30
+ export function fullyQualifiedTableName(
31
+ context: SQLTableContext | undefined,
32
+ tableName: string,
33
+ ): string {
34
+ return context
35
+ ? getFormatter(context.dialect).formatTablePath(
36
+ tablePath(context, tableName),
37
+ )
38
+ : tableName;
36
39
  }
37
40
 
38
41
  // Some databases have no schemas, so we don't show it (eg. Clickhouse)
@@ -114,6 +117,10 @@ const defaultFormatter: SqlCodeFormatter = {
114
117
  `SELECT ${columnName} FROM ${tableName} LIMIT 100`,
115
118
  };
116
119
 
120
+ function quoteIdentifier(identifier: string): string {
121
+ return `"${identifier.replaceAll('"', '""')}"`;
122
+ }
123
+
117
124
  function getFormatter(dialect: string): SqlCodeFormatter {
118
125
  dialect = dialect.toLowerCase();
119
126
  if (!isKnownDialect(dialect)) {
@@ -146,9 +153,9 @@ function getFormatter(dialect: string): SqlCodeFormatter {
146
153
  // Quote column and table names to avoid raising errors on weird characters
147
154
  return {
148
155
  formatTablePath: (tablePath: string[]) =>
149
- tablePath.map((part) => `"${part}"`).join("."),
156
+ tablePath.map(quoteIdentifier).join("."),
150
157
  formatSelectClause: (columnName: string, tableName: string) =>
151
- `SELECT ${columnName === "*" ? "*" : `"${columnName}"`} FROM ${tableName} LIMIT 100`,
158
+ `SELECT ${columnName === "*" ? "*" : quoteIdentifier(columnName)} FROM ${tableName} LIMIT 100`,
152
159
  };
153
160
  case "db2":
154
161
  case "db2i":
@@ -194,6 +201,7 @@ export function sqlCode({
194
201
  engine,
195
202
  schema,
196
203
  defaultSchema,
204
+ schemaPath,
197
205
  defaultDatabase,
198
206
  database,
199
207
  dialect,
@@ -207,12 +215,13 @@ export function sqlCode({
207
215
  }
208
216
  } else {
209
217
  // Include schema if it's not the default schema
210
- if (schema !== defaultSchema) {
211
- tablePath.unshift(schema);
218
+ const path = schemaPath?.length ? schemaPath : [schema];
219
+ if (path.length > 1 || path[0] !== defaultSchema) {
220
+ tablePath.unshift(...path);
212
221
  }
213
222
 
214
- // Include database if it's not the default database
215
- if (database !== defaultDatabase) {
223
+ // Nested schema names can also be attached catalog names.
224
+ if (path.length > 1 || database !== defaultDatabase) {
216
225
  tablePath.unshift(database);
217
226
  }
218
227
  }
@@ -0,0 +1,165 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { render } from "@testing-library/react";
4
+ import * as React from "react";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { RangeSlider } from "../range-slider";
7
+
8
+ vi.mock("react-aria", () => ({
9
+ useLocale: () => ({ locale: "en-US" }),
10
+ }));
11
+
12
+ vi.mock("../tooltip", () => {
13
+ const Wrapper = ({ children }: { children: React.ReactNode }) => (
14
+ <div>{children}</div>
15
+ );
16
+ const Content = ({ children }: { children: React.ReactNode }) => (
17
+ <div>{children}</div>
18
+ );
19
+
20
+ return {
21
+ TooltipContent: Content,
22
+ TooltipPortal: Wrapper,
23
+ TooltipProvider: Wrapper,
24
+ TooltipRoot: Wrapper,
25
+ TooltipTrigger: Wrapper,
26
+ };
27
+ });
28
+
29
+ vi.mock("radix-ui", () => {
30
+ const Primitive = React.forwardRef<
31
+ HTMLDivElement,
32
+ React.HTMLAttributes<HTMLDivElement>
33
+ >(({ children, ...props }, ref) => (
34
+ <div ref={ref} {...props}>
35
+ {children}
36
+ </div>
37
+ ));
38
+ Primitive.displayName = "Primitive";
39
+
40
+ return {
41
+ Slider: {
42
+ Root: Primitive,
43
+ Track: Primitive,
44
+ Range: Primitive,
45
+ Thumb: Primitive,
46
+ },
47
+ };
48
+ });
49
+
50
+ describe("RangeSlider", () => {
51
+ it("formats both tooltip values without floating-point artifacts", () => {
52
+ const { getAllByText, queryByText } = render(
53
+ <RangeSlider
54
+ aria-label="Range"
55
+ min={0}
56
+ max={10}
57
+ step={0.01}
58
+ value={[1.2, 1.500000000000001]}
59
+ valueMap={(value) => value}
60
+ />,
61
+ );
62
+
63
+ expect(getAllByText("1.2")).toHaveLength(1);
64
+ expect(getAllByText("1.5")).toHaveLength(1);
65
+ expect(queryByText("1.500000000000001")).toBeNull();
66
+ });
67
+
68
+ it("keeps the legacy precision when no step is supplied", () => {
69
+ const { getAllByText, queryByText } = render(
70
+ <RangeSlider
71
+ aria-label="Range"
72
+ min={0}
73
+ max={10}
74
+ value={[1.2, 1.500000000000001]}
75
+ valueMap={(value) => value}
76
+ />,
77
+ );
78
+
79
+ expect(getAllByText("1.2")).toHaveLength(1);
80
+ expect(getAllByText("1.5")).toHaveLength(1);
81
+ expect(queryByText("1.500000000000001")).toBeNull();
82
+ });
83
+
84
+ it("retains value precision when no step is supplied", () => {
85
+ const { getByText } = render(
86
+ <RangeSlider
87
+ aria-label="Range"
88
+ min={0}
89
+ max={10}
90
+ value={[1.2345, 1.500000000000001]}
91
+ valueMap={(value) => value}
92
+ />,
93
+ );
94
+
95
+ expect(getByText("1.2345")).toBeDefined();
96
+ expect(getByText("1.5")).toBeDefined();
97
+ });
98
+
99
+ it("preserves precision from finer-grained steps", () => {
100
+ const { getAllByText, queryByText } = render(
101
+ <RangeSlider
102
+ aria-label="Range"
103
+ min={0}
104
+ max={10}
105
+ step={0.001}
106
+ value={[1.234, 1.235000000000001]}
107
+ valueMap={(value) => value}
108
+ />,
109
+ );
110
+
111
+ expect(getAllByText("1.234")).toHaveLength(1);
112
+ expect(getAllByText("1.235")).toHaveLength(1);
113
+ expect(queryByText("1.23")).toBeNull();
114
+ });
115
+
116
+ it("preserves precision for small tooltip values", () => {
117
+ const { getByText } = render(
118
+ <RangeSlider
119
+ aria-label="Range"
120
+ min={0}
121
+ max={0.01}
122
+ step={0.00001}
123
+ value={[0.00123, 0.0012400000000000002]}
124
+ valueMap={(value) => value}
125
+ />,
126
+ );
127
+
128
+ expect(getByText("1.23e-3")).toBeDefined();
129
+ expect(getByText("1.24e-3")).toBeDefined();
130
+ });
131
+
132
+ it("infers precision from custom mapped steps", () => {
133
+ const steps = [1, 1.234, 2];
134
+ const { getAllByText } = render(
135
+ <RangeSlider
136
+ aria-label="Range"
137
+ min={0}
138
+ max={steps.length - 1}
139
+ step={1}
140
+ steps={steps}
141
+ value={[0, 1]}
142
+ valueMap={(value) => steps[value]}
143
+ />,
144
+ );
145
+
146
+ expect(getAllByText("1")).toHaveLength(1);
147
+ expect(getAllByText("1.234")).toHaveLength(1);
148
+ });
149
+
150
+ it("preserves decimals from the slider origin", () => {
151
+ const { getAllByText } = render(
152
+ <RangeSlider
153
+ aria-label="Range"
154
+ min={0.5}
155
+ max={10.5}
156
+ step={1}
157
+ value={[0.5, 1.5]}
158
+ valueMap={(value) => value}
159
+ />,
160
+ );
161
+
162
+ expect(getAllByText("0.5")).toHaveLength(1);
163
+ expect(getAllByText("1.5")).toHaveLength(1);
164
+ });
165
+ });
@@ -4,7 +4,10 @@ import { Slider as SliderPrimitive } from "radix-ui";
4
4
  import * as React from "react";
5
5
  import { useLocale } from "react-aria";
6
6
  import { cn } from "@/utils/cn";
7
- import { prettyScientificNumber } from "@/utils/numbers";
7
+ import {
8
+ fractionDigitsForSlider,
9
+ prettyScientificNumber,
10
+ } from "@/utils/numbers";
8
11
  import { useBoolean } from "../../hooks/useBoolean";
9
12
  import {
10
13
  TooltipContent,
@@ -20,9 +23,15 @@ const RangeSlider = React.forwardRef<
20
23
  valueMap: (sliderValue: number) => number;
21
24
  steps?: number[];
22
25
  }
23
- >(({ className, valueMap, ...props }, ref) => {
26
+ >(({ className, steps, valueMap, ...props }, ref) => {
24
27
  const [open, openActions] = useBoolean(false);
25
28
  const { locale } = useLocale();
29
+ const fractionDigits = fractionDigitsForSlider(
30
+ props.step,
31
+ steps,
32
+ props.min,
33
+ props.value?.map(valueMap),
34
+ );
26
35
 
27
36
  const isDraggingRange = React.useRef(false);
28
37
  const dragStartX = React.useRef(0);
@@ -94,11 +103,9 @@ const RangeSlider = React.forwardRef<
94
103
  const [origLeft, origRight] = dragStartValue.current;
95
104
  const rangeWidth = origRight - origLeft;
96
105
 
97
- const steps = props.steps;
98
- const step: number =
99
- steps && steps.length > 1
100
- ? Math.min(...steps.slice(1).map((s, i) => s - steps[i]))
101
- : (props.step ?? 1);
106
+ // `steps` contains mapped display values, while pointer movement happens
107
+ // in the slider's index/value space. Use the Radix step for snapping.
108
+ const step = props.step ?? 1;
102
109
  const snappedDelta = Math.round(delta / step) * step;
103
110
 
104
111
  const clampedDelta = Math.max(
@@ -177,7 +184,10 @@ const RangeSlider = React.forwardRef<
177
184
  <TooltipPortal>
178
185
  {props.value != null && props.value.length === 2 && (
179
186
  <TooltipContent key={props.value[0]}>
180
- {prettyScientificNumber(valueMap(props.value[0]), { locale })}
187
+ {prettyScientificNumber(valueMap(props.value[0]), {
188
+ maximumFractionDigits: fractionDigits,
189
+ locale,
190
+ })}
181
191
  </TooltipContent>
182
192
  )}
183
193
  </TooltipPortal>
@@ -198,7 +208,10 @@ const RangeSlider = React.forwardRef<
198
208
  <TooltipPortal>
199
209
  {props.value != null && props.value.length === 2 && (
200
210
  <TooltipContent key={props.value[1]}>
201
- {prettyScientificNumber(valueMap(props.value[1]), { locale })}
211
+ {prettyScientificNumber(valueMap(props.value[1]), {
212
+ maximumFractionDigits: fractionDigits,
213
+ locale,
214
+ })}
202
215
  </TooltipContent>
203
216
  )}
204
217
  </TooltipPortal>
@@ -5,7 +5,10 @@ import { type JSX, useEffect, useId, useState } from "react";
5
5
  import { useLocale } from "react-aria";
6
6
  import { z } from "zod";
7
7
  import { cn } from "@/utils/cn";
8
- import { prettyScientificNumber } from "@/utils/numbers";
8
+ import {
9
+ fractionDigitsForSlider,
10
+ prettyScientificNumber,
11
+ } from "@/utils/numbers";
9
12
  import { RangeSlider } from "../../components/ui/range-slider";
10
13
  import type { IPlugin, IPluginProps, Setter } from "../types";
11
14
  import { Labeled } from "./common/labeled";
@@ -74,6 +77,7 @@ const RangeSliderComponent = ({
74
77
  start,
75
78
  stop,
76
79
  step,
80
+ steps,
77
81
  debounce,
78
82
  orientation,
79
83
  showValue,
@@ -86,6 +90,12 @@ const RangeSliderComponent = ({
86
90
 
87
91
  // Hold internal value
88
92
  const [internalValue, setInternalValue] = useState(value);
93
+ const fractionDigits = fractionDigitsForSlider(
94
+ step,
95
+ steps,
96
+ start,
97
+ internalValue.map(valueMap),
98
+ );
89
99
 
90
100
  // Update internal value on prop change
91
101
  useEffect(() => {
@@ -121,6 +131,7 @@ const RangeSliderComponent = ({
121
131
  min={start}
122
132
  max={stop}
123
133
  step={step}
134
+ steps={steps ?? undefined}
124
135
  orientation={orientation}
125
136
  disabled={disabled}
126
137
  // Triggered on all value changes
@@ -154,8 +165,12 @@ const RangeSliderComponent = ({
154
165
  {showValue && (
155
166
  <div className="text-xs text-muted-foreground min-w-[16px]">
156
167
  {`${prettyScientificNumber(valueMap(internalValue[0]), {
168
+ maximumFractionDigits: fractionDigits,
169
+ locale,
170
+ })}, ${prettyScientificNumber(valueMap(internalValue[1]), {
171
+ maximumFractionDigits: fractionDigits,
157
172
  locale,
158
- })}, ${prettyScientificNumber(valueMap(internalValue[1]), { locale })}`}
173
+ })}`}
159
174
  </div>
160
175
  )}
161
176
  </div>
@@ -0,0 +1,109 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { render } from "@testing-library/react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import type { z } from "zod";
6
+ import type { IPluginProps } from "../../types";
7
+ import { RangeSliderPlugin } from "../RangeSliderPlugin";
8
+
9
+ vi.mock("@/components/ui/range-slider", () => ({
10
+ RangeSlider: () => <div data-testid="range-slider" />,
11
+ }));
12
+
13
+ vi.mock("react-aria", () => ({
14
+ useLocale: () => ({ locale: "en-US" }),
15
+ }));
16
+
17
+ describe("RangeSliderPlugin", () => {
18
+ type PluginProps = IPluginProps<
19
+ number[],
20
+ z.infer<typeof RangeSliderPlugin.prototype.validator>
21
+ >;
22
+
23
+ const createProps = (
24
+ value: number[],
25
+ data: Partial<PluginProps["data"]> = {},
26
+ ): PluginProps => ({
27
+ host: document.createElement("div"),
28
+ value,
29
+ setValue: vi.fn(),
30
+ data: {
31
+ initialValue: value,
32
+ label: null,
33
+ start: 0,
34
+ stop: 10,
35
+ step: 0.01,
36
+ steps: null,
37
+ debounce: false,
38
+ orientation: "horizontal",
39
+ showValue: true,
40
+ fullWidth: true,
41
+ disabled: false,
42
+ ...data,
43
+ },
44
+ functions: {},
45
+ });
46
+
47
+ it("rounds floating-point artifacts in the displayed range", () => {
48
+ const plugin = new RangeSliderPlugin();
49
+ const props = createProps([1.2, 1.500000000000001]);
50
+
51
+ const { getByText } = render(plugin.render(props));
52
+
53
+ expect(getByText("1.2, 1.5")).toBeDefined();
54
+ });
55
+
56
+ it("preserves precision from finer-grained steps", () => {
57
+ const plugin = new RangeSliderPlugin();
58
+ const props = createProps([1.234, 1.235000000000001], { step: 0.001 });
59
+
60
+ const { getByText } = render(plugin.render(props));
61
+
62
+ expect(getByText("1.234, 1.235")).toBeDefined();
63
+ });
64
+
65
+ it("retains value precision when no step is supplied", () => {
66
+ const plugin = new RangeSliderPlugin();
67
+ const props = createProps([1.2345, 1.500000000000001], {
68
+ step: undefined,
69
+ });
70
+
71
+ const { getByText } = render(plugin.render(props));
72
+
73
+ expect(getByText("1.2345, 1.5")).toBeDefined();
74
+ });
75
+
76
+ it("preserves precision for small displayed values", () => {
77
+ const plugin = new RangeSliderPlugin();
78
+ const props = createProps([0.00123, 0.0012400000000000002], {
79
+ stop: 0.01,
80
+ step: 0.00001,
81
+ });
82
+
83
+ const { getByText } = render(plugin.render(props));
84
+
85
+ expect(getByText("1.23e-3, 1.24e-3")).toBeDefined();
86
+ });
87
+
88
+ it("infers precision from custom steps", () => {
89
+ const plugin = new RangeSliderPlugin();
90
+ const props = createProps([0, 1], {
91
+ stop: 2,
92
+ step: undefined,
93
+ steps: [1, 1.234, 2],
94
+ });
95
+
96
+ const { getByText } = render(plugin.render(props));
97
+
98
+ expect(getByText("1, 1.234")).toBeDefined();
99
+ });
100
+
101
+ it("preserves decimals from the slider origin", () => {
102
+ const plugin = new RangeSliderPlugin();
103
+ const props = createProps([0.5, 1.5], { start: 0.5, stop: 10, step: 1 });
104
+
105
+ const { getByText } = render(plugin.render(props));
106
+
107
+ expect(getByText("0.5, 1.5")).toBeDefined();
108
+ });
109
+ });