| 67 | // ─── Component ──────────────────────────────────────────────────────────────── |
| 68 | |
| 69 | export function Spinner({ |
| 70 | mode = "loading", |
| 71 | spinnerTip, |
| 72 | overrideMessage, |
| 73 | spinnerSuffix, |
| 74 | inline = false, |
| 75 | className, |
| 76 | }: SpinnerProps) { |
| 77 | if (mode === "disabled") return null; |
| 78 | |
| 79 | const label = |
| 80 | overrideMessage ?? |
| 81 | spinnerTip ?? |
| 82 | MODE_LABEL[mode]; |
| 83 | |
| 84 | const ringClass = MODE_RING_CLASS[mode]; |
| 85 | const textClass = MODE_TEXT_CLASS[mode]; |
| 86 | |
| 87 | return ( |
| 88 | <span |
| 89 | role="status" |
| 90 | aria-label={label || "Loading"} |
| 91 | className={cn( |
| 92 | "flex items-center gap-2", |
| 93 | inline ? "inline-flex" : "flex", |
| 94 | className |
| 95 | )} |
| 96 | > |
| 97 | {/* CSS spinning ring */} |
| 98 | <span |
| 99 | className={cn( |
| 100 | "block w-3.5 h-3.5 rounded-full border-2 border-transparent animate-spin flex-shrink-0", |
| 101 | ringClass, |
| 102 | // Top border only — creates the "gap" in the ring for the spinning effect |
| 103 | "[border-top-color:currentColor]" |
| 104 | )} |
| 105 | style={{ borderTopColor: undefined }} |
| 106 | aria-hidden |
| 107 | > |
| 108 | {/* Inner ring for the visible arc — achieved via box-shadow trick */} |
| 109 | </span> |
| 110 | |
| 111 | {(label || spinnerSuffix) && ( |
| 112 | <span className={cn("text-sm font-mono", textClass)}> |
| 113 | {label} |
| 114 | {spinnerSuffix && ( |
| 115 | <span className="text-surface-500 ml-1">{spinnerSuffix}</span> |
| 116 | )} |
| 117 | </span> |
| 118 | )} |
| 119 | </span> |
| 120 | ); |
| 121 | } |
| 122 | |
| 123 | // ─── Shimmer / glimmer variant ──────────────────────────────────────────────── |
| 124 | |