@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.
- package/dist/assets/{CellStatus-DC6FFQOg.js → CellStatus-B9hoYYSq.js} +1 -1
- package/dist/assets/{ErrorBoundary-Ce-Ujlwj.js → ErrorBoundary-DIoi62xQ.js} +2 -2
- package/dist/assets/{JsonOutput-CfEBzaqW.js → JsonOutput-f0zvWw7g.js} +5 -5
- package/dist/assets/{MarimoErrorOutput-Bh6o9zle.js → MarimoErrorOutput-DFCWAXPj.js} +4 -4
- package/dist/assets/{RenderHTML-COGZsXQV.js → RenderHTML-CJOIsfDF.js} +2 -2
- package/dist/assets/{RunButton-DQ51plUv.js → RunButton-BhbOSnBI.js} +1 -1
- package/dist/assets/{add-cell-with-ai-D7gtGUsg.js → add-cell-with-ai-xzt8ZIZY.js} +26 -26
- package/dist/assets/{add-connection-dialog-CpMCc97S.js → add-connection-dialog-CLcZdzEt.js} +51 -51
- package/dist/assets/{agent-panel-BSFYqJ9B.js → agent-panel-x_kvcauj.js} +3 -3
- package/dist/assets/ai-model-dropdown-DcOMJlL4.js +13 -0
- package/dist/assets/{app-config-button-Cfcg_5Bz.js → app-config-button-gr-9epH3.js} +1 -1
- package/dist/assets/{blob-CszMu60N.js → blob-B25C4ORK.js} +1 -1
- package/dist/assets/{cache-panel-DuBsA6oO.js → cache-panel-BK4-1NJC.js} +1 -1
- package/dist/assets/cell-editor-CO_2xdmY.js +42 -0
- package/dist/assets/{cell-link-Cj0gGaMV.js → cell-link-DBP_BcJY.js} +1 -1
- package/dist/assets/{cells-SvbLY10y.js → cells-B9RIIuYa.js} +42 -42
- package/dist/assets/{chat-display-D43_g3TX.js → chat-display-CvnF2GaH.js} +1 -1
- package/dist/assets/{chat-panel-B_HAJMwx.js → chat-panel-CJFHiZ2R.js} +2 -2
- package/dist/assets/{chat-ui-BEcYCphu.js → chat-ui-DbPlwime.js} +1 -1
- package/dist/assets/{column-preview-CrzaJKk8.js → column-preview-j0kenrsq.js} +1 -1
- package/dist/assets/{command-palette-Bm9yIVcD.js → command-palette-CQ1DPGNl.js} +1 -1
- package/dist/assets/{common-BJyUxCzv.js → common-C9Gvbmz5.js} +1 -1
- package/dist/assets/{components-D1f_Cz4i.js → components-CMj235gS.js} +1 -1
- package/dist/assets/{datasource-C8KSVfSD.js → datasource-BsklC6tp.js} +2 -2
- package/dist/assets/{dependency-graph-panel-BhmyJbUl.js → dependency-graph-panel-BNqY5al9.js} +3 -3
- package/dist/assets/{documentation-panel-DcVG0Q0d.js → documentation-panel-UrRQcsOB.js} +1 -1
- package/dist/assets/{download-Bx52VRE4.js → download-BRqw2JzE.js} +1 -1
- package/dist/assets/{edit-page-DUrwOHR-.js → edit-page-Dqkmdmfj.js} +6 -6
- package/dist/assets/{error-entries-11OuHzUF.js → error-entries-CuIzi5P7.js} +1 -1
- package/dist/assets/{error-panel-XJy5S25F.js → error-panel-CFCs0M8e.js} +1 -1
- package/dist/assets/{feedback-button-B7jh_B4w.js → feedback-button-DZVOiUEf.js} +2 -2
- package/dist/assets/feedback-button-J9hRaX_K.js +1 -0
- package/dist/assets/{file-explorer-panel-CnVxbF4A.js → file-explorer-panel-Cqey5tMY.js} +1 -1
- package/dist/assets/{file-name-input-BJuqjQqH.js → file-name-input-khW7mtt2.js} +1 -1
- package/dist/assets/{floating-outline-8yyZ-MOJ.js → floating-outline-CSwtWNm9.js} +1 -1
- package/dist/assets/{formatting-DW2u7HXI.js → formatting-CMgDQsM2.js} +1 -1
- package/dist/assets/{gallery-page-DJvGHePf.js → gallery-page-CcG0Msi3.js} +1 -1
- package/dist/assets/{glide-data-editor-Cyc8DHZx.js → glide-data-editor-B46-8C6x.js} +1 -1
- package/dist/assets/{home-page-tRZJmSU6.js → home-page-C5qcnnak.js} +1 -1
- package/dist/assets/{hooks-CxGwybGw.js → hooks-CiNPyjez.js} +1 -1
- package/dist/assets/index-D1hW_v1v.js +30 -0
- package/dist/assets/{input-DLA5Dhn3.js → input-BQBy_25D.js} +1 -1
- package/dist/assets/{layout-DvXJxBiL.js → layout-DSsztKhq.js} +5 -5
- package/dist/assets/{logs-panel-KtsY7TrR.js → logs-panel-DZ8zJWNT.js} +1 -1
- package/dist/assets/{markdown-renderer-DUgV7_fm.js → markdown-renderer-DszNRkaL.js} +1 -1
- package/dist/assets/{name-cell-input-C3-Cj-Ek.js → name-cell-input-BIpvW2kg.js} +1 -1
- package/dist/assets/numbers-BB-lSqeY.js +1 -0
- package/dist/assets/{outline-panel-BzyY4JA6.js → outline-panel-CHwFhhFo.js} +1 -1
- package/dist/assets/{packages-panel-DxosbWko.js → packages-panel-qT6TuHGP.js} +1 -1
- package/dist/assets/{panel-accordion-state-BRbTib2B.js → panel-accordion-state-DROYE5RR.js} +1 -1
- package/dist/assets/panels-3HImHlAU.js +1 -0
- package/dist/assets/{process-output-CSwx7rvo.js → process-output-CPh21qjo.js} +1 -1
- package/dist/assets/{readonly-code-display-B9y9423Y.js → readonly-code-display-DKoAsBb-.js} +1 -1
- package/dist/assets/{readonly-python-code-Dgw6trlk.js → readonly-python-code-QqiDzhYb.js} +1 -1
- package/dist/assets/{reveal-component-B9A4wkG-.js → reveal-component-Dd29QoYx.js} +2 -2
- package/dist/assets/{run-page-C1MQ5b_A.js → run-page-J_1gGmPH.js} +1 -1
- package/dist/assets/{runtime-details-state-D_hOQL9T.js → runtime-details-state-BlarRoM1.js} +1 -1
- package/dist/assets/{scratchpad-panel-CBCsPmMW.js → scratchpad-panel-BPw7VxO6.js} +1 -1
- package/dist/assets/{secrets-panel-ConQpchl.js → secrets-panel-j7vcP2r2.js} +1 -1
- package/dist/assets/session-panel-BnszHmkW.js +1 -0
- package/dist/assets/{snippets-panel-C0VDE_4i.js → snippets-panel-a-A15D0n.js} +1 -1
- package/dist/assets/{state-DvU2AiFx.js → state-2U7LSUNv.js} +4 -4
- package/dist/assets/{state-DrraMmqz.js → state-B_SxQGvw.js} +1 -1
- package/dist/assets/{switch-D2gdC_ep.js → switch-DLzvDOLo.js} +1 -1
- package/dist/assets/{tracing-C5d51Nmi.js → tracing-CpbvZwxP.js} +1 -1
- package/dist/assets/{tracing-panel-B89R8dWE.js → tracing-panel-Dxtm4-GJ.js} +2 -2
- package/dist/assets/{tree-actions-Cph8FVzh.js → tree-actions-CIBdpNC-.js} +1 -1
- package/dist/assets/{useAddCell-VPkB72pn.js → useAddCell-B6my967m.js} +1 -1
- package/dist/assets/{useCellActionButton--zI3GFnt.js → useCellActionButton-BwBEO8zi.js} +1 -1
- package/dist/assets/{useDeleteCell-CjewKeSr.js → useDeleteCell-DYRsSrAa.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-Bt_ka-wl.js → useDependencyPanelTab-lNbBYJZs.js} +1 -1
- package/dist/assets/{useInstallPackage-BfNKN33w.js → useInstallPackage-Q9agSKO7.js} +1 -1
- package/dist/assets/useNotebookActions-CmG2irDr.js +1 -0
- package/dist/assets/{useRunCells-BupCZpzk.js → useRunCells-Bl702L84.js} +1 -1
- package/dist/assets/{useSplitCell-CaLIR0TS.js → useSplitCell-DQRIXpFK.js} +1 -1
- package/dist/assets/{write-secret-modal-CBi2vNK_.js → write-secret-modal-BujVAO67.js} +1 -1
- package/dist/index.html +25 -25
- package/package.json +1 -1
- package/src/components/datasources/__tests__/column-preview.test.tsx +35 -3
- package/src/components/datasources/__tests__/datasources-interactions.test.tsx +181 -0
- package/src/components/datasources/__tests__/utils.test.ts +57 -6
- package/src/components/datasources/column-preview.tsx +5 -2
- package/src/components/datasources/datasources.tsx +3 -4
- package/src/components/datasources/utils.ts +32 -23
- package/src/components/ui/__tests__/range-slider.test.tsx +165 -0
- package/src/components/ui/range-slider.tsx +22 -9
- package/src/plugins/impl/RangeSliderPlugin.tsx +17 -2
- package/src/plugins/impl/__tests__/RangeSliderPlugin.test.tsx +109 -0
- package/src/utils/__tests__/numbers.test.ts +57 -0
- package/src/utils/numbers.ts +59 -9
- package/dist/assets/ai-model-dropdown-D575Vid8.js +0 -13
- package/dist/assets/cell-editor-BLpOlLnM.js +0 -42
- package/dist/assets/feedback-button-C-eKtxXE.js +0 -1
- package/dist/assets/index-CoxbqhGH.js +0 -30
- package/dist/assets/numbers-DJK15_4S.js +0 -1
- package/dist/assets/panels-Da3071Q4.js +0 -1
- package/dist/assets/session-panel-CiHCKPlx.js +0 -1
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
557
|
+
).toBe('["db","t"]');
|
|
527
558
|
expect(tableUniqueId(ctx({ database: "db", schema: "" }), "t")).toBe(
|
|
528
|
-
"db
|
|
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,
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
+
context: SQLTableContext | undefined,
|
|
25
25
|
tableName: string,
|
|
26
26
|
): string {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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(
|
|
156
|
+
tablePath.map(quoteIdentifier).join("."),
|
|
150
157
|
formatSelectClause: (columnName: string, tableName: string) =>
|
|
151
|
-
`SELECT ${columnName === "*" ? "*" :
|
|
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
|
-
|
|
211
|
-
|
|
218
|
+
const path = schemaPath?.length ? schemaPath : [schema];
|
|
219
|
+
if (path.length > 1 || path[0] !== defaultSchema) {
|
|
220
|
+
tablePath.unshift(...path);
|
|
212
221
|
}
|
|
213
222
|
|
|
214
|
-
//
|
|
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 {
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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]), {
|
|
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]), {
|
|
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 {
|
|
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
|
-
})}
|
|
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
|
+
});
|