Tooltip delay on hover with RXJS - reactjs

I'm trying to add tooltip delay (300msemphasized text) using rxjs (without setTimeout()). My goal is to have this logic inside of TooltipPopover component which will be later be reused and delay will be passed (if needed) as a prop.
I'm not sure how can I add "delay" logic inside of TooltipPopover component using rxjs?
Portal.js
const Portal = ({ children }) => {
const mount = document.getElementById("portal-root");
const el = document.createElement("div");
useEffect(() => {
mount.appendChild(el);
return () => mount.removeChild(el);
}, [el, mount]);
return createPortal(children, el);
};
export default Portal;
TooltipPopover.js
import React from "react";
const TooltipPopover = ({ delay??? }) => {
return (
<div className="ant-popover-title">Title</div>
<div className="ant-popover-inner-content">{children}</div>
);
};
App.js
const App = () => {
return (
<Portal>
<TooltipPopover>
<div>
Content...
</div>
</TooltipPopover>
</Portal>
);
};
Then, I'm rendering TooltipPopover in different places:
ReactDOM.render(<TooltipPopover delay={1000}>
<SomeChildComponent/>
</TooltipPopover>, rootEl)

Here would be my approach:
mouseenter$.pipe(
// by default, the tooltip is not shown
startWith(CLOSE_TOOLTIP),
switchMap(
() => concat(timer(300), NEVER).pipe(
mapTo(SHOW_TOOLTIP),
takeUntil(mouseleave$),
endWith(CLOSE_TOOLTIP),
),
),
distinctUntilChanged(),
)
I'm not very familiar with best practices in React with RxJS, but this would be my reasoning. So, the flow would be this:
on mouseenter$, start the timer. concat(timer(300), NEVER) is used because although after 300ms the tooltip should be shown, we only want to hide it when mouseleave$ emits.
after 300ms, the tooltip is shown and will be closed mouseleave$
if mouseleave$ emits before 300ms pass, the CLOSE_TOOLTIP will emit, but you could avoid(I think) unnecessary re-renders with the help of distinctUntilChanged

Related

How do I add an onScroll handler to the menuList of a react-select component?

I would like to add an onScroll handler to a react-select component's menuList, but the following isn't working. I suspect that I need to set the onScroll for one of the children rather than an element that contains the children, but I don't know how to do that.
import React from 'react';
import Select, { components, MenuListProps } from 'react-select';
import {
ColourOption,
colourOptions,
FlavourOption,
GroupedOption,
groupedOptions,
} from './docs/data';
const handleScroll = () =>{
console.log('scrolling')
}
const MenuList = (
props: MenuListProps<ColourOption | FlavourOption, false, GroupedOption>
) => {
return (
<components.MenuList {...props}>
<div onScroll={handleScroll}>
{props.children}
</div>
</components.MenuList>
);
};
export default () => (
<Select<ColourOption | FlavourOption, false, GroupedOption>
defaultValue={colourOptions[1]}
menuIsOpen={true}
options={groupedOptions}
components={{ MenuList }}
/>
);
https://codesandbox.io/s/codesandboxer-example-forked-jr74k?file=/example.tsx:0-721
I had also tried using something like this
mySelectRef.current.menuListRef.addEventListener("scroll", handleScroll)
but .current or .current.menuListRef was always undefined or null at the time when the command executed. I tried using React.useEffect and setTimeout, with poor results. I'm hoping that I can just accomplish this by setting onScroll of the right inner div.
You need to wrap your MenuList component with div and provide ref to this component and then access the immediate div child of this HTML component. Then you can define the onscroll function to this element.
const MenuList = (
props: MenuListProps<ColourOption | FlavourOption, false, GroupedOption>
) => {
const menuListRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (menuListRef.current) {
menuListRef.current.querySelector("div").onscroll = () => {
console.log("scrolling");
};
}
}, [menuListRef]);
return (
<div ref={menuListRef}>
<components.MenuList {...props}>
<div>{props.children}</div>
</components.MenuList>
</div>
);
};
Approach
I tried to find if I can get access to a native wrapper element where onScroll property is supported already. If you dig in into components documentation you can find that Menu component accepts the innerProps which are the regular HTMLAttributes every HTML element accepts. So what I did was to provide an onScroll property to innerProps.
Code
const Menu = (
props: MenuProps<ColourOption | FlavourOption, false, GroupedOption>
) => {
const handleScroll = () => {
console.log("scrolling");
};
return (
<components.Menu
{...props}
innerProps={{ ...props.innerProps, onScroll: handleScroll }}
>
{props.children}
</components.Menu>
);
};
export default () => (
<Select<ColourOption | FlavourOption, false, GroupedOption>
defaultValue={colourOptions[1]}
menuIsOpen={true}
options={groupedOptions}
components={{ Menu }}
/>
);
Working example
Rationale
Ideally you would not implement a custom handler from scratch, but rather reuse or start from a hook that already exists and maintained by an open source community.
When implementing it yourself, there are a few caveats like the event listener having to be passive and you having to register as well as deregister the listener using React's useEffect hook.
Examples
There are many examples out there. One could be the implementation from react-use.
Implementation
Your component could look like:
const Component = () => {
const ref = useRef();
const { x, y } = useScroll(ref);
return <MyComponent ref={ref} />
}

