feat: mejoras componentes UI — card variants, kpi_card slots, sparkline colors, bar_chart horizontal radius

- card: variantes default/borderless/ghost con ring condicional
- kpi_card: props unit, action, chart y delta con label/suffix personalizable
- sparkline: prop colors para colores por barra en variant bar
- bar_chart: radius condicional según orientación horizontal/vertical

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 15:32:35 +02:00
parent 1aaeec5090
commit aea2131dcb
8 changed files with 125 additions and 32 deletions
+5 -1
View File
@@ -3,7 +3,7 @@ name: bar_chart
kind: component kind: component
lang: typescript lang: typescript
domain: ui domain: ui
version: "1.0.0" version: "1.1.0"
purity: impure purity: impure
signature: "BarChart(props: BarChartProps): JSX.Element" signature: "BarChart(props: BarChartProps): JSX.Element"
description: "Gráfico de barras Recharts con multi-series, orientación horizontal/vertical, tooltips temáticos y bordes redondeados." description: "Gráfico de barras Recharts con multi-series, orientación horizontal/vertical, tooltips temáticos y bordes redondeados."
@@ -50,3 +50,7 @@ source_file: "frontend/src/components/ui/charts/bar-chart.tsx"
<BarChart data={data} xKey="category" yKey="sales" showLegend /> <BarChart data={data} xKey="category" yKey="sales" showLegend />
<BarChart data={data} xKey="name" series={series} horizontal /> <BarChart data={data} xKey="name" series={series} horizontal />
``` ```
## Notas
En modo `horizontal=true`: el layout de Recharts es `'vertical'`, YAxis recibe `dataKey={xKey}` con `type="category"` (categorías en eje Y), XAxis recibe `type="number"` (valores en eje X). El radius de las barras se ajusta a `[0, 4, 4, 0]` para redondear la punta derecha. Este intercambio de ejes es obligatorio — sin él las barras horizontales no se renderizan.
+1 -1
View File
@@ -43,7 +43,7 @@ function BarChartComponent({
)} )}
<Tooltip content={<ChartTooltipContent valueFormatter={valueFormatter} />} cursor={{ fill: 'hsl(var(--muted) / 0.3)' }} /> <Tooltip content={<ChartTooltipContent valueFormatter={valueFormatter} />} cursor={{ fill: 'hsl(var(--muted) / 0.3)' }} />
{showLegend && <Legend />} {showLegend && <Legend />}
{bars.map((bar) => <Bar key={bar.dataKey} dataKey={bar.dataKey} name={bar.name} fill={bar.fill} radius={[4, 4, 0, 0]} />)} {bars.map((bar) => <Bar key={bar.dataKey} dataKey={bar.dataKey} name={bar.name} fill={bar.fill} radius={horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]} />)}
</RechartsBarChart> </RechartsBarChart>
</ChartContainer> </ChartContainer>
) )
+21 -5
View File
@@ -3,11 +3,11 @@ name: card
kind: component kind: component
lang: ts lang: ts
domain: ui domain: ui
version: "1.0.0" version: "1.1.0"
purity: impure purity: impure
signature: "Card(props: { size?: 'default' | 'sm'; className?: string; children: ReactNode }): JSX.Element" signature: "Card(props: { size?: 'default' | 'sm'; variant?: 'default' | 'borderless' | 'ghost'; className?: string; children: ReactNode }): JSX.Element"
description: "Contenedor card con header, title, description, action, content y footer. Sistema de slots composable." description: "Contenedor card con header, title, description, action, content y footer. Sistema de slots composable. Variantes default, borderless y ghost para dashboards dark."
tags: [card, container, layout, component, ui] tags: [card, container, layout, component, ui, dashboard, dark]
uses_functions: [cn_typescript_core] uses_functions: [cn_typescript_core]
uses_types: [] uses_types: []
returns: [] returns: []
@@ -23,6 +23,10 @@ props:
type: "'default' | 'sm'" type: "'default' | 'sm'"
required: false required: false
description: "Tamaño del card" description: "Tamaño del card"
- name: variant
type: "'default' | 'borderless' | 'ghost'"
required: false
description: "Variante visual. borderless quita borde/shadow, ghost además hace bg transparente"
- name: className - name: className
type: "string" type: "string"
required: false required: false
@@ -30,7 +34,7 @@ props:
emits: [] emits: []
has_state: false has_state: false
framework: react framework: react
variant: [default, sm] variant: [default, sm, borderless, ghost]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT" source_license: "MIT"
source_file: "frontend/src/components/ui/card.tsx" source_file: "frontend/src/components/ui/card.tsx"
@@ -47,8 +51,20 @@ source_file: "frontend/src/components/ui/card.tsx"
<CardContent>Contenido</CardContent> <CardContent>Contenido</CardContent>
<CardFooter>Footer</CardFooter> <CardFooter>Footer</CardFooter>
</Card> </Card>
{/* Dashboard dark — sin bordes */}
<Card variant="borderless">
<CardContent>Widget sin marco</CardContent>
</Card>
{/* Completamente transparente */}
<Card variant="ghost">
<CardContent>Sin fondo ni borde</CardContent>
</Card>
``` ```
## Notas ## Notas
Sistema de slots via data-slot attributes. Card detecta automáticamente la presencia de CardFooter y ajusta el padding. Exporta 7 subcomponentes composables. Sistema de slots via data-slot attributes. Card detecta automáticamente la presencia de CardFooter y ajusta el padding. Exporta 7 subcomponentes composables.
Las variantes `borderless` y `ghost` eliminan el `ring-1` del borde por defecto. `ghost` además hace el fondo transparente. Alternativa con CSS global: `[data-slot="card"] { --tw-ring-opacity: 0; }` o `[data-variant="borderless"] { ring: 0 }` via `data-variant` attribute expuesto.
+9 -2
View File
@@ -1,17 +1,24 @@
import * as React from "react" import * as React from "react"
import { cn } from "../core/cn" import { cn } from "../core/cn"
type CardVariant = "default" | "borderless" | "ghost"
function Card({ function Card({
className, className,
size = "default", size = "default",
variant = "default",
...props ...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { }: React.ComponentProps<"div"> & { size?: "default" | "sm"; variant?: CardVariant }) {
return ( return (
<div <div
data-slot="card" data-slot="card"
data-size={size} data-size={size}
data-variant={variant}
className={cn( className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", "group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
variant === "default" && "ring-1 ring-foreground/10",
variant === "borderless" && "ring-0 shadow-none",
variant === "ghost" && "ring-0 shadow-none bg-transparent",
className className
)} )}
{...props} {...props}
+45 -8
View File
@@ -3,11 +3,11 @@ name: kpi_card
kind: component kind: component
lang: typescript lang: typescript
domain: ui domain: ui
version: "1.0.0" version: "2.0.0"
purity: impure purity: impure
signature: "KPICard(props: KPICardProps): JSX.Element" signature: "KPICard(props: KPICardProps): JSX.Element"
description: "Card de KPI con label, valor, delta porcentual con color semántico, icono y subtítulo. 3 tamaños." description: "Card de KPI con label, valor+unidad, delta descriptivo con color semántico, icono, slot de chart inline y action. 3 tamaños."
tags: [kpi, card, metrics, dashboard, component, ui] tags: [kpi, card, metrics, dashboard, component, ui, sparkline]
uses_functions: [cn_typescript_core] uses_functions: [cn_typescript_core]
uses_types: [] uses_types: []
returns: [] returns: []
@@ -27,14 +27,26 @@ props:
type: "string | number" type: "string | number"
required: true required: true
description: "Valor principal" description: "Valor principal"
- name: delta - name: unit
type: "{ value: number; isPositive: boolean }" type: "string"
required: false required: false
description: "Cambio porcentual con dirección" description: "Unidad junto al valor en font menor (ej: k, ms, %)"
- name: delta
type: "{ value: number; isPositive: boolean; label?: string; suffix?: string }"
required: false
description: "Cambio con dirección, label descriptivo y sufijo"
- name: icon - name: icon
type: "ReactNode" type: "ReactNode"
required: false required: false
description: "Icono decorativo" description: "Icono a la izquierda del label"
- name: action
type: "ReactNode"
required: false
description: "Slot top-right para menú o acciones"
- name: chart
type: "ReactNode"
required: false
description: "Slot para mini chart inline junto al valor"
- name: size - name: size
type: "'sm' | 'default' | 'lg'" type: "'sm' | 'default' | 'lg'"
required: false required: false
@@ -51,6 +63,31 @@ source_file: "frontend/src/components/ui/kpi-card.tsx"
## Ejemplo ## Ejemplo
```tsx ```tsx
import { KPICard, Sparkline } from '@anthropic/frontend-lib'
{/* Básico */}
<KPICard label="Revenue" value="$12,450" delta={{ value: 12.5, isPositive: true }} /> <KPICard label="Revenue" value="$12,450" delta={{ value: 12.5, isPositive: true }} />
<KPICard label="Users" value={1234} size="lg" icon={<UsersIcon />} />
{/* Con unidad separada, delta descriptivo, y mini barras */}
<KPICard
label="Processed Prompts"
value="124"
unit="k"
icon={<ZapIcon className="h-4 w-4" />}
delta={{ value: 15, isPositive: true, label: "Prompts Increased by", suffix: "vs yesterday" }}
chart={<Sparkline data={[5, 8, 3, 9, 6, 12, 7]} variant="bar" colors={['#3b82f6', '#8b5cf6', '#f59e0b', '#10b981', '#ef4444', '#ec4899', '#06b6d4']} height={32} />}
action={<button className="text-muted-foreground hover:text-foreground">...</button>}
/>
{/* Dashboard dark sin bordes */}
<KPICard label="Sessions" value={9821} className="border-0 shadow-none" />
``` ```
## Notas
- El icono ahora se renderiza a la **izquierda** del label (antes estaba a la derecha).
- `unit` separa la unidad del valor con font menor para el efecto "124 k" del diseño.
- `delta.label` y `delta.suffix` permiten texto descriptivo: "Increased by ▲ +15% vs yesterday".
- `chart` es un slot genérico — pasar un `<Sparkline variant="bar" colors={[...]} />` para mini barras multicolor.
- `action` es un slot top-right para menú contextual.
- Usa `cn()` para merge de clases. `className="border-0 shadow-none"` para dashboards dark.
+35 -13
View File
@@ -6,25 +6,35 @@ type KPICardSize = 'sm' | 'default' | 'lg'
interface Delta { interface Delta {
value: number value: number
isPositive: boolean isPositive: boolean
/** Descriptive label before value, e.g. "Increased by" */
label?: string
/** Suffix after value, e.g. "vs yesterday" */
suffix?: string
} }
interface KPICardProps extends React.HTMLAttributes<HTMLDivElement> { interface KPICardProps extends React.HTMLAttributes<HTMLDivElement> {
label: string label: string
value: string | number value: string | number
/** Unit displayed next to value in smaller font, e.g. "k", "ms", "%" */
unit?: string
delta?: Delta delta?: Delta
icon?: React.ReactNode icon?: React.ReactNode
/** Action slot rendered top-right, e.g. a menu button */
action?: React.ReactNode
/** Inline chart slot rendered to the right of the value */
chart?: React.ReactNode
subtitle?: string subtitle?: string
size?: KPICardSize size?: KPICardSize
} }
const sizeStyles: Record<KPICardSize, { value: string; label: string }> = { const sizeStyles: Record<KPICardSize, { value: string; unit: string; label: string }> = {
sm: { value: 'text-2xl font-bold', label: 'text-xs' }, sm: { value: 'text-2xl font-bold', unit: 'text-base font-medium', label: 'text-xs' },
default: { value: 'text-3xl font-bold', label: 'text-sm' }, default: { value: 'text-3xl font-bold', unit: 'text-lg font-medium', label: 'text-sm' },
lg: { value: 'text-4xl font-bold', label: 'text-base' }, lg: { value: 'text-4xl font-bold', unit: 'text-xl font-medium', label: 'text-base' },
} }
const KPICard = React.forwardRef<HTMLDivElement, KPICardProps>( const KPICard = React.forwardRef<HTMLDivElement, KPICardProps>(
({ label, value, delta, icon, subtitle, size = 'default', className, ...props }, ref) => { ({ label, value, unit, delta, icon, action, chart, subtitle, size = 'default', className, ...props }, ref) => {
const styles = sizeStyles[size] const styles = sizeStyles[size]
const deltaColor = delta const deltaColor = delta
? delta.value === 0 ? 'text-muted-foreground' ? delta.value === 0 ? 'text-muted-foreground'
@@ -35,21 +45,32 @@ const KPICard = React.forwardRef<HTMLDivElement, KPICardProps>(
return ( return (
<div ref={ref} className={cn('rounded-lg border bg-card p-4 text-card-foreground shadow-sm', className)} {...props}> <div ref={ref} className={cn('rounded-lg border bg-card p-4 text-card-foreground shadow-sm', className)} {...props}>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="space-y-1"> <div className="flex items-center gap-2">
<p className={cn('text-muted-foreground', styles.label)}>{label}</p> {icon && <div className="text-muted-foreground">{icon}</div>}
{subtitle && <p className="text-xs text-muted-foreground/80">{subtitle}</p>} <div className="space-y-1">
<p className={cn('text-muted-foreground', styles.label)}>{label}</p>
{subtitle && <p className="text-xs text-muted-foreground/80">{subtitle}</p>}
</div>
</div> </div>
{icon && <div className="text-muted-foreground">{icon}</div>} {action && <div className="text-muted-foreground">{action}</div>}
</div> </div>
<div className="mt-3 flex items-end justify-between gap-4"> <div className="mt-3 flex items-end justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<p className={cn('tracking-tight', styles.value)}>{value}</p> <div className="flex items-baseline gap-1">
<span className={cn('tracking-tight', styles.value)}>{value}</span>
{unit && <span className={cn('text-muted-foreground', styles.unit)}>{unit}</span>}
</div>
{delta && ( {delta && (
<div className={cn('flex items-center gap-1 text-sm font-medium', deltaColor)}> <div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>{delta.value > 0 ? '+' : ''}{delta.value}%</span> {delta.label && <span>{delta.label}</span>}
<span className={cn('font-medium', deltaColor)}>
{delta.isPositive ? '▲' : '▼'} {delta.value > 0 ? '+' : ''}{delta.value}{delta.label ? '' : '%'}
</span>
{delta.suffix && <span>{delta.suffix}</span>}
</div> </div>
)} )}
</div> </div>
{chart && <div className="flex-shrink-0">{chart}</div>}
</div> </div>
</div> </div>
) )
@@ -57,4 +78,5 @@ const KPICard = React.forwardRef<HTMLDivElement, KPICardProps>(
) )
KPICard.displayName = 'KPICard' KPICard.displayName = 'KPICard'
export { KPICard, type KPICardProps, type Delta, type KPICardSize } export { KPICard }
export type { KPICardProps, Delta, KPICardSize }
+4
View File
@@ -31,6 +31,10 @@ props:
type: "string" type: "string"
required: false required: false
description: "Color del gráfico" description: "Color del gráfico"
- name: colors
type: "string[]"
required: false
description: "Colores por barra para variant 'bar'. Cicla si es más corto que data."
- name: width - name: width
type: "number" type: "number"
required: false required: false
+5 -2
View File
@@ -7,6 +7,8 @@ interface SparklineProps extends React.SVGAttributes<SVGSVGElement> {
data: number[] data: number[]
variant?: SparklineVariant variant?: SparklineVariant
color?: string color?: string
/** Per-bar colors for 'bar' variant. Cycles if shorter than data. */
colors?: string[]
width?: number width?: number
height?: number height?: number
strokeWidth?: number strokeWidth?: number
@@ -30,7 +32,7 @@ function getPath(data: number[], width: number, height: number, padding: number
} }
const Sparkline = React.forwardRef<SVGSVGElement, SparklineProps>( const Sparkline = React.forwardRef<SVGSVGElement, SparklineProps>(
({ data, variant = 'line', color = 'currentColor', width = 80, height = 24, strokeWidth = 1.5, showLastPoint = true, className, ...props }, ref) => { ({ data, variant = 'line', color = 'currentColor', colors, width = 80, height = 24, strokeWidth = 1.5, showLastPoint = true, className, ...props }, ref) => {
if (data.length === 0) return <svg ref={ref} width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={cn('text-primary', className)} {...props} /> if (data.length === 0) return <svg ref={ref} width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={cn('text-primary', className)} {...props} />
if (variant === 'bar') { if (variant === 'bar') {
@@ -46,7 +48,8 @@ const Sparkline = React.forwardRef<SVGSVGElement, SparklineProps>(
const bh = ((value - min) / range) * eh const bh = ((value - min) / range) * eh
const x = p + index * ((width - p * 2) / data.length) + 0.5 const x = p + index * ((width - p * 2) / data.length) + 0.5
const y = p + eh - bh const y = p + eh - bh
return <rect key={index} x={x} y={y} width={Math.max(bw, 1)} height={Math.max(bh, 1)} fill={color} opacity={0.8} /> const barColor = colors ? colors[index % colors.length] : color
return <rect key={index} x={x} y={y} width={Math.max(bw, 1)} height={Math.max(bh, 1)} fill={barColor} rx={1} opacity={0.85} />
})} })}
</svg> </svg>
) )