Skip to content

Commit 58847e0

Browse files
committed
Fix edge cases around text input in slider inputs
1 parent 2a11585 commit 58847e0

File tree

2 files changed

+395
-55
lines changed

2 files changed

+395
-55
lines changed

components/dash-core-components/src/fragments/RangeSlider.tsx

Lines changed: 152 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export default function RangeSlider(props: RangeSliderProps) {
4949
const [showInputs, setShowInputs] = useState<boolean>(value.length === 2);
5050

5151
const sliderRef = useRef<HTMLDivElement>(null);
52+
const inputRef = useRef<HTMLInputElement>(null);
5253

5354
// Handle initial mount - equivalent to componentWillMount
5455
useEffect(() => {
@@ -192,9 +193,95 @@ export default function RangeSlider(props: RangeSliderProps) {
192193
minWidth,
193194
Math.min(maxWidth, charBasedWidth)
194195
);
195-
return `${calculatedWidth}px`;
196+
197+
// Add padding if box-sizing is border-box
198+
let inputPadding = 0;
199+
if (inputRef.current) {
200+
const computedStyle = window.getComputedStyle(inputRef.current);
201+
if (computedStyle.boxSizing === 'border-box') {
202+
const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0;
203+
const paddingRight =
204+
parseFloat(computedStyle.paddingRight) || 0;
205+
inputPadding = paddingLeft + paddingRight;
206+
}
207+
}
208+
209+
const totalWidth = calculatedWidth + inputPadding;
210+
211+
return `${totalWidth}px`;
196212
}, [sliderWidth, minMaxValues.min_mark, minMaxValues.max_mark]);
197213

214+
const valueIsValid = (val: number): boolean => {
215+
// Check if value is within min/max bounds
216+
if (val < minMaxValues.min_mark || val > minMaxValues.max_mark) {
217+
return false;
218+
}
219+
220+
// If step is defined, check if value aligns with step
221+
if (stepValue !== undefined) {
222+
const min = minMaxValues.min_mark;
223+
const offset = val - min;
224+
const remainder = Math.abs(offset % stepValue);
225+
const epsilon = 0.0001; // tolerance for floating point comparison
226+
if (remainder > epsilon && remainder < stepValue - epsilon) {
227+
return false;
228+
}
229+
}
230+
231+
// If step is null and marks exist, value must match a mark
232+
if (
233+
step === null &&
234+
processedMarks &&
235+
typeof processedMarks === 'object'
236+
) {
237+
const markValues = Object.keys(processedMarks).map(Number);
238+
const epsilon = 0.0001;
239+
return markValues.some(mark => Math.abs(val - mark) < epsilon);
240+
}
241+
242+
return true;
243+
};
244+
245+
const constrainToValidValue = (val: number): number => {
246+
// First constrain to min/max bounds
247+
let constrained = Math.max(
248+
minMaxValues.min_mark,
249+
Math.min(minMaxValues.max_mark, val)
250+
);
251+
252+
// If step is null and marks exist, snap to nearest mark
253+
if (
254+
step === null &&
255+
processedMarks &&
256+
typeof processedMarks === 'object'
257+
) {
258+
return snapToNearestMark(constrained, processedMarks);
259+
}
260+
261+
// If step is defined, round to nearest step
262+
if (stepValue !== undefined) {
263+
const min = minMaxValues.min_mark;
264+
const steps = Math.round((constrained - min) / stepValue);
265+
constrained = min + steps * stepValue;
266+
267+
// Round to avoid floating point precision issues
268+
// Determine decimal places from step value
269+
const stepStr = stepValue.toString();
270+
const decimalPlaces = stepStr.includes('.')
271+
? stepStr.split('.')[1].length
272+
: 0;
273+
constrained = Number(constrained.toFixed(decimalPlaces));
274+
275+
// Ensure we stay within bounds after rounding
276+
constrained = Math.max(
277+
minMaxValues.min_mark,
278+
Math.min(minMaxValues.max_mark, constrained)
279+
);
280+
}
281+
282+
return constrained;
283+
};
284+
198285
const handleValueChange = (newValue: number[]) => {
199286
let adjustedValue = newValue;
200287

@@ -233,26 +320,25 @@ export default function RangeSlider(props: RangeSliderProps) {
233320
type="number"
234321
className="dash-input-container dash-range-slider-input dash-range-slider-min-input"
235322
style={{width: inputWidth}}
236-
value={value[0] ?? ''}
323+
value={isNaN(value[0]) ? '' : value[0]}
237324
onChange={e => {
238325
const inputValue = e.target.value;
239-
// Allow empty string (user is clearing the field)
240-
if (inputValue === '') {
241-
// Don't update props while user is typing, just update local state
242-
setValue([null as any, value[1]]);
243-
} else {
244-
const newMin = parseFloat(inputValue);
245-
if (!isNaN(newMin)) {
246-
const newValue = [newMin, value[1]];
247-
setValue(newValue);
248-
if (updatemode === 'drag') {
249-
setProps({
250-
value: newValue,
251-
drag_value: newValue,
252-
});
253-
} else {
254-
setProps({drag_value: newValue});
255-
}
326+
327+
// Parse the input value
328+
const newMin = parseFloat(inputValue);
329+
const newValue = [newMin, value[1]];
330+
setValue(newValue);
331+
// Only update props if value is valid
332+
if (valueIsValid(newMin)) {
333+
if (updatemode === 'drag') {
334+
setProps({
335+
value: newValue,
336+
drag_value: newValue,
337+
});
338+
} else {
339+
setProps({
340+
drag_value: newValue,
341+
});
256342
}
257343
}
258344
}}
@@ -262,21 +348,25 @@ export default function RangeSlider(props: RangeSliderProps) {
262348

263349
// If empty, default to current value or min_mark
264350
if (inputValue === '') {
265-
newMin = value[0] ?? minMaxValues.min_mark;
351+
newMin = isNaN(value[0])
352+
? minMaxValues.min_mark
353+
: value[0];
266354
} else {
267355
newMin = parseFloat(inputValue);
268356
newMin = isNaN(newMin)
269357
? minMaxValues.min_mark
270358
: newMin;
271359
}
272360

273-
const constrainedMin = Math.max(
274-
minMaxValues.min_mark,
275-
Math.min(
276-
value[1] ?? minMaxValues.max_mark,
277-
newMin
278-
)
361+
// Constrain to not exceed the max value
362+
newMin = Math.min(
363+
value[1] ?? minMaxValues.max_mark,
364+
newMin
279365
);
366+
367+
// Snap to valid value (respecting step and marks)
368+
const constrainedMin =
369+
constrainToValidValue(newMin);
280370
const newValue = [constrainedMin, value[1]];
281371
setValue(newValue);
282372
if (updatemode === 'mouseup') {
@@ -285,39 +375,41 @@ export default function RangeSlider(props: RangeSliderProps) {
285375
}}
286376
pattern="^\\d*\\.?\\d*$"
287377
min={minMaxValues.min_mark}
288-
max={value[1]}
378+
max={isNaN(value[1]) ? max : value[1]}
289379
step={step || undefined}
290380
disabled={disabled}
291381
/>
292382
)}
293383
{showInputs && !vertical && (
294384
<input
385+
ref={inputRef}
295386
type="number"
296387
className="dash-input-container dash-range-slider-input dash-range-slider-max-input"
297388
style={{width: inputWidth}}
298-
value={value[value.length - 1] ?? ''}
389+
value={
390+
isNaN(value[value.length - 1])
391+
? ''
392+
: value[value.length - 1]
393+
}
299394
onChange={e => {
300395
const inputValue = e.target.value;
301-
// Allow empty string (user is clearing the field)
302-
if (inputValue === '') {
303-
// Don't update props while user is typing, just update local state
304-
const newValue = [...value];
305-
newValue[newValue.length - 1] = '' as any;
306-
setValue(newValue);
307-
} else {
308-
const newMax = parseFloat(inputValue);
309-
const constrainedMax = Math.max(
310-
minMaxValues.min_mark,
311-
Math.min(minMaxValues.max_mark, newMax)
312-
);
313-
314-
if (newMax === constrainedMax) {
315-
const newValue = [...value];
316-
newValue[newValue.length - 1] = newMax;
396+
397+
// Parse the input value
398+
const newMax = parseFloat(inputValue);
399+
const newValue = [...value];
400+
newValue[newValue.length - 1] = newMax;
401+
setValue(newValue);
402+
// Only update props if value is valid
403+
if (valueIsValid(newMax)) {
404+
if (updatemode === 'drag') {
317405
setProps({
318406
value: newValue,
319407
drag_value: newValue,
320408
});
409+
} else {
410+
setProps({
411+
drag_value: newValue,
412+
});
321413
}
322414
}
323415
}}
@@ -327,23 +419,24 @@ export default function RangeSlider(props: RangeSliderProps) {
327419

328420
// If empty, default to current value or max_mark
329421
if (inputValue === '') {
330-
newMax =
331-
value[value.length - 1] ??
332-
minMaxValues.max_mark;
422+
newMax = isNaN(value[value.length - 1])
423+
? minMaxValues.max_mark
424+
: value[value.length - 1];
333425
} else {
334426
newMax = parseFloat(inputValue);
335427
newMax = isNaN(newMax)
336428
? minMaxValues.max_mark
337429
: newMax;
338430
}
339-
340-
const constrainedMax = Math.min(
341-
minMaxValues.max_mark,
342-
Math.max(
343-
value[0] ?? minMaxValues.min_mark,
344-
newMax
345-
)
431+
// Constrain to not be less than the min value
432+
newMax = Math.max(
433+
value[0] ?? minMaxValues.min_mark,
434+
newMax
346435
);
436+
437+
// Snap to valid value (respecting step and marks)
438+
const constrainedMax =
439+
constrainToValidValue(newMax);
347440
const newValue = [...value];
348441
newValue[newValue.length - 1] = constrainedMax;
349442
setValue(newValue);
@@ -357,7 +450,11 @@ export default function RangeSlider(props: RangeSliderProps) {
357450
? minMaxValues.min_mark
358451
: value[0]
359452
}
360-
max={minMaxValues.max_mark}
453+
max={
454+
isNaN(minMaxValues.max_mark)
455+
? max
456+
: minMaxValues.max_mark
457+
}
361458
step={step || undefined}
362459
disabled={disabled}
363460
/>

0 commit comments

Comments
 (0)