Skip to content

Commit 6fbb8f5

Browse files
AnnMarieWtcbegley
andauthored
Allow dict ids as target for Popover+Tooltip (#698)
* Allow dict ids as target id for Tooltip * Included Popover too * update based on review Co-authored-by: Tom Begley <[email protected]>
1 parent 47bd0ee commit 6fbb8f5

File tree

5 files changed

+89
-56
lines changed

5 files changed

+89
-56
lines changed

docs/content/docs/faq.md

Lines changed: 0 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -6,58 +6,6 @@ title: FAQ
66

77
This page contains various tips and tricks and answers to frequently asked questions about _dash-bootstrap-components_. If you think something is missing, please submit an [issue][issue] on the GitHub issue tracker.
88

9-
### How do I use `Tooltip` or `Popover` with pattern-matching callbacks?
10-
11-
Dash 1.11.0 added support for [pattern matching callbacks](https://dash.plotly.com/pattern-matching-callbacks) which allows you to write callbacks that can update an arbitrary or dynamic number of Dash components. To enable this, the `id` of a Dash component can now be a Python dictionary, and the callback is triggered based on a matching rule with one or more of the keys in this dictionary.
12-
13-
However, it is not possible to use a dictionary as the `target` of the `Popover` or `Tooltip` components. The reason for this is explained below. To get around the problem, the best thing to do is to wrap your dynamically created components with a `html.Div` element or similar, and use a string `id` for the wrapper which you then use as the target for the `Tooltip` or `Popover`. For example this example from the Dash documentation
14-
15-
```python
16-
@app.callback(
17-
Output('dropdown-container', 'children'),
18-
Input('add-filter', 'n_clicks'),
19-
State('dropdown-container', 'children'))
20-
def display_dropdowns(n_clicks, children):
21-
new_dropdown = dcc.Dropdown(
22-
id={
23-
'type': 'filter-dropdown',
24-
'index': n_clicks
25-
},
26-
options=[{'label': i, 'value': i} for i in ['NYC', 'MTL', 'LA', 'TOKYO']]
27-
)
28-
children.append(new_dropdown)
29-
return children
30-
```
31-
32-
might become the following
33-
34-
```python
35-
@app.callback(
36-
Output('dropdown-container', 'children'),
37-
Input('add-filter', 'n_clicks'),
38-
State('dropdown-container', 'children'))
39-
def display_dropdowns(n_clicks, children):
40-
new_dropdown = html.Div(
41-
dcc.Dropdown(
42-
id={
43-
'type': 'filter-dropdown',
44-
'index': n_clicks
45-
},
46-
options=[{'label': i, 'value': i} for i in ['NYC', 'MTL', 'LA', 'TOKYO']]
47-
),
48-
id=f"dropdown-wrapper-{n_clicks}"
49-
)
50-
new_tooltip = dbc.Tooltip(
51-
f"This is dropdown number {n_clicks}",
52-
target=f"dropdown-wrapper-{n_clicks}",
53-
)
54-
children.append(new_dropdown)
55-
children.append(new_tooltip)
56-
return children
57-
```
58-
59-
The reason `Popover` and `Tooltip` can't support the dictionary-based `id` is that under the hood these components are searching for the `id` using a function called `querySelectorAll` implemented as part of the standard Web APIs. This function can only search for a valid CSS selector string, which is restricted more or less to alphanumeric characters plus hyphens and underscores. Dash serialises dictionary ids as JSON, which contains characters like `{` and `}` that are invalid in CSS selectors. The above workaround avoids this issue.
60-
619
### How do I scale the viewport on mobile devices?
6210

6311
When building responsive layouts it is typical to have something like the following in your HTML template

src/components/popover/Popover.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import Overlay from '../../private/Overlay';
1212
* Use the `PopoverHeader` and `PopoverBody` components to control the layout
1313
* of the children.
1414
*/
15+
1516
const Popover = props => {
1617
const {
1718
children,
@@ -112,7 +113,7 @@ Popover.propTypes = {
112113
/**
113114
* ID of the component to attach the popover to.
114115
*/
115-
target: PropTypes.string,
116+
target: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
116117

117118
/**
118119
* Space separated list of triggers (e.g. "click hover focus legacy"). These

src/components/tooltip/Tooltip.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import Overlay from '../../private/Overlay';
1111
* Simply add the Tooltip to you layout, and give it a target (id of a
1212
* component to which the tooltip should be attached)
1313
*/
14+
1415
const Tooltip = props => {
1516
const {
1617
id,
@@ -81,7 +82,7 @@ Tooltip.propTypes = {
8182
/**
8283
* The id of the element to attach the tooltip to
8384
*/
84-
target: PropTypes.string,
85+
target: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
8586

8687
/**
8788
* How to place the tooltip.

src/private/Overlay.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ function useStateRef(initialValue) {
1919
return [value, setValue, ref];
2020
}
2121

22+
// stringifies object ids used in pattern matching callbacks
23+
const stringifyId = id => {
24+
if (typeof id !== 'object') {
25+
return id;
26+
}
27+
const stringifyVal = v => (v && v.wild) || JSON.stringify(v);
28+
const parts = Object.keys(id)
29+
.sort()
30+
.map(k => JSON.stringify(k) + ':' + stringifyVal(id[k]));
31+
return '{' + parts.join(',') + '}';
32+
};
33+
2234
const Overlay = ({
2335
children,
2436
target,
@@ -40,6 +52,8 @@ const Overlay = ({
4052

4153
const triggers = typeof trigger === 'string' ? trigger.split(' ') : [];
4254

55+
const targetStr = stringifyId(target);
56+
4357
const hide = () => {
4458
if (isOpenRef.current) {
4559
hideTimeout.current = clearTimeout(hideTimeout.current);
@@ -133,9 +147,9 @@ const Overlay = ({
133147
}, [defaultShow]);
134148

135149
useEffect(() => {
136-
targetRef.current = document.getElementById(target);
150+
targetRef.current = document.getElementById(targetStr);
137151
addEventListeners(targetRef.current);
138-
}, [target]);
152+
}, [targetStr]);
139153

140154
return (
141155
<RBOverlay show={isOpen} target={targetRef.current} {...otherProps}>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import React from 'react';
2+
import {act, fireEvent, render} from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import Tooltip from '../../components/tooltip/Tooltip';
5+
jest.useFakeTimers();
6+
7+
describe('Tooltip with dict id', () => {
8+
// this is just a little hack to silence a warning that we'll get until we
9+
// upgrade to 16.9. See also: https://github.com/facebook/react/pull/14853
10+
const originalError = console.error;
11+
beforeAll(() => {
12+
console.error = (...args) => {
13+
if (/Warning.*not wrapped in act/.test(args[0])) {
14+
return;
15+
}
16+
originalError.call(console, ...args);
17+
};
18+
});
19+
20+
afterAll(() => {
21+
console.error = originalError;
22+
});
23+
24+
let div;
25+
beforeAll(() => {
26+
div = document.createElement('div');
27+
div.setAttribute('id', '{"index":1,"type":"target"}');
28+
});
29+
30+
test('renders nothing by default', () => {
31+
render(
32+
<Tooltip target={{type: 'target', index: 1}}>Test content</Tooltip>,
33+
{
34+
container: document.body.appendChild(div)
35+
}
36+
);
37+
38+
expect(document.body.querySelector('.tooltip')).toBe(null);
39+
});
40+
41+
test('renders a div with class "tooltip"', () => {
42+
render(<Tooltip target={{type: 'target', index: 1}} />, {
43+
container: document.body.appendChild(div)
44+
});
45+
46+
fireEvent.mouseOver(div);
47+
act(() => jest.runAllTimers());
48+
expect(document.body.querySelector('.tooltip')).not.toBe(null);
49+
50+
fireEvent.mouseLeave(div);
51+
act(() => jest.runAllTimers());
52+
expect(document.body.querySelector('.tooltip')).toBe(null);
53+
});
54+
55+
test('renders its content', () => {
56+
render(
57+
<Tooltip target={{type: 'target', index: 1}}>Tooltip content</Tooltip>,
58+
{
59+
container: document.body.appendChild(div)
60+
}
61+
);
62+
63+
fireEvent.mouseOver(div);
64+
act(() => jest.runAllTimers());
65+
expect(document.body.querySelector('.tooltip')).toHaveTextContent(
66+
'Tooltip content'
67+
);
68+
});
69+
});

0 commit comments

Comments
 (0)