Skip to content
Merged
14 changes: 12 additions & 2 deletions components/dash-core-components/src/components/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,11 @@ function Input({
(direction: 'increment' | 'decrement') => {
const currentValue = parseFloat(input.current.value) || 0;
const stepAsNum = parseFloat(step as string) || 1;

// Count decimal places to avoid floating point precision issues
const decimalPlaces = (stepAsNum.toString().split('.')[1] || '')
.length;

const newValue =
direction === 'increment'
? currentValue + stepAsNum
Expand All @@ -196,8 +201,13 @@ function Input({
);
}

input.current.value = constrainedValue.toString();
setValue(constrainedValue.toString());
// Round to the step's decimal precision
const roundedValue = parseFloat(
constrainedValue.toFixed(decimalPlaces)
);

input.current.value = roundedValue.toString();
setValue(roundedValue.toString());
onEvent();
},
[step, props.min, props.max, onEvent]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@
z-index: 500;
box-shadow: 0px 10px 38px -10px var(--Dash-Shading-Strong),
0px 10px 20px -15px var(--Dash-Shading-Weak);
overscroll-behavior: contain;
}

.dash-datepicker-calendar-wrapper {
Expand Down Expand Up @@ -226,8 +227,3 @@
width: 20px;
height: 20px;
}

/* Override Radix's position: fixed to use position: absolute when using custom container */
div[data-radix-popper-content-wrapper]:has(.dash-datepicker-content) {
position: absolute !important;
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
z-index: 500;
box-shadow: 0px 10px 38px -10px var(--Dash-Shading-Strong),
0px 10px 20px -15px var(--Dash-Shading-Weak);
overscroll-behavior: contain;
}

.dash-dropdown-value-count,
Expand Down Expand Up @@ -235,8 +236,3 @@
.dash-dropdown-wrapper {
position: relative;
}

/* Override Radix's position: fixed to use position: absolute when using custom container */
div[data-radix-popper-content-wrapper]:has(.dash-dropdown-content) {
position: absolute !important;
}
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ const DatePickerRange = ({
id={start_date_id || accessibleId}
inputClassName="dash-datepicker-input dash-datepicker-start-date"
value={startInputValue}
onChange={e => setStartInputValue(e.target.value)}
onChange={e => setStartInputValue(e.target?.value)}
onKeyDown={handleStartInputKeyDown}
onFocus={() => {
if (isCalendarOpen) {
Expand All @@ -354,7 +354,7 @@ const DatePickerRange = ({
id={end_date_id || accessibleId + '-end-date'}
inputClassName="dash-datepicker-input dash-datepicker-end-date"
value={endInputValue}
onChange={e => setEndInputValue(e.target.value)}
onChange={e => setEndInputValue(e.target?.value)}
onKeyDown={handleEndInputKeyDown}
onFocus={() => {
if (isCalendarOpen) {
Expand Down Expand Up @@ -384,9 +384,6 @@ const DatePickerRange = ({
className="dash-datepicker-content"
align="start"
sideOffset={5}
collisionBoundary={containerRef.current?.closest(
'#_dash-app-content'
)}
onOpenAutoFocus={e => e.preventDefault()}
onCloseAutoFocus={e => {
e.preventDefault();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ const DatePickerSingle = ({
id={accessibleId}
inputClassName="dash-datepicker-input dash-datepicker-end-date"
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onChange={e => setInputValue(e.target?.value)}
onKeyDown={handleInputKeyDown}
placeholder={placeholder}
disabled={disabled}
Expand All @@ -203,9 +203,6 @@ const DatePickerSingle = ({
className="dash-datepicker-content"
align="start"
sideOffset={5}
collisionBoundary={containerRef.current?.closest(
'#_dash-app-content'
)}
onOpenAutoFocus={e => e.preventDefault()}
onCloseAutoFocus={e => {
e.preventDefault();
Expand Down
3 changes: 0 additions & 3 deletions components/dash-core-components/src/fragments/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -458,9 +458,6 @@ const Dropdown = (props: DropdownProps) => {
className="dash-dropdown-content"
align="start"
sideOffset={5}
collisionBoundary={positioningContainerRef.current?.closest(
'#_dash-app-content'
)}
onOpenAutoFocus={e => e.preventDefault()}
onKeyDown={handleKeyDown}
style={{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import time
import sys
import pytest
from dash import Dash, Input, Output, html, dcc
from selenium.webdriver.common.keys import Keys

Expand Down Expand Up @@ -148,6 +149,93 @@ def update_output(val):
assert dash_dcc.get_logs() == []


@pytest.mark.parametrize("step", [0.1, 0.01, 0.001, 0.0001])
def test_inni006_stepper_floating_point_precision(dash_dcc, step):
"""Test that stepper increments/decrements with decimal steps don't accumulate floating point errors."""

app = Dash(__name__)
app.layout = html.Div(
[
dcc.Input(id="decimal-input", value=0, type="number", step=step),
html.Div(id="output"),
]
)

@app.callback(Output("output", "children"), [Input("decimal-input", "value")])
def update_output(val):
return val

dash_dcc.start_server(app)
increment_btn = dash_dcc.find_element(".dash-stepper-increment")
decrement_btn = dash_dcc.find_element(".dash-stepper-decrement")

# Determine decimal places for formatting
decimal_places = len(str(step).split(".")[1]) if "." in str(step) else 0
num_clicks = 9

# Test increment: without precision fix, accumulates floating point errors (e.g., 0.30000000000000004)
for i in range(1, num_clicks + 1):
increment_btn.click()
expected = format(step * i, f".{decimal_places}f")
dash_dcc.wait_for_text_to_equal("#output", expected)

# Test decrement: should go back down through the same values
for i in range(num_clicks - 1, 0, -1):
decrement_btn.click()
expected = format(step * i, f".{decimal_places}f")
dash_dcc.wait_for_text_to_equal("#output", expected)

# One more decrement to get back to 0
decrement_btn.click()
dash_dcc.wait_for_text_to_equal("#output", "0")

assert dash_dcc.get_logs() == []


@pytest.mark.parametrize("step", [0.00001, 0.000001])
def test_inni007_stepper_very_small_steps(dash_dcc, step):
"""Test that stepper works correctly with very small decimal steps."""

app = Dash(__name__)
app.layout = html.Div(
[
dcc.Input(id="decimal-input", value=0, type="number", step=step),
html.Div(id="output"),
]
)

@app.callback(Output("output", "children"), [Input("decimal-input", "value")])
def update_output(val):
return val

dash_dcc.start_server(app)
increment_btn = dash_dcc.find_element(".dash-stepper-increment")
decrement_btn = dash_dcc.find_element(".dash-stepper-decrement")

# For very small steps, format with enough precision then strip trailing zeros
step_str = f"{step:.10f}".rstrip("0").rstrip(".")
decimal_places = len(step_str.split(".")[1]) if "." in step_str else 0
num_clicks = 5

# Test increment
for i in range(1, num_clicks + 1):
increment_btn.click()
expected = f"{step * i:.{decimal_places}f}".rstrip("0").rstrip(".")
dash_dcc.wait_for_text_to_equal("#output", expected)

# Test decrement
for i in range(num_clicks - 1, 0, -1):
decrement_btn.click()
expected = f"{step * i:.{decimal_places}f}".rstrip("0").rstrip(".")
dash_dcc.wait_for_text_to_equal("#output", expected)

# One more decrement to get back to 0
decrement_btn.click()
dash_dcc.wait_for_text_to_equal("#output", "0")

assert dash_dcc.get_logs() == []


def test_inni010_valid_numbers(dash_dcc, ninput_app):
dash_dcc.start_server(ninput_app)
for num, op in (
Expand Down
Loading