diff --git a/package-lock.json b/package-lock.json index b57bd6f9..154a8350 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "@deriv-com/ui", "version": "0.0.0-development", "dependencies": { + "@deriv/quill-icons": "^1.22.5", "@types/react-modal": "^3.16.3" }, "devDependencies": { @@ -2174,6 +2175,15 @@ "node": ">=0.1.90" } }, + "node_modules/@deriv/quill-icons": { + "version": "1.22.5", + "resolved": "https://registry.npmjs.org/@deriv/quill-icons/-/quill-icons-1.22.5.tgz", + "integrity": "sha512-HsuM6yh/D3l+fQe7YSwUm25E9zoP7gP/S1/u524wRDFxUfNrq5N4VTpfy2Brl3fvRq0ModHjKaVcrB00Ebef1w==", + "peerDependencies": { + "react": ">= 16", + "react-dom": ">= 16" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", @@ -15322,8 +15332,7 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "3.14.1", @@ -15916,7 +15925,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -20447,7 +20455,6 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", - "dev": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -20505,7 +20512,6 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", - "dev": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -21180,7 +21186,6 @@ "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", - "dev": true, "dependencies": { "loose-envify": "^1.1.0" } diff --git a/package.json b/package.json index 45bf7bb5..265775e7 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "test:report": "jest --collectCoverage" }, "dependencies": { + "@deriv/quill-icons": "^1.22.5", "@types/react-modal": "^3.16.3" }, "devDependencies": { diff --git a/playground/index.tsx b/playground/index.tsx index 12b41819..8f67a326 100644 --- a/playground/index.tsx +++ b/playground/index.tsx @@ -1,9 +1,34 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' +import React from "react"; +import ReactDOM from "react-dom/client"; +import { Drawer, Button } from "../src/main"; +const App = () => { + const [isOpen, setIsOpen] = React.useState(false); -ReactDOM.createRoot(document.getElementById('root')!).render( - -
playground app
-
, -) + const handleCloseDrawer = () => { + setIsOpen(false); + }; + + return ( +
+ + + Menu + + this is the ss + Footer + + +
+ ); +}; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/src/components/AppLayout/Drawer/Drawer.scss b/src/components/AppLayout/Drawer/Drawer.scss new file mode 100644 index 00000000..f1f8647f --- /dev/null +++ b/src/components/AppLayout/Drawer/Drawer.scss @@ -0,0 +1,73 @@ +@keyframes openDrawer { + 0% { + transform: translateX(-100%); + } + 60%, + 100% { + transform: translateX(0); + } + 80% { + transform: translateX(-5%); + } +} + +@keyframes closeDrawer { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-100%); + } +} + +@keyframes disappear { + 0%{ + opacity: 1; + } + 100%{ + opacity: 0; + } +} + +.deriv-drawer__container { + background-color: white; + position: absolute; + width: 60%; + left: 0; + top: 0; + bottom: 0; + box-shadow: + 0 16px 16px 0 rgba(0, 0, 0, 0.16), + 0 0 16px 0 rgba(0, 0, 0, 0.16); + z-index: 10000; + display: flex; + flex-direction: column; + will-change: transform; + transform: translateX(-100%); + animation-name: openDrawer; + animation-duration: 0.4s; + animation-fill-mode: forwards; + overflow: hidden; + + &.exit { + animation: closeDrawer 0.3s; + } +} + +.deriv-drawer__overlay { + background-color: rgba(0, 0, 0, 0.5); + position: absolute; + inset: 0; + margin: 0; + overflow-y: hidden; + z-index: 9999; + will-change: transform; + transition-delay: 0.4s; + + &.exit { + animation: disappear 0.3s; + opacity: 0; + } +} + + diff --git a/src/components/AppLayout/Drawer/DrawerContent/DrawerContent.scss b/src/components/AppLayout/Drawer/DrawerContent/DrawerContent.scss new file mode 100644 index 00000000..a4430c95 --- /dev/null +++ b/src/components/AppLayout/Drawer/DrawerContent/DrawerContent.scss @@ -0,0 +1,4 @@ +.deriv-drawer__content { + flex: 1; + overflow-y: auto; +} \ No newline at end of file diff --git a/src/components/AppLayout/Drawer/DrawerContent/__test__/DrawerContent.spec.tsx b/src/components/AppLayout/Drawer/DrawerContent/__test__/DrawerContent.spec.tsx new file mode 100644 index 00000000..26796398 --- /dev/null +++ b/src/components/AppLayout/Drawer/DrawerContent/__test__/DrawerContent.spec.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { DrawerContent } from "../"; + +describe("DrawerContent Component", () => { + it("renders the component correctly", () => { + render( + +
some test content
+
, + ); + + const contentElement = screen.getByText("some test content"); + expect(contentElement).toBeInTheDocument(); + }); + + it("applies className correctly", () => { + render( + + some test content + , + ); + + const contentElement = screen.getByText("some test content"); + expect(contentElement).toBeInTheDocument(); + expect(contentElement).toHaveClass("test-class"); + }); +}); diff --git a/src/components/AppLayout/Drawer/DrawerContent/index.tsx b/src/components/AppLayout/Drawer/DrawerContent/index.tsx new file mode 100644 index 00000000..31a94eca --- /dev/null +++ b/src/components/AppLayout/Drawer/DrawerContent/index.tsx @@ -0,0 +1,20 @@ +import { ComponentProps, PropsWithChildren } from "react"; + +import "./DrawerContent.scss"; +import clsx from "clsx"; + +type DrawerContentProps = ComponentProps<"div">; +export const DrawerContent = ({ + children, + className, + ...rest +}: PropsWithChildren) => { + return ( +
+ {children} +
+ ); +}; + +DrawerContent.displayName = "DrawerContent"; + diff --git a/src/components/AppLayout/Drawer/DrawerFooter/DrawerFooter.scss b/src/components/AppLayout/Drawer/DrawerFooter/DrawerFooter.scss new file mode 100644 index 00000000..8edbad8f --- /dev/null +++ b/src/components/AppLayout/Drawer/DrawerFooter/DrawerFooter.scss @@ -0,0 +1,6 @@ +.deriv-drawer__footer { + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid var(--du-border-divider, #f2f3f4); +} \ No newline at end of file diff --git a/src/components/AppLayout/Drawer/DrawerFooter/__test__/DrawerFooter.spec.tsx b/src/components/AppLayout/Drawer/DrawerFooter/__test__/DrawerFooter.spec.tsx new file mode 100644 index 00000000..838073c5 --- /dev/null +++ b/src/components/AppLayout/Drawer/DrawerFooter/__test__/DrawerFooter.spec.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { DrawerFooter } from "../"; + +describe("DrawerFooter Component", () => { + it("renders the component correctly", () => { + render( + +
footer content
+
, + ); + + const contentElement = screen.getByText("footer content"); + expect(contentElement).toBeInTheDocument(); + }); + + it("applies className correctly", () => { + render( + + some test content + , + ); + + const contentElement = screen.getByText("some test content"); + screen.debug(); + expect(contentElement).toBeInTheDocument(); + expect(contentElement).toHaveClass("deriv-drawer__footer test-class"); + }); +}); diff --git a/src/components/AppLayout/Drawer/DrawerFooter/index.tsx b/src/components/AppLayout/Drawer/DrawerFooter/index.tsx new file mode 100644 index 00000000..61093daa --- /dev/null +++ b/src/components/AppLayout/Drawer/DrawerFooter/index.tsx @@ -0,0 +1,19 @@ +import { ComponentProps, PropsWithChildren } from "react"; + +import "./DrawerFooter.scss"; +import clsx from "clsx"; + +type DrawerFooterProps = ComponentProps<"div">; +export const DrawerFooter = ({ + children, + className, + ...rest +}: PropsWithChildren) => { + return ( +
+ {children} +
+ ); +}; + +DrawerFooter.displayName = "DrawerFooter"; diff --git a/src/components/AppLayout/Drawer/DrawerHeader/DrawerHeader.scss b/src/components/AppLayout/Drawer/DrawerHeader/DrawerHeader.scss new file mode 100644 index 00000000..41add271 --- /dev/null +++ b/src/components/AppLayout/Drawer/DrawerHeader/DrawerHeader.scss @@ -0,0 +1,23 @@ +.deriv-drawer__header { + display: flex; + height: 50px; + z-index: 1001; + border-bottom: 1px solid var(--du-border-divider, #f2f3f4); + + + &__close-btn{ + display: flex; + cursor: pointer; + padding: 12px; + border-right: 1px solid var(--du-border-divider, #f2f3f4); + align-items: center; + margin: 5px 12px 5px 0; + } + + &__content { + display: flex; + flex: 1; + overflow-y: auto; + align-items: center; + } +} \ No newline at end of file diff --git a/src/components/AppLayout/Drawer/DrawerHeader/__test__/DrawerHeader.spec.tsx b/src/components/AppLayout/Drawer/DrawerHeader/__test__/DrawerHeader.spec.tsx new file mode 100644 index 00000000..cf89df4f --- /dev/null +++ b/src/components/AppLayout/Drawer/DrawerHeader/__test__/DrawerHeader.spec.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { DrawerHeader } from ".."; + +describe("DrawerHeader Component", () => { + const onCloseDrawerMock = jest.fn(); + + beforeEach(() => { + onCloseDrawerMock.mockClear(); + }); + + it("renders Drawer correctly", () => { + render( + + Test Header Content + , + ); + + const headerContentElement = screen.getByText("Test Header Content"); + expect(headerContentElement).toBeInTheDocument(); + }); + + it("applies wrapper class correctly", () => { + const wrapperClassName = "test-class"; + render( + + Test Header Content + , + ); + + const headerContentElement = screen.getByText("Test Header Content"); + expect(headerContentElement).toBeInTheDocument(); + expect(headerContentElement.parentElement).toHaveClass( + "deriv-drawer__header", + wrapperClassName, + ); + }); + + it("applies wrapper class correctly", () => { + const className = "test-class"; + render( + + Test Header Content + , + ); + + const headerContentElement = screen.getByText("Test Header Content"); + expect(headerContentElement).toBeInTheDocument(); + expect(headerContentElement).toHaveClass( + "deriv-drawer__header__content", + className, + ); + }); + + it("calls onCloseDrawer when close button is clicked", async () => { + render( + + Test Header Content + , + ); + + const closeButton = screen.getByRole("drawer-close-button"); + expect(closeButton).toBeInTheDocument(); + + await fireEvent.click(closeButton); + + expect(onCloseDrawerMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/AppLayout/Drawer/DrawerHeader/index.tsx b/src/components/AppLayout/Drawer/DrawerHeader/index.tsx new file mode 100644 index 00000000..fa907a4d --- /dev/null +++ b/src/components/AppLayout/Drawer/DrawerHeader/index.tsx @@ -0,0 +1,47 @@ +import { ComponentProps, PropsWithChildren } from "react"; +import { LegacyClose2pxIcon } from "@deriv/quill-icons"; + +import "./DrawerHeader.scss"; +import clsx from "clsx"; + +type DrawerHeaderProps = ComponentProps<"div"> & { + onCloseDrawer: () => void; + wrapperClassName?: string; +}; + +/** + * DrawerHeader component. + * @param {VoidFunction} onCloseDrawer + * @param {React.ReactNode} children - The children nodes to be rendered inside the header. + * @param {string} className - Applies on the right side content wrapper of the Drawerheader. + * @param {string} WrapperClassName - Applies on the header wrapper + * + * @returns {JSX.Element} - Returns the header element. + */ +export const DrawerHeader = ({ + onCloseDrawer, + children, + className, + wrapperClassName, + ...rest +}: PropsWithChildren) => { + return ( +
+
+ +
+
+ {children} +
+
+ ); +}; + +DrawerHeader.displayName = "DrawerHeader"; diff --git a/src/components/AppLayout/Drawer/__test__/Drawer.spec.tsx b/src/components/AppLayout/Drawer/__test__/Drawer.spec.tsx new file mode 100644 index 00000000..26913638 --- /dev/null +++ b/src/components/AppLayout/Drawer/__test__/Drawer.spec.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Drawer } from ".."; + +describe("Drawer Component", () => { + const onCloseDrawerMock = jest.fn(); + + beforeEach(() => { + onCloseDrawerMock.mockClear(); + }); + + test("renders children when isOpen is true", () => { + render( + +
Test Drawer Content
+
, + ); + + const drawerContentElement = screen.getByText("Test Drawer Content"); + expect(drawerContentElement).toBeInTheDocument(); + }); + + test("does not render children when isOpen is false", () => { + const testText = "Test Drawer Content"; + render( + +
{testText}
+
, + ); + + const drawerContentElement = screen.queryByText(testText); + expect(drawerContentElement).not.toBeInTheDocument(); + }); + + test("calls onCloseDrawer when overlay is clicked", async () => { + render( + +
Test Drawer Content
+
+ ); + + const overlayElement = screen.getByTestId("drawer-overlay"); + await fireEvent.click(overlayElement); + + expect(onCloseDrawerMock).toHaveBeenCalledTimes(1); + }); + + test("apply the width to the drawer", async () => { + render( + + Test Drawer Content + + ); + + const overlayElement = screen.getByText("Test Drawer Content"); + expect(overlayElement).toHaveStyle("width: 400px"); + + }); +}); diff --git a/src/components/AppLayout/Drawer/index.tsx b/src/components/AppLayout/Drawer/index.tsx new file mode 100644 index 00000000..49a6bcfb --- /dev/null +++ b/src/components/AppLayout/Drawer/index.tsx @@ -0,0 +1,76 @@ +import React, { ComponentProps, useEffect, useRef, useState } from "react"; +import { DrawerHeader } from "./DrawerHeader"; +import { DrawerContent } from "./DrawerContent"; +import { DrawerFooter } from "./DrawerFooter"; + +import "./Drawer.scss"; +import clsx from "clsx"; + +type DrawerProps = ComponentProps<"div"> & { + isOpen: boolean; + onCloseDrawer: () => void; + width?: string; + overlayClassName?: string; +}; + +export const Drawer = ({ + isOpen, + onCloseDrawer, + width = "auto", + children, + className, + overlayClassName, + ...rest +}: React.PropsWithChildren) => { + const [hide, setHide] = useState(true); + const timerRef = useRef | null>(null); + + useEffect(() => { + if (isOpen) setHide(false); + else { + timerRef.current = setTimeout(() => { + setHide(!isOpen); + }, 400); + } + return () => { + timerRef.current && clearTimeout(timerRef.current); + }; + }, [isOpen]); + + if (hide) return null; + + return ( + <> +
{ + e.stopPropagation(); + onCloseDrawer(); + }} + /> +
+ {children} +
+ + ); +}; + +Drawer.Header = DrawerHeader; +Drawer.Content = DrawerContent; +Drawer.Footer = DrawerFooter; + +Drawer.displayName = "Drawer"; + diff --git a/src/components/AppLayout/SideBarMenu/index.tsx b/src/components/AppLayout/SideBarMenu/index.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/src/components/AppLayout/index.ts b/src/components/AppLayout/index.ts index 3189715c..b01cea9d 100644 --- a/src/components/AppLayout/index.ts +++ b/src/components/AppLayout/index.ts @@ -1,3 +1,4 @@ -export { Header } from "./Header"; +export { Drawer } from "./Drawer"; export { Footer } from "./Footer"; +export { Header } from "./Header"; export { Wrapper } from "./Wrapper"; diff --git a/src/main.ts b/src/main.ts index dfe40a8b..624794bd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,4 +23,4 @@ export { ToggleSwitch } from "./components/ToggleSwitch"; export { Tooltip } from "./components/Tooltip"; export { useDevice } from "./hooks"; export { VerticalTab, VerticalTabItems } from "./components/VerticalTab"; -export { Header, Footer, Wrapper } from "./components/AppLayout"; +export { Header, Footer, Wrapper, Drawer } from "./components/AppLayout"; diff --git a/stories/Drawer.stories.tsx b/stories/Drawer.stories.tsx new file mode 100644 index 00000000..66524ac8 --- /dev/null +++ b/stories/Drawer.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { Drawer, Button } from "../src/main"; +import { useEffect, useState } from "react"; + +const meta = { + title: "Components/Drawer", + component: Drawer, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + args: { + children: , + isOpen: false, + onCloseDrawer: () => {}, + }, + argTypes: { + children: { + description: + "The content of the Drawer.you can use the `Drawer.Header`, `Drawer.Body`, and `Drawer.Footer` components to structure the content of the Drawer.", + control: false, + }, + isOpen: { + description: "controls the visibility of the Drawer", + control: { + type: "boolean", + }, + }, + onCloseDrawer: { + description: "Callback function to close the Drawer", + control: false, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + name: "Default Drawer", + args: { + isOpen: false, + }, + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + const handleCloseDrawer = () => { + setIsOpen(false); + }; + useEffect(() => { + setIsOpen(args.isOpen); + }, [args.isOpen]); + return ( +
+ + + Menu + + this is the content + Footer + + +
+ ); + }, +};