More precise useHover state control
- Dominant language
- TypeScript
- Stars
- 15.9k
- Forks
- 1.6k
- Avg merge
- 3d 9m
- Merged PRs (30d)
- 59
Description
### Provide a general summary of the feature here
There is a problem with the current `useHover` implementation that makes it hard to use with popovers that open inside a portal.
I have a case, where I'm building an app nav menu, that should be collapsed and opens full size on hover. Similar to how gmail menu behaves, when collapsed. My menu also have some buttons, which open popovers, when pressed. When I move my pointer to the opened popover, `isHovered` becomes false, despite the `pointerleave` event is not generated.
### 🤔 Expected Behavior?
I'm expecting, that I could somehow configure the hook behaviour to control, what I'm considering a hover state.
### 😯 Current Behavior
I've found, that the current behaviour occurs because of this check
https://github.com/adobe/react-spectrum/blob/69b8ec6eabc93bca9b885cdde1bb89bd8167c044/packages/%40react-aria/interactions/src/useHover.ts#L123
This special case may be valid for some scenarios, but in my situation, the `nodeContains` check is exactly what ruins my hover state, cause my popover is rendered in a portal.
### 💁 Possible Solution
**First: Hack**
For anyone, who comes here, I've found a workaround, that works in most scenarios. I've added a listener, that works before the library's one and stops the event propagation to avoid the react-aria hover end logic. But this is obviously not a good way ti handle this situations.
```ts
const { addGlobalListener, removeGlobalListener } = useGlobalListeners();
useEffect(() => {
const handlePointerOver = (e: PointerEvent) => {
const isInsideFloatingElement = !!(e.target as HTMLElement).closest(
// This is the data attribute I use to mark popovers
'[data-is-floating-element=true]'
);
if (isInsideFloatingElement) {
e.stopImmediatePropagation();
e.stopPropagation();
}
};
addGlobalListener(document, 'pointerover', handlePointerOver, {
capture: true,
});
return () => {
removeGlobalListener(document, 'pointerover', handlePointerOver);
};
}, []);
```
**Second: improve hook API**
I really like the pattern that [floating-ui](https://floating-ui.com/) uses for [press outside](https://floating-ui.com/docs/usedismiss#outsidepress) behaviour. It allows you to perform you custom checks on the events it listens to. It gives you the additional level of control, but keeps things simple and incapsulated.
`useHover` may also receive a prop like `shouldEndHover?: (event: PointerEvent) => boolean`
The check can be rewritten like:
```ts
if (state.isHovered && state.target && !nodeContains(state.target, e.target as Element) && shouldEndHover?.(e) ?? true)
```
**Additional problem**
The problem, I didn't find a reliable solution for is that only `pointerleave` event is generated, when I move my pointer out of the popover boundaries, but when I move it back to the popover, no `pointerenter` event is generated, so the hover state is lost. But this is a rare case, that probably won't affect users as much as collapsing menu, when they just move their mouse from the button to the popover.
The only way that now I can think of to solve it is provide the `triggerHoverStart` function from the hook, so I can register my own `pointerenter ` handler on the popover and start the hover manually, when user returns his pointer to it.
### 🔦 Context
Outside click has always been a tricky thing in React, I remember the days I used the deprecated `findDOMNode`API for it. Other solutions also were challenging to use with portals and often lead to inconsistence behaviour with multiple popovers. But since I've started using floating-ui, I've never had an unsolvable events conflict again. This library gave me the level of control over events I wanted.
That's why I this the proposed change may benefit the library flexibility. If you agree to it, I can make a PR with it.
### 💻 Examples
_No response_
### 🧢 Your Company/Team
_No response_
### 🕷 Tracking Issue
_No response_
Contributor guide
Assessment
This issue has not been assessed yet.