Skip to content

Commit

Permalink
Merge pull request #189 from deriv-com/niloofar/menu-item
Browse files Browse the repository at this point in the history
Niloofar/Added menu item component
  • Loading branch information
shayan-deriv authored May 9, 2024
2 parents 81565d3 + 7e45efe commit 114fa34
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 28 deletions.
47 changes: 19 additions & 28 deletions playground/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<Button
style={{ position: "relative" }}
size="sm"
onClick={() => setIsOpen(!isOpen)}
>
Toggle Drawer
</Button>

<ContextMenu
onClickOutside={() => setIsOpen(false)}
isOpen={isOpen}
style={{ position: "absolute", top: 40, left: 0 }}
>
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
</ContextMenu>
</div>
);
};
const App = () => (
<div style={{ margin: "50px", display: "flex" }}>
<Header.MenuItem
as="button"
leftComponent={<LegacyFullscreen1pxIcon width={16} height={16} />}
rightComponent={<LegacyHandleMoreIcon width={16} height={16} />}
>
<span style={{ padding: "8px 16px" }}>
<Text size="md">Home</Text>
</span>
</Header.MenuItem>
</div>
);

ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
Expand Down
16 changes: 16 additions & 0 deletions src/components/AppLayout/Header/MenuItem/MenuItem.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.deriv-menu-item {
display: flex;
align-items: center;
height: 100%;
cursor: pointer;

&:hover {
background: var(--du-general-hover, #e6e9e9);
}

&--active {
&:hover {
background: transparent;
}
}
}
71 changes: 71 additions & 0 deletions src/components/AppLayout/Header/MenuItem/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
ComponentProps,
ElementType,
PropsWithChildren,
ReactNode,
} 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.
*/
interface TMenuItem extends ComponentProps<ElementType> {
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<TMenuItem>} 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 = "a",
leftComponent,
children,
rightComponent,
disableHover,
active,
className,
...props
}: PropsWithChildren<TMenuItem>) => {
const Tag = as;

return (
<Tag
className={clsx(
"deriv-menu-item",
{ "deriv-menu-item--active": active || disableHover },
className,
)}
{...props}
>
{leftComponent}
{children}
{rightComponent}
</Tag>
);
};

MenuItem.displayName = "MenuItem";
2 changes: 2 additions & 0 deletions src/components/AppLayout/Header/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ComponentProps, PropsWithChildren } from "react";
import clsx from "clsx";
import { DerivLogo } from "./DerivLogo";
import { MenuItem } from "./MenuItem";
import "./Header.scss";

/**
Expand All @@ -21,5 +22,6 @@ export const Header = ({
);

Header.DerivLogo = DerivLogo;
Header.MenuItem = MenuItem;

Header.displayName = "Header";
63 changes: 63 additions & 0 deletions src/components/AppLayout/__test__/MenuItem.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MenuItem as="button" onClick={jest.fn()}>
Click me
</MenuItem>,
);
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(
<MenuItem as="a" href={href}>
Visit Example
</MenuItem>,
);
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(
<MenuItem as="button" active disableHover className="custom-class">
Click me
</MenuItem>,
);
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(
<MenuItem as="button" onClick={mockClick}>
Click me
</MenuItem>,
);
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(
<MenuItem as="button" onClick={jest.fn()}>
Click me
</MenuItem>,
);
const button = screen.getByRole("button", { name: "Click me" });
expect(button).toBeInTheDocument();
});
});
36 changes: 36 additions & 0 deletions stories/MenuItem.stories.tsx
Original file line number Diff line number Diff line change
@@ -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: <LegacyWhatsappIcon width={16} height={16} />,
rightComponent: <LegacyAdsIcon width={16} height={16} />,
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<typeof Header.MenuItem>;

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

export const Default: Story = {
name: "Header.MenuItem",
render: (args) => (
<Header.MenuItem {...args}>
<span style={{ margin: "0 10px" }}>Menu Item</span>
</Header.MenuItem>
),
};

0 comments on commit 114fa34

Please sign in to comment.