Skip to content

Commit 111a2a9

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

6 files changed

Lines changed: 196 additions & 26 deletions

File tree

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

Lines changed: 91 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,16 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
126128
showBoundaries = true,
127129
'aria-describedby': ariaDescribedby,
128130
'aria-labelledby': ariaLabelledby,
131+
locale = 'en',
129132
...props
130133
}: SliderProps) => {
131134
const sliderRailRef = useRef<HTMLDivElement>(undefined);
132135
const thumbRef = useRef<HTMLDivElement>(undefined);
136+
const isInputFocusedRef = useRef(false);
133137

134138
const [localValue, setValue] = useState(value);
135139
const [localInputValue, setLocalInputValue] = useState(inputValue);
140+
const [inputDisplayValue, setInputDisplayValue] = useState(() => formatLocalizedDecimal(inputValue, 'en'));
136141

137142
let isRTL: boolean;
138143

@@ -144,46 +149,103 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
144149
setValue(value);
145150
}, [value]);
146151

152+
const updateInputDisplay = useCallback((numericValue: number) => {
153+
setInputDisplayValue(formatLocalizedDecimal(numericValue, locale));
154+
}, []);
155+
156+
const updateInputFromSliderValue = useCallback(
157+
(numericValue: number) => {
158+
setLocalInputValue(numericValue);
159+
if (!isInputFocusedRef.current) {
160+
updateInputDisplay(numericValue);
161+
}
162+
},
163+
[updateInputDisplay]
164+
);
165+
166+
const setLocalInputValueWithDisplay = useCallback<React.Dispatch<React.SetStateAction<number>>>(
167+
(nextValue) => {
168+
setLocalInputValue((previousValue) => {
169+
const resolvedValue = typeof nextValue === 'function' ? nextValue(previousValue) : nextValue;
170+
171+
if (!isInputFocusedRef.current) {
172+
updateInputDisplay(resolvedValue);
173+
}
174+
175+
return resolvedValue;
176+
});
177+
},
178+
[updateInputDisplay]
179+
);
180+
147181
useEffect(() => {
148-
setLocalInputValue(inputValue);
149-
}, [inputValue]);
182+
if (!isInputFocusedRef.current) {
183+
setLocalInputValue(inputValue);
184+
updateInputDisplay(inputValue);
185+
}
186+
}, [inputValue, updateInputDisplay]);
150187

151188
let diff = 0;
152189
let snapValue: number;
153190

154191
// calculate style value percentage
155192
const stylePercent = ((localValue - min) * 100) / (max - min);
156193
const style = { [cssSliderValue.name]: `${stylePercent}%` } as React.CSSProperties;
157-
const widthChars = useMemo(() => localInputValue.toString().length, [localInputValue]);
194+
const widthChars = useMemo(() => inputDisplayValue.length || 1, [inputDisplayValue]);
158195
const inputStyle = { [cssFormControlWidthChars.name]: widthChars } as React.CSSProperties;
159196

160197
const onChangeHandler = (event: React.FormEvent<HTMLInputElement>, value: string) => {
161-
const newValue = Number(value);
162-
setLocalInputValue(newValue);
198+
setInputDisplayValue(value);
199+
200+
const parsedValue = parseLocalizedDecimal(value, locale);
163201

164-
isInputLive && onChange && onChange(event, localValue, newValue, setLocalInputValue);
202+
if (!Number.isNaN(parsedValue)) {
203+
setLocalInputValue(parsedValue);
204+
isInputLive && onChange && onChange(event, localValue, parsedValue, setLocalInputValueWithDisplay);
205+
}
165206
};
166207

167208
const handleKeyPressOnInput = (event: React.KeyboardEvent) => {
168209
if (event.key === 'Enter') {
169210
event.preventDefault();
211+
const parsedValue = parseLocalizedDecimal(inputDisplayValue, locale);
212+
213+
if (!Number.isNaN(parsedValue)) {
214+
setLocalInputValue(parsedValue);
215+
updateInputDisplay(parsedValue);
216+
}
217+
170218
if (onChange) {
171-
onChange(event, localValue, localInputValue, setLocalInputValue);
219+
onChange(
220+
event,
221+
localValue,
222+
Number.isNaN(parsedValue) ? localInputValue : parsedValue,
223+
setLocalInputValueWithDisplay
224+
);
172225
}
173226
}
174227
};
175228

176-
const onInputFocus = (e: any) => {
229+
const onInputFocus = (e: React.SyntheticEvent<HTMLInputElement>) => {
177230
e.stopPropagation();
231+
isInputFocusedRef.current = true;
178232
};
179233

180234
const onThumbClick = () => {
181235
thumbRef.current.focus();
182236
};
183237

184238
const onBlur = (event: React.FocusEvent<HTMLInputElement>) => {
239+
isInputFocusedRef.current = false;
240+
241+
const parsedValue = parseLocalizedDecimal(inputDisplayValue, locale);
242+
const resolvedInputValue = Number.isNaN(parsedValue) ? localInputValue : parsedValue;
243+
244+
setLocalInputValue(resolvedInputValue);
245+
updateInputDisplay(resolvedInputValue);
246+
185247
if (onChange) {
186-
onChange(event, localValue, localInputValue, setLocalInputValue);
248+
onChange(event, localValue, resolvedInputValue, setLocalInputValueWithDisplay);
187249
}
188250
};
189251

@@ -240,8 +302,9 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
240302
if (snapValue && !areCustomStepsContinuous) {
241303
thumbRef.current.style.setProperty(cssSliderValue.name, `${snapValue}%`);
242304
setValue(snapValue);
305+
updateInputFromSliderValue(snapValue);
243306
if (onChange) {
244-
onChange(e, snapValue);
307+
onChange(e, snapValue, undefined, setLocalInputValueWithDisplay);
245308
}
246309
}
247310
};
@@ -308,16 +371,22 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
308371
}
309372

