Skip to content

Commit

Permalink
feat: Implement nested context menu items
Browse files Browse the repository at this point in the history
  • Loading branch information
miyanokomiya committed Sep 28, 2024
1 parent d54c756 commit b6623cd
Show file tree
Hide file tree
Showing 2 changed files with 164 additions and 13 deletions.
86 changes: 86 additions & 0 deletions src/components/ContextMenu.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { expect, describe, test, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { ContextMenu } from "./ContextMenu";
import { ContextMenuItem } from "../composables/states/types";

describe("NumberInput", () => {
beforeEach(() => {
cleanup();
});

const items: ContextMenuItem[] = [
{ key: "root0", label: "label_root0" },
{ key: "root1", label: "label_root1" },
{
key: "root2",
label: "label_root2",
children: [
{ key: "root2_child0", label: "label_root2_child0" },
{ key: "root2_child1", label: "label_root2_child1" },
],
},
{
key: "root3",
label: "label_root3",
children: [
{
key: "root3_child0",
label: "label_root3_child0",
children: [{ key: "root3_child0_child0", label: "label_root3_child0_child0" }],
},
],
},
];

test("should toggle children panel when a parent element is clicked", () => {
render(<ContextMenu items={items} point={{ x: 0, y: 0 }} />);

expect(screen.queryByText("label_root2_child0")).toBe(null);
expect(screen.queryByText("label_root2_child1")).toBe(null);

fireEvent.click(screen.getByText("label_root2"));
expect(screen.queryByText("label_root2_child0")).not.toBe(null);
expect(screen.queryByText("label_root2_child1")).not.toBe(null);

fireEvent.click(screen.getByText("label_root2"));
expect(screen.queryByText("label_root2_child0")).toBe(null);
expect(screen.queryByText("label_root2_child1")).toBe(null);
});

test("should close the dropdown when other dropdown in the same level opens", () => {
render(<ContextMenu items={items} point={{ x: 0, y: 0 }} />);

fireEvent.click(screen.getByText("label_root2"));
fireEvent.click(screen.getByText("label_root3"));
expect(screen.queryByText("label_root2_child0")).toBe(null);
expect(screen.queryByText("label_root3_child0")).not.toBe(null);
expect(screen.queryByText("label_root3_child0_child0")).toBe(null);

fireEvent.click(screen.getByText("label_root3_child0"));
expect(screen.queryByText("label_root3_child0_child0")).not.toBe(null);
});

test("should execute onClickItem with a childless item when it's clicked", () => {
const keys: string[] = [];
const onClickItem = (key: string) => keys.push(key);
render(<ContextMenu items={items} point={{ x: 0, y: 0 }} onClickItem={onClickItem} />);

expect(keys).toHaveLength(0);
fireEvent.click(screen.getByText("label_root0"));
expect(keys).toContain("root0");
fireEvent.click(screen.getByText("label_root1"));
expect(keys).toContain("root1");

fireEvent.click(screen.getByText("label_root2"));
expect(keys, "this element has children").not.toContain("root2");
fireEvent.click(screen.getByText("label_root2_child0"));
expect(keys).toContain("root2_child0");

fireEvent.click(screen.getByText("label_root3"));
expect(keys, "this element has children").not.toContain("root3");
fireEvent.click(screen.getByText("label_root3_child0"));
expect(keys, "this element has children").not.toContain("root3_child0");
fireEvent.click(screen.getByText("label_root3_child0_child0"));
expect(keys).toContain("root3_child0_child0");
});
});
91 changes: 78 additions & 13 deletions src/components/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { IVec2 } from "okageo";
import { ContextMenuItem } from "../composables/states/types";
import { useMemo } from "react";
import { useCallback, useState } from "react";
import { ListButton, ListSpacer } from "./atoms/buttons/ListButton";
import { AppText } from "./molecules/AppText";
import iconDropdown from "../assets/icons/dropdown.svg";

interface Props {
items: ContextMenuItem[];
Expand All @@ -11,17 +12,13 @@ interface Props {
}

export const ContextMenu: React.FC<Props> = ({ items, point, onClickItem }) => {
const itemElm = useMemo(() => {
return items.map((item, i) =>
"separator" in item ? (
<ListSpacer key={i} />
) : (
<ListButton key={item.key} onClick={() => onClickItem?.(item.key, item.meta)}>
<AppText portal={true}>{item.label}</AppText>
</ListButton>
),
);
}, [items, onClickItem]);
const handleClick = useCallback(
(item: ContextMenuItem) => {
if ("separator" in item) return;
onClickItem?.(item.key, item.meta);
},
[onClickItem],
);

return (
<div
Expand All @@ -30,7 +27,75 @@ export const ContextMenu: React.FC<Props> = ({ items, point, onClickItem }) => {
transform: `translate(${point.x}px, ${point.y}px)`,
}}
>
<div className="flex flex-col">{itemElm}</div>
<div className="flex flex-col">
<ContextList items={items} onClickItem={handleClick} />
</div>
</div>
);
};

interface ContextListProps {
items: ContextMenuItem[];
onClickItem?: (item: ContextMenuItem) => void;
}

const ContextList: React.FC<ContextListProps> = ({ items, onClickItem }) => {
const [dropdownKey, setDropdownKey] = useState("");

const handleClick = useCallback(
(item: ContextMenuItem) => {
if ("separator" in item) return;
if (item.children?.length) {
setDropdownKey((v) => (item.key === v ? "" : item.key));
return;
}

onClickItem?.(item);
},
[onClickItem],
);

return items.map((item, i) => (
<ContextItem key={i} item={item} dropdownKey={dropdownKey} onClickItem={handleClick} />
));
};

interface ContextItemProps {
item: ContextMenuItem;
dropdownKey?: string;
onClickItem?: (item: ContextMenuItem) => void;
}

const ContextItem: React.FC<ContextItemProps> = ({ item, dropdownKey, onClickItem }) => {
const handleClick = useCallback(() => {
onClickItem?.(item);
}, [item, onClickItem]);

if ("separator" in item) return <ListSpacer />;
if (!item.children || item.children.length === 0)
return (
<ListButton onClick={handleClick}>
<AppText portal={true}>{item.label}</AppText>
</ListButton>
);

return (
<div className="relative">
<div>
<ListButton onClick={handleClick}>
<AppText portal={true}>{item.label}</AppText>
<img
className={"ml-2 w-3 h-3 transition-transform " + (dropdownKey === item.key ? "rotate-90" : "-rotate-90")}
src={iconDropdown}
alt=""
/>
</ListButton>
</div>
{dropdownKey === item.key ? (
<div className="absolute left-full top-0 border bg-white w-max">
<ContextList items={item.children} onClickItem={onClickItem} />
</div>
) : undefined}
</div>
);
};

0 comments on commit b6623cd

Please sign in to comment.