@marimo-team/islands 0.23.10-dev37 → 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.
- package/dist/{chat-ui-CJOUDE_t.js → chat-ui-CqtCMdc-.js} +3 -3
- package/dist/{code-visibility-DtdoPGry.js → code-visibility-CtLSFag8.js} +3 -3
- package/dist/{glide-data-editor-Qhu8oCX-.js → glide-data-editor-BPkCPs7L.js} +248 -248
- package/dist/{html-to-image-D3CbHZwH.js → html-to-image-BviMxrkm.js} +1 -1
- package/dist/{input-CMYy4hzj.js → input-OdWHkobi.js} +211 -195
- package/dist/main.js +1279 -1245
- package/dist/{process-output-MAetFLBT.js → process-output-Bq3VMBsg.js} +1 -1
- package/dist/{reveal-component-BhKAeoca.js → reveal-component-ets0P11U.js} +3 -3
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/ui/number-field.tsx +5 -1
- package/src/plugins/impl/SliderPlugin.tsx +132 -18
- package/src/plugins/impl/__tests__/SliderPlugin.test.tsx +278 -8
- package/src/utils/__tests__/numbers.test.ts +20 -0
- package/src/utils/numbers.ts +27 -0
package/package.json
CHANGED
|
@@ -25,7 +25,10 @@ export interface NumberFieldProps extends AriaNumberFieldProps {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export const NumberField = React.forwardRef<HTMLInputElement, NumberFieldProps>(
|
|
28
|
-
(
|
|
28
|
+
(
|
|
29
|
+
{ placeholder, variant = "default", onInputText, formatOptions, ...props },
|
|
30
|
+
ref,
|
|
31
|
+
) => {
|
|
29
32
|
const { locale } = useLocale();
|
|
30
33
|
return (
|
|
31
34
|
<AriaNumberField
|
|
@@ -33,6 +36,7 @@ export const NumberField = React.forwardRef<HTMLInputElement, NumberFieldProps>(
|
|
|
33
36
|
formatOptions={{
|
|
34
37
|
minimumFractionDigits: 0,
|
|
35
38
|
maximumFractionDigits: maxFractionalDigits(locale),
|
|
39
|
+
...formatOptions,
|
|
36
40
|
}}
|
|
37
41
|
>
|
|
38
42
|
<div
|
|
@@ -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 {
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
149
|
-
onChange={
|
|
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}
|
|
@@ -50,11 +50,18 @@ describe("SliderPlugin", () => {
|
|
|
50
50
|
vi.useRealTimers();
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
const createProps = (
|
|
54
|
-
debounce
|
|
55
|
-
includeInput
|
|
56
|
-
setValue
|
|
57
|
-
|
|
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(
|
|
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(
|
|
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
|
});
|
|
@@ -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
|
+
});
|
package/src/utils/numbers.ts
CHANGED
|
@@ -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 "";
|