@marimo-team/islands 0.25.3-dev322 → 0.25.3-dev348

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 (125) hide show
  1. package/dist/{ConnectedDataExplorerComponent-DkXBUBf3.js → ConnectedDataExplorerComponent-xliyyXNO.js} +16 -16
  2. package/dist/{ErrorBoundary-CYa6lyKz.js → ErrorBoundary-CIimOpqs.js} +7 -7
  3. package/dist/{abnfDiagram-VCTEODGH-BPS-pTU2.js → abnfDiagram-VCTEODGH-Dl9Nrtbf.js} +2 -2
  4. package/dist/{any-language-editor-DIS7IdV1.js → any-language-editor-CBHhdCC0.js} +2 -2
  5. package/dist/{architectureDiagram-5GKGNRK7-DB2MHOb7.js → architectureDiagram-5GKGNRK7-DTg032fQ.js} +1 -1
  6. package/dist/{assertNever-HbUt8DyH.js → assertNever-ie77PjMn.js} +13 -13
  7. package/dist/{chat-ui-85k50jaj.js → chat-ui-BV0qyfru.js} +51 -51
  8. package/dist/{check-CApDh1-n.js → check-DTIv5ZZ3.js} +1 -1
  9. package/dist/{chunk-IMKFNOWR-CjFCedjx.js → chunk-IMKFNOWR-CsFB-2LW.js} +1 -1
  10. package/dist/{chunk-SHT3W25Y-Dptltg0_.js → chunk-SHT3W25Y-ByuJHeBL.js} +1 -1
  11. package/dist/{chunk-SVP7TREG-2jBN760m.js → chunk-SVP7TREG-9oBZhbrr.js} +1 -1
  12. package/dist/{chunk-TICWLB2K-CS8BE-b9.js → chunk-TICWLB2K-Bx4m0cJ1.js} +1 -1
  13. package/dist/{classDiagram-ZZMXUADV-HtQWp-Cm.js → classDiagram-ZZMXUADV-Cms37-C9.js} +2 -2
  14. package/dist/{classDiagram-v2-VYDZK3BY-DA9LETN-.js → classDiagram-v2-VYDZK3BY-qAom4Bi7.js} +2 -2
  15. package/dist/{code-visibility-CGcIJMGj.js → code-visibility-_-8-dvb4.js} +20 -20
  16. package/dist/{common-B-Ng3J3K.js → common-CNXuhphD.js} +620 -620
  17. package/dist/{config-B171m9zn.js → config-CvBnR945.js} +3 -3
  18. package/dist/{copy-icon-k2_Fx5Qv.js → copy-icon-DmtNidSa.js} +2 -2
  19. package/dist/{createReducer-BPPI11pP.js → createReducer-BxBCTvKn.js} +5 -5
  20. package/dist/{cynefinDiagram-5FMLGOSQ-eyocrg_E.js → cynefinDiagram-5FMLGOSQ-BjJZwMEz.js} +1 -1
  21. package/dist/{dagre-GXQ25YYZ-Dtm96Nvx.js → dagre-GXQ25YYZ-Bohu7_09.js} +1 -1
  22. package/dist/{data-grid-overlay-editor-CUD6F0Vj.js → data-grid-overlay-editor--4Gk_rwA.js} +1 -1
  23. package/dist/{diagram-S7CK7UJ4-C09TrdNO.js → diagram-S7CK7UJ4-Chl0n835.js} +1 -1
  24. package/dist/{diagram-UQ7AKVKN-vHUsSML6.js → diagram-UQ7AKVKN-DHx8nzy7.js} +1 -1
  25. package/dist/{diagram-VX7I27RA-Ch4mRgz8.js → diagram-VX7I27RA-CKdpv1U4.js} +1 -1
  26. package/dist/{diagram-Z3DM3KII-DyFQNdAL.js → diagram-Z3DM3KII-sY-sI--2.js} +1 -1
  27. package/dist/{dialog-C2Muh059.js → dialog-Cqu5Hp5U.js} +21 -21
  28. package/dist/{dropdown-menu-BGbNnuFq.js → dropdown-menu-DXEYk99S.js} +707 -707
  29. package/dist/{ebnfDiagram-PWID7BFC-N3SvlrCs.js → ebnfDiagram-PWID7BFC-CD0P2xeH.js} +2 -2
  30. package/dist/{erDiagram-RLTQ6QDP-RR2q2xtd.js → erDiagram-RLTQ6QDP-BaKQuYxC.js} +1 -1
  31. package/dist/{error-banner-3_4Lc93Y.js → error-banner-DUB_qUeL.js} +58 -58
  32. package/dist/{error-entries-BhNAZWU1.js → error-entries-CTb_cxXY.js} +310 -310
  33. package/dist/{feedback-button-DK6zUzAk.js → feedback-button-BaA4r2dl.js} +7 -7
  34. package/dist/{flowDiagram-HODETNUW-nxd7WhlW.js → flowDiagram-HODETNUW-BcCpHh7-.js} +2 -2
  35. package/dist/{formats-aUcw6MrS.js → formats-hF6fETr6.js} +1 -1
  36. package/dist/{glide-data-editor-O-94UN5R.js → glide-data-editor-CRn3KXyR.js} +3129 -3119
  37. package/dist/{glide-utils-D4UP8TGv.js → glide-utils-Bnd_k48H.js} +2 -2
  38. package/dist/{globals-BVjYePCn.js → globals-_FmD97Ui.js} +3 -3
  39. package/dist/{html-to-image-DOOtP-4-.js → html-to-image-Bbl0PeC7.js} +478 -478
  40. package/dist/{infoDiagram-27XIBGKW-CUYXQKGf.js → infoDiagram-27XIBGKW-DWltseIa.js} +1 -1
  41. package/dist/{ishikawaDiagram-5VMMS53U-v6gsCrlK.js → ishikawaDiagram-5VMMS53U-DHgepBPo.js} +1 -1
  42. package/dist/{kanban-definition-UXKFOSKX-z236-0Ok.js → kanban-definition-UXKFOSKX-B2_BIjBq.js} +1 -1
  43. package/dist/main.js +6712 -6590
  44. package/dist/{mermaid-C7lQ_2LJ.js → mermaid--2K0cmt9.js} +34 -34
  45. package/dist/{mindmap-definition-YA3MSWOX--pZF7OCY.js → mindmap-definition-YA3MSWOX-DNebycIP.js} +1 -1
  46. package/dist/{number-overlay-editor-jkzzCN4e.js → number-overlay-editor-Bcg25qrp.js} +1 -1
  47. package/dist/{pegDiagram-XKGWAZYB-Bgw0Zz_6.js → pegDiagram-XKGWAZYB-CH4U3II-.js} +2 -2
  48. package/dist/{pencil-RXFY-Ek8.js → pencil-MMnw1YQh.js} +1 -1
  49. package/dist/{pieDiagram-E7YTZNPT-B27tDYvK.js → pieDiagram-E7YTZNPT-pfcrdUCa.js} +1 -1
  50. package/dist/{process-output-CiX_Dl4L.js → process-output-Dw_sI4Xp.js} +4 -4
  51. package/dist/{railroadDiagram-O6MQD6OU-CBjPhGpj.js → railroadDiagram-O6MQD6OU-BZKpAbNG.js} +2 -2
  52. package/dist/{requirementDiagram-BXWQKSXE-DDDrLEw9.js → requirementDiagram-BXWQKSXE-DnyfIHe3.js} +1 -1
  53. package/dist/{reveal-component-CvPmNI5C.js → reveal-component-BVC491am.js} +18 -18
  54. package/dist/{select-Cv6lR39D.js → select-suaAxFuo.js} +286 -286
  55. package/dist/{sequenceDiagram-WJ2MYXX4-DQPaMygQ.js → sequenceDiagram-WJ2MYXX4-BeYuWX7N.js} +1 -1
  56. package/dist/{spec-BoCLWa-M.js → spec-BQNx2-D7.js} +4 -4
  57. package/dist/{stateDiagram-D77RDMKH-BDoDVVnq.js → stateDiagram-D77RDMKH-B8FxwAM2.js} +2 -2
  58. package/dist/{stateDiagram-v2-MP3YSRHH-KNko46uJ.js → stateDiagram-v2-MP3YSRHH-CzWLBqm1.js} +2 -2
  59. package/dist/{static-state-DgdOadIH.js → static-state-Al_hmf1n.js} +6 -6
  60. package/dist/style.css +1 -1
  61. package/dist/{swimlanes-42K2YHIH-boFjOuiI.js → swimlanes-42K2YHIH-CzKmGMts.js} +1 -1
  62. package/dist/{swimlanesDiagram-VR7AAH4N-BC4FLNwY.js → swimlanesDiagram-VR7AAH4N-DnWIUN_9.js} +2 -2
  63. package/dist/{timeline-definition-24CTP7MA-CLVPYA-v.js → timeline-definition-24CTP7MA-Czf2QN2R.js} +1 -1
  64. package/dist/{toast-CUuX7uXg.js → toast-CsJa1nGj.js} +66 -66
  65. package/dist/{tooltip-DBv8QUvS.js → tooltip-BVbJPkcl.js} +49 -49
  66. package/dist/{use-restore-focus-BxJh5Zfa.js → use-restore-focus-tPmARo1Q.js} +1035 -1022
  67. package/dist/{useDateFormatter-2k4mHRBq.js → useDateFormatter-B4ygYuUU.js} +2 -2
  68. package/dist/{useDeepCompareMemoize-Cb2GRQIS.js → useDeepCompareMemoize-Bcu0DkCS.js} +1 -1
  69. package/dist/{useLifecycle-B2fV3c8N.js → useLifecycle-MTdtnR3-.js} +1 -1
  70. package/dist/{vega-component-do3stf0K.js → vega-component-BhBbP4S7.js} +6 -6
  71. package/dist/{vennDiagram-4TSXK5OY-BLq7sNjb.js → vennDiagram-4TSXK5OY-BrHqWB1U.js} +1 -1
  72. package/dist/{wardleyDiagram-VM6X3IG4-oyJEWWtb.js → wardleyDiagram-VM6X3IG4-Cms1K_3v.js} +1 -1
  73. package/dist/{xychartDiagram-S5SC5T6Z-DMjWgQPm.js → xychartDiagram-S5SC5T6Z-_kUiODzS.js} +1 -1
  74. package/package.json +1 -1
  75. package/src/__mocks__/requests.ts +0 -2
  76. package/src/components/chat/__tests__/notebook-context.test.ts +199 -0
  77. package/src/components/chat/chat-panel.tsx +33 -15
  78. package/src/components/chat/notebook-context.ts +80 -0
  79. package/src/components/editor/cell/StopButton.tsx +3 -3
  80. package/src/components/editor/cell/cell-context-menu.tsx +18 -2
  81. package/src/components/editor/cell/code/__tests__/language-toggle.test.tsx +38 -1
  82. package/src/components/editor/cell/code/cell-editor.tsx +2 -1
  83. package/src/components/editor/cell/code/language-toggle.tsx +6 -4
  84. package/src/components/editor/chrome/wrapper/__tests__/document-sync-status.test.tsx +9 -6
  85. package/src/components/slides/slide-cell-view.tsx +2 -1
  86. package/src/components/ui/__tests__/fullscreen.test.tsx +56 -1
  87. package/src/components/ui/fullscreen.tsx +18 -2
  88. package/src/components/ui/slider.tsx +6 -2
  89. package/src/core/codemirror/copilot/getCodes.ts +1 -1
  90. package/src/core/edit-app.tsx +5 -4
  91. package/src/core/executions/__tests__/input.test.ts +1 -1
  92. package/src/core/executions/__tests__/interrupt.test.ts +200 -0
  93. package/src/core/executions/__tests__/run.test.ts +3 -3
  94. package/src/core/executions/index.ts +1 -0
  95. package/src/core/executions/interrupt.ts +49 -0
  96. package/src/core/islands/bridge.ts +0 -2
  97. package/src/core/network/requests-network.ts +0 -15
  98. package/src/core/network/requests-static.ts +0 -2
  99. package/src/core/network/requests-toasting.tsx +0 -2
  100. package/src/core/network/types.ts +0 -3
  101. package/src/core/notebook/document/__tests__/model.test.ts +4 -4
  102. package/src/core/notebook/document/__tests__/test-host.test.ts +6 -6
  103. package/src/core/notebook/document/__tests__/test-host.ts +42 -46
  104. package/src/core/notebook/document/__tests__/test-notebook.ts +5 -4
  105. package/src/core/notebook/document/sync.ts +12 -5
  106. package/src/core/notebook/replica/__tests__/fixtures.ts +8 -0
  107. package/src/core/notebook/replica/backend.ts +1 -1
  108. package/src/core/saving/__tests__/document.test.tsx +1 -1
  109. package/src/core/transports/__tests__/static.test.ts +1 -1
  110. package/src/core/transports/__tests__/wasm.test.ts +1 -1
  111. package/src/core/transports/static.ts +8 -2
  112. package/src/core/transports/wasm.ts +2 -4
  113. package/src/core/wasm/__tests__/bridge.test.ts +170 -26
  114. package/src/core/wasm/bridge.ts +64 -19
  115. package/src/core/wasm/worker/types.ts +0 -1
  116. package/src/plugins/impl/DataEditorPlugin.tsx +32 -21
  117. package/src/plugins/impl/SliderPlugin.tsx +35 -3
  118. package/src/plugins/impl/__test__/slider-labels.test.tsx +30 -0
  119. package/src/plugins/impl/__tests__/DataEditorPlugin.test.ts +103 -12
  120. package/src/plugins/impl/__tests__/SliderPlugin.test.tsx +40 -0
  121. package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +159 -4
  122. package/src/plugins/impl/data-editor/glide-data-editor.tsx +30 -10
  123. package/src/plugins/impl/data-editor/glide-portal.tsx +5 -3
  124. package/src/plugins/layout/AccordionPlugin.tsx +2 -1
  125. package/src/plugins/layout/__tests__/AccordionPlugin.test.tsx +21 -0
