feat: add React Flow UI foundation components for network diagrams

BaseNode (structured node shell with header/content/footer slots),
BaseHandle (styled connection handle), LabeledHandle (handle with
port label), NodeStatusIndicator (status border effect),
NodeTooltip (hover details via NodeToolbar).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-04 14:20:42 +00:00
parent 87de51b06e
commit 855cff07c2
5 changed files with 235 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
import type { ComponentProps } from 'react'
import { Handle, type HandleProps } from '@xyflow/react'
import { cn } from '@/lib/utils'
export type BaseHandleProps = HandleProps
export function BaseHandle({ className, children, ...props }: ComponentProps<typeof Handle>) {
return (
<Handle
{...props}
className={cn(
'h-[10px] w-[10px] rounded-full border border-default bg-elevated transition-opacity',
'opacity-0 group-hover:opacity-100',
className,
)}
>
{children}
</Handle>
)
}

View File

@@ -0,0 +1,56 @@
import type { ComponentProps } from 'react'
import { cn } from '@/lib/utils'
export function BaseNode({ className, ...props }: ComponentProps<'div'>) {
return (
<div
className={cn(
'bg-card text-heading relative rounded-lg border border-default',
'transition-colors hover:border-hover',
'in-[.selected]:border-accent',
className,
)}
tabIndex={0}
{...props}
/>
)
}
export function BaseNodeHeader({ className, ...props }: ComponentProps<'header'>) {
return (
<header
{...props}
className={cn('flex flex-row items-center gap-2 px-3 py-2', className)}
/>
)
}
export function BaseNodeHeaderTitle({ className, ...props }: ComponentProps<'h3'>) {
return (
<h3
data-slot="base-node-title"
className={cn('select-none flex-1 text-xs font-semibold text-heading', className)}
{...props}
/>
)
}
export function BaseNodeContent({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="base-node-content"
className={cn('flex flex-col gap-y-1 px-3 pb-2', className)}
{...props}
/>
)
}
export function BaseNodeFooter({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="base-node-footer"
className={cn('flex flex-col items-center gap-y-1 border-t border-default px-3 pt-1.5 pb-2', className)}
{...props}
/>
)
}

View File

@@ -0,0 +1,39 @@
import type { ComponentProps } from 'react'
import { type HandleProps, Position } from '@xyflow/react'
import { cn } from '@/lib/utils'
import { BaseHandle } from './base-handle'
const flexDirections: Record<string, string> = {
[Position.Top]: 'flex-col',
[Position.Right]: 'flex-row-reverse justify-end',
[Position.Bottom]: 'flex-col-reverse justify-end',
[Position.Left]: 'flex-row',
}
export function LabeledHandle({
className,
labelClassName,
handleClassName,
title,
position,
...props
}: HandleProps &
ComponentProps<'div'> & {
title: string
handleClassName?: string
labelClassName?: string
}) {
const { ref, ...handleProps } = props
return (
<div
title={title}
className={cn('relative flex items-center', flexDirections[position], className)}
ref={ref}
>
<BaseHandle position={position} className={handleClassName} {...handleProps} />
<label className={cn('text-muted-foreground text-[10px] font-mono px-1.5', labelClassName)}>
{title}
</label>
</div>
)
}

View File

@@ -0,0 +1,43 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
export type NodeStatus = 'online' | 'offline' | 'degraded' | 'unknown'
const STATUS_BORDER_COLORS: Record<NodeStatus, string> = {
online: 'border-emerald-400',
offline: 'border-red-400',
degraded: 'border-yellow-400',
unknown: '',
}
const STATUS_GLOW: Record<NodeStatus, string> = {
online: 'shadow-[0_0_8px_rgba(52,211,153,0.3)]',
offline: 'shadow-[0_0_8px_rgba(248,113,113,0.3)]',
degraded: 'shadow-[0_0_8px_rgba(250,204,21,0.3)]',
unknown: '',
}
interface NodeStatusIndicatorProps {
status?: NodeStatus
children: ReactNode
className?: string
}
export function NodeStatusIndicator({ status = 'unknown', children, className }: NodeStatusIndicatorProps) {
if (status === 'unknown') {
return <>{children}</>
}
return (
<div
className={cn(
'rounded-lg border-2 transition-colors',
STATUS_BORDER_COLORS[status],
STATUS_GLOW[status],
className,
)}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,77 @@
import { createContext, useContext, useState, useCallback, type ReactNode, type ComponentProps } from 'react'
import { NodeToolbar, type NodeToolbarProps } from '@xyflow/react'
import { cn } from '@/lib/utils'
interface NodeTooltipContextValue {
visible: boolean
show: () => void
hide: () => void
}
const NodeTooltipContext = createContext<NodeTooltipContextValue>({
visible: false,
show: () => {},
hide: () => {},
})
export function NodeTooltip({ children, ...props }: ComponentProps<'div'>) {
const [visible, setVisible] = useState(false)
const show = useCallback(() => setVisible(true), [])
const hide = useCallback(() => setVisible(false), [])
return (
<NodeTooltipContext.Provider value={{ visible, show, hide }}>
<div {...props}>{children}</div>
</NodeTooltipContext.Provider>
)
}
export function NodeTooltipTrigger({
children,
onMouseEnter,
onMouseLeave,
...props
}: ComponentProps<'div'>) {
const { show, hide } = useContext(NodeTooltipContext)
return (
<div
onMouseEnter={(e) => {
show()
onMouseEnter?.(e)
}}
onMouseLeave={(e) => {
hide()
onMouseLeave?.(e)
}}
{...props}
>
{children}
</div>
)
}
export function NodeTooltipContent({
className,
position,
children,
...props
}: Omit<NodeToolbarProps, 'children'> & { children: ReactNode }) {
const { visible } = useContext(NodeTooltipContext)
if (!visible) return null
return (
<NodeToolbar
position={position}
className={cn(
'rounded-lg border border-default bg-elevated px-3 py-2',
'pointer-events-none',
className,
)}
{...props}
>
{children}
</NodeToolbar>
)
}