diff --git a/packages/pluggableWidgets/slider-web/CHANGELOG.md b/packages/pluggableWidgets/slider-web/CHANGELOG.md index 20079df8d5..bf6d52181b 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 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 diff --git a/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js b/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js index 02376b95e0..0681753473 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.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"); @@ -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/Slider.editorPreview.tsx b/packages/pluggableWidgets/slider-web/src/Slider.editorPreview.tsx index 8ad56d8f9d..bf31ffcf8a 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, + 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 b26135235b..10a9b3105f 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 { 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)); @@ -30,19 +30,31 @@ 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 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 }); 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..d29273207a --- /dev/null +++ b/packages/pluggableWidgets/slider-web/src/utils/__tests__/createHandleRender.spec.tsx @@ -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 =
; + +// 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 { + const sliderRef = createRef(); + 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; + 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, + 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 new file mode 100644 index 0000000000..9ba6e3106e --- /dev/null +++ b/packages/pluggableWidgets/slider-web/src/utils/__tests__/marks.spec.ts @@ -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(); + }); +}); diff --git a/packages/pluggableWidgets/slider-web/src/utils/createHandleRender.tsx b/packages/pluggableWidgets/slider-web/src/utils/createHandleRender.tsx index 5b1a86bdc4..2bbdb155a5 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 { ValueFormatter } from "./helpers"; import "@rc-component/tooltip/assets/bootstrap.css"; @@ -10,26 +11,28 @@ type CreateHandleRenderProps = { tooltipType: "value" | "customText"; tooltipAlwaysVisible: boolean; sliderRef: RefObject; + 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 =
{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 : 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 df4a690418..3f896d8ee7 100644 --- a/packages/pluggableWidgets/slider-web/src/utils/helpers.ts +++ b/packages/pluggableWidgets/slider-web/src/utils/helpers.ts @@ -1 +1,19 @@ +import { Big } from "big.js"; +import { NumberFormatter } from "mendix"; + export const getSliderLabel = (sliderId: string): Element | null => document.querySelector(`label[for="${sliderId}"]`); + +export type ValueFormatter = (value: number) => string; + +/** + * 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 2c27eba8e6..1f8eb53142 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 { ValueFormatter } from "./helpers"; export type Marks = Record; export interface CreateMarksParams { numberOfMarks: number; decimalPlaces: number; + format: ValueFormatter; min: number; max: number; } @@ -20,12 +22,16 @@ export function createMarks(params: CreateMarksParams): Marks | undefined { } const marks: Marks = {}; - const { numberOfMarks, decimalPlaces, min, max } = params; + const { numberOfMarks, decimalPlaces, format, 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; + // 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] = 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 4e8dd10dee..3626fb7921 100644 --- a/packages/pluggableWidgets/slider-web/src/utils/useMarks.ts +++ b/packages/pluggableWidgets/slider-web/src/utils/useMarks.ts @@ -1,24 +1,27 @@ import { useMemo } from "react"; +import { ValueFormatter } from "./helpers"; import { createMarks } from "./marks"; type UseMarksParams = { noOfMarkers: number; decimalPlaces: number; + format: ValueFormatter; min?: number; max?: number; }; export function useMarks(props: UseMarksParams): ReturnType { - const { noOfMarkers, decimalPlaces, min = 0, max = 100 } = props; + const { noOfMarkers, decimalPlaces, format, min = 0, max = 100 } = props; return useMemo( () => createMarks({ numberOfMarks: noOfMarkers, decimalPlaces, + format, min, max }), - [min, max, noOfMarkers, decimalPlaces] + [min, max, noOfMarkers, decimalPlaces, format] ); } 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";