Files
krow_talent_app/src/components/ds/Progress.jsx
2026-08-17 17:33:56 +05:30

260 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as React from 'react';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
const clamp = (n) => Math.max(0, Math.min(100, Number(n) || 0));
const TRACK_TONES = {
brand: 'bg-krow-blue',
navy: 'bg-krow-navy',
accent: 'bg-krow-yellow',
success: 'bg-success',
warning: 'bg-warning',
destructive: 'bg-destructive',
};
/**
* Score-band colour, shared by every progress component.
*
* The app scores people constantly, and the band boundaries (80/60/40) are the
* same ones the pipeline tiers use — so a 72 is the same colour on a bar, a ring
* and a leaderboard row.
*/
export function toneForScore(score) {
const s = clamp(score);
if (s >= 80) return 'brand';
if (s >= 60) return 'success';
if (s >= 40) return 'warning';
return 'destructive';
}
/**
* ProgressBar — a horizontal track.
*
* `tone="score"` picks the colour from the value, which is what most usages in
* this app want.
*/
/** @param {any} props */
export function ProgressBar({
value,
max = 100,
tone = 'brand',
size = 'default',
label,
showValue = false,
valueSuffix = '',
className,
trackClassName,
}) {
const pct = clamp((Number(value) / max) * 100);
const resolvedTone = tone === 'score' ? toneForScore(pct) : tone;
const heights = { xs: 'h-1', sm: 'h-1.5', default: 'h-2', lg: 'h-2.5' };
return (
<div className={className}>
{(label || showValue) && (
<div className="flex items-center justify-between gap-2 mb-1.5">
{label && <span className="text-caption font-medium text-ink-2">{label}</span>}
{showValue && (
<span className="text-caption font-semibold text-ink-1 tabular-nums">
{value}
{valueSuffix}
</span>
)}
</div>
)}
<div
role="progressbar"
aria-valuenow={clamp(value)}
aria-valuemin={0}
aria-valuemax={max}
aria-label={label || undefined}
className={cn(
'w-full overflow-hidden rounded-full bg-surface-sunken',
heights[size] || heights.default,
trackClassName
)}
>
<div
className={cn(
'h-full rounded-full transition-all duration-slow ease-out',
TRACK_TONES[resolvedTone]
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
/**
* ProgressRing — a circular gauge.
*
* Supports the app's two scales: 0–100 percentages and the 300–850 career
* score, via `min`/`max`, so the KROW ID gauge and a completion ring are the
* same component.
*/
/** @param {any} props */
export function ProgressRing({
value,
min = 0,
max = 100,
size = 88,
strokeWidth = 8,
tone = 'brand',
/** Big text in the centre. Defaults to the value. */
label,
/** Small text above the label. */
caption,
/** Small text below the label. */
footnote,
className,
}) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const ratio = Math.max(0, Math.min(1, (Number(value) - min) / (max - min)));
const resolvedTone = tone === 'score' ? toneForScore(ratio * 100) : tone;
const strokeColor = {
brand: '#0838E0',
navy: '#333F48',
accent: '#F9E547',
success: 'hsl(142 71% 36%)',
warning: 'hsl(32 95% 44%)',
destructive: 'hsl(0 84% 60%)',
}[resolvedTone];
return (
<div
className={cn('relative inline-grid place-items-center shrink-0', className)}
style={{ width: size, height: size }}
role="img"
aria-label={`${label ?? value} ${caption ?? ''}`.trim()}
>
<svg width={size} height={size} className="-rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
className="text-surface-sunken"
strokeWidth={strokeWidth}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={strokeColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={`${ratio * circumference} ${circumference}`}
className="transition-[stroke-dasharray] duration-slow ease-out"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center text-center leading-none">
{caption && (
<span className="text-[8px] uppercase tracking-wide text-ink-4 font-medium">{caption}</span>
)}
<span
className="font-heading font-bold text-ink-1 tabular-nums"
style={{ fontSize: Math.max(12, size * 0.24) }}
>
{label ?? value}
</span>
{footnote && <span className="text-[8px] text-ink-4 font-medium mt-0.5">{footnote}</span>}
</div>
</div>
);
}
/**
* ProgressSteps — a linear multi-step indicator for wizards.
*
* Completed steps show a check rather than their number, so "done" is readable
* at a glance without relying on colour.
*/
/** @param {any} props */
export function ProgressSteps({ steps = [], current = 0, className = '', onStepClick = null }) {
const normalized = steps.map((s) => (typeof s === 'string' ? { label: s } : s));
return (
<ol className={cn('flex items-center gap-2', className)}>
{normalized.map((step, i) => {
const done = i < current;
const active = i === current;
const clickable = onStepClick && i <= current;
return (
<li key={step.label ?? i} className="flex items-center gap-2 flex-1 last:flex-none">
<button
type="button"
disabled={!clickable}
onClick={clickable ? () => onStepClick(i) : undefined}
aria-current={active ? 'step' : undefined}
className={cn(
'flex items-center gap-2 min-w-0',
clickable ? 'cursor-pointer' : 'cursor-default'
)}
>
<span
className={cn(
'grid place-items-center w-6 h-6 rounded-full text-[11px] font-bold shrink-0 transition-colors duration-base',
done && 'bg-krow-blue text-white',
active && 'bg-krow-blue-tint text-krow-blue ring-2 ring-krow-blue',
!done && !active && 'bg-surface-sunken text-ink-4'
)}
>
{done ? <Check className="w-3 h-3" aria-hidden="true" /> : i + 1}
</span>
{step.label && (
<span
className={cn(
'text-caption font-medium truncate hidden sm:block',
active ? 'text-ink-1' : done ? 'text-ink-2' : 'text-ink-4'
)}
>
{step.label}
</span>
)}
</button>
{i < normalized.length - 1 && (
<span
aria-hidden="true"
className={cn(
'h-px flex-1 min-w-4 transition-colors duration-base',
done ? 'bg-krow-blue' : 'bg-border'
)}
/>
)}
</li>
);
})}
</ol>
);
}
/**
* ScoreMeter — a labelled bar row used in score breakdowns, where many
* dimensions are listed with a weight and a value.
*/
/** @param {any} props */
export function ScoreMeter({ label, value, weight, tone = 'score', className }) {
return (
<div className={cn('space-y-1.5', className)}>
<div className="flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2 truncate">{label}</span>
<span className="flex items-baseline gap-1.5 shrink-0">
{weight != null && <span className="text-[10px] text-ink-4">{weight}% weight</span>}
<span className="text-body-sm font-semibold text-ink-1 tabular-nums">{value}</span>
</span>
</div>
<ProgressBar value={value} tone={tone} size="sm" />
</div>
);
}