Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
52 changes: 0 additions & 52 deletions docs/content/docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,58 +6,6 @@ title: FAQ

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.

### How do I use `Tooltip` or `Popover` with pattern-matching callbacks?

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.

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

```python
@app.callback(
Output('dropdown-container', 'children'),
Input('add-filter', 'n_clicks'),
State('dropdown-container', 'children'))
def display_dropdowns(n_clicks, children):
new_dropdown = dcc.Dropdown(
id={
'type': 'filter-dropdown',
'index': n_clicks
},
options=[{'label': i, 'value': i} for i in ['NYC', 'MTL', 'LA', 'TOKYO']]
)
children.append(new_dropdown)
return children
```

might become the following

```python
@app.callback(
Output('dropdown-container', 'children'),
Input('add-filter', 'n_clicks'),
State('dropdown-container', 'children'))
def display_dropdowns(n_clicks, children):
new_dropdown = html.Div(
dcc.Dropdown(
id={
'type': 'filter-dropdown',
'index': n_clicks
},
options=[{'label': i, 'value': i} for i in ['NYC', 'MTL', 'LA', 'TOKYO']]
),
id=f"dropdown-wrapper-{n_clicks}"
)
new_tooltip = dbc.Tooltip(
f"This is dropdown number {n_clicks}",
target=f"dropdown-wrapper-{n_clicks}",
)
children.append(new_dropdown)
children.append(new_tooltip)
return children
```

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.

### How do I scale the viewport on mobile devices?

When building responsive layouts it is typical to have something like the following in your HTML template
Expand Down
3 changes: 2 additions & 1 deletion src/components/popover/Popover.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Overlay from '../../private/Overlay';
* Use the `PopoverHeader` and `PopoverBody` components to control the layout
* of the children.
*/

const Popover = props => {
const {
children,
Expand Down Expand Up @@ -112,7 +113,7 @@ Popover.propTypes = {
/**
* ID of the component to attach the popover to.
*/
target: PropTypes.string,
target: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),

/**
* Space separated list of triggers (e.g. "click hover focus legacy"). These
Expand Down
3 changes: 2 additions & 1 deletion src/components/tooltip/Tooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Overlay from '../../private/Overlay';
* Simply add the Tooltip to you layout, and give it a target (id of a
* component to which the tooltip should be attached)
*/

const Tooltip = props => {
const {
id,
Expand Down Expand Up @@ -81,7 +82,7 @@ Tooltip.propTypes = {
/**
* The id of the element to attach the tooltip to
*/
target: PropTypes.string,
target: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),

/**
* How to place the tooltip.
Expand Down
18 changes: 16 additions & 2 deletions src/private/Overlay.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ function useStateRef(initialValue) {
return [value, setValue, ref];
}

// stringifies object ids used in pattern matching callbacks
const stringifyId = id => {
if (typeof id !== 'object') {
return id;
}
const stringifyVal = v => (v && v.wild) || JSON.stringify(v);
const parts = Object.keys(id)
.sort()
.map(k => JSON.stringify(k) + ':' + stringifyVal(id[k]));
return '{' + parts.join(',') + '}';
};

const Overlay = ({
children,
target,
Expand All @@ -40,6 +52,8 @@ const Overlay = ({

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

const targetStr = stringifyId(target);

const hide = () => {
if (isOpenRef.current) {
hideTimeout.current = clearTimeout(hideTimeout.current);
Expand Down Expand Up @@ -133,9 +147,9 @@ const Overlay = ({
}, [defaultShow]);

useEffect(() => {
targetRef.current = document.getElementById(target);
targetRef.current = document.getElementById(targetStr);
addEventListeners(targetRef.current);
}, [target]);
}, [targetStr]);

return (
<RBOverlay show={isOpen} target={targetRef.current} {...otherProps}>
Expand Down
69 changes: 69 additions & 0 deletions src/private/__tests__/Overlay.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React from 'react';
import {act, fireEvent, render} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Tooltip from '../../components/tooltip/Tooltip';
jest.useFakeTimers();

describe('Tooltip with dict id', () => {
// this is just a little hack to silence a warning that we'll get until we
// upgrade to 16.9. See also: https://github.com/facebook/react/pull/14853
const originalError = console.error;
beforeAll(() => {
console.error = (...args) => {
if (/Warning.*not wrapped in act/.test(args[0])) {
return;
}
originalError.call(console, ...args);
};
});

afterAll(() => {
console.error = originalError;
});

let div;
beforeAll(() => {
div = document.createElement('div');
div.setAttribute('id', '{"index":1,"type":"target"}');
});

test('renders nothing by default', () => {
render(
<Tooltip target={{type: 'target', index: 1}}>Test content</Tooltip>,
{
container: document.body.appendChild(div)
}
);

expect(document.body.querySelector('.tooltip')).toBe(null);
});

test('renders a div with class "tooltip"', () => {
render(<Tooltip target={{type: 'target', index: 1}} />, {
container: document.body.appendChild(div)
});

fireEvent.mouseOver(div);
act(() => jest.runAllTimers());
expect(document.body.querySelector('.tooltip')).not.toBe(null);

fireEvent.mouseLeave(div);
act(() => jest.runAllTimers());
expect(document.body.querySelector('.tooltip')).toBe(null);
});

test('renders its content', () => {
render(
<Tooltip target={{type: 'target', index: 1}}>Tooltip content</Tooltip>,
{
container: document.body.appendChild(div)
}
);

fireEvent.mouseOver(div);
act(() => jest.runAllTimers());
expect(document.body.querySelector('.tooltip')).toHaveTextContent(
'Tooltip content'
);
});
});