@@ -76,6 +76,9 @@ export class PyodideBridge implements RunRequests, EditRequests {
76
76
  /** Runs asked to stop before they ran; the buffer is raised once they do. */
77
77
  private pendingInterrupts = new Set<string>();
78
78
 
79
+ // Match the worker mutation receipt window, including requests still in flight.
80
+ private interruptReceipts = new Map<string, { expiresAt: number }>();
81
+
79
82
  public initialized = new Deferred<void>();
80
83
 
81
84
  private getSaveWorker(): SaveWorker {
@@ -251,19 +254,53 @@ export class PyodideBridge implements RunRequests, EditRequests {
251
254
  throw error;
252
255
  }
253
256
  },
254
- call: (call) => {
257
+ call: async (call) => {
258
+ if (
259
+ call.operation !== "runtime.interrupt" &&
260
+ call.operation !== "runtime.cancel"
261
+ ) {
262
+ return this.rpc.proxy.request.notebookCall(call);
263
+ }
264
+ const now = performance.now();
265
+ for (const [key, receipt] of this.interruptReceipts) {
266
+ if (receipt.expiresAt <= now) {
267
+ this.interruptReceipts.delete(key);
268
+ }
269
+ }
270
+ const key = call.headers["idempotency-key"];
271
+ if (!key || this.interruptReceipts.has(key)) {
272
+ return this.rpc.proxy.request.notebookCall(call);
273
+ }
274
+ const receipt = { expiresAt: Infinity };
275
+ this.interruptReceipts.set(key, receipt);
276
+ // The worker cannot take a call while a cell runs, so the interrupt
277
+ // is raised on the shared buffer first and the call follows.
278
+ // A queued run is ended by the worker, unless it starts before the
279
+ // call arrives. Then it is raised once the run reports running.
255
280
  if (call.operation === "runtime.interrupt") {
256
- // The worker cannot take the call while a cell runs; raise the
257
- // interrupt there first. A queued run is cancelled by the worker,
258
- // unless it starts before the call arrives: then it is raised
259
- // when the run is reported running.
281
+ this.interruptEveryRun();
282
+ } else if (call.operation === "runtime.cancel") {
260
283
  if (this.isRunning(call.params.run_id)) {
261
- void this.sendInterrupt();
284
+ this.raiseInterrupt();
262
285
  } else {
263
286
  this.pendingInterrupts.add(call.params.run_id);
264
287
  }
265
288
  }
266
- return this.rpc.proxy.request.notebookCall(call);
289
+ try {
290
+ const reply = await this.rpc.proxy.request.notebookCall(call);
291
+ if (
292
+ typeof reply === "object" &&
293
+ reply !== null &&
294
+ "status" in reply &&
295
+ reply.status !== 202
296
+ ) {
297
+ this.interruptReceipts.delete(key);
298
+ }
299
+ return reply;
300
+ } finally {
301
+ // A lost reply may still have committed; retries must not signal new work.
302
+ receipt.expiresAt = performance.now() + 300_000;
303
+ }
267
304
  },
268
305
  });
