diff --git a/src/components/Slider.react.js b/src/components/Slider.react.js index 116180dd..30372213 100644 --- a/src/components/Slider.react.js +++ b/src/components/Slider.react.js @@ -115,10 +115,40 @@ Slider.propTypes = { }), /** - * Value by which increments or decrements are made + * Value by which increments or decrements are made. */ step: PropTypes.number, + /** + * If true, display an Input component whose value is synced with the Slider's value. + */ + synced_input: PropTypes.bool, + + /** + * The classname to be given to the synced Input component. + */ + synced_input_class_name: PropTypes.string, + + /** + * The CSS to be applied to the class of the input (div). + */ + synced_input_style: PropTypes.object, + + /** + * The id to be applied to the input (div). Default is "syncedInput". + */ + synced_input_id: PropTypes.string, + + /** + * The CSS to be applied to the class of the slider (div). + */ + style: PropTypes.object, + + /** + * The amount of time the synced Input should wait before passing along state changes without a change of focus or the user pressing Enter. In milliseconds. + */ + synced_input_debounce_time: PropTypes.number, + /** * If true, the slider will be vertical */ @@ -199,6 +229,8 @@ Slider.defaultProps = { persisted_props: ['value'], persistence_type: 'local', verticalHeight: 400, + synced_input_debounce_time: 450, + synced_input_id: 'syncedInput', }; export const propTypes = Slider.propTypes; diff --git a/src/fragments/Slider.react.js b/src/fragments/Slider.react.js index 178014e3..221f662c 100644 --- a/src/fragments/Slider.react.js +++ b/src/fragments/Slider.react.js @@ -7,6 +7,10 @@ import 'rc-slider/assets/index.css'; import {propTypes, defaultProps} from '../components/Slider.react'; +function round(number, increment, offset) { + return Math.round((number - offset) / increment ) * increment + offset; +} + /** * A slider component with a single handle. */ @@ -18,6 +22,42 @@ export default class Slider extends Component { : ReactSlider; this._computeStyle = computeSliderStyle(); this.state = {value: props.value}; + this.syncInputWithSlider = this.syncInputWithSlider.bind(this); + this.input = React.createRef(); + } + + syncInputWithSlider(trigger) { + + if (trigger == "onChange"){ + if (this.timeout) { + clearTimeout(this.timeout); + } + return + } + + if (this.input.current.value === ""){ + this.input.current.value = this.props.value + } + + if (Number(this.input.current.value) > this.props.max) { + this.input.current.value = this.props.max; + } + + if (Number(this.input.current.value) < this.props.min) { + this.input.current.value = this.props.min; + } + + if (this.props.step){ + if ((this.input.current.value - this.props.step) % 1 !== 0){ + this.input.current.value = round(this.input.current.value, this.props.step, 0) + } + } + + this.setState({value: Number(this.input.current.value)}); + this.props.setProps({ + value: Number(this.input.current.value), + drag_value: Number(this.input.current.value), + }); } UNSAFE_componentWillReceiveProps(newProps) { @@ -47,6 +87,13 @@ export default class Slider extends Component { setProps, tooltip, updatemode, + synced_input, + synced_input_debounce_time, + synced_input_class_name, + synced_input_style, + synced_input_id, + style, + step, vertical, verticalHeight, } = this.props; @@ -72,6 +119,18 @@ export default class Slider extends Component { ) : this.props.marks; + const computedStyle = this._computeStyle( + vertical, + verticalHeight, + tooltip + ); + + const defaultInputStyle = { + width: '60px', + marginRight: vertical && synced_input ? '' : '25px', + marginBottom: vertical && synced_input ? '25px' : '', + }; + return (
+ {synced_input ? ( + { + this.timeout = setTimeout( + function() { + this.syncInputWithSlider("onChange"); + }.bind(this), + synced_input_debounce_time + ); + }} + onBlur={() => { + this.syncInputWithSlider("onBlur"); + }} + onKeyPress={event => { + if (event.key === 'Enter') { + this.syncInputWithSlider("onKeyPress"); + } + }} + type="number" + defaultValue={value} + step={step} + className={synced_input_class_name} + id={synced_input_id} + style={{...defaultInputStyle, ...synced_input_style}} + ref={this.input} + /> + ) : null} { if (updatemode === 'drag') { @@ -89,11 +175,17 @@ export default class Slider extends Component { this.setState({value: value}); setProps({drag_value: value}); } + if (synced_input) { + this.input.current.value = value; + } }} onAfterChange={value => { if (updatemode === 'mouseup') { setProps({value}); } + if (synced_input) { + this.input.current.value = value; + } }} /* if/when rc-slider or rc-tooltip are updated to latest versions, diff --git a/src/utils/computeSliderStyle.js b/src/utils/computeSliderStyle.js index 5d653131..eec3c72e 100644 --- a/src/utils/computeSliderStyle.js +++ b/src/utils/computeSliderStyle.js @@ -4,10 +4,12 @@ export default () => { return memoizeWith(identity, (vertical, verticalHeight, tooltip) => { const style = { padding: '25px', + display: 'flex', }; if (vertical) { style.height = verticalHeight + 'px'; + style.flexDirection = 'column'; if ( !tooltip || @@ -28,6 +30,7 @@ export default () => { ) { style.paddingTop = '0px'; } + style.alignItems = 'center'; } return style; diff --git a/tests/integration/sliders/test_sliders.py b/tests/integration/sliders/test_sliders.py index d28c0429..19987b35 100644 --- a/tests/integration/sliders/test_sliders.py +++ b/tests/integration/sliders/test_sliders.py @@ -306,3 +306,102 @@ def update_output(value): dash_dcc.wait_for_text_to_equal("#out-value", "You have selected 5-15") dash_dcc.release() dash_dcc.wait_for_text_to_equal("#out-value", "You have selected 10-15") + + +def test_slsl008_horizontal_slider_with_input(dash_dcc): + app = dash.Dash(__name__) + app.layout = html.Div( + [ + dcc.Slider( + id="slider", + min=0, + max=20, + step=1, + value=5, + syncedInput=True, + syncedInputID="syncedInput", + tooltip={"always_visible": True}, + ), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), [Input("slider", "value")]) + def update_output(value): + return "You have selected {}".format(value) + + dash_dcc.start_server(app) + dash_dcc.wait_for_text_to_equal("#out", "You have selected 5") + + input_ = dash_dcc.find_element("#syncedInput") + + input_.clear() + input_.send_keys("8") + + dash_dcc.wait_for_text_to_equal("#out", "You have selected 8") + + +def test_slsl009_vertical_slider_with_input(dash_dcc): + app = dash.Dash(__name__) + app.layout = html.Div( + [ + dcc.Slider( + id="slider", + min=0, + max=20, + step=1, + value=5, + vertical=True, + syncedInput=True, + syncedInputID="arbitraryID", + tooltip={"always_visible": True}, + ), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), [Input("slider", "value")]) + def update_output(value): + return "You have selected {}".format(value) + + dash_dcc.start_server(app) + dash_dcc.wait_for_text_to_equal("#out", "You have selected 5") + + input_ = dash_dcc.find_element("#arbitraryID") + + input_.clear() + input_.send_keys("8") + + dash_dcc.wait_for_text_to_equal("#out", "You have selected 8") + + +def test_slsl010_horizontal_slider_with_input(dash_dcc): + app = dash.Dash(__name__) + app.layout = html.Div( + [ + dcc.Slider( + id="slider", + min=0, + max=20, + step=1, + value=5, + vertical=True, + syncedInput=True, + syncedInputClassName="slider-input", + syncedInputID="arbitraryID", + tooltip={"always_visible": True}, + ), + html.Div(id="out"), + ] + ) + + @app.callback(Output("out", "children"), [Input("slider", "value")]) + def update_output(value): + return "You have selected {}".format(value) + + dash_dcc.start_server(app) + dash_dcc.wait_for_text_to_equal("#out", "You have selected 5") + + input_ = dash_dcc.find_element("#arbitraryID") + + assert input_.get_attribute("class") == "slider-input" diff --git a/tests/test_integration_1.py b/tests/test_integration_1.py index b202f9d0..aca05067 100644 --- a/tests/test_integration_1.py +++ b/tests/test_integration_1.py @@ -156,6 +156,61 @@ def test_vertical_slider(self): for entry in self.get_log(): raise Exception("browser error logged during test", entry) + def test_horizontal_slider_with_input(self): + app = dash.Dash(__name__) + + app.layout = html.Div( + [ + html.Label("Horizontal Slider with Input"), + dcc.Slider( + id="horizontal-slider-with-input", + min=0, + max=9, + value=5, + syncedInputClassName="arbitraryClassName", + syncedInput=True, + ), + ], + style={"height": "500px"}, + ) + self.startServer(app) + + self.wait_for_element_by_css_selector("#horizontal-slider-with-input") + self.wait_for_element_by_css_selector(".arbitraryClassName") + + self.snapshot("horizontal slider with input") + + for entry in self.get_log(): + raise Exception("browser error logged during test", entry) + + def test_vertical_slider_with_input(self): + app = dash.Dash(__name__) + + app.layout = html.Div( + [ + html.Label("Vertical Slider with Input"), + dcc.Slider( + id="vertical-slider-with-input", + min=0, + max=9, + value=5, + vertical=True, + syncedInputClassName="arbitraryClassName", + syncedInput=True, + ), + ], + style={"height": "500px"}, + ) + self.startServer(app) + + self.wait_for_element_by_css_selector("#vertical-slider-with-input") + self.wait_for_element_by_css_selector(".arbitraryClassName") + + self.snapshot("vertical slider with input") + + for entry in self.get_log(): + raise Exception("browser error logged during test", entry) + def test_loading_range_slider(self): lock = Lock()