import { useId, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import { cn } from '@/lib/utils' import { observeResize } from '@/lib/sharedResizeObserver' export type NodeStatus = 'loading' | 'success' | 'error' | 'initial' export type NodeStatusVariant = 'overlay' | 'border' export type NodeStatusIndicatorProps = { status?: NodeStatus variant?: NodeStatusVariant children: ReactNode /** Optional: node width/height so the spinner can match the border exactly */ width?: number height?: number } const R = 6 // rounded corner radius (0.375rem ≈ 6px) const DURATION_MS = 2400 /** Ease-out with overshoot: 0→70%→85%→100% keyframe values */ function dashOffsetAtProgress(progress: number, pathLength: number): number { if (progress <= 0.7) return ((pathLength + 30) * progress) / 0.7 if (progress <= 0.85) return pathLength + 30 + (25 * (progress - 0.7)) / 0.15 return pathLength + 55 - (5 * (progress - 0.85)) / 0.15 } /** One solid segment (half path length) moving along the border, with gradient opacity 0→1 along the segment. * Uses ResizeObserver so the border always matches the actual rendered node size (avoids min-width/min-height mismatch). */ function BorderLoadingIndicator({ children, width, height, }: { children: ReactNode width?: number height?: number }) { const containerRef = useRef(null) const [measured, setMeasured] = useState({ w: 0, h: 0 }) const hasMeasured = measured.w > 0 && measured.h > 0 const fallbackW = (width != null && height != null && width > 0 && height > 0) ? width + 4 : 0 const fallbackH = (width != null && height != null && width > 0 && height > 0) ? height + 4 : 0 const w = hasMeasured ? measured.w + 4 : fallbackW const h = hasMeasured ? measured.h + 4 : fallbackH const hasSize = w > 0 && h > 0 const pathD = hasSize ? `M ${R + 2} ${2} L ${w - R - 2} ${2} Q ${w - 2} ${2} ${w - 2} ${R + 2} L ${w - 2} ${h - R - 2} Q ${w - 2} ${h - 2} ${w - R - 2} ${h - 2} L ${R + 2} ${h - 2} Q ${2} ${h - 2} ${2} ${h - R - 2} L ${2} ${R + 2} Q ${2} ${2} ${R + 2} ${2} Z` : '' const gradientId = useId().replace(/:/g, '-') const pathRef = useRef(null) const gradientRef = useRef(null) const rafRef = useRef(0) const startTimeRef = useRef(0) useLayoutEffect(() => { const container = containerRef.current if (!container) return const target = container.firstElementChild instanceof HTMLElement ? container.firstElementChild : container const cw = (target as HTMLElement).offsetWidth const ch = (target as HTMLElement).offsetHeight if (cw > 0 && ch > 0) setMeasured({ w: cw, h: ch }) const unObserve = observeResize(target, ({ width: w, height: h }) => { if (w > 0 && h > 0) setMeasured({ w, h }) }) return unObserve }, []) useLayoutEffect(() => { if (!hasSize) return const pathEl = pathRef.current const gradientEl = gradientRef.current if (!pathEl || !gradientEl) return const totalLen = pathEl.getTotalLength() const segmentLen = totalLen * 0.5 const gapLen = totalLen * 0.5 + 80 pathEl.style.strokeDasharray = `${segmentLen} ${gapLen}` const tick = () => { const elapsed = (performance.now() - startTimeRef.current) % DURATION_MS const progress = Math.min(1, elapsed / DURATION_MS) const dashOffset = dashOffsetAtProgress(progress, totalLen) pathEl.style.strokeDashoffset = String(dashOffset) const startLen = dashOffset % totalLen const endLen = (dashOffset + segmentLen) % totalLen const startPt = pathEl.getPointAtLength(startLen) const endPt = pathEl.getPointAtLength(endLen) gradientEl.setAttribute('x1', String(startPt.x)) gradientEl.setAttribute('y1', String(startPt.y)) gradientEl.setAttribute('x2', String(endPt.x)) gradientEl.setAttribute('y2', String(endPt.y)) rafRef.current = requestAnimationFrame(tick) } startTimeRef.current = performance.now() rafRef.current = requestAnimationFrame(tick) return () => cancelAnimationFrame(rafRef.current) }, [hasSize, w, h]) return (
{children} {hasSize && ( )} {!hasSize && ( <>
)}
) } /** Error state: red/destructive border around the node */ function ErrorStatusBorder({ children, className }: { children: ReactNode; className?: string }) { return (
{children}
) } /** Success state: optional subtle border (e.g. green) - not required per user, keep minimal */ function SuccessStatusBorder({ children }: { children: ReactNode }) { return
{children}
} export function NodeStatusIndicator({ status, variant = 'border', children, width, height, }: NodeStatusIndicatorProps) { switch (status) { case 'loading': return variant === 'border' ? ( {children} ) : ( <>{children} ) case 'error': return {children} case 'success': return {children} default: return <>{children} } }