How to share a single MUI useScrollTrigger return value among multiple components?

I am currently using MUI's useScrollTrigger hook to determine the appearance of three components - NavBar, a post FAB a back to top button e.g.:
export default function NavBar() {
const isScrolledDown = useScrollTrigger({ target: window, threshold: 100 });
return (
<>
<Slide in={!isScrolledDown} >
<AppBar>
<Toolbar>
</Toolbar>
</AppBar>
</Slide>
<Toolbar />
<BackToTopFAB isScrolledDown={isScrolledDown} />
<PostCreateFAB isScrolledDown={isScrolledDown} />
</>
);
}
Since I do not want to make the browser listen for three separate "scroll" events, I am currently drilling the hook's return value from the NavBar into the two buttons.
However, as a result, I am unable to decouple the two buttons from the NavBar.
Does anyone have any suggestions how this may be possible, so that all three components share the same hook return value? If having multiple "scroll" listeners is not DOM-intensive, I am also willing to consider that
React hook is designed to be reusable, you probably want to move the useScrollTrigger hook to the components that need it like below:
const useCustomScrollTrigger = () => useScrollTrigger({ target: window, threshold: 100 });
const BackToTopFAB = () => {
const isScrolledDown = useCustomScrollTrigger();
return (...)
}
const PostCreateFAB = () => {
const isScrolledDown = useCustomScrollTrigger();
return (...)
}
const MyAppBar = () => {
const isScrolledDown = useCustomScrollTrigger();
return (
<Slide in={!isScrolledDown} >
<AppBar />
</Slide>
)
}
export default function NavBar() {
return (
<>
<MyAppBar />
<OtherContent />
<BackToTopFAB />
<PostCreateFAB />
</>
);
}
Doing so has a couple of advantages:
Your code is easier to read because the logic is hidden away in each specific component. Code readability is one of the most important factors when choosing between trade-offs IMO. Several additional event listeners should never impact your application performance in any way.
Improve your the performance of the parent component since there is no props at the top-level component, if the isScrolledDown state is changed, only 3 isolated components are re-rendered as a result. Otherwise, other components in the page like OtherContent also need to be rendered because the state in the parent component changes.
You can also have a look at some react state management libraries like redux-toolkit if you want to store the state in a single place and access it anywhere in the components regardless of its position in the hierarchy:
import { createSlice } from '#reduxjs/toolkit'
const { actions } = createSlice({
name: 'globalState',
initialState: { isScrolledDown: false },
reducers: {
setIsScrolledDown: (state, action) => {
state.isScrolledDown = action.payload
},
},
})
const ScrollLisenter = () => {
const isScrolledDown = useScrollTrigger({ /* ... */ });
const dispatch = useDispatch()
useEffect(() => {
dispatch(actions.setIsScrolledDown(isScrolledDown));
}, [isScrolledDown]);
return null
}
const BackToTopFAB = () => {
const isScrolledDown = useSelector(state => state.globalState.isScrolledDown);
return (...)
}
const PostCreateFAB = () => {
const isScrolledDown = useSelector(state => state.globalState.isScrolledDown);
return (...)
}
<App>
<ScrollLisenter />
<NavBar />
</App>
Related Question
Does adding too many event listeners affect performance?

