Files
doormilxpress_astryx/src/components/ds/MetricStrip.jsx
2026-08-10 19:36:45 +05:30

114 lines
4.0 KiB
JavaScript

import * as React from 'react';
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* MetricStrip — a row of operational figures.
*
* The Admin surface reports six to eight numbers at once. As individual cards
* that becomes a wall of boxes competing with the content below it, so this
* presents them as one banded strip: hairline dividers instead of borders, one
* shared surface, numbers aligned on a baseline. Dense enough for an operations
* console, still readable.
*
* `items: [{ label, value, delta?, deltaLabel?, invertTrend?, sub?, icon?, tone? }]`
*
* `invertTrend` exists because up is not always good: time-to-hire rising is
* bad, and colouring it green would misinform.
*/
const TONE = {
default: 'text-ink-1',
brand: 'text-krow-blue',
success: 'text-success',
warning: 'text-warning',
risk: 'text-destructive',
};
function Delta({ delta, label, invert }) {
if (typeof delta !== 'number') return null;
const flat = delta === 0;
const good = invert ? delta < 0 : delta > 0;
const Icon = flat ? Minus : delta > 0 ? ArrowUpRight : ArrowDownRight;
return (
<span
className={cn(
'inline-flex items-center gap-0.5 text-[11px] font-semibold tabular-nums',
flat ? 'text-ink-4' : good ? 'text-success' : 'text-destructive'
)}
>
<Icon className="h-3 w-3" aria-hidden="true" />
{flat ? '0%' : `${Math.abs(delta)}%`}
{label && <span className="ml-1 font-normal text-ink-4">{label}</span>}
</span>
);
}
export function MetricStrip({ items = [], columns, className, loading = false }) {
// Column count follows the data by default, so the strip divides evenly
// instead of leaving an orphan cell.
const count = columns ?? Math.min(items.length, 6);
/**
* Five and six columns wait for `xl` rather than `lg`.
*
* The strip lives in the Admin main column, which is narrower than the
* viewport whenever the Owliver workspace is open — roughly 400px narrower. A
* `lg` breakpoint measures the window and so cheerfully packs six cells into a
* 580px column, where every label clips. Two rows of three is the better
* answer at that width.
*/
const gridCols = {
2: 'sm:grid-cols-2',
3: 'sm:grid-cols-3',
4: 'grid-cols-2 sm:grid-cols-4',
5: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-5',
6: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6',
}[count] || 'sm:grid-cols-3';
return (
<div
className={cn(
'grid overflow-hidden rounded-xl border border-border bg-surface',
// Hairline dividers rather than gaps: one object, not six.
'divide-x divide-y divide-border sm:divide-y-0',
gridCols,
className
)}
>
{items.map((item) => (
<div key={item.label} className="min-w-0 px-4 py-3.5">
<div className="flex items-center gap-1.5">
{item.icon && <item.icon className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />}
{/* Wraps rather than truncates. A clipped label is worse than a
two-line one, and grid rows stretch together so the strip stays
even either way. */}
<p className="text-[11px] font-medium uppercase leading-tight tracking-wide text-ink-4">
{item.label}
</p>
</div>
{loading ? (
<div className="mt-1.5 h-7 w-16 animate-pulse rounded bg-surface-sunken" />
) : (
<div className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span
className={cn(
'font-heading text-title-lg font-bold tabular-nums leading-none',
TONE[item.tone] || TONE.default
)}
>
{item.value}
</span>
<Delta delta={item.delta} label={item.deltaLabel} invert={item.invertTrend} />
</div>
)}
{item.sub && <p className="mt-1 truncate text-caption text-ink-3">{item.sub}</p>}
</div>
))}
</div>
);
}