Skip to content

Commit 24b0a32

Browse files
committed
feat(Slider): Recognize localized number input
Assisted-by: Cursor Fixes #12052
1 parent f59b184 commit 24b0a32

6 files changed

Lines changed: 208 additions & 26 deletions

File tree

packages/react-core/src/components/Slider/Slider.tsx

Lines changed: 101 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { TextInput } from '../TextInput';
77
import { Tooltip, TooltipProps } from '../Tooltip';
88
import cssSliderValue from '@patternfly/react-tokens/dist/esm/c_slider_value';
99
import cssFormControlWidthChars from '@patternfly/react-tokens/dist/esm/c_slider__value_c_form_control_width_chars';
10-
import { getLanguageDirection } from '../../helpers/util';
10+
import { formatLocalizedDecimal, getLanguageDirection, parseLocalizedDecimal } from '../../helpers/util';
1111

1212
/** Properties for creating custom steps in a slider. These properties should be passed in as
1313
* an object within an array to the slider component's customSteps property.
@@ -93,6 +93,8 @@ export interface SliderProps extends Omit<React.HTMLProps<HTMLDivElement>, 'onCh
9393
thumbAriaValueText?: string;
9494
/** Current value of the slider. */
9595
value?: number;
96+
/** Locale string used for input formatting and parsing. See https://developer.mozilla.org/en-US/docs/Glossary/BCP_47_language_tag for more information. */
97+
locale?: string;
9698
}
9799

98100
const getPercentage = (current: number, max: number) => (100 * current) / max;
@@ -126,13 +128,22 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
126128
showBoundaries = true,
127129
'aria-describedby': ariaDescribedby,
128130
'aria-labelledby': ariaLabelledby,
131+
locale,
129132
...props
130133
}: SliderProps) => {
131134
const sliderRailRef = useRef<HTMLDivElement>(undefined);
132135
const thumbRef = useRef<HTMLDivElement>(undefined);
136+
const isInputFocusedRef = useRef(false);
137+
let formatter: Intl.NumberFormat;
138+
try {
139+
formatter = new Intl.NumberFormat(locale);
140+
} catch {
141+
formatter = new Intl.NumberFormat(); // grabs default locale
142+
}
133143

134144
const [localValue, setValue] = useState(value);
135145
const [localInputValue, setLocalInputValue] = useState(inputValue);
146+
const [inputDisplayValue, setInputDisplayValue] = useState(() => formatLocalizedDecimal(inputValue, formatter));
136147

137148
let isRTL: boolean;
138149

@@ -144,46 +155,107 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
144155
setValue(value);
145156
}, [value]);
146157

158+
const updateInputDisplay = useCallback((numericValue: number) => {
159+
setInputDisplayValue(formatLocalizedDecimal(numericValue, formatter));
160+
}, []);
161+
162+
const updateInputFromSliderValue = useCallback(
163+
(numericValue: number) => {
164+
if (!isInputVisible) {
165+
return;
166+
}
167+
168+
setLocalInputValue(numericValue);
169+
if (!isInputFocusedRef.current) {
170+
updateInputDisplay(numericValue);
171+
}
172+
},
173+
[isInputVisible, updateInputDisplay]
174+
);
175+
176+
const setLocalInputValueWithDisplay = useCallback<React.Dispatch<React.SetStateAction<number>>>(
177+
(nextValue) => {
178+
setLocalInputValue((previousValue) => {
179+
const resolvedValue = typeof nextValue === 'function' ? nextValue(previousValue) : nextValue;
180+
181+
if (!isInputFocusedRef.current) {
182+
updateInputDisplay(resolvedValue);
183+
}
184+
185+
return resolvedValue;
186+
});
187+
},
188+
[updateInputDisplay]
189+
);
190+
147191
useEffect(() => {
148-
setLocalInputValue(inputValue);
149-
}, [inputValue]);
192+
if (!isInputFocusedRef.current) {
193+
setLocalInputValue(inputValue);
194+
updateInputDisplay(inputValue);
195+
}
196+
}, [inputValue, updateInputDisplay]);
150197

151198
let diff = 0;
152199
let snapValue: number;
153200

