Skip to content
This repository has been archived by the owner on Jun 3, 2024. It is now read-only.

Add optional numerical input box to dcc.Slider #944

Open
wants to merge 29 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c858dc9
add input that shows slider value
Feb 25, 2021
6aeea78
sync slider from input
Mar 4, 2021
d57bf70
lint
Mar 4, 2021
7fc4de4
fix lint errors
Mar 4, 2021
e24c3f5
fix lint errors
Mar 4, 2021
796d30d
debounce and components on same line
Mar 6, 2021
c5b7e51
set min width of synced input
Mar 12, 2021
168a99c
pass through props for synced input
Mar 18, 2021
2e2dbe0
let user override default styles for input
Mar 29, 2021
57936d4
run linter
Mar 29, 2021
205b9ed
set proper props for input
Apr 1, 2021
3168a64
refactor input state/props sync
Apr 1, 2021
128017b
update tests
Apr 6, 2021
b555515
Merge branch 'dev' into synced-input-slider
Apr 6, 2021
3e62912
add tests
Apr 6, 2021
3820ce4
lint tests
Apr 6, 2021
884cccb
run prettier
Apr 6, 2021
403ee9e
small fixups
Apr 6, 2021
761103c
make sure that pressing enter when input is focused changes state of …
Apr 8, 2021
1709c21
clear debounce timeout after every state sync
Apr 8, 2021
2db424b
Merge branch 'dev' into synced-input-slider
Apr 13, 2021
da7d116
Merge branch 'synced-input-slider' of https://github.com/plotly/dash-…
Apr 13, 2021
4b45284
refactor synced input event handling
Apr 13, 2021
b7c3154
Merge branch 'dev' into synced-input-slider
Apr 20, 2021
aa1ab08
Merge branch 'dev' into synced-input-slider
Apr 27, 2021
0111a74
camelCase to snake_case new props
Apr 27, 2021
02c10c6
Merge branch 'dev' into synced-input-slider
wbrgss May 14, 2021
aebce8d
add some ui ;ogic
May 21, 2021
f445f58
Merge branch 'synced-input-slider' of https://github.com/plotly/dash-…
May 21, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion src/components/Slider.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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;
Expand Down
94 changes: 93 additions & 1 deletion src/fragments/Slider.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -72,15 +119,54 @@ 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 (
<div
id={id}
data-dash-is-loading={
(loading_state && loading_state.is_loading) || undefined
}
className={className}
style={this._computeStyle(vertical, verticalHeight, tooltip)}
style={{...computedStyle, ...style}}
>
{synced_input ? (
<input
onChange={() => {
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}
<this.DashSlider
onChange={value => {
if (updatemode === 'drag') {
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/utils/computeSliderStyle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand All @@ -28,6 +30,7 @@ export default () => {
) {
style.paddingTop = '0px';
}
style.alignItems = 'center';
}

return style;
Expand Down
99 changes: 99 additions & 0 deletions tests/integration/sliders/test_sliders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
55 changes: 55 additions & 0 deletions tests/test_integration_1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a great test, but let's not add any more tests to the top-level files test_integration_1 or test_integration_2. These use the legacy unittest framework rather than dash.testing. If anything we should take opportunities when we're working in neighboring code to move more tests OUT of these files and port them to use dash.testing in the subdirectories.

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()

Expand Down