310373
// Call onchange callback
374+
const resolvedValue = snapValue !== undefined ? snapValue : newValue;
375+
updateInputFromSliderValue(resolvedValue);
376+
311377
if (onChange) {
312-
if (snapValue !== undefined) {
313-
onChange(e, snapValue);
314-
} else {
315-
onChange(e, newValue);
316-
}
378+
onChange(e, resolvedValue, undefined, setLocalInputValueWithDisplay);
317379
}
318380
};
319381

320-
const callbackThumbMove = useCallback(handleThumbMove, [min, max, customSteps, onChange]);
382+
const callbackThumbMove = useCallback(handleThumbMove, [
383+
min,
384+
max,
385+
customSteps,
386+
onChange,
387+
updateInputFromSliderValue,
388+
setLocalInputValueWithDisplay
389+
]);
321390
const callbackThumbUp = useCallback(handleThumbDragEnd, [min, max, customSteps, onChange]);
322391

323392
const handleThumbKeys = (e: React.KeyboardEvent) => {
@@ -373,8 +442,9 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
373442
if (newValue !== localValue) {
374443
thumbRef.current.style.setProperty(cssSliderValue.name, `${newValue}%`);
375444
setValue(newValue);
445+
updateInputFromSliderValue(newValue);
376446
if (onChange) {
377-
onChange(e, newValue);
447+
onChange(e, newValue, undefined, setLocalInputValueWithDisplay);
378448
}
379449
}
380450
};
@@ -383,8 +453,9 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
383453
const textInput = (
384454
<TextInput
385455
isDisabled={isDisabled}
386-
type="number"
387-
value={localInputValue}
456+
type="text"
457+
inputMode="decimal"
458+
value={inputDisplayValue}
388459
aria-label={inputAriaLabel}
389460
onKeyDown={handleKeyPressOnInput}
390461
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: 18 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,16 @@ 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+
expect(parseLocalizedDecimal('50.2', 'en')).toBe(50.2);
119+
expect(parseLocalizedDecimal('50,2', 'es')).toBe(50.2);
120+
expect(parseLocalizedDecimal('1.234,56', 'es')).toBe(1234.56);
121+
expect(parseLocalizedDecimal('1,234.56', 'en')).toBe(1234.56);
122+
expect(parseLocalizedDecimal('', 'en')).toBeNaN();
123+
});
124+
125+
test('formatLocalizedDecimal uses locale decimal separator', () => {
126+
expect(formatLocalizedDecimal(50.2, 'en-US')).toBe('50.2');
127+
expect(formatLocalizedDecimal(50.2, 'de-DE')).toBe('50,2');
128+
});

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,3 +590,60 @@ 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 {string} locale - The locale string to use for parsing
600+
* @returns {number} - The parsed number, or NaN if the input is not a valid number
601+
*/
602+
export const parseLocalizedDecimal = (value: string, locale: string): number => {
603+
if (typeof value !== 'string' || !value.trim()) {
604+
return NaN;
605+
}
606+
607+
const formatter = new Intl.NumberFormat(locale);
608+
const parts = formatter.formatToParts(12345.6);
609+
const groupSymbol = parts.find((p) => p.type === 'group')?.value || ',';
610+
const decimalSymbol = parts.find((p) => p.type === 'decimal')?.value || '.';
611+
612+
// in case of non-Arabic numerals
613+
const digitParts = formatter.formatToParts(1234567890);
614+
const localDigits = digitParts
615+
.filter((p) => p.type === 'integer')
616+
.map((p) => p.value)
617+
.join('');
618+
let normalizedString = value;
619+
if (localDigits.length === 10 && localDigits !== '1234567890') {
620+
const standardDigits = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
621+
const digitMap = new Map();
622+
[...localDigits].forEach((char, idx) => digitMap.set(char, standardDigits[idx]));
623+
normalizedString = [...value].map((char) => digitMap.get(char) || char).join('');
624+
}
625+
626+
const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
627+
628+
const cleaned = normalizedString
629+
.replace(new RegExp(escapeRegex(groupSymbol), 'g'), '')
630+
.replace(new RegExp(escapeRegex(decimalSymbol), 'g'), '.')
631+
.replace(/[^0-9.-]/g, '');
632+
633+
return parseFloat(cleaned);
634+
};
635+
636+
/**
637+
* Formats a number using the decimal separator for the given locale.
638+
*
639+
* @param {number} value - The number to format
640+
* @param {string} locale - The locale string to use for formatting
641+
* @returns {string} - The formatted number string
642+
*/
643+
export const formatLocalizedDecimal = (value: number, locale: string): string => {
644+
if (Number.isNaN(value)) {
645+
return '';
646+
}
647+
648+
return new Intl.NumberFormat(locale).format(value);
649+
};

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)