269
306
  }
@@ -283,12 +320,29 @@ export class PyodideBridge implements RunRequests, EditRequests {
283
320
  }
284
321
  if (run.status === "running") {
285
322
  this.pendingInterrupts.delete(run.id);
286
- void this.sendInterrupt();
323
+ this.raiseInterrupt();
287
324
  } else if (run.status === "completed") {
288
325
  this.pendingInterrupts.delete(run.id);
289
326
  }
290
327
  }
291
328
 
329
+ /**
330
+ * Signal work outside runs too. Python ignores the signal while idle;
331
+ * the following host call consumes it before acknowledging the interrupt.
332
+ */
333
+ private interruptEveryRun(): void {
334
+ this.raiseInterrupt();
335
+ const replica = store.get(activeNotebookReplicaAtom);
336
+ if (replica === null) {
337
+ return;
338
+ }
339
+ for (const run of store.get(replica.executionsAtom).runs.values()) {
340
+ if (run.status === "queued") {
341
+ this.pendingInterrupts.add(run.id);
342
+ }
343
+ }
344
+ }
345
+
292
346
  /** Whether the host reports `runId` as running, so a cell is executing. */
293
347
  private isRunning(runId: string): boolean {
294
348
  const replica = store.get(activeNotebookReplicaAtom);
@@ -324,14 +378,6 @@ export class PyodideBridge implements RunRequests, EditRequests {
324
378
  throwNotImplemented();
325
379
  };
326
380
 
327
- public sendStdin: EditRequests["sendStdin"] = async (request) => {
328
- await this.rpc.proxy.request.bridge({
329
- functionName: "put_input",
330
- payload: request.text,
331
- });
332
- return null;
333
- };
334
-
335
381
  public sendPdb: EditRequests["sendPdb"] = async () => {
336
382
  throwNotImplemented();
337
383
  };
@@ -351,13 +397,12 @@ export class PyodideBridge implements RunRequests, EditRequests {
351
397
  });
