nsfwapp/frontend/src/components/ui/LoadingSpinner.tsx
2026-02-20 18:18:59 +01:00

88 lines
2.0 KiB
TypeScript

// frontend\src\components\ui\LoadingSpinner.tsx
'use client'
import * as React from 'react'
type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | number
export type LoadingSpinnerProps = {
/** Größe als Preset oder px (number) */
size?: SpinnerSize
/** Farbe via Tailwind (z.B. "text-indigo-500") */
className?: string
/** Optionaler Text neben dem Spinner (sichtbar) */
label?: React.ReactNode
/** Screenreader-Text (wenn label nicht gesetzt ist) */
srLabel?: string
/** Zentriert Spinner + Label als Inline-Flex */
center?: boolean
}
function sizeToPx(size: SpinnerSize): number {
if (typeof size === 'number') return size
if (size === 'xs') return 12
if (size === 'sm') return 16
if (size === 'lg') return 28
return 20 // md default
}
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
export default function LoadingSpinner({
size = 'md',
className,
label,
srLabel = 'Lädt…',
center = false,
}: LoadingSpinnerProps) {
const px = sizeToPx(size)
return (
<span
className={cn(
'inline-flex items-center gap-2',
center && 'justify-center w-full',
)}
role="status"
aria-live="polite"
>
<svg
width={px}
height={px}
viewBox="0 0 24 24"
className={cn('animate-spin text-gray-500', className)}
aria-hidden="true"
>
{/* Hintergrund-Ring (leicht transparent) */}
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke="currentColor"
strokeWidth="3"
opacity="0.25"
/>
{/* “Arc” vorne */}
<path
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
d="M21 12a9 9 0 0 0-9-9"
opacity="0.95"
/>
</svg>
{label ? (
<span className="text-sm text-gray-700 dark:text-gray-200">{label}</span>
) : (
<span className="sr-only">{srLabel}</span>
)}
</span>
)
}