Skip to content

Commit

Permalink
Merge pull request #214 from deriv-com/drawer-component
Browse files Browse the repository at this point in the history
Niloofar/ Submenu component
  • Loading branch information
shayan-deriv authored Jun 10, 2024
2 parents b95b21a + 9f27004 commit 9830c40
Show file tree
Hide file tree
Showing 6 changed files with 205 additions and 0 deletions.
33 changes: 33 additions & 0 deletions src/components/AppLayout/Submenu /Submenu.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
@keyframes openSubmenu {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(0);
}
}

@keyframes closeSubmenu {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100%);
}
}

.submenu {
background-color: #fff;
position: absolute;
inset: 0;
will-change: transform;
transform: translateX(-100%);
animation-name: openSubmenu;
animation-duration: 0.4s;
animation-fill-mode: forwards;
overflow: hidden;

&_exit {
animation: closeDrawer 0.3s;
}
}
40 changes: 40 additions & 0 deletions src/components/AppLayout/Submenu /__tests__/Submenu.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { render, screen } from "@testing-library/react";
import { Submenu } from "..";

const mockText = "Submenu test content";

describe("Submenu Component", () => {
it("renders when isOpen is true", () => {
render(
<Submenu isOpen>
<p>{mockText}</p>
</Submenu>,
);
expect(screen.getByText(mockText)).toBeInTheDocument();
});

it("does not render when isOpen is false", () => {
render(
<Submenu isOpen={false}>
<p>{mockText}</p>
</Submenu>,
);
expect(screen.queryByText(mockText)).not.toBeInTheDocument();
});

it("applies exit animation class when isOpen changes to false", () => {
const { rerender, container } = render(
<Submenu isOpen>
<p>{mockText}</p>
</Submenu>,
);
expect(container.firstChild).toHaveClass("submenu");

rerender(
<Submenu isOpen={false}>
<p>{mockText}</p>
</Submenu>,
);
expect(container.firstChild).toHaveClass("submenu_exit");
});
});
71 changes: 71 additions & 0 deletions src/components/AppLayout/Submenu /index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
useState,
ComponentProps,
PropsWithChildren,
useRef,
useEffect,
} from "react";
import clsx from "clsx";
import "./Submenu.scss";

type TSubmenu = ComponentProps<"div"> & {
isOpen: boolean;
};

/**
* `Submenu` is a component that renders a collapsible/expandable menu which supports an exit animation when closed.
* The component will remain in the DOM just long enough to perform the exit animation before it is unmounted,
* ensuring a smooth user experience. It utilizes the CSS class `submenu_exit` to apply styles for the exit animation.
*
* @component
* @param {React.ReactNode} [props.children] - The content to be rendered inside the submenu. This is optional and can be any React node.
* @param {string} [props.className] - Optional CSS class to be applied to the submenu container for additional styling.
* @param {boolean} props.isOpen - A boolean flag to control the visibility of the submenu. When set to true, the submenu is open or visible. When set to false, the submenu will start the exit animation and then unmount.
*
* @returns {JSX.Element|null} The JSX code for the submenu if it is mounted; otherwise, null if it is not mounted.
*
* @example
* // To use the Submenu component:
* <Submenu isOpen={true} className="custom-submenu">
* <p>Menu Content Here</p>
* </Submenu>
*
* @example
* // To trigger an exit animation, change isOpen to false:
* <Submenu isOpen={false} className="custom-submenu">
* <p>Menu Content Here</p>
* </Submenu>
*/
export const Submenu = ({
children,
className,
isOpen,
...rest
}: PropsWithChildren<TSubmenu>) => {
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [isClosing, setIsClosing] = useState(true);

useEffect(() => {
if (isOpen) setIsClosing(false);
else {
timerRef.current = setTimeout(() => {
setIsClosing(!isOpen);
}, 400);
}
return () => {
timerRef.current && clearTimeout(timerRef.current);
};
}, [isOpen]);

if (isClosing) return null;
return (
<div
className={clsx("submenu", { submenu_exit: !isOpen }, className)}
{...rest}
>
{children}
</div>
);
};

Submenu.displayName = "Submenu";
1 change: 1 addition & 0 deletions src/components/AppLayout/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export { PlatformSwitcherItem } from "./PlatformSwitcher/PlatformSwitcherItem";
export { DesktopLanguagesModal } from "./LanguagesSwitcher/DesktopLanguagesModal";
export { MobileLanguagesDrawer } from "./LanguagesSwitcher/MobileLanguagesDrawer";
export { TooltipMenuIcon } from "./TooltipMenuIcon";
export { Submenu } from "./Submenu ";
1 change: 1 addition & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,6 @@ export {
MobileLanguagesDrawer,
Notifications,
TooltipMenuIcon,
Submenu,
} from "./components/AppLayout";
export { ContextMenu } from "./components/ContextMenu";
59 changes: 59 additions & 0 deletions stories/Submenu.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { Submenu } from "../src/main";

const meta = {
title: "Components/Submenu",
component: Submenu,
args: {
children: <span>Test Children</span>,
className: "",
isOpen: false,
},
argTypes: {
children: {
control: false,
description:
"The children nodes provided to the submenu panel, which are displayed below the submenuContent.",
},
isOpen: {
description:
"A boolean flag to control the visibility of the submenu. When set to true, the submenu is open or visible. When set to false, the submenu will start the exit animation and then unmount.",
},
className: {
control: false,
description:
"Optional custom class name for styling the toggle button.",
},
},
parameters: { layout: "left" },
tags: ["autodocs"],
} satisfies Meta<typeof Submenu>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
name: "Submenu",
render: () => {
const [isOpen, setIsOpen] = useState(false);

return (
<div
style={{
height: "400px",
width: "300px",
backgroundColor: "yellowgreen",
position: "relative",
padding: "20px",
}}
>
<button onClick={() => setIsOpen(true)}>Click me</button>
<Submenu isOpen={isOpen}>
<div>Test children</div>
<button onClick={() => setIsOpen(false)}>Close me</button>
</Submenu>
</div>
);
},
};

0 comments on commit 9830c40

Please sign in to comment.