Reputation: 7900
I'm working on a Notification feature in my app (pretty much like Facebook notifications).
When I click a button in the header navigation, the dropdown opens and shows the notification list. The notification has a Link
(from react-router
) in it.
What I need to do is to close the dropdown whenever a Link
is clicked.
Here's roughly the hierarchy I currently have:
Header > Navigation > Button > Dropdown > List > Notification > Link
Since the dropdown functionality is used more that once, I've abstracted its behavior away into a HOC that uses render prop:
export default function withDropDown(ClickableElement) {
return class ClickableDropdown extends PureComponent {
static propTypes = {
children: PropTypes.func.isRequired,
showOnInit: PropTypes.bool,
};
static defaultProps = {
showOnInit: false,
};
state = {
show: !!this.props.showOnInit,
};
domRef = createRef();
componentDidMount() {
document.addEventListener('mousedown', this.handleGlobalClick);
}
toggle = show => {
this.setState({ show });
};
handleClick = () => this.toggle(true);
handleGlobalClick = event => {
if (this.domRef.current && !this.domRef.current.contains(event.target)) {
this.toggle(false);
}
};
render() {
const { children, ...props } = this.props;
return (
<Fragment>
<ClickableElement {...props} onClick={this.handleClick} />
{this.state.show && children(this.domRef)}
</Fragment>
);
}
};
}
The HOC above encloses the Button
component, so I have:
const ButtonWithDropdown = withDropdown(Button);
class NotificationsHeaderDropdown extends PureComponent {
static propTypes = {
data: PropTypes.arrayOf(notification),
load: PropTypes.func,
};
static defaultProps = {
data: [],
load: () => {},
};
componentDidMount() {
this.props.load();
}
renderDropdown = ref => (
<Dropdown ref={ref}>
{data.length > 0 && <List items={this.props.data} />}
{data.length === 0 && <EmptyList />}
</Dropdown>
);
render() {
return (
<ButtonWithDropdown count={this.props.data.length}>
{this.renderDropdown}
</ButtonWithDropdown>
);
}
}
List
and Notification
are both dumb functional components, so I'm not posting their code here. Dropdown
is pretty much the same, with the difference it uses ref forwarding.
What I really need is to call that .toggle()
method from ClickableDropdown
created by the HOC to be called whenever I click on a Link
on the list.
Is there any way of doing this without passing that .toggle()
method down the Button > Dropdown > List > Notification > Link
subtree?
I'm using redux
, but I'm not sure this is the kind of thing I'd put on the store.
Or should I handle this imperatively using the DOM API, by changing the implementation of handleGlobalClick
from ClickableDropdown
?
I'm trying with the imperative approach, so I've changed the handleGlobalClick
method:
const DISMISS_KEY = 'dropdown';
function contains(current, element) {
if (!current) {
return false;
}
return current.contains(element);
}
function isDismisser(dismissKey, current, element) {
if (!element || !contains(current, element)) {
return false;
}
const shouldDismiss = element.dataset.dismiss === dismissKey;
return shouldDismiss || isDismisser(dismissKey, current, element.parentNode);
}
// Then...
handleGlobalClick = event => {
const containsEventTarget = contains(this.domRef.current, event.target);
const shouldDismiss = isDismisser(
DISMISS_KEY,
this.domRef.current,
event.target
);
if (!containsEventTarget || shouldDismiss) {
this.toggle(false);
}
return true;
};
Then I changed the Link
to include a data-dismiss
property:
<Link
to={url}
data-dismiss="dropdown"
>
...
</Link>
Now the dropdown is closed, but I'm not redirected to the provided url
anymore.
I tried to defer the execution of this.toggle(false)
using requestAnimationFrame
and setTimeout
, but it didn't work either.
Based on the answer by @streletss bellow, I came up with the following solution:
In order to be as generic as possible, I created a shouldHideOnUpdate
prop in the ClickableDropdown
dropdown component, whose Hindley-Milner-ish signature is:
shouldHideOnUpdate :: Props curr, Props prev => (curr, prev) -> Boolean
Here's the componentDidUpdate
implementation:
componentDidUpdate(prevProps) {
if (this.props.shouldHideOnUpdate(this.props, prevProps)) {
this.toggle(false);
}
}
This way, I didn't need to use the withRouter
HOC directly in my withDropdown
HOC.
So, I lifted the responsibility of defining the condition for hiding the dropdown to the caller, which is my case is the Navigation
component, where I did something like this:
const container = compose(withRouter, withDropdown);
const ButtonWithDropdown = container(Button);
function routeStateHasChanged(currentProps, prevProps) {
return currentProps.location.state !== prevProps.location.state;
}
// ... then
render() {
<ButtonWithDropdown shouldHideOnUpdate={routeStateHasChanged}>
{this.renderDropdown}
</ButtonWithDropdown>
}
Upvotes: 0
Views: 306
Reputation: 2361
In the question, you mention that you are using redux.So I assume that you store showOnInit in redux.We don't usually store a function in redux.In toggle function,I think you should dispatch an CHANGE_SHOW action to change the showOnInit in redux, then pass the show data not the function to the children component.Then after reducer dispatch,the react will change “show” automatically.
switch (action.type) {
case CHANGE_SHOW:
return Object.assign({}, state, {
showOnInit: action.text
})
...
default:
return state
}
Use the property in Link-to,not data-...Like this:
<Link
to={{
pathname: url,
state:{dismiss:"dropdown"}
}}
/>
And the state property will be found in this.props.location.
It may lead your project to instable and some other problems.(https://reactjs.org/docs/context.html#classcontexttype)
First,define context
const MyContext = React.createContext(defaultValue);
Second,define pass value
<MyContext.Provider value={this.toggle}>
Then,get the value in the nested component
<div value={this.context} />
Upvotes: 1
Reputation: 6086
It seems you could simply make use of withRouter
HOC and check if this.props.location.pathname
has changed when componentDidUpdate
:
export default function withDropDown(ClickableElement) {
class ClickableDropdown extends Component {
// ...
componentDidUpdate(prevProps) {
if (this.props.location.pathname !== prevProps.location.pathname) {
this.toggle(false);
}
}
// ...
};
return withRouter(ClickableDropdown)
}
Upvotes: 1