diff --git a/frontend/app/element/dropdown.less b/frontend/app/element/dropdown.less new file mode 100644 index 00000000..d81f79dc --- /dev/null +++ b/frontend/app/element/dropdown.less @@ -0,0 +1,46 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.dropdown { + position: absolute; + z-index: 1000; // TODO: put this in theme.less + display: flex; + width: 104px; + 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); + + .dropdown-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%; + + &:hover { + background-color: var(--accent-color); + } + + .arrow { + float: right; + } + } + + .sub-dropdown { + position: absolute; + top: 0; + left: 100%; + min-width: 100%; + white-space: nowrap; + } +} diff --git a/frontend/app/element/dropdown.stories.tsx b/frontend/app/element/dropdown.stories.tsx new file mode 100644 index 00000000..413775d5 --- /dev/null +++ b/frontend/app/element/dropdown.stories.tsx @@ -0,0 +1,94 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useRef, useState } from "react"; +import { Dropdown } from "./dropdown"; + +const meta = { + title: "Elements/Dropdown", + component: Dropdown, + args: { + items: [], + anchorRef: undefined, + boundaryRef: undefined, + className: "", + }, + argTypes: { + items: { + description: "Items of dropdown", + }, + anchorRef: { + description: "Element to attach the dropdown", + }, + boundaryRef: { + description: "Component that defines the boundaries of the dropdown", + }, + className: { + description: "Custom className", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Test: Story = { + render: (args) => { + const anchorRef = useRef(null); + const boundaryRef = useRef(null); + const [isDropdownVisible, setIsDropdownVisible] = useState(false); + + const handleAnchorClick = () => { + setIsDropdownVisible((prev) => !prev); + }; + + return ( +
+
+ Anchor Element +
+ {isDropdownVisible && } +
+ ); + }, + args: { + items: [ + { label: "Option 1", onClick: () => console.log("Clicked Option 1") }, + { + label: "Option 2", + onClick: () => console.log("Clicked Option 2"), + subItems: [ + { label: "Option 2 -> 1", onClick: () => console.log("Clicked Sub-option 1") }, + { label: "Option 2 -> 2", onClick: () => console.log("Clicked Sub-option 2") }, + ], + }, + { + label: "Option 3", + onClick: () => console.log("Clicked Option 3"), + subItems: [ + { label: "Option 3 -> 1", onClick: () => console.log("Clicked Sub-option 1") }, + { label: "Option 3 -> 2", onClick: () => console.log("Clicked Sub-option 2") }, + { + label: "Option 3 -> 3", + onClick: () => console.log("Clicked Option 3"), + subItems: [ + { label: "Option 3 -> 3 -> 1", onClick: () => console.log("Clicked Sub-option 1") }, + { label: "Option 3 -> 3 -> 2", onClick: () => console.log("Clicked Sub-option 2") }, + ], + }, + ], + }, + ], + }, +}; diff --git a/frontend/app/element/dropdown.tsx b/frontend/app/element/dropdown.tsx new file mode 100644 index 00000000..7328699c --- /dev/null +++ b/frontend/app/element/dropdown.tsx @@ -0,0 +1,252 @@ +import { useHeight } from "@/app/hook/useHeight"; +import { useWidth } from "@/app/hook/useWidth"; +import clsx from "clsx"; +import React, { memo, useLayoutEffect, useRef, useState } from "react"; +import ReactDOM from "react-dom"; + +import "./dropdown.less"; + +type DropdownItem = { + label: string; + onClick?: () => void; + subItems?: DropdownItem[]; +}; + +interface DropdownProps { + items: DropdownItem[]; + anchorRef: React.RefObject; + boundaryRef?: React.RefObject; + className?: string; +} + +const Dropdown = memo(({ items, anchorRef, boundaryRef, className }: DropdownProps) => { + const [visibleSubMenus, setVisibleSubMenus] = useState<{ [key: number]: any }>({}); // Track visibility of each submenu (nested object) + const [subMenuPosition, setSubMenuPosition] = useState<{ [key: number]: { top: number; left: number } }>({}); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const dropdownRef = useRef(null); + const subMenuRefs = useRef>>([]); // Array of refs for each submenu + + const effectiveBoundaryRef: React.RefObject = boundaryRef ?? { current: document.documentElement }; + const width = useWidth(effectiveBoundaryRef); + const height = useHeight(effectiveBoundaryRef); + + // Add ref for each submenu item dynamically + if (subMenuRefs.current.length !== items.length) { + subMenuRefs.current = Array(items.length) + .fill(null) + .map((_, i) => subMenuRefs.current[i] || React.createRef()); + } + + useLayoutEffect(() => { + if (anchorRef.current && dropdownRef.current) { + const anchorRect = anchorRef.current.getBoundingClientRect(); + let boundaryRect = effectiveBoundaryRef.current?.getBoundingClientRect() || { + top: 0, + left: 0, + bottom: window.innerHeight, + right: window.innerWidth, + }; + + let top = anchorRect.bottom; + let left = anchorRect.left; + + // Adjust if overflowing the right boundary + if (left + dropdownRef.current.offsetWidth > boundaryRect.right) { + left = boundaryRect.right - dropdownRef.current.offsetWidth; + } + + // Adjust if overflowing the bottom boundary + if (top + dropdownRef.current.offsetHeight > boundaryRect.bottom) { + top = boundaryRect.bottom - dropdownRef.current.offsetHeight; + } + + setPosition({ top, left }); + } + }, [width, height]); + + const handleSubMenuPosition = ( + parentIndex: number, + index: number, + itemRect: DOMRect, + parentRef: React.RefObject | React.RefObject + ) => { + const subMenuRef = subMenuRefs.current[index].current; + const parentMenuRef = parentRef.current; + if (subMenuRef && parentMenuRef) { + const boundaryRect = effectiveBoundaryRef.current?.getBoundingClientRect() || { + top: 0, + left: 0, + bottom: window.innerHeight, + right: window.innerWidth, + }; + + // Position to the right of the hovered item + let left = itemRect.width - 5; + + // Adjust to the left if overflowing right boundary + if (left + subMenuRef.offsetWidth > boundaryRect.right) { + left = itemRect.left - subMenuRef.offsetWidth; // Align left if overflow + } + + // Calculate top based on parent's position + const parentRect = parentMenuRef.getBoundingClientRect(); + const top = itemRect.top - parentRect.top; + + setSubMenuPosition((prev) => ({ + ...prev, + [parentIndex]: { + ...prev[parentIndex], + [index]: { top, left }, + }, + })); + } + }; + + // Recursive function to update visibility for multi-level submenus + const updateVisibility = (currentState: any, parentIndex: number | null, index: number, item: DropdownItem) => { + // Recursively clone the current state + const updatedState = { ...currentState }; + + // Handle the case where we need to update a nested level (parentIndex is not null) + if (parentIndex !== null) { + // Ensure that the parentIndex exists in the state + if (!updatedState[parentIndex]) { + updatedState[parentIndex] = {}; + } + + // Reset visibility for all nested submenus at this level + for (let key in updatedState[parentIndex]) { + if (updatedState[parentIndex][key]?.visible !== undefined) { + updatedState[parentIndex][key].visible = false; + } + } + + // Update the submenu at the correct level + updatedState[parentIndex] = updateVisibility( + updatedState[parentIndex], // Pass the state for the current level + null, // Stop recursion when we reach the correct level + index, + item + ); + } else { + // We're at the correct level (root or no parent), so set all siblings' visibility to false + for (let key in updatedState) { + if (updatedState[key]?.visible !== undefined) { + updatedState[key].visible = false; + } + } + // Set the current index submenu to visible + updatedState[index] = { visible: true, label: item.label }; + } + + return updatedState; + }; + + const handleMouseEnterItem = ( + event: React.MouseEvent, + parentIndex: number | null, + index: number, + item: DropdownItem, + parentRef: React.RefObject | React.RefObject, + reason?: string + ) => { + event.stopPropagation(); + setVisibleSubMenus((prev) => { + return updateVisibility(prev, parentIndex, index, item); + }); + + if (subMenuRefs.current[index].current) { + const itemRect = subMenuRefs.current[index].current!.parentElement?.getBoundingClientRect(); + if (itemRect) { + handleSubMenuPosition(parentIndex ?? index, index, itemRect, parentRef); + } + } + }; + + const handleMouseLeaveItem = (parentIndex: number | null, index: number) => { + setTimeout(() => { + setVisibleSubMenus((prev) => ({ + ...prev, + [parentIndex ?? index]: { + ...prev[parentIndex ?? index], + [index]: { visible: false, label: prev[parentIndex ?? index][index].label }, // Maintain label + }, + })); // Hide the specific submenu + }, 200); + }; + + // Recursive renderSubMenu to handle multiple nested submenus + const renderSubMenu = ( + subItems: DropdownItem[], + parentIndex: number | null, + index: number, + parentRef: React.RefObject + ) => { + return ( +
+ {subItems.map((item, idx) => ( +
+ handleMouseEnterItem( + event, + index, + idx, + item, + subMenuRefs.current[index], + "submenu item hovered" + ) + } + onClick={item.onClick} + > + {item.label} + {item.subItems && } + {visibleSubMenus[index]?.[idx]?.visible && + item.subItems && + renderSubMenu(item.subItems, index, idx, subMenuRefs.current[index])} +
+ ))} +
+ ); + }; + + if (!anchorRef?.current) { + return null; + } + + return ReactDOM.createPortal( +
+ {items.map((item, index) => ( +
+ handleMouseEnterItem(event, null, index, item, dropdownRef, "root menu item hovered") + } + onClick={item.onClick} + > + {item.label} + {item.subItems && } + {visibleSubMenus[index]?.visible && + item.subItems && + renderSubMenu(item.subItems, null, index, dropdownRef)} +
+ ))} +
, + document.body + ); +}); + +export { Dropdown };