From 24cdf72495ca02a569e7951925230b5407c03aa3 Mon Sep 17 00:00:00 2001 From: Niloofar Date: Wed, 8 May 2024 18:11:21 +0800 Subject: [PATCH 1/2] feat: menu item component --- .../AppLayout/Header/MenuItem/MenuItem.scss | 15 ++++ .../AppLayout/Header/MenuItem/index.tsx | 70 +++++++++++++++++++ src/components/AppLayout/Header/index.tsx | 2 + .../AppLayout/__test__/MenuItem.spec.tsx | 63 +++++++++++++++++ stories/MenuItem.stories.tsx | 36 ++++++++++ 5 files changed, 186 insertions(+) create mode 100644 src/components/AppLayout/Header/MenuItem/MenuItem.scss create mode 100644 src/components/AppLayout/Header/MenuItem/index.tsx create mode 100644 src/components/AppLayout/__test__/MenuItem.spec.tsx create mode 100644 stories/MenuItem.stories.tsx diff --git a/src/components/AppLayout/Header/MenuItem/MenuItem.scss b/src/components/AppLayout/Header/MenuItem/MenuItem.scss new file mode 100644 index 00000000..3a146b33 --- /dev/null +++ b/src/components/AppLayout/Header/MenuItem/MenuItem.scss @@ -0,0 +1,15 @@ +.deriv-menu-item { + display: flex; + align-items: center; + height: 100%; + + &:hover { + background: var(--du-general-hover, #e6e9e9); + } + + &--active { + &:hover { + background: transparent; + } + } +} diff --git a/src/components/AppLayout/Header/MenuItem/index.tsx b/src/components/AppLayout/Header/MenuItem/index.tsx new file mode 100644 index 00000000..51ac5d0c --- /dev/null +++ b/src/components/AppLayout/Header/MenuItem/index.tsx @@ -0,0 +1,70 @@ +import { + ComponentProps, + PropsWithChildren, + ReactNode, + createElement, +} from "react"; +import clsx from "clsx"; +import "./MenuItem.scss"; + +/** + * Type definition for MenuItem props. + * @typedef TMenuItem + * @property {'a' | 'button'} as - The element type to render, 'a' for anchor or 'button' for button. + * @property {ReactNode} leftComponent - Optional component to display on the left side. + * @property {ReactNode} rightComponent - Optional component to display on the right side. + * @property {boolean} disableHover - If true, disables hover effects. + * @property {boolean} active - If true, applies an active state style. + */ +type TMenuItem = ComponentProps<"a" | "button"> & { + as: "a" | "button"; + leftComponent?: ReactNode; + rightComponent?: ReactNode; + disableHover?: boolean; + active?: boolean; +}; + +/** + * MenuItem component that can render as either an anchor or a button element, with optional left and right components. + * The component uses the `as` prop to determine which HTML element to render. + * It supports additional HTML attributes which are spread into the resulting element. + * + * @param {PropsWithChildren} props - The props object for the MenuItem component. + * @param {'a' | 'button'} props.as - Determines the element type ('a' or 'button'). + * @param {ReactNode} props.leftComponent - Optional component rendered on the left side of the MenuItem. + * @param {ReactNode} props.children - The main content of the MenuItem. + * @param {ReactNode} props.rightComponent - Optional component rendered on the right side of the MenuItem. + * @param {boolean} props.disableHover - If set to true, no hover effects are applied. + * @param {boolean} props.active - If set to true, the 'active' styling is applied. + * @param {string} props.className - Additional className for custom styling. + * @param {Object} props.otherProps - Spread into the element as additional HTML attributes. + * @returns {React.ReactElement} A React Element of type 'a' or 'button' based on the 'as' prop. + */ +export const MenuItem = ({ + as, + leftComponent, + children, + rightComponent, + disableHover, + active, + className, + ...props +}: PropsWithChildren) => { + const content = { + className: clsx( + "deriv-menu-item", + { "deriv-menu-item--active": active || disableHover }, + className, + ), + children: [ + createElement("div", { key: "leftComponent" }, leftComponent), + createElement("div", { key: "mainChildren" }, children), + createElement("div", { key: "rightComponent" }, rightComponent), + ], + ...props, + }; + + return createElement(as, { ...content }); +}; + +MenuItem.displayName = "MenuItem"; diff --git a/src/components/AppLayout/Header/index.tsx b/src/components/AppLayout/Header/index.tsx index e7b39144..9ceb5526 100644 --- a/src/components/AppLayout/Header/index.tsx +++ b/src/components/AppLayout/Header/index.tsx @@ -1,6 +1,7 @@ import { ComponentProps, PropsWithChildren } from "react"; import clsx from "clsx"; import { DerivLogo } from "./DerivLogo"; +import { MenuItem } from "./MenuItem"; import "./Header.scss"; /** @@ -21,5 +22,6 @@ export const Header = ({ ); Header.DerivLogo = DerivLogo; +Header.MenuItem = MenuItem; Header.displayName = "Header"; diff --git a/src/components/AppLayout/__test__/MenuItem.spec.tsx b/src/components/AppLayout/__test__/MenuItem.spec.tsx new file mode 100644 index 00000000..c93c9486 --- /dev/null +++ b/src/components/AppLayout/__test__/MenuItem.spec.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MenuItem } from "../Header/MenuItem"; + +describe("MenuItem Component", () => { + it('renders as a button when "as" prop is "button"', () => { + render( + + Click me + , + ); + const button = screen.getByRole("button", { name: "Click me" }); + expect(button).toBeInTheDocument(); + expect(button.tagName).toBe("BUTTON"); + }); + + it('renders as a link when "as" prop is "a" and href is provided', () => { + const href = "https://example.com"; + render( + + Visit Example + , + ); + const link = screen.getByRole("link", { name: "Visit Example" }); + expect(link).toBeInTheDocument(); + expect(link.tagName).toBe("A"); + expect(link).toHaveAttribute("href", href); + }); + + it("applies active and disableHover classes correctly", () => { + render( + + Click me + , + ); + const button = screen.getByRole("button", { name: "Click me" }); + expect(button).toHaveClass("deriv-menu-item"); + expect(button).toHaveClass("deriv-menu-item--active"); + expect(button).toHaveClass("custom-class"); + }); + + it("handles onClick event for button", async () => { + const mockClick = jest.fn(); + render( + + Click me + , + ); + const button = screen.getByRole("button", { name: "Click me" }); + await userEvent.click(button); + expect(mockClick).toHaveBeenCalledTimes(1); + }); + + it("does not break when missing optional props", () => { + render( + + Click me + , + ); + const button = screen.getByRole("button", { name: "Click me" }); + expect(button).toBeInTheDocument(); + }); +}); diff --git a/stories/MenuItem.stories.tsx b/stories/MenuItem.stories.tsx new file mode 100644 index 00000000..aa05f236 --- /dev/null +++ b/stories/MenuItem.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Header } from "../src/main"; +import { LegacyAdsIcon, LegacyWhatsappIcon } from "@deriv/quill-icons"; + +const meta = { + title: "Components/MenuItem", + component: Header.MenuItem, + args: { + as: "a", + leftComponent: , + rightComponent: , + disableHover: false, + active: true, + }, + argTypes: { + as: { control: false }, + leftComponent: { control: false }, + rightComponent: { control: false }, + disableHover: { control: false }, + active: { control: false }, + }, + parameters: { layout: "centered" }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + name: "Header.MenuItem", + render: (args) => ( + + Menu Item + + ), +}; From 7e45efe771f5e8a08215cfc4de7a3732fc8154cd Mon Sep 17 00:00:00 2001 From: Niloofar Date: Thu, 9 May 2024 11:32:57 +0800 Subject: [PATCH 2/2] fix: review comments --- playground/index.tsx | 47 ++++++++----------- .../AppLayout/Header/MenuItem/MenuItem.scss | 1 + .../AppLayout/Header/MenuItem/index.tsx | 39 +++++++-------- 3 files changed, 40 insertions(+), 47 deletions(-) diff --git a/playground/index.tsx b/playground/index.tsx index 0d8f7ab7..5876abd6 100644 --- a/playground/index.tsx +++ b/playground/index.tsx @@ -1,34 +1,25 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { Button, ContextMenu } from "../src/main"; +import { + LegacyFullscreen1pxIcon, + LegacyHandleMoreIcon, +} from "@deriv/quill-icons"; +import { Header } from "../src/main"; +import { Text } from "../src/main"; -const App = () => { - const [isOpen, setIsOpen] = React.useState(false); - - return ( -
- - - setIsOpen(false)} - isOpen={isOpen} - style={{ position: "absolute", top: 40, left: 0 }} - > -
    -
  • Item 1
  • -
  • Item 2
  • -
  • Item 3
  • -
-
-
- ); -}; +const App = () => ( +
+ } + rightComponent={} + > + + Home + + +
+); ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/src/components/AppLayout/Header/MenuItem/MenuItem.scss b/src/components/AppLayout/Header/MenuItem/MenuItem.scss index 3a146b33..1ca103ca 100644 --- a/src/components/AppLayout/Header/MenuItem/MenuItem.scss +++ b/src/components/AppLayout/Header/MenuItem/MenuItem.scss @@ -2,6 +2,7 @@ display: flex; align-items: center; height: 100%; + cursor: pointer; &:hover { background: var(--du-general-hover, #e6e9e9); diff --git a/src/components/AppLayout/Header/MenuItem/index.tsx b/src/components/AppLayout/Header/MenuItem/index.tsx index 51ac5d0c..94f559d0 100644 --- a/src/components/AppLayout/Header/MenuItem/index.tsx +++ b/src/components/AppLayout/Header/MenuItem/index.tsx @@ -1,8 +1,8 @@ import { ComponentProps, + ElementType, PropsWithChildren, ReactNode, - createElement, } from "react"; import clsx from "clsx"; import "./MenuItem.scss"; @@ -16,13 +16,13 @@ import "./MenuItem.scss"; * @property {boolean} disableHover - If true, disables hover effects. * @property {boolean} active - If true, applies an active state style. */ -type TMenuItem = ComponentProps<"a" | "button"> & { - as: "a" | "button"; +interface TMenuItem extends ComponentProps { + as?: "a" | "button"; leftComponent?: ReactNode; rightComponent?: ReactNode; disableHover?: boolean; active?: boolean; -}; +} /** * MenuItem component that can render as either an anchor or a button element, with optional left and right components. @@ -41,7 +41,7 @@ type TMenuItem = ComponentProps<"a" | "button"> & { * @returns {React.ReactElement} A React Element of type 'a' or 'button' based on the 'as' prop. */ export const MenuItem = ({ - as, + as = "a", leftComponent, children, rightComponent, @@ -50,21 +50,22 @@ export const MenuItem = ({ className, ...props }: PropsWithChildren) => { - const content = { - className: clsx( - "deriv-menu-item", - { "deriv-menu-item--active": active || disableHover }, - className, - ), - children: [ - createElement("div", { key: "leftComponent" }, leftComponent), - createElement("div", { key: "mainChildren" }, children), - createElement("div", { key: "rightComponent" }, rightComponent), - ], - ...props, - }; + const Tag = as; - return createElement(as, { ...content }); + return ( + + {leftComponent} + {children} + {rightComponent} + + ); }; MenuItem.displayName = "MenuItem";