From 2a11585cc2d5bcfea789d46904d42b36b50eda0d Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Mon, 6 Oct 2025 11:42:32 -0600 Subject: [PATCH 1/4] ensure dropdown popover renders within the Dash app --- components/dash-core-components/src/fragments/Dropdown.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index 01c3c83b3d..6808ff7a84 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -420,7 +420,12 @@ const Dropdown = (props: DropdownProps) => { - + Date: Mon, 6 Oct 2025 11:43:41 -0600 Subject: [PATCH 2/4] Fix edge cases around text input in slider inputs --- .../src/fragments/RangeSlider.tsx | 207 +++++++++++---- .../sliders/test_sliders_keyboard_input.py | 243 ++++++++++++++++++ 2 files changed, 395 insertions(+), 55 deletions(-) create mode 100644 components/dash-core-components/tests/integration/sliders/test_sliders_keyboard_input.py diff --git a/components/dash-core-components/src/fragments/RangeSlider.tsx b/components/dash-core-components/src/fragments/RangeSlider.tsx index 90d8e7ccb3..0d151ec721 100644 --- a/components/dash-core-components/src/fragments/RangeSlider.tsx +++ b/components/dash-core-components/src/fragments/RangeSlider.tsx @@ -49,6 +49,7 @@ export default function RangeSlider(props: RangeSliderProps) { const [showInputs, setShowInputs] = useState(value.length === 2); const sliderRef = useRef(null); + const inputRef = useRef(null); // Handle initial mount - equivalent to componentWillMount useEffect(() => { @@ -192,9 +193,95 @@ export default function RangeSlider(props: RangeSliderProps) { minWidth, Math.min(maxWidth, charBasedWidth) ); - return `${calculatedWidth}px`; + + // Add padding if box-sizing is border-box + let inputPadding = 0; + if (inputRef.current) { + const computedStyle = window.getComputedStyle(inputRef.current); + if (computedStyle.boxSizing === 'border-box') { + const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0; + const paddingRight = + parseFloat(computedStyle.paddingRight) || 0; + inputPadding = paddingLeft + paddingRight; + } + } + + const totalWidth = calculatedWidth + inputPadding; + + return `${totalWidth}px`; }, [sliderWidth, minMaxValues.min_mark, minMaxValues.max_mark]); + const valueIsValid = (val: number): boolean => { + // Check if value is within min/max bounds + if (val < minMaxValues.min_mark || val > minMaxValues.max_mark) { + return false; + } + + // If step is defined, check if value aligns with step + if (stepValue !== undefined) { + const min = minMaxValues.min_mark; + const offset = val - min; + const remainder = Math.abs(offset % stepValue); + const epsilon = 0.0001; // tolerance for floating point comparison + if (remainder > epsilon && remainder < stepValue - epsilon) { + return false; + } + } + + // If step is null and marks exist, value must match a mark + if ( + step === null && + processedMarks && + typeof processedMarks === 'object' + ) { + const markValues = Object.keys(processedMarks).map(Number); + const epsilon = 0.0001; + return markValues.some(mark => Math.abs(val - mark) < epsilon); + } + + return true; + }; + + const constrainToValidValue = (val: number): number => { + // First constrain to min/max bounds + let constrained = Math.max( + minMaxValues.min_mark, + Math.min(minMaxValues.max_mark, val) + ); + + // If step is null and marks exist, snap to nearest mark + if ( + step === null && + processedMarks && + typeof processedMarks === 'object' + ) { + return snapToNearestMark(constrained, processedMarks); + } + + // If step is defined, round to nearest step + if (stepValue !== undefined) { + const min = minMaxValues.min_mark; + const steps = Math.round((constrained - min) / stepValue); + constrained = min + steps * stepValue; + + // Round to avoid floating point precision issues + // Determine decimal places from step value + const stepStr = stepValue.toString(); + const decimalPlaces = stepStr.includes('.') + ? stepStr.split('.')[1].length + : 0; + constrained = Number(constrained.toFixed(decimalPlaces)); + + // Ensure we stay within bounds after rounding + constrained = Math.max( + minMaxValues.min_mark, + Math.min(minMaxValues.max_mark, constrained) + ); + } + + return constrained; + }; + const handleValueChange = (newValue: number[]) => { let adjustedValue = newValue; @@ -233,26 +320,25 @@ export default function RangeSlider(props: RangeSliderProps) { type="number" className="dash-input-container dash-range-slider-input dash-range-slider-min-input" style={{width: inputWidth}} - value={value[0] ?? ''} + value={isNaN(value[0]) ? '' : value[0]} onChange={e => { const inputValue = e.target.value; - // Allow empty string (user is clearing the field) - if (inputValue === '') { - // Don't update props while user is typing, just update local state - setValue([null as any, value[1]]); - } else { - const newMin = parseFloat(inputValue); - if (!isNaN(newMin)) { - const newValue = [newMin, value[1]]; - setValue(newValue); - if (updatemode === 'drag') { - setProps({ - value: newValue, - drag_value: newValue, - }); - } else { - setProps({drag_value: newValue}); - } + + // Parse the input value + const newMin = parseFloat(inputValue); + const newValue = [newMin, value[1]]; + setValue(newValue); + // Only update props if value is valid + if (valueIsValid(newMin)) { + if (updatemode === 'drag') { + setProps({ + value: newValue, + drag_value: newValue, + }); + } else { + setProps({ + drag_value: newValue, + }); } } }} @@ -262,7 +348,9 @@ export default function RangeSlider(props: RangeSliderProps) { // If empty, default to current value or min_mark if (inputValue === '') { - newMin = value[0] ?? minMaxValues.min_mark; + newMin = isNaN(value[0]) + ? minMaxValues.min_mark + : value[0]; } else { newMin = parseFloat(inputValue); newMin = isNaN(newMin) @@ -270,13 +358,15 @@ export default function RangeSlider(props: RangeSliderProps) { : newMin; } - const constrainedMin = Math.max( - minMaxValues.min_mark, - Math.min( - value[1] ?? minMaxValues.max_mark, - newMin - ) + // Constrain to not exceed the max value + newMin = Math.min( + value[1] ?? minMaxValues.max_mark, + newMin ); + + // Snap to valid value (respecting step and marks) + const constrainedMin = + constrainToValidValue(newMin); const newValue = [constrainedMin, value[1]]; setValue(newValue); if (updatemode === 'mouseup') { @@ -285,39 +375,41 @@ export default function RangeSlider(props: RangeSliderProps) { }} pattern="^\\d*\\.?\\d*$" min={minMaxValues.min_mark} - max={value[1]} + max={isNaN(value[1]) ? max : value[1]} step={step || undefined} disabled={disabled} /> )} {showInputs && !vertical && ( { const inputValue = e.target.value; - // Allow empty string (user is clearing the field) - if (inputValue === '') { - // Don't update props while user is typing, just update local state - const newValue = [...value]; - newValue[newValue.length - 1] = '' as any; - setValue(newValue); - } else { - const newMax = parseFloat(inputValue); - const constrainedMax = Math.max( - minMaxValues.min_mark, - Math.min(minMaxValues.max_mark, newMax) - ); - - if (newMax === constrainedMax) { - const newValue = [...value]; - newValue[newValue.length - 1] = newMax; + + // Parse the input value + const newMax = parseFloat(inputValue); + const newValue = [...value]; + newValue[newValue.length - 1] = newMax; + setValue(newValue); + // Only update props if value is valid + if (valueIsValid(newMax)) { + if (updatemode === 'drag') { setProps({ value: newValue, drag_value: newValue, }); + } else { + setProps({ + drag_value: newValue, + }); } } }} @@ -327,23 +419,24 @@ export default function RangeSlider(props: RangeSliderProps) { // If empty, default to current value or max_mark if (inputValue === '') { - newMax = - value[value.length - 1] ?? - minMaxValues.max_mark; + newMax = isNaN(value[value.length - 1]) + ? minMaxValues.max_mark + : value[value.length - 1]; } else { newMax = parseFloat(inputValue); newMax = isNaN(newMax) ? minMaxValues.max_mark : newMax; } - - const constrainedMax = Math.min( - minMaxValues.max_mark, - Math.max( - value[0] ?? minMaxValues.min_mark, - newMax - ) + // Constrain to not be less than the min value + newMax = Math.max( + value[0] ?? minMaxValues.min_mark, + newMax ); + + // Snap to valid value (respecting step and marks) + const constrainedMax = + constrainToValidValue(newMax); const newValue = [...value]; newValue[newValue.length - 1] = constrainedMax; setValue(newValue); @@ -357,7 +450,11 @@ export default function RangeSlider(props: RangeSliderProps) { ? minMaxValues.min_mark : value[0] } - max={minMaxValues.max_mark} + max={ + isNaN(minMaxValues.max_mark) + ? max + : minMaxValues.max_mark + } step={step || undefined} disabled={disabled} /> diff --git a/components/dash-core-components/tests/integration/sliders/test_sliders_keyboard_input.py b/components/dash-core-components/tests/integration/sliders/test_sliders_keyboard_input.py new file mode 100644 index 0000000000..d8c9490fd1 --- /dev/null +++ b/components/dash-core-components/tests/integration/sliders/test_sliders_keyboard_input.py @@ -0,0 +1,243 @@ +from dash import Dash, Input, Output, dcc, html +from selenium.webdriver.common.keys import Keys + + +def test_slkb001_input_constrained_by_min_max(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Slider( + id="slider", + min=1, + max=20, + value=5, + ), + html.Div(id="value"), + html.Div(id="drag_value"), + ] + ) + + @app.callback(Output("value", "children"), [Input("slider", "value")]) + def update_output(value): + return f"value is {value}" + + @app.callback(Output("drag_value", "children"), [Input("slider", "drag_value")]) + def update_drag_value(value): + return f"drag_value is {value}" + + dash_dcc.start_server(app) + + dash_dcc.driver.set_window_size(800, 600) + dash_dcc.wait_for_text_to_equal("#value", "value is 5") + + inpt = dash_dcc.find_element("#slider .dash-range-slider-max-input") + + inpt.send_keys(Keys.BACKSPACE, 4, Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is 4") + dash_dcc.wait_for_text_to_equal("#drag_value", "drag_value is 4") + + # cannot enter a value greater than `max` + inpt.send_keys(Keys.BACKSPACE, 42, Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is 20") + + # cannot enter a value less than `min` + inpt.send_keys(Keys.ARROW_LEFT, Keys.ARROW_LEFT, "-", Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is 1") + + # cannot enter a value less than `min` + inpt.send_keys(5, Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is 15") + dash_dcc.wait_for_text_to_equal("#drag_value", "drag_value is 15") + + assert dash_dcc.get_logs() == [] + + +def test_slkb002_range_input_constrained_by_min_max(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.RangeSlider( + id="slider", + min=1, + max=20, + value=[5, 7], + ), + html.Div(id="value"), + html.Div(id="drag_value"), + ] + ) + + @app.callback(Output("value", "children"), [Input("slider", "value")]) + def update_output(value): + return f"value is {value}" + + @app.callback(Output("drag_value", "children"), [Input("slider", "drag_value")]) + def update_drag_value(value): + return f"drag_value is {value}" + + dash_dcc.start_server(app) + + dash_dcc.driver.set_window_size(800, 600) + dash_dcc.wait_for_text_to_equal("#value", "value is [5, 7]") + + min_inpt = dash_dcc.find_element("#slider .dash-range-slider-min-input") + max_inpt = dash_dcc.find_element("#slider .dash-range-slider-max-input") + + max_inpt.send_keys(Keys.BACKSPACE, 8, Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is [5, 8]") + dash_dcc.wait_for_text_to_equal("#drag_value", "drag_value is [5, 8]") + + min_inpt.send_keys(Keys.BACKSPACE, 4, Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is [4, 8]") + dash_dcc.wait_for_text_to_equal("#drag_value", "drag_value is [4, 8]") + + # cannot enter a value greater than `max` + max_inpt.send_keys(Keys.BACKSPACE, 42, Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is [4, 20]") + + # cannot enter a value less than `min` + min_inpt.send_keys(Keys.ARROW_LEFT, Keys.ARROW_LEFT, "-", Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is [1, 20]") + + # cannot enter a value less than `min` + max_inpt.send_keys(5, Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is [1, 5]") + dash_dcc.wait_for_text_to_equal("#drag_value", "drag_value is [1, 5]") + + # cannot enter a value less than `min` + min_inpt.send_keys(Keys.BACKSPACE, 7, Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is [5, 5]") + dash_dcc.wait_for_text_to_equal("#drag_value", "drag_value is [7, 5]") + + assert dash_dcc.get_logs() == [] + + +def test_slkb003_input_constrained_by_step(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Slider( + id="slider", + min=-20, + max=20, + step=5, + value=5, + ), + html.Div(id="value"), + html.Div(id="drag_value"), + ] + ) + + @app.callback(Output("value", "children"), [Input("slider", "value")]) + def update_output(value): + return f"value is {value}" + + @app.callback(Output("drag_value", "children"), [Input("slider", "drag_value")]) + def update_drag_value(value): + return f"drag_value is {value}" + + dash_dcc.start_server(app) + + dash_dcc.driver.set_window_size(800, 600) + dash_dcc.wait_for_text_to_equal("#value", "value is 5") + + inpt = dash_dcc.find_element("#slider .dash-range-slider-max-input") + + inpt.send_keys(Keys.BACKSPACE, -15, Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is -15") + dash_dcc.wait_for_text_to_equal("#drag_value", "drag_value is -15") + + inpt.send_keys(Keys.BACKSPACE, 4, Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is -15") + + inpt.send_keys(Keys.BACKSPACE, 2, Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is -10") + + inpt.send_keys(Keys.BACKSPACE, Keys.BACKSPACE, Keys.BACKSPACE, 2, Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is 0") + + inpt.send_keys(20, Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is 20") + dash_dcc.wait_for_text_to_equal("#drag_value", "drag_value is 20") + + assert dash_dcc.get_logs() == [] + + +def test_slkb004_range_input_constrained_by_step(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.RangeSlider( + id="slider", + min=-20, + max=20, + step=5, + value=[-5, 5], + ), + html.Div(id="value"), + html.Div(id="drag_value"), + ] + ) + + @app.callback(Output("value", "children"), [Input("slider", "value")]) + def update_output(value): + return f"value is {value}" + + @app.callback(Output("drag_value", "children"), [Input("slider", "drag_value")]) + def update_drag_value(value): + return f"drag_value is {value}" + + dash_dcc.start_server(app) + + dash_dcc.driver.set_window_size(800, 600) + dash_dcc.wait_for_text_to_equal("#value", "value is [-5, 5]") + + min_inpt = dash_dcc.find_element("#slider .dash-range-slider-min-input") + max_inpt = dash_dcc.find_element("#slider .dash-range-slider-max-input") + + max_inpt.send_keys(Keys.BACKSPACE, 19, Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is [-5, 20]") + + min_inpt.send_keys(Keys.BACKSPACE, Keys.BACKSPACE, -14, Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is [-15, 20]") + + assert dash_dcc.get_logs() == [] + + +def test_slkb005_input_decimals_precision(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Slider( + id="slider", + min=-20.5, + max=20.5, + step=0.01, + value=5, + ), + html.Div(id="value"), + html.Div(id="drag_value"), + ] + ) + + @app.callback(Output("value", "children"), [Input("slider", "value")]) + def update_output(value): + return f"value is {value}" + + @app.callback(Output("drag_value", "children"), [Input("slider", "drag_value")]) + def update_drag_value(value): + return f"drag_value is {value}" + + dash_dcc.start_server(app) + + dash_dcc.driver.set_window_size(800, 600) + dash_dcc.wait_for_text_to_equal("#value", "value is 5") + + inpt = dash_dcc.find_element("#slider .dash-range-slider-max-input") + + # value should respect the slider's `step` prop + inpt.send_keys(Keys.BACKSPACE, 3.14159, Keys.TAB) + dash_dcc.wait_for_text_to_equal("#value", "value is 3.14") + dash_dcc.wait_for_text_to_equal("#drag_value", "drag_value is 3.14") + + assert dash_dcc.get_logs() == [] From 7b4d1bc93448ed71e45b04593ef6eaf27ca697e0 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 8 Oct 2025 10:40:15 -0600 Subject: [PATCH 3/4] Add support for `reverse` in sliders --- .../src/components/css/dropdown.css | 4 +++ .../src/fragments/RangeSlider.tsx | 8 ++++-- components/dash-core-components/src/types.ts | 10 +++++++ .../src/utils/sliderRendering.tsx | 27 ++++++++++++++++--- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/components/dash-core-components/src/components/css/dropdown.css b/components/dash-core-components/src/components/css/dropdown.css index c47397d2b0..87cb68face 100644 --- a/components/dash-core-components/src/components/css/dropdown.css +++ b/components/dash-core-components/src/components/css/dropdown.css @@ -44,6 +44,10 @@ height: 100%; } +.dash-dropdown:focus { + outline: 1px solid var(--Dash-Fill-Interactive-Strong); +} + .dash-dropdown:disabled { opacity: 0.6; cursor: not-allowed; diff --git a/components/dash-core-components/src/fragments/RangeSlider.tsx b/components/dash-core-components/src/fragments/RangeSlider.tsx index 0d151ec721..370269fa91 100644 --- a/components/dash-core-components/src/fragments/RangeSlider.tsx +++ b/components/dash-core-components/src/fragments/RangeSlider.tsx @@ -39,6 +39,7 @@ export default function RangeSlider(props: RangeSliderProps) { allowCross, pushable, count, + reverse, } = props; // For range slider, we expect an array of values @@ -481,6 +482,7 @@ export default function RangeSlider(props: RangeSliderProps) { step={stepValue} disabled={disabled} orientation={vertical ? 'vertical' : 'horizontal'} + inverted={reverse} data-included={included !== false} minStepsBetweenThumbs={ typeof pushable === 'number' @@ -498,14 +500,16 @@ export default function RangeSlider(props: RangeSliderProps) { renderedMarks, !!vertical, minMaxValues, - !!dots + !!dots, + !!reverse )} {dots && stepValue && renderSliderDots( stepValue, minMaxValues, - !!vertical + !!vertical, + !!reverse )} {/* Render thumbs with tooltips for each value */} {value.map((val, index) => { diff --git a/components/dash-core-components/src/types.ts b/components/dash-core-components/src/types.ts index 7bf9da8476..39d9ce418a 100644 --- a/components/dash-core-components/src/types.ts +++ b/components/dash-core-components/src/types.ts @@ -176,6 +176,11 @@ export interface SliderProps extends BaseComponentProps { */ included?: boolean; + /** + * If the value is true, the slider is rendered in reverse. + */ + reverse?: boolean; + /** * Configuration for tooltips describing the current slider value */ @@ -277,6 +282,11 @@ export interface RangeSliderProps extends BaseComponentProps { */ included?: boolean; + /** + * If the value is true, the slider is rendered in reverse. + */ + reverse?: boolean; + /** * Configuration for tooltips describing the current slider values */ diff --git a/components/dash-core-components/src/utils/sliderRendering.tsx b/components/dash-core-components/src/utils/sliderRendering.tsx index 91786a0e54..68f16c3d0b 100644 --- a/components/dash-core-components/src/utils/sliderRendering.tsx +++ b/components/dash-core-components/src/utils/sliderRendering.tsx @@ -58,11 +58,19 @@ export const renderSliderMarks = ( renderedMarks: SliderMarks, vertical: boolean, minMaxValues: {min_mark: number; max_mark: number}, - dots: boolean + dots: boolean, + reverse = false ) => { return Object.entries(renderedMarks).map(([position, mark]) => { const pos = parseFloat(position); - const thumbPosition = getRadixThumbPosition(pos, minMaxValues); + + // When reversed, use the inverted value for positioning + const displayPos = reverse + ? minMaxValues.max_mark - pos + minMaxValues.min_mark + : pos; + + const thumbPosition = getRadixThumbPosition(displayPos, minMaxValues); + const style = vertical ? { bottom: `calc(${thumbPosition.percentage}% + ${thumbPosition.offset}px - 13px)`, @@ -95,7 +103,8 @@ export const renderSliderMarks = ( export const renderSliderDots = ( stepValue: number, minMaxValues: {min_mark: number; max_mark: number}, - vertical: boolean + vertical: boolean, + reverse = false ) => { if (stepValue <= 1) { return null; @@ -117,7 +126,17 @@ export const renderSliderDots = ( }, (_, i) => { const dotValue = minMaxValues.min_mark + i * stepValue; - const thumbPosition = getRadixThumbPosition(dotValue, minMaxValues); + + // When reversed, use the inverted value for positioning + const displayValue = reverse + ? minMaxValues.max_mark - dotValue + minMaxValues.min_mark + : dotValue; + + const thumbPosition = getRadixThumbPosition( + displayValue, + minMaxValues + ); + const dotStyle = vertical ? { bottom: `calc(${thumbPosition.percentage}% + ${thumbPosition.offset}px)`, From e17c3ca6585e74f51a0a2d09263b9cdd84ba9f07 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 8 Oct 2025 15:02:53 -0600 Subject: [PATCH 4/4] Fix issue when dropdown is placed in flexbox --- components/dash-core-components/src/components/css/dropdown.css | 1 + 1 file changed, 1 insertion(+) diff --git a/components/dash-core-components/src/components/css/dropdown.css b/components/dash-core-components/src/components/css/dropdown.css index 87cb68face..7746c49a75 100644 --- a/components/dash-core-components/src/components/css/dropdown.css +++ b/components/dash-core-components/src/components/css/dropdown.css @@ -1,5 +1,6 @@ .dash-dropdown { display: block; + flex: 1; box-sizing: border-box; margin: calc(var(--Dash-Spacing) * 2) 0; padding: 0;