/* Copyright 2026 Marimo. All rights reserved. */
import { act, fireEvent, render } from "@testing-library/react";
import {
afterEach,
beforeEach,
describe,
expect,
it,
type Mock,
vi,
} from "vitest";
import type { z } from "zod";
import { SetupMocks } from "@/__mocks__/common";
import { initialModeAtom } from "@/core/mode";
import { store } from "@/core/state/jotai";
import type { IPluginProps, Setter } from "../../types";
import { SliderPlugin } from "../SliderPlugin";
vi.mock("@/components/ui/slider", () => ({
Slider: ({
disabled,
onValueChange,
onValueCommit,
value,
}: {
disabled?: boolean;
onValueChange?: (value: number[]) => void;
onValueCommit?: (value: number[]) => void;
value: number[];
}) => (
),
}));
SetupMocks.resizeObserver();
describe("SliderPlugin", () => {
beforeEach(() => {
vi.useFakeTimers();
store.set(initialModeAtom, "edit");
});
afterEach(() => {
vi.useRealTimers();
});
const createProps = ({
debounce,
includeInput,
setValue,
}: {
debounce: boolean;
includeInput: boolean;
setValue: Mock>;
}): IPluginProps<
number,
z.infer
> => {
return {
host: document.createElement("div"),
value: 5,
setValue,
data: {
initialValue: 5,
start: 0,
stop: 10,
step: 1,
label: "Test Slider",
debounce,
orientation: "horizontal" as const,
showValue: false,
fullWidth: false,
includeInput,
steps: null,
},
functions: {},
};
};
// When `steps` are provided, the slider works in *index* space: `value`,
// `start`, `stop` and `step` are all indices into the `steps` array, while
// the editable input shows/accepts the actual step values.
const createStepsProps = ({
steps,
valueIndex,
setValue,
}: {
steps: number[];
valueIndex: number;
setValue: Mock>;
}): IPluginProps<
number,
z.infer
> => {
return {
host: document.createElement("div"),
value: valueIndex,
setValue,
data: {
initialValue: valueIndex,
start: 0,
stop: steps.length - 1,
step: 1,
label: "Test Slider",
debounce: false,
orientation: "horizontal" as const,
showValue: false,
fullWidth: false,
includeInput: true,
steps,
},
functions: {},
};
};
const typeAndCommit = (input: HTMLElement, value: string) => {
act(() => {
fireEvent.change(input, { target: { value } });
fireEvent.blur(input);
});
};
it("slider triggers setValue immediately when debounce is false", () => {
const plugin = new SliderPlugin();
const setValue = vi.fn();
const props = createProps({
debounce: false,
includeInput: false,
setValue,
});
const { getByRole } = render(plugin.render(props));
act(() => {
vi.advanceTimersByTime(0);
});
const changeButton = getByRole("button", { name: "Slider change" });
act(() => {
fireEvent.click(changeButton);
});
expect(setValue).toHaveBeenCalledWith(6);
});
it("slider waits until commit before calling setValue when debounce is true", () => {
const plugin = new SliderPlugin();
const setValue = vi.fn();
const props = createProps({
debounce: true,
includeInput: false,
setValue,
});
const { getByRole } = render(plugin.render(props));
act(() => {
vi.advanceTimersByTime(0);
});
const changeButton = getByRole("button", { name: "Slider change" });
const commitButton = getByRole("button", { name: "Slider commit" });
act(() => {
fireEvent.click(changeButton);
});
expect(setValue).not.toHaveBeenCalled();
act(() => {
fireEvent.click(commitButton);
});
expect(setValue).toHaveBeenCalledWith(6);
});
it("editable input triggers setValue immediately even when slider debounce is true", () => {
const plugin = new SliderPlugin();
const setValue = vi.fn();
const props = createProps({ debounce: true, includeInput: true, setValue });
const { getByRole } = render(plugin.render(props));
act(() => {
vi.advanceTimersByTime(0);
});
// The react-aria NumberField renders an input textbox.
const numericInput = getByRole("textbox");
act(() => {
// Simulate typing a new value and pressing enter
// With React-Aria NumberField, onChange fires on blur or enter
fireEvent.change(numericInput, { target: { value: "9" } });
fireEvent.blur(numericInput);
});
// Because the user explicitly typed 9 in the editable input,
// setValue should be called immediately regardless of debounce=true.
expect(setValue).toHaveBeenCalledWith(9);
});
describe("editable input with steps (regression for #9850)", () => {
it("displays the actual step value, not the index", () => {
const plugin = new SliderPlugin();
const setValue = vi.fn();
// steps[0] === -4, displayed value must be -4 (not the index 0).
const props = createStepsProps({
steps: [-4, -3, -2, -1],
valueIndex: 0,
setValue,
});
const { getByRole } = render(plugin.render(props));
act(() => {
vi.advanceTimersByTime(0);
});
const input = getByRole("textbox") as HTMLInputElement;
expect(input.value).toBe("-4");
});
it("displays decimal step values without float artifacts", () => {
const plugin = new SliderPlugin();
const setValue = vi.fn();
const props = createStepsProps({
steps: [0.1, 0.2, 0.3, 0.4],
valueIndex: 2,
setValue,
});
const { getByRole } = render(plugin.render(props));
act(() => {
vi.advanceTimersByTime(0);
});
const input = getByRole("textbox") as HTMLInputElement;
expect(input.value).toBe("0.3");
});
it("steps decimal input increment and decrement buttons move between steps", () => {
const plugin = new SliderPlugin();
const setValue = vi.fn();
const props = createStepsProps({
steps: [0.1, 0.2, 0.3, 0.4],
valueIndex: 2,
setValue,
});
const { getByRole } = render(plugin.render(props));
act(() => {
vi.advanceTimersByTime(0);
});
const decrement = getByRole("button", {
name: "Decrease Test Slider value input",
});
const increment = getByRole("button", {
name: "Increase Test Slider value input",
});
expect(decrement).not.toBeDisabled();
act(() => {
fireEvent.click(decrement);
});
expect(setValue.mock.calls).toEqual([[1]]);
act(() => {
fireEvent.click(increment);
});
expect(setValue).toHaveBeenLastCalledWith(2);
});
it("maps a typed integer step value back to its index", () => {
const plugin = new SliderPlugin();
const setValue = vi.fn();
const props = createStepsProps({
steps: [1, 2, 3, 4],
valueIndex: 0,
setValue,
});
const { getByRole } = render(plugin.render(props));
act(() => {
vi.advanceTimersByTime(0);
});
const input = getByRole("textbox");
// Typing "4" should select the last step (index 3), not index 4.
typeAndCommit(input, "4");
expect(setValue).toHaveBeenLastCalledWith(3);
// Typing "2" should select index 1, not get "stuck" on 3.
typeAndCommit(input, "2");
expect(setValue).toHaveBeenLastCalledWith(1);
});
it("maps fractional step values back to their index", () => {
const plugin = new SliderPlugin();
const setValue = vi.fn();
const props = createStepsProps({
steps: [0.1, 0.2, 0.3, 0.4],
valueIndex: 0,
setValue,
});
const { getByRole } = render(plugin.render(props));
act(() => {
vi.advanceTimersByTime(0);
});
const input = getByRole("textbox");
typeAndCommit(input, "0.3");
expect(setValue).toHaveBeenLastCalledWith(2);
});
it("maps negative step values back to their index", () => {
const plugin = new SliderPlugin();
const setValue = vi.fn();
const props = createStepsProps({
steps: [-4, -3, -2, -1],
valueIndex: 0,
setValue,
});
const { getByRole } = render(plugin.render(props));
act(() => {
vi.advanceTimersByTime(0);
});
const input = getByRole("textbox");
typeAndCommit(input, "-2");
expect(setValue).toHaveBeenLastCalledWith(2);
});
it("clamps out-of-range typed values to the nearest step index", () => {
const plugin = new SliderPlugin();
const setValue = vi.fn();
const props = createStepsProps({
steps: [1, 2, 3, 4],
valueIndex: 0,
setValue,
});
const { getByRole } = render(plugin.render(props));
act(() => {
vi.advanceTimersByTime(0);
});
const input = getByRole("textbox");
// Above the max step -> clamps to the last index.
typeAndCommit(input, "100");
expect(setValue).toHaveBeenLastCalledWith(3);
});
it("does not crash when steps shrink while the index is temporarily out of range", () => {
const plugin = new SliderPlugin();
const setValue = vi.fn();
// Start at the last index of a 5-element steps array.
const props = createStepsProps({
steps: [10, 20, 30, 40, 50],
valueIndex: 4,
setValue,
});
const { getByRole, rerender } = render(plugin.render(props));
act(() => {
vi.advanceTimersByTime(0);
});
// Shrink `steps` so the held index (4) is now out of range. The index
// syncs in an effect after this render, so the render must not throw on
// the stale, out-of-range index.
const shrunkProps = createStepsProps({
steps: [10, 20],
valueIndex: 1,
setValue,
});
expect(() => {
act(() => {
rerender(plugin.render(shrunkProps));
vi.advanceTimersByTime(0);
});
}).not.toThrow();
const input = getByRole("textbox") as HTMLInputElement;
expect(input.value).toBe("20");
});
it("nudges to the adjacent step on non-uniform steps even when the current step is nearest (known caveat)", () => {
const plugin = new SliderPlugin();
const setValue = vi.fn();
// Non-uniform steps; start at index 2 (value 10).
const props = createStepsProps({
steps: [0, 1, 10, 100],
valueIndex: 2,
setValue,
});
const { getByRole } = render(plugin.render(props));
act(() => {
vi.advanceTimersByTime(0);
});
const input = getByRole("textbox");
// 10 is far closer to 12 than 100 is, but because 12 > 10 the input nudges
// up one index. This documents the stepper-vs-typing limitation: onChange
// can't distinguish a typed value from a stepper-button increment.
typeAndCommit(input, "12");
expect(setValue).toHaveBeenLastCalledWith(3);
});
});
});