diff --git a/components/dash-core-components/src/components/css/dropdown.css b/components/dash-core-components/src/components/css/dropdown.css index c47397d2b0..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; @@ -44,6 +45,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/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) => { - + (value.length === 2); const sliderRef = useRef(null); + const inputRef = useRef(null); // Handle initial mount - equivalent to componentWillMount useEffect(() => { @@ -192,9 +194,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 +321,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 +349,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 +359,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 +376,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 +420,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 +451,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} /> @@ -384,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' @@ -401,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 9ee5d41d25..eedc9bb892 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)`, 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() == []