Test document listener with React Testing Library

I'm attempting to test a React component similar to the following:
import React, { useState, useEffect, useRef } from "react";
export default function Tooltip({ children }) {
const [open, setOpen] = useState(false);
const wrapperRef = useRef(null);
const handleClickOutside = (event) => {
if (
open &&
wrapperRef.current &&
!wrapperRef.current.contains(event.target)
) {
setOpen(false);
}
};
useEffect(() => {
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
});
const className = `tooltip-wrapper${(open && " open") || ""}`;
return (
<span ref={wrapperRef} className={className}>
<button type="button" onClick={() => setOpen(!open)} />
<span>{children}</span>
<br />
<span>DEBUG: className is {className}</span>
</span>
);
}
Clicking on the tooltip button changes the state to open (changing the className), and clicking again outside of the component changes it to closed.
The component works (with appropriate styling), and all of the React Testing Library (with user-event) tests work except for clicking outside.
it("should close the tooltip on click outside", () => {
// Arrange
render(
<div>
<p>outside</p>
<Tooltip>content</Tooltip>
</div>
);
const button = screen.getByRole("button");
userEvent.click(button);
// Temporary assertion - passes
expect(button.parentElement).toHaveClass("open");
// Act
const outside = screen.getByText("outside");
// Gives should be wrapped into act(...) warning otherwise
act(() => {
userEvent.click(outside);
});
// Assert
expect(button.parentElement).not.toHaveClass("open"); // FAILS
});
I don't understand why I had to wrap the click event in act - that's generally not necessary with React Testing Library.
I also don't understand why the final assertion fails. The click handler is called twice, but open is true both times.
There are a bunch of articles about limitations of React synthetic events, but it's not clear to me how to put all of this together.
I finally got it working.
it("should close the tooltip on click outside", async () => {
// Arrange
render(
<div>
<p data-testid="outside">outside</p>
<Tooltip>content</Tooltip>
</div>
);
const button = screen.getByRole("button");
userEvent.click(button);
// Verify initial state
expect(button.parentElement).toHaveClass("open");
const outside = screen.getByTestId("outside");
// Act
userEvent.click(outside);
// Assert
await waitFor(() => expect(button.parentElement).not.toHaveClass("open"));
});
The key seems to be to be sure that all activity completes before the test ends.
Say a test triggers a click event that in turn sets state. Setting state typically causes a rerender, and your test will need to wait for that to occur. Normally you do that by waiting for the new state to be displayed.
In this particular case waitFor was appropriate.

clickOutside hook triggers on inside select

