From 7e5ee352ccb2d850b61f8d0db6f5f420ba64e55c Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Thu, 2 Apr 2026 23:23:06 +0200 Subject: [PATCH] Add HierarchicalMenu component --- src/shared-ui/HierarchicalMenu.tsx | 104 +++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/shared-ui/HierarchicalMenu.tsx diff --git a/src/shared-ui/HierarchicalMenu.tsx b/src/shared-ui/HierarchicalMenu.tsx new file mode 100644 index 00000000..266a78a7 --- /dev/null +++ b/src/shared-ui/HierarchicalMenu.tsx @@ -0,0 +1,104 @@ +import type { CSSProperties, PropsWithChildren } from "react"; + +export type HierarchicalMenuPosition = { + x: number; + y: number; +}; + +export type HierarchicalMenuItem = + | { + kind: "action"; + label: string; + onSelect: () => void; + testId?: string; + disabled?: boolean; + } + | { + kind: "group"; + label: string; + testId?: string; + children: HierarchicalMenuItem[]; + } + | { + kind: "separator"; + }; + +interface HierarchicalMenuProps extends PropsWithChildren { + title: string; + position: HierarchicalMenuPosition; + items: HierarchicalMenuItem[]; + onClose(): void; +} + +function clampMenuPosition(position: HierarchicalMenuPosition): HierarchicalMenuPosition { + const horizontalPadding = 12; + const verticalPadding = 12; + const estimatedMenuWidth = 300; + const estimatedMenuHeight = 420; + + return { + x: Math.max(horizontalPadding, Math.min(position.x, window.innerWidth - estimatedMenuWidth - horizontalPadding)), + y: Math.max(verticalPadding, Math.min(position.y, window.innerHeight - estimatedMenuHeight - verticalPadding)) + }; +} + +function renderHierarchicalMenuItems(items: HierarchicalMenuItem[], onClose: () => void): React.ReactNode { + return items.map((item, index) => { + if (item.kind === "separator") { + return
; + } + + if (item.kind === "group") { + return ( +
+ + {item.label} + +
{renderHierarchicalMenuItems(item.children, onClose)}
+
+ ); + } + + return ( + + ); + }); +} + +export function HierarchicalMenu({ title, position, items, onClose }: HierarchicalMenuProps) { + const clampedPosition = clampMenuPosition(position); + const style: CSSProperties = { + left: `${clampedPosition.x}px`, + top: `${clampedPosition.y}px` + }; + + return ( +
+
event.stopPropagation()}> +
{title}
+
{renderHierarchicalMenuItems(items, onClose)}
+
+
+ ); +}