From 20ea2cefa5276f85dd851c9517aa4133ca5fbc9b Mon Sep 17 00:00:00 2001 From: Abbey Yacoe Date: Mon, 25 Nov 2024 16:34:07 +0100 Subject: [PATCH 1/2] feat(edge-dropdown-menu): create edge component with a dropdown --- .../components/edge-dropdown-menu/page.tsx | 10 + .../components/ui/dropdown-menu.tsx | 200 ++++++++++++++++++ apps/ui-components/package.json | 1 + .../components/edge-dropdown-menu/demo.tsx | 45 ++++ .../components/edge-dropdown-menu/index.tsx | 119 +++++++++++ .../edge-dropdown-menu/registry.json | 16 ++ pnpm-lock.yaml | 76 ++++++- .../src/pages/components/edges/_meta.tsx | 1 + .../components/edges/edge-dropdown-menu.mdx | 15 ++ 9 files changed, 480 insertions(+), 3 deletions(-) create mode 100644 apps/ui-components/app/components/edge-dropdown-menu/page.tsx create mode 100644 apps/ui-components/components/ui/dropdown-menu.tsx create mode 100644 apps/ui-components/registry/components/edge-dropdown-menu/demo.tsx create mode 100644 apps/ui-components/registry/components/edge-dropdown-menu/index.tsx create mode 100644 apps/ui-components/registry/components/edge-dropdown-menu/registry.json create mode 100644 sites/reactflow.dev/src/pages/components/edges/edge-dropdown-menu.mdx diff --git a/apps/ui-components/app/components/edge-dropdown-menu/page.tsx b/apps/ui-components/app/components/edge-dropdown-menu/page.tsx new file mode 100644 index 000000000..4462d180d --- /dev/null +++ b/apps/ui-components/app/components/edge-dropdown-menu/page.tsx @@ -0,0 +1,10 @@ +import DemoWrapper from "@/components/demo-wrapper"; +import Demo from "@/registry/components/edge-dropdown-menu/demo"; + +export default function DemoPage() { + return ( + + + + ); +} diff --git a/apps/ui-components/components/ui/dropdown-menu.tsx b/apps/ui-components/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..eb4b1cd94 --- /dev/null +++ b/apps/ui-components/components/ui/dropdown-menu.tsx @@ -0,0 +1,200 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { cn } from "@/lib/utils" +import { CheckIcon, ChevronRightIcon, DotFilledIcon } from "@radix-ui/react-icons" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className + )} + {...props} + /> +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/apps/ui-components/package.json b/apps/ui-components/package.json index 56432a743..d32f9e1c8 100644 --- a/apps/ui-components/package.json +++ b/apps/ui-components/package.json @@ -12,6 +12,7 @@ "generate-registry": "node ./scripts/generate-registry.js" }, "dependencies": { + "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slider": "^1.2.1", diff --git a/apps/ui-components/registry/components/edge-dropdown-menu/demo.tsx b/apps/ui-components/registry/components/edge-dropdown-menu/demo.tsx new file mode 100644 index 000000000..42ff0fc3a --- /dev/null +++ b/apps/ui-components/registry/components/edge-dropdown-menu/demo.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { Background, ReactFlow } from "@xyflow/react"; +import { EdgeDropdownMenu } from "@/registry/components/edge-dropdown-menu"; + +const defaultNodes = [ + { + id: "1", + position: { x: 200, y: 200 }, + data: { label: "Node" }, + }, + { + id: "2", + position: { x: 500, y: 500 }, + data: { label: "Node" }, + }, +]; + +const defaultEdges = [ + { + id: "e1-2", + source: "1", + target: "2", + type: "edgeDropdownMenu", + }, +]; + +const edgeTypes = { + edgeDropdownMenu: EdgeDropdownMenu, +}; + +export default function Demo() { + return ( +
+ + + +
+ ); +} diff --git a/apps/ui-components/registry/components/edge-dropdown-menu/index.tsx b/apps/ui-components/registry/components/edge-dropdown-menu/index.tsx new file mode 100644 index 000000000..7ee432507 --- /dev/null +++ b/apps/ui-components/registry/components/edge-dropdown-menu/index.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + BaseEdge, + EdgeLabelRenderer, + EdgeProps, + getBezierPath, +} from "@xyflow/react"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; + +export function EdgeDropdownMenu({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + style = {}, + markerEnd, +}: EdgeProps) { + const [isToolbarVisible, setIsToolbarVisible] = useState(false); + const menuRef = useRef(null); + + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + const toggleToolbarVisibility = ( + event: React.MouseEvent, + ) => { + event.stopPropagation(); + setIsToolbarVisible((prev) => !prev); + }; + + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsToolbarVisible(false); + } + }; + + useEffect(() => { + if (isToolbarVisible) { + document.addEventListener("click", handleClickOutside); + } else { + document.removeEventListener("click", handleClickOutside); + } + return () => { + document.removeEventListener("click", handleClickOutside); + }; + }, [isToolbarVisible]); + + return ( + <> + + + +
+ +
+ + {isToolbarVisible && ( +
+ + + + window.alert("New Node Created")} + > + Create New Node + + window.alert("Node Deleted")}> + Delete Node + + + +
+ )} +
+ + ); +} + +EdgeDropdownMenu.displayName = "EdgeDropdownMenu"; diff --git a/apps/ui-components/registry/components/edge-dropdown-menu/registry.json b/apps/ui-components/registry/components/edge-dropdown-menu/registry.json new file mode 100644 index 000000000..b18f2c7d1 --- /dev/null +++ b/apps/ui-components/registry/components/edge-dropdown-menu/registry.json @@ -0,0 +1,16 @@ +{ + "name": "edge-dropdown-menu", + "type": "registry:component", + "dependencies": ["@xyflow/react"], + "devDependencies": [], + "registryDependencies": ["dropdown-menu", "button"], + "files": [ + { + "type": "registry:component", + "path": "edge-dropdown-menu.tsx" + } + ], + "tailwind": {}, + "cssVars": {}, + "meta": {} +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1504c298..ea33ef62d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,6 +160,9 @@ importers: apps/ui-components: dependencies: + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.2 + version: 2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-icons': specifier: ^1.3.0 version: 1.3.0(react@18.3.1) @@ -1606,6 +1609,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dropdown-menu@2.1.2': + resolution: {integrity: sha512-GVZMR+eqK8/Kes0a36Qrv+i20bAPXSn8rCBTHx30w+3ECnR5o3xixAlqcVaYvLeyKUsm0aqyhWfmUcqufM8nYA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.1.1': resolution: {integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==} peerDependencies: @@ -1655,6 +1671,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-menu@2.1.2': + resolution: {integrity: sha512-lZ0R4qR2Al6fZ4yCCZzu/ReTFrylHFxIqy7OezIpWF4bL0o9biKo0pFIvkaew3TyZ9Fy5gYVrR5zCGZBVbO1zg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popover@1.1.2': resolution: {integrity: sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==} peerDependencies: @@ -7831,6 +7860,21 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-dropdown-menu@2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-menu': 2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-focus-guards@1.1.1(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 @@ -7868,6 +7912,32 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-menu@2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-popper': 1.2.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.6.0(@types/react@18.3.12)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-popover@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -10160,7 +10230,7 @@ snapshots: debug: 4.3.7 enhanced-resolve: 5.17.1 eslint: 8.57.1 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.12.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.12.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1) fast-glob: 3.3.2 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 @@ -10173,7 +10243,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.12.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.12.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -10195,7 +10265,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.12.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.12.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 diff --git a/sites/reactflow.dev/src/pages/components/edges/_meta.tsx b/sites/reactflow.dev/src/pages/components/edges/_meta.tsx index e7a4c9321..3b1d32f1b 100644 --- a/sites/reactflow.dev/src/pages/components/edges/_meta.tsx +++ b/sites/reactflow.dev/src/pages/components/edges/_meta.tsx @@ -1,4 +1,5 @@ export default { 'button-edge': 'Edge with Button', + 'edge-dropdown-menu': 'Edge with Dropdown Menu', 'animated-svg-edge': 'Animated SVG Edge', }; diff --git a/sites/reactflow.dev/src/pages/components/edges/edge-dropdown-menu.mdx b/sites/reactflow.dev/src/pages/components/edges/edge-dropdown-menu.mdx new file mode 100644 index 000000000..ef6c9ebf0 --- /dev/null +++ b/sites/reactflow.dev/src/pages/components/edges/edge-dropdown-menu.mdx @@ -0,0 +1,15 @@ +--- +title: Edge Dropdown Menu +description: A custom edge component with a dropdown menu. +--- + +import UiComponentViewer from '@/components/ui-component-viewer'; +import fetchShadcnComponent from '@/utils/get-static-props/fetch-shadcn-component'; + +export const getStaticProps = fetchShadcnComponent('edge-dropdown-menu'); + +# Edge Dropdown Menu + +A custom edge with a dropdown menu that appears when a button is clicked. + + From 3d7feeca35e080c92495d09630098523489a5d0a Mon Sep 17 00:00:00 2001 From: Abbey Yacoe Date: Mon, 25 Nov 2024 16:40:34 +0100 Subject: [PATCH 2/2] fix(edge-dropdown-menu): swap word toggle for dropdown --- .../components/edge-dropdown-menu/index.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ui-components/registry/components/edge-dropdown-menu/index.tsx b/apps/ui-components/registry/components/edge-dropdown-menu/index.tsx index 7ee432507..a43c22a20 100644 --- a/apps/ui-components/registry/components/edge-dropdown-menu/index.tsx +++ b/apps/ui-components/registry/components/edge-dropdown-menu/index.tsx @@ -24,7 +24,7 @@ export function EdgeDropdownMenu({ style = {}, markerEnd, }: EdgeProps) { - const [isToolbarVisible, setIsToolbarVisible] = useState(false); + const [isDropdownVisible, setIsDropdownVisible] = useState(false); const menuRef = useRef(null); const [edgePath, labelX, labelY] = getBezierPath({ @@ -36,21 +36,21 @@ export function EdgeDropdownMenu({ targetPosition, }); - const toggleToolbarVisibility = ( + const toggleDropdownVisibility = ( event: React.MouseEvent, ) => { event.stopPropagation(); - setIsToolbarVisible((prev) => !prev); + setIsDropdownVisible((prev) => !prev); }; const handleClickOutside = (event: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setIsToolbarVisible(false); + setIsDropdownVisible(false); } }; useEffect(() => { - if (isToolbarVisible) { + if (isDropdownVisible) { document.addEventListener("click", handleClickOutside); } else { document.removeEventListener("click", handleClickOutside); @@ -58,7 +58,7 @@ export function EdgeDropdownMenu({ return () => { document.removeEventListener("click", handleClickOutside); }; - }, [isToolbarVisible]); + }, [isDropdownVisible]); return ( <> @@ -76,7 +76,7 @@ export function EdgeDropdownMenu({ }} > - {isToolbarVisible && ( + {isDropdownVisible && (