I have a card component which consists of 2 selects and a button, select1 is always shown and select2 is invisible until you press the button changing the state. I also have an onClickOutside hook that reverts the state and hides select2 when you click outside the card.
The problem Im having is that in the case when select2 is visible, if you use any select and click on an option it registers as a click outside the card and hides select2, how can I fix this?
Heres the relevant code from my card component:
const divRef = useRef() as React.MutableRefObject<HTMLInputElement>;
const [disableSelect2, setDisableSelect2] = useState(true);
const handleActionButtonClick = () => {
setDisableSelect2(!disableSelect2)
}
useOutsideClick(divRef, () => {
if (!disableSelect2) {
setDisableSelect2(!disableSelect2);
}
});
return (
<div ref={divRef}>
<Card>
<Select1>[options]</Select1>
!disableSelect2 ?
<Select2>[options]</Select2>
: null
<div
className="d-c_r_action-button"
onClick={handleActionButtonClick}
>
</Card>
</div>
);
};
And this is my useoutsideClick hook
const useOutsideClick = (ref:React.MutableRefObject<HTMLInputElement>, callback:any) => {
const handleClick = (e:any) => {
if (ref.current && !ref.current.contains(e.target)) {
callback();
}
};
useEffect(() => {
document.addEventListener("click", handleClick);
return () => {
document.removeEventListener("click", handleClick);
};
});
};
Extra informtaion: Im using customized antd components and cant use MaterialUI
I tried to recreate your case from the code you shared. But the version I 'built' works.
Perhaps you can make it fail by adding in other special features from your case and then raise the issue again, or perhaps you could use the working code from there to fix yours?
See the draft of your problem I made at https://codesandbox.io/s/serverless-dust-njw0f?file=/src/Component.tsx

Jest testing library - multiple calls to load data on click event

I am trying to test a load more button call on an onClick fireEvent but I am having trouble simulating the click to trigger a load data.
component:
class Items extends Component {
// states
componentDidMount() {
this.getData()
}
getData() { ...
// get data from state - pagination # and data size
}
onLoadMore() {
// increment pagination & offset on states
this.getData()
}
render() {
return (
<div className='container'>
{items.map((item, i) => {
return (
<div className='item-box'>
// item info
</div>
)
}
)}
<button onClick={this.onLoadMore}>Load More</button>
</div>
)
}
}
test:
it('load more data on load more button click', () => {
const Items = require('./default').default
// set initial load values: initVals (2 items)
// set second call values: secondVals (4 items)
Items.prototype.getData = jest.fn()
Items.prototype.getData.mockReturnValue(initVals)
Items.prototype.getData.mockReturnValue(secondVals)
const { container } = render(
<Items
fields={{ loadMore: true }}
/>
)
const button = screen.getByText('Load More')
fireEvent.click(button)
expect(container.querySelectorAll('.item-box').length).toBe(2)
expect(container.querySelectorAll('.item-box').length).toBe(4)
})
So this only reads the last call, finding 4 items.
Calling .mockReturnValue() multiple times has only yielded me the last call instead of it consecutively. I know I am using it wrong but I can't figure out the sequence of running this. My goal is to initialize the component with first values (load 2 items), then on click, it loads more (4 items).
Help?
I think you need to call mockReturnValueOnce instead of mockReturnValue, and you definitely need to move your first assertion before clicking the event. Also, I think your second assertion should expect 6, not 4.
The order of operations for your test should be:
Set up mocks
Render component
Assert initial value on load
Simulate click event
Assert value after click.
Here is a simple example that demonstrates this concept:
// src/Demo.js
import React, { useState } from "react";
const Demo = ({ loadText }) => {
const [text, setText] = useState("");
return (
<div>
<button onClick={() => setText(loadText())}>Load</button>
<p data-testid="data">{text}</p>
</div>
);
};
export default Demo;
// src/Demo.test.js
import { fireEvent, render, screen } from "#testing-library/react";
import '#testing-library/jest-dom'
import Demo from "./Demo";
test("callbacks", () => {
const myMock = jest.fn();
myMock.mockReturnValueOnce(2);
myMock.mockReturnValueOnce(4);
render(<Demo loadText={myMock} />);
fireEvent.click(screen.getByRole("button"));
expect(screen.getByTestId("data")).toHaveTextContent("2");
fireEvent.click(screen.getByRole("button"));
expect(screen.getByTestId("data")).toHaveTextContent("4");
});
Also, the way you are mocking this function (Items.prototype.getData = jest.fn()) seems odd to me. I recommend you explore other options, such as (1) mocking out axios or similar library, (2) mocking out a redux store, or (3) mocking out props that you pass to this component.

Resources