From b6623cd847ffedcad9fd5201d33b2a7a2e3a5cab Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Sat, 28 Sep 2024 19:23:48 +0900 Subject: [PATCH] feat: Implement nested context menu items --- src/components/ContextMenu.spec.tsx | 86 +++++++++++++++++++++++++++ src/components/ContextMenu.tsx | 91 ++++++++++++++++++++++++----- 2 files changed, 164 insertions(+), 13 deletions(-) create mode 100644 src/components/ContextMenu.spec.tsx diff --git a/src/components/ContextMenu.spec.tsx b/src/components/ContextMenu.spec.tsx new file mode 100644 index 00000000..ae356514 --- /dev/null +++ b/src/components/ContextMenu.spec.tsx @@ -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(); + + 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(); + + 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(); + + 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"); + }); +}); diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 99a537ec..c7ccc177 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -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[]; @@ -11,17 +12,13 @@ interface Props { } export const ContextMenu: React.FC = ({ items, point, onClickItem }) => { - const itemElm = useMemo(() => { - return items.map((item, i) => - "separator" in item ? ( - - ) : ( - onClickItem?.(item.key, item.meta)}> - {item.label} - - ), - ); - }, [items, onClickItem]); + const handleClick = useCallback( + (item: ContextMenuItem) => { + if ("separator" in item) return; + onClickItem?.(item.key, item.meta); + }, + [onClickItem], + ); return (
= ({ items, point, onClickItem }) => { transform: `translate(${point.x}px, ${point.y}px)`, }} > -
{itemElm}
+
+ +
+
+ ); +}; + +interface ContextListProps { + items: ContextMenuItem[]; + onClickItem?: (item: ContextMenuItem) => void; +} + +const ContextList: React.FC = ({ 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) => ( + + )); +}; + +interface ContextItemProps { + item: ContextMenuItem; + dropdownKey?: string; + onClickItem?: (item: ContextMenuItem) => void; +} + +const ContextItem: React.FC = ({ item, dropdownKey, onClickItem }) => { + const handleClick = useCallback(() => { + onClickItem?.(item); + }, [item, onClickItem]); + + if ("separator" in item) return ; + if (!item.children || item.children.length === 0) + return ( + + {item.label} + + ); + + return ( +
+
+ + {item.label} + + +
+ {dropdownKey === item.key ? ( +
+ +
+ ) : undefined}
); };