From 5ecb2853397a94d5b40d200709854d9f958d441d Mon Sep 17 00:00:00 2001 From: Red J Adaya Date: Fri, 27 Sep 2024 09:21:28 +0800 Subject: [PATCH] menu (#846) --- .storybook/global.css | 1 + .storybook/preview-head.html | 5 + .storybook/preview.tsx | 3 + frontend/app/element/button.less | 5 +- frontend/app/element/button.stories.tsx | 121 ++++++ frontend/app/element/button.tsx | 54 ++- frontend/app/element/menu.less | 55 +++ frontend/app/element/menu.stories.tsx | 471 ++++++++++++++++++++++++ frontend/app/element/menu.tsx | 438 ++++++++++++++++++++++ frontend/app/modals/typeaheadmodal.tsx | 3 + 10 files changed, 1125 insertions(+), 31 deletions(-) create mode 100644 .storybook/preview-head.html create mode 100644 frontend/app/element/button.stories.tsx create mode 100644 frontend/app/element/menu.less create mode 100644 frontend/app/element/menu.stories.tsx create mode 100644 frontend/app/element/menu.tsx diff --git a/.storybook/global.css b/.storybook/global.css index 7a132c094..32d95f6e4 100644 --- a/.storybook/global.css +++ b/.storybook/global.css @@ -1,6 +1,7 @@ body { height: 100vh; padding: 0; + overflow: auto; } #storybook-root { diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 000000000..da6fc3e91 --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,5 @@ + + + + + diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index a6c2c09bb..d0d802178 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -3,6 +3,9 @@ import type { Preview } from "@storybook/react"; import React from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; +import "../frontend/app/theme.less"; +import "../frontend/app/app.less"; +import "../frontend/app/reset.less"; import "./global.css"; const preview: Preview = { diff --git a/frontend/app/element/button.less b/frontend/app/element/button.less index dc383dbe2..254c75942 100644 --- a/frontend/app/element/button.less +++ b/frontend/app/element/button.less @@ -5,7 +5,7 @@ // override default button appearance border: 1px solid transparent; outline: 1px solid transparent; - border: none; + border: 1px solid transparent; cursor: pointer; display: flex; @@ -150,8 +150,7 @@ pointer-events: none; } - &:focus, - &.focus { + &:focus-visible { outline: 1px solid var(--success-color); outline-offset: 2px; } diff --git a/frontend/app/element/button.stories.tsx b/frontend/app/element/button.stories.tsx new file mode 100644 index 000000000..69e407e64 --- /dev/null +++ b/frontend/app/element/button.stories.tsx @@ -0,0 +1,121 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { Button } from "./button"; + +const meta = { + title: "Elements/Button", + component: Button, + args: { + children: "Click Me", + disabled: false, + className: "", + onClick: fn(), + }, + argTypes: { + onClick: { + action: "clicked", + description: "Click event handler", + }, + children: { + description: "Content inside the button", + }, + disabled: { + description: "Disables the button if true", + }, + className: { + description: "Additional class names to style the button", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Disabled: Story = { + args: { + disabled: true, + children: "Disabled Button", + }, +}; + +export const GreySolid: Story = { + args: { + className: "solid grey", + children: "Grey Solid Button", + }, +}; + +export const RedSolid: Story = { + args: { + className: "solid red", + children: "Red Solid Button", + }, +}; + +export const YellowSolid: Story = { + args: { + className: "solid yellow", + children: "Yellow Solid Button", + }, +}; + +export const GreenOutlined: Story = { + args: { + className: "outlined green", + children: "Green Outline Button", + }, +}; + +export const GreyOutlined: Story = { + args: { + className: "outlined grey", + children: "Grey Outline Button", + }, +}; + +export const RedOutlined: Story = { + args: { + className: "outlined red", + children: "Red Outline Button", + }, +}; + +export const YellowOutlined: Story = { + args: { + className: "outlined yellow", + children: "Yellow Outline Button", + }, +}; + +export const GreenGhostText: Story = { + args: { + className: "ghost green", + children: "Yellow Ghost Text Button", + }, +}; + +export const GreyGhostText: Story = { + args: { + className: "ghost grey", + children: "Grey Ghost Text Button", + }, +}; + +export const RedGhost: Story = { + args: { + className: "ghost red", + children: "Red Ghost Text Button", + }, +}; + +export const YellowGhostText: Story = { + args: { + className: "ghost yellow", + children: "Yellow Ghost Text Button", + }, +}; diff --git a/frontend/app/element/button.tsx b/frontend/app/element/button.tsx index 964671c47..a85088da9 100644 --- a/frontend/app/element/button.tsx +++ b/frontend/app/element/button.tsx @@ -9,41 +9,39 @@ import "./button.less"; interface ButtonProps extends React.ButtonHTMLAttributes { className?: string; children?: ReactNode; - target?: string; - source?: string; } const Button = memo( - forwardRef( - ({ children, disabled, source, className = "", ...props }: ButtonProps, ref) => { - const btnRef = useRef(null); - useImperativeHandle(ref, () => btnRef.current as HTMLButtonElement); + forwardRef(({ children, disabled, className = "", ...props }: ButtonProps, ref) => { + const btnRef = useRef(null); + useImperativeHandle(ref, () => btnRef.current as HTMLButtonElement); - const childrenArray = Children.toArray(children); + const childrenArray = Children.toArray(children); - // Check if the className contains any of the categories: solid, outlined, or ghost - const containsButtonCategory = /(solid|outline|ghost)/.test(className); - // If no category is present, default to 'solid' - const categoryClassName = containsButtonCategory ? className : `solid ${className}`; + // Check if the className contains any of the categories: solid, outlined, or ghost + const containsButtonCategory = /(solid|outline|ghost)/.test(className); + // If no category is present, default to 'solid' + const categoryClassName = containsButtonCategory ? className : `solid ${className}`; - // Check if the className contains any of the color options: green, grey, red, or yellow - const containsColor = /(green|grey|red|yellow)/.test(categoryClassName); - // If no color is present, default to 'green' - const finalClassName = containsColor ? categoryClassName : `green ${categoryClassName}`; + // Check if the className contains any of the color options: green, grey, red, or yellow + const containsColor = /(green|grey|red|yellow)/.test(categoryClassName); + // If no color is present, default to 'green' + const finalClassName = containsColor ? categoryClassName : `green ${categoryClassName}`; - return ( - - ); - } - ) + return ( + + ); + }) ); +Button.displayName = "Button"; + export { Button }; diff --git a/frontend/app/element/menu.less b/frontend/app/element/menu.less new file mode 100644 index 000000000..f16d78478 --- /dev/null +++ b/frontend/app/element/menu.less @@ -0,0 +1,55 @@ +.menu { + position: absolute; + z-index: 1000; // TODO: put this in theme.less + display: flex; + width: 125px; /* Ensure the menu has a fixed width */ + padding: 2px; + flex-direction: column; + justify-content: flex-end; + align-items: flex-start; + gap: 1px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: #212121; + box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3); + + .menu-item { + padding: 4px 6px; + cursor: pointer; + color: #fff; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: normal; + letter-spacing: -0.12px; + width: 100%; + border-radius: 2px; + display: flex; + align-items: center; + justify-content: space-between; + + /* Make sure the label and the icon don't overlap */ + .label { + flex: 1; /* Allow the label to take up available space */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-right: 8px; /* Add some space between label and icon */ + } + + i { + color: var(--main-text-color); + flex-shrink: 0; /* Prevent icon from shrinking */ + } + + &:hover, + &.active { + background-color: var(--accent-color); + color: var(--button-text-color); + + i { + color: var(--button-text-color); + } + } + } +} diff --git a/frontend/app/element/menu.stories.tsx b/frontend/app/element/menu.stories.tsx new file mode 100644 index 000000000..e471e4da1 --- /dev/null +++ b/frontend/app/element/menu.stories.tsx @@ -0,0 +1,471 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "./button"; +import { Menu } from "./menu"; + +const items = [ + { label: "Fruit", onClick: (e) => console.log("Clicked Option 1") }, + { + label: "Vegetables", + subItems: [ + { label: "Carrot", onClick: (e) => console.log("Clicked Option 2 -> 1") }, + { label: "Potato", onClick: (e) => console.log("Clicked Option 2 -> 2") }, + ], + }, + { + label: "Beverages", + subItems: [ + { label: "Juice", onClick: (e) => console.log("Clicked Option 3 -> 1") }, + { label: "Tea", onClick: (e) => console.log("Clicked Option 3 -> 2") }, + { + label: "Coffee", + subItems: [ + { label: "Espresso", onClick: (e) => console.log("Clicked Option 3 -> 3 -> 1") }, + { label: "Latte", onClick: (e) => console.log("Clicked Option 3 -> 3 -> 2") }, + { label: "Cappuccino", onClick: (e) => console.log("Clicked Option 3 -> 3 -> 3") }, + { + label: "Mocha", + subItems: [ + { label: "Dark Chocolate", onClick: (e) => console.log("Clicked Option 3 -> 3 -> 4 -> 1") }, + { + label: "White Chocolate", + onClick: (e) => console.log("Clicked Option 3 -> 3 -> 4 -> 2"), + }, + { label: "Milk Chocolate", onClick: (e) => console.log("Clicked Option 3 -> 3 -> 4 -> 3") }, + ], + }, + ], + }, + ], + }, + { + label: "Desserts", + subItems: [ + { label: "Cake", onClick: (e) => console.log("Clicked Option 4 -> 1") }, + { label: "Ice Cream", onClick: (e) => console.log("Clicked Option 4 -> 2") }, + { label: "Cookies", onClick: (e) => console.log("Clicked Option 4 -> 3") }, + { label: "Brownies", onClick: (e) => console.log("Clicked Option 4 -> 4") }, + { label: "Cupcakes", onClick: (e) => console.log("Clicked Option 4 -> 5") }, + { label: "Donuts", onClick: (e) => console.log("Clicked Option 4 -> 6") }, + { label: "Pie", onClick: (e) => console.log("Clicked Option 4 -> 7") }, + ], + }, +]; + +const meta = { + title: "Elements/Menu", + component: Menu, + args: { + items: [], + anchorRef: undefined, + scopeRef: undefined, + initialPosition: undefined, + className: "", + setVisibility: fn(), + }, + argTypes: { + items: { + description: "Items of menu", + }, + anchorRef: { + description: "Element to attach the menu", + }, + initialPosition: { + description: "Initial position of the menu", + }, + setVisibility: { + description: "Visibility event handler", + }, + scopeRef: { + description: "Component that defines the boundaries of the menu", + }, + className: { + description: "Custom className", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const DefaultRendererLeftPositioned: Story = { + render: (args) => { + const anchorRef = useRef(null); + const scopeRef = useRef(null); + const [isMenuVisible, setIsMenuVisible] = useState(false); + + const handleAnchorClick = () => { + setIsMenuVisible((prev) => !prev); + }; + + const mapItemsWithClick = (items: any[]) => { + return items.map((item) => ({ + ...item, + onClick: () => { + if (item.onClick) { + item.onClick(); + setIsMenuVisible(false); + } + }, + subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined, + })); + }; + + const modifiedArgs = { + ...args, + items: mapItemsWithClick(args.items), + }; + + return ( +
+
+ +
+ {isMenuVisible && ( + setIsMenuVisible(visible)} + anchorRef={anchorRef} + scopeRef={scopeRef} + /> + )} +
+ ); + }, + args: { + items: items, + }, +}; + +export const DefaultRendererRightPositioned: Story = { + render: (args) => { + const anchorRef = useRef(null); + const scopeRef = useRef(null); + const [isMenuVisible, setIsMenuVisible] = useState(false); + + const handleAnchorClick = () => { + setIsMenuVisible((prev) => !prev); + }; + + const mapItemsWithClick = (items: any[]) => { + return items.map((item) => ({ + ...item, + onClick: () => { + if (item.onClick) { + item.onClick(); + } + setIsMenuVisible(false); + }, + subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined, + })); + }; + + const modifiedArgs = { + ...args, + items: mapItemsWithClick(args.items), + }; + + return ( +
+
+ +
+ {isMenuVisible && ( + setIsMenuVisible(visible)} + anchorRef={anchorRef} + scopeRef={scopeRef} + /> + )} +
+ ); + }, + args: { + items: items, + }, +}; + +export const DefaultRendererBottomRightPositioned: Story = { + render: (args) => { + const anchorRef = useRef(null); + const scopeRef = useRef(null); + const [isMenuVisible, setIsMenuVisible] = useState(false); + + const handleAnchorClick = () => { + setIsMenuVisible((prev) => !prev); + }; + + const mapItemsWithClick = (items: any[]) => { + return items.map((item) => ({ + ...item, + onClick: () => { + if (item.onClick) { + item.onClick(); + } + setIsMenuVisible(false); + }, + subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined, + })); + }; + + const modifiedArgs = { + ...args, + items: mapItemsWithClick(args.items), + }; + + return ( +
+
+ +
+ {isMenuVisible && ( + setIsMenuVisible(visible)} + anchorRef={anchorRef} + scopeRef={scopeRef} + /> + )} +
+ ); + }, + args: { + items: items, + }, +}; + +export const DefaultRendererBottomLeftPositioned: Story = { + render: (args) => { + const anchorRef = useRef(null); + const scopeRef = useRef(null); + const [isMenuVisible, setIsMenuVisible] = useState(false); + + const handleAnchorClick = () => { + setIsMenuVisible((prev) => !prev); + }; + + const mapItemsWithClick = (items: any[]) => { + return items.map((item) => ({ + ...item, + onClick: () => { + if (item.onClick) { + item.onClick(); + } + setIsMenuVisible(false); + }, + subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined, + })); + }; + + const modifiedArgs = { + ...args, + items: mapItemsWithClick(args.items), + }; + + return ( +
+
+ +
+ {isMenuVisible && ( + setIsMenuVisible(visible)} + anchorRef={anchorRef} + scopeRef={scopeRef} + /> + )} +
+ ); + }, + args: { + items: items, + }, +}; + +export const CustomRenderer: Story = { + render: (args) => { + const anchorRef = useRef(null); + const scopeRef = useRef(null); + const [isMenuVisible, setIsMenuVisible] = useState(false); + + const handleAnchorClick = () => { + setIsMenuVisible((prev) => !prev); + }; + + const mapItemsWithClick = (items: any[]) => { + return items.map((item) => ({ + ...item, + onClick: () => { + if (item.onClick) { + item.onClick(); + } + setIsMenuVisible(false); + }, + subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined, + })); + }; + + const renderMenuItem = (item: any, props: any) => ( +
+ {item.label} + {item.subItems && } +
+ ); + + const renderMenu = (subMenu: JSX.Element) =>
{subMenu}
; + + const modifiedArgs = { + ...args, + items: mapItemsWithClick(args.items), + }; + + return ( +
+
+ +
+ {isMenuVisible && ( + setIsMenuVisible(visible)} + anchorRef={anchorRef} + scopeRef={scopeRef} + renderMenu={renderMenu} + renderMenuItem={renderMenuItem} + /> + )} +
+ ); + }, + args: { + items: items, + }, +}; + +export const ContextMenu: Story = { + render: (args) => { + const scopeRef = useRef(null); + const [isMenuVisible, setIsMenuVisible] = useState(false); + const [menuPosition, setMenuPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); + + const handleBlockRightClick = (e: MouseEvent) => { + e.preventDefault(); // Prevent the default context menu + setMenuPosition({ top: e.clientY, left: e.clientX }); + setIsMenuVisible(true); + }; + + useEffect(() => { + const blockElement = scopeRef.current; + if (blockElement) { + blockElement.addEventListener("contextmenu", handleBlockRightClick); + } + + return () => { + if (blockElement) { + blockElement.removeEventListener("contextmenu", handleBlockRightClick); + } + }; + }, []); + + const mapItemsWithClick = (items: any[]) => { + return items.map((item) => ({ + ...item, + onClick: () => { + if (item.onClick) { + item.onClick(); + setIsMenuVisible(false); + } + }, + subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined, + })); + }; + + const modifiedArgs = { + ...args, + items: mapItemsWithClick(args.items), + }; + + return ( +
+ {isMenuVisible && ( + setIsMenuVisible(visible)} + initialPosition={menuPosition} + scopeRef={scopeRef} + /> + )} +
+ ); + }, + args: { + items: items, + }, +}; diff --git a/frontend/app/element/menu.tsx b/frontend/app/element/menu.tsx new file mode 100644 index 000000000..d32260082 --- /dev/null +++ b/frontend/app/element/menu.tsx @@ -0,0 +1,438 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { useHeight } from "@/app/hook/useHeight"; +import { useWidth } from "@/app/hook/useWidth"; +import clsx from "clsx"; +import React, { memo, useEffect, useLayoutEffect, useRef, useState } from "react"; +import ReactDOM from "react-dom"; + +import "./menu.less"; + +type MenuItem = { + label: string; + subItems?: MenuItem[]; + onClick?: (e) => void; +}; + +const SubMenu = memo( + ({ + subItems, + parentKey, + subMenuPosition, + visibleSubMenus, + hoveredItems, + subMenuRefs, + handleMouseEnterItem, + handleOnClick, + renderMenu, + renderMenuItem, + }: { + subItems: MenuItem[]; + parentKey: string; + subMenuPosition: { + [key: string]: { top: number; left: number; label: string }; + }; + visibleSubMenus: { [key: string]: any }; + hoveredItems: string[]; + subMenuRefs: React.MutableRefObject<{ [key: string]: React.RefObject }>; + handleMouseEnterItem: ( + event: React.MouseEvent, + parentKey: string | null, + index: number, + item: MenuItem + ) => void; + handleOnClick: (e: React.MouseEvent, item: MenuItem) => void; + renderMenu?: (subMenu: JSX.Element, props: any) => JSX.Element; + renderMenuItem?: (item: MenuItem, props: any) => JSX.Element; + }) => { + subItems.forEach((_, idx) => { + const newKey = `${parentKey}-${idx}`; + if (!subMenuRefs.current[newKey]) { + subMenuRefs.current[newKey] = React.createRef(); + } + }); + + const position = subMenuPosition[parentKey]; + const isPositioned = position && position.top !== undefined && position.left !== undefined; + + const subMenu = ( +
+ {subItems.map((item, idx) => { + const newKey = `${parentKey}-${idx}`; + const isActive = hoveredItems.includes(newKey); + + const menuItemProps = { + className: clsx("menu-item", { active: isActive }), + onMouseEnter: (event: React.MouseEvent) => + handleMouseEnterItem(event, parentKey, idx, item), + onClick: (e: React.MouseEvent) => handleOnClick(e, item), + }; + + const renderedItem = renderMenuItem ? ( + renderMenuItem(item, menuItemProps) // Remove portal here + ) : ( +
+ {item.label} + {item.subItems && } +
+ ); + + return ( + + {renderedItem} + {visibleSubMenus[newKey]?.visible && item.subItems && ( + + )} + + ); + })} +
+ ); + + return ReactDOM.createPortal(renderMenu ? renderMenu(subMenu, { parentKey }) : subMenu, document.body); + } +); + +const Menu = memo( + ({ + items, + anchorRef, + scopeRef, + initialPosition, + className, + setVisibility, + renderMenu, + renderMenuItem, + }: { + items: MenuItem[]; + anchorRef: React.RefObject; + scopeRef?: React.RefObject; + initialPosition?: { top: number; left: number }; + className?: string; + setVisibility: (_: boolean) => void; + renderMenu?: (subMenu: JSX.Element, props: any) => JSX.Element; + renderMenuItem?: (item: MenuItem, props: any) => JSX.Element; + }) => { + const [visibleSubMenus, setVisibleSubMenus] = useState<{ [key: string]: any }>({}); + const [hoveredItems, setHoveredItems] = useState([]); + const [subMenuPosition, setSubMenuPosition] = useState<{ + [key: string]: { top: number; left: number; label: string }; + }>({}); + const [position, setPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); + const menuRef = useRef(null); + const subMenuRefs = useRef<{ [key: string]: React.RefObject }>({}); + + const width = useWidth(scopeRef); + const height = useHeight(scopeRef); + + items.forEach((_, idx) => { + const key = `${idx}`; + if (!subMenuRefs.current[key]) { + subMenuRefs.current[key] = React.createRef(); + } + }); + + useLayoutEffect(() => { + const shadowOffset = 10; // Adjust for box shadow space + + if (initialPosition) { + // Adjust position if initialPosition is provided + let { top, left } = initialPosition; + + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const scrollLeft = window.scrollX || document.documentElement.scrollLeft; + + const menuWidth = menuRef.current?.offsetWidth || 0; + const menuHeight = menuRef.current?.offsetHeight || 0; + + const boundaryTop = 0; + const boundaryLeft = 0; + const boundaryRight = window.innerWidth + scrollLeft; + const boundaryBottom = window.innerHeight + scrollTop; + + // Adjust if the menu overflows the right boundary + if (left + menuWidth > boundaryRight) { + left = boundaryRight - menuWidth - shadowOffset; // Shift left more for shadow + } + + // Adjust if the menu overflows the bottom boundary: move the menu upwards so its bottom edge aligns with the initial position + if (top + menuHeight > boundaryBottom) { + top = initialPosition.top - menuHeight - shadowOffset; // Shift up for shadow + } + + // Adjust if the menu overflows the left boundary + if (left < boundaryLeft) { + left = boundaryLeft + shadowOffset; // Add shadow offset from the left edge + } + + // Adjust if the menu overflows the top boundary + if (top < boundaryTop) { + top = boundaryTop + shadowOffset; // Add shadow offset from the top edge + } + + setPosition({ top, left }); + } else if (anchorRef.current && menuRef.current) { + // Calculate position based on anchorRef if it exists + const anchorRect = anchorRef.current.getBoundingClientRect(); + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const scrollLeft = window.scrollX || document.documentElement.scrollLeft; + + let top = anchorRect.bottom + scrollTop; + let left = anchorRect.left + scrollLeft; + + const menuWidth = menuRef.current.offsetWidth; + const menuHeight = menuRef.current.offsetHeight; + + const boundaryTop = 0; + const boundaryLeft = 0; + const boundaryRight = window.innerWidth + scrollLeft; + const boundaryBottom = window.innerHeight + scrollTop; + + // Adjust if the menu overflows the right boundary + if (left + menuWidth > boundaryRight) { + left = boundaryRight - menuWidth; + } + + // Adjust if the menu overflows the bottom boundary: move the menu upwards so its bottom edge aligns with the anchor top + if (top + menuHeight > boundaryBottom) { + top = anchorRect.top + scrollTop - menuHeight; + } + + // Adjust if the menu overflows the left boundary + if (left < boundaryLeft) { + left = boundaryLeft; + } + + // Adjust if the menu overflows the top boundary + if (top < boundaryTop) { + top = boundaryTop; + } + + setPosition({ top, left }); + } else { + console.warn("Neither initialPosition nor anchorRef provided. Defaulting to { top: 0, left: 0 }."); + } + }, [width, height, initialPosition]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const isClickInsideDropdown = menuRef.current && menuRef.current.contains(event.target as Node); + + const isClickInsideAnchor = anchorRef?.current + ? anchorRef.current.contains(event.target as Node) + : false; + + const isClickInsideSubMenus = Object.keys(subMenuRefs.current).some( + (key) => + subMenuRefs.current[key]?.current && + subMenuRefs.current[key]?.current.contains(event.target as Node) + ); + + if (!isClickInsideDropdown && !isClickInsideAnchor && !isClickInsideSubMenus) { + setVisibility(false); + } + }; + + scopeRef?.current?.addEventListener("mousedown", handleClickOutside); + + return () => { + scopeRef?.current?.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + // Position submenus based on available space and scroll position + const handleSubMenuPosition = ( + key: string, + itemRect: DOMRect, + parentRef: React.RefObject, + label: string + ) => { + setTimeout(() => { + const subMenuRef = subMenuRefs.current[key]?.current; + if (!subMenuRef) return; + + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const scrollLeft = window.scrollX || document.documentElement.scrollLeft; + + const submenuWidth = subMenuRef.offsetWidth; + const submenuHeight = subMenuRef.offsetHeight; + + let left = itemRect.right + scrollLeft - 2; // Adjust for horizontal scroll + let top = itemRect.top - 2 + scrollTop; // Adjust for vertical scroll + + // Adjust to the left if overflowing the right boundary + if (left + submenuWidth > window.innerWidth + scrollLeft) { + left = itemRect.left + scrollLeft - submenuWidth; + } + + // Adjust if the submenu overflows the bottom boundary + if (top + submenuHeight > window.innerHeight + scrollTop) { + top = window.innerHeight + scrollTop - submenuHeight - 10; + } + + setSubMenuPosition((prev) => ({ + ...prev, + [key]: { top, left, label }, + })); + }, 0); + }; + + const handleMouseEnterItem = ( + event: React.MouseEvent, + parentKey: string | null, + index: number, + item: MenuItem + ) => { + event.stopPropagation(); + + const key = parentKey ? `${parentKey}-${index}` : `${index}`; + + setVisibleSubMenus((prev) => { + const updatedState = { ...prev }; + updatedState[key] = { visible: true, label: item.label }; + + const ancestors = key.split("-").reduce((acc, part, idx) => { + if (idx === 0) return [part]; + return [...acc, `${acc[idx - 1]}-${part}`]; + }, [] as string[]); + + ancestors.forEach((ancestorKey) => { + if (updatedState[ancestorKey]) { + updatedState[ancestorKey].visible = true; + } + }); + + for (const pkey in updatedState) { + if (!ancestors.includes(pkey) && pkey !== key) { + updatedState[pkey].visible = false; + } + } + + return updatedState; + }); + + const newHoveredItems = key.split("-").reduce((acc, part, idx) => { + if (idx === 0) return [part]; + return [...acc, `${acc[idx - 1]}-${part}`]; + }, [] as string[]); + + setHoveredItems(newHoveredItems); + + const itemRect = event.currentTarget.getBoundingClientRect(); + handleSubMenuPosition(key, itemRect, menuRef, item.label); + }; + + const handleOnClick = (e: React.MouseEvent, item: MenuItem) => { + e.stopPropagation(); + item.onClick && item.onClick(e); + }; + + // const handleKeyDown = useCallback( + // (waveEvent: WaveKeyboardEvent): boolean => { + // if (keyutil.checkKeyPressed(waveEvent, "ArrowDown")) { + // setFocusedIndex((prev) => (prev + 1) % items.length); // Move down + // return true; + // } + // if (keyutil.checkKeyPressed(waveEvent, "ArrowUp")) { + // setFocusedIndex((prev) => (prev - 1 + items.length) % items.length); // Move up + // return true; + // } + // if (keyutil.checkKeyPressed(waveEvent, "ArrowRight")) { + // if (items[focusedIndex].subItems) { + // setSubmenuOpen(focusedIndex); // Open the submenu + // } + // return true; + // } + // if (keyutil.checkKeyPressed(waveEvent, "ArrowLeft")) { + // if (submenuOpen !== null) { + // setSubmenuOpen(null); // Close the submenu + // } + // return true; + // } + // if (keyutil.checkKeyPressed(waveEvent, "Enter") || keyutil.checkKeyPressed(waveEvent, " ")) { + // if (items[focusedIndex].onClick) { + // items[focusedIndex].onClick(); // Trigger click + // } + // return true; + // } + // if (keyutil.checkKeyPressed(waveEvent, "Escape")) { + // setVisibility(false); // Close the menu + // return true; + // } + // return false; + // }, + // [focusedIndex, submenuOpen, items, setVisibility] + // ); + + const menuMenu = ( +
+ {items.map((item, index) => { + const key = `${index}`; + const isActive = hoveredItems.includes(key); + + const menuItemProps = { + className: clsx("menu-item", { active: isActive }), + onMouseEnter: (event: React.MouseEvent) => + handleMouseEnterItem(event, null, index, item), + onClick: (e: React.MouseEvent) => handleOnClick(e, item), + }; + + const renderedItem = renderMenuItem ? ( + renderMenuItem(item, menuItemProps) + ) : ( +
+ {item.label} + {item.subItems && } +
+ ); + + return ( + + {renderedItem} + {visibleSubMenus[key]?.visible && item.subItems && ( + + )} + + ); + })} +
+ ); + + return ReactDOM.createPortal(renderMenu ? renderMenu(menuMenu, { parentKey: null }) : menuMenu, document.body); + } +); + +export { Menu }; diff --git a/frontend/app/modals/typeaheadmodal.tsx b/frontend/app/modals/typeaheadmodal.tsx index c9920cad4..64f21c9dd 100644 --- a/frontend/app/modals/typeaheadmodal.tsx +++ b/frontend/app/modals/typeaheadmodal.tsx @@ -1,3 +1,6 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + import { Input } from "@/app/element/input"; import { InputDecoration } from "@/app/element/inputdecoration"; import { useDimensions } from "@/app/hook/useDimensions";