352
398
  return null;
353
399
  };
354
- public sendInterrupt: EditRequests["sendInterrupt"] = async () => {
400
+ private raiseInterrupt(): void {
355
401
  if (this.interruptBuffer !== undefined) {
356
402
  // 2 sends a SIGINT
357
403
  this.interruptBuffer[0] = 2;
358
404
  }
359
- return null;
360
- };
405
+ }
361
406
  public sendShutdown: EditRequests["sendShutdown"] = async () => {
362
407
  window.close();
363
408
  return null;
@@ -72,7 +72,6 @@ export interface WasmSession {
72
72
 
73
73
  export interface RawBridge {
74
74
  put_control_request(operation: ClientCommand): Promise<string>;
75
- put_input(input: string): Promise<string>;
76
75
  code_complete(request: CodeCompletionRequest): Promise<string>;
77
76
  read_code(): Promise<{ contents: string }>;
78
77
  read_snippets(): Promise<Snippets>;
@@ -2,7 +2,7 @@
2
2
 
3
3
  import glideCss from "@glideapps/glide-data-grid/dist/index.css?inline";
4
4
  import glideCellsCss from "@glideapps/glide-data-grid-cells/dist/index.css?inline";
5
- import React, { useEffect, useState } from "react";
5
+ import React, { useMemo } from "react";
6
6
  import { z } from "zod";
7
7
  import { inferFieldTypes } from "@/components/data-table/columns";
8
8
  import { LoadingTable } from "@/components/data-table/loading-table";
@@ -90,15 +90,23 @@ interface Props {
90
90
  }
91
91
 
92
92
  const LoadingDataEditor = (props: Props) => {
93
- const [editorState, setEditorState] = useState<EditorState | null>(null);
93
+ const source = useMemo(
94
+ () => ({
95
+ data: props.data,
96
+ fieldTypes: props.fieldTypes,
97
+ columnNames: props.columnNames,
98
+ columnTypes: props.columnTypes,
99
+ }),
100
+ [props.data, props.fieldTypes, props.columnNames, props.columnTypes],
101
+ );
94
102
 
95
103
  const { data: loadedState, error } = useAsyncData(async () => {
96
- const withoutExternalTypes = toFieldTypes(props.fieldTypes ?? []);
104
+ const withoutExternalTypes = toFieldTypes(source.fieldTypes ?? []);
97
105
 
98
- const localData = Array.isArray(props.data)
99
- ? props.data
106
+ const localData = Array.isArray(source.data)
107
+ ? source.data
100
108
  : await vegaLoadData<EditorRow>(
101
- props.data,
109
+ source.data,
102
110
  {
103
111
  type: "csv",
104
112
  parse: getVegaFieldTypes(Object.fromEntries(withoutExternalTypes)),
@@ -106,9 +114,9 @@ const LoadingDataEditor = (props: Props) => {
106
114
  { handleBigIntAndNumberLike: true },
107
115
  );
108
116
 
109
- const columnTypes = new Map(Object.entries(props.columnTypes));
117
+ const columnTypes = new Map(Object.entries(source.columnTypes));
110
118
  const inferredFields = toFieldTypes(
111
- props.fieldTypes ?? inferFieldTypes(localData),
119
+ source.fieldTypes ?? inferFieldTypes(localData),
112
120
  );
113
121
  for (const [columnName, columnType] of columnTypes) {
114
122
  inferredFields.set(
@@ -118,17 +126,23 @@ const LoadingDataEditor = (props: Props) => {
118
126
  }
119
127
 
120
128
  return {
121
- data: normalizeConfiguredColumns(localData, columnTypes),
122
- columnFields: orderColumnFields(inferredFields, props.columnNames),
123
- columnTypes,
124
- } satisfies EditorState;
125
- }, [props.fieldTypes, props.columnNames, props.columnTypes, props.data]);
129
+ source,
130
+ state: {
131
+ data: normalizeConfiguredColumns(localData, columnTypes),
132
+ columnFields: orderColumnFields(inferredFields, source.columnNames),
133
+ columnTypes,
134
+ } satisfies EditorState,
135
+ };
136
+ }, [source]);
126
137
 
127
- useEffect(() => {
128
- if (loadedState !== undefined) {
129
- setEditorState(applyEditorEdits(loadedState, props.edits.edits));
130
- }
131
- }, [loadedState, props.edits.edits]);
138
+ // useAsyncData retains previous data while a replacement source loads.
139
+ const editorState = useMemo(
140
+ () =>
141
+ loadedState?.source === source
142
+ ? applyEditorEdits(loadedState.state, props.edits.edits)
143
+ : null,
144
+ [loadedState, source, props.edits.edits],
145
+ );
132
146
 
133
147
  if (error) {
134
148
  return (
@@ -156,9 +170,6 @@ const LoadingDataEditor = (props: Props) => {
156
170
  columnTypes={editorState.columnTypes}
157
171
  editableColumns={props.editableColumns}
158
172
  onAddEdits={(edits) => {
159
- setEditorState((state) =>
160
- state === null ? null : applyEditorEdits(state, edits),
161
- );
162
173
  props.onEdits((v) => ({ ...v, edits: [...v.edits, ...edits] }));
163
174
  }}
164
175
  />
@@ -7,6 +7,8 @@ import { useInternalStateWithSync } from "@/hooks/useInternalStateWithSync";
7
7
  import { cn } from "@/utils/cn";
8
8
  import { clamp } from "@/utils/math";
9
9
  import {
10
+ countFractionDigits,
11
+ fractionDigitsForSlider,
10
12
  maxFractionDigitsForSteps,
11
13
  prettyScientificNumber,
12
14
  roundToFractionDigits,
@@ -155,6 +157,7 @@ const SliderComponent = ({
155
157
  const id = useId();
156
158
  const labelId = useId();
157
159
  const { locale } = useLocale();
160
+ const isFullWidthHorizontal = fullWidth && orientation === "horizontal";
158
161
 
159
162
  // Hold internal value
160
163
  const [internalValue, setInternalValue] = useInternalStateWithSync(
@@ -163,6 +166,18 @@ const SliderComponent = ({
163
166
  );
164
167
 
165
168
  const stepsConfig = useMemo(() => computeStepsConfig(steps), [steps]);
169
+ const stepDigits = countFractionDigits(step ?? 0);
170
+ // Keep exact decimals; repeating fractions need only enough digits to distinguish steps.
171
+ const isDecimalStep =
172
+ step != null && roundToFractionDigits(step, stepDigits) === step;
173
+ const fractionDigits =
174
+ step != null && Number.isFinite(step) && step > 0 && !steps?.length
175
+ ? Math.max(
176
+ countFractionDigits(start),
177
+ isDecimalStep ? stepDigits : 2,
178
+ Math.ceil(-Math.log10(step)),
179
+ )
180
+ : fractionDigitsForSlider(step, steps, start, [valueMap(internalValue)]);
166
181
 
167
182
  const handleInputChange = (nextValue: number | null | undefined): void => {
168
183
  if (stepsConfig) {
@@ -232,6 +247,10 @@ const SliderComponent = ({
232
247
  <div
233
248
  className={cn(
234
249
  "flex items-center gap-2",
250
+ isFullWidthHorizontal && "grid w-full grid-cols-1 gap-y-1",
251
+ isFullWidthHorizontal &&
252
+ includeInput &&
253
+ "grid-cols-[minmax(0,1fr)_auto]",
235
254
  orientation === "vertical" &&
236
255
  "items-end inline-flex justify-center self-center mx-2",
237
256
  )}
@@ -264,18 +283,31 @@ const SliderComponent = ({
264
283
  }
265
284
  }}
266
285
  valueMap={valueMap} // Pass valueMap to Slider
286
+ maximumFractionDigits={fractionDigits}
267
287
  disabled={disabled}
268
288
  />
269
289
  {showValue && (
270
- <div className="text-xs text-muted-foreground min-w-[16px]">
271
- {prettyScientificNumber(valueMap(internalValue), { locale })}
290
+ <div
291
+ className={cn(
292
+ "text-xs text-muted-foreground min-w-[16px]",
293
+ isFullWidthHorizontal &&
294
+ "col-span-full row-start-2 min-w-0 max-w-full justify-self-end break-all",
295
+ )}
296
+ >
297
+ {prettyScientificNumber(valueMap(internalValue), {
298
+ locale,
299
+ maximumFractionDigits: fractionDigits,
300
+ })}
272
301
  </div>
273
302
  )}
274
303
  {includeInput && (
275
304
  <NumberField
276
305
  {...inputProps}
277
306
  onChange={handleInputChange}
278
- className="w-24"
307
+ className={cn(
308
+ "w-24",
309
+ isFullWidthHorizontal && "col-start-2 row-start-1",
310
+ )}
279
311
  aria-label={`${label || "Slider"} value input`}
280
312
  isDisabled={disabled}
281
313
  />
@@ -134,3 +134,33 @@ describe("SliderPlugin accessible labels", () => {
134
134
  expect(screen.getByRole("tooltip")).toHaveTextContent("50");
135
135
  });
136
136
  });
137
+
138
+ describe("SliderPlugin value formatting", () => {
139
+ test.each([
140
+ { step: 0.125, value: 0.125, expected: "0.125" },
141
+ { step: 0.025, value: 0.025, expected: "0.025" },
142
+ { step: 0.125, value: 0.5, expected: "0.5" },
143
+ { step: 0.025, value: 1, expected: "1" },
144
+ { step: 1 / 30, value: 1 / 30, expected: "0.03" },
145
+ { step: 1 / 30, value: 35.86666666666667, expected: "35.87" },
146
+ { step: 0.1, value: 0.30000000000000004, expected: "0.3" },
147
+ ])(
148
+ "formats $value with step $step consistently",
149
+ ({ step, value, expected }) => {
150
+ const initial = props("Value");
151
+ render(
152
+ plugin.render({
153
+ ...initial,
154
+ value,
155
+ data: { ...initial.data, step, stop: 60, showValue: true },
156
+ }),
157
+ );
158
+ expect(screen.getByText(expected)).toBeVisible();
159
+ const thumb = screen.getByRole("slider");
160
+ fireEvent.focus(thumb);
161
+ expect(screen.getByRole("tooltip")).toHaveTextContent(expected);
162
+ expect(thumb).toHaveAttribute("aria-valuenow", String(value));
163
+ expect(initial.setValue).not.toHaveBeenCalled();
164
+ },
165
+ );
166
+ });
@@ -4,14 +4,17 @@ import React, { Suspense } from "react";
4
4
  import { act, render, screen, waitFor } from "@testing-library/react";
5
5
  import { beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import type { z } from "zod";
7
- import type { IPluginProps } from "../../types";
8
- import type { EditorRow, Edits } from "../data-editor/types";
7
+ import type { IPluginProps, Setter } from "../../types";
8
+ import type { GlideDataEditor } from "../data-editor/glide-data-editor";
9
+ import type { ColumnType, EditorRow, Edits } from "../data-editor/types";
9
10
  import { DataEditorPlugin } from "../DataEditorPlugin";
10
11
 
11
12
  type PluginData = z.infer<typeof DataEditorPlugin.validator>;
12
13
 
13
14
  const mocks = vi.hoisted(() => ({
14
15
  loadData: vi.fn(),
16
+ renderEditor:
17
+ vi.fn<(props: React.ComponentProps<typeof GlideDataEditor>) => void>(),
15
18
  }));
16
19
 
17
20
  vi.mock("../vega/loader", () => ({
@@ -21,8 +24,9 @@ vi.mock("../vega/loader", () => ({
21
24
  vi.mock("../data-editor/glide-data-editor", async () => {
22
25
  const React = await import("react");
23
26
  return {
24
- default: (props: { data: EditorRow[]; columnFields: unknown }) =>
25
- React.createElement(
27
+ default: (props: React.ComponentProps<typeof GlideDataEditor>) => {
28
+ mocks.renderEditor(props);
29
+ return React.createElement(
26
30
  React.Fragment,
27
31
  null,
28
32
  React.createElement(
@@ -33,22 +37,23 @@ vi.mock("../data-editor/glide-data-editor", async () => {
33
37
  React.createElement(
34
38
  "pre",
35
39
  { "data-testid": "editor-fields" },
36
- JSON.stringify(
37
- props.columnFields instanceof Map
38
- ? [...props.columnFields]
39
- : props.columnFields,
40
- ),
40
+ JSON.stringify([...props.columnFields]),
41
41
  ),
42
- ),
42
+ );
43
+ },
43
44
  };
44
45
  });
45
46
 
46
- function renderPlugin(data: PluginData, value: Edits) {
47
+ function renderPlugin(
48
+ data: PluginData,
49
+ value: Edits,
50
+ setValue: Setter<Edits> = vi.fn(),
51
+ ) {
47
52
  const props: IPluginProps<Edits, PluginData> = {
48
53
  host: document.createElement("div"),
49
54
  data,
50
55
  value,
51
- setValue: vi.fn(),
56
+ setValue,
52
57
  functions: {},
53
58
  };
54
59
  return React.createElement(
@@ -61,6 +66,7 @@ function renderPlugin(data: PluginData, value: Edits) {
61
66
  describe("DataEditorPlugin", () => {
62
67
  beforeEach(() => {
63
68
  mocks.loadData.mockReset();
69
+ mocks.renderEditor.mockClear();
64
70
  });
65
71
 
66
72
  it("normalizes an unrecognized field type", () => {
@@ -212,6 +218,7 @@ describe("DataEditorPlugin", () => {
212
218
  );
213
219
  });
214
220
 
221
+ mocks.renderEditor.mockClear();
215
222
  result.rerender(
216
223
  renderPlugin(data, {
217
224
  edits: [{ rowIdx: 0, columnId: "name", value: "updated" }],
@@ -223,6 +230,90 @@ describe("DataEditorPlugin", () => {
223
230
  JSON.stringify([{ name: "updated" }]),
224
231
  );
225
232
  });
233
+ expect(mocks.renderEditor).toHaveBeenCalled();
234
+ for (const [props] of mocks.renderEditor.mock.calls) {
235
+ expect(props.data).toEqual([{ name: "updated" }]);
236
+ }
237
+ });
238
+
239
+ it("accumulates cell edits through the plugin value", async () => {
240
+ const data = DataEditorPlugin.validator.parse({
241
+ initialValue: { edits: [] },
242
+ label: null,
243
+ data: [{ name: "original" }],
244
+ editableColumns: "all",
245
+ });
246
+ const Editor = () => {
247
+ const [value, setValue] = React.useState<Edits>({ edits: [] });
248
+ return renderPlugin(data, value, setValue);
249
+ };
250
+ render(React.createElement(Editor));
251
+ await screen.findByTestId("editor-data");
252
+
253
+ act(() => {
254
+ const onAddEdits = mocks.renderEditor.mock.lastCall?.[0].onAddEdits;
255
+ onAddEdits?.([{ rowIdx: 0, columnId: "name", value: "updated" }]);
256
+ onAddEdits?.([{ rowIdx: 1, columnId: "name", value: "appended" }]);
257
+ });
258
+
259
+ expect(screen.getByTestId("editor-data").textContent).toBe(
260
+ JSON.stringify([{ name: "updated" }, { name: "appended" }]),
261
+ );
262
+ });
263
+
264
+ it("removes the previous editor while loading a replacement schema", async () => {
265
+ const original = DataEditorPlugin.validator.parse({
266
+ initialValue: { edits: [] },
267
+ label: null,
268
+ data: [{ name: "original", day: "2026-11-06" }],
269
+ editableColumns: "all",
270
+ });
271
+ const result = render(renderPlugin(original, { edits: [] }));
272
+ await screen.findByTestId("editor-data");
273
+ mocks.renderEditor.mockClear();
274
+
275
+ let resolveLoad: ((data: EditorRow[]) => void) | undefined;
276
+ mocks.loadData.mockImplementationOnce(
277
+ () =>
278
+ new Promise<EditorRow[]>((resolve) => {
279
+ resolveLoad = resolve;
280
+ }),
281
+ );
282
+ const replacement = DataEditorPlugin.validator.parse({
283
+ initialValue: { edits: [] },
284
+ label: null,
285
+ data: "replacement.csv",
286
+ columnNames: ["task", "done", "priority"],
287
+ columnTypes: { done: "boolean", priority: ["01", "02", "10"] },
288
+ editableColumns: ["task"],
289
+ });
290
+ result.rerender(
291
+ renderPlugin(replacement, {
292
+ edits: [{ rowIdx: 0, columnId: "task", value: "latest" }],
293
+ }),
294
+ );
295
+
296
+ expect(screen.queryByTestId("editor-data")).toBeNull();
297
+ expect(mocks.renderEditor).not.toHaveBeenCalled();
298
+ await act(async () => {
299
+ resolveLoad?.([{ task: "Review", done: "yes", priority: "01" }]);
300
+ });
301
+ await screen.findByTestId("editor-data");
302
+ expect(mocks.renderEditor).toHaveBeenLastCalledWith(
303
+ expect.objectContaining({
304
+ data: [{ task: "latest", done: true, priority: "01" }],
305
+ columnFields: new Map([
306
+ ["task", "string"],
307
+ ["done", "boolean"],
308
+ ["priority", "string"],
309
+ ]),
310
+ columnTypes: new Map<string, ColumnType>([
311
+ ["done", "boolean"],
312
+ ["priority", ["01", "02", "10"]],
313
+ ]),
314
+ editableColumns: ["task"],
315
+ }),
316
+ );
226
317
  });
227
318
 
228
319
  it("ignores a superseded async data load", async () => {
@@ -134,6 +134,46 @@ describe("SliderPlugin", () => {
134
134
  });
135
135
  };
136
136
 
137
+ it.each([
138
+ { step: 1 / 30, value: 35.86666666666667, expected: "35.87" },
139
+ { step: 0.1, value: 0.30000000000000004, expected: "0.3" },
140
+ { step: 0.25, value: 1.25, expected: "1.25" },
141
+ { step: 0.001, value: 1.234, expected: "1.234" },
142
+ { step: 1e-7, value: 3e-7, expected: "3.0e-7" },
143
+ { step: 1e-15, value: 3e-15, expected: "3.0e-15" },
144
+ { step: 1, value: 1.0001, expected: "1.0001", start: 0.0001 },
145
+ { step: undefined, value: 0.1234, expected: "0.1234" },
146
+ { step: 1, value: 1, expected: "1.234", steps: [1, 1.234, 2] },
147
+ ])(
148
+ "formats the displayed value for step $step",
149
+ ({ step, value, expected, start = 0, steps = null }) => {
150
+ const plugin = new SliderPlugin();
151
+ const setValue = vi.fn();
152
+ const props = createProps({
153
+ debounce: false,
154
+ includeInput: false,
155
+ setValue,
156
+ });
157
+ const { getByText } = render(
158
+ plugin.render({
159
+ ...props,
160
+ value,
161
+ data: {
162
+ ...props.data,
163
+ step,
164
+ start,
165
+ steps,
166
+ stop: 60,
167
+ showValue: true,
168
+ },
169
+ }),
170
+ );
171
+
172
+ expect(getByText(expected)).toBeVisible();
173
+ expect(setValue).not.toHaveBeenCalled();
174
+ },
175
+ );
176
+
137
177
  it("slider triggers setValue immediately when debounce is false", () => {
138
178
  const plugin = new SliderPlugin();
139
179
  const setValue = vi.fn();