blob: 3f0932814774669e8c4f6ac0ac758da33b81ed6d [file]
// packages/ui/src/primitives/stat-tile.tsx
//
// The shared implementation for "big number + label (+ detail)" stat tiles. Before
// this, four near-identical recipes lived in page CSS (permission summary,
// health summary — literal twins — plus the filled MetricCard and the
// daily-review totals cell).
//
// Recipe decisions (union of the twins):
// - value is tabular-nums ALWAYS (tabular-nums-converge contract);
// - emphasis="outline" = card-like tile (hairline + radius-surface +
// 1.5em value); emphasis="filled" = compact quiet tile (foreground-5
// wash + radius-control + ui-size value) for dense metric strips;
// - tone paints the value ink AND (outline only) tints the border —
// the health model, which scans better than value-only;
// - a ZERO count is not an exception: numeric zero drops the tone to
// neutral and sets data-empty (dim) — the permission rationale
// (0 已拒绝 in red read as a false alarm) now applies everywhere.
//
// Styled with package-owned semantic classes so the primitive is portable; wrapper
// classes from call sites (grid placement, page pins) pass through.
import type { ReactNode } from 'react';
import { cn } from '../utils.js';
export type StatTileTone = 'neutral' | 'info' | 'success' | 'warning' | 'destructive';
const TONE_VALUE_CLASS: Record<StatTileTone, string> = {
neutral: '',
info: 'maka-stat-tile-value-info',
success: 'maka-stat-tile-value-success',
warning: 'maka-stat-tile-value-warning',
destructive: 'maka-stat-tile-value-destructive',
};
const TONE_BORDER_CLASS: Record<StatTileTone, string> = {
neutral: '',
info: 'maka-stat-tile-border-info',
success: 'maka-stat-tile-border-success',
warning: 'maka-stat-tile-border-warning',
destructive: 'maka-stat-tile-border-destructive',
};
export interface StatTileProps {
label: ReactNode;
value: ReactNode;
/** Optional third quiet line under the label (MetricCard's detail). */
detail?: ReactNode;
tone?: StatTileTone;
/** outline = card tile (permission/health); filled = compact metric strip. */
emphasis?: 'outline' | 'filled';
/** Numeric zero drops tone to neutral + dims (data-empty). Default on. */
zeroNeutral?: boolean;
as?: 'div' | 'li';
className?: string;
}
export function StatTile({
label,
value,
detail,
tone = 'neutral',
emphasis = 'outline',
zeroNeutral = true,
as: Tag = 'div',
className,
}: StatTileProps) {
const isEmptyCount = zeroNeutral && typeof value === 'number' && value === 0;
const effectiveTone: StatTileTone = isEmptyCount ? 'neutral' : tone;
return (
<Tag
className={cn(
'maka-stat-tile',
emphasis === 'outline'
? 'maka-stat-tile-outline'
: 'maka-stat-tile-filled',
emphasis === 'outline' ? TONE_BORDER_CLASS[effectiveTone] : '',
isEmptyCount ? 'maka-stat-tile-empty' : '',
className,
)}
data-slot="stat-tile"
data-tone={effectiveTone}
data-empty={isEmptyCount ? 'true' : undefined}
>
<span
className={cn(
'maka-stat-tile-value',
emphasis === 'outline' ? 'maka-stat-tile-value-outline' : 'maka-stat-tile-value-filled',
TONE_VALUE_CLASS[effectiveTone],
)}
data-slot="stat-tile-value"
>
{value}
</span>
<span
className="maka-stat-tile-label"
data-slot="stat-tile-label"
>
{label}
</span>
{detail != null && (
<span
className="maka-stat-tile-detail"
data-slot="stat-tile-detail"
>
{detail}
</span>
)}
</Tag>
);
}