Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/pluggableWidgets/slider-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Fixed

- We fixed mark labels and tooltip values not respecting the value attribute's number formatting. They now use the attribute's own formatter with the configured number of decimal places, so the decimal separator and thousands grouping follow the current locale and the attribute's settings (e.g., `10` displays as `10.00` and `9.2` as `9.20` with two decimal places, and grouping like `1,000,000` is preserved when enabled).

## [3.0.2] - 2026-02-19

### Fixed
Expand Down
12 changes: 5 additions & 7 deletions packages/pluggableWidgets/slider-web/e2e/Slider.spec.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { test, expect } from "@mendix/run-e2e/fixtures";
import { waitForMendixApp } from "@mendix/run-e2e/mendix-helpers";

Expand All @@ -6,19 +6,17 @@
await page.goto("/");
await waitForMendixApp(page);

const minimumValue = await page.inputValue(".mx-name-textBoxMinimumValue input");
const minimumValueText = await page
.locator(".mx-name-sliderContext .rc-slider-mark > span")
.first()
.textContent();
await expect(minimumValueText).toBe(minimumValue);
await expect(minimumValueText).toBe("0.0");

const maximumValue = await page.inputValue(".mx-name-textBoxMaximumValue input");
const maximumValueText = await page
.locator(".mx-name-sliderContext .rc-slider-mark > span")
.nth(2)
.textContent();
await expect(maximumValueText).toBe(maximumValue);
await expect(maximumValueText).toBe("20.0");

const value = await page.inputValue(".mx-name-textBoxValue input");
await expect(value).toContain("10");
Expand All @@ -38,13 +36,13 @@
.locator(".mx-name-sliderNoContext .rc-slider-mark > span")
.first()
.textContent();
await expect(minimumValueText).toBe("0");
await expect(minimumValueText).toBe("0.0");

const maximumValueText = await page
.locator(".mx-name-sliderNoContext .rc-slider-mark > span")
.nth(2)
.textContent();
await expect(maximumValueText).toBe("100");
await expect(maximumValueText).toBe("100.0");

const handleStyle = await page.locator(".mx-name-sliderNoContext .rc-slider-handle").getAttribute("style");
await expect(handleStyle).toContain("left: 0%;");
Expand Down Expand Up @@ -173,7 +171,7 @@
await waitForMendixApp(page);

