From 24b0a32e089a41962d00a108aeab2a9c75bcdf5c Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Mon, 27 Jul 2026 16:54:16 -0400 Subject: [PATCH 1/2] feat(Slider): Recognize localized number input Assisted-by: Cursor Fixes https://github.com/patternfly/patternfly-react/issues/12052 --- .../src/components/Slider/Slider.tsx | 121 +++++++++++++++--- .../Slider/__tests__/Slider.test.tsx | 21 +++ .../__snapshots__/Slider.test.tsx.snap | 12 +- .../src/helpers/__tests__/util.test.ts | 23 +++- packages/react-core/src/helpers/util.ts | 56 ++++++++ packages/tsconfig.base.json | 1 + 6 files changed, 208 insertions(+), 26 deletions(-) diff --git a/packages/react-core/src/components/Slider/Slider.tsx b/packages/react-core/src/components/Slider/Slider.tsx index 08b8dadf339..cd4fcead4a2 100644 --- a/packages/react-core/src/components/Slider/Slider.tsx +++ b/packages/react-core/src/components/Slider/Slider.tsx @@ -7,7 +7,7 @@ import { TextInput } from '../TextInput'; import { Tooltip, TooltipProps } from '../Tooltip'; import cssSliderValue from '@patternfly/react-tokens/dist/esm/c_slider_value'; import cssFormControlWidthChars from '@patternfly/react-tokens/dist/esm/c_slider__value_c_form_control_width_chars'; -import { getLanguageDirection } from '../../helpers/util'; +import { formatLocalizedDecimal, getLanguageDirection, parseLocalizedDecimal } from '../../helpers/util'; /** Properties for creating custom steps in a slider. These properties should be passed in as * an object within an array to the slider component's customSteps property. @@ -93,6 +93,8 @@ export interface SliderProps extends Omit, 'onCh thumbAriaValueText?: string; /** Current value of the slider. */ value?: number; + /** Locale string used for input formatting and parsing. See https://developer.mozilla.org/en-US/docs/Glossary/BCP_47_language_tag for more information. */ + locale?: string; } const getPercentage = (current: number, max: number) => (100 * current) / max; @@ -126,13 +128,22 @@ export const Slider: React.FunctionComponent = ({ showBoundaries = true, 'aria-describedby': ariaDescribedby, 'aria-labelledby': ariaLabelledby, + locale, ...props }: SliderProps) => { const sliderRailRef = useRef(undefined); const thumbRef = useRef(undefined); + const isInputFocusedRef = useRef(false); + let formatter: Intl.NumberFormat; + try { + formatter = new Intl.NumberFormat(locale); + } catch { + formatter = new Intl.NumberFormat(); // grabs default locale + } const [localValue, setValue] = useState(value); const [localInputValue, setLocalInputValue] = useState(inputValue); + const [inputDisplayValue, setInputDisplayValue] = useState(() => formatLocalizedDecimal(inputValue, formatter)); let isRTL: boolean; @@ -144,9 +155,45 @@ export const Slider: React.FunctionComponent = ({ setValue(value); }, [value]); + const updateInputDisplay = useCallback((numericValue: number) => { + setInputDisplayValue(formatLocalizedDecimal(numericValue, formatter)); + }, []); + + const updateInputFromSliderValue = useCallback( + (numericValue: number) => { + if (!isInputVisible) { + return; + } + + setLocalInputValue(numericValue); + if (!isInputFocusedRef.current) { + updateInputDisplay(numericValue); + } + }, + [isInputVisible, updateInputDisplay] + ); + + const setLocalInputValueWithDisplay = useCallback>>( + (nextValue) => { + setLocalInputValue((previousValue) => { + const resolvedValue = typeof nextValue === 'function' ? nextValue(previousValue) : nextValue; + + if (!isInputFocusedRef.current) { + updateInputDisplay(resolvedValue); + } + + return resolvedValue; + }); + }, + [updateInputDisplay] + ); + useEffect(() => { - setLocalInputValue(inputValue); - }, [inputValue]); + if (!isInputFocusedRef.current) { + setLocalInputValue(inputValue); + updateInputDisplay(inputValue); + } + }, [inputValue, updateInputDisplay]); let diff = 0; let snapValue: number; @@ -154,27 +201,44 @@ export const Slider: React.FunctionComponent = ({ // calculate style value percentage const stylePercent = ((localValue - min) * 100) / (max - min); const style = { [cssSliderValue.name]: `${stylePercent}%` } as React.CSSProperties; - const widthChars = useMemo(() => localInputValue.toString().length, [localInputValue]); + const widthChars = useMemo(() => inputDisplayValue.length || 1, [inputDisplayValue]); const inputStyle = { [cssFormControlWidthChars.name]: widthChars } as React.CSSProperties; const onChangeHandler = (event: React.FormEvent, value: string) => { - const newValue = Number(value); - setLocalInputValue(newValue); + setInputDisplayValue(value); - isInputLive && onChange && onChange(event, localValue, newValue, setLocalInputValue); + const parsedValue = parseLocalizedDecimal(value, formatter); + + if (!Number.isNaN(parsedValue)) { + setLocalInputValue(parsedValue); + isInputLive && onChange && onChange(event, localValue, parsedValue, setLocalInputValueWithDisplay); + } }; const handleKeyPressOnInput = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { event.preventDefault(); + const parsedValue = parseLocalizedDecimal(inputDisplayValue, formatter); + + if (!Number.isNaN(parsedValue)) { + setLocalInputValue(parsedValue); + updateInputDisplay(parsedValue); + } + if (onChange) { - onChange(event, localValue, localInputValue, setLocalInputValue); + onChange( + event, + localValue, + Number.isNaN(parsedValue) ? localInputValue : parsedValue, + setLocalInputValueWithDisplay + ); } } }; - const onInputFocus = (e: any) => { + const onInputFocus = (e: React.SyntheticEvent) => { e.stopPropagation(); + isInputFocusedRef.current = true; }; const onThumbClick = () => { @@ -182,8 +246,16 @@ export const Slider: React.FunctionComponent = ({ }; const onBlur = (event: React.FocusEvent) => { + isInputFocusedRef.current = false; + + const parsedValue = parseLocalizedDecimal(inputDisplayValue, formatter); + const resolvedInputValue = Number.isNaN(parsedValue) ? localInputValue : parsedValue; + + setLocalInputValue(resolvedInputValue); + updateInputDisplay(resolvedInputValue); + if (onChange) { - onChange(event, localValue, localInputValue, setLocalInputValue); + onChange(event, localValue, resolvedInputValue, setLocalInputValueWithDisplay); } }; @@ -240,8 +312,9 @@ export const Slider: React.FunctionComponent = ({ if (snapValue && !areCustomStepsContinuous) { thumbRef.current.style.setProperty(cssSliderValue.name, `${snapValue}%`); setValue(snapValue); + updateInputFromSliderValue(snapValue); if (onChange) { - onChange(e, snapValue); + onChange(e, snapValue, undefined, setLocalInputValueWithDisplay); } } }; @@ -308,16 +381,22 @@ export const Slider: React.FunctionComponent = ({ } // Call onchange callback + const resolvedValue = snapValue !== undefined ? snapValue : newValue; + updateInputFromSliderValue(resolvedValue); + if (onChange) { - if (snapValue !== undefined) { - onChange(e, snapValue); - } else { - onChange(e, newValue); - } + onChange(e, resolvedValue, undefined, setLocalInputValueWithDisplay); } }; - const callbackThumbMove = useCallback(handleThumbMove, [min, max, customSteps, onChange]); + const callbackThumbMove = useCallback(handleThumbMove, [ + min, + max, + customSteps, + onChange, + updateInputFromSliderValue, + setLocalInputValueWithDisplay + ]); const callbackThumbUp = useCallback(handleThumbDragEnd, [min, max, customSteps, onChange]); const handleThumbKeys = (e: React.KeyboardEvent) => { @@ -373,8 +452,9 @@ export const Slider: React.FunctionComponent = ({ if (newValue !== localValue) { thumbRef.current.style.setProperty(cssSliderValue.name, `${newValue}%`); setValue(newValue); + updateInputFromSliderValue(newValue); if (onChange) { - onChange(e, newValue); + onChange(e, newValue, undefined, setLocalInputValueWithDisplay); } } }; @@ -383,8 +463,9 @@ export const Slider: React.FunctionComponent = ({ const textInput = ( { expect(slider).toHaveAttribute('aria-valuetext', 'Half capacity'); }); + +test('displays localized decimal separator based on language', () => { + render(); + + expect(screen.getByRole('textbox', { name: 'Slider value input' })).toHaveValue('50,2'); +}); + +test('accepts comma decimal input and normalizes on blur', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + render(); + + const input = screen.getByRole('textbox', { name: 'Slider value input' }); + await user.clear(input); + await user.type(input, '62,5'); + await user.tab(); + + expect(input).toHaveValue('62,5'); + expect(onChange).toHaveBeenLastCalledWith(expect.any(Object), 50, 62.5, expect.any(Function)); +}); diff --git a/packages/react-core/src/components/Slider/__tests__/__snapshots__/Slider.test.tsx.snap b/packages/react-core/src/components/Slider/__tests__/__snapshots__/Slider.test.tsx.snap index 3cb539b1129..bb73d4c22da 100644 --- a/packages/react-core/src/components/Slider/__tests__/__snapshots__/Slider.test.tsx.snap +++ b/packages/react-core/src/components/Slider/__tests__/__snapshots__/Slider.test.tsx.snap @@ -65,7 +65,8 @@ exports[`slider renders continuous slider 1`] = ` data-ouia-component-id="OUIA-Generated-TextInputBase-:r1:" data-ouia-component-type="PF6/TextInput" data-ouia-safe="true" - type="number" + inputmode="decimal" + type="text" value="50" /> @@ -259,7 +260,8 @@ exports[`slider renders discrete slider 1`] = ` data-ouia-component-id="OUIA-Generated-TextInputBase-:r3:" data-ouia-component-type="PF6/TextInput" data-ouia-safe="true" - type="number" + inputmode="decimal" + type="text" value="50" /> @@ -431,7 +433,8 @@ exports[`slider renders slider with input 1`] = ` data-ouia-component-id="OUIA-Generated-TextInputBase-:r5:" data-ouia-component-type="PF6/TextInput" data-ouia-safe="true" - type="number" + inputmode="decimal" + type="text" value="50" /> @@ -521,7 +524,8 @@ exports[`slider renders slider with input above thumb 1`] = ` data-ouia-component-id="OUIA-Generated-TextInputBase-:r7:" data-ouia-component-type="PF6/TextInput" data-ouia-safe="true" - type="number" + inputmode="decimal" + type="text" value="50" /> diff --git a/packages/react-core/src/helpers/__tests__/util.test.ts b/packages/react-core/src/helpers/__tests__/util.test.ts index a726cedf997..d6ef53b5b1f 100644 --- a/packages/react-core/src/helpers/__tests__/util.test.ts +++ b/packages/react-core/src/helpers/__tests__/util.test.ts @@ -1,12 +1,15 @@ import { capitalize, + formatLocalizedDecimal, + formatBreakpointMods, + getElementLocale, getUniqueId, debounce, isElementInView, + parseLocalizedDecimal, sideElementIsOutOfView, fillTemplate, - pluralize, - formatBreakpointMods + pluralize } from '../util'; import { SIDE } from '../constants'; import styles from '@patternfly/react-styles/css/layouts/Flex/flex'; @@ -110,3 +113,19 @@ test('formatBreakpointMods', () => { expect(formatBreakpointMods({ md: 'spacerNone' }, styles)).toEqual('pf-m-spacer-none-on-md'); expect(formatBreakpointMods({ default: 'column', lg: 'row' }, styles)).toEqual('pf-m-column pf-m-row-on-lg'); }); + +test('parseLocalizedDecimal accepts dot and comma decimal separators', () => { + const enFormatter = new Intl.NumberFormat('en'); + const esFormatter = new Intl.NumberFormat('es'); + + expect(parseLocalizedDecimal('50.2', enFormatter)).toBe(50.2); + expect(parseLocalizedDecimal('50,2', esFormatter)).toBe(50.2); + expect(parseLocalizedDecimal('1.234,56', esFormatter)).toBe(1234.56); + expect(parseLocalizedDecimal('1,234.56', enFormatter)).toBe(1234.56); + expect(parseLocalizedDecimal('', enFormatter)).toBeNaN(); +}); + +test('formatLocalizedDecimal uses locale decimal separator', () => { + expect(formatLocalizedDecimal(50.2, new Intl.NumberFormat('en-US'))).toBe('50.2'); + expect(formatLocalizedDecimal(50.2, new Intl.NumberFormat('de-DE'))).toBe('50,2'); +}); diff --git a/packages/react-core/src/helpers/util.ts b/packages/react-core/src/helpers/util.ts index ac63bbaeade..7b60b0ea651 100644 --- a/packages/react-core/src/helpers/util.ts +++ b/packages/react-core/src/helpers/util.ts @@ -590,3 +590,59 @@ export const getInlineStartProperty = ( const widthProperty: 'offsetWidth' | 'clientWidth' | 'scrollWidth' = `${inlineType}Width`; return ancestorElement[widthProperty] - (targetElement[inlineProperty] + targetElement[widthProperty]); }; + +/** + * Parses a decimal input string, accepting both comma and dot as the decimal separator. + * Given the locale, it discovers the locale's separators and integers using a reference value. + * + * @param {string} value - The input string to parse + * @param {Intl.NumberFormat} formatter - The Intl.NumberFormat instance to use for formatting + * @returns {number} - The parsed number, or NaN if the input is not a valid number + */ +export const parseLocalizedDecimal = (value: string, formatter: Intl.NumberFormat): number => { + if (typeof value !== 'string' || !value.trim()) { + return NaN; + } + + const parts = formatter.formatToParts(12345.6); + const groupSymbol = parts.find((p) => p.type === 'group')?.value || ','; + const decimalSymbol = parts.find((p) => p.type === 'decimal')?.value || '.'; + + // in case of non-Arabic numerals + const digitParts = formatter.formatToParts(1234567890); + const localDigits = digitParts + .filter((p) => p.type === 'integer') + .map((p) => p.value) + .join(''); + let normalizedString = value; + if (localDigits.length === 10 && localDigits !== '1234567890') { + const standardDigits = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']; + const digitMap = new Map(); + [...localDigits].forEach((char, idx) => digitMap.set(char, standardDigits[idx])); + normalizedString = [...value].map((char) => digitMap.get(char) || char).join(''); + } + + const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + const cleaned = normalizedString + .replace(new RegExp(escapeRegex(groupSymbol), 'g'), '') + .replace(new RegExp(escapeRegex(decimalSymbol), 'g'), '.') + .replace(/[^0-9.-]/g, ''); + + return parseFloat(cleaned); +}; + +/** + * Formats a number using the decimal separator for the given locale. + * + * @param {number} value - The number to format + * @param {Intl.NumberFormat} formatter - Intl.NumberFormat instance to use for formatting + * @returns {string} - The formatted number string + */ +export const formatLocalizedDecimal = (value: number, formatter: Intl.NumberFormat): string => { + if (Number.isNaN(value)) { + return ''; + } + + return formatter.format(value); +}; diff --git a/packages/tsconfig.base.json b/packages/tsconfig.base.json index fe6c7d19c4e..8f925dd83ef 100644 --- a/packages/tsconfig.base.json +++ b/packages/tsconfig.base.json @@ -4,6 +4,7 @@ "jsx": "react-jsx", "lib": [ "es2015", + "es2018.intl", "dom" ], "target": "es2015", From bd10366789ea8c02f13e7d622e827d6fd9bc2fa0 Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Tue, 28 Jul 2026 14:06:09 -0400 Subject: [PATCH 2/2] Address CodeRabbit feedback --- .../src/helpers/__tests__/util.test.ts | 51 +++++++++++++- packages/react-core/src/helpers/util.ts | 68 +++++++++++++------ 2 files changed, 96 insertions(+), 23 deletions(-) diff --git a/packages/react-core/src/helpers/__tests__/util.test.ts b/packages/react-core/src/helpers/__tests__/util.test.ts index d6ef53b5b1f..53e471cc6fe 100644 --- a/packages/react-core/src/helpers/__tests__/util.test.ts +++ b/packages/react-core/src/helpers/__tests__/util.test.ts @@ -114,17 +114,64 @@ test('formatBreakpointMods', () => { expect(formatBreakpointMods({ default: 'column', lg: 'row' }, styles)).toEqual('pf-m-column pf-m-row-on-lg'); }); -test('parseLocalizedDecimal accepts dot and comma decimal separators', () => { +test('parseLocalizedDecimal accepts locale-formatted decimals', () => { const enFormatter = new Intl.NumberFormat('en'); const esFormatter = new Intl.NumberFormat('es'); + const deFormatter = new Intl.NumberFormat('de-DE'); expect(parseLocalizedDecimal('50.2', enFormatter)).toBe(50.2); expect(parseLocalizedDecimal('50,2', esFormatter)).toBe(50.2); - expect(parseLocalizedDecimal('1.234,56', esFormatter)).toBe(1234.56); + expect(parseLocalizedDecimal('12.345,67', esFormatter)).toBe(12345.67); expect(parseLocalizedDecimal('1,234.56', enFormatter)).toBe(1234.56); + expect(parseLocalizedDecimal('1.234,56', deFormatter)).toBe(1234.56); expect(parseLocalizedDecimal('', enFormatter)).toBeNaN(); }); +test('parseLocalizedDecimal rejects mismatched separators', () => { + const enFormatter = new Intl.NumberFormat('en-US'); + const deFormatter = new Intl.NumberFormat('de-DE'); + const deNoGrouping = new Intl.NumberFormat('de-DE', { useGrouping: false }); + + // Comma used as decimal in en-US would otherwise silently merge to 502 + expect(parseLocalizedDecimal('50,2', enFormatter)).toBeNaN(); + // Dot used as decimal in de-DE would otherwise silently merge to 502 + expect(parseLocalizedDecimal('50.2', deFormatter)).toBeNaN(); + // Mixed separators for de-DE + expect(parseLocalizedDecimal('1,234.56', deFormatter)).toBeNaN(); + // Without a reported group separator, alternate '.' must not be stripped/merged + expect(parseLocalizedDecimal('1.234,56', deNoGrouping)).toBeNaN(); + expect(parseLocalizedDecimal('50,2', deNoGrouping)).toBe(50.2); +}); + +test('parseLocalizedDecimal accepts locale-specific grouping patterns', () => { + const enINFormatter = new Intl.NumberFormat('en-IN'); + const enUSFormatter = new Intl.NumberFormat('en-US'); + const esFormatter = new Intl.NumberFormat('es'); + + expect(parseLocalizedDecimal('12,34,567', enINFormatter)).toBe(1234567); + expect(parseLocalizedDecimal('12,345', enINFormatter)).toBe(12345); + expect(parseLocalizedDecimal('1,234', enINFormatter)).toBe(1234); + expect(parseLocalizedDecimal('1,23,4567', enINFormatter)).toBeNaN(); + expect(parseLocalizedDecimal('50,2', enUSFormatter)).toBeNaN(); + expect(parseLocalizedDecimal('1.234,56', esFormatter)).toBeNaN(); +}); + +test('parseLocalizedDecimal rejects malformed numeric tokens', () => { + const enFormatter = new Intl.NumberFormat('en-US'); + + expect(parseLocalizedDecimal('12abc', enFormatter)).toBeNaN(); + expect(parseLocalizedDecimal('1,2abc', enFormatter)).toBeNaN(); + expect(parseLocalizedDecimal('12-3', enFormatter)).toBeNaN(); + expect(parseLocalizedDecimal('--12', enFormatter)).toBeNaN(); + expect(parseLocalizedDecimal('+-12', enFormatter)).toBeNaN(); + expect(parseLocalizedDecimal('12.3.4', enFormatter)).toBeNaN(); + expect(parseLocalizedDecimal('abc', enFormatter)).toBeNaN(); + + expect(parseLocalizedDecimal('-50.2', enFormatter)).toBe(-50.2); + expect(parseLocalizedDecimal('+12', enFormatter)).toBe(12); + expect(parseLocalizedDecimal('1,234.56', enFormatter)).toBe(1234.56); +}); + test('formatLocalizedDecimal uses locale decimal separator', () => { expect(formatLocalizedDecimal(50.2, new Intl.NumberFormat('en-US'))).toBe('50.2'); expect(formatLocalizedDecimal(50.2, new Intl.NumberFormat('de-DE'))).toBe('50,2'); diff --git a/packages/react-core/src/helpers/util.ts b/packages/react-core/src/helpers/util.ts index 7b60b0ea651..3f049d9302c 100644 --- a/packages/react-core/src/helpers/util.ts +++ b/packages/react-core/src/helpers/util.ts @@ -592,8 +592,8 @@ export const getInlineStartProperty = ( }; /** - * Parses a decimal input string, accepting both comma and dot as the decimal separator. - * Given the locale, it discovers the locale's separators and integers using a reference value. + * Parses a decimal input string using the given formatter's locale separators. + * Grouping is removed only when formatToParts reports a group separator. * * @param {string} value - The input string to parse * @param {Intl.NumberFormat} formatter - The Intl.NumberFormat instance to use for formatting @@ -605,31 +605,57 @@ export const parseLocalizedDecimal = (value: string, formatter: Intl.NumberForma } const parts = formatter.formatToParts(12345.6); - const groupSymbol = parts.find((p) => p.type === 'group')?.value || ','; + // Only strip grouping when the formatter actually reports a group separator. + // Never fall back to ',' — that can match comma-decimal locales and eat the decimal. + const groupSymbol = parts.find((p) => p.type === 'group')?.value; const decimalSymbol = parts.find((p) => p.type === 'decimal')?.value || '.'; + const normalizedString = value.trim(); - // in case of non-Arabic numerals - const digitParts = formatter.formatToParts(1234567890); - const localDigits = digitParts - .filter((p) => p.type === 'integer') - .map((p) => p.value) - .join(''); - let normalizedString = value; - if (localDigits.length === 10 && localDigits !== '1234567890') { - const standardDigits = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']; - const digitMap = new Map(); - [...localDigits].forEach((char, idx) => digitMap.set(char, standardDigits[idx])); - normalizedString = [...value].map((char) => digitMap.get(char) || char).join(''); + // Reject '.' / ',' when they are neither this locale's decimal nor its reported group separator + // (e.g. mixed-separator de-DE input like "1,234.56"). + for (const sep of ['.', ','] as const) { + if (sep !== decimalSymbol && sep !== groupSymbol && normalizedString.includes(sep)) { + return NaN; + } } - const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const decimalIndex = normalizedString.indexOf(decimalSymbol); + if (decimalIndex !== -1 && normalizedString.indexOf(decimalSymbol, decimalIndex + decimalSymbol.length) !== -1) { + return NaN; + } + + let intPart = decimalIndex === -1 ? normalizedString : normalizedString.slice(0, decimalIndex); + const fracPart = decimalIndex === -1 ? undefined : normalizedString.slice(decimalIndex + decimalSymbol.length); + + let sign = ''; + if (intPart.startsWith('-') || intPart.startsWith('+')) { + sign = intPart[0] === '-' ? '-' : ''; + intPart = intPart.slice(1); + } + + if (fracPart !== undefined && groupSymbol && fracPart.includes(groupSymbol)) { + return NaN; + } - const cleaned = normalizedString - .replace(new RegExp(escapeRegex(groupSymbol), 'g'), '') - .replace(new RegExp(escapeRegex(decimalSymbol), 'g'), '.') - .replace(/[^0-9.-]/g, ''); + // If grouping appears, require locale-valid groups so values like en-US "50,2" are invalid + // instead of silently merging into 502, while accepting locales such as en-IN "12,34,567". + if (groupSymbol && intPart.includes(groupSymbol)) { + const digitsOnly = intPart.split(groupSymbol).join(''); + if (!/^\d+$/.test(digitsOnly) || formatter.format(Number(digitsOnly)) !== intPart) { + return NaN; + } + intPart = digitsOnly; + } + + if (!/^\d*$/.test(intPart) || (fracPart !== undefined && !/^\d*$/.test(fracPart))) { + return NaN; + } + + if (intPart === '' && (fracPart === undefined || fracPart === '')) { + return NaN; + } - return parseFloat(cleaned); + return parseFloat(`${sign}${intPart || '0'}${fracPart !== undefined ? `.${fracPart}` : ''}`); }; /**