diff --git a/.changeset/wet-keys-occur.md b/.changeset/wet-keys-occur.md new file mode 100644 index 0000000..12ec744 --- /dev/null +++ b/.changeset/wet-keys-occur.md @@ -0,0 +1,5 @@ +--- +"@openfun/cunningham-react": minor +--- + +Position Datepicker popover on top or bottom depending space available diff --git a/packages/react/src/components/Popover/index.spec.tsx b/packages/react/src/components/Popover/index.spec.tsx new file mode 100644 index 0000000..e5df328 --- /dev/null +++ b/packages/react/src/components/Popover/index.spec.tsx @@ -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(null); + + return ( +
+
Other container
+
Parent container
+ + Hello Popover + +
+ ); +}; + +describe("", () => { + it("checks the render", async () => { + render(); + + 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(); + expect(screen.getByRole("dialog")).toHaveClass("c__popover--borderless"); + }); + + it("checks onClickOutside prop", async () => { + const mockOnClickOutside = vi.fn(); + render(); + + 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() as any; + ref.current = { + offsetTop: 400, + clientHeight: 100, + getBoundingClientRect: () => ({ + top: 700, + }), + }; + + render( {}} 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() as any; + ref.current = { + offsetTop: 400, + clientHeight: 0, + getBoundingClientRect: () => ({ + top: 800, + }), + }; + + render( {}} 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() as any; + ref.current = { + offsetTop: 500, + clientHeight: 100, + getBoundingClientRect: () => ({ + top: 200, + }), + }; + + render( {}} parentRef={ref} />); + + expect(await screen.findByRole("dialog")).toHaveStyle({ + top: undefined, + }); + }); +}); diff --git a/packages/react/src/components/Popover/index.stories.tsx b/packages/react/src/components/Popover/index.stories.tsx index 6998933..c7aac27 100644 --- a/packages/react/src/components/Popover/index.stories.tsx +++ b/packages/react/src/components/Popover/index.stories.tsx @@ -14,23 +14,31 @@ export const Default = () => { const parentRef = useRef(null); return ( -
- - {isOpen && ( - setIsOpen(false)}> -
+
+ + {isOpen && ( + setIsOpen(false)} > - I am open -
- - )} +
+ I am open +
+ + )} +
); }; diff --git a/packages/react/src/components/Popover/index.tsx b/packages/react/src/components/Popover/index.tsx index db42a4a..15ba616 100644 --- a/packages/react/src/components/Popover/index.tsx +++ b/packages/react/src/components/Popover/index.tsx @@ -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(null); useHandleClickOutside(parentRef, onClickOutside); + const timeout = useRef>(); + const [topPosition, setTopPosition] = useState(); + + 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 (
{children}