154201
// calculate style value percentage
155202
const stylePercent = ((localValue - min) * 100) / (max - min);
156203
const style = { [cssSliderValue.name]: `${stylePercent}%` } as React.CSSProperties;
157-
const widthChars = useMemo(() => localInputValue.toString().length, [localInputValue]);
204+
const widthChars = useMemo(() => inputDisplayValue.length || 1, [inputDisplayValue]);
158205
const inputStyle = { [cssFormControlWidthChars.name]: widthChars } as React.CSSProperties;
159206

160207
const onChangeHandler = (event: React.FormEvent<HTMLInputElement>, value: string) => {
161-
const newValue = Number(value);
162-
setLocalInputValue(newValue);
208+
setInputDisplayValue(value);
163209

164-
isInputLive && onChange && onChange(event, localValue, newValue, setLocalInputValue);
210+
const parsedValue = parseLocalizedDecimal(value, formatter);
211+
212+
if (!Number.isNaN(parsedValue)) {
213+
setLocalInputValue(parsedValue);
214+
isInputLive && onChange && onChange(event, localValue, parsedValue, setLocalInputValueWithDisplay);
215+
}
165216
};
166217

167218
const handleKeyPressOnInput = (event: React.KeyboardEvent) => {
168219
if (event.key === 'Enter') {
169220
event.preventDefault();
221+
const parsedValue = parseLocalizedDecimal(inputDisplayValue, formatter);
222+
223+
if (!Number.isNaN(parsedValue)) {
224+
setLocalInputValue(parsedValue);
225+
updateInputDisplay(parsedValue);
226+
}
227+
170228
if (onChange) {
171-
onChange(event, localValue, localInputValue, setLocalInputValue);
229+
onChange(
230+
event,
231+
localValue,
232+
Number.isNaN(parsedValue) ? localInputValue : parsedValue,
233+
setLocalInputValueWithDisplay
234+
);
172235
}
173236
}
174237
};
175238

176-
const onInputFocus = (e: any) => {
239+
const onInputFocus = (e: React.SyntheticEvent<HTMLInputElement>) => {
177240
e.stopPropagation();
241+
isInputFocusedRef.current = true;
178242
};
179243

180244
const onThumbClick = () => {
181245
thumbRef.current.focus();
182246
};
183247

184248
const onBlur = (event: React.FocusEvent<HTMLInputElement>) => {
249+
isInputFocusedRef.current = false;
250+
251+
const parsedValue = parseLocalizedDecimal(inputDisplayValue, formatter);
252+
const resolvedInputValue = Number.isNaN(parsedValue) ? localInputValue : parsedValue;
253+
254+
setLocalInputValue(resolvedInputValue);
255+
updateInputDisplay(resolvedInputValue);
256+
185257
if (onChange) {
186-
onChange(event, localValue, localInputValue, setLocalInputValue);
258+
onChange(event, localValue, resolvedInputValue, setLocalInputValueWithDisplay);
187259
}
188260
};
189261

@@ -240,8 +312,9 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
240312
if (snapValue && !areCustomStepsContinuous) {
241313
thumbRef.current.style.setProperty(cssSliderValue.name, `${snapValue}%`);
242314
setValue(snapValue);
315+
updateInputFromSliderValue(snapValue);
243316
if (onChange) {
244-
onChange(e, snapValue);
317+
onChange(e, snapValue, undefined, setLocalInputValueWithDisplay);
245318
}
246319
}
247320
};
@@ -308,16 +381,22 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
308381
}
309382

310383
// Call onchange callback
384+
const resolvedValue = snapValue !== undefined ? snapValue : newValue;
385+
updateInputFromSliderValue(resolvedValue);
386+
311387
if (onChange) {
312-
if (snapValue !== undefined) {
313-
onChange(e, snapValue);
314-
} else {
315-
onChange(e, newValue);
316-
}
388+
onChange(e, resolvedValue, undefined, setLocalInputValueWithDisplay);
317389
}
318390
};
319391

320-
const callbackThumbMove = useCallback(handleThumbMove, [min, max, customSteps, onChange]);
392+
const callbackThumbMove = useCallback(handleThumbMove, [
393+
min,
394+
max,
395+
customSteps,
396+
onChange,
397+
updateInputFromSliderValue,
398+
setLocalInputValueWithDisplay
399+
]);
321400
const callbackThumbUp = useCallback(handleThumbDragEnd, [min, max, customSteps, onChange]);
322401

