(react) position top datepicker

Position the datepicker on top of the input when the input is
at the bottom of the screen and whenthere is enough space to display
the datepicker on the top.
This commit is contained in:
Anthony Le Courric
2023-08-03 15:38:06 +02:00
committed by Jean-Baptiste PENRATH
parent 6ea8544fed
commit 9edb9765db
4 changed files with 198 additions and 17 deletions

View File

@@ -0,0 +1,5 @@
---
"@openfun/cunningham-react": minor
---
Position Datepicker popover on top or bottom depending space available

View File

@@ -0,0 +1,121 @@
import React, { useRef } from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Popover } from ".";
interface TestComponentProps {
borderless?: boolean;
onClickOutside?: () => void;
}
const TestComponent = ({
borderless,
onClickOutside = () => {},
}: TestComponentProps) => {
const parentRef = useRef<HTMLDivElement>(null);
return (
<div>
<div>Other container</div>
<div ref={parentRef}>Parent container</div>
<Popover
parentRef={parentRef}
onClickOutside={onClickOutside}
borderless={borderless}
>
Hello Popover
</Popover>
</div>
);
};
describe("<Popover />", () => {
it("checks the render", async () => {
render(<TestComponent />);
expect(screen.getByText("Parent container")).toBeInTheDocument();
expect(screen.getByText("Hello Popover")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toHaveClass(
"c__popover--borderless",
);
});
it("checks the borderless prop", async () => {
render(<TestComponent borderless />);
expect(screen.getByRole("dialog")).toHaveClass("c__popover--borderless");
});
it("checks onClickOutside prop", async () => {
const mockOnClickOutside = vi.fn();
render(<TestComponent onClickOutside={mockOnClickOutside} />);
await userEvent.click(screen.getByText("Parent container"));
expect(mockOnClickOutside).not.toHaveBeenCalled();
await userEvent.click(screen.getByText("Other container"));
expect(mockOnClickOutside).toHaveBeenCalledTimes(1);
});
it("checks popover position top with parent not as origin", async () => {
// by default window.innerHeight === 768
const ref = React.createRef<HTMLDivElement>() as any;
ref.current = {
offsetTop: 400,
clientHeight: 100,
getBoundingClientRect: () => ({
top: 700,
}),
};
render(<Popover onClickOutside={() => {}} parentRef={ref} />);
waitFor(
() => {
expect(screen.getByRole("dialog")).toHaveStyle({
top: "400px",
});
},
{ timeout: 1000 },
);
});
it("checks popover position top with parent as origin", async () => {
// by default window.innerHeight === 768
const ref = React.createRef<HTMLDivElement>() as any;
ref.current = {
offsetTop: 400,
clientHeight: 0,
getBoundingClientRect: () => ({
top: 800,
}),
};
render(<Popover onClickOutside={() => {}} parentRef={ref} />);
waitFor(
() => {
expect(screen.getByRole("dialog")).toHaveStyle({
top: "0px",
});
},
{ timeout: 1000 },
);
});
it("checks popover position bottom", async () => {
// by default window.innerHeight === 768
const ref = React.createRef<HTMLDivElement>() as any;
ref.current = {
offsetTop: 500,
clientHeight: 100,
getBoundingClientRect: () => ({
top: 200,
}),
};
render(<Popover onClickOutside={() => {}} parentRef={ref} />);
expect(await screen.findByRole("dialog")).toHaveStyle({
top: undefined,
});
});
});

View File

@@ -14,23 +14,31 @@ export const Default = () => {
const parentRef = useRef<HTMLDivElement>(null);
return (
<div ref={parentRef} style={{ width: "fit-content" }}>
<Button onClick={() => setIsOpen(!isOpen)}>Toggle</Button>
{isOpen && (
<Popover parentRef={parentRef} onClickOutside={() => setIsOpen(false)}>
<div
style={{
height: "200px",
width: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
<div style={{ display: "grid", height: "120vh", placeItems: "center" }}>
<div
ref={parentRef}
style={{ width: "fit-content", position: "relative" }}
>
<Button onClick={() => setIsOpen(!isOpen)}>Toggle</Button>
{isOpen && (
<Popover
parentRef={parentRef}
onClickOutside={() => setIsOpen(false)}
>
I am open
</div>
</Popover>
)}
<div
style={{
height: "200px",
width: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
I am open
</div>
</Popover>
)}
</div>
</div>
);
};

View File

@@ -1,4 +1,10 @@
import React, { PropsWithChildren, RefObject } from "react";
import React, {
PropsWithChildren,
RefObject,
useLayoutEffect,
useRef,
useState,
} from "react";
import classNames from "classnames";
import { useHandleClickOutside } from ":/hooks/useHandleClickOutside";
@@ -14,13 +20,54 @@ export const Popover = ({
onClickOutside,
borderless = false,
}: PopoverProps) => {
const popoverRef = useRef<HTMLDivElement>(null);
useHandleClickOutside(parentRef, onClickOutside);
const timeout = useRef<ReturnType<typeof setTimeout>>();
const [topPosition, setTopPosition] = useState<number | undefined>();
useLayoutEffect(() => {
const setPopoverTopPosition = () => {
if (!parentRef.current || !popoverRef.current) return;
const parentBounds = parentRef.current.getBoundingClientRect();
const popoverBounds = popoverRef.current.getBoundingClientRect();
const hasNotEnoughBottomPlace =
window.innerHeight - parentBounds.bottom < popoverBounds.height;
const hasEnoughTopPlace = parentBounds.top >= popoverBounds.height;
if (hasNotEnoughBottomPlace && hasEnoughTopPlace) {
setTopPosition(-popoverBounds.height);
} else {
setTopPosition(undefined);
}
};
const handleWindowResize = () => {
if (timeout.current) clearTimeout(timeout.current);
timeout.current = setTimeout(setPopoverTopPosition, 1000 / 30);
};
window.addEventListener("resize", handleWindowResize);
setPopoverTopPosition();
return () => {
window.removeEventListener("resize", handleWindowResize);
if (timeout.current) clearTimeout(timeout.current);
};
}, []);
return (
<div
ref={popoverRef}
className={classNames("c__popover", {
"c__popover--borderless": borderless,
})}
style={{
top: topPosition,
}}
role="dialog"
>
{children}
</div>