From b64593cbdcddeb5e34315c874f5cddfe4cd2d3ac Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Thu, 28 May 2026 14:39:17 +0200 Subject: [PATCH 1/5] fix(slider-web): declare CSS module type to fix side-effect import error --- packages/pluggableWidgets/slider-web/typings/declare-svg.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/pluggableWidgets/slider-web/typings/declare-svg.ts b/packages/pluggableWidgets/slider-web/typings/declare-svg.ts index e6958d5a9f..d966c93688 100644 --- a/packages/pluggableWidgets/slider-web/typings/declare-svg.ts +++ b/packages/pluggableWidgets/slider-web/typings/declare-svg.ts @@ -2,3 +2,5 @@ declare module "*.svg" { const content: string; export = content; } + +declare module "*.css"; From c12ed23514a21adfdb86ed92b81011510d706124 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Thu, 28 May 2026 18:03:33 +0200 Subject: [PATCH 2/5] fix(slider-web): format marks and tooltip with locale-aware decimal separator Co-Authored-By: Claude Sonnet 4.6 --- .../slider-web/src/Slider.editorPreview.tsx | 4 +- .../slider-web/src/components/Container.tsx | 44 ++++++++++++++----- .../src/utils/createHandleRender.tsx | 11 +++-- .../slider-web/src/utils/helpers.ts | 16 +++++++ .../slider-web/src/utils/marks.ts | 9 ++-- .../slider-web/src/utils/useMarks.ts | 6 ++- 6 files changed, 69 insertions(+), 21 deletions(-) diff --git a/packages/pluggableWidgets/slider-web/src/Slider.editorPreview.tsx b/packages/pluggableWidgets/slider-web/src/Slider.editorPreview.tsx index 8ad56d8f9d..2acbffe635 100644 --- a/packages/pluggableWidgets/slider-web/src/Slider.editorPreview.tsx +++ b/packages/pluggableWidgets/slider-web/src/Slider.editorPreview.tsx @@ -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, + decimalSeparator: "." }); const style = getStyleProp({ orientation: props.orientation, diff --git a/packages/pluggableWidgets/slider-web/src/components/Container.tsx b/packages/pluggableWidgets/slider-web/src/components/Container.tsx index b26135235b..23faa8c72c 100644 --- a/packages/pluggableWidgets/slider-web/src/components/Container.tsx +++ b/packages/pluggableWidgets/slider-web/src/components/Container.tsx @@ -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 { getDecimalSeparator, 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)); @@ -30,19 +30,39 @@ interface InnerContainerProps extends SliderContainerProps { function InnerContainer(props: InnerContainerProps): ReactElement { const sliderRef = useRef(null); - const handleRender = props.showTooltip - ? createHandleRender({ - tooltip: props.tooltip, - tooltipType: props.tooltipType, - tooltipAlwaysVisible: props.tooltipAlwaysVisible, - sliderRef - }) - : undefined; + + const decimalSeparator = useMemo( + () => getDecimalSeparator(props.valueAttribute.formatter as NumberFormatter), + [props.valueAttribute.formatter] + ); + + const handleRender = useMemo( + () => + props.showTooltip + ? createHandleRender({ + tooltip: props.tooltip, + tooltipType: props.tooltipType, + tooltipAlwaysVisible: props.tooltipAlwaysVisible, + sliderRef, + decimalPlaces: props.decimalPlaces, + decimalSeparator + }) + : undefined, + [ + props.showTooltip, + props.tooltip, + props.tooltipType, + props.tooltipAlwaysVisible, + props.decimalPlaces, + decimalSeparator + ] + ); const { onChange } = useOnChangeDebounced({ valueAttribute: props.valueAttribute, onChange: props.onChange }); const marks = useMarks({ noOfMarkers: props.noOfMarkers, decimalPlaces: props.decimalPlaces, + decimalSeparator, min: props.min, max: props.max }); diff --git a/packages/pluggableWidgets/slider-web/src/utils/createHandleRender.tsx b/packages/pluggableWidgets/slider-web/src/utils/createHandleRender.tsx index 5b1a86bdc4..09d37e5001 100644 --- a/packages/pluggableWidgets/slider-web/src/utils/createHandleRender.tsx +++ b/packages/pluggableWidgets/slider-web/src/utils/createHandleRender.tsx @@ -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 { formatNumber } from "./helpers"; import "@rc-component/tooltip/assets/bootstrap.css"; @@ -10,26 +11,30 @@ type CreateHandleRenderProps = { tooltipType: "value" | "customText"; tooltipAlwaysVisible: boolean; sliderRef: RefObject; + decimalPlaces: number; + decimalSeparator: string; }; export function createHandleRender({ tooltip, tooltipType, tooltipAlwaysVisible, - sliderRef + sliderRef, + decimalPlaces, + decimalSeparator }: CreateHandleRenderProps): RcSliderProps["handleRender"] | undefined { const isCustomText = tooltipType === "customText"; const handleRender: RcSliderProps["handleRender"] = (node, props) => { const { dragging, index, ...restProps } = props; - const overlay =
{tooltip?.value ?? ""}
; + const overlay = isCustomText ?
{tooltip?.value ?? ""}
: null; return ( sliderRef.current ?? document.body} defaultVisible prefixCls="rc-slider-tooltip" - overlay={isCustomText ? overlay : restProps.value} + overlay={isCustomText ? overlay : formatNumber(restProps.value, decimalPlaces, decimalSeparator)} trigger={["hover", "click", "focus"]} visible={tooltipAlwaysVisible || dragging} placement="top" diff --git a/packages/pluggableWidgets/slider-web/src/utils/helpers.ts b/packages/pluggableWidgets/slider-web/src/utils/helpers.ts index df4a690418..42ea8571f6 100644 --- a/packages/pluggableWidgets/slider-web/src/utils/helpers.ts +++ b/packages/pluggableWidgets/slider-web/src/utils/helpers.ts @@ -1 +1,17 @@ +import { Big } from "big.js"; +import { NumberFormatter } from "mendix"; + export const getSliderLabel = (sliderId: string): Element | null => document.querySelector(`label[for="${sliderId}"]`); + +export function getDecimalSeparator(formatter: NumberFormatter): string { + const formatted = formatter.format(new Big("1.1")); + return formatted.charAt(1); +} + +export function formatNumber(value: number, decimalPlaces: number, decimalSeparator: string): string { + if (decimalPlaces === 0) { + return String(Math.round(value)); + } + const formatted = value.toFixed(decimalPlaces); + return decimalSeparator !== "." ? formatted.replace(".", decimalSeparator) : formatted; +} diff --git a/packages/pluggableWidgets/slider-web/src/utils/marks.ts b/packages/pluggableWidgets/slider-web/src/utils/marks.ts index 2c27eba8e6..e24ab46190 100644 --- a/packages/pluggableWidgets/slider-web/src/utils/marks.ts +++ b/packages/pluggableWidgets/slider-web/src/utils/marks.ts @@ -1,11 +1,13 @@ import { MarkObj } from "@rc-component/slider/lib/Marks"; import { ReactNode } from "react"; +import { formatNumber } from "./helpers"; export type Marks = Record; export interface CreateMarksParams { numberOfMarks: number; decimalPlaces: number; + decimalSeparator: string; min: number; max: number; } @@ -20,12 +22,13 @@ export function createMarks(params: CreateMarksParams): Marks | undefined { } const marks: Marks = {}; - const { numberOfMarks, decimalPlaces, min, max } = params; + const { numberOfMarks, decimalPlaces, decimalSeparator, min, max } = params; const interval = (max - min) / numberOfMarks; for (let i = 0; i <= numberOfMarks; i++) { - const value = parseFloat((min + i * interval).toFixed(decimalPlaces)); - marks[value] = value.toString(); + const rawValue = min + i * interval; + const key = parseFloat(rawValue.toFixed(decimalPlaces)); + marks[key] = formatNumber(rawValue, decimalPlaces, decimalSeparator); } return marks; diff --git a/packages/pluggableWidgets/slider-web/src/utils/useMarks.ts b/packages/pluggableWidgets/slider-web/src/utils/useMarks.ts index 4e8dd10dee..7604c249e5 100644 --- a/packages/pluggableWidgets/slider-web/src/utils/useMarks.ts +++ b/packages/pluggableWidgets/slider-web/src/utils/useMarks.ts @@ -4,21 +4,23 @@ import { createMarks } from "./marks"; type UseMarksParams = { noOfMarkers: number; decimalPlaces: number; + decimalSeparator: string; min?: number; max?: number; }; export function useMarks(props: UseMarksParams): ReturnType { - const { noOfMarkers, decimalPlaces, min = 0, max = 100 } = props; + const { noOfMarkers, decimalPlaces, decimalSeparator, min = 0, max = 100 } = props; return useMemo( () => createMarks({ numberOfMarks: noOfMarkers, decimalPlaces, + decimalSeparator, min, max }), - [min, max, noOfMarkers, decimalPlaces] + [min, max, noOfMarkers, decimalPlaces, decimalSeparator] ); } From 1a3d5c046595a8a3af17afa02ae6436158e5c25f Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Thu, 28 May 2026 18:03:46 +0200 Subject: [PATCH 3/5] test(slider-web): update unit and E2E tests for decimal formatting Co-Authored-By: Claude Sonnet 4.6 --- .../slider-web/e2e/Slider.spec.js | 12 ++- .../__tests__/createHandleRender.spec.tsx | 75 +++++++++++++++++++ .../src/utils/__tests__/marks.spec.ts | 57 ++++++++++++++ 3 files changed, 137 insertions(+), 7 deletions(-) create mode 100644 packages/pluggableWidgets/slider-web/src/utils/__tests__/createHandleRender.spec.tsx create mode 100644 packages/pluggableWidgets/slider-web/src/utils/__tests__/marks.spec.ts diff --git a/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js b/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js index 02376b95e0..7d85e10064 100644 --- a/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js +++ b/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js @@ -6,19 +6,17 @@ test.describe("Slider", () => { 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.000"); - 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.000"); const value = await page.inputValue(".mx-name-textBoxValue input"); await expect(value).toContain("10"); @@ -38,13 +36,13 @@ test.describe("Slider", () => { .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%;"); @@ -173,7 +171,7 @@ test.describe("Slider", () => { 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%;/ diff --git a/packages/pluggableWidgets/slider-web/src/utils/__tests__/createHandleRender.spec.tsx b/packages/pluggableWidgets/slider-web/src/utils/__tests__/createHandleRender.spec.tsx new file mode 100644 index 0000000000..6291a4e330 --- /dev/null +++ b/packages/pluggableWidgets/slider-web/src/utils/__tests__/createHandleRender.spec.tsx @@ -0,0 +1,75 @@ +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 =
; + +function buildHandleRender( + decimalPlaces: number, + tooltipType: "value" | "customText" = "value", + decimalSeparator = "." +): NonNullable { + const sliderRef = createRef(); + return createHandleRender({ + tooltipType, + tooltipAlwaysVisible: true, + sliderRef, + 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; + 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; + 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; + 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; + expect(result.props.overlay).toBe("9,20"); + }); + + it("renders custom text tooltip ignoring value formatting", () => { + const sliderRef = createRef(); + const handleRender = createHandleRender({ + tooltip: { value: "custom label" } as any, + tooltipType: "customText", + tooltipAlwaysVisible: true, + sliderRef, + decimalPlaces: 2, + decimalSeparator: "." + })!; + const result = handleRender(mockNode, { ...defaultRenderProps, value: 10 } as any) as ReactElement; + expect(result.props.overlay.props.children).toBe("custom label"); + }); +}); diff --git a/packages/pluggableWidgets/slider-web/src/utils/__tests__/marks.spec.ts b/packages/pluggableWidgets/slider-web/src/utils/__tests__/marks.spec.ts new file mode 100644 index 0000000000..ce90184943 --- /dev/null +++ b/packages/pluggableWidgets/slider-web/src/utils/__tests__/marks.spec.ts @@ -0,0 +1,57 @@ +import { createMarks } from "../marks"; + +describe("createMarks", () => { + it("forces trailing zeros when decimalPlaces > 0 and value is whole number", () => { + const marks = createMarks({ numberOfMarks: 2, decimalPlaces: 2, decimalSeparator: ".", 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, decimalSeparator: ".", 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, decimalSeparator: ".", 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, decimalSeparator: ",", 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, decimalSeparator: ",", min: 0, max: 9.2 }); + expect(marks![4.6]).toBe("4,60"); + expect(marks![9.2]).toBe("9,20"); + }); + + it("returns undefined when numberOfMarks is 0", () => { + expect( + createMarks({ numberOfMarks: 0, decimalPlaces: 2, decimalSeparator: ".", min: 0, max: 100 }) + ).toBeUndefined(); + }); + + it("returns undefined when min equals max", () => { + expect( + createMarks({ numberOfMarks: 4, decimalPlaces: 2, decimalSeparator: ".", min: 5, max: 5 }) + ).toBeUndefined(); + }); + + it("returns undefined when min > max", () => { + expect( + createMarks({ numberOfMarks: 2, decimalPlaces: 1, decimalSeparator: ".", min: 10, max: 5 }) + ).toBeUndefined(); + }); +}); From 5a05fd63b6ad1231439c153ff242845f25a71efe Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Thu, 28 May 2026 18:03:52 +0200 Subject: [PATCH 4/5] chore(slider-web): add changelog entry for decimal places formatting fix Co-Authored-By: Claude Sonnet 4.6 --- packages/pluggableWidgets/slider-web/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/pluggableWidgets/slider-web/CHANGELOG.md b/packages/pluggableWidgets/slider-web/CHANGELOG.md index 20079df8d5..0753d662c3 100644 --- a/packages/pluggableWidgets/slider-web/CHANGELOG.md +++ b/packages/pluggableWidgets/slider-web/CHANGELOG.md @@ -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 preserving trailing zeros when decimal places are configured (e.g., `10` now displays as `10.00` and `9.2` as `9.20` when two decimal places are set). + ## [3.0.2] - 2026-02-19 ### Fixed From 0c6fc1fec5e9db9cf6e2fecd7bbd016a772a178b Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Fri, 29 May 2026 15:17:32 +0200 Subject: [PATCH 5/5] refactor(slider-web): use built-in NumberFormatter for marks and tooltip Replace manual toFixed + decimal-separator swapping with the value attribute's own Mendix NumberFormatter, overriding only decimalPrecision via withConfig. The locale decimal separator and thousands grouping now follow the user's session locale and the attribute's groupDigits setting automatically. Mark keys remain rounded with parseFloat(rawValue.toFixed(dp)) (always "." based, locale-safe) so rc-slider positions dots where their labels read. Fix E2E context expectations to match the model's decimalPlaces=1 output (0.0 / 20.0). Co-Authored-By: Claude Opus 4.8 --- .../pluggableWidgets/slider-web/CHANGELOG.md | 2 +- .../slider-web/e2e/Slider.spec.js | 4 +- .../slider-web/src/Slider.editorPreview.tsx | 2 +- .../slider-web/src/components/Container.tsx | 22 +++---- .../__tests__/createHandleRender.spec.tsx | 14 +++-- .../src/utils/__tests__/helpers.spec.ts | 62 +++++++++++++++++++ .../src/utils/__tests__/marks.spec.ts | 33 +++++++--- .../src/utils/createHandleRender.tsx | 10 ++- .../slider-web/src/utils/helpers.ts | 22 ++++--- .../slider-web/src/utils/marks.ts | 11 ++-- .../slider-web/src/utils/useMarks.ts | 9 +-- 11 files changed, 136 insertions(+), 55 deletions(-) create mode 100644 packages/pluggableWidgets/slider-web/src/utils/__tests__/helpers.spec.ts diff --git a/packages/pluggableWidgets/slider-web/CHANGELOG.md b/packages/pluggableWidgets/slider-web/CHANGELOG.md index 0753d662c3..bf6d52181b 100644 --- a/packages/pluggableWidgets/slider-web/CHANGELOG.md +++ b/packages/pluggableWidgets/slider-web/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed -- We fixed mark labels and tooltip values not preserving trailing zeros when decimal places are configured (e.g., `10` now displays as `10.00` and `9.2` as `9.20` when two decimal places are set). +- 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 diff --git a/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js b/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js index 7d85e10064..0681753473 100644 --- a/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js +++ b/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js @@ -10,13 +10,13 @@ test.describe("Slider", () => { .locator(".mx-name-sliderContext .rc-slider-mark > span") .first() .textContent(); - await expect(minimumValueText).toBe("0.000"); + await expect(minimumValueText).toBe("0.0"); const maximumValueText = await page .locator(".mx-name-sliderContext .rc-slider-mark > span") .nth(2) .textContent(); - await expect(maximumValueText).toBe("20.000"); + await expect(maximumValueText).toBe("20.0"); const value = await page.inputValue(".mx-name-textBoxValue input"); await expect(value).toContain("10"); diff --git a/packages/pluggableWidgets/slider-web/src/Slider.editorPreview.tsx b/packages/pluggableWidgets/slider-web/src/Slider.editorPreview.tsx index 2acbffe635..bf31ffcf8a 100644 --- a/packages/pluggableWidgets/slider-web/src/Slider.editorPreview.tsx +++ b/packages/pluggableWidgets/slider-web/src/Slider.editorPreview.tsx @@ -17,7 +17,7 @@ export function preview(props: SliderPreviewProps): ReactNode { max: values.max, numberOfMarks: props.noOfMarkers ?? 2, decimalPlaces, - decimalSeparator: "." + format: (value: number) => value.toFixed(decimalPlaces) }); const style = getStyleProp({ orientation: props.orientation, diff --git a/packages/pluggableWidgets/slider-web/src/components/Container.tsx b/packages/pluggableWidgets/slider-web/src/components/Container.tsx index 23faa8c72c..10a9b3105f 100644 --- a/packages/pluggableWidgets/slider-web/src/components/Container.tsx +++ b/packages/pluggableWidgets/slider-web/src/components/Container.tsx @@ -4,7 +4,7 @@ import { ReactElement, useMemo, useRef } from "react"; import { Slider as SliderComponent } from "./Slider"; import { SliderContainerProps } from "../../typings/SliderProps"; import { createHandleRender } from "../utils/createHandleRender"; -import { getDecimalSeparator, getSliderLabel } from "../utils/helpers"; +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"; @@ -31,9 +31,9 @@ interface InnerContainerProps extends SliderContainerProps { function InnerContainer(props: InnerContainerProps): ReactElement { const sliderRef = useRef(null); - const decimalSeparator = useMemo( - () => getDecimalSeparator(props.valueAttribute.formatter as NumberFormatter), - [props.valueAttribute.formatter] + const format = useMemo( + () => createValueFormatter(props.valueAttribute.formatter as NumberFormatter, props.decimalPlaces), + [props.valueAttribute.formatter, props.decimalPlaces] ); const handleRender = useMemo( @@ -44,25 +44,17 @@ function InnerContainer(props: InnerContainerProps): ReactElement { tooltipType: props.tooltipType, tooltipAlwaysVisible: props.tooltipAlwaysVisible, sliderRef, - decimalPlaces: props.decimalPlaces, - decimalSeparator + format }) : undefined, - [ - props.showTooltip, - props.tooltip, - props.tooltipType, - props.tooltipAlwaysVisible, - props.decimalPlaces, - decimalSeparator - ] + [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, - decimalSeparator, + format, min: props.min, max: props.max }); diff --git a/packages/pluggableWidgets/slider-web/src/utils/__tests__/createHandleRender.spec.tsx b/packages/pluggableWidgets/slider-web/src/utils/__tests__/createHandleRender.spec.tsx index 6291a4e330..d29273207a 100644 --- a/packages/pluggableWidgets/slider-web/src/utils/__tests__/createHandleRender.spec.tsx +++ b/packages/pluggableWidgets/slider-web/src/utils/__tests__/createHandleRender.spec.tsx @@ -13,6 +13,14 @@ const defaultRenderProps = { const mockNode =
; +// 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", @@ -23,8 +31,7 @@ function buildHandleRender( tooltipType, tooltipAlwaysVisible: true, sliderRef, - decimalPlaces, - decimalSeparator + format: formatWith(decimalPlaces, decimalSeparator) })!; } @@ -66,8 +73,7 @@ describe("createHandleRender tooltip value formatting", () => { tooltipType: "customText", tooltipAlwaysVisible: true, sliderRef, - decimalPlaces: 2, - decimalSeparator: "." + format: formatWith(2) })!; const result = handleRender(mockNode, { ...defaultRenderProps, value: 10 } as any) as ReactElement; expect(result.props.overlay.props.children).toBe("custom label"); diff --git a/packages/pluggableWidgets/slider-web/src/utils/__tests__/helpers.spec.ts b/packages/pluggableWidgets/slider-web/src/utils/__tests__/helpers.spec.ts new file mode 100644 index 0000000000..80492644e5 --- /dev/null +++ b/packages/pluggableWidgets/slider-web/src/utils/__tests__/helpers.spec.ts @@ -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"); + }); +}); diff --git a/packages/pluggableWidgets/slider-web/src/utils/__tests__/marks.spec.ts b/packages/pluggableWidgets/slider-web/src/utils/__tests__/marks.spec.ts index ce90184943..9ba6e3106e 100644 --- a/packages/pluggableWidgets/slider-web/src/utils/__tests__/marks.spec.ts +++ b/packages/pluggableWidgets/slider-web/src/utils/__tests__/marks.spec.ts @@ -1,8 +1,16 @@ 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, decimalSeparator: ".", min: 0, max: 10 }); + 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"); @@ -10,14 +18,14 @@ describe("createMarks", () => { }); it("forces trailing zeros when decimalPlaces > 0 and value has fewer decimals", () => { - const marks = createMarks({ numberOfMarks: 2, decimalPlaces: 2, decimalSeparator: ".", min: 0, max: 9.2 }); + 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, decimalSeparator: ".", min: 0, max: 100 }); + 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"); @@ -25,33 +33,42 @@ describe("createMarks", () => { }); it("uses locale decimal separator", () => { - const marks = createMarks({ numberOfMarks: 2, decimalPlaces: 2, decimalSeparator: ",", min: 0, max: 10 }); + 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, decimalSeparator: ",", min: 0, max: 9.2 }); + 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, decimalSeparator: ".", min: 0, max: 100 }) + 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, decimalSeparator: ".", min: 5, max: 5 }) + 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, decimalSeparator: ".", min: 10, max: 5 }) + createMarks({ numberOfMarks: 2, decimalPlaces: 1, format: formatWith(1), min: 10, max: 5 }) ).toBeUndefined(); }); }); diff --git a/packages/pluggableWidgets/slider-web/src/utils/createHandleRender.tsx b/packages/pluggableWidgets/slider-web/src/utils/createHandleRender.tsx index 09d37e5001..2bbdb155a5 100644 --- a/packages/pluggableWidgets/slider-web/src/utils/createHandleRender.tsx +++ b/packages/pluggableWidgets/slider-web/src/utils/createHandleRender.tsx @@ -2,7 +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 { formatNumber } from "./helpers"; +import { ValueFormatter } from "./helpers"; import "@rc-component/tooltip/assets/bootstrap.css"; @@ -11,8 +11,7 @@ type CreateHandleRenderProps = { tooltipType: "value" | "customText"; tooltipAlwaysVisible: boolean; sliderRef: RefObject; - decimalPlaces: number; - decimalSeparator: string; + format: ValueFormatter; }; export function createHandleRender({ @@ -20,8 +19,7 @@ export function createHandleRender({ tooltipType, tooltipAlwaysVisible, sliderRef, - decimalPlaces, - decimalSeparator + format }: CreateHandleRenderProps): RcSliderProps["handleRender"] | undefined { const isCustomText = tooltipType === "customText"; @@ -34,7 +32,7 @@ export function createHandleRender({ getTooltipContainer={() => sliderRef.current ?? document.body} defaultVisible prefixCls="rc-slider-tooltip" - overlay={isCustomText ? overlay : formatNumber(restProps.value, decimalPlaces, decimalSeparator)} + overlay={isCustomText ? overlay : format(restProps.value)} trigger={["hover", "click", "focus"]} visible={tooltipAlwaysVisible || dragging} placement="top" diff --git a/packages/pluggableWidgets/slider-web/src/utils/helpers.ts b/packages/pluggableWidgets/slider-web/src/utils/helpers.ts index 42ea8571f6..3f896d8ee7 100644 --- a/packages/pluggableWidgets/slider-web/src/utils/helpers.ts +++ b/packages/pluggableWidgets/slider-web/src/utils/helpers.ts @@ -3,15 +3,17 @@ import { NumberFormatter } from "mendix"; export const getSliderLabel = (sliderId: string): Element | null => document.querySelector(`label[for="${sliderId}"]`); -export function getDecimalSeparator(formatter: NumberFormatter): string { - const formatted = formatter.format(new Big("1.1")); - return formatted.charAt(1); -} +export type ValueFormatter = (value: number) => string; -export function formatNumber(value: number, decimalPlaces: number, decimalSeparator: string): string { - if (decimalPlaces === 0) { - return String(Math.round(value)); - } - const formatted = value.toFixed(decimalPlaces); - return decimalSeparator !== "." ? formatted.replace(".", decimalSeparator) : formatted; +/** + * Builds a value formatter from the attribute's own Mendix NumberFormatter, overriding only the + * decimal precision. Reusing the runtime formatter means the decimal separator and thousands + * grouping follow the user's session locale and the attribute's `groupDigits` setting automatically. + */ +export function createValueFormatter(formatter: NumberFormatter, decimalPlaces: number): ValueFormatter { + const configured = formatter.withConfig({ + groupDigits: formatter.config.groupDigits, + decimalPrecision: decimalPlaces + }); + return (value: number) => configured.format(new Big(value)); } diff --git a/packages/pluggableWidgets/slider-web/src/utils/marks.ts b/packages/pluggableWidgets/slider-web/src/utils/marks.ts index e24ab46190..1f8eb53142 100644 --- a/packages/pluggableWidgets/slider-web/src/utils/marks.ts +++ b/packages/pluggableWidgets/slider-web/src/utils/marks.ts @@ -1,13 +1,13 @@ import { MarkObj } from "@rc-component/slider/lib/Marks"; import { ReactNode } from "react"; -import { formatNumber } from "./helpers"; +import { ValueFormatter } from "./helpers"; export type Marks = Record; export interface CreateMarksParams { numberOfMarks: number; decimalPlaces: number; - decimalSeparator: string; + format: ValueFormatter; min: number; max: number; } @@ -22,13 +22,16 @@ export function createMarks(params: CreateMarksParams): Marks | undefined { } const marks: Marks = {}; - const { numberOfMarks, decimalPlaces, decimalSeparator, min, max } = params; + const { numberOfMarks, decimalPlaces, format, min, max } = params; const interval = (max - min) / numberOfMarks; for (let i = 0; i <= numberOfMarks; i++) { const rawValue = min + i * interval; + // Round the key to the configured precision so rc-slider positions the dot where its + // label reads. toFixed always uses "." here, so parseFloat is locale-safe (unlike parsing + // the formatted label, which may contain a comma decimal separator). const key = parseFloat(rawValue.toFixed(decimalPlaces)); - marks[key] = formatNumber(rawValue, decimalPlaces, decimalSeparator); + marks[key] = format(rawValue); } return marks; diff --git a/packages/pluggableWidgets/slider-web/src/utils/useMarks.ts b/packages/pluggableWidgets/slider-web/src/utils/useMarks.ts index 7604c249e5..3626fb7921 100644 --- a/packages/pluggableWidgets/slider-web/src/utils/useMarks.ts +++ b/packages/pluggableWidgets/slider-web/src/utils/useMarks.ts @@ -1,26 +1,27 @@ import { useMemo } from "react"; +import { ValueFormatter } from "./helpers"; import { createMarks } from "./marks"; type UseMarksParams = { noOfMarkers: number; decimalPlaces: number; - decimalSeparator: string; + format: ValueFormatter; min?: number; max?: number; }; export function useMarks(props: UseMarksParams): ReturnType { - const { noOfMarkers, decimalPlaces, decimalSeparator, min = 0, max = 100 } = props; + const { noOfMarkers, decimalPlaces, format, min = 0, max = 100 } = props; return useMemo( () => createMarks({ numberOfMarks: noOfMarkers, decimalPlaces, - decimalSeparator, + format, min, max }), - [min, max, noOfMarkers, decimalPlaces, decimalSeparator] + [min, max, noOfMarkers, decimalPlaces, format] ); }