109 lines
3.3 KiB
TypeScript
109 lines
3.3 KiB
TypeScript
'use client'
|
|
|
|
import * as React from 'react'
|
|
|
|
type ProgressBarProps = {
|
|
label?: React.ReactNode
|
|
value?: number | null // 0..100
|
|
indeterminate?: boolean // wenn true -> “läuft…” ohne Prozent
|
|
showPercent?: boolean // zeigt “xx%” (nur determinate)
|
|
rightLabel?: React.ReactNode // optionaler Text unter der Bar (z.B. 3/10)
|
|
steps?: string[] // optional: Step-Labels
|
|
currentStep?: number // 0-basiert
|
|
size?: 'sm' | 'md'
|
|
className?: string
|
|
}
|
|
|
|
export default function ProgressBar({
|
|
label,
|
|
value = 0,
|
|
indeterminate = false,
|
|
showPercent = false,
|
|
rightLabel,
|
|
steps,
|
|
currentStep,
|
|
size = 'md',
|
|
className,
|
|
}: ProgressBarProps) {
|
|
const clamped = Math.max(0, Math.min(100, Number(value) || 0))
|
|
const h = size === 'sm' ? 'h-1.5' : 'h-2'
|
|
|
|
const hasSteps = Array.isArray(steps) && steps.length > 0
|
|
const stepCount = hasSteps ? steps!.length : 0
|
|
|
|
const stepAlign = (i: number) => {
|
|
if (i === 0) return 'text-left'
|
|
if (i === stepCount - 1) return 'text-right'
|
|
return 'text-center'
|
|
}
|
|
|
|
const isActiveStep = (i: number) => {
|
|
if (typeof currentStep !== 'number' || !Number.isFinite(currentStep)) return false
|
|
return i <= currentStep
|
|
}
|
|
|
|
const showPct = showPercent && !indeterminate
|
|
|
|
return (
|
|
<div className={className}>
|
|
{/* ✅ Label + Prozent jetzt ÜBER der Bar */}
|
|
{(label || showPct) ? (
|
|
<div className="flex items-center justify-between gap-2">
|
|
{label ? (
|
|
<p className="flex-1 min-w-0 truncate text-xs font-medium text-gray-900 dark:text-white">
|
|
{label}
|
|
</p>
|
|
) : (
|
|
<span className="flex-1" />
|
|
)}
|
|
|
|
{showPct ? (
|
|
<span className="shrink-0 text-xs font-medium text-gray-700 dark:text-gray-300">
|
|
{Math.round(clamped)}%
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<div aria-hidden="true" className={(label || showPct) ? 'mt-2' : ''}>
|
|
<div className="overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
|
|
{indeterminate ? (
|
|
<div className={`${h} w-full rounded-full bg-indigo-600/70 dark:bg-indigo-500/70 animate-pulse`} />
|
|
) : (
|
|
<div
|
|
className={`${h} rounded-full bg-indigo-600 dark:bg-indigo-500 transition-[width] duration-200`}
|
|
style={{ width: `${clamped}%` }}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* ✅ rightLabel bleibt unter der Bar (links), Prozent ist jetzt oben */}
|
|
{rightLabel ? (
|
|
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
|
{rightLabel}
|
|
</div>
|
|
) : null}
|
|
|
|
{hasSteps ? (
|
|
<div
|
|
className="mt-3 hidden text-sm font-medium text-gray-600 sm:grid dark:text-gray-400"
|
|
style={{ gridTemplateColumns: `repeat(${stepCount}, minmax(0, 1fr))` }}
|
|
>
|
|
{steps!.map((s, i) => (
|
|
<div
|
|
key={`${i}-${s}`}
|
|
className={[
|
|
stepAlign(i),
|
|
isActiveStep(i) ? 'text-indigo-600 dark:text-indigo-400' : '',
|
|
].join(' ')}
|
|
>
|
|
{s}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|