198 lines
7.5 KiB
TypeScript
198 lines
7.5 KiB
TypeScript
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<HTMLDivElement>(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<SVGPathElement>(null)
|
|
const gradientRef = useRef<SVGLinearGradientElement>(null)
|
|
const rafRef = useRef<number>(0)
|
|
const startTimeRef = useRef<number>(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 (
|
|
<div ref={containerRef} className="relative w-full h-full overflow-visible">
|
|
{children}
|
|
{hasSize && (
|
|
<svg
|
|
className="absolute pointer-events-none z-10"
|
|
style={{ top: -2, left: -2, width: w, height: h }}
|
|
aria-hidden
|
|
>
|
|
<defs>
|
|
<linearGradient
|
|
ref={gradientRef}
|
|
id={gradientId}
|
|
gradientUnits="userSpaceOnUse"
|
|
x1="0"
|
|
y1="0"
|
|
x2="1"
|
|
y2="0"
|
|
>
|
|
<stop offset="0" stopColor="hsl(var(--primary))" stopOpacity="0" />
|
|
<stop offset="1" stopColor="hsl(var(--primary))" stopOpacity="1" />
|
|
</linearGradient>
|
|
</defs>
|
|
<path
|
|
ref={pathRef}
|
|
className="fill-none stroke-[2]"
|
|
d={pathD}
|
|
stroke={`url(#${gradientId})`}
|
|
style={{ strokeLinecap: 'round' }}
|
|
/>
|
|
</svg>
|
|
)}
|
|
{!hasSize && (
|
|
<>
|
|
<style>{`
|
|
@keyframes node-status-pulse {
|
|
0%, 100% { opacity: 0.35; }
|
|
50% { opacity: 0.7; }
|
|
}
|
|
`}</style>
|
|
<div
|
|
className="absolute -inset-[2px] rounded-md border-[1.5px] border-primary pointer-events-none z-10"
|
|
style={{ opacity: 0.8, animation: 'node-status-pulse 2s ease-in-out infinite' }}
|
|
aria-hidden
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Error state: red/destructive border around the node */
|
|
function ErrorStatusBorder({ children, className }: { children: ReactNode; className?: string }) {
|
|
return (
|
|
<div className={cn('rounded-md ring-2 ring-destructive', className)}>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Success state: optional subtle border (e.g. green) - not required per user, keep minimal */
|
|
function SuccessStatusBorder({ children }: { children: ReactNode }) {
|
|
return <div className="rounded-md">{children}</div>
|
|
}
|
|
|
|
export function NodeStatusIndicator({
|
|
status,
|
|
variant = 'border',
|
|
children,
|
|
width,
|
|
height,
|
|
}: NodeStatusIndicatorProps) {
|
|
switch (status) {
|
|
case 'loading':
|
|
return variant === 'border' ? (
|
|
<BorderLoadingIndicator width={width} height={height}>{children}</BorderLoadingIndicator>
|
|
) : (
|
|
<>{children}</>
|
|
)
|
|
case 'error':
|
|
return <ErrorStatusBorder>{children}</ErrorStatusBorder>
|
|
case 'success':
|
|
return <SuccessStatusBorder>{children}</SuccessStatusBorder>
|
|
default:
|
|
return <>{children}</>
|
|
}
|
|
}
|