Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 101 additions & 20 deletions packages/react-core/src/components/Slider/Slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -93,6 +93,8 @@ export interface SliderProps extends Omit<React.HTMLProps<HTMLDivElement>, '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;
Expand Down Expand Up @@ -126,13 +128,22 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
showBoundaries = true,
'aria-describedby': ariaDescribedby,
'aria-labelledby': ariaLabelledby,
locale,
...props
}: SliderProps) => {
const sliderRailRef = useRef<HTMLDivElement>(undefined);
const thumbRef = useRef<HTMLDivElement>(undefined);
const isInputFocusedRef = useRef(false);
let formatter: Intl.NumberFormat;
try {
formatter = new Intl.NumberFormat(locale);
} catch {
formatter = new Intl.NumberFormat(); // grabs default locale
}
Comment on lines +137 to +142

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the try catch block necessary? The default locale should be grabbed if locale prop is unedfined/not valid


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

let isRTL: boolean;

Expand All @@ -144,46 +155,107 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
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<React.Dispatch<React.SetStateAction<number>>>(
(nextValue) => {
setLocalInputValue((previousValue) => {
const resolvedValue = typeof nextValue === 'function' ? nextValue(previousValue) : nextValue;

if (!isInputFocusedRef.current) {
updateInputDisplay(resolvedValue);
}

return resolvedValue;
});
},
[updateInputDisplay]
);
Comment thread
rebeccaalpert marked this conversation as resolved.

useEffect(() => {
setLocalInputValue(inputValue);
}, [inputValue]);
if (!isInputFocusedRef.current) {
setLocalInputValue(inputValue);
updateInputDisplay(inputValue);
}
}, [inputValue, updateInputDisplay]);
Comment thread
rebeccaalpert marked this conversation as resolved.

let diff = 0;
let snapValue: number;

// 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<HTMLInputElement>, 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<HTMLInputElement>) => {
e.stopPropagation();
isInputFocusedRef.current = true;
};

const onThumbClick = () => {
thumbRef.current.focus();
};

const onBlur = (event: React.FocusEvent<HTMLInputElement>) => {
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);
}
};

Expand Down Expand Up @@ -240,8 +312,9 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
if (snapValue && !areCustomStepsContinuous) {
thumbRef.current.style.setProperty(cssSliderValue.name, `${snapValue}%`);
setValue(snapValue);
updateInputFromSliderValue(snapValue);
if (onChange) {
onChange(e, snapValue);
onChange(e, snapValue, undefined, setLocalInputValueWithDisplay);
}
}
};
Expand Down Expand Up @@ -308,16 +381,22 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
}

// 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) => {
Expand Down Expand Up @@ -373,8 +452,9 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
if (newValue !== localValue) {
thumbRef.current.style.setProperty(cssSliderValue.name, `${newValue}%`);
setValue(newValue);
updateInputFromSliderValue(newValue);
if (onChange) {
onChange(e, newValue);
onChange(e, newValue, undefined, setLocalInputValueWithDisplay);
}
}
};
Expand All @@ -383,8 +463,9 @@ export const Slider: React.FunctionComponent<SliderProps> = ({
const textInput = (
<TextInput
isDisabled={isDisabled}
type="number"
value={localInputValue}
type="text"
inputMode="decimal"
value={inputDisplayValue}
aria-label={inputAriaLabel}
onKeyDown={handleKeyPressOnInput}
onChange={onChangeHandler}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,24 @@ test('renders slider with thumbAriaValueText', () => {

expect(slider).toHaveAttribute('aria-valuetext', 'Half capacity');
});

test('displays localized decimal separator based on language', () => {
render(<Slider value={50.2} isInputVisible inputValue={50.2} locale="de-DE" />);

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(<Slider value={50} isInputVisible inputValue={50} locale="de-DE" onChange={onChange} />);

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));
});
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
</span>
Expand Down Expand Up @@ -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"
/>
</span>
Expand Down Expand Up @@ -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"
/>
</span>
Expand Down Expand Up @@ -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"
/>
</span>
Expand Down
70 changes: 68 additions & 2 deletions packages/react-core/src/helpers/__tests__/util.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -110,3 +113,66 @@ 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 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('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');
});
Loading
Loading