diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c16c55fa..873b4b1143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Added - [#2695](https://github.com/plotly/dash/pull/2695) Adds `triggered_id` to `dash_clientside.callback_context`. Fixes [#2692](https://github.com/plotly/dash/issues/2692) +- [#2723](https://github.com/plotly/dash/pull/2723) Improve dcc Slider/RangeSlider tooltips. Fixes [#1846](https://github.com/plotly/dash/issues/1846) + - Add `tooltip.format` a string for the format template, {value} will be formatted with the actual value. + - Add `tooltip.style` a style object to give to the div of the tooltip. - [#2732](https://github.com/plotly/dash/pull/2732) Add special key `_dash_error` to `setProps`, allowing component developers to send error without throwing in render. Usage `props.setProps({_dash_error: new Error("custom error")})` ## Fixed diff --git a/components/dash-core-components/src/components/RangeSlider.react.js b/components/dash-core-components/src/components/RangeSlider.react.js index 6db2a43b76..93dbf015d6 100644 --- a/components/dash-core-components/src/components/RangeSlider.react.js +++ b/components/dash-core-components/src/components/RangeSlider.react.js @@ -125,6 +125,31 @@ RangeSlider.propTypes = { 'bottomLeft', 'bottomRight', ]), + /** + * Template string to display the tooltip in. + * Must contain `{value}`, which will be replaced with either + * the default string representation of the value or the result of the + * transform function if there is one. + */ + template: PropTypes.string, + /** + * Custom style for the tooltip. + */ + style: PropTypes.object, + /** + * Reference to a function in the `window.dccFunctions` namespace. + * This can be added in a script in the asset folder. + * + * For example, in `assets/tooltip.js`: + * ``` + * window.dccFunctions = window.dccFunctions || {}; + * window.dccFunctions.multByTen = function(value) { + * return value * 10; + * } + * ``` + * Then in the component `tooltip={'transform': 'multByTen'}` + */ + transform: PropTypes.string, }), /** diff --git a/components/dash-core-components/src/components/Slider.react.js b/components/dash-core-components/src/components/Slider.react.js index 00c6c41a18..7e642591c2 100644 --- a/components/dash-core-components/src/components/Slider.react.js +++ b/components/dash-core-components/src/components/Slider.react.js @@ -2,6 +2,8 @@ import React, {Component, lazy, Suspense} from 'react'; import PropTypes from 'prop-types'; import slider from '../utils/LazyLoader/slider'; +import './css/sliders.css'; + const RealSlider = lazy(slider); /** @@ -105,6 +107,31 @@ Slider.propTypes = { 'bottomLeft', 'bottomRight', ]), + /** + * Template string to display the tooltip in. + * Must contain `{value}`, which will be replaced with either + * the default string representation of the value or the result of the + * transform function if there is one. + */ + template: PropTypes.string, + /** + * Custom style for the tooltip. + */ + style: PropTypes.object, + /** + * Reference to a function in the `window.dccFunctions` namespace. + * This can be added in a script in the asset folder. + * + * For example, in `assets/tooltip.js`: + * ``` + * window.dccFunctions = window.dccFunctions || {}; + * window.dccFunctions.multByTen = function(value) { + * return value * 10; + * } + * ``` + * Then in the component `tooltip={'transform': 'multByTen'}` + */ + transform: PropTypes.string, }), /** diff --git a/components/dash-core-components/src/components/css/sliders.css b/components/dash-core-components/src/components/css/sliders.css new file mode 100644 index 0000000000..664ddc6a50 --- /dev/null +++ b/components/dash-core-components/src/components/css/sliders.css @@ -0,0 +1,5 @@ +/* Fix the default tooltip style height conflicting with the actual size of the tooltip. */ +.rc-slider-tooltip-content > .rc-slider-tooltip-inner { + height: unset; + min-height: 20px; +} diff --git a/components/dash-core-components/src/fragments/RangeSlider.react.js b/components/dash-core-components/src/fragments/RangeSlider.react.js index 5e93772a0b..3c4f79c942 100644 --- a/components/dash-core-components/src/fragments/RangeSlider.react.js +++ b/components/dash-core-components/src/fragments/RangeSlider.react.js @@ -1,5 +1,5 @@ import React, {Component} from 'react'; -import {assoc, pick, isNil} from 'ramda'; +import {assoc, pick, isNil, pipe, omit} from 'ramda'; import {Range, createSliderWithTooltip} from 'rc-slider'; import computeSliderStyle from '../utils/computeSliderStyle'; @@ -11,6 +11,10 @@ import { setUndefined, } from '../utils/computeSliderMarkers'; import {propTypes, defaultProps} from '../components/RangeSlider.react'; +import { + formatSliderTooltip, + transformSliderTooltip, +} from '../utils/formatSliderTooltip'; const sliderProps = [ 'min', @@ -72,17 +76,33 @@ export default class RangeSlider extends Component { } = this.props; const value = this.state.value; - let tipProps; - if (tooltip && tooltip.always_visible) { + let tipProps, tipFormatter; + if (tooltip) { /** * clone `tooltip` but with renamed key `always_visible` -> `visible` - * the rc-tooltip API uses `visible`, but `always_visible is more semantic + * the rc-tooltip API uses `visible`, but `always_visible` is more semantic * assigns the new (renamed) key to the old key and deletes the old key */ - tipProps = assoc('visible', tooltip.always_visible, tooltip); - delete tipProps.always_visible; - } else { - tipProps = tooltip; + tipProps = pipe( + assoc('visible', tooltip.always_visible), + omit(['always_visible', 'template', 'style', 'transform']) + )(tooltip); + if (tooltip.template || tooltip.style || tooltip.transform) { + tipFormatter = tipValue => { + let t = tipValue; + if (tooltip.transform) { + t = transformSliderTooltip(tooltip.transform, tipValue); + } + return ( +
+ {formatSliderTooltip( + tooltip.template || '{value}', + t + )} +
+ ); + }; + } } return ( @@ -116,6 +136,7 @@ export default class RangeSlider extends Component { ...tipProps, getTooltipContainer: node => node, }} + tipFormatter={tipFormatter} style={{position: 'relative'}} value={value ? value : calcValue(min, max, value)} marks={sanitizeMarks({min, max, marks, step})} diff --git a/components/dash-core-components/src/fragments/Slider.react.js b/components/dash-core-components/src/fragments/Slider.react.js index 2878658b0d..512ea5525c 100644 --- a/components/dash-core-components/src/fragments/Slider.react.js +++ b/components/dash-core-components/src/fragments/Slider.react.js @@ -1,6 +1,6 @@ import React, {Component} from 'react'; import ReactSlider, {createSliderWithTooltip} from 'rc-slider'; -import {assoc, isNil, pick} from 'ramda'; +import {assoc, isNil, pick, pipe, omit} from 'ramda'; import computeSliderStyle from '../utils/computeSliderStyle'; import 'rc-slider/assets/index.css'; @@ -11,6 +11,10 @@ import { setUndefined, } from '../utils/computeSliderMarkers'; import {propTypes, defaultProps} from '../components/Slider.react'; +import { + formatSliderTooltip, + transformSliderTooltip, +} from '../utils/formatSliderTooltip'; const sliderProps = [ 'min', @@ -72,17 +76,33 @@ export default class Slider extends Component { } = this.props; const value = this.state.value; - let tipProps; - if (tooltip && tooltip.always_visible) { + let tipProps, tipFormatter; + if (tooltip) { /** * clone `tooltip` but with renamed key `always_visible` -> `visible` * the rc-tooltip API uses `visible`, but `always_visible` is more semantic * assigns the new (renamed) key to the old key and deletes the old key */ - tipProps = assoc('visible', tooltip.always_visible, tooltip); - delete tipProps.always_visible; - } else { - tipProps = tooltip; + tipProps = pipe( + assoc('visible', tooltip.always_visible), + omit(['always_visible', 'template', 'style', 'transform']) + )(tooltip); + if (tooltip.template || tooltip.style || tooltip.transform) { + tipFormatter = tipValue => { + let t = tipValue; + if (tooltip.transform) { + t = transformSliderTooltip(tooltip.transform, tipValue); + } + return ( +
+ {formatSliderTooltip( + tooltip.template || '{value}', + t + )} +
+ ); + }; + } } return ( @@ -116,6 +136,7 @@ export default class Slider extends Component { ...tipProps, getTooltipContainer: node => node, }} + tipFormatter={tipFormatter} style={{position: 'relative'}} value={value} marks={sanitizeMarks({min, max, marks, step})} diff --git a/components/dash-core-components/src/utils/formatSliderTooltip.js b/components/dash-core-components/src/utils/formatSliderTooltip.js new file mode 100644 index 0000000000..5519db4b86 --- /dev/null +++ b/components/dash-core-components/src/utils/formatSliderTooltip.js @@ -0,0 +1,19 @@ +import {replace, path, split, concat, pipe} from 'ramda'; + +export const formatSliderTooltip = (template, value) => { + return replace('{value}', value, template); +}; + +export const transformSliderTooltip = (funcName, value) => { + const func = pipe( + split('.'), + s => concat(['dccFunctions'], s), + s => path(s, window) + )(funcName); + if (!func) { + throw new Error( + `Invalid func for slider tooltip transform: ${funcName}` + ); + } + return func(value); +}; diff --git a/components/dash-core-components/tests/integration/sliders/assets/transform.js b/components/dash-core-components/tests/integration/sliders/assets/transform.js new file mode 100644 index 0000000000..0b68f9a23d --- /dev/null +++ b/components/dash-core-components/tests/integration/sliders/assets/transform.js @@ -0,0 +1,4 @@ +window.dccFunctions = window.dccFunctions || {}; +window.dccFunctions.transformTooltip = function(value) { + return "Transformed " + value +} diff --git a/components/dash-core-components/tests/integration/sliders/test_sliders.py b/components/dash-core-components/tests/integration/sliders/test_sliders.py index 42e95de5dd..08e303da37 100644 --- a/components/dash-core-components/tests/integration/sliders/test_sliders.py +++ b/components/dash-core-components/tests/integration/sliders/test_sliders.py @@ -559,3 +559,60 @@ def test_slsl015_range_slider_no_min_max(dash_dcc): ) assert dash_dcc.get_logs() == [] + + +def test_sls016_sliders_format_tooltips(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Slider( + value=34, + min=20, + max=100, + id="slider", + tooltip={ + "template": "Custom tooltip: {value}", + "always_visible": True, + "style": {"padding": "8px"}, + }, + ), + dcc.RangeSlider( + value=[48, 60], + min=20, + max=100, + id="range-slider", + tooltip={"template": "Custom tooltip: {value}", "always_visible": True}, + ), + dcc.Slider( + min=20, + max=100, + id="slider-transform", + tooltip={"always_visible": True, "transform": "transformTooltip"}, + ), + ], + style={"padding": "12px", "marginTop": "48px"}, + ) + + dash_dcc.start_server(app) + # dash_dcc.wait_for_element("#slider") + + dash_dcc.wait_for_text_to_equal( + "#slider .rc-slider-tooltip-content", "Custom tooltip: 34" + ) + dash_dcc.wait_for_text_to_equal( + "#range-slider .rc-slider-tooltip-content", "Custom tooltip: 48" + ) + dash_dcc.wait_for_text_to_equal( + "#range-slider > div:nth-child(1) > div:last-child .rc-slider-tooltip-content", + "Custom tooltip: 60", + ) + dash_dcc.wait_for_style_to_equal( + "#slider .rc-slider-tooltip-inner > div", "padding", "8px" + ) + dash_dcc.wait_for_text_to_equal( + "#slider-transform .rc-slider-tooltip-content", "Transformed 20" + ) + + dash_dcc.percy_snapshot("sliders-format-tooltips") + + assert dash_dcc.get_logs() == []