await expect(page.locator(".mx-name-slider")).toBeVisible();
await expect(page.locator(".mx-name-slider .rc-slider-mark > span").nth(1)).toHaveText("140000");
await expect(page.locator(".mx-name-slider .rc-slider-mark > span").nth(1)).toHaveText("140000.0");
await expect(page.locator(".mx-name-slider .rc-slider-mark > span").nth(1)).toHaveAttribute(
"style",
/left: 33.3333%;/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ export function getPreviewCss(): string {

export function preview(props: SliderPreviewProps): ReactNode {
const values = getPreviewValues(props);
const decimalPlaces = props.decimalPlaces ?? 2;
const marks = createMarks({
min: values.min,
max: values.max,
numberOfMarks: props.noOfMarkers ?? 2,
decimalPlaces: props.decimalPlaces ?? 2
decimalPlaces,
format: (value: number) => value.toFixed(decimalPlaces)
});
const style = getStyleProp({
orientation: props.orientation,
Expand Down
36 changes: 24 additions & 12 deletions packages/pluggableWidgets/slider-web/src/components/Container.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { NumberFormatter } from "mendix";
import { ReactElement, useMemo, useRef } from "react";
import { SliderContainerProps } from "../../typings/SliderProps";

import { Slider as SliderComponent } from "./Slider";
import { SliderContainerProps } from "../../typings/SliderProps";
import { createHandleRender } from "../utils/createHandleRender";
import { createValueFormatter, getSliderLabel } from "../utils/helpers";
import { getStyleProp, isVertical, maxProp, minProp, stepProp } from "../utils/prop-utils";
import { useMarks } from "../utils/useMarks";
import { useNumber } from "../utils/useNumber";
import { getSliderLabel } from "../utils/helpers";
import { useOnChangeDebounced } from "../utils/useOnChangeDebounced";

import { Slider as SliderComponent } from "./Slider";

export function Container(props: SliderContainerProps): ReactElement {
const min = useNumber(minProp(props));
const max = useNumber(maxProp(props));
Expand All @@ -30,19 +30,31 @@ interface InnerContainerProps extends SliderContainerProps {

function InnerContainer(props: InnerContainerProps): ReactElement {
const sliderRef = useRef<HTMLDivElement>(null);
const handleRender = props.showTooltip
? createHandleRender({
tooltip: props.tooltip,
tooltipType: props.tooltipType,
tooltipAlwaysVisible: props.tooltipAlwaysVisible,
sliderRef
})
: undefined;

const format = useMemo(
() => createValueFormatter(props.valueAttribute.formatter as NumberFormatter, props.decimalPlaces),
[props.valueAttribute.formatter, props.decimalPlaces]
);

const handleRender = useMemo(
() =>
props.showTooltip
? createHandleRender({
tooltip: props.tooltip,
tooltipType: props.tooltipType,
tooltipAlwaysVisible: props.tooltipAlwaysVisible,
sliderRef,
format
})
: undefined,
[props.showTooltip, props.tooltip, props.tooltipType, props.tooltipAlwaysVisible, format]
);

const { onChange } = useOnChangeDebounced({ valueAttribute: props.valueAttribute, onChange: props.onChange });
const marks = useMarks({
noOfMarkers: props.noOfMarkers,
decimalPlaces: props.decimalPlaces,
format,
min: props.min,
max: props.max
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { SliderProps as RcSliderProps } from "@rc-component/slider";
import { createRef, ReactElement } from "react";
import { createHandleRender } from "../createHandleRender";

const defaultRenderProps = {
dragging: false,
index: 0,
prefixCls: "rc-slider-handle",
draggingDelete: false,
onFocus: jest.fn(),
onBlur: jest.fn()
};

const mockNode = <div />;

// Deterministic stand-in for createValueFormatter's output.
const formatWith =
(decimalPlaces: number, decimalSeparator = ".") =>
(value: number): string => {
const fixed = value.toFixed(decimalPlaces);
return decimalSeparator === "." ? fixed : fixed.replace(".", decimalSeparator);
};

function buildHandleRender(
decimalPlaces: number,
tooltipType: "value" | "customText" = "value",
decimalSeparator = "."
): NonNullable<RcSliderProps["handleRender"]> {
const sliderRef = createRef<HTMLDivElement>();
return createHandleRender({
tooltipType,
tooltipAlwaysVisible: true,
sliderRef,
format: formatWith(decimalPlaces, decimalSeparator)
})!;
}

describe("createHandleRender tooltip value formatting", () => {
it("formats whole number with trailing zeros when decimalPlaces=2", () => {
const result = buildHandleRender(2)(mockNode, { ...defaultRenderProps, value: 10 } as any) as ReactElement<any>;
expect(result.props.overlay).toBe("10.00");
});

it("formats partial decimal with trailing zero when decimalPlaces=2", () => {
const result = buildHandleRender(2)(mockNode, {
...defaultRenderProps,
value: 9.2
} as any) as ReactElement<any>;
expect(result.props.overlay).toBe("9.20");
});

it("formats value without decimals when decimalPlaces=0", () => {
const result = buildHandleRender(0)(mockNode, { ...defaultRenderProps, value: 10 } as any) as ReactElement<any>;
expect(result.props.overlay).toBe("10");
});

it("uses locale decimal separator", () => {
const result = buildHandleRender(
2,
"value",
","
)(mockNode, {
...defaultRenderProps,
value: 9.2
} as any) as ReactElement<any>;
expect(result.props.overlay).toBe("9,20");
});

it("renders custom text tooltip ignoring value formatting", () => {
const sliderRef = createRef<HTMLDivElement>();
const handleRender = createHandleRender({
tooltip: { value: "custom label" } as any,
tooltipType: "customText",
tooltipAlwaysVisible: true,
sliderRef,
format: formatWith(2)
})!;
const result = handleRender(mockNode, { ...defaultRenderProps, value: 10 } as any) as ReactElement<any>;
expect(result.props.overlay.props.children).toBe("custom label");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Big } from "big.js";
import { NumberFormatter } from "mendix";
import { createValueFormatter } from "../helpers";

/**
* Minimal stand-in for the Mendix runtime NumberFormatter. It mimics the two behaviours the
* widget relies on: `withConfig` returns a new formatter with the merged config, and `format`
* honours `decimalPrecision`, `groupDigits` and the configured locale separators.
*/
function fakeNumberFormatter(
config: { groupDigits: boolean; decimalPrecision?: number },
locale: { decimal: string; group: string } = { decimal: ".", group: "," }
): NumberFormatter {
return {
type: "number",
config,
withConfig: (next: { groupDigits: boolean; decimalPrecision?: number }) =>
fakeNumberFormatter({ ...config, ...next }, locale),
format: (value?: Big) => {
if (value == null) {
return "";
}
const fixed = value.toNumber().toFixed(config.decimalPrecision ?? 0);
const [intPart, fracPart] = fixed.split(".");
const grouped = config.groupDigits ? intPart.replace(/\B(?=(\d{3})+(?!\d))/g, locale.group) : intPart;
return fracPart != null ? `${grouped}${locale.decimal}${fracPart}` : grouped;
},
parse: () => ({ valid: false })
} as unknown as NumberFormatter;
}

describe("createValueFormatter", () => {
it("redefines the formatter's decimal precision (forces trailing zeros)", () => {
const format = createValueFormatter(fakeNumberFormatter({ groupDigits: false }), 2);
expect(format(10)).toBe("10.00");
expect(format(9.2)).toBe("9.20");
});

it("formats without decimals when decimalPlaces is 0", () => {
const format = createValueFormatter(fakeNumberFormatter({ groupDigits: false }), 0);
expect(format(10)).toBe("10");
expect(format(9.7)).toBe("10");
});

it("respects the locale decimal separator from the formatter", () => {
const format = createValueFormatter(
fakeNumberFormatter({ groupDigits: false }, { decimal: ",", group: "." }),
2
);
expect(format(9.2)).toBe("9,20");
});

it("keeps the attribute's thousands grouping when groupDigits is enabled", () => {
const format = createValueFormatter(fakeNumberFormatter({ groupDigits: true }), 0);
expect(format(1000000)).toBe("1,000,000");
});

it("omits thousands grouping when groupDigits is disabled", () => {
const format = createValueFormatter(fakeNumberFormatter({ groupDigits: false }), 0);
expect(format(1000000)).toBe("1000000");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { createMarks } from "../marks";

// Simple deterministic formatter standing in for createValueFormatter's output.
const formatWith =
(decimalPlaces: number, decimalSeparator = ".") =>
(value: number): string => {
const fixed = value.toFixed(decimalPlaces);
return decimalSeparator === "." ? fixed : fixed.replace(".", decimalSeparator);
};

describe("createMarks", () => {
it("forces trailing zeros when decimalPlaces > 0 and value is whole number", () => {
const marks = createMarks({ numberOfMarks: 2, decimalPlaces: 2, format: formatWith(2), min: 0, max: 10 });
expect(marks).toBeDefined();
expect(marks![0]).toBe("0.00");
expect(marks![5]).toBe("5.00");
expect(marks![10]).toBe("10.00");
});

it("forces trailing zeros when decimalPlaces > 0 and value has fewer decimals", () => {
const marks = createMarks({ numberOfMarks: 2, decimalPlaces: 2, format: formatWith(2), min: 0, max: 9.2 });
expect(marks).toBeDefined();
expect(marks![4.6]).toBe("4.60");
expect(marks![9.2]).toBe("9.20");
});

it("does not add decimal places when decimalPlaces is 0", () => {
const marks = createMarks({ numberOfMarks: 4, decimalPlaces: 0, format: formatWith(0), min: 0, max: 100 });
expect(marks).toBeDefined();
expect(marks![0]).toBe("0");
expect(marks![25]).toBe("25");
expect(marks![100]).toBe("100");
});

it("uses locale decimal separator", () => {
const marks = createMarks({ numberOfMarks: 2, decimalPlaces: 2, format: formatWith(2, ","), min: 0, max: 10 });
expect(marks![0]).toBe("0,00");
expect(marks![5]).toBe("5,00");
expect(marks![10]).toBe("10,00");
});

it("uses correct numeric keys for fractional values with comma locale", () => {
const marks = createMarks({ numberOfMarks: 2, decimalPlaces: 2, format: formatWith(2, ","), min: 0, max: 9.2 });
expect(marks![4.6]).toBe("4,60");
expect(marks![9.2]).toBe("9,20");
});

it("rounds mark keys to the configured decimal places so dots align with their labels", () => {
// 9 intervals over 0..20 yields repeating decimals (e.g. 6.6667). The key must be the
// rounded value (6.7) so rc-slider positions the dot where the label reads.
const marks = createMarks({ numberOfMarks: 9, decimalPlaces: 1, format: formatWith(1), min: 0, max: 20 });
expect(Object.keys(marks!)).toContain("6.7");
expect(Object.keys(marks!)).not.toContain("6.666666666666667");
expect(marks![6.7]).toBe("6.7");
});

it("returns undefined when numberOfMarks is 0", () => {
expect(
createMarks({ numberOfMarks: 0, decimalPlaces: 2, format: formatWith(2), min: 0, max: 100 })
).toBeUndefined();
});

it("returns undefined when min equals max", () => {
expect(
createMarks({ numberOfMarks: 4, decimalPlaces: 2, format: formatWith(2), min: 5, max: 5 })
).toBeUndefined();
});

it("returns undefined when min > max", () => {
expect(
createMarks({ numberOfMarks: 2, decimalPlaces: 1, format: formatWith(1), min: 10, max: 5 })
).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { SliderProps as RcSliderProps } from "@rc-component/slider";
import RcTooltip from "@rc-component/tooltip";
import { DynamicValue } from "mendix";
import { RefObject } from "react";
import { ValueFormatter } from "./helpers";

import "@rc-component/tooltip/assets/bootstrap.css";

Expand All @@ -10,26 +11,28 @@ type CreateHandleRenderProps = {
tooltipType: "value" | "customText";
tooltipAlwaysVisible: boolean;
sliderRef: RefObject<HTMLDivElement | null>;
format: ValueFormatter;
};

export function createHandleRender({
tooltip,
tooltipType,
tooltipAlwaysVisible,
sliderRef
sliderRef,
format
}: CreateHandleRenderProps): RcSliderProps["handleRender"] | undefined {
const isCustomText = tooltipType === "customText";

const handleRender: RcSliderProps["handleRender"] = (node, props) => {
const { dragging, index, ...restProps } = props;
const overlay = <div>{tooltip?.value ?? ""}</div>;
const overlay = isCustomText ? <div>{tooltip?.value ?? ""}</div> : null;

return (
<RcTooltip
getTooltipContainer={() => sliderRef.current ?? document.body}
defaultVisible
prefixCls="rc-slider-tooltip"
overlay={isCustomText ? overlay : restProps.value}
overlay={isCustomText ? overlay : format(restProps.value)}
trigger={["hover", "click", "focus"]}
visible={tooltipAlwaysVisible || dragging}
placement="top"
Expand Down
Loading
Loading