nsfwapp/frontend/src/components/ui/LoadingSpinner.tsx
2026-05-12 09:25:27 +02:00

98 lines
2.3 KiB
TypeScript

// frontend\src\components\ui\LoadingSpinner.tsx
// frontend/src/components/ui/LoadingSpinner.tsx
'use client'
import { type ReactNode } 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?: 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 && 'w-full justify-center',
)}
role="status"
aria-live="polite"
aria-label={label ? undefined : srLabel}
>
<svg
width={px}
height={px}
viewBox="0 0 24 24"
className={cn(
'block shrink-0 text-gray-500 animate-spin',
className,
)}
aria-hidden="true"
>
{/* Hintergrund-Ring */}
<circle
cx="12"
cy="12"
r="8.5"
fill="none"
stroke="currentColor"
strokeWidth="3"
opacity="0.22"
/>
{/* Vorderes Segment mit runden Enden */}
<circle
cx="12"
cy="12"
r="8.5"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="22 54"
strokeDashoffset="0"
opacity="0.98"
/>
</svg>
{label ? (
<span className="text-sm text-gray-700 dark:text-gray-200">{label}</span>
) : (
<span className="sr-only">{srLabel}</span>
)}
</span>
)
}