323402
const handleThumbKeys = (e: React.KeyboardEvent) => {
@@ -373,8 +452,9 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
373452
if (newValue !== localValue) {
374453
thumbRef.current.style.setProperty(cssSliderValue.name, `${newValue}%`);
375454
setValue(newValue);
455+
updateInputFromSliderValue(newValue);
376456
if (onChange) {
377-
onChange(e, newValue);
457+
onChange(e, newValue, undefined, setLocalInputValueWithDisplay);
378458
}
379459
}
380460
};
@@ -383,8 +463,9 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
383463
const textInput = (
384464
<TextInput
385465
isDisabled={isDisabled}
386-
type="number"
387-
value={localInputValue}
466+
type="text"
467+
inputMode="decimal"
468+
value={inputDisplayValue}
388469
aria-label={inputAriaLabel}
389470
onKeyDown={handleKeyPressOnInput}
390471
onChange={onChangeHandler}

packages/react-core/src/components/Slider/__tests__/Slider.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,24 @@ test('renders slider with thumbAriaValueText', () => {
124124

125125
expect(slider).toHaveAttribute('aria-valuetext', 'Half capacity');
126126
});
127+
128+
test('displays localized decimal separator based on language', () => {
129+
render(<Slider value={50.2} isInputVisible inputValue={50.2} locale="de-DE" />);
130+
131+
expect(screen.getByRole('textbox', { name: 'Slider value input' })).toHaveValue('50,2');
132+
});
133+
134+
test('accepts comma decimal input and normalizes on blur', async () => {
135+
const user = userEvent.setup();
136+
const onChange = jest.fn();
137+
138+
render(<Slider value={50} isInputVisible inputValue={50} locale="de-DE" onChange={onChange} />);
139+
140+
const input = screen.getByRole('textbox', { name: 'Slider value input' });
141+
await user.clear(input);
142+
await user.type(input, '62,5');
143+
await user.tab();
144+
145+
expect(input).toHaveValue('62,5');
146+
expect(onChange).toHaveBeenLastCalledWith(expect.any(Object), 50, 62.5, expect.any(Function));
147+
});

packages/react-core/src/components/Slider/__tests__/__snapshots__/Slider.test.tsx.snap

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ exports[`slider renders continuous slider 1`] = `
6565
data-ouia-component-id="OUIA-Generated-TextInputBase-:r1:"
6666
data-ouia-component-type="PF6/TextInput"
6767
data-ouia-safe="true"
68-
type="number"
68+
inputmode="decimal"
69+
type="text"
6970
value="50"
7071
/>
7172
</span>
@@ -259,7 +260,8 @@ exports[`slider renders discrete slider 1`] = `
259260
data-ouia-component-id="OUIA-Generated-TextInputBase-:r3:"
260261
data-ouia-component-type="PF6/TextInput"
261262
data-ouia-safe="true"
262-
type="number"
263+
inputmode="decimal"
264+
type="text"
263265
value="50"
264266
/>
265267
</span>
@@ -431,7 +433,8 @@ exports[`slider renders slider with input 1`] = `
431433
data-ouia-component-id="OUIA-Generated-TextInputBase-:r5:"
432434
data-ouia-component-type="PF6/TextInput"
433435
data-ouia-safe="true"
434-
type="number"
436+
inputmode="decimal"
437+
type="text"
435438
value="50"
436439
/>
437440
</span>
@@ -521,7 +524,8 @@ exports[`slider renders slider with input above thumb 1`] = `
521524
data-ouia-component-id="OUIA-Generated-TextInputBase-:r7:"
522525
data-ouia-component-type="PF6/TextInput"
523526
data-ouia-safe="true"
524-
type="number"
527+
inputmode="decimal"
528+
type="text"
525529
value="50"
526530
/>
527531
</span>

packages/react-core/src/helpers/__tests__/util.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import {
22
capitalize,
3+
formatLocalizedDecimal,
4+
formatBreakpointMods,
5+
getElementLocale,
36
getUniqueId,
47
debounce,
58
isElementInView,
9+
parseLocalizedDecimal,
610
sideElementIsOutOfView,
711
fillTemplate,
8-
pluralize,
9-
formatBreakpointMods
12+
pluralize
1013
} from '../util';
1114
import { SIDE } from '../constants';
1215
import styles from '@patternfly/react-styles/css/layouts/Flex/flex';
@@ -110,3 +113,19 @@ test('formatBreakpointMods', () => {
110113
expect(formatBreakpointMods({ md: 'spacerNone' }, styles)).toEqual('pf-m-spacer-none-on-md');
111114
expect(formatBreakpointMods({ default: 'column', lg: 'row' }, styles)).toEqual('pf-m-column pf-m-row-on-lg');
112115
});
116+
117+
test('parseLocalizedDecimal accepts dot and comma decimal separators', () => {
118+
const enFormatter = new Intl.NumberFormat('en');
119+
const esFormatter = new Intl.NumberFormat('es');
120+
121+
expect(parseLocalizedDecimal('50.2', enFormatter)).toBe(50.2);
122+
expect(parseLocalizedDecimal('50,2', esFormatter)).toBe(50.2);
123+
expect(parseLocalizedDecimal('1.234,56', esFormatter)).toBe(1234.56);
124+
expect(parseLocalizedDecimal('1,234.56', enFormatter)).toBe(1234.56);
125+
expect(parseLocalizedDecimal('', enFormatter)).toBeNaN();
126+
});
127+
128+
test('formatLocalizedDecimal uses locale decimal separator', () => {
129+
expect(formatLocalizedDecimal(50.2, new Intl.NumberFormat('en-US'))).toBe('50.2');
130+
expect(formatLocalizedDecimal(50.2, new Intl.NumberFormat('de-DE'))).toBe('50,2');
131+
});

packages/react-core/src/helpers/util.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,3 +590,59 @@ export const getInlineStartProperty = (
590590
const widthProperty: 'offsetWidth' | 'clientWidth' | 'scrollWidth' = `${inlineType}Width`;
591591
return ancestorElement[widthProperty] - (targetElement[inlineProperty] + targetElement[widthProperty]);
592592
};
593+
594+
/**
595+
* Parses a decimal input string, accepting both comma and dot as the decimal separator.
596+
* Given the locale, it discovers the locale's separators and integers using a reference value.
597+
*
598+
* @param {string} value - The input string to parse
599+
* @param {Intl.NumberFormat} formatter - The Intl.NumberFormat instance to use for formatting
600+
* @returns {number} - The parsed number, or NaN if the input is not a valid number
601+
*/
602+
export const parseLocalizedDecimal = (value: string, formatter: Intl.NumberFormat): number => {
603+
if (typeof value !== 'string' || !value.trim()) {
604+
return NaN;
605+
}
606+
607+
const parts = formatter.formatToParts(12345.6);
608+
const groupSymbol = parts.find((p) => p.type === 'group')?.value || ',';
609+
const decimalSymbol = parts.find((p) => p.type === 'decimal')?.value || '.';
610+
611+
// in case of non-Arabic numerals
612+
const digitParts = formatter.formatToParts(1234567890);
613+
const localDigits = digitParts
614+
.filter((p) => p.type === 'integer')
615+
.map((p) => p.value)
616+
.join('');
617+
let normalizedString = value;
618+
if (localDigits.length === 10 && localDigits !== '1234567890') {
619+
const standardDigits = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
620+
const digitMap = new Map();
621+
[...localDigits].forEach((char, idx) => digitMap.set(char, standardDigits[idx]));
622+
normalizedString = [...value].map((char) => digitMap.get(char) || char).join('');
623+
}
624+
625+
const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
626+
627+
const cleaned = normalizedString
628+
.replace(new RegExp(escapeRegex(groupSymbol), 'g'), '')
629+
.replace(new RegExp(escapeRegex(decimalSymbol), 'g'), '.')
630+
.replace(/[^0-9.-]/g, '');
631+
632+
return parseFloat(cleaned);
633+
};
634+
635+
/**
636+
* Formats a number using the decimal separator for the given locale.
637+
*
638+
* @param {number} value - The number to format
639+
* @param {Intl.NumberFormat} formatter - Intl.NumberFormat instance to use for formatting
640+
* @returns {string} - The formatted number string
641+
*/
642+
export const formatLocalizedDecimal = (value: number, formatter: Intl.NumberFormat): string => {
643+
if (Number.isNaN(value)) {
644+
return '';
645+
}
646+
647+
return formatter.format(value);
648+
};

packages/tsconfig.base.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"jsx": "react-jsx",
55
"lib": [
66
"es2015",
7+
"es2018.intl",
78
"dom"
89
],
910
"target": "es2015",

0 commit comments

Comments
 (0)