initial: mirror of @fn_library from fn_registry

75 components + DESIGN_SYSTEM.md + sync script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Egutierrez
2026-04-21 19:06:49 +02:00
commit 5a824c2eee
157 changed files with 11539 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.DS_Store
*.log
.env
.env.*
!.env.example
+457
View File
@@ -0,0 +1,457 @@
# @fn_library — Design System
> **Fuente de verdad para Claude Design y para cualquier agente que genere UI en este repo.**
> Si generas código frontend, obedece este documento. Sin excepciones.
---
## 1. Qué es esto
`@fn_library` es el design system del registry. Son 75 wrappers React sobre **Mantine v9** que viven en `frontend/functions/ui/`. Toda app del registry (`apps/*/frontend/`) los consume vía el alias Vite `@fn_library` — barrel único en `frontend/functions/ui/index.ts`.
**Regla suprema:** en las apps NUNCA se importa Mantine directamente, NUNCA se mezclan otras librerías de UI, NUNCA se escribe CSS manual. Todo pasa por `@fn_library`.
---
## 2. Stack
| Capa | Librería | Notas |
|------|----------|-------|
| Framework | React 19 | con TypeScript 6 |
| UI base | `@mantine/core` v9 | todos los wrappers la usan |
| Charts | `@mantine/charts` v9 | line, bar, area, pie — Recharts por debajo |
| Fechas | `@mantine/dates` v9 + `dayjs` | DatePickerInput |
| Forms | `@mantine/form` v9 | validación, no es obligatorio |
| Dropzone | `@mantine/dropzone` v9 | drag & drop de ficheros |
| Hooks | `@mantine/hooks` v9 | disclosure, useForm, etc. |
| Notifs | `@mantine/notifications` v9 | incluidas en FnMantineProvider |
| Iconos | `@tabler/icons-react` v3 | el set nativo de Mantine |
| Fuente | `@fontsource-variable/geist` | cargada en el provider |
| Grafos | `sigma` + `graphology` | solo para `GraphContainer` |
| Build | Vite 8 + pnpm | no webpack, no npm, no yarn |
### Deny-list (NO usar nunca)
-`tailwindcss`, `postcss-preset-tailwind`
-`class-variance-authority` (CVA), `clsx`, `cn`
-`shadcn/ui`, `@radix-ui/*` directos, `@headlessui/*`
-`lucide-react`, `heroicons`, `feather`, `phosphor-icons`
-`chakra-ui`, `mui`, `antd`
-`framer-motion` (Mantine trae transiciones suficientes)
-`styled-components`, `emotion`
- ❌ CSS modules, CSS manual (`.css` inline), `style={{...}}` para maquetar
Si una idea solo encaja con algo de la deny-list → pregunta antes de escribir código.
---
## 3. Import rules
### 3.1 Un solo barrel
```ts
// ✅ Correcto — siempre desde el barrel
import { Button, Card, CardHeader, CardTitle, KPICard, LineChart, DataTable } from '@fn_library'
// ❌ Prohibido — subpath
import { Button } from '@fn_library/button'
// ❌ Prohibido — Mantine directo en apps
import { Button } from '@mantine/core'
```
### 3.2 Provider raíz obligatorio
Toda app monta su árbol dentro de `FnMantineProvider`. No `MantineProvider` directo.
```tsx
import { FnMantineProvider } from '@fn_library'
import { createTheme } from '@mantine/core'
const theme = createTheme({
primaryColor: 'indigo',
fontFamily: 'Geist Variable, sans-serif',
})
export function App() {
return (
<FnMantineProvider theme={theme} defaultColorScheme="dark">
<MyPage />
</FnMantineProvider>
)
}
```
`FnMantineProvider` ya incluye:
- `<Notifications position="top-right" />`
- Los CSS de `@mantine/core`, `@mantine/charts`, `@mantine/notifications`
- `defaultColorScheme="dark"` por defecto
### 3.3 Iconos
```tsx
import { IconPlus, IconTrash, IconRefresh, IconSearch } from '@tabler/icons-react'
<Button leftSection={<IconPlus size={16} />}>Añadir</Button>
```
### 3.4 Layout: solo componentes Mantine de layout
Los componentes de layout (`AppShell`, `Group`, `Stack`, `Grid`, `SimpleGrid`, `Flex`, `Container`, `Box`, `Paper`) **sí** se importan directo de `@mantine/core`, porque `@fn_library` no los wrappea — son parte fija del sistema.
```tsx
import { Stack, Group, SimpleGrid, Paper, Box } from '@mantine/core'
```
El único layout wrappeado es `FnAppShell` (en `@fn_library`) — úsalo para el shell principal de la app.
---
## 4. Reglas duras (obedecer siempre)
1. **Props Mantine, no clases.** Tamaños, márgenes, padding, gap, radio, color: `size`, `p`, `m`, `mt`, `mb`, `gap`, `radius`, `shadow`, `c`, `fw`, `fz`, `lh`. Sin `className="..."` con clases propias.
2. **Color scheme dark** es el default del registro. Los componentes están pensados para oscuro; si quieres claro, pásalo explícito.
3. **No hardcodees colores**. Usa los tokens Mantine (`--mantine-color-*`) o los helpers del registry (`get_series_color_ts_core`, `chart_colors_ts_core`).
4. **Formulario**: envuelve cada campo con `<FormField label="..." error="..." helperText="...">`. Gestiona ARIA, label, error.
5. **Tablas**: usa `DataTable`. Auto-detecta columnas del primer row. No escribas `<table>` a mano.
6. **KPIs**: usa `KPICard` (no Paper manual). Acepta `label`, `value`, `unit`, `delta`, `icon`, `chart`, `action`, `subtitle`.
7. **Charts**: siempre con `@fn_library` charts; nunca Recharts directo. Envuelve en `ChartContainer` si necesitas header/footer del propio chart.
8. **Loading**: `Skeleton` + variantes (`SkeletonText`, `SkeletonCard`, `SkeletonTable`, `SkeletonAvatar`, `SkeletonButton`) o `FnLoadingOverlay` para secciones completas.
9. **Vacío**: `EmptyState` con icono Tabler, título, descripción, acción opcional.
10. **Errores de página entera**: `ErrorPage`. 404/500/403 o código custom.
11. **Modal**: `Dialog`. Drawer lateral: `Sheet`. Popover flotante: `Popover`. Menús: `DropdownMenu`.
12. **Notificaciones**: desde `@mantine/notifications` `notifications.show({...})` — el provider ya está montado. O usa el `Toast` del registry si necesitas la API `useToast`.
---
## 5. Catálogo completo (75 componentes)
> Todos exportados desde `@fn_library` (barrel). Cada uno detalla variantes y nota clave.
### 5.1 Primitives
| Componente | Variantes | Notas |
|------------|-----------|-------|
| `Button` | `default \| outline \| secondary \| ghost \| destructive \| link` — sizes `default \| xs \| sm \| lg \| icon \| icon-xs \| icon-sm \| icon-lg` | Wrapper Mantine Button. Soporta `leftSection`/`rightSection` con icono. |
| `FnActionIcon` | `filled \| light \| outline \| transparent \| default \| subtle` | Botón cuadrado para icono único. Loading + tooltip integrados. |
| `Badge` | 10 semánticas (`default, secondary, destructive, outline, ghost, link, success, warning, error, info`) | 2 tamaños. |
| `Chip` | `filled \| outline \| light` | Seleccionable. `ChipGroup` para selección simple/múltiple. |
| `Indicator` (FnIndicator) | — | Badge posicionado sobre un hijo. |
| `Label` | — | Etiqueta de formulario accesible. |
| `Skeleton` | `base \| text \| card \| avatar \| button \| table` | Exporta también `SkeletonText`, `SkeletonCard`, `SkeletonTable`, `SkeletonAvatar`, `SkeletonButton`. |
| `Sparkline` | `line \| area \| bar` | Mini chart SVG puro — para tablas y KPIs. |
| `ProgressBar` | `primary \| success \| warning \| destructive` | Con buffer, indeterminado y display de valor. |
| `FnRingProgress` | — | Anillo de progreso con secciones. |
| `Tooltip` | `default` | Delay configurable. |
| `Avatar` | `xs \| sm \| md \| lg \| xl` | Fallback a iniciales. |
### 5.2 Inputs
| Componente | Notas |
|------------|-------|
| `Input` | `TextInput` con `InputGroup` e `InputIcon`. |
| `Textarea` | autosize. |
| `PasswordInput` | Toggle visibilidad + strength meter opcional. |
| `NumberInput` (FnNumberInput) | min/max, step, prefix, suffix. |
| `Select` | Declarativo `data={[...]}`. Búsqueda, grupos. |
| `SimpleSelect` | Acepta array plano o agrupado. |
| `MultiSelect` | Pills + límite. |
| `Autocomplete` | Admite valores libres (a diferencia de Select). |
| `TagsInput` | Tags libres. |
| `DatePickerInput` | Fecha simple, múltiple o rango. |
| `FileInput` | Ficheros único/múltiple, tipos MIME. |
| `Dropzone` | Drag & drop de archivos. Estados idle/accept/reject. |
| `Checkbox` | Con indeterminate. |
| `RadioGroup` | Opciones exclusivas. |
| `SwitchToggle` | Label a izquierda o derecha. |
| `FnSegmentedControl` | Selección única entre opciones. |
| `Slider` / `RangeSlider` | Marcas, labels. |
| `Rating` | Fracciones, símbolos custom. |
| `ColorInput` | Picker + swatches + alpha. |
| `PinInput` | Código OTP con autocompletado. |
| `SearchBar` | Input con debounce, icono y clear. |
| `Command` / `CommandSearch` | Combobox cmdk-style con búsqueda, grupos, shortcuts. |
| `FormField` | **Wrapper obligatorio** para cada campo de form. |
### 5.3 Data display
| Componente | Notas |
|------------|-------|
| `DataTable` | Sticky header, overflow scroll, heatmap, formato condicional (number/datetime/currency), auto-columns. Variantes: `default \| heatmap`. |
| `KPICard` | Size `sm \| default \| lg`. Acepta icon, chart inline, action. |
| `Card` + slots | `CardHeader`, `CardTitle`, `CardDescription`, `CardAction`, `CardContent`, `CardFooter`. Variantes `default \| borderless \| ghost`, sizes `default \| sm`. |
| `FnTimeline` | Items con iconos y colores. |
| `EmptyState` | Icono + título + descripción + acción. |
| `Pagination` | Autocontenido. |
### 5.4 Charts
| Componente | Variantes | Notas |
|------------|-----------|-------|
| `LineChart` | 5 curvas (`linear, monotone, step, stepBefore, stepAfter`) | Multi-series, reference lines. |
| `BarChart` | `vertical \| horizontal` | Multi-series. |
| `AreaChart` | `default \| stacked` | Gradientes auto. |
| `PieChart` | `pie \| donut` | Colores auto. |
| `Sparkline` | `line \| area \| bar` | Mini chart inline sin Recharts. |
| `ChartContainer` | `default` | Wrapper Paper + helpers (`getSeriesColor`, `Series`). |
| `GraphContainer` | — | sigma.js + graphology + ForceAtlas2. |
### 5.5 Overlays y feedback
| Componente | Notas |
|------------|-------|
| `Dialog` | Modal con slots (`DialogHeader`, `DialogTitle`, `DialogDescription`, `DialogContent`, `DialogFooter`, `DialogClose`). |
| `Sheet` | Drawer `top \| bottom \| left \| right`. |
| `Popover` | Contenido flotante. |
| `DropdownMenu` | Items, checkboxes, radios, separadores, submenús. |
| `Alert` | `default \| destructive`. Slots `AlertTitle`, `AlertDescription`. |
| `Toast` | `default \| success \| error \| warning \| info`. API `useToast`. |
| `FnLoadingOverlay` | Blur + opacidad. Para secciones o página entera. |
### 5.6 Navegación y shell
| Componente | Notas |
|------------|-------|
| `FnAppShell` | Shell con header + navbar colapsable + main. |
| `PageHeader` | `full \| simple`. Título, subtítulo, acciones, back, tabs integrados, badge, sticky. |
| `Breadcrumb` | Con elipsis y router links vía `asChild`. |
| `FnNavLink` | Link con icono, descripción, anidamiento. |
| `Tabs` | `default \| line`. Horizontal/vertical. |
| `Accordion` | `AccordionItem` + `AccordionTrigger` + `AccordionContent`. |
| `FnStepper` | Horizontal/vertical. |
### 5.7 Hooks y providers (TS)
| Hook / Provider | Fuente | Notas |
|---|---|---|
| `FnMantineProvider` | `@fn_library` | Provider raíz. |
| `useToast` | `@fn_library` (toast) | API imperativa para notificaciones. |
| `useAnimatedCanvas` | `@fn_library` | Canvas con requestAnimationFrame, DPR, FPS real. |
| `WailsProvider` + `useWailsContext` + `useWailsCache` | `@fn_library` | Solo si la app es Wails. |
| `useWailsQuery` / `useWailsMutation` / `useWailsStream` / `useWailsEvent` | `@fn_library` | IPC Wails. |
### 5.8 Hooks TS core (registry TS core, no UI)
No están en `@fn_library` pero son parte del stack. Se importan desde `frontend/functions/core/` o se copian al proyecto según se necesite:
- `use_fetch`, `use_mutation`, `use_sse`, `use_websocket`, `use_infinite_scroll`, `use_debounced_search`, `use_form`
- `api_client`, `http_cache`, `format_compact` (K/M/B, bytes, duración, Hz), `chart_colors`, `get_series_color`, `get_computed_color`
---
## 6. Page generators (preferir antes que montar a mano)
Cuando generes una pantalla, intenta **primero** con un page generator. Solo si no encaja, compone con Card/Paper/SimpleGrid.
| Generator | Cuándo usarlo | Props clave |
|-----------|---------------|-------------|
| `analyticsPage` | Dashboard con KPIs + charts en grid | `title, subtitle, metrics[], charts[], dateRange?, actions?` |
| `crudPage<T>` | Listado + tabla + modal create/edit/delete | `title, columns, rows, schema, onAdd, onEdit, onDelete` |
| `dashboardLayout` | Grid responsive 1-4 cols de widgets con span | `widgets[{content, span}]` |
| `detailPage` | Ficha de entidad con header, campos, tabs, timeline | `entity, fields[], tabs[], timeline[]` |
| `settingsPage` | Config con navegación lateral y secciones | `sections[{nav, fields[]}]` |
| `AuthForm` | Login / register con social buttons | `mode, onSubmit, extraFields?, socialProviders?` |
| `ErrorPage` | 404 / 500 / 403 / custom | `code, title, description, actions` |
Estas funciones viven en `frontend/functions/ui/` igual que los componentes, se importan desde `@fn_library` y retornan un `ReactElement`.
---
## 7. Ejemplos canónicos
### 7.1 Dashboard de analytics
```tsx
import { analyticsPage, LineChart, BarChart, AreaChart, PieChart, Button } from '@fn_library'
import { IconRefresh } from '@tabler/icons-react'
const runsData = [
{ hour: '00', success: 120, failure: 3 },
{ hour: '01', success: 115, failure: 1 },
// ...
]
export const PipelineMonitoring = () => analyticsPage({
title: 'Pipeline Monitoring',
subtitle: 'últimas 24h',
actions: <Button variant="outline" leftSection={<IconRefresh size={14} />}>Refresh</Button>,
metrics: [
{ label: 'Runs totales', value: '1,284', delta: { value: 12, isPositive: true } },
{ label: 'Success rate', value: '97.3%', delta: { value: 0.4, isPositive: true } },
{ label: 'P95 latency', value: '842ms', delta: { value: -8, isPositive: true } },
{ label: 'Errores', value: 34, delta: { value: 5, isPositive: false } },
],
charts: [
{ id: 'runs', title: 'Runs por hora', type: 'line',
content: <LineChart data={runsData} xKey="hour"
series={[{ key: 'success', name: 'Success' }, { key: 'failure', name: 'Failure' }]} /> },
{ id: 'top', title: 'Top pipelines', type: 'bar',
content: <BarChart data={[]} xKey="name" yKey="count" /> },
{ id: 'latency', title: 'Latencia por etapa', type: 'area', span: 2,
content: <AreaChart data={[]} xKey="t" series={[{ key: 'p50' }, { key: 'p95' }]} variant="stacked" /> },
],
})
```
### 7.2 CRUD
```tsx
import { crudPage, Badge } from '@fn_library'
import type { ColumnDef } from '@fn_library'
interface Target { app: string; host: string; port: number; status: 'ok' | 'down' }
const columns: ColumnDef<Target>[] = [
{ key: 'app', label: 'App' },
{ key: 'host', label: 'Host', render: (v) => <Badge variant="outline">{v as string}</Badge> },
{ key: 'port', label: 'Port', format: 'number' },
{ key: 'status', label: 'Status',
render: (v) => <Badge variant={v === 'ok' ? 'success' : 'destructive'}>{v as string}</Badge> },
]
export const DeployTargets = () => crudPage<Target>({
title: 'Deploy Targets',
columns,
rows: [],
schema: {
app: { type: 'text', label: 'App', required: true },
host: { type: 'select', label: 'Host', options: ['vps-1','vps-2'] },
port: { type: 'number', label: 'Port', min: 1, max: 65535 },
build: { type: 'textarea', label: 'Build cmd' },
},
onAdd: async (row) => { /* ... */ },
onEdit: async (row) => { /* ... */ },
onDelete: async (row) => { /* ... */ },
})
```
### 7.3 Página de detalle
```tsx
import { detailPage, Badge } from '@fn_library'
export const FunctionDetail = (fn) => detailPage({
entity: { title: fn.id, subtitle: fn.description, avatar: fn.name.slice(0,2).toUpperCase(),
badge: <Badge variant="secondary">{fn.domain}</Badge>, backHref: '/functions' },
fields: [
{ label: 'Language', value: fn.lang },
{ label: 'Purity', value: fn.purity },
{ label: 'Kind', value: fn.kind },
{ label: 'Version', value: fn.version },
{ label: 'Tested', value: fn.tested ? 'yes' : 'no' },
{ label: 'Source', value: fn.source_repo || '—' },
],
tabs: [
{ key: 'code', label: 'Code', count: 1, content: <pre>{fn.code}</pre> },
{ key: 'tests', label: 'Tests', count: fn.tests_count },
{ key: 'deps', label: 'Deps', count: fn.deps_count },
],
timeline: fn.activity,
})
```
---
## 8. Theming
Cada app define su `theme`:
```tsx
import { createTheme, type MantineThemeOverride } from '@mantine/core'
export const theme: MantineThemeOverride = createTheme({
primaryColor: 'indigo',
primaryShade: { light: 6, dark: 4 },
fontFamily: 'Geist Variable, sans-serif',
headings: { fontFamily: 'Geist Variable, sans-serif', fontWeight: '600' },
defaultRadius: 'md',
// colores custom solo si son parte del brand
colors: {
brand: ['#f0f4ff','#d9e2ff','#b3c7ff','#8daaff','#668dff','#3f70ff','#3059d9','#2242b3','#13298d','#041066'],
},
})
```
Y lo pasa al provider:
```tsx
<FnMantineProvider theme={theme} defaultColorScheme="dark">
<App />
</FnMantineProvider>
```
**No** definas variables CSS custom, **no** toques `:root`, **no** cargues hojas de estilo adicionales. Mantine genera todas las variables (`--mantine-color-*`, `--mantine-spacing-*`, `--mantine-radius-*`) automáticamente del `theme`.
---
## 9. Anti-patrones (rechazar en code review)
```tsx
// ❌ className con clases custom
<Button className="bg-blue-500 rounded-lg px-4 py-2">Click</Button>
// ❌ CSS modules, Tailwind, style inline
<div className={styles.card} />
<div className="flex flex-col gap-4" />
<div style={{ display: 'flex', gap: 16 }} />
// ✅ en su lugar
<Stack gap="md" />
// ❌ Mantine directo en apps
import { Button } from '@mantine/core'
// ✅
import { Button } from '@fn_library'
// ❌ Iconos ajenos
import { Plus } from 'lucide-react'
// ✅
import { IconPlus } from '@tabler/icons-react'
// ❌ HTML nativo para formulario
<label>Email<input type="email" /></label>
// ✅
<FormField label="Email"><Input type="email" /></FormField>
// ❌ <table> a mano
<table><thead>...</thead><tbody>...</tbody></table>
// ✅
<DataTable data={rows} columns={cols} />
// ❌ Recharts directo
import { LineChart } from 'recharts'
// ✅
import { LineChart } from '@fn_library'
```
---
## 10. Checklist para handoffs de Claude Design
Antes de integrar un export al repo, verificar:
- [ ] Todos los imports UI vienen de `@fn_library` (excepto layout Mantine: `Stack`, `Group`, `SimpleGrid`, `Grid`, `Paper`, `Box`, `Container`, `Flex`, `Title`, `Text`, `AppShell`).
- [ ] Cero imports de `lucide-react`, `shadcn`, `@radix-ui/*`, `tailwindcss`, `clsx`, `cn`, `cva`, `framer-motion`, `chakra`, `mui`, `antd`.
- [ ] Cero `className` con clases CSS propias (solo se toleran las que el wrapper pasa al nodo raíz para forwardref).
- [ ] Iconos únicamente `@tabler/icons-react`.
- [ ] Charts únicamente `LineChart`/`BarChart`/`AreaChart`/`PieChart`/`Sparkline` del registry.
- [ ] Formulario con `FormField` wrapper para cada campo.
- [ ] Tablas con `DataTable`.
- [ ] KPIs con `KPICard`.
- [ ] Página entera envuelta en `FnMantineProvider` (solo el root).
- [ ] Si aparece un componente que no existe en `@fn_library`, decidir: (a) reescribirlo con wrappers existentes, o (b) extraerlo como función nueva al registry (`frontend/functions/ui/{name}.tsx` + `.md` + `fn index`).
---
## 11. Metadata para Claude Design
Cuando enlaces este repo en `claude.ai/design`, apunta al subdirectorio **`frontend/`** (no a la raíz del monorepo). Lo único que necesita leer para entender el sistema:
```
frontend/
DESIGN_SYSTEM.md ← este documento (regla suprema)
package.json ← dependencias runtime
functions/ui/ ← los 75 componentes con .tsx + .md
index.ts ← barrel @fn_library
```
Si aun así es demasiado, usa el **repo espejo** `fn-design-system` (generado desde este repo) que contiene exactamente esos archivos y nada más.
+54
View File
@@ -0,0 +1,54 @@
# fn-design-system
**Read-only mirror of `@fn_library`** — the React 19 + Mantine v9 design system that lives inside [fn_registry](../fn_registry).
This repo exists for one purpose: **give Claude Design (and other external tools) a clean, minimal view of the design system** without exposing the rest of the monorepo.
> ⚠️ Do not edit files in `components/` directly. They are overwritten on each sync from `fn_registry`.
## Structure
```
fn-design-system/
DESIGN_SYSTEM.md ← the contract (read this first)
components/ ← mirror of fn_registry/frontend/functions/ui/
index.ts ← @fn_library barrel
*.tsx + *.md ← 75 components, one pair each
package.json ← runtime deps (Mantine v9, Tabler, React 19)
tsconfig.json ← paths → @fn_library maps to ./components
sync_from_registry.sh ← re-syncs from fn_registry
```
## How to use
### For Claude Design
1. Link this repo to your Claude Design project (`claude.ai/design` → Settings → Connected repos).
2. In prompts, refer to components **by name from `@fn_library`** and obey `DESIGN_SYSTEM.md`.
3. Handoff to Claude Code → the generated code lands in your local `fn_registry` apps.
### For humans / agents
Read `DESIGN_SYSTEM.md`. It is the single source of truth.
## How to re-sync
When you add or modify components in `fn_registry/frontend/functions/ui/`:
```bash
./sync_from_registry.sh
git add -A
git commit -m "sync: <what changed>"
git push
```
The script requires `FN_REGISTRY_ROOT` to point at your local fn_registry clone (defaults to `~/fn_registry`).
## What is NOT in this repo
- Any application code (`apps/*/`)
- The registry itself (`registry.db`, `cmd/fn/`, Go code)
- Python / Bash functions
- Deploy config, operations DBs, secrets
Everything here is derivable from `fn_registry` — if this repo is lost, `./sync_from_registry.sh` rebuilds it.
+54
View File
@@ -0,0 +1,54 @@
---
name: accordion
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Accordion(props: AccordionProps): JSX.Element"
description: "Secciones colapsables con animaciones. Mantine Accordion. Composable: AccordionItem + AccordionTrigger + AccordionContent."
tags: [accordion, collapsible, component, ui, interactive, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
output: "Componente Accordion que renderiza secciones colapsables con soporte para múltiples items abiertos simultáneamente"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/accordion.tsx"
props:
- name: className
type: "string"
required: false
description: "Clases CSS adicionales para el contenedor"
emits: []
has_state: false
framework: react
variant: []
---
## Ejemplo
```tsx
<Accordion defaultValue="section-1">
<AccordionItem value="section-1">
<AccordionTrigger>Seccion 1</AccordionTrigger>
<AccordionContent>
Contenido de la primera seccion.
</AccordionContent>
</AccordionItem>
<AccordionItem value="section-2">
<AccordionTrigger>Seccion 2</AccordionTrigger>
<AccordionContent>
Contenido de la segunda seccion.
</AccordionContent>
</AccordionItem>
</Accordion>
```
## Notas
Usa Mantine Accordion nativo. Soporta type single (default) y multiple para multiples items abiertos. El chevron se maneja automaticamente por Mantine. AccordionItem requiere prop value unico. Exports: Accordion, AccordionItem, AccordionTrigger, AccordionContent.
+90
View File
@@ -0,0 +1,90 @@
import * as React from "react"
import { Accordion as MantineAccordion } from "@mantine/core"
interface AccordionItem {
value: string
trigger: React.ReactNode
content: React.ReactNode
disabled?: boolean
}
interface AccordionProps {
items?: AccordionItem[]
type?: "single" | "multiple"
defaultValue?: string | string[]
className?: string
itemClassName?: string
children?: React.ReactNode
}
function Accordion({ className, type, defaultValue, children }: AccordionProps) {
if (type === "multiple") {
return (
<MantineAccordion
multiple
data-slot="accordion"
className={className}
defaultValue={Array.isArray(defaultValue) ? defaultValue : undefined}
>
{children}
</MantineAccordion>
)
}
return (
<MantineAccordion
data-slot="accordion"
className={className}
defaultValue={typeof defaultValue === "string" ? defaultValue : undefined}
>
{children}
</MantineAccordion>
)
}
interface AccordionItemProps {
value: string
className?: string
children?: React.ReactNode
disabled?: boolean
}
function AccordionItem({ className, value, children, ...props }: AccordionItemProps) {
return (
<MantineAccordion.Item
data-slot="accordion-item"
value={value}
className={className}
{...props}
>
{children}
</MantineAccordion.Item>
)
}
function AccordionTrigger({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
return (
<MantineAccordion.Control
data-slot="accordion-trigger"
className={className}
{...props}
>
{children}
</MantineAccordion.Control>
)
}
function AccordionContent({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
return (
<MantineAccordion.Panel
data-slot="accordion-content"
className={className}
{...props}
>
{children}
</MantineAccordion.Panel>
)
}
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }
export type { AccordionItem as AccordionItemData, AccordionProps }
+77
View File
@@ -0,0 +1,77 @@
---
name: action_icon
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "FnActionIcon(props: FnActionIconProps): JSX.Element"
description: "Boton de icono con variantes, loading y tooltip opcional. Wrapper sobre Mantine ActionIcon."
tags: [mantine, button, icon, action, tooltip, component, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
framework: react
props:
- name: icon
type: "ReactNode"
required: true
description: "Icono a renderizar dentro del boton"
- name: variant
type: "'filled' | 'light' | 'outline' | 'transparent' | 'default' | 'subtle'"
required: false
description: "Variante visual del boton, default 'default'"
- name: size
type: "MantineSize | number"
required: false
description: "Tamano del boton"
- name: color
type: "MantineColor"
required: false
description: "Color del boton"
- name: onClick
type: "MouseEventHandler"
required: false
description: "Callback al hacer click"
- name: loading
type: "boolean"
required: false
description: "Muestra spinner de carga"
- name: disabled
type: "boolean"
required: false
description: "Deshabilita el boton"
- name: tooltip
type: "string"
required: false
description: "Si se provee, envuelve el boton en un Tooltip"
output: "Boton de icono con tooltip opcional, estados loading/disabled y multiples variantes"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/action_icon.tsx"
emits: []
has_state: false
variant: [filled, light, outline, transparent, default, subtle]
---
## Ejemplo
```tsx
import { FnActionIcon } from '@fn_library'
import { IconSettings } from '@tabler/icons-react'
<FnActionIcon
icon={<IconSettings size={18} />}
tooltip="Configuracion"
variant="light"
onClick={() => openSettings()}
/>
```
## Notas
Wrapper sobre Mantine `ActionIcon`. Si se provee `tooltip`, envuelve automaticamente en Mantine `Tooltip`. Compatible con iconos de `@tabler/icons-react`.
+47
View File
@@ -0,0 +1,47 @@
import * as React from 'react'
import { ActionIcon, Tooltip } from '@mantine/core'
import type { MantineSize, MantineColor } from '@mantine/core'
interface FnActionIconProps {
icon: React.ReactNode
variant?: 'filled' | 'light' | 'outline' | 'transparent' | 'default' | 'subtle'
size?: MantineSize | number
color?: MantineColor
onClick?: React.MouseEventHandler<HTMLButtonElement>
loading?: boolean
disabled?: boolean
tooltip?: string
}
function FnActionIcon({
icon,
variant = 'default',
size = 'md',
color,
onClick,
loading,
disabled,
tooltip,
}: FnActionIconProps) {
const button = (
<ActionIcon
variant={variant}
size={size}
color={color}
onClick={onClick}
loading={loading}
disabled={disabled}
>
{icon}
</ActionIcon>
)
if (tooltip) {
return <Tooltip label={tooltip}>{button}</Tooltip>
}
return button
}
export { FnActionIcon }
export type { FnActionIconProps }
+50
View File
@@ -0,0 +1,50 @@
---
name: alert
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Alert(props: { variant?: 'default' | 'destructive' }): JSX.Element"
description: "Alerta accesible con variantes default y destructive. Mantine Alert con slots para título, descripción y acción."
tags: [alert, feedback, component, ui, notification, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core", react]
output: "Componente Alert que renderiza una alerta accesible via Mantine Alert con slots para título, descripción y acción"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/alert.tsx"
props:
- name: variant
type: "'default' | 'destructive'"
required: false
description: "Variante visual"
emits: []
has_state: false
framework: react
variant: [default, destructive]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/alert.tsx"
---
## Ejemplo
```tsx
<Alert variant="destructive">
<AlertCircle />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Something went wrong.</AlertDescription>
</Alert>
```
## Notas
Exporta 4 subcomponentes composables via data-slot: Alert, AlertTitle, AlertDescription, AlertAction.
AlertAction se posiciona absolute top-right para acciones secundarias (ej: boton cerrar).
alertVariants se exporta como objeto vacio por compatibilidad (Mantine gestiona variantes via color prop).
+70
View File
@@ -0,0 +1,70 @@
import * as React from 'react'
import { Alert as MantineAlert, Box, Text } from '@mantine/core'
type AlertVariant = 'default' | 'destructive'
const variantColorMap: Record<AlertVariant, string | undefined> = {
default: undefined,
destructive: 'red',
}
function Alert({
className,
variant = 'default',
children,
...props
}: React.ComponentProps<'div'> & { variant?: AlertVariant }) {
return (
<MantineAlert
data-slot="alert"
color={variantColorMap[variant]}
radius="md"
variant="light"
className={className}
{...props}
>
{children}
</MantineAlert>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<Text
component="div"
data-slot="alert-title"
fw={500}
size="sm"
className={className}
{...props}
/>
)
}
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<Text
component="div"
data-slot="alert-description"
size="sm"
c="dimmed"
className={className}
{...props}
/>
)
}
function AlertAction({ className, style, ...props }: React.ComponentProps<'div'>) {
return (
<Box
data-slot="alert-action"
style={{ position: 'absolute', top: 'var(--mantine-spacing-xs)', right: 'var(--mantine-spacing-xs)', ...style }}
className={className}
{...props}
/>
)
}
const alertVariants = {} as const
export { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants }
+51
View File
@@ -0,0 +1,51 @@
---
name: analytics_page
kind: function
lang: ts
domain: ui
version: "1.0.0"
purity: pure
signature: "analyticsPage(props: AnalyticsPageProps): ReactElement"
description: "Genera un dashboard de analytics completo con header, fila de KPIs con deltas y grid de charts configurables."
tags: [analytics, dashboard, kpi, charts, factory, composition, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react, "@mantine/core"]
params:
- name: props
desc: "Configuración del dashboard: título, métricas con deltas, y lista de charts con span"
output: "Componente ReactElement que renderiza un dashboard de analytics completo con header, KPIs y grid de charts"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/analytics_page.tsx"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: ""
---
## Ejemplo
```tsx
analyticsPage({
title: 'Sales Analytics',
metrics: [
{ label: 'Revenue', value: '$124,500', delta: { value: 12.5, isPositive: true } },
{ label: 'Orders', value: '1,234', delta: { value: -3.2, isPositive: false } },
{ label: 'Avg Order', value: '$101', delta: { value: 0, isPositive: true } },
{ label: 'Customers', value: '892' },
],
charts: [
{ id: 'revenue', title: 'Revenue Over Time', type: 'area', span: 2, content: <AreaChart data={revenueData} xKey="month" yKey="revenue" /> },
{ id: 'orders', title: 'Orders by Category', type: 'bar', content: <BarChart data={orderData} xKey="category" yKey="count" /> },
{ id: 'trends', title: 'Customer Trends', type: 'line', content: <LineChart data={trendData} xKey="week" yKey="customers" /> },
],
})
```
## Notas
Layout inteligente: los KPIs se ajustan automáticamente a 2/3/4 columnas según cantidad. Los charts soportan span para ancho completo.
+97
View File
@@ -0,0 +1,97 @@
import * as React from 'react'
import { Stack, Group, Title, Text, Paper, SimpleGrid } from '@mantine/core'
interface MetricConfig {
label: string
value: string | number
delta?: { value: number; isPositive: boolean }
sparklineData?: number[]
}
interface ChartConfig {
id: string
title: string
type: 'line' | 'bar' | 'area'
span?: 1 | 2
height?: number
content: React.ReactNode
}
interface AnalyticsPageProps {
title: string
subtitle?: string
dateRange?: React.ReactNode
metrics: MetricConfig[]
charts: ChartConfig[]
actions?: React.ReactNode
className?: string
}
export function analyticsPage({
title,
subtitle,
dateRange,
metrics,
charts,
actions,
}: AnalyticsPageProps): React.ReactElement {
const metricCols = metrics.length <= 2 ? { base: 1, md: 2 } : metrics.length <= 3 ? { base: 1, md: 3 } : { base: 1, md: 2, lg: 4 }
return (
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
<Stack gap={4}>
<Title order={2}>{title}</Title>
{subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
</Stack>
<Group gap="xs">
{dateRange}
{actions}
</Group>
</Group>
{/* KPI Row */}
<SimpleGrid cols={metricCols} spacing="md">
{metrics.map((metric, i) => (
<Paper key={i} p="md" withBorder shadow="xs">
<Text size="sm" c="dimmed">{metric.label}</Text>
<Group mt="xs" justify="space-between" align="flex-end" gap="md">
<Stack gap={4}>
<Text fz={30} fw={700} lh={1}>{metric.value}</Text>
{metric.delta && (
<Text
size="sm"
fw={500}
c={metric.delta.value === 0 ? 'dimmed' : metric.delta.isPositive ? 'green' : 'red'}
>
{metric.delta.value > 0 ? '+' : ''}{metric.delta.value}%
</Text>
)}
</Stack>
</Group>
</Paper>
))}
</SimpleGrid>
{/* Charts Grid */}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
{charts.map((chart) => (
<Paper
key={chart.id}
p="md"
withBorder
shadow="xs"
radius="md"
style={chart.span === 2 ? { gridColumn: 'span 2' } : undefined}
>
<Text size="sm" fw={500} c="dimmed" mb="sm">{chart.title}</Text>
{chart.content}
</Paper>
))}
</SimpleGrid>
</Stack>
)
}
export type { AnalyticsPageProps, MetricConfig, ChartConfig }
+65
View File
@@ -0,0 +1,65 @@
---
name: app_shell
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "FnAppShell(props: FnAppShellProps): JSX.Element"
description: "Layout shell con header, navbar colapsable y area principal. Wrapper sobre Mantine AppShell."
tags: [mantine, layout, shell, navigation, component, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
framework: react
props:
- name: header
type: "ReactNode"
required: false
description: "Contenido del header superior"
- name: navbar
type: "ReactNode"
required: false
description: "Contenido del sidebar de navegacion"
- name: navbarWidth
type: "number"
required: false
description: "Ancho del navbar en px, default 250"
- name: navbarCollapsed
type: "boolean"
required: false
description: "Si el navbar esta colapsado"
- name: children
type: "ReactNode"
required: true
description: "Contenido principal del area main"
output: "Layout de aplicacion con header fijo, sidebar colapsable y area de contenido principal"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/app_shell.tsx"
emits: []
has_state: false
variant: []
---
## Ejemplo
```tsx
import { FnAppShell } from '@fn_library'
<FnAppShell
header={<Group px="md">Logo</Group>}
navbar={<NavLinks />}
navbarCollapsed={collapsed}
>
<MainContent />
</FnAppShell>
```
## Notas
Wrapper sobre Mantine `AppShell`. El header tiene altura fija de 60px. El navbar colapsa tanto en mobile como en desktop cuando `navbarCollapsed` es true. El breakpoint de responsive es `sm`.
+33
View File
@@ -0,0 +1,33 @@
import * as React from 'react'
import { AppShell } from '@mantine/core'
interface FnAppShellProps {
header?: React.ReactNode
navbar?: React.ReactNode
navbarWidth?: number
navbarCollapsed?: boolean
children: React.ReactNode
}
function FnAppShell({
header,
navbar,
navbarWidth = 250,
navbarCollapsed = false,
children,
}: FnAppShellProps) {
return (
<AppShell
header={header ? { height: 60 } : undefined}
navbar={navbar ? { width: navbarWidth, breakpoint: 'sm', collapsed: { mobile: navbarCollapsed, desktop: navbarCollapsed } } : undefined}
padding="md"
>
{header && <AppShell.Header>{header}</AppShell.Header>}
{navbar && <AppShell.Navbar p="md">{navbar}</AppShell.Navbar>}
<AppShell.Main>{children}</AppShell.Main>
</AppShell>
)
}
export { FnAppShell }
export type { FnAppShellProps }
+57
View File
@@ -0,0 +1,57 @@
---
name: area_chart
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "AreaChart(props: AreaChartProps): JSX.Element"
description: "Gráfico de área @mantine/charts con gradientes automáticos, multi-series, stacking y tooltips."
tags: [chart, area, visualization, mantine, gradient, component, ui]
uses_functions: [chart_container_ts_ui]
uses_types: [ChartSeries_ts_ui]
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/charts", "@mantine/core"]
output: "Componente JSX que renderiza un gráfico de área con gradientes, multi-series y tooltips"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/area_chart.tsx"
props:
- name: data
type: "Record<string, unknown>[]"
required: true
description: "Array de datos"
- name: xKey
type: "string"
required: true
description: "Key del eje X"
- name: stacked
type: "boolean"
required: false
description: "Apilar áreas"
- name: gradient
type: "GradientConfig | boolean"
required: false
description: "Gradiente (true por defecto)"
- name: series
type: "Series[]"
required: false
description: "Series de datos para multi-series"
emits: []
has_state: false
framework: react
variant: [default, stacked]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/charts/area-chart.tsx"
---
## Ejemplo
```tsx
<AreaChart data={data} xKey="date" yKey="revenue" gradient />
<AreaChart data={data} xKey="date" series={series} stacked showLegend />
```
+54
View File
@@ -0,0 +1,54 @@
import { AreaChart as MantineAreaChart } from '@mantine/charts'
import { Paper } from '@mantine/core'
import { type Series, getSeriesColor } from './chart_container'
interface AreaChartProps {
data: Record<string, unknown>[]
xKey: string
yKey?: string
series?: Series[]
stacked?: boolean
gradient?: boolean
showGrid?: boolean
showLegend?: boolean
height?: number
xAxisFormatter?: (value: unknown) => string
yAxisFormatter?: (value: unknown) => string
valueFormatter?: (value: number) => string
}
function AreaChartComponent({
data, xKey, yKey, series, stacked = false, gradient = true, showGrid = true,
showLegend = false, height = 300, xAxisFormatter, yAxisFormatter,
valueFormatter = (v) => v.toLocaleString(),
}: AreaChartProps) {
const chartSeries = series
? series.map((s, i) => ({ name: s.key, label: s.name, color: getSeriesColor(i, s.color) }))
: yKey ? [{ name: yKey, label: yKey, color: getSeriesColor(0) }] : []
return (
<Paper p="md">
<MantineAreaChart
h={height}
data={data}
dataKey={xKey}
series={chartSeries}
type={stacked ? 'stacked' : 'default'}
curveType="monotone"
withGradient={gradient}
gridAxis={showGrid ? 'xy' : 'none'}
withLegend={showLegend}
withTooltip
valueFormatter={valueFormatter}
xAxisProps={xAxisFormatter ? { tickFormatter: xAxisFormatter } : undefined}
yAxisProps={yAxisFormatter ? { tickFormatter: yAxisFormatter } : undefined}
/>
</Paper>
)
}
/** @deprecated Gradient is handled by Mantine's withGradient prop */
type GradientConfig = { from: string; to: string }
export const AreaChart = AreaChartComponent
export type { AreaChartProps, GradientConfig }
+101
View File
@@ -0,0 +1,101 @@
---
name: auth_form
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "AuthForm(config: AuthFormConfig): ReactElement"
description: "Genera página de autenticación con toggle login/register, social buttons opcionales, campos extra en registro y validación. Basado en Mantine AuthenticationForm."
tags: [auth, login, register, form, page, ui, mantine, toggle]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core", "@mantine/hooks"]
has_state: true
framework: react
emits: [onSubmit]
params:
- name: title
desc: "Título principal que aparece en la cabecera del formulario (default: 'Welcome')"
- name: socialButtons
desc: "Lista de botones de login social, cada uno con label, icono opcional y callback onClick"
- name: extraFields
desc: "Campos de texto adicionales que se muestran únicamente en el modo registro (ej: nombre, empresa)"
- name: onSubmit
desc: "Callback invocado al enviar el formulario con type ('login'|'register'), email, password y valores de extraFields"
- name: defaultType
desc: "Modo inicial del formulario: 'login' (default) o 'register'"
- name: paperProps
desc: "Props de Mantine Paper para personalizar el contenedor (shadow, radius, p, etc.)"
output: "Página de autenticación completa con toggle login/register, campos email/password, botones sociales opcionales y campo de términos en registro"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/auth_form.tsx"
source_repo: ""
source_license: ""
source_file: ""
---
## Ejemplo
### Config mínima (solo login)
```tsx
import { AuthForm } from '@fn_library/auth_form'
function LoginPage() {
return (
<AuthForm
title="Acceder"
onSubmit={({ type, email, password }) => {
console.log(type, email, password)
}}
/>
)
}
```
### Con social buttons y campos extra en registro
```tsx
import { AuthForm } from '@fn_library/auth_form'
import { IconBrandGoogle, IconBrandGithub } from '@tabler/icons-react'
function AuthPage() {
return (
<AuthForm
title="fn_registry"
defaultType="register"
socialButtons={[
{ label: 'Google', icon: <IconBrandGoogle size={16} />, onClick: () => signInWithGoogle() },
{ label: 'GitHub', icon: <IconBrandGithub size={16} />, onClick: () => signInWithGitHub() },
]}
extraFields={[
{ name: 'name', label: 'Nombre completo', placeholder: 'Lucas García', required: true },
{ name: 'company', label: 'Empresa', placeholder: 'Acme Corp' },
]}
onSubmit={({ type, email, password, name, company }) => {
if (type === 'register') {
registerUser({ email, password, name, company })
} else {
loginUser({ email, password })
}
}}
/>
)
}
```
## Notas
Función con estado interno (useToggle, useState de @mantine/hooks). Gestiona el toggle entre login y register sin prop externa — el padre solo recibe el valor final via onSubmit.type.
Los `extraFields` solo se renderizan en modo register. El campo de términos y condiciones (Checkbox) también es exclusivo del registro.
Los `socialButtons` se renderizan con un Divider "O continúa con email" cuando están presentes. Sin socialButtons el Divider no aparece.
El campo `password` usa PasswordInput de Mantine, que incluye el toggle de visibilidad integrado.
+181
View File
@@ -0,0 +1,181 @@
import * as React from 'react'
import {
Anchor,
Button,
Checkbox,
Divider,
Group,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
Title,
Container,
type PaperProps,
} from '@mantine/core'
import { useToggle, upperFirst } from '@mantine/hooks'
interface SocialButtonConfig {
label: string
icon?: React.ReactNode
onClick?: () => void
}
interface ExtraFieldConfig {
name: string
label: string
placeholder?: string
required?: boolean
}
interface AuthFormSubmitValues {
type: 'login' | 'register'
email: string
password: string
[key: string]: unknown
}
interface AuthFormConfig {
/** Título principal de la página */
title?: string
/** Botones de autenticación social opcionales */
socialButtons?: SocialButtonConfig[]
/** Campos adicionales que se muestran solo en el modo registro */
extraFields?: ExtraFieldConfig[]
/** Callback invocado al enviar el formulario */
onSubmit?: (values: AuthFormSubmitValues) => void
/** Modo inicial: 'login' (default) o 'register' */
defaultType?: 'login' | 'register'
/** Props adicionales para el Paper contenedor */
paperProps?: PaperProps
}
function AuthForm({
title = 'Welcome',
socialButtons = [],
extraFields = [],
onSubmit,
defaultType = 'login',
paperProps,
}: AuthFormConfig): React.ReactElement {
const [type, toggle] = useToggle<'login' | 'register'>([
defaultType,
defaultType === 'login' ? 'register' : 'login',
])
const [email, setEmail] = React.useState('')
const [password, setPassword] = React.useState('')
const [terms, setTerms] = React.useState(true)
const [extraValues, setExtraValues] = React.useState<Record<string, string>>({})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit?.({ type, email, password, ...extraValues })
}
const handleExtraChange = (name: string, value: string) => {
setExtraValues((prev) => ({ ...prev, [name]: value }))
}
return (
<Container size={420} py={40}>
<Paper radius="md" p="lg" withBorder {...paperProps}>
<Title order={2} ta="center" mb="md">
{title}
</Title>
{socialButtons.length > 0 && (
<>
<Group grow mb="md" gap="xs">
{socialButtons.map((btn) => (
<Button
key={btn.label}
variant="default"
radius="xl"
leftSection={btn.icon}
onClick={btn.onClick}
>
{btn.label}
</Button>
))}
</Group>
<Divider label="O continúa con email" labelPosition="center" my="lg" />
</>
)}
<form onSubmit={handleSubmit}>
<Stack gap="sm">
{type === 'register' &&
extraFields.map((field) => (
<TextInput
key={field.name}
label={field.label}
placeholder={field.placeholder}
required={field.required}
value={extraValues[field.name] ?? ''}
onChange={(e) => handleExtraChange(field.name, e.currentTarget.value)}
radius="md"
/>
))}
<TextInput
required
label="Email"
placeholder="tu@email.com"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
radius="md"
/>
<PasswordInput
required
label="Contraseña"
placeholder="Tu contraseña"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
radius="md"
/>
{type === 'register' && (
<Checkbox
label="Acepto los términos y condiciones"
checked={terms}
onChange={(e) => setTerms(e.currentTarget.checked)}
/>
)}
</Stack>
<Group justify="space-between" mt="xl">
<Anchor
component="button"
type="button"
c="dimmed"
size="xs"
onClick={() => toggle()}
>
{type === 'register'
? '¿Ya tienes cuenta? Inicia sesión'
: '¿No tienes cuenta? Regístrate'}
</Anchor>
<Button type="submit" radius="xl">
{upperFirst(type)}
</Button>
</Group>
</form>
{type === 'register' && (
<Text c="dimmed" size="xs" ta="center" mt="md">
Al registrarte aceptas nuestra{' '}
<Anchor size="xs" href="#">
política de privacidad
</Anchor>
.
</Text>
)}
</Paper>
</Container>
)
}
export { AuthForm }
export type { AuthFormConfig, AuthFormSubmitValues, SocialButtonConfig, ExtraFieldConfig }
+136
View File
@@ -0,0 +1,136 @@
---
name: autocomplete
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Autocomplete(props: AutocompleteProps): JSX.Element"
description: "Input con sugerencias de autocompletado. Permite valores libres a diferencia de Select. Wrapper sobre Mantine Autocomplete."
tags: [autocomplete, input, form, suggestions, component, ui, interactive, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["@mantine/core"]
output: "Componente Autocomplete que renderiza input con dropdown de sugerencias filtradas"
props:
- name: data
type: "string[] | { value: string; label?: string; group?: string }[]"
required: true
description: "Lista de opciones a mostrar en el dropdown"
- name: value
type: "string"
required: false
description: "Valor controlado del input"
- name: onChange
type: "(value: string) => void"
required: false
description: "Callback al cambiar el valor del input"
- name: label
type: "string"
required: false
description: "Etiqueta visible encima del input"
- name: placeholder
type: "string"
required: false
description: "Texto placeholder cuando el input está vacío"
- name: clearable
type: "boolean"
required: false
description: "Muestra botón para limpiar el valor"
- name: loading
type: "boolean"
required: false
description: "Muestra spinner de carga en el input"
- name: disabled
type: "boolean"
required: false
description: "Deshabilita el input"
- name: limit
type: "number"
required: false
description: "Número máximo de sugerencias a mostrar en el dropdown"
- name: size
type: "'xs' | 'sm' | 'md' | 'lg' | 'xl'"
required: false
description: "Tamaño visual del input"
emits: [onChange]
has_state: true
framework: react
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/autocomplete.tsx"
---
## Ejemplo
```tsx
import { Autocomplete } from '@fn_library/autocomplete'
// Básico — lista de strings
function BasicAutocomplete() {
return (
<Autocomplete
label="País"
placeholder="Escribe para buscar..."
data={['Argentina', 'Brasil', 'Chile', 'Colombia', 'Uruguay']}
/>
)
}
// Con grupos
function GroupedAutocomplete() {
return (
<Autocomplete
label="Ciudad"
placeholder="Selecciona una ciudad"
data={[
{ value: 'Buenos Aires', group: 'Argentina' },
{ value: 'Rosario', group: 'Argentina' },
{ value: 'São Paulo', group: 'Brasil' },
{ value: 'Río de Janeiro', group: 'Brasil' },
]}
limit={5}
/>
)
}
// Con loading y clearable (búsqueda asíncrona)
function AsyncAutocomplete() {
const [value, setValue] = useState('')
const [data, setData] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const handleChange = async (val: string) => {
setValue(val)
if (val.length < 2) return
setLoading(true)
const results = await fetchSuggestions(val)
setData(results)
setLoading(false)
}
return (
<Autocomplete
label="Búsqueda"
placeholder="Escribe al menos 2 caracteres..."
value={value}
onChange={handleChange}
data={data}
loading={loading}
clearable
/>
)
}
```
## Notas
A diferencia de `Select`, `Autocomplete` permite que el usuario ingrese cualquier valor libre, no solo los de la lista. Ideal para búsquedas con sugerencias donde el valor final puede no estar en el dataset.
Cuando `data` contiene objetos con `group`, el dropdown agrupa visualmente las opciones bajo el encabezado del grupo.
El prop `limit` controla cuántas sugerencias se muestran simultáneamente (por defecto Mantine muestra todas). Útil para datasets grandes o búsquedas asíncronas donde se quiere limitar el ruido visual.
+10
View File
@@ -0,0 +1,10 @@
import { Autocomplete as MantineAutocomplete, type AutocompleteProps as MantineAutcompleteProps } from '@mantine/core'
interface AutocompleteProps extends MantineAutcompleteProps {}
function Autocomplete(props: AutocompleteProps) {
return <MantineAutocomplete {...props} />
}
export { Autocomplete }
export type { AutocompleteProps }
+71
View File
@@ -0,0 +1,71 @@
---
name: avatar
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Avatar(props: AvatarProps): JSX.Element"
description: "Imagen de usuario circular con fallback a iniciales generadas automaticamente. 5 tamaños via Mantine Avatar."
tags: [avatar, user, image, component, ui, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
output: "Componente Avatar que renderiza imagen de usuario circular con fallback a iniciales generadas"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/avatar.tsx"
props:
- name: src
type: "string"
required: false
description: "URL de la imagen"
- name: alt
type: "string"
required: false
description: "Texto alternativo de la imagen"
- name: fallback
type: "string"
required: false
description: "Nombre completo del que extraer iniciales (ej: 'Juan Perez' -> 'JP')"
- name: initials
type: "string"
required: false
description: "Iniciales explicitas para el fallback (sobrescribe fallback)"
- name: size
type: "'xs' | 'sm' | 'md' | 'lg' | 'xl'"
required: false
description: "Tamanio del avatar (default: md)"
- name: className
type: "string"
required: false
description: "Clases CSS adicionales"
emits: []
has_state: false
framework: react
variant: [xs, sm, md, lg, xl]
---
## Ejemplo
```tsx
// Con imagen
<Avatar src="https://example.com/user.jpg" alt="Juan Perez" size="md" />
// Con fallback a iniciales
<Avatar fallback="Juan Perez" size="lg" />
// Iniciales explicitas
<Avatar initials="JD" size="sm" />
// Maneja error de imagen automaticamente
<Avatar src="/broken-url.jpg" fallback="Maria Garcia" />
```
## Notas
Usa Mantine Avatar que maneja errores de carga de imagen nativamente. La funcion getInitials extrae 2 iniciales del nombre completo (primera y ultima palabra). Si solo hay una palabra, toma los 2 primeros caracteres. Usa forwardRef para compatibilidad con wrappers.
+57
View File
@@ -0,0 +1,57 @@
import * as React from 'react'
import { Avatar as MantineAvatar } from '@mantine/core'
type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
const sizeMap: Record<AvatarSize, string> = {
xs: 'sm',
sm: 'sm',
md: 'md',
lg: 'lg',
xl: 'xl',
}
interface AvatarProps extends React.ComponentPropsWithoutRef<'div'> {
src?: string
alt?: string
fallback?: string
initials?: string
size?: AvatarSize
}
function getInitials(name?: string): string {
if (!name) return '?'
const parts = name.trim().split(/\s+/)
const first = parts[0] ?? ''
const last = parts[parts.length - 1] ?? ''
if (parts.length === 1) return first.slice(0, 2).toUpperCase()
return ((first[0] ?? '') + (last[0] ?? '')).toUpperCase()
}
/** Kept for backwards compatibility */
const avatarVariants = sizeMap
const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
({ className, size = 'md', src, alt, fallback, initials, ...props }, ref) => {
const displayInitials = initials ?? getInitials(fallback ?? alt)
return (
<MantineAvatar
ref={ref}
data-slot="avatar"
src={src}
alt={alt ?? ''}
size={sizeMap[size]}
radius="xl"
className={className}
{...props}
>
{displayInitials}
</MantineAvatar>
)
}
)
Avatar.displayName = 'Avatar'
export { Avatar, avatarVariants }
export type { AvatarProps, AvatarSize }
+49
View File
@@ -0,0 +1,49 @@
---
name: badge
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Badge(props: BadgeProps & VariantProps<typeof badgeVariants>): JSX.Element"
description: "Badge con 10 variantes semánticas (default, secondary, destructive, outline, ghost, link, success, warning, error, info) y 2 tamaños. Mantine Badge."
tags: [badge, status, component, ui, indicator, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
output: "Componente Badge que renderiza un indicador visual con 10 variantes semánticas de estado"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/badge.tsx"
props:
- name: variant
type: "'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link' | 'success' | 'warning' | 'error' | 'info'"
required: false
description: "Variante visual"
- name: size
type: "'default' | 'sm'"
required: false
description: "Tamaño"
emits: []
has_state: false
framework: react
variant: [default, secondary, destructive, outline, ghost, link, success, warning, error, info]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/badge.tsx"
---
## Ejemplo
```tsx
<Badge variant="success">Active</Badge>
<Badge variant="error" size="sm">Error</Badge>
```
## Notas
Usa Mantine Badge internamente. Las 10 variantes se mapean a combinaciones de variant+color de Mantine (filled, light, outline, subtle, transparent).
+47
View File
@@ -0,0 +1,47 @@
import * as React from 'react'
import { Badge as MantineBadge } from '@mantine/core'
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link' | 'success' | 'warning' | 'error' | 'info'
type BadgeSize = 'default' | 'sm'
const variantMap: Record<BadgeVariant, { variant: string; color?: string }> = {
default: { variant: 'filled' },
secondary: { variant: 'light' },
destructive: { variant: 'light', color: 'red' },
outline: { variant: 'outline' },
ghost: { variant: 'subtle' },
link: { variant: 'transparent' },
success: { variant: 'light', color: 'green' },
warning: { variant: 'light', color: 'yellow' },
error: { variant: 'light', color: 'red' },
info: { variant: 'light', color: 'blue' },
}
/** Kept for backwards compatibility */
const badgeVariants = variantMap
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: BadgeVariant
size?: BadgeSize
}
function Badge({ className, variant = 'default', size = 'default', children, ...props }: BadgeProps) {
const mv = variantMap[variant]
return (
<MantineBadge
data-slot="badge"
variant={mv.variant}
color={mv.color}
size={size === 'sm' ? 'xs' : 'sm'}
radius="xl"
className={className}
{...props}
>
{children}
</MantineBadge>
)
}
export { Badge, badgeVariants }
export type { BadgeProps, BadgeVariant, BadgeSize }
+57
View File
@@ -0,0 +1,57 @@
---
name: bar_chart
kind: component
lang: ts
domain: ui
version: "1.1.0"
purity: impure
signature: "BarChart(props: BarChartProps): JSX.Element"
description: "Gráfico de barras @mantine/charts con multi-series, orientación horizontal/vertical y tooltips."
tags: [chart, bar, visualization, mantine, component, ui]
uses_functions: [chart_container_ts_ui]
uses_types: [ChartSeries_ts_ui]
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/charts", "@mantine/core"]
output: "Componente JSX que renderiza un gráfico de barras vertical u horizontal con multi-series y tooltips"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/bar_chart.tsx"
props:
- name: data
type: "Record<string, unknown>[]"
required: true
description: "Array de datos"
- name: xKey
type: "string"
required: true
description: "Key del eje X/categoría"
- name: horizontal
type: "boolean"
required: false
description: "Orientación horizontal"
- name: series
type: "Series[]"
required: false
description: "Series de datos para multi-series"
emits: []
has_state: false
framework: react
variant: [vertical, horizontal]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/charts/bar-chart.tsx"
---
## Ejemplo
```tsx
<BarChart data={data} xKey="category" yKey="sales" showLegend />
<BarChart data={data} xKey="name" series={series} horizontal />
```
## Notas
En modo `horizontal=true` se pasa `orientation="vertical"` a Mantine BarChart, que internamente intercambia los ejes.
+47
View File
@@ -0,0 +1,47 @@
import { BarChart as MantineBarChart } from '@mantine/charts'
import { Paper } from '@mantine/core'
import { type Series, getSeriesColor } from './chart_container'
interface BarChartProps {
data: Record<string, unknown>[]
xKey: string
yKey?: string
series?: Series[]
horizontal?: boolean
showGrid?: boolean
showLegend?: boolean
height?: number
xAxisFormatter?: (value: unknown) => string
yAxisFormatter?: (value: unknown) => string
valueFormatter?: (value: number) => string
}
function BarChartComponent({
data, xKey, yKey, series, horizontal = false, showGrid = true, showLegend = false,
height = 300, xAxisFormatter, yAxisFormatter, valueFormatter = (v) => v.toLocaleString(),
}: BarChartProps) {
const chartSeries = series
? series.map((s, i) => ({ name: s.key, label: s.name, color: getSeriesColor(i, s.color) }))
: yKey ? [{ name: yKey, label: yKey, color: getSeriesColor(0) }] : []
return (
<Paper p="md">
<MantineBarChart
h={height}
data={data}
dataKey={xKey}
series={chartSeries}
orientation={horizontal ? 'vertical' : 'horizontal'}
gridAxis={showGrid ? 'xy' : 'none'}
withLegend={showLegend}
withTooltip
valueFormatter={valueFormatter}
xAxisProps={xAxisFormatter ? { tickFormatter: xAxisFormatter } : undefined}
yAxisProps={yAxisFormatter ? { tickFormatter: yAxisFormatter } : undefined}
/>
</Paper>
)
}
export const BarChart = BarChartComponent
export type { BarChartProps }
+72
View File
@@ -0,0 +1,72 @@
---
name: breadcrumb
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Breadcrumb(props: BreadcrumbProps): JSX.Element"
description: "Navegacion jerarquica con separadores, elipsis para paths largos y soporte para router links via asChild. Mantine Anchor/Text."
tags: [breadcrumb, navigation, component, ui, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core", "@tabler/icons-react"]
output: "Componente Breadcrumb que renderiza navegación jerárquica con separadores, elipsis y soporte para router links"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/breadcrumb.tsx"
props:
- name: className
type: "string"
required: false
description: "Clases CSS adicionales"
emits: []
has_state: false
framework: react
variant: []
---
## Ejemplo
```tsx
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/">Inicio</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="/docs">Documentacion</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Componentes</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
// Con elipsis para paths largos
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/">Inicio</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbEllipsis />
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Pagina actual</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
```
## Notas
Exports: Breadcrumb (nav), BreadcrumbList (ol via Group), BreadcrumbItem (li via Group), BreadcrumbLink (Mantine Anchor con asChild), BreadcrumbPage (Text aria-current=page), BreadcrumbSeparator (IconChevronRight por defecto, customizable), BreadcrumbEllipsis (IconDots). BreadcrumbLink acepta asChild para usar con Link de React Router o Next.js. Usa Tabler icons en vez de lucide-react.
+95
View File
@@ -0,0 +1,95 @@
import * as React from "react"
import { Anchor, Text, Box } from "@mantine/core"
import { IconChevronRight, IconDots } from "@tabler/icons-react"
function Breadcrumb({ children, ...props }: React.ComponentPropsWithoutRef<"nav">) {
return <nav data-slot="breadcrumb" aria-label="breadcrumb" {...props}>{children}</nav>
}
function BreadcrumbList({ className, children, ...props }: React.ComponentPropsWithoutRef<"ol">) {
return (
<ol data-slot="breadcrumb-list" style={{ listStyle: "none", display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8, padding: 0, margin: 0 }} className={className} {...props}>
{children}
</ol>
)
}
function BreadcrumbItem({ className, children, ...props }: React.ComponentPropsWithoutRef<"li">) {
return (
<li data-slot="breadcrumb-item" style={{ display: "flex", alignItems: "center", gap: 8 }} className={className} {...props}>
{children}
</li>
)
}
function BreadcrumbLink({
className,
href,
asChild,
children,
...props
}: React.ComponentPropsWithoutRef<"a"> & { asChild?: boolean }) {
if (asChild) {
return (
<Text data-slot="breadcrumb-link" component="span" size="sm" className={className} {...(props as React.ComponentPropsWithoutRef<"span">)}>
{children}
</Text>
)
}
return (
<Anchor data-slot="breadcrumb-link" href={href} size="sm" className={className} {...props}>
{children}
</Anchor>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentPropsWithoutRef<"span">) {
return (
<Text
data-slot="breadcrumb-page"
component="span"
size="sm"
fw={500}
role="link"
aria-current="page"
aria-disabled="true"
className={className}
{...props}
/>
)
}
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
return (
<Box
data-slot="breadcrumb-separator"
component="li"
role="presentation"
aria-hidden="true"
className={className}
style={{ display: "flex", alignItems: "center" }}
{...props}
>
{children ?? <IconChevronRight size={14} />}
</Box>
)
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentPropsWithoutRef<"span">) {
return (
<Box
data-slot="breadcrumb-ellipsis"
component="span"
role="presentation"
aria-hidden="true"
style={{ display: "flex", width: 36, height: 36, alignItems: "center", justifyContent: "center" }}
className={className}
{...props}
>
<IconDots size={16} />
<span style={{ position: "absolute", width: 1, height: 1, padding: 0, margin: -1, overflow: "hidden", clip: "rect(0,0,0,0)", whiteSpace: "nowrap", borderWidth: 0 }}>More</span>
</Box>
)
}
export { Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator }
+54
View File
@@ -0,0 +1,54 @@
---
name: button
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Button(props: ButtonProps & VariantProps<typeof buttonVariants>): JSX.Element"
description: "Botón accesible con 6 variantes (default, outline, secondary, ghost, destructive, link) y 8 tamaños. Mantine Button."
tags: [button, component, ui, interactive, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
output: "JSX.Element: botón renderizado con los estilos y comportamientos configurados"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/button.tsx"
props:
- name: variant
type: "'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'"
required: false
description: "Estilo visual del botón"
- name: size
type: "'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'"
required: false
description: "Tamaño del botón"
- name: className
type: "string"
required: false
description: "Clases CSS adicionales"
emits: [onClick]
has_state: false
framework: react
variant: [default, outline, secondary, ghost, destructive, link]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/button.tsx"
---
## Ejemplo
```tsx
<Button variant="outline" size="sm">Click me</Button>
<Button variant="destructive">Delete</Button>
<Button variant="ghost" size="icon"><TrashIcon /></Button>
```
## Notas
Componente base del sistema. Usa Mantine Button para accesibilidad completa (keyboard, ARIA). Las variantes se mapean a Mantine: default->filled, outline->outline, secondary->light, ghost->subtle, destructive->filled(red), link->transparent.
+64
View File
@@ -0,0 +1,64 @@
import * as React from 'react'
import { Button as MantineButton } from '@mantine/core'
type ButtonVariant = 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
type ButtonSize = 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
const variantMap: Record<ButtonVariant, { variant: string; color?: string }> = {
default: { variant: 'filled' },
outline: { variant: 'outline' },
secondary: { variant: 'light' },
ghost: { variant: 'subtle' },
destructive: { variant: 'filled', color: 'red' },
link: { variant: 'transparent' },
}
const sizeMap: Record<ButtonSize, { size: string; style?: React.CSSProperties }> = {
default: { size: 'sm' },
xs: { size: 'xs' },
sm: { size: 'xs' },
lg: { size: 'md' },
icon: { size: 'sm', style: { width: 32, height: 32, padding: 0 } },
'icon-xs': { size: 'xs', style: { width: 24, height: 24, padding: 0 } },
'icon-sm': { size: 'xs', style: { width: 28, height: 28, padding: 0 } },
'icon-lg': { size: 'md', style: { width: 36, height: 36, padding: 0 } },
}
/** Kept for backwards compatibility — maps variant names to Mantine equivalents */
const buttonVariants = variantMap
interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {
variant?: ButtonVariant
size?: ButtonSize
children?: React.ReactNode
}
function Button({
className,
variant = 'default',
size = 'default',
style,
children,
...props
}: ButtonProps) {
const mv = variantMap[variant]
const ms = sizeMap[size]
return (
<MantineButton
data-slot="button"
variant={mv.variant}
color={mv.color}
size={ms.size}
radius="md"
className={className}
style={{ ...ms.style, ...style }}
{...props}
>
{children}
</MantineButton>
)
}
export { Button, buttonVariants }
export type { ButtonProps, ButtonVariant, ButtonSize }
+71
View File
@@ -0,0 +1,71 @@
---
name: card
kind: component
lang: ts
domain: ui
version: "1.1.0"
purity: impure
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. Variantes default, borderless y ghost para dashboards dark."
tags: [card, container, layout, component, ui, dashboard, dark]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["react"]
output: "Componente Card que renderiza un contenedor con slots composables (header, content, footer) y 3 variantes visuales"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/card.tsx"
props:
- name: size
type: "'default' | 'sm'"
required: false
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
type: "string"
required: false
description: "Clases CSS adicionales"
emits: []
has_state: false
framework: react
variant: [default, sm, borderless, ghost]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/card.tsx"
---
## Ejemplo
```tsx
<Card>
<CardHeader>
<CardTitle>Título</CardTitle>
<CardDescription>Descripción</CardDescription>
</CardHeader>
<CardContent>Contenido</CardContent>
<CardFooter>Footer</CardFooter>
</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
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.
+102
View File
@@ -0,0 +1,102 @@
import * as React from 'react'
import { Paper, Box, Text } from '@mantine/core'
type CardVariant = 'default' | 'borderless' | 'ghost'
function Card({
className,
size = 'default',
variant = 'default',
children,
...props
}: React.ComponentProps<'div'> & { size?: 'default' | 'sm'; variant?: CardVariant }) {
return (
<Paper
data-slot="card"
data-size={size}
data-variant={variant}
withBorder={variant === 'default'}
shadow={variant === 'default' ? 'xs' : undefined}
radius="md"
p={size === 'sm' ? 'sm' : 'md'}
bg={variant === 'ghost' ? 'transparent' : undefined}
className={className}
{...props}
>
{children}
</Paper>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<Box
data-slot="card-header"
pb="xs"
className={className}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<Text
component="div"
data-slot="card-title"
fw={600}
size="sm"
className={className}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<Text
component="div"
data-slot="card-description"
size="sm"
c="dimmed"
className={className}
{...props}
/>
)
}
function CardAction({ className, style, ...props }: React.ComponentProps<'div'>) {
return (
<Box
data-slot="card-action"
style={{ position: 'absolute', top: 0, right: 0, ...style }}
className={className}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<Box
data-slot="card-content"
className={className}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<Box
data-slot="card-footer"
pt="sm"
mt="auto"
style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}
className={className}
{...props}
/>
)
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
+52
View File
@@ -0,0 +1,52 @@
---
name: chart_container
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "ChartContainer(props: { children: ReactNode; height?: number | string }): JSX.Element"
description: "Thin wrapper Paper y utilidades de colores/series para los charts @mantine/charts."
tags: [chart, container, mantine, base, visualization, component, ui]
uses_functions: []
uses_types: [ChartSeries_ts_ui]
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
output: "Componente ChartContainer Paper wrapper y utilidades getSeriesColor/Series para charts Mantine"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/chart_container.tsx"
props:
- name: height
type: "number | string"
required: false
description: "Altura del chart (default 300)"
- name: className
type: "string"
required: false
description: "Clases CSS adicionales"
emits: []
has_state: false
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/charts/chart-base.tsx"
---
## Ejemplo
```tsx
import { ChartContainer, getSeriesColor, type Series } from './chart_container'
<ChartContainer height={400}>
<MantineLineChart ... />
</ChartContainer>
```
## Notas
Exporta: ChartContainer, defaultColors, getSeriesColor, Series. Wrapper fino sobre Mantine Paper para layout uniforme de charts.
+33
View File
@@ -0,0 +1,33 @@
import { Paper } from '@mantine/core'
export const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899']
export interface Series {
key: string
name: string
color?: string
}
export function getSeriesColor(index: number, color?: string): string {
return color || defaultColors[index % defaultColors.length]!
}
interface ChartContainerProps {
children: React.ReactNode
height?: number | string
}
export function ChartContainer({ children, height = 300 }: ChartContainerProps) {
return (
<Paper p="md" style={{ height, minWidth: 100, minHeight: typeof height === 'number' ? Math.min(height, 100) : 100 }}>
{children}
</Paper>
)
}
/** @deprecated Mantine charts handle tooltips internally. Kept for index.ts compat. */
export function ChartTooltipContent() { return null }
/** @deprecated Mantine charts handle tooltips internally. Kept for index.ts compat. */
export function ChartTooltip() { return null }
/** @deprecated Mantine charts handle legends internally. Kept for index.ts compat. */
export function ChartLegend() { return null }
+73
View File
@@ -0,0 +1,73 @@
---
name: checkbox
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Checkbox(props: CheckboxProps): JSX.Element"
description: "Input booleano accesible con label opcional y variante indeterminate. Mantine Checkbox."
tags: [checkbox, component, ui, interactive, form, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
output: "Componente Checkbox que renderiza input booleano accesible con label opcional y estado indeterminate"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/checkbox.tsx"
props:
- name: label
type: "string"
required: false
description: "Texto de etiqueta visible junto al checkbox"
- name: indeterminate
type: "boolean"
required: false
description: "Estado indeterminate (guion) en vez de checked/unchecked"
- name: checked
type: "boolean"
required: false
description: "Estado controlado del checkbox"
- name: defaultChecked
type: "boolean"
required: false
description: "Estado inicial no controlado"
- name: disabled
type: "boolean"
required: false
description: "Deshabilita el checkbox"
- name: onCheckedChange
type: "(checked: boolean) => void"
required: false
description: "Callback cuando cambia el estado"
emits: [onCheckedChange]
has_state: false
framework: react
variant: []
---
## Ejemplo
```tsx
// Basico
<Checkbox label="Acepto los terminos" />
// Controlado
<Checkbox
label="Seleccionar todos"
checked={allSelected}
indeterminate={someSelected}
onCheckedChange={setAllSelected}
/>
// Sin label
<Checkbox checked={isActive} onCheckedChange={setIsActive} />
```
## Notas
Usa Mantine Checkbox para accesibilidad completa (keyboard, ARIA). El estado indeterminate se muestra con un guion horizontal. El callback onCheckedChange se adapta desde el onChange nativo de Mantine.
+34
View File
@@ -0,0 +1,34 @@
import { Checkbox as MantineCheckbox } from "@mantine/core"
interface CheckboxProps {
label?: string
indeterminate?: boolean
className?: string
labelClassName?: string
checked?: boolean
defaultChecked?: boolean
disabled?: boolean
onCheckedChange?: (checked: boolean) => void
id?: string
}
function Checkbox({ className, label, id, indeterminate, checked, defaultChecked, disabled, onCheckedChange, ...props }: CheckboxProps) {
return (
<MantineCheckbox
id={id}
data-slot="checkbox"
label={label}
indeterminate={indeterminate}
checked={checked}
defaultChecked={defaultChecked}
disabled={disabled}
onChange={(event) => onCheckedChange?.(event.currentTarget.checked)}
className={className}
size="sm"
{...props}
/>
)
}
export { Checkbox }
export type { CheckboxProps }
+88
View File
@@ -0,0 +1,88 @@
---
name: chip
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Chip(props: ChipProps): JSX.Element"
description: "Chip seleccionable con variantes filled/outline/light. ChipGroup para selección simple o múltiple. Wrapper sobre Mantine Chip."
tags: [chip, toggle, selection, component, ui, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
output: "Componente Chip que renderiza un elemento seleccionable tipo badge, con ChipGroup para gestionar selección simple o múltiple entre varios chips"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/chip.tsx"
props:
- name: checked
type: "boolean"
required: false
description: "Estado seleccionado del chip (controlled)"
- name: onChange
type: "(checked: boolean) => void"
required: false
description: "Callback al cambiar el estado del chip"
- name: variant
type: "'filled' | 'outline' | 'light'"
required: false
description: "Estilo visual del chip"
- name: color
type: "string"
required: false
description: "Color del chip cuando está seleccionado"
- name: size
type: "'xs' | 'sm' | 'md' | 'lg' | 'xl'"
required: false
description: "Tamaño del chip"
- name: children
type: "React.ReactNode"
required: true
description: "Contenido del chip — texto o elemento"
emits: [onChange]
has_state: true
framework: react
variant: [filled, outline, light]
---
## Ejemplo
```tsx
import { Chip, ChipGroup } from '@fn_library'
// Chip individual controlado
<Chip checked={active} onChange={setActive}>
Activo
</Chip>
// ChipGroup selección simple (una sola opción)
<ChipGroup value={selected} onChange={setSelected}>
<Chip value="react">React</Chip>
<Chip value="vue">Vue</Chip>
<Chip value="svelte">Svelte</Chip>
</ChipGroup>
// ChipGroup selección múltiple
<ChipGroup multiple value={tags} onChange={setTags}>
<Chip value="frontend">Frontend</Chip>
<Chip value="backend">Backend</Chip>
<Chip value="devops">DevOps</Chip>
</ChipGroup>
// Con variante y color custom
<Chip variant="outline" color="teal" size="lg">
Destacado
</Chip>
```
## Notas
- Wrapper directo sobre `Chip` de `@mantine/core` v9. Todas las props de Mantine Chip son válidas.
- `ChipGroup` es un alias de `MantineChip.Group` — gestiona el estado de selección entre chips hijos.
- En `ChipGroup` sin `multiple`, `value` es `string`. Con `multiple`, `value` es `string[]`.
- Internamente cada `Chip` renderiza un checkbox/radio accesible oculto con label visual.
+12
View File
@@ -0,0 +1,12 @@
import { Chip as MantineChip, type ChipProps as MantineChipProps } from '@mantine/core'
interface ChipProps extends MantineChipProps {}
function Chip(props: ChipProps) {
return <MantineChip {...props} />
}
const ChipGroup = MantineChip.Group
export { Chip, ChipGroup }
export type { ChipProps }
+104
View File
@@ -0,0 +1,104 @@
---
name: color_input
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "ColorInput(props: ColorInputProps): JSX.Element"
description: "Selector de color con picker, swatches predefinidos y eye dropper. Soporta hex, rgb, hsl con alpha. Wrapper sobre Mantine ColorInput."
tags: [color, picker, input, form, component, ui, interactive, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/color_input.tsx"
framework: react
has_state: true
props:
- name: format
type: "\"hex\" | \"hexa\" | \"rgb\" | \"rgba\" | \"hsl\" | \"hsla\""
required: false
description: "Formato de color a usar en el valor. Por defecto hex."
- name: swatches
type: "string[]"
required: false
description: "Lista de colores predefinidos mostrados como swatches en el picker."
- name: withEyeDropper
type: "boolean"
required: false
description: "Muestra el boton eye dropper para seleccionar color de la pantalla."
- name: withPicker
type: "boolean"
required: false
description: "Muestra el color picker interactivo. Por defecto true."
- name: value
type: "string"
required: false
description: "Valor controlado del color en el formato especificado."
- name: onChange
type: "(value: string) => void"
required: false
description: "Callback invocado cuando el color cambia."
- name: label
type: "React.ReactNode"
required: false
description: "Etiqueta del campo."
- name: placeholder
type: "string"
required: false
description: "Placeholder del input de texto."
- name: disabled
type: "boolean"
required: false
description: "Deshabilita el input."
- name: size
type: "\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\""
required: false
description: "Tamanio del componente."
emits: [onChange, onChangeEnd]
output: "Componente ColorInput que renderiza input de color con picker desplegable y swatches"
params: []
---
## Ejemplo
```tsx
import { ColorInput } from '@fn_library/color_input'
// Basico con hex (default)
<ColorInput
label="Color primario"
placeholder="#228be6"
/>
// Con swatches predefinidos
<ColorInput
label="Color de marca"
swatches={['#2e2e2e', '#868e96', '#fa5252', '#e64980', '#be4bdb', '#228be6', '#15aabf', '#12b886', '#40c057', '#82c91e', '#fab005', '#fd7e14']}
swatchesPerRow={7}
placeholder="Elige un color"
/>
// Con rgba y eye dropper
<ColorInput
label="Color con transparencia"
format="rgba"
withEyeDropper
value="rgba(34, 139, 230, 0.5)"
onChange={(value) => console.log(value)}
/>
```
## Notas
Wrapper directo sobre `ColorInput` de Mantine v9. Hereda todas las props de Mantine sin restricciones.
El eye dropper (`withEyeDropper`) solo funciona en browsers que soporten la EyeDropper API (Chrome/Edge). En Firefox no aparece el boton automaticamente.
Cuando `format` es `hex` o `hsl`, el valor no incluye canal alpha. Para transparencia usar `hexa`, `rgba` o `hsla`.
+10
View File
@@ -0,0 +1,10 @@
import { ColorInput as MantineColorInput, type ColorInputProps as MantineColorInputProps } from '@mantine/core'
interface ColorInputProps extends MantineColorInputProps {}
function ColorInput(props: ColorInputProps) {
return <MantineColorInput {...props} />
}
export { ColorInput }
export type { ColorInputProps }
+82
View File
@@ -0,0 +1,82 @@
---
name: command
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Command(props: CommandProps): JSX.Element"
description: "Combobox de busqueda y seleccion estilo cmdk. Filtra items por query, soporta grupos, iconos y shortcuts. Incluye CommandSearch para uso de una linea."
tags: [command, search, combobox, component, ui, interactive]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core", "@tabler/icons-react"]
output: "Componente Command que renderiza combobox de búsqueda y selección con filtrado reactivo, grupos e iconos"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/command.tsx"
props:
- name: items
type: "CommandItem[]"
required: true
description: "Array de items con value, label, description, icon, disabled, group"
- name: value
type: "string"
required: false
description: "Valor seleccionado (controlado)"
- name: onValueChange
type: "(value: string) => void"
required: false
description: "Callback al seleccionar un item"
- name: placeholder
type: "string"
required: false
description: "Placeholder del input de busqueda (default: Search...)"
- name: emptyMessage
type: "string"
required: false
description: "Mensaje cuando no hay resultados (default: No results found.)"
emits: [onValueChange]
has_state: true
framework: react
variant: []
---
## Ejemplo
```tsx
// Uso simple con CommandSearch
const items = [
{ value: "react", label: "React", group: "Frameworks" },
{ value: "vue", label: "Vue", group: "Frameworks" },
{ value: "typescript", label: "TypeScript", group: "Lenguajes" },
]
<CommandSearch
items={items}
placeholder="Buscar tecnologia..."
onValueChange={(val) => console.log(val)}
/>
// Composable para mayor control
<Command>
<CommandInput placeholder="Buscar..." value={query} onChange={(e) => setQuery(e.target.value)} />
<CommandList>
<CommandEmpty>Sin resultados.</CommandEmpty>
<CommandGroup heading="Sugerencias">
<CommandItem selected={selected === "1"} onSelect={() => setSelected("1")}>
Opcion 1
<CommandShortcut>K</CommandShortcut>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
```
## Notas
Implementacion propia (sin dependencia de cmdk) usando primitivos HTML nativos. CommandSearch es el wrapper de alto nivel con filtrado reactivo integrado. El filtrado es case-insensitive sobre label, description y value. Los grupos se renderizan en orden de aparicion en items.
+189
View File
@@ -0,0 +1,189 @@
import * as React from 'react'
import { TextInput, Text, Box, ScrollArea } from '@mantine/core'
import { IconSearch } from '@tabler/icons-react'
interface CommandItemData {
value: string
label: string
description?: string
icon?: React.ReactNode
disabled?: boolean
group?: string
}
interface CommandProps {
items: CommandItemData[]
value?: string
onValueChange?: (value: string) => void
placeholder?: string
emptyMessage?: string
className?: string
inputClassName?: string
listClassName?: string
}
function Command({ className, children, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return <Box data-slot="command" className={className} {...props}>{children}</Box>
}
function CommandInput({ className, value, onChange, placeholder, ...props }: {
className?: string
value?: string
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
placeholder?: string
}) {
return (
<TextInput
data-slot="command-input"
leftSection={<IconSearch size={16} />}
className={className}
value={value}
onChange={onChange}
placeholder={placeholder}
styles={{ input: { border: 'none', borderBottom: '1px solid var(--mantine-color-default-border)' } }}
{...props}
/>
)
}
function CommandList({ className, children }: { className?: string; children?: React.ReactNode }) {
return (
<ScrollArea.Autosize mah={300} data-slot="command-list" className={className}>
{children}
</ScrollArea.Autosize>
)
}
function CommandEmpty({ className, children }: { className?: string; children?: React.ReactNode }) {
return (
<Text ta="center" c="dimmed" size="sm" py="xl" data-slot="command-empty" className={className}>
{children}
</Text>
)
}
function CommandGroup({ className, heading, children }: { className?: string; heading?: string; children?: React.ReactNode }) {
return (
<Box data-slot="command-group" p={4} className={className}>
{heading && <Text size="xs" fw={500} c="dimmed" px="sm" py={6}>{heading}</Text>}
<div>{children}</div>
</Box>
)
}
function CommandSeparator({ className }: { className?: string }) {
return <Box data-slot="command-separator" h={1} bg="var(--mantine-color-default-border)" mx={-4} className={className} />
}
function CommandItem({ className, selected, disabled, onSelect, children }: {
className?: string
selected?: boolean
disabled?: boolean
onSelect?: () => void
children?: React.ReactNode
}) {
return (
<Box
data-slot="command-item"
data-selected={selected}
aria-disabled={disabled}
role="option"
aria-selected={selected}
onClick={!disabled ? onSelect : undefined}
px="sm"
py={6}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
borderRadius: 'var(--mantine-radius-sm)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
backgroundColor: selected ? 'var(--mantine-color-default-hover)' : undefined,
fontSize: 'var(--mantine-font-size-sm)',
}}
className={className}
>
{children}
</Box>
)
}
function CommandShortcut({ className, children }: { className?: string; children?: React.ReactNode }) {
return <Text span size="xs" c="dimmed" ml="auto" className={className}>{children}</Text>
}
function CommandSearch({
items,
value,
onValueChange,
placeholder = 'Search...',
emptyMessage = 'No results found.',
className,
}: CommandProps) {
const [query, setQuery] = React.useState('')
const [selectedValue, setSelectedValue] = React.useState(value ?? '')
const filtered = React.useMemo(() => {
if (!query) return items
const q = query.toLowerCase()
return items.filter(
(item) =>
item.label.toLowerCase().includes(q) ||
item.description?.toLowerCase().includes(q) ||
item.value.toLowerCase().includes(q)
)
}, [items, query])
const groups = React.useMemo(() => {
const map = new Map<string, CommandItemData[]>()
for (const item of filtered) {
const key = item.group ?? ''
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(item)
}
return map
}, [filtered])
const handleSelect = (val: string) => {
setSelectedValue(val)
onValueChange?.(val)
}
return (
<Command className={className}>
<CommandInput
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
/>
<CommandList>
{filtered.length === 0 ? (
<CommandEmpty>{emptyMessage}</CommandEmpty>
) : (
Array.from(groups.entries()).map(([group, groupItems]) => (
<CommandGroup key={group} heading={group || undefined}>
{groupItems.map((item) => (
<CommandItem
key={item.value}
selected={selectedValue === item.value}
disabled={item.disabled}
onSelect={() => handleSelect(item.value)}
>
{item.icon && <span>{item.icon}</span>}
<span>{item.label}</span>
{item.description && (
<Text span size="xs" c="dimmed" ml="auto">{item.description}</Text>
)}
</CommandItem>
))}
</CommandGroup>
))
)}
</CommandList>
</Command>
)
}
export { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSearch, CommandSeparator, CommandShortcut }
export type { CommandItemData, CommandProps }
+55
View File
@@ -0,0 +1,55 @@
---
name: crud_page
kind: function
lang: ts
domain: ui
version: "1.0.0"
purity: pure
signature: "crudPage<T>(props: CrudPageProps<T>): ReactElement"
description: "Genera una página CRUD completa con header, tabla con columnas configurables, botones de acción (add/edit/delete) y schema de formulario."
tags: [crud, page, table, form, factory, composition, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react, "@mantine/core", "@tabler/icons-react"]
params:
- name: props
desc: "Configuración CRUD: título, datos, columnas de tabla, campos de formulario y callbacks para add/edit/delete"
output: "Componente ReactElement que renderiza página CRUD completa con tabla y botones de acción"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/crud_page.tsx"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: ""
---
## Ejemplo
```tsx
crudPage({
title: 'Users',
subtitle: 'Manage system users',
data: users,
fields: [
{ key: 'name', label: 'Name', type: 'text', required: true },
{ key: 'email', label: 'Email', type: 'email', required: true },
{ key: 'role', label: 'Role', type: 'select', options: [{ label: 'Admin', value: 'admin' }, { label: 'User', value: 'user' }] },
],
columns: [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'role', label: 'Role', render: (v) => <Badge variant={v === 'admin' ? 'default' : 'secondary'}>{v}</Badge> },
],
onAdd: handleAdd,
onEdit: handleEdit,
onDelete: handleDelete,
})
```
## Notas
El schema de campos se almacena como data attribute para que un agente pueda leerlo y generar el formulario de diálogo correspondiente. La tabla incluye sorting visual implícito por columnas.
+121
View File
@@ -0,0 +1,121 @@
import * as React from 'react'
import { Stack, Group, Title, Text, Paper, Table, Button, ActionIcon, Center } from '@mantine/core'
import { IconPlus, IconPencil, IconTrash } from '@tabler/icons-react'
interface CrudField {
key: string
label: string
type: 'text' | 'number' | 'email' | 'select' | 'textarea'
required?: boolean
options?: Array<{ label: string; value: string }>
placeholder?: string
}
interface CrudPageProps<T extends Record<string, unknown>> {
title: string
subtitle?: string
data: T[]
fields: CrudField[]
columns: Array<{
key: keyof T
label: string
render?: (value: unknown, row: T) => React.ReactNode
}>
onAdd?: (item: Partial<T>) => void
onEdit?: (item: T) => void
onDelete?: (item: T) => void
actions?: React.ReactNode
className?: string
}
export function crudPage<T extends Record<string, unknown>>({
title,
subtitle,
data,
fields,
columns,
onAdd,
onEdit,
onDelete,
actions,
}: CrudPageProps<T>): React.ReactElement {
return (
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
<Stack gap={4}>
<Title order={2}>{title}</Title>
{subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
</Stack>
<Group gap="xs">
{actions}
{onAdd && (
<Button size="xs" leftSection={<IconPlus size={16} />}>
Add {title.replace(/s$/, '')}
</Button>
)}
</Group>
</Group>
{/* Table */}
<Paper withBorder radius="md">
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
{columns.map((col) => (
<Table.Th key={String(col.key)} fz="sm" fw={500} c="dimmed" px="md" py="sm">
{col.label}
</Table.Th>
))}
{(onEdit || onDelete) && (
<Table.Th ta="right" fz="sm" fw={500} c="dimmed" px="md" py="sm">Actions</Table.Th>
)}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={columns.length + (onEdit || onDelete ? 1 : 0)}>
<Center h={96}>
<Text c="dimmed">No items yet.</Text>
</Center>
</Table.Td>
</Table.Tr>
) : (
data.map((row, i) => (
<Table.Tr key={i}>
{columns.map((col) => (
<Table.Td key={String(col.key)} px="md" py="sm" style={{ verticalAlign: 'middle' }}>
{col.render ? col.render(row[col.key], row) : String(row[col.key] ?? '')}
</Table.Td>
))}
{(onEdit || onDelete) && (
<Table.Td px="md" py="sm" ta="right">
<Group gap={4} justify="flex-end">
{onEdit && (
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(row)}>
<IconPencil size={14} />
</ActionIcon>
)}
{onDelete && (
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => onDelete(row)}>
<IconTrash size={14} />
</ActionIcon>
)}
</Group>
</Table.Td>
)}
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Paper>
{/* Form fields definition (for agent use) */}
<div style={{ display: 'none' }} data-slot="crud-form-schema" data-fields={JSON.stringify(fields)} />
</Stack>
)
}
export type { CrudPageProps, CrudField }
+46
View File
@@ -0,0 +1,46 @@
---
name: dashboard_layout
kind: function
lang: ts
domain: ui
version: "1.0.0"
purity: pure
signature: "dashboardLayout(props: DashboardLayoutProps): ReactElement"
description: "Genera un grid responsive de dashboard a partir de un array de widgets con span configurable. 1-4 columnas con auto-responsive."
tags: [dashboard, layout, grid, factory, composition, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react, "@mantine/core"]
params:
- name: props
desc: "Configuración de layout: número de columnas y array de widgets con id, título, contenido y span"
output: "Componente ReactElement que renderiza grid responsive de dashboard con ancho adaptable por widget"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/dashboard_layout.tsx"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: ""
---
## Ejemplo
```tsx
dashboardLayout({
columns: 4,
widgets: [
{ id: 'revenue', title: 'Revenue', content: <KPICard label="Revenue" value="$12k" /> },
{ id: 'users', title: 'Users', content: <KPICard label="Users" value={1234} /> },
{ id: 'chart', title: 'Trends', span: 2, content: <LineChart data={data} xKey="month" yKey="value" /> },
{ id: 'table', span: 4, content: <DataTable columns={cols} data={rows} /> },
]
})
```
## Notas
Factory pura — dado el mismo input siempre genera el mismo JSX. Un agente puede construir dashboards completos pasando widgets como configuración declarativa.
+53
View File
@@ -0,0 +1,53 @@
import * as React from 'react'
import { SimpleGrid, Paper, Text } from '@mantine/core'
interface DashboardWidget {
id: string
title?: string
span?: 1 | 2 | 3 | 4
rowSpan?: 1 | 2
content: React.ReactNode
}
interface DashboardLayoutProps {
widgets: DashboardWidget[]
columns?: 1 | 2 | 3 | 4
gap?: 'sm' | 'md' | 'lg'
className?: string
}
const gapMap = { sm: 'xs', md: 'md', lg: 'lg' } as const
export function dashboardLayout({
widgets,
columns = 4,
gap = 'md',
}: DashboardLayoutProps): React.ReactElement {
return (
<SimpleGrid
cols={{ base: 1, md: Math.min(columns, 2), lg: columns }}
spacing={gapMap[gap]}
>
{widgets.map((widget) => (
<Paper
key={widget.id}
p="md"
withBorder
shadow="xs"
radius="md"
style={{
gridColumn: widget.span && widget.span > 1 ? `span ${widget.span}` : undefined,
gridRow: widget.rowSpan === 2 ? 'span 2' : undefined,
}}
>
{widget.title && (
<Text size="sm" fw={500} c="dimmed" mb="sm">{widget.title}</Text>
)}
{widget.content}
</Paper>
))}
</SimpleGrid>
)
}
export type { DashboardWidget, DashboardLayoutProps }
+93
View File
@@ -0,0 +1,93 @@
---
name: data_table
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "DataTable(props: DataTableProps): JSX.Element"
description: "Tabla de datos con sticky header, overflow scroll, heatmap por columna, formato condicional (number/datetime/currency) y hover rows. Auto-detecta columnas desde la primera fila si no se proveen."
tags: [table, data, heatmap, dashboard, component, ui, format, visualization]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react, "@mantine/core"]
output: "Componente DataTable que renderiza tabla con sticky header, heatmap condicional y formato automático de datos"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/data_table.tsx"
props:
- name: data
type: "Record<string, unknown>[]"
required: true
description: "Filas de datos. Cada objeto es una fila."
- name: columns
type: "ColumnDef[]"
required: false
description: "Definición de columnas con key, label, format y align. Si se omite, se auto-detectan desde la primera fila."
- name: heatmapColumns
type: "string[]"
required: false
description: "Keys de columnas numéricas que deben colorearse por intensidad (azul oscuro=bajo, azul claro=alto)."
- name: maxHeight
type: "number | string"
required: false
description: "Altura máxima antes de scroll. Default 500px."
- name: loading
type: "boolean"
required: false
description: "Estado de carga. Muestra spinner si data está vacía."
- name: error
type: "Error | null"
required: false
description: "Error a mostrar si la carga falló."
emits: []
has_state: false
framework: react
variant: [default, heatmap]
---
## Ejemplo
```tsx
// Tabla simple con auto-detección de columnas
<DataTable data={rows} />
// Con columnas definidas y heatmap
<DataTable
data={metrics}
columns={[
{ key: 'domain', label: 'Domain' },
{ key: 'count', label: 'Functions', format: ',' },
{ key: 'pure_pct', label: 'Pure %', format: '.1f' },
]}
heatmapColumns={['count', 'pure_pct']}
/>
// Con formato moneda y fecha
<DataTable
data={transactions}
columns={[
{ key: 'date', label: 'Date', format: 'datetime' },
{ key: 'amount', label: 'Amount', format: '$,.2f', align: 'right' },
{ key: 'description', label: 'Description' },
]}
/>
```
## Formatos soportados (campo `format` en ColumnDef)
| format | Ejemplo input | Output |
|--------|--------------|--------|
| `','` | `1234567` | `1,234,567` |
| `',.2f'` | `1234.5` | `1,234.50` |
| `'$,.2f'` | `1234.5` | `$1,234.50` |
| `'.0f'` | `42.7` | `43` |
| `'datetime'` | `'2026-04-01T12:00:00Z'` | `4/1/2026, 12:00:00 PM` |
## Notas
Extraido y generalizado desde `apps/rapid_dashboards/frontend/src/components/widgets/TableWidget.tsx`. El heatmap usa `useMemo` para calcular min/max por columna solo cuando cambian `data` o `heatmapColumns`. La alineación de celdas numéricas es automática (derecha) cuando el valor es `typeof 'number'`; se puede sobreescribir con el campo `align` en ColumnDef.
+155
View File
@@ -0,0 +1,155 @@
import * as React from 'react'
import { Table, Text, Center, Loader } from '@mantine/core'
interface ColumnDef {
key: string
label: string
/** Format string: ',.2f' | '$,.2f' | 'datetime' | ',' */
format?: string
/** Alignment override. Numbers default to right, strings to left. */
align?: 'left' | 'right' | 'center'
}
interface DataTableProps {
data: Record<string, unknown>[]
columns?: ColumnDef[]
/** Column keys that should be colored by value intensity (heatmap). */
heatmapColumns?: string[]
maxHeight?: number | string
loading?: boolean
error?: Error | null
}
function formatCell(value: unknown, format?: string): string {
if (value == null) return '—'
if (!format) return String(value)
if (format === 'datetime' && !isNaN(Date.parse(String(value)))) {
return new Date(String(value)).toLocaleString()
}
const num = Number(value)
if (!isNaN(num)) {
if (format.includes('f')) {
const match = format.match(/\.(\d+)f/)
const d = match ? parseInt(match[1]!) : 0
let str = num.toFixed(d)
if (format.includes(',')) {
str = Number(str).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d })
}
if (format.startsWith('$')) str = '$' + str
return str
}
if (format === ',') return num.toLocaleString()
}
return String(value)
}
function DataTableComponent({
data,
columns,
heatmapColumns = [],
maxHeight = 500,
loading = false,
error = null,
}: DataTableProps) {
// Auto-detect columns from first row if not provided
const effectiveColumns: ColumnDef[] = (columns && columns.length > 0)
? columns
: (data && data.length > 0)
? Object.keys(data[0]!).map(k => ({ key: k, label: k }))
: []
// Compute heatmap ranges per column
const heatmapRanges = React.useMemo(() => {
const ranges: Record<string, { min: number; max: number }> = {}
if (heatmapColumns.length > 0 && data && data.length > 0) {
for (const key of heatmapColumns) {
const values = data.map(r => Number(r[key])).filter(n => !isNaN(n))
if (values.length > 0) {
ranges[key] = { min: Math.min(...values), max: Math.max(...values) }
}
}
}
return ranges
}, [data, heatmapColumns])
function heatmapStyle(key: string, value: unknown): React.CSSProperties | undefined {
const range = heatmapRanges[key]
if (!range || range.max === range.min) return undefined
const num = Number(value)
if (isNaN(num)) return undefined
const t = (num - range.min) / (range.max - range.min)
const alpha = 0.1 + t * 0.55
return { backgroundColor: `rgba(59, 130, 246, ${alpha})` }
}
if (loading && (!data || data.length === 0)) {
return (
<Center h={200}>
<Loader size="sm" />
</Center>
)
}
if (error) {
return (
<Center h={200}>
<Text size="sm" c="red">{error.message}</Text>
</Center>
)
}
return (
<Table.ScrollContainer minWidth={0} mah={maxHeight} type="scrollarea">
<Table striped={false} highlightOnHover withTableBorder={false} withColumnBorders={false}>
<Table.Thead style={{ position: 'sticky', top: 0, zIndex: 10, backgroundColor: 'var(--mantine-color-body)' }}>
<Table.Tr>
{effectiveColumns.map(col => (
<Table.Th
key={col.key}
style={{ whiteSpace: 'nowrap' }}
fz="xs"
fw={500}
c="dimmed"
tt="uppercase"
py={6}
px="sm"
>
{col.label}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(data ?? []).map((row, i) => (
<Table.Tr key={i}>
{effectiveColumns.map(col => {
const align = col.align ?? (typeof row[col.key] === 'number' ? 'right' : 'left')
return (
<Table.Td
key={col.key}
style={{ textAlign: align, fontFamily: 'var(--mantine-font-family-monospace)', ...heatmapStyle(col.key, row[col.key]) }}
fz="xs"
py={6}
px="sm"
>
{formatCell(row[col.key], col.format)}
</Table.Td>
)
})}
</Table.Tr>
))}
</Table.Tbody>
</Table>
{(!data || data.length === 0) && (
<Center py="xl">
<Text size="sm" c="dimmed">No data</Text>
</Center>
)}
</Table.ScrollContainer>
)
}
export const DataTable = DataTableComponent
export type { DataTableProps, ColumnDef }
+145
View File
@@ -0,0 +1,145 @@
---
name: date_picker_input
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "DatePickerInput(props: DatePickerInputProps): JSX.Element"
description: "Selector de fecha con input y calendario desplegable. Soporta fecha simple, múltiple y rango. Wrapper sobre Mantine DatePickerInput."
tags: [date, picker, calendar, form, component, ui, interactive, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/dates"]
output: "Componente DatePickerInput que renderiza input con calendario para selección de fechas"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/date_picker_input.tsx"
props:
- name: type
type: "'default' | 'multiple' | 'range'"
required: false
description: "Modo de selección — fecha simple, múltiples fechas, o rango de fechas"
- name: value
type: "DateValue | DateValue[] | [DateValue, DateValue] | null"
required: false
description: "Fecha o fechas seleccionadas (controlled)"
- name: onChange
type: "(value: DateValue | DateValue[] | [DateValue, DateValue] | null) => void"
required: false
description: "Callback al cambiar la selección de fecha"
- name: valueFormat
type: "string"
required: false
description: "Formato de la fecha mostrada en el input (ej: 'DD/MM/YYYY')"
- name: clearable
type: "boolean"
required: false
description: "Permite limpiar la selección de fecha"
- name: label
type: "string"
required: false
description: "Label del campo"
- name: placeholder
type: "string"
required: false
description: "Texto cuando no hay fecha seleccionada"
- name: minDate
type: "Date"
required: false
description: "Fecha mínima seleccionable"
- name: maxDate
type: "Date"
required: false
description: "Fecha máxima seleccionable"
- name: disabled
type: "boolean"
required: false
description: "Deshabilitar el selector"
- name: size
type: "'xs' | 'sm' | 'md' | 'lg' | 'xl'"
required: false
description: "Tamaño del componente"
emits: [onChange]
has_state: true
framework: react
variant: [default]
params:
- name: props
desc: "Props del componente DatePickerInput — incluye type (modo de selección), value, onChange, valueFormat, clearable, label, placeholder, minDate, maxDate, disabled y size"
---
## Ejemplo
```tsx
import { DatePickerInput, DatePicker } from '@fn_library'
import { useState } from 'react'
// Fecha simple
function SingleDateExample() {
const [value, setValue] = useState<Date | null>(null)
return (
<DatePickerInput
label="Fecha de inicio"
placeholder="Selecciona una fecha"
value={value}
onChange={setValue}
clearable
/>
)
}
// Rango de fechas
function RangeDateExample() {
const [range, setRange] = useState<[Date | null, Date | null]>([null, null])
return (
<DatePickerInput
type="range"
label="Periodo"
placeholder="Selecciona un rango"
value={range}
onChange={setRange}
valueFormat="DD/MM/YYYY"
/>
)
}
// Múltiples fechas
function MultipleDateExample() {
const [dates, setDates] = useState<Date[]>([])
return (
<DatePickerInput
type="multiple"
label="Días seleccionados"
placeholder="Selecciona fechas"
value={dates}
onChange={setDates}
minDate={new Date()}
/>
)
}
// DatePicker inline (sin input)
function InlineDateExample() {
const [value, setValue] = useState<Date | null>(null)
return (
<DatePicker
value={value}
onChange={setValue}
/>
)
}
```
## Notas
- Wrapper directo sobre `DatePickerInput` y `DatePicker` de `@mantine/dates` v9. Todas las props de Mantine son válidas.
- Requiere importar `@mantine/dates/styles.css` — este wrapper ya lo incluye.
- El prop `type` controla el modo: `'default'` (fecha simple), `'multiple'` (varias fechas), `'range'` (rango con inicio y fin).
- `DatePicker` es el calendario inline sin input — útil para formularios donde el calendario debe estar siempre visible.
- `valueFormat` acepta tokens de dayjs (ej: `'DD/MM/YYYY'`, `'MMMM D, YYYY'`).
- Re-exporta también `DatePicker` de `@mantine/dates` con el mismo patrón de wrapper.
+17
View File
@@ -0,0 +1,17 @@
import { DatePickerInput as MantineDatePickerInput, DatePicker as MantineDatePicker } from '@mantine/dates'
import type { DatePickerInputProps as MantineDatePickerInputProps, DatePickerProps as MantineDatePickerProps } from '@mantine/dates'
import '@mantine/dates/styles.css'
interface DatePickerInputProps extends MantineDatePickerInputProps<'default'> {}
interface DatePickerProps extends MantineDatePickerProps<'default'> {}
function DatePickerInput(props: DatePickerInputProps) {
return <MantineDatePickerInput {...props} />
}
function DatePicker(props: DatePickerProps) {
return <MantineDatePicker {...props} />
}
export { DatePickerInput, DatePicker }
export type { DatePickerInputProps, DatePickerProps }
+58
View File
@@ -0,0 +1,58 @@
---
name: detail_page
kind: function
lang: ts
domain: ui
version: "1.0.0"
purity: pure
signature: "detailPage(props: DetailPageProps): ReactElement"
description: "Genera una página de detalle de entidad con header (avatar, badge, back), grid de campos, tabs con contadores y timeline de actividad."
tags: [detail, page, entity, timeline, factory, composition, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react, "@mantine/core", "@tabler/icons-react"]
params:
- name: props
desc: "Configuración de página de detalle: título, avatar, badge, tabs, timeline y campos de metadata"
output: "Componente ReactElement que renderiza página de detalle con header, grid de campos y timeline de actividad"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/detail_page.tsx"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: ""
---
## Ejemplo
```tsx
detailPage({
title: 'John Doe',
subtitle: 'john@example.com',
badge: <Badge variant="success">Active</Badge>,
onBack: () => router.back(),
fields: [
{ label: 'Role', value: 'Administrator' },
{ label: 'Created', value: 'Mar 15, 2026' },
{ label: 'Bio', value: 'Full stack developer...', span: 2 },
],
tabs: [
{ label: 'Projects', value: 'projects', count: 12, content: <ProjectList /> },
{ label: 'Activity', value: 'activity', count: 48, content: <ActivityList /> },
],
activeTab: 'projects',
timeline: [
{ id: '1', title: 'Deployed v2.1', timestamp: '2 hours ago', variant: 'success' },
{ id: '2', title: 'Updated settings', timestamp: 'Yesterday' },
{ id: '3', title: 'Created project', timestamp: 'Mar 10, 2026' },
],
})
```
## Notas
Factory completa para páginas de detalle. Combina header con back/avatar/badge, grid de metadata, tabs con badges de conteo, y timeline de actividad con variantes de color semántico.
+137
View File
@@ -0,0 +1,137 @@
import * as React from 'react'
import { Stack, Group, Title, Text, ActionIcon, Box, Tabs, Badge, Timeline, SimpleGrid } from '@mantine/core'
import { IconChevronLeft } from '@tabler/icons-react'
interface DetailField {
label: string
value: React.ReactNode
span?: 1 | 2
}
interface DetailTab {
label: string
value: string
content: React.ReactNode
count?: number
}
interface TimelineEvent {
id: string
title: string
description?: string
timestamp: string
icon?: React.ReactNode
variant?: 'default' | 'success' | 'warning' | 'error'
}
interface DetailPageProps {
title: string
subtitle?: string
badge?: React.ReactNode
avatar?: React.ReactNode
actions?: React.ReactNode
onBack?: () => void
fields: DetailField[]
tabs?: DetailTab[]
activeTab?: string
onTabChange?: (value: string) => void
timeline?: TimelineEvent[]
className?: string
}
const variantColors: Record<string, string> = {
default: 'blue',
success: 'green',
warning: 'yellow',
error: 'red',
}
export function detailPage({
title, subtitle, badge, avatar, actions, onBack,
fields, tabs, activeTab, onTabChange, timeline,
}: DetailPageProps): React.ReactElement {
return (
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" align="flex-start" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
<Group align="flex-start" gap="md">
{onBack && (
<ActionIcon variant="subtle" size="sm" onClick={onBack} mt={4}>
<IconChevronLeft size={16} />
</ActionIcon>
)}
{avatar && (
<Box
w={48}
h={48}
style={{ flexShrink: 0, overflow: 'hidden', borderRadius: '50%', backgroundColor: 'var(--mantine-color-default)' }}
>
{avatar}
</Box>
)}
<Stack gap={4}>
<Group gap="sm" align="center">
<Title order={2}>{title}</Title>
{badge}
</Group>
{subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
</Stack>
</Group>
{actions && <Group gap="xs">{actions}</Group>}
</Group>
{/* Fields grid */}
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
{fields.map((field, i) => (
<Box key={i} style={field.span === 2 ? { gridColumn: 'span 2' } : undefined}>
<Stack gap={4}>
<Text size="sm" c="dimmed">{field.label}</Text>
<Text size="sm" fw={500}>{field.value}</Text>
</Stack>
</Box>
))}
</SimpleGrid>
{/* Tabs */}
{tabs && tabs.length > 0 && (
<Stack gap="md">
<Tabs value={activeTab} onChange={(v) => v && onTabChange?.(v)}>
<Tabs.List>
{tabs.map((tab) => (
<Tabs.Tab
key={tab.value}
value={tab.value}
rightSection={tab.count !== undefined ? <Badge size="xs" variant="filled" circle>{tab.count}</Badge> : undefined}
>
{tab.label}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
{tabs.find(t => t.value === activeTab)?.content}
</Stack>
)}
{/* Timeline */}
{timeline && timeline.length > 0 && (
<Stack gap="sm">
<Text size="sm" fw={500} c="dimmed">Activity</Text>
<Timeline active={timeline.length - 1} bulletSize={12} lineWidth={2}>
{timeline.map((event) => (
<Timeline.Item
key={event.id}
color={variantColors[event.variant || 'default']}
title={<Text size="sm" fw={500}>{event.title}</Text>}
>
{event.description && <Text size="xs" c="dimmed">{event.description}</Text>}
<Text size="xs" c="dimmed" opacity={0.7}>{event.timestamp}</Text>
</Timeline.Item>
))}
</Timeline>
</Stack>
)}
</Stack>
)
}
export type { DetailPageProps, DetailField, DetailTab, TimelineEvent }
+56
View File
@@ -0,0 +1,56 @@
---
name: dialog
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Dialog(props: DialogRootProps): JSX.Element"
description: "Diálogo modal accesible con close button y sistema de slots (header, footer, title, description). Mantine Modal."
tags: [dialog, modal, overlay, component, ui, interactive, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core", react]
output: "Componente Dialog que renderiza modal accesible con focus trap y sistema de slots composables via Mantine Modal"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/dialog.tsx"
props:
- name: showCloseButton
type: "boolean"
required: false
description: "Mostrar botón de cerrar (default true)"
emits: [onOpenChange]
has_state: true
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/dialog.tsx"
---
## Ejemplo
```tsx
<Dialog>
<DialogTrigger asChild><Button>Open</Button></DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Título</DialogTitle>
<DialogDescription>Descripción</DialogDescription>
</DialogHeader>
<p>Contenido</p>
<DialogFooter>
<Button>Confirmar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
```
## Notas
10 subcomponentes exportados. Mantine Modal para accesibilidad completa (focus trap, escape, click outside). DialogPortal y DialogOverlay son no-ops mantenidos por compatibilidad.
+134
View File
@@ -0,0 +1,134 @@
import * as React from 'react'
import { Modal, Box, Text, Group } from '@mantine/core'
interface DialogProps {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}
const DialogContext = React.createContext<{
open: boolean
setOpen: (open: boolean) => void
}>({ open: false, setOpen: () => {} })
function Dialog({ open: controlledOpen, onOpenChange, children }: DialogProps) {
const [internalOpen, setInternalOpen] = React.useState(false)
const open = controlledOpen ?? internalOpen
const setOpen = React.useCallback(
(v: boolean) => {
onOpenChange?.(v)
if (controlledOpen === undefined) setInternalOpen(v)
},
[controlledOpen, onOpenChange],
)
return (
<DialogContext.Provider value={{ open, setOpen }}>
{children}
</DialogContext.Provider>
)
}
function DialogTrigger({ children, ...props }: React.ComponentProps<'button'>) {
const { setOpen } = React.useContext(DialogContext)
return (
<button type="button" data-slot="dialog-trigger" onClick={() => setOpen(true)} {...props}>
{children}
</button>
)
}
function DialogPortal({ children }: { children: React.ReactNode }) {
return <>{children}</>
}
function DialogClose({ children, ...props }: React.ComponentProps<'button'>) {
const { setOpen } = React.useContext(DialogContext)
return (
<button type="button" data-slot="dialog-close" onClick={() => setOpen(false)} {...props}>
{children}
</button>
)
}
function DialogOverlay() {
return null
}
function DialogContent({
children,
showCloseButton = true,
className,
...props
}: React.ComponentProps<'div'> & { showCloseButton?: boolean }) {
const { open, setOpen } = React.useContext(DialogContext)
return (
<Modal
opened={open}
onClose={() => setOpen(false)}
withCloseButton={showCloseButton}
radius="md"
padding="md"
size="sm"
centered
data-slot="dialog-content"
className={className}
{...props}
>
{children}
</Modal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <Box data-slot="dialog-header" mb="xs" className={className} {...props} />
}
function DialogFooter({ className, children, ...props }: React.ComponentProps<'div'>) {
return (
<Group
data-slot="dialog-footer"
justify="flex-end"
gap="sm"
mt="md"
pt="sm"
style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}
className={className}
{...props}
>
{children}
</Group>
)
}
function DialogTitle({ className, children, ...props }: React.ComponentProps<'div'>) {
return (
<Text
component="div"
data-slot="dialog-title"
fw={500}
size="md"
className={className}
{...props}
>
{children}
</Text>
)
}
function DialogDescription({ className, children, ...props }: React.ComponentProps<'div'>) {
return (
<Text
component="div"
data-slot="dialog-description"
size="sm"
c="dimmed"
className={className}
{...props}
>
{children}
</Text>
)
}
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger }
+74
View File
@@ -0,0 +1,74 @@
---
name: dropdown_menu
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "DropdownMenu(props: DropdownMenuProps): JSX.Element"
description: "Menu de acciones y contexto accesible con items, checkboxes, radios, separadores y submenus. Base-UI Menu primitive."
tags: [dropdown, menu, component, ui, interactive, overlay, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
output: "Componente DropdownMenu que renderiza menú desplegable accesible con items, checkboxes, radios y submenus"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/dropdown_menu.tsx"
props:
- name: open
type: "boolean"
required: false
description: "Estado controlado de apertura"
- name: defaultOpen
type: "boolean"
required: false
description: "Estado inicial de apertura"
- name: onOpenChange
type: "(open: boolean) => void"
required: false
description: "Callback cuando cambia el estado"
- name: modal
type: "boolean"
required: false
description: "Comportamiento modal (default: true)"
emits: [onOpenChange]
has_state: false
framework: react
variant: []
---
## Ejemplo
```tsx
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">Acciones</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onActivate={() => console.log("Perfil")}>
Perfil
</DropdownMenuItem>
<DropdownMenuCheckboxItem checked={showBookmarks} onCheckedChange={setShowBookmarks}>
Marcadores
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>Mas opciones</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Opcion A</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
```
## Notas
Exports: DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuPortal.
+139
View File
@@ -0,0 +1,139 @@
import * as React from 'react'
import { Menu, Text } from '@mantine/core'
function DropdownMenu({ children, ...props }: { children: React.ReactNode; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; modal?: boolean }) {
return (
<Menu
opened={props.open}
defaultOpened={props.defaultOpen}
onChange={props.onOpenChange}
withinPortal
shadow="md"
>
{children}
</Menu>
)
}
function DropdownMenuTrigger({ children, ...props }: { children: React.ReactNode; asChild?: boolean; className?: string }) {
return <Menu.Target {...props}>{children}</Menu.Target>
}
function DropdownMenuPortal({ children }: { children?: React.ReactNode }) {
return <>{children}</>
}
function DropdownMenuContent({ children, className }: { children?: React.ReactNode; className?: string; sideOffset?: number }) {
return <Menu.Dropdown className={className}>{children}</Menu.Dropdown>
}
function DropdownMenuItem({ children, className, inset, ...props }: {
children?: React.ReactNode
className?: string
inset?: boolean
onClick?: () => void
onActivate?: () => void
disabled?: boolean
}) {
return (
<Menu.Item
className={className}
onClick={props.onClick ?? props.onActivate}
disabled={props.disabled}
pl={inset ? 'xl' : undefined}
>
{children}
</Menu.Item>
)
}
function DropdownMenuCheckboxItem({ children, className, checked, onCheckedChange, ...props }: {
children?: React.ReactNode
className?: string
checked?: boolean
onCheckedChange?: (checked: boolean) => void
disabled?: boolean
}) {
return (
<Menu.Item
className={className}
onClick={() => onCheckedChange?.(!checked)}
disabled={props.disabled}
leftSection={checked ? <span style={{ fontSize: 14 }}></span> : <span style={{ width: 14 }} />}
>
{children}
</Menu.Item>
)
}
function DropdownMenuRadioGroup({ children }: { children?: React.ReactNode; value?: string; onValueChange?: (value: string) => void }) {
return <>{children}</>
}
function DropdownMenuRadioItem({ children, className, value, ...props }: {
children?: React.ReactNode
className?: string
value?: string
disabled?: boolean
onClick?: () => void
}) {
return (
<Menu.Item className={className} onClick={props.onClick} disabled={props.disabled}>
{children}
</Menu.Item>
)
}
function DropdownMenuGroup({ children }: { children?: React.ReactNode }) {
return <>{children}</>
}
function DropdownMenuLabel({ children, className, inset }: { children?: React.ReactNode; className?: string; inset?: boolean }) {
return (
<Menu.Label className={className} pl={inset ? 'xl' : undefined}>
{children}
</Menu.Label>
)
}
function DropdownMenuSeparator({ className }: { className?: string }) {
return <Menu.Divider className={className} />
}
function DropdownMenuShortcut({ children, className }: { children?: React.ReactNode; className?: string }) {
return <Text span size="xs" c="dimmed" ml="auto" className={className}>{children}</Text>
}
function DropdownMenuSub({ children }: { children?: React.ReactNode }) {
return <>{children}</>
}
function DropdownMenuSubTrigger({ children, className, inset }: { children?: React.ReactNode; className?: string; inset?: boolean }) {
return (
<Menu.Item className={className} pl={inset ? 'xl' : undefined}>
{children}
</Menu.Item>
)
}
function DropdownMenuSubContent({ children, className }: { children?: React.ReactNode; className?: string }) {
return <Menu.Dropdown className={className}>{children}</Menu.Dropdown>
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}
+118
View File
@@ -0,0 +1,118 @@
---
name: dropzone
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Dropzone(props: DropzoneProps): JSX.Element"
description: "Zona de drag-and-drop para archivos con estados idle/accept/reject, límite de tamaño y tipos MIME. Wrapper sobre Mantine Dropzone."
tags: [dropzone, upload, drag-drop, file, component, ui, interactive, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/dropzone"]
output: "Componente Dropzone que renderiza área de arrastrar y soltar archivos con feedback visual"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/dropzone.tsx"
props:
- name: onDrop
type: "(files: File[]) => void"
required: false
description: "Callback ejecutado cuando el usuario suelta archivos aceptados"
- name: onReject
type: "(files: FileRejection[]) => void"
required: false
description: "Callback ejecutado cuando el usuario suelta archivos rechazados (tipo o tamaño inválido)"
- name: accept
type: "Record<string, string[]>"
required: false
description: "Tipos MIME aceptados. Usar IMAGE_MIME_TYPE o MIME_TYPES para constantes predefinidas"
- name: maxSize
type: "number"
required: false
description: "Tamaño máximo de archivo en bytes"
- name: multiple
type: "boolean"
required: false
description: "Permite seleccionar múltiples archivos a la vez"
- name: loading
type: "boolean"
required: false
description: "Muestra estado de carga y desactiva la interacción"
- name: disabled
type: "boolean"
required: false
description: "Desactiva el dropzone"
- name: children
type: "React.ReactNode"
required: true
description: "Contenido interno, generalmente compuesto con DropzoneAccept, DropzoneReject y DropzoneIdle"
emits: [onDrop, onReject]
has_state: true
framework: react
variant: []
---
## Ejemplo
```tsx
import {
Dropzone,
DropzoneAccept,
DropzoneReject,
DropzoneIdle,
IMAGE_MIME_TYPE,
} from '@fn_library/dropzone'
import { IconPhoto, IconUpload, IconX } from '@tabler/icons-react'
import { Group, Text } from '@mantine/core'
function ImageUploader() {
return (
<Dropzone
onDrop={(files) => console.log('Archivos aceptados:', files)}
onReject={(files) => console.log('Archivos rechazados:', files)}
accept={IMAGE_MIME_TYPE}
maxSize={5 * 1024 ** 2}
>
<Group justify="center" gap="xl" mih={120} style={{ pointerEvents: 'none' }}>
<DropzoneAccept>
<IconUpload size={52} stroke={1.5} />
</DropzoneAccept>
<DropzoneReject>
<IconX size={52} stroke={1.5} />
</DropzoneReject>
<DropzoneIdle>
<IconPhoto size={52} stroke={1.5} />
</DropzoneIdle>
<div>
<Text size="xl" inline>
Arrastra imágenes aquí o haz clic para seleccionar
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
Máximo 5 MB por imagen
</Text>
</div>
</Group>
</Dropzone>
)
}
```
## Notas
El prop `onDrop` tiene un default vacío (`() => {}`) para que el componente sea válido sin handler. Siempre sobreescribirlo en uso real.
Sub-componentes exportados:
- `DropzoneAccept` — visible cuando el archivo arrastrado es aceptado (tipo y tamaño válidos)
- `DropzoneReject` — visible cuando el archivo es rechazado
- `DropzoneIdle` — visible en estado de reposo
- `DropzoneFullScreen` — captura drops en cualquier parte de la pantalla
Constantes de tipos MIME exportadas:
- `IMAGE_MIME_TYPE` — imágenes comunes (png, jpg, gif, webp, etc.)
- `MIME_TYPES` — objeto con claves por tipo (pdf, csv, xlsx, mp4, etc.)
+20
View File
@@ -0,0 +1,20 @@
import { Dropzone as MantineDropzone, IMAGE_MIME_TYPE, MIME_TYPES } from '@mantine/dropzone'
import type { DropzoneProps as MantineDropzoneProps } from '@mantine/dropzone'
import '@mantine/dropzone/styles.css'
interface DropzoneProps extends Partial<MantineDropzoneProps> {
children: React.ReactNode
}
function Dropzone({ children, ...props }: DropzoneProps) {
return <MantineDropzone onDrop={() => {}} {...props}>{children}</MantineDropzone>
}
// Re-export sub-components and constants
const DropzoneAccept = MantineDropzone.Accept
const DropzoneReject = MantineDropzone.Reject
const DropzoneIdle = MantineDropzone.Idle
const DropzoneFullScreen = MantineDropzone.FullScreen
export { Dropzone, DropzoneAccept, DropzoneReject, DropzoneIdle, DropzoneFullScreen, IMAGE_MIME_TYPE, MIME_TYPES }
export type { DropzoneProps }
+103
View File
@@ -0,0 +1,103 @@
---
name: empty_state
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "EmptyState(props: EmptyStateProps): JSX.Element"
description: "Placeholder para listas y tablas vacías con icono, título, descripción y acción opcional. Tabler Icons por defecto."
tags: [empty-state, placeholder, no-data, component, ui, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core", "@tabler/icons-react"]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/empty_state.tsx"
framework: react
has_state: false
emits: [onAction]
props:
- name: icon
type: "React.ReactNode"
required: false
description: "Icono a mostrar. Default: IconInbox de @tabler/icons-react."
- name: title
type: "string"
required: false
description: "Título del empty state. Default: 'No data found'."
- name: description
type: "string"
required: false
description: "Descripción explicativa. Default: 'There are no items to display yet.'."
- name: actionLabel
type: "string"
required: false
description: "Texto del botón de acción. Se muestra solo si también hay onAction."
- name: onAction
type: "() => void"
required: false
description: "Callback del botón de acción. Se muestra solo si también hay actionLabel."
- name: size
type: "MantineSize"
required: false
description: "Tamaño general del componente. Afecta el icono, texto y botón. Default: 'md'."
- name: children
type: "React.ReactNode"
required: false
description: "Contenido custom renderizado debajo de la descripción y antes del botón."
output: "Componente EmptyState centrado con icono, mensaje y botón de acción para estados sin datos"
params:
- name: props
desc: "Props del componente EmptyState"
---
## Ejemplo
```tsx
import { EmptyState } from '@fn_library/empty_state'
// Default — sin datos
<EmptyState />
// Con acción
<EmptyState
title="No functions found"
description="Try adjusting your search or create a new function."
actionLabel="Create function"
onAction={() => navigate('/new')}
/>
// Con icono custom
import { IconDatabase } from '@tabler/icons-react'
<EmptyState
icon={<IconDatabase size={48} stroke={1.5} />}
title="No databases connected"
description="Connect a database to start querying data."
size="lg"
/>
// Dentro de una Card
import { Card } from '@mantine/core'
import { EmptyState } from '@fn_library/empty_state'
<Card withBorder p="xl">
<EmptyState
title="No results"
description="Your query returned no rows."
size="sm"
/>
</Card>
```
## Notas
El tamaño del icono escala con `size`: xs=32, sm=40, md=48, lg=64, xl=80.
El orden del heading (`Title order`) es 5 para xs/sm y 4 para md/lg/xl.
El botón usa `variant="light"` de Mantine — hereda el color primario del tema.
`children` se renderiza entre la descripción y el botón, útil para filtros o links adicionales.
+55
View File
@@ -0,0 +1,55 @@
import * as React from 'react'
import { Button, Stack, Text, Title, type MantineSize } from '@mantine/core'
import { IconInbox } from '@tabler/icons-react'
interface EmptyStateProps {
/** Icono a mostrar (default: IconInbox) */
icon?: React.ReactNode
/** Título */
title?: string
/** Descripción */
description?: string
/** Texto del botón de acción */
actionLabel?: string
/** Callback del botón */
onAction?: () => void
/** Tamaño general */
size?: MantineSize
/** Contenido custom debajo de la descripción */
children?: React.ReactNode
}
function EmptyState({
icon,
title = 'No data found',
description = 'There are no items to display yet.',
actionLabel,
onAction,
size = 'md',
children,
}: EmptyStateProps) {
const iconSize = size === 'xs' ? 32 : size === 'sm' ? 40 : size === 'lg' ? 64 : size === 'xl' ? 80 : 48
return (
<Stack align="center" gap="sm" py="xl">
<Text c="dimmed" style={{ opacity: 0.5 }}>
{icon || <IconInbox size={iconSize} stroke={1.5} />}
</Text>
<Title order={size === 'xs' || size === 'sm' ? 5 : 4} ta="center">
{title}
</Title>
<Text c="dimmed" size={size} ta="center" maw={400}>
{description}
</Text>
{children}
{actionLabel && onAction && (
<Button variant="light" size={size} onClick={onAction} mt="xs">
{actionLabel}
</Button>
)}
</Stack>
)
}
export { EmptyState }
export type { EmptyStateProps }
+75
View File
@@ -0,0 +1,75 @@
---
name: error_page
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "ErrorPage(config: ErrorPageConfig): JSX.Element"
description: "Genera página de error con código grande, título, descripción y acciones. Soporta 404, 500, 403 y cualquier código custom."
tags: [error, 404, 500, page, empty-state, ui, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react, "@mantine/core"]
params:
- name: code
desc: "Código de error numérico o string a mostrar prominentemente (404, 500, 403, o cualquier valor custom)"
- name: title
desc: "Título del error mostrado bajo el código. Default: 'Page not found'"
- name: description
desc: "Descripción explicativa del error. Default: mensaje genérico de página no encontrada"
- name: actionLabel
desc: "Texto del botón de acción principal. Default: 'Go back to home'"
- name: onAction
desc: "Callback invocado al pulsar el botón de acción principal"
- name: extraActions
desc: "Nodos React adicionales renderizados junto al botón principal (botones secundarios, links, etc.)"
output: "Página de error centrada con código prominente, mensaje descriptivo y botones de acción"
has_state: false
framework: react
emits: [onAction]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/error_page.tsx"
---
## Ejemplo
```tsx
import { ErrorPage } from '@fn_library/error_page'
import { Button } from '@mantine/core'
// 404 con defaults
<ErrorPage onAction={() => navigate('/')} />
// 500 custom
<ErrorPage
code={500}
title="Internal Server Error"
description="Something went wrong on our end. Please try again later or contact support."
actionLabel="Retry"
onAction={() => window.location.reload()}
/>
// 403 con acciones extra
<ErrorPage
code={403}
title="Access Denied"
description="You don't have permission to view this page. Contact your administrator to request access."
actionLabel="Go to Dashboard"
onAction={() => navigate('/dashboard')}
extraActions={
<Button variant="outline" size="md" onClick={() => navigate('/login')}>
Switch Account
</Button>
}
/>
```
## Notas
El código de error se muestra con `fz={120}` y opacidad reducida (0.25) para crear un efecto visual de fondo sin distraer del mensaje. Acepta `number | string` para soportar códigos custom como "503" o "Maintenance".
+56
View File
@@ -0,0 +1,56 @@
import * as React from 'react'
import { Button, Container, Group, Stack, Text, Title } from '@mantine/core'
interface ErrorPageConfig {
/** Código de error (404, 500, 403, etc.) */
code?: number | string
/** Título del error */
title?: string
/** Descripción del error */
description?: string
/** Texto del botón de acción */
actionLabel?: string
/** Callback del botón */
onAction?: () => void
/** Acciones extra además del botón principal */
extraActions?: React.ReactNode
}
function ErrorPage({
code = 404,
title = 'Page not found',
description = 'The page you are looking for does not exist. You may have mistyped the address, or the page has been moved to another URL.',
actionLabel = 'Go back to home',
onAction,
extraActions,
}: ErrorPageConfig) {
return (
<Container py={80}>
<Stack align="center" gap="md">
<Text
fz={120}
fw={900}
c="dimmed"
style={{ lineHeight: 1, opacity: 0.25 }}
>
{code}
</Text>
<Title order={2} ta="center">
{title}
</Title>
<Text c="dimmed" size="lg" ta="center" maw={500}>
{description}
</Text>
<Group mt="md">
<Button size="md" onClick={onAction}>
{actionLabel}
</Button>
{extraActions}
</Group>
</Stack>
</Container>
)
}
export { ErrorPage }
export type { ErrorPageConfig }
+102
View File
@@ -0,0 +1,102 @@
---
name: file_input
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "FileInput(props: FileInputProps): JSX.Element"
description: "Input de archivos con soporte para múltiples archivos, tipos aceptados y botón de limpiar. Wrapper sobre Mantine FileInput."
tags: [file, upload, input, form, component, ui, interactive, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/file_input.tsx"
framework: react
has_state: true
emits: [onChange]
props:
- name: multiple
type: "boolean"
required: false
description: "Permite seleccionar múltiples archivos"
- name: accept
type: "string"
required: false
description: "Tipos MIME o extensiones aceptadas (ej: 'image/*', '.pdf,.docx')"
- name: clearable
type: "boolean"
required: false
description: "Muestra botón para limpiar el archivo seleccionado"
- name: value
type: "File | File[] | null"
required: false
description: "Valor controlado del input"
- name: onChange
type: "(value: File | File[] | null) => void"
required: false
description: "Callback que se dispara al seleccionar o limpiar un archivo"
- name: placeholder
type: "string"
required: false
description: "Texto mostrado cuando no hay archivo seleccionado"
- name: label
type: "string"
required: false
description: "Etiqueta visible sobre el input"
- name: disabled
type: "boolean"
required: false
description: "Deshabilita el input"
- name: size
type: "'xs' | 'sm' | 'md' | 'lg' | 'xl'"
required: false
description: "Tamaño del componente"
params:
- name: props
desc: "Props de FileInput: archivo(s) seleccionado(s), tipos aceptados, modo múltiple, estado controlado y apariencia"
output: "Componente FileInput que renderiza input para selección de archivos con preview del nombre"
---
## Ejemplo
```tsx
import { FileInput } from '@fn_library'
// Archivo único
<FileInput
label="Subir documento"
placeholder="Selecciona un archivo"
clearable
/>
// Múltiples archivos
<FileInput
label="Subir archivos"
placeholder="Selecciona uno o varios archivos"
multiple
clearable
/>
// Solo imágenes
<FileInput
label="Subir imagen"
placeholder="Selecciona una imagen"
accept="image/*"
clearable
/>
```
## Notas
Wrapper directo sobre `FileInput` de `@mantine/core`. Acepta todas las props de Mantine sin restricciones.
Para `multiple: true`, el tipo de `value` y `onChange` cambia a `File[] | null` automáticamente gracias al tipado genérico de Mantine.
El prop `clearable` añade un ícono de X que permite vaciar la selección sin reabrir el explorador de archivos.
+10
View File
@@ -0,0 +1,10 @@
import { FileInput as MantineFileInput, type FileInputProps as MantineFileInputProps } from '@mantine/core'
interface FileInputProps extends MantineFileInputProps {}
function FileInput(props: FileInputProps) {
return <MantineFileInput {...props} />
}
export { FileInput }
export type { FileInputProps }
+54
View File
@@ -0,0 +1,54 @@
---
name: form_field
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "FormField(props: FormFieldProps): JSX.Element"
description: "Wrapper de campo de formulario con label, helper text, error y ARIA automáticos. Inyecta id y aria-describedby a hijos."
tags: [form, field, label, error, component, ui, accessibility]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
output: "Componente FormField que renderiza wrapper de campo con label, helper text, error y ARIA automáticos"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/form_field.tsx"
props:
- name: label
type: "string"
required: false
description: "Texto del label"
- name: helperText
type: "string"
required: false
description: "Texto de ayuda"
- name: error
type: "string"
required: false
description: "Mensaje de error (reemplaza helperText)"
- name: children
type: "ReactNode"
required: true
description: "Input o componente de formulario"
emits: []
has_state: false
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/form-field.tsx"
---
## Ejemplo
```tsx
<FormField label="Email" helperText="Tu email corporativo" error={errors.email}>
<Input type="email" />
</FormField>
```
+55
View File
@@ -0,0 +1,55 @@
import * as React from 'react'
import { Box, Text } from '@mantine/core'
interface FormFieldProps {
label?: string
helperText?: string
error?: string
children: React.ReactNode
className?: string
}
function FormField({ label, helperText, error, children, className }: FormFieldProps) {
const id = React.useId()
const inputId = `${id}-input`
const helperId = `${id}-helper`
const errorId = `${id}-error`
const describedBy = [helperText ? helperId : null, error ? errorId : null].filter(Boolean).join(' ') || undefined
const childWithProps = React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
return React.cloneElement(child as React.ReactElement<Record<string, unknown>>, {
id: inputId,
'aria-invalid': error ? true : undefined,
'aria-describedby': describedBy,
error: error || undefined,
})
}
return child
})
return (
<Box className={className}>
{label && (
<Text component="label" htmlFor={inputId} size="sm" fw={500} mb={4} display="block">
{label}
</Text>
)}
{childWithProps}
{helperText && !error && (
<Text id={helperId} size="sm" c="dimmed" mt={4}>
{helperText}
</Text>
)}
{error && (
<Text id={errorId} size="sm" c="red" mt={4}>
{error}
</Text>
)}
</Box>
)
}
export { FormField }
export type { FormFieldProps }
+104
View File
@@ -0,0 +1,104 @@
---
name: graph_container
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "GraphContainer(props: GraphContainerProps): JSX.Element"
description: "Interactive graph visualization with sigma.js, graphology, and ForceAtlas2 layout"
tags: [component, ui, graph, visualization, sigma, graphology, network]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["graphology", "graphology-layout-forceatlas2", "sigma"]
output: "Componente GraphContainer que renderiza grafo interactivo con sigma.js, ForceAtlas2 layout y legend"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/graph/index.tsx"
props:
- name: data
type: "GraphData"
required: true
description: "Graph data with nodes and edges arrays"
- name: layout
type: "'organic' | 'random'"
required: false
description: "Layout algorithm (default: organic/ForceAtlas2)"
- name: showLegend
type: "boolean"
required: false
description: "Show node type legend overlay"
- name: nodeTypes
type: "NodeType[]"
required: false
description: "Node type definitions for legend"
- name: onNodeClick
type: "(node: GraphNode) => void"
required: false
description: "Node click handler"
- name: onNodeDoubleClick
type: "(node: GraphNode) => void"
required: false
description: "Node double-click handler"
- name: theme
type: "GraphTheme"
required: false
description: "Visual theme overrides"
- name: height
type: "string | number"
required: false
description: "Container height (default: 100%)"
- name: className
type: "string"
required: false
description: "Additional CSS classes"
emits: []
has_state: true
framework: react
variant: []
---
## Ejemplo
```tsx
import { GraphContainer } from '@fn_library/graph'
import type { GraphData } from '@fn_library/graph'
const data: GraphData = {
nodes: [
{ id: '1', label: 'Node A', color: '#e74c3c', size: 10 },
{ id: '2', label: 'Node B', color: '#3498db', size: 8 },
],
edges: [
{ id: 'e1', source: '1', target: '2', label: 'connects', type: 'arrow' },
],
}
function MyGraph() {
return (
<GraphContainer
data={data}
layout="organic"
showLegend
nodeTypes={[
{ type: 'person', color: '#e74c3c', label: 'Person' },
{ type: 'org', color: '#3498db', label: 'Organization' },
]}
onNodeClick={(node) => console.log('clicked:', node.id)}
height="500px"
/>
)
}
```
## Notas
- Usa graphology como estructura de datos de grafo
- ForceAtlas2 para layout organico (iterations adaptativas segun numero de nodos)
- Sigma.js para renderizado WebGL de alto rendimiento
- Soporta grafos dirigidos multi-edge
- El componente limpia la instancia Sigma al desmontar
+283
View File
@@ -0,0 +1,283 @@
import * as React from "react"
import Graph from "graphology"
import forceAtlas2 from "graphology-layout-forceatlas2"
import { Sigma } from "sigma"
// ── Types ─────────────────────────────────────────────────────────────────
export interface GraphNode {
id: string
label: string
type?: string
color?: string
size?: number
x?: number
y?: number
[key: string]: unknown
}
export interface GraphEdge {
id: string
source: string
target: string
label?: string
color?: string
size?: number
type?: "arrow" | "line"
weight?: number
[key: string]: unknown
}
export interface GraphData {
nodes: GraphNode[]
edges: GraphEdge[]
}
export interface NodeType {
type: string
color: string
label: string
}
export interface GraphTheme {
backgroundColor?: string
nodeColor?: string
nodeSize?: number
edgeColor?: string
edgeSize?: number
labelColor?: string
selectionColor?: string
}
export interface ContextMenuTarget {
type: "node" | "edge" | "canvas"
id?: string
data?: GraphNode | GraphEdge
}
export interface GraphContainerProps {
data: GraphData
layout?: "organic" | "random"
showToolbar?: boolean
showLegend?: boolean
showMinimap?: boolean
nodeTypes?: NodeType[]
onNodeClick?: (node: GraphNode) => void
onNodeDoubleClick?: (node: GraphNode) => void
onContextMenu?: (event: MouseEvent, target: ContextMenuTarget) => void
enableSelection?: boolean
selectionMode?: "single" | "multiple"
theme?: GraphTheme
height?: string | number
className?: string
}
const DEFAULT_THEME: Required<GraphTheme> = {
backgroundColor: "var(--background, #0a0a0f)",
nodeColor: "#95a5a6",
nodeSize: 8,
edgeColor: "rgba(255,255,255,0.19)",
edgeSize: 1,
labelColor: "#e0e0e0",
selectionColor: "#3b82f6",
}
// ── Component ─────────────────────────────────────────────────────────────
function GraphContainer({
data,
layout = "organic",
showLegend = false,
nodeTypes = [],
onNodeClick,
onNodeDoubleClick,
onContextMenu,
theme: themeProp,
height = "100%",
className,
}: GraphContainerProps) {
const containerRef = React.useRef<HTMLDivElement>(null)
const sigmaRef = React.useRef<Sigma | null>(null)
const graphRef = React.useRef<Graph | null>(null)
const theme = React.useMemo(
() => ({ ...DEFAULT_THEME, ...themeProp }),
[themeProp],
)
// Build + render — wait for container to have dimensions
const [ready, setReady] = React.useState(false)
React.useEffect(() => {
const el = containerRef.current
if (!el) return
if (el.clientHeight > 0 && el.clientWidth > 0) {
setReady(true)
return
}
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentRect.height > 0 && entry.contentRect.width > 0) {
setReady(true)
ro.disconnect()
}
}
})
ro.observe(el)
return () => ro.disconnect()
}, [])
React.useEffect(() => {
const el = containerRef.current
if (!el || !ready) return
// Cleanup previous instance
if (sigmaRef.current) {
sigmaRef.current.kill()
sigmaRef.current = null
}
const g = new Graph({ multi: true, type: "directed" })
graphRef.current = g
// Add nodes — store entity type as entityType to avoid sigma interpreting it as render program
for (const n of data.nodes) {
g.addNode(n.id, {
label: n.label,
x: n.x ?? (Math.random() - 0.5) * 10,
y: n.y ?? (Math.random() - 0.5) * 10,
size: n.size ?? theme.nodeSize,
color: n.color ?? theme.nodeColor,
entityType: n.type,
})
}
// Add edges
for (const e of data.edges) {
try {
g.addEdgeWithKey(e.id, e.source, e.target, {
label: e.label,
size: e.size ?? theme.edgeSize,
color: e.color ?? theme.edgeColor,
type: e.type === "arrow" ? "arrow" : "line",
weight: e.weight ?? 1,
})
} catch {
// skip duplicate keys
}
}
// Layout
if (layout === "organic" && g.order > 0) {
forceAtlas2.assign(g, {
iterations: Math.min(500, Math.max(100, g.order * 5)),
settings: {
gravity: 1,
scalingRatio: 2,
slowDown: 5,
barnesHutOptimize: g.order > 300,
},
})
}
// Render
const renderer = new Sigma(g, el, {
allowInvalidContainer: true,
renderEdgeLabels: false,
defaultEdgeColor: theme.edgeColor,
defaultNodeColor: theme.nodeColor,
labelColor: { color: theme.labelColor },
labelSize: 11,
})
sigmaRef.current = renderer
// Events
if (onNodeClick) {
renderer.on("clickNode", ({ node }) => {
const attrs = g.getNodeAttributes(node)
onNodeClick({ id: node, ...attrs } as unknown as GraphNode)
})
}
if (onNodeDoubleClick) {
renderer.on("doubleClickNode", ({ node }) => {
const attrs = g.getNodeAttributes(node)
onNodeDoubleClick({ id: node, ...attrs } as unknown as GraphNode)
})
}
if (onContextMenu) {
renderer.on("rightClickNode", ({ node, event }) => {
const mouseEvent = event.original as MouseEvent
mouseEvent.preventDefault()
const attrs = g.getNodeAttributes(node)
onContextMenu(mouseEvent, {
type: "node",
id: node,
data: { id: node, ...attrs } as unknown as GraphNode,
})
})
renderer.on("rightClickStage", ({ event }) => {
const mouseEvent = event.original as MouseEvent
mouseEvent.preventDefault()
onContextMenu(mouseEvent, { type: "canvas" })
})
}
return () => {
renderer.kill()
sigmaRef.current = null
graphRef.current = null
}
}, [data, layout, theme, onNodeClick, onNodeDoubleClick, onContextMenu, ready])
// Container background
const containerStyle: React.CSSProperties = {
height,
width: "100%",
position: "relative",
background: theme.backgroundColor,
borderRadius: "var(--radius, 0.5rem)",
overflow: "hidden",
}
return (
<div className={className} style={containerStyle}>
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
{showLegend && nodeTypes.length > 0 && (
<div
style={{
position: "absolute",
top: 12,
right: 12,
background: "rgba(0,0,0,0.7)",
backdropFilter: "blur(6px)",
borderRadius: 8,
padding: "10px 14px",
fontSize: 12,
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
{nodeTypes.map((nt) => (
<div
key={nt.type}
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<span
style={{
width: 10,
height: 10,
borderRadius: "50%",
background: nt.color,
flexShrink: 0,
}}
/>
<span style={{ color: theme.labelColor }}>{nt.label}</span>
</div>
))}
</div>
)}
</div>
)
}
export { GraphContainer }
+136
View File
@@ -0,0 +1,136 @@
// Barrel export — @fn_library
// Primitives
export { Alert, AlertTitle, AlertDescription } from './alert'
export { Badge, badgeVariants } from './badge'
export { Button, buttonVariants } from './button'
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent } from './card'
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from './dialog'
export { Input, InputGroup, InputIcon } from './input'
export { Label } from './label'
export { KPICard } from './kpi_card'
export { Select } from './select'
export type { SelectProps } from './select'
export { SimpleSelect } from './simple_select'
export type { SimpleSelectOption, SimpleSelectGroup, SimpleSelectOptions } from './simple_select'
export { Skeleton, SkeletonAvatar, SkeletonButton, SkeletonCard, SkeletonTable, SkeletonText } from './skeleton'
export { Sparkline } from './sparkline'
export type { SparklineProps, SparklineVariant } from './sparkline'
export { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs'
export { Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger } from './tooltip'
export { FormField } from './form_field'
export type { FormFieldProps } from './form_field'
export { PageHeader } from './page_header'
export { ProgressBar } from './progress_bar'
// Charts
export { AreaChart } from './area_chart'
export type { AreaChartProps, GradientConfig } from './area_chart'
export { BarChart } from './bar_chart'
export type { BarChartProps } from './bar_chart'
export { LineChart } from './line_chart'
export type { LineChartProps, CurveType } from './line_chart'
export { PieChart } from './pie_chart'
export type { PieChartProps } from './pie_chart'
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, getSeriesColor } from './chart_container'
export type { Series } from './chart_container'
// Data
export { DataTable } from './data_table'
export type { DataTableProps, ColumnDef } from './data_table'
// Mantine Provider
export { FnMantineProvider } from './mantine_provider'
export type { FnMantineProviderProps } from './mantine_provider'
// Page templates
export { analyticsPage } from './analytics_page'
export type { AnalyticsPageProps, MetricConfig, ChartConfig } from './analytics_page'
export { crudPage } from './crud_page'
export type { CrudPageProps, CrudField } from './crud_page'
export { dashboardLayout } from './dashboard_layout'
export type { DashboardWidget, DashboardLayoutProps } from './dashboard_layout'
export { detailPage } from './detail_page'
export type { DetailPageProps, DetailField, DetailTab, TimelineEvent } from './detail_page'
export { settingsPage } from './settings_page'
export type { SettingsPageProps, SettingSection, SettingField } from './settings_page'
// Hooks — Wails
export { useWailsQuery } from './use_wails_query'
export type { UseWailsQueryOptions, UseWailsQueryResult } from './use_wails_query'
export { useWailsMutation } from './use_wails_mutation'
export type { UseWailsMutationOptions, UseWailsMutationResult } from './use_wails_mutation'
export { useWailsStream, useWailsLogs } from './use_wails_stream'
export type { UseWailsStreamOptions, UseWailsStreamResult } from './use_wails_stream'
export { useWailsEvent, useWailsEmit } from './use_wails_event'
export type { UseWailsEventOptions, UseWailsEventResult } from './use_wails_event'
// Accordion
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './accordion'
export type { AccordionProps } from './accordion'
// Avatar
export { Avatar, avatarVariants } from './avatar'
export type { AvatarProps } from './avatar'
// Breadcrumb
export { Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from './breadcrumb'
// Checkbox
export { Checkbox } from './checkbox'
export type { CheckboxProps } from './checkbox'
// Command
export { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSearch, CommandSeparator, CommandShortcut } from './command'
export type { CommandItemData, CommandProps } from './command'
// Dropdown Menu
export { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from './dropdown_menu'
// Pagination
export { Pagination } from './pagination'
export type { PaginationProps } from './pagination'
// Popover
export { Popover, PopoverClose, PopoverContent, PopoverDescription, PopoverHeader, PopoverPortal, PopoverTitle, PopoverTrigger } from './popover'
// Radio Group
export { RadioGroup, RadioGroupItem } from './radio_group'
export type { RadioGroupItemProps } from './radio_group'
// Sheet
export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, sheetVariants } from './sheet'
export type { SheetContentProps } from './sheet'
// Switch
export { SwitchToggle } from './switch_toggle'
export type { SwitchToggleProps } from './switch_toggle'
// Textarea
export { Textarea } from './textarea'
export type { TextareaProps } from './textarea'
// Toast
export { Toast, ToastProvider, ToastViewport, toastVariants, useToast } from './toast'
export type { ToastEntry, ToastProps, ToastViewportProps } from './toast'
// Search
export { SearchBar } from './search_bar'
export type { SearchBarProps } from './search_bar'
// Hooks — Canvas
export { useAnimatedCanvas } from './use_animated_canvas'
// Wails Provider
export { WailsProvider } from './wails_provider'
// New Mantine components
export { FnAppShell } from './app_shell'
export { FnStepper } from './stepper'
export { FnTimeline } from './timeline'
export { FnActionIcon } from './action_icon'
export { FnNumberInput } from './number_input'
export { FnSegmentedControl } from './segmented_control'
export { FnLoadingOverlay } from './loading_overlay'
export { FnRingProgress } from './ring_progress'
export { FnNavLink } from './nav_link'
export { FnIndicator } from './indicator'
+77
View File
@@ -0,0 +1,77 @@
---
name: indicator
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "FnIndicator(props: FnIndicatorProps): JSX.Element"
description: "Badge indicador posicionado sobre un elemento hijo. Wrapper sobre Mantine Indicator."
tags: [mantine, indicator, badge, notification, dot, component, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
framework: react
props:
- name: children
type: "ReactNode"
required: true
description: "Elemento sobre el cual se posiciona el indicador"
- name: color
type: "MantineColor"
required: false
description: "Color del indicador, default red"
- name: size
type: "number"
required: false
description: "Tamano del dot en px, default 10"
- name: position
type: "'top-start' | 'top-center' | 'top-end' | 'middle-start' | 'middle-center' | 'middle-end' | 'bottom-start' | 'bottom-center' | 'bottom-end'"
required: false
description: "Posicion del indicador, default top-end"
- name: processing
type: "boolean"
required: false
description: "Animacion de pulso, default false"
- name: disabled
type: "boolean"
required: false
description: "Oculta el indicador, default false"
- name: label
type: "ReactNode"
required: false
description: "Contenido dentro del indicador (numero, texto)"
output: "Elemento hijo con un dot/badge indicador posicionado en una esquina"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/indicator.tsx"
emits: []
has_state: false
variant: []
---
## Ejemplo
```tsx
import { FnIndicator } from '@fn_library'
import { FnActionIcon } from '@fn_library'
import { IconBell } from '@tabler/icons-react'
{/* Dot simple */}
<FnIndicator processing>
<FnActionIcon icon={<IconBell size={18} />} />
</FnIndicator>
{/* Con contador */}
<FnIndicator label={5} size={16} color="blue">
<Avatar src="user.png" />
</FnIndicator>
```
## Notas
Wrapper sobre Mantine `Indicator`. El `processing` prop agrega una animacion de pulso al dot. Si se provee `label`, el indicador se agranda para mostrar contenido. `disabled` oculta el indicador sin desmontar el componente.
+39
View File
@@ -0,0 +1,39 @@
import * as React from 'react'
import { Indicator } from '@mantine/core'
import type { MantineColor } from '@mantine/core'
interface FnIndicatorProps {
children: React.ReactNode
color?: MantineColor
size?: number
position?: 'top-start' | 'top-center' | 'top-end' | 'middle-start' | 'middle-center' | 'middle-end' | 'bottom-start' | 'bottom-center' | 'bottom-end'
processing?: boolean
disabled?: boolean
label?: React.ReactNode
}
function FnIndicator({
children,
color = 'red',
size = 10,
position = 'top-end',
processing = false,
disabled = false,
label,
}: FnIndicatorProps) {
return (
<Indicator
color={color}
size={size}
position={position}
processing={processing}
disabled={disabled}
label={label}
>
{children}
</Indicator>
)
}
export { FnIndicator }
export type { FnIndicatorProps }
+52
View File
@@ -0,0 +1,52 @@
---
name: input
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Input(props: InputHTMLAttributes): JSX.Element"
description: "Campo de entrada accesible con soporte para iconos, grupos, validación ARIA y estados disabled/invalid. Mantine TextInput."
tags: [input, form, component, ui, interactive, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
output: "Componente Input que renderiza campo de entrada accesible con soporte para iconos y validación ARIA"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/input.tsx"
props:
- name: type
type: "string"
required: false
description: "Tipo de input HTML"
- name: className
type: "string"
required: false
description: "Clases CSS adicionales"
emits: [onChange, onFocus, onBlur]
has_state: false
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/input.tsx"
---
## Ejemplo
```tsx
<Input placeholder="Email" type="email" />
<InputGroup>
<InputIcon position="start"><SearchIcon /></InputIcon>
<Input placeholder="Buscar..." />
</InputGroup>
```
## Notas
Exporta Input, InputGroup e InputIcon. Usa Mantine TextInput internamente. InputGroup e InputIcon se mantienen como wrappers de compatibilidad — para nuevos usos preferir leftSection/rightSection de Mantine TextInput directamente.
+57
View File
@@ -0,0 +1,57 @@
import * as React from 'react'
import { TextInput, Box } from '@mantine/core'
function Input({
className,
type,
...props
}: React.ComponentProps<typeof TextInput> & { type?: string }) {
return (
<TextInput
type={type}
data-slot="input"
size="sm"
className={className}
{...props}
/>
)
}
interface InputGroupProps {
children: React.ReactNode
className?: string
}
function InputGroup({ children, className }: InputGroupProps) {
return (
<Box data-slot="input-group" className={className}>
{children}
</Box>
)
}
interface InputIconProps {
children: React.ReactNode
position: 'start' | 'end'
className?: string
}
function InputIcon({ children, position, className }: InputIconProps) {
return (
<Box
data-slot={`input-icon-${position}`}
component="span"
className={className}
style={{
display: 'inline-flex',
alignItems: 'center',
pointerEvents: 'none',
}}
>
{children}
</Box>
)
}
export { Input, InputGroup, InputIcon }
export type { InputGroupProps, InputIconProps }
+94
View File
@@ -0,0 +1,94 @@
---
name: kpi_card
kind: component
lang: ts
domain: ui
version: "2.0.0"
purity: impure
signature: "KPICard(props: KPICardProps): JSX.Element"
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, sparkline]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
output: "Componente KPICard que renderiza card de métrica con label, valor, delta descriptivo, icono y slot de mini chart"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/kpi_card.tsx"
props:
- name: label
type: "string"
required: true
description: "Etiqueta del KPI"
- name: value
type: "string | number"
required: true
description: "Valor principal"
- name: unit
type: "string"
required: false
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
type: "ReactNode"
required: false
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
type: "'sm' | 'default' | 'lg'"
required: false
description: "Tamaño"
emits: []
has_state: false
framework: react
variant: [sm, default, lg]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/kpi-card.tsx"
---
## Ejemplo
```tsx
import { KPICard, Sparkline } from '@fn_library'
{/* Básico */}
<KPICard label="Revenue" value="$12,450" delta={{ value: 12.5, isPositive: true }} />
{/* 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.
+91
View File
@@ -0,0 +1,91 @@
import * as React from 'react'
import { Paper, Text, Group, Stack, Box } from '@mantine/core'
type KPICardSize = 'sm' | 'default' | 'lg'
interface Delta {
value: number
isPositive: boolean
label?: string
suffix?: string
}
interface KPICardProps extends React.HTMLAttributes<HTMLDivElement> {
label: string
value: string | number
unit?: string
delta?: Delta
icon?: React.ReactNode
action?: React.ReactNode
chart?: React.ReactNode
subtitle?: string
size?: KPICardSize
}
const valueSizes: Record<KPICardSize, string> = {
sm: '1.5rem',
default: '1.875rem',
lg: '2.25rem',
}
const unitSizes: Record<KPICardSize, string> = {
sm: 'md',
default: 'lg',
lg: 'xl',
}
const labelSizes: Record<KPICardSize, string> = {
sm: 'xs',
default: 'sm',
lg: 'md',
}
const KPICard = React.forwardRef<HTMLDivElement, KPICardProps>(
({ label, value, unit, delta, icon, action, chart, subtitle, size = 'default', className, ...props }, ref) => {
const deltaColor = delta
? delta.value === 0 ? 'dimmed'
: delta.isPositive ? 'teal'
: 'red'
: undefined
return (
<Paper ref={ref} withBorder shadow="xs" radius="md" p="md" className={className} {...props}>
<Group justify="space-between" align="flex-start">
<Group gap="xs" align="center">
{icon && <Box c="dimmed">{icon}</Box>}
<Stack gap={2}>
<Text size={labelSizes[size]} c="dimmed">{label}</Text>
{subtitle && <Text size="xs" c="dimmed" opacity={0.8}>{subtitle}</Text>}
</Stack>
</Group>
{action && <Box c="dimmed">{action}</Box>}
</Group>
<Group justify="space-between" align="flex-end" mt="md" gap="lg">
<Stack gap={4}>
<Group gap={4} align="baseline">
<Text fw={700} style={{ fontSize: valueSizes[size], lineHeight: 1, letterSpacing: '-0.025em' }}>
{value}
</Text>
{unit && <Text size={unitSizes[size]} c="dimmed" fw={500}>{unit}</Text>}
</Group>
{delta && (
<Group gap={4} align="center">
{delta.label && <Text size="xs" c="dimmed">{delta.label}</Text>}
<Text size="xs" fw={500} c={deltaColor}>
{delta.isPositive ? '\u25B2' : '\u25BC'} {delta.value > 0 ? '+' : ''}{delta.value}{delta.label ? '' : '%'}
</Text>
{delta.suffix && <Text size="xs" c="dimmed">{delta.suffix}</Text>}
</Group>
)}
</Stack>
{chart && <Box style={{ flexShrink: 0 }}>{chart}</Box>}
</Group>
</Paper>
)
}
)
KPICard.displayName = 'KPICard'
export { KPICard }
export type { KPICardProps, Delta, KPICardSize }
+40
View File
@@ -0,0 +1,40 @@
---
name: label
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Label(props: LabelHTMLAttributes): JSX.Element"
description: "Etiqueta de formulario accesible con soporte para estados disabled. Mantine Text con component=label."
tags: [label, form, component, ui, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
output: "Componente Label que renderiza etiqueta de formulario accesible con soporte para estados disabled"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/label.tsx"
props:
- name: className
type: "string"
required: false
description: "Clases CSS adicionales"
emits: []
has_state: false
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/label.tsx"
---
## Ejemplo
```tsx
<Label htmlFor="email">Email</Label>
```
+18
View File
@@ -0,0 +1,18 @@
import * as React from 'react'
import { Text } from '@mantine/core'
function Label({ className, ...props }: React.ComponentProps<'label'>) {
return (
<Text
component="label"
data-slot="label"
size="sm"
fw={500}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, userSelect: 'none' }}
className={className}
{...props}
/>
)
}
export { Label }
+65
View File
@@ -0,0 +1,65 @@
---
name: line_chart
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "LineChart(props: LineChartProps): JSX.Element"
description: "Gráfico de líneas @mantine/charts con multi-series, 5 tipos de curva, líneas de referencia y tooltips."
tags: [chart, line, visualization, mantine, component, ui]
uses_functions: [chart_container_ts_ui]
uses_types: [ChartSeries_ts_ui]
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/charts", "@mantine/core"]
output: "Componente LineChart que renderiza gráfico de líneas multi-series con curvas customizables y líneas de referencia"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/line_chart.tsx"
props:
- name: data
type: "Record<string, unknown>[]"
required: true
description: "Array de datos"
- name: xKey
type: "string"
required: true
description: "Key del eje X"
- name: series
type: "Series[]"
required: false
description: "Series de datos"
- name: curveType
type: "'linear' | 'monotone' | 'step' | 'stepBefore' | 'stepAfter'"
required: false
description: "Tipo de curva (default monotone)"
- name: referenceLines
type: "Array<{ y: number; label?: string; color?: string }>"
required: false
description: "Líneas de referencia horizontales"
emits: []
has_state: false
framework: react
variant: [default]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/charts/line-chart.tsx"
---
## Ejemplo
```tsx
<LineChart
data={salesData}
xKey="month"
series={[
{ key: "revenue", name: "Revenue", color: "#3b82f6" },
{ key: "cost", name: "Cost", color: "#ef4444" },
]}
zoomable
showLegend
/>
```
+60
View File
@@ -0,0 +1,60 @@
import { LineChart as MantineLineChart } from '@mantine/charts'
import { Paper } from '@mantine/core'
import { type Series, getSeriesColor } from './chart_container'
type CurveType = 'linear' | 'monotone' | 'step' | 'stepBefore' | 'stepAfter'
interface LineChartProps {
data: Record<string, unknown>[]
xKey: string
yKey?: string
series?: Series[]
curveType?: CurveType
showGrid?: boolean
showLegend?: boolean
showDots?: boolean
height?: number
xAxisFormatter?: (value: unknown) => string
yAxisFormatter?: (value: unknown) => string
valueFormatter?: (value: number) => string
referenceLines?: Array<{ y: number; label?: string; color?: string }>
}
function LineChartComponent({
data, xKey, yKey, series, curveType = 'monotone', showGrid = true, showLegend = false,
showDots = true, height = 300, xAxisFormatter, yAxisFormatter,
valueFormatter = (v) => v.toLocaleString(), referenceLines = [],
}: LineChartProps) {
const chartSeries = series
? series.map((s, i) => ({ name: s.key, label: s.name, color: getSeriesColor(i, s.color) }))
: yKey ? [{ name: yKey, label: yKey, color: getSeriesColor(0) }] : []
const refLines = referenceLines.map((ref) => ({
y: ref.y,
label: ref.label || '',
color: ref.color || 'gray.6',
}))
return (
<Paper p="md">
<MantineLineChart
h={height}
data={data}
dataKey={xKey}
series={chartSeries}
curveType={curveType}
gridAxis={showGrid ? 'xy' : 'none'}
withLegend={showLegend}
withTooltip
withDots={showDots}
valueFormatter={valueFormatter}
referenceLines={refLines}
xAxisProps={xAxisFormatter ? { tickFormatter: xAxisFormatter } : undefined}
yAxisProps={yAxisFormatter ? { tickFormatter: yAxisFormatter } : undefined}
/>
</Paper>
)
}
export const LineChart = LineChartComponent
export type { LineChartProps, CurveType }
+58
View File
@@ -0,0 +1,58 @@
---
name: loading_overlay
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "FnLoadingOverlay(props: FnLoadingOverlayProps): JSX.Element"
description: "Overlay de carga con blur y opacidad configurable. Wrapper sobre Mantine LoadingOverlay."
tags: [mantine, loading, overlay, spinner, component, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
framework: react
props:
- name: visible
type: "boolean"
required: true
description: "Controla visibilidad del overlay"
- name: loaderSize
type: "number | string"
required: false
description: "Tamano del loader/spinner"
- name: overlayBlur
type: "number"
required: false
description: "Intensidad del blur del fondo, default 2"
- name: overlayOpacity
type: "number"
required: false
description: "Opacidad del overlay, default 0.5"
output: "Overlay semi-transparente con spinner centrado que cubre el contenedor padre"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/loading_overlay.tsx"
emits: []
has_state: false
variant: []
---
## Ejemplo
```tsx
import { FnLoadingOverlay } from '@fn_library'
<Box pos="relative">
<FnLoadingOverlay visible={loading} overlayBlur={3} />
<DataTable data={rows} />
</Box>
```
## Notas
Wrapper sobre Mantine `LoadingOverlay`. El contenedor padre necesita `position: relative` para que el overlay se posicione correctamente. Usa `loaderProps` y `overlayProps` internamente para mapear las props simplificadas.
+26
View File
@@ -0,0 +1,26 @@
import { LoadingOverlay } from '@mantine/core'
interface FnLoadingOverlayProps {
visible: boolean
loaderSize?: number | string
overlayBlur?: number
overlayOpacity?: number
}
function FnLoadingOverlay({
visible,
loaderSize,
overlayBlur = 2,
overlayOpacity = 0.5,
}: FnLoadingOverlayProps) {
return (
<LoadingOverlay
visible={visible}
loaderProps={loaderSize ? { size: loaderSize } : undefined}
overlayProps={{ blur: overlayBlur, backgroundOpacity: overlayOpacity }}
/>
)
}
export { FnLoadingOverlay }
export type { FnLoadingOverlayProps }
+59
View File
@@ -0,0 +1,59 @@
---
name: mantine_provider
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "FnMantineProvider({ children, theme?, defaultColorScheme? })"
description: "Provider raiz de Mantine para apps del registry. Wrappea MantineProvider con Notifications incluido. Importa los CSS de @mantine/core, charts y notifications."
tags: [mantine, provider, theme, react]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["@mantine/core", "@mantine/notifications", "@mantine/charts"]
framework: react
props:
- name: children
desc: "contenido de la app"
- name: theme
desc: "tema Mantine creado con createTheme() — colores, fuentes, radio, etc."
- name: defaultColorScheme
desc: "esquema de color por defecto: 'dark' | 'light' | 'auto'"
output: "arbol React envuelto en MantineProvider con notificaciones"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/mantine_provider.tsx"
---
## Ejemplo
```tsx
import { createTheme, MantineColorsTuple } from '@mantine/core'
import { FnMantineProvider } from '@fn_library'
const brand: MantineColorsTuple = [
'#e5f3ff', '#cde2ff', '#9ac2ff', '#64a0ff', '#3884fe',
'#1d72fe', '#0063ff', '#0058e4', '#004ecd', '#0043b5'
]
const theme = createTheme({
colors: { brand },
primaryColor: 'brand',
})
function App() {
return (
<FnMantineProvider theme={theme} defaultColorScheme="dark">
{/* Tu app aqui */}
</FnMantineProvider>
)
}
```
## Notas
Reemplaza ThemeProvider + applyTheme del sistema anterior. Las apps definen su propio tema con `createTheme()` y lo pasan como prop. Los CSS de Mantine se importan una sola vez aqui.
+29
View File
@@ -0,0 +1,29 @@
import '@mantine/core/styles.css'
import '@mantine/charts/styles.css'
import '@mantine/notifications/styles.css'
import * as React from 'react'
import { MantineProvider, type MantineThemeOverride, type MantineColorScheme } from '@mantine/core'
import { Notifications } from '@mantine/notifications'
interface FnMantineProviderProps {
children: React.ReactNode
theme?: MantineThemeOverride
defaultColorScheme?: MantineColorScheme
}
function FnMantineProvider({
children,
theme,
defaultColorScheme = 'dark',
}: FnMantineProviderProps) {
return (
<MantineProvider theme={theme} defaultColorScheme={defaultColorScheme}>
<Notifications position="top-right" />
{children}
</MantineProvider>
)
}
export { FnMantineProvider }
export type { FnMantineProviderProps }
+111
View File
@@ -0,0 +1,111 @@
---
name: multi_select
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "MultiSelect(props: MultiSelectProps): JSX.Element"
description: "Selector múltiple con búsqueda, pills y límite de selecciones. Wrapper sobre Mantine MultiSelect."
tags: [multi-select, form, dropdown, pills, component, ui, interactive, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
output: "Componente MultiSelect que renderiza dropdown con selección múltiple, búsqueda y pills"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/multi_select.tsx"
props:
- name: data
type: "string[] | { value: string; label: string; disabled?: boolean }[]"
required: true
description: "Opciones del selector — strings o objetos {value, label}"
- name: value
type: "string[]"
required: false
description: "Valores seleccionados (controlled)"
- name: onChange
type: "(value: string[]) => void"
required: false
description: "Callback al cambiar la selección"
- name: searchable
type: "boolean"
required: false
description: "Permite buscar entre opciones"
- name: clearable
type: "boolean"
required: false
description: "Muestra botón para limpiar toda la selección"
- name: maxValues
type: "number"
required: false
description: "Número máximo de valores seleccionables"
- name: placeholder
type: "string"
required: false
description: "Texto cuando no hay selección"
- name: label
type: "string"
required: false
description: "Label del campo"
- name: disabled
type: "boolean"
required: false
description: "Deshabilitar el selector"
- name: size
type: "'xs' | 'sm' | 'md' | 'lg' | 'xl'"
required: false
description: "Tamaño del componente"
emits: [onChange]
has_state: true
framework: react
variant: [default]
---
## Ejemplo
```tsx
import { MultiSelect } from '@fn_library'
// Opciones simples
<MultiSelect
label="Frameworks favoritos"
placeholder="Elige uno o más"
data={['React', 'Vue', 'Angular', 'Svelte']}
/>
// Con búsqueda y clearable
<MultiSelect
label="Tecnologías"
placeholder="Busca y selecciona"
searchable
clearable
value={selected}
onChange={setSelected}
data={[
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
{ value: 'ts', label: 'TypeScript' },
]}
/>
// Máximo de selecciones
<MultiSelect
label="Elige hasta 2"
placeholder="Máximo 2"
maxValues={2}
data={['Opción A', 'Opción B', 'Opción C', 'Opción D']}
/>
```
## Notas
- Wrapper directo sobre `MultiSelect` de `@mantine/core` v9. Todas las props de Mantine MultiSelect son válidas.
- A diferencia de `Select`, `value` es `string[]` y `onChange` recibe `string[]`.
- Las selecciones se muestran como pills dentro del input, eliminables con clic.
- `maxValues` limita cuántos items pueden seleccionarse — el dropdown bloquea más selecciones al alcanzar el límite.
- Soporta `searchable` para filtrar opciones y `clearable` para un botón de limpiar todo.
+10
View File
@@ -0,0 +1,10 @@
import { MultiSelect as MantineMultiSelect, type MultiSelectProps as MantineMultiSelectProps } from '@mantine/core'
interface MultiSelectProps extends MantineMultiSelectProps {}
function MultiSelect(props: MultiSelectProps) {
return <MantineMultiSelect {...props} />
}
export { MultiSelect }
export type { MultiSelectProps }
+80
View File
@@ -0,0 +1,80 @@
---
name: nav_link
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "FnNavLink(props: FnNavLinkProps): JSX.Element"
description: "Link de navegacion con icono, descripcion y anidamiento. Wrapper sobre Mantine NavLink."
tags: [mantine, navigation, link, sidebar, menu, component, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
framework: react
props:
- name: label
type: "string"
required: true
description: "Texto principal del link"
- name: description
type: "string"
required: false
description: "Texto secundario debajo del label"
- name: icon
type: "ReactNode"
required: false
description: "Icono a la izquierda del label"
- name: active
type: "boolean"
required: false
description: "Estado activo/seleccionado"
- name: onClick
type: "MouseEventHandler"
required: false
description: "Callback al hacer click"
- name: href
type: "string"
required: false
description: "URL para navegacion como anchor"
- name: children
type: "ReactNode"
required: false
description: "NavLinks hijos para crear arbol anidado"
- name: opened
type: "boolean"
required: false
description: "Controla si los hijos estan expandidos"
- name: defaultOpened
type: "boolean"
required: false
description: "Estado inicial de expansion de hijos"
output: "Link de navegacion con highlight activo, icono y soporte para sub-items colapsables"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/nav_link.tsx"
emits: []
has_state: false
variant: []
---
## Ejemplo
```tsx
import { FnNavLink } from '@fn_library'
import { IconHome, IconSettings } from '@tabler/icons-react'
<FnNavLink label="Home" icon={<IconHome size={16} />} active />
<FnNavLink label="Settings" icon={<IconSettings size={16} />} defaultOpened>
<FnNavLink label="General" />
<FnNavLink label="Seguridad" />
</FnNavLink>
```
## Notas
Wrapper sobre Mantine `NavLink`. Soporta anidamiento -- pasar `FnNavLink` como children crea un arbol colapsable. El `icon` se mapea a `leftSection` internamente. Ideal para uso dentro de `FnAppShell` navbar.
+44
View File
@@ -0,0 +1,44 @@
import * as React from 'react'
import { NavLink } from '@mantine/core'
interface FnNavLinkProps {
label: string
description?: string
icon?: React.ReactNode
active?: boolean
onClick?: React.MouseEventHandler<HTMLButtonElement>
href?: string
children?: React.ReactNode
opened?: boolean
defaultOpened?: boolean
}
function FnNavLink({
label,
description,
icon,
active,
onClick,
href,
children,
opened,
defaultOpened,
}: FnNavLinkProps) {
return (
<NavLink
label={label}
description={description}
leftSection={icon}
active={active}
onClick={onClick}
href={href}
opened={opened}
defaultOpened={defaultOpened}
>
{children}
</NavLink>
)
}
export { FnNavLink }
export type { FnNavLinkProps }
+92
View File
@@ -0,0 +1,92 @@
---
name: number_input
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "FnNumberInput(props: FnNumberInputProps): JSX.Element"
description: "Input numerico con min/max, step, prefijo y sufijo. Wrapper sobre Mantine NumberInput."
tags: [mantine, input, number, form, component, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
framework: react
props:
- name: value
type: "number | string"
required: false
description: "Valor actual del input"
- name: onChange
type: "(value: number | string) => void"
required: false
description: "Callback cuando cambia el valor"
- name: min
type: "number"
required: false
description: "Valor minimo permitido"
- name: max
type: "number"
required: false
description: "Valor maximo permitido"
- name: step
type: "number"
required: false
description: "Incremento/decremento por click"
- name: label
type: "string"
required: false
description: "Etiqueta del input"
- name: description
type: "string"
required: false
description: "Texto descriptivo debajo del input"
- name: error
type: "string"
required: false
description: "Mensaje de error"
- name: placeholder
type: "string"
required: false
description: "Placeholder del input"
- name: prefix
type: "string"
required: false
description: "Texto prefijo dentro del input (ej: $)"
- name: suffix
type: "string"
required: false
description: "Texto sufijo dentro del input (ej: kg)"
output: "Input numerico con controles de incremento, validacion y decoradores de texto"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/number_input.tsx"
emits: []
has_state: false
variant: []
---
## Ejemplo
```tsx
import { FnNumberInput } from '@fn_library'
<FnNumberInput
label="Precio"
value={price}
onChange={setPrice}
min={0}
max={10000}
step={0.01}
prefix="$"
description="Precio unitario en USD"
/>
```
## Notas
Wrapper sobre Mantine `NumberInput`. Soporta prefix/suffix para decorar el valor visualmente. Los controles de incremento/decremento respetan min/max/step.
+48
View File
@@ -0,0 +1,48 @@
import { NumberInput } from '@mantine/core'
interface FnNumberInputProps {
value?: number | string
onChange?: (value: number | string) => void
min?: number
max?: number
step?: number
label?: string
description?: string
error?: string
placeholder?: string
prefix?: string
suffix?: string
}
function FnNumberInput({
value,
onChange,
min,
max,
step,
label,
description,
error,
placeholder,
prefix,
suffix,
}: FnNumberInputProps) {
return (
<NumberInput
value={value}
onChange={onChange}
min={min}
max={max}
step={step}
label={label}
description={description}
error={error}
placeholder={placeholder}
prefix={prefix}
suffix={suffix}
/>
)
}
export { FnNumberInput }
export type { FnNumberInputProps }
+63
View File
@@ -0,0 +1,63 @@
---
name: page_header
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "PageHeader(props: PageHeaderProps): JSX.Element"
description: "Cabecera de página con título, subtítulo, acciones, back button, tabs integrados, badge y modo sticky. Incluye SimplePageHeader."
tags: [header, page, layout, navigation, component, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react, "@mantine/core", "@tabler/icons-react"]
output: "Componente PageHeader que renderiza cabecera de página con título, acciones, tabs integrados y modo sticky"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/page_header.tsx"
props:
- name: title
type: "string"
required: true
description: "Título principal"
- name: subtitle
type: "string"
required: false
description: "Subtítulo"
- name: actions
type: "ReactNode"
required: false
description: "Botones de acción"
- name: tabs
type: "TabItem[]"
required: false
description: "Tabs de navegación integrados"
- name: sticky
type: "boolean"
required: false
description: "Header fijo al scroll"
emits: [onBack, onTabChange]
has_state: false
framework: react
variant: [full, simple]
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/components/ui/page-header.tsx"
---
## Ejemplo
```tsx
<PageHeader
title="Dashboard"
subtitle="Vista general"
actions={<Button>Export</Button>}
tabs={[{ label: "Overview", value: "overview" }, { label: "Analytics", value: "analytics" }]}
activeTab="overview"
onTabChange={setTab}
/>
```
+92
View File
@@ -0,0 +1,92 @@
"use client"
import * as React from "react"
import { Group, Stack, Title, Text, ActionIcon, Tabs, Box } from "@mantine/core"
import { IconChevronLeft } from "@tabler/icons-react"
interface TabItem {
label: string
value: string
icon?: React.ReactNode
disabled?: boolean
}
interface PageHeaderProps {
title: string
subtitle?: string
actions?: React.ReactNode
onBack?: () => void
tabs?: TabItem[]
activeTab?: string
onTabChange?: (value: string) => void
badge?: React.ReactNode
sticky?: boolean
}
function PageHeader({
title, subtitle, actions, onBack, tabs, activeTab, onTabChange,
badge, sticky = false,
}: PageHeaderProps) {
return (
<Box
data-slot="page-header"
pb="md"
style={{
borderBottom: '1px solid var(--mantine-color-default-border)',
backgroundColor: 'var(--mantine-color-body)',
...(sticky ? { position: 'sticky', top: 0, zIndex: 20 } : {}),
}}
>
<Stack gap="md">
<Group justify="space-between" align="flex-start" gap="md">
<Group align="flex-start" gap="sm">
{onBack && (
<ActionIcon variant="subtle" size="sm" onClick={onBack} mt={4}>
<IconChevronLeft size={16} />
</ActionIcon>
)}
<Stack gap={4}>
<Group gap="sm" align="center">
<Title order={2}>{title}</Title>
{badge}
</Group>
{subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
</Stack>
</Group>
{actions && <Group gap="xs" style={{ flexShrink: 0 }}>{actions}</Group>}
</Group>
{tabs && tabs.length > 0 && (
<Tabs value={activeTab} onChange={(v) => v && onTabChange?.(v)} style={{ marginBottom: 'calc(-1 * var(--mantine-spacing-md))' }}>
<Tabs.List>
{tabs.map((tab) => (
<Tabs.Tab key={tab.value} value={tab.value} disabled={tab.disabled} leftSection={tab.icon}>
{tab.label}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
)}
</Stack>
</Box>
)
}
interface SimplePageHeaderProps {
title: string
description?: string
children?: React.ReactNode
}
function SimplePageHeader({ title, description, children }: SimplePageHeaderProps) {
return (
<Group justify="space-between" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
<Stack gap={4}>
<Title order={2}>{title}</Title>
{description && <Text size="sm" c="dimmed">{description}</Text>}
</Stack>
{children && <Group gap="xs">{children}</Group>}
</Group>
)
}
export { PageHeader, SimplePageHeader }
+76
View File
@@ -0,0 +1,76 @@
---
name: pagination
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Pagination(props: PaginationProps): JSX.Element"
description: "Controles de navegacion de paginas autocontenido. Mantine Pagination."
tags: [pagination, navigation, component, ui, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
output: "Componente Pagination autocontenido que renderiza controles de navegacion de paginas"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/pagination.tsx"
props:
- name: total
type: "number"
required: true
description: "Numero total de paginas"
- name: value
type: "number"
required: false
description: "Pagina actual (controlado)"
- name: defaultValue
type: "number"
required: false
description: "Pagina inicial (no controlado)"
- name: onChange
type: "(page: number) => void"
required: false
description: "Callback al cambiar de pagina"
- name: siblings
type: "number"
required: false
description: "Paginas visibles a cada lado de la actual"
- name: boundaries
type: "number"
required: false
description: "Paginas visibles al inicio y final"
- name: withEdges
type: "boolean"
required: false
description: "Mostrar botones first/last page"
- name: className
type: "string"
required: false
description: "Clases CSS adicionales"
emits: [onChange]
has_state: false
framework: react
variant: []
---
## Ejemplo
```tsx
// Basico
<Pagination total={10} defaultValue={1} />
// Controlado
<Pagination total={20} value={page} onChange={setPage} />
// Con botones first/last
<Pagination total={50} withEdges siblings={2} />
```
## Notas
Usa Mantine Pagination autocontenido. Previous/Next, numeros de pagina y elipsis se generan automaticamente. La API anterior con sub-componentes (PaginationContent, PaginationItem, PaginationLink, etc.) fue reemplazada por un unico componente con props declarativas. Export: Pagination y PaginationProps.
+43
View File
@@ -0,0 +1,43 @@
import { Pagination as MantinePagination } from "@mantine/core"
interface PaginationProps {
total: number
value?: number
defaultValue?: number
onChange?: (page: number) => void
siblings?: number
boundaries?: number
withEdges?: boolean
className?: string
}
function Pagination({
total,
value,
defaultValue,
onChange,
siblings,
boundaries,
withEdges = false,
className,
...props
}: PaginationProps) {
return (
<MantinePagination
data-slot="pagination"
total={total}
value={value}
defaultValue={defaultValue}
onChange={onChange}
siblings={siblings}
boundaries={boundaries}
withEdges={withEdges}
className={className}
size="sm"
{...props}
/>
)
}
export { Pagination }
export type { PaginationProps }
+109
View File
@@ -0,0 +1,109 @@
---
name: password_input
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "PasswordInput(props: PasswordInputProps): JSX.Element"
description: "Input de contraseña con toggle de visibilidad y soporte para indicador de fortaleza. Wrapper sobre Mantine PasswordInput."
tags: [password, input, form, security, component, ui, interactive, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/password_input.tsx"
framework: react
has_state: true
props:
- name: visible
type: "boolean"
required: false
description: "Controla externamente si la contraseña es visible"
- name: onVisibilityChange
type: "(visible: boolean) => void"
required: false
description: "Callback cuando el usuario cambia la visibilidad"
- name: visibilityToggleIcon
type: "React.FC<{ reveal: boolean }>"
required: false
description: "Icono personalizado para el botón de toggle"
- name: value
type: "string"
required: false
description: "Valor controlado del input"
- name: onChange
type: "(event: React.ChangeEvent<HTMLInputElement>) => void"
required: false
description: "Callback al cambiar el valor"
- name: label
type: "React.ReactNode"
required: false
description: "Etiqueta del campo"
- name: placeholder
type: "string"
required: false
description: "Texto placeholder cuando está vacío"
- name: error
type: "React.ReactNode"
required: false
description: "Mensaje de error a mostrar bajo el input"
- name: disabled
type: "boolean"
required: false
description: "Deshabilita el input"
- name: size
type: "'xs' | 'sm' | 'md' | 'lg' | 'xl'"
required: false
description: "Tamaño del componente"
emits: [onChange, onVisibilityChange]
params:
- name: props
desc: "Props de Mantine PasswordInput: visible, onVisibilityChange, visibilityToggleIcon, value, onChange, label, placeholder, error, disabled, size y cualquier prop HTML nativa de input"
output: "Componente PasswordInput que renderiza input enmascarado con botón de mostrar/ocultar"
---
## Ejemplo
```tsx
import { PasswordInput } from '@fn_library'
// Uso básico
function LoginForm() {
return (
<PasswordInput
label="Contraseña"
placeholder="Tu contraseña"
/>
)
}
// Con visibilidad controlada
import { useState } from 'react'
function ControlledPasswordInput() {
const [visible, setVisible] = useState(false)
const [value, setValue] = useState('')
return (
<PasswordInput
label="Contraseña"
placeholder="Ingresa tu contraseña"
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
visible={visible}
onVisibilityChange={setVisible}
error={value.length > 0 && value.length < 8 ? 'Mínimo 8 caracteres' : undefined}
/>
)
}
```
## Notas
Wrapper delgado sobre `PasswordInput` de Mantine v9. El toggle de visibilidad es nativo de Mantine y se controla opcionalmente con `visible` + `onVisibilityChange`. Soporta todas las props de Mantine incluyendo `strengthMeter` para indicadores de fortaleza. Los iconos del toggle deben venir de `@tabler/icons-react`.
+10
View File
@@ -0,0 +1,10 @@
import { PasswordInput as MantinePasswordInput, type PasswordInputProps as MantinePasswordInputProps } from '@mantine/core'
interface PasswordInputProps extends MantinePasswordInputProps {}
function PasswordInput(props: PasswordInputProps) {
return <MantinePasswordInput {...props} />
}
export { PasswordInput }
export type { PasswordInputProps }
+88
View File
@@ -0,0 +1,88 @@
---
name: pie_chart
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "PieChart(props: PieChartProps): JSX.Element"
description: "Gráfico de torta/dona @mantine/charts con colores automáticos, labels y tooltip. Usa DonutChart para dona, PieChart para torta."
tags: [chart, pie, donut, visualization, mantine, component, ui, dashboard]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/charts", "@mantine/core"]
output: "Componente PieChart que renderiza gráfico de torta o dona con labels y tooltip"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/pie_chart.tsx"
props:
- name: data
type: "Record<string, unknown>[]"
required: true
description: "Array de datos. Los valores de valueKey se convierten a number automáticamente."
- name: nameKey
type: "string"
required: true
description: "Key del campo que contiene el nombre/etiqueta de cada segmento"
- name: valueKey
type: "string"
required: true
description: "Key del campo numérico que determina el tamaño de cada segmento"
- name: colors
type: "string[]"
required: false
description: "Paleta de colores hex. Default: 8 colores accesibles. Se repite cíclicamente."
- name: donut
type: "boolean"
required: false
description: "Modo dona. innerRadius pasa a 50 por defecto cuando donut=true."
- name: showLegend
type: "boolean"
required: false
description: "Mostrar leyenda. Default true."
- name: showLabels
type: "boolean"
required: false
description: "Mostrar labels nombre+% en cada segmento. Default true."
- name: height
type: "number | string"
required: false
description: "Altura del contenedor. Default 300."
- name: valueFormatter
type: "(value: number) => string"
required: false
description: "Formateador de valores para el tooltip. Default toLocaleString."
emits: []
has_state: false
framework: react
variant: [pie, donut]
---
## Ejemplo
```tsx
// Pie simple
<PieChart
data={[{ lang: 'Go', count: 42 }, { lang: 'Python', count: 28 }, { lang: 'Bash', count: 15 }]}
nameKey="lang"
valueKey="count"
/>
// Dona sin labels
<PieChart
data={distributions}
nameKey="domain"
valueKey="functions"
donut
showLabels={false}
valueFormatter={(v) => `${v} fns`}
/>
```
## Notas
Los valores de `valueKey` se convierten a `Number()` antes de pasarlos al chart. Cuando `donut=true` se usa `DonutChart` de Mantine, de lo contrario `PieChart`.
+58
View File
@@ -0,0 +1,58 @@
import { PieChart as MantinePieChart, DonutChart } from '@mantine/charts'
import { Paper } from '@mantine/core'
const DEFAULT_COLORS = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#ec4899', '#06b6d4', '#f97316',
]
interface PieChartProps {
data: Record<string, unknown>[]
nameKey: string
valueKey: string
colors?: string[]
donut?: boolean
showLegend?: boolean
showLabels?: boolean
height?: number
valueFormatter?: (value: number) => string
}
function PieChartComponent({
data,
nameKey,
valueKey,
colors = DEFAULT_COLORS,
donut = false,
showLegend: _showLegend = true,
showLabels = true,
height = 300,
valueFormatter = (v) => v.toLocaleString(),
}: PieChartProps) {
const chartData = data.map((row, i) => ({
name: String(row[nameKey] ?? ''),
value: Number(row[valueKey]) || 0,
color: colors[i % colors.length] as string,
}))
const Chart = donut ? DonutChart : MantinePieChart
return (
<Paper p="md">
<Chart
h={height}
data={chartData}
withLabels={showLabels}
withLabelsLine={showLabels}
withTooltip
tooltipDataSource="segment"
valueFormatter={valueFormatter}
/>
{/* Mantine PieChart/DonutChart does not have a built-in legend prop;
legend is handled via withLabels. showLegend kept for API compat. */}
</Paper>
)
}
export const PieChart = PieChartComponent
export type { PieChartProps }
+105
View File
@@ -0,0 +1,105 @@
---
name: pin_input
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "PinInput(props: PinInputProps): JSX.Element"
description: "Input de código PIN/OTP con campos individuales y autocompletado. Wrapper sobre Mantine PinInput."
tags: [pin, otp, code, input, form, component, ui, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
output: "Componente PinInput que renderiza campos individuales por dígito con soporte para autocompletado OTP, máscaras y teclado numérico o alfanumérico"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/pin_input.tsx"
props:
- name: length
type: "number"
required: false
description: "Número de campos individuales del PIN (por defecto 4)"
- name: type
type: "'number' | 'alphanumeric'"
required: false
description: "Tipo de caracteres aceptados — solo números o también letras"
- name: mask
type: "boolean"
required: false
description: "Oculta los caracteres ingresados como contraseña"
- name: oneTimeCode
type: "boolean"
required: false
description: "Activa el atributo autocomplete=one-time-code para OTP en móvil"
- name: value
type: "string"
required: false
description: "Valor actual del PIN (controlled)"
- name: onChange
type: "(value: string) => void"
required: false
description: "Callback al cambiar cualquier campo"
- name: onComplete
type: "(value: string) => void"
required: false
description: "Callback cuando todos los campos están completos"
- name: placeholder
type: "string"
required: false
description: "Carácter placeholder en cada campo vacío"
- name: disabled
type: "boolean"
required: false
description: "Deshabilita todos los campos"
- name: size
type: "'xs' | 'sm' | 'md' | 'lg' | 'xl'"
required: false
description: "Tamaño visual de los campos"
emits: [onChange, onComplete]
has_state: true
framework: react
variant: [default]
---
## Ejemplo
```tsx
import { PinInput } from '@fn_library'
// PIN numérico de 4 dígitos
<PinInput
length={4}
type="number"
onComplete={(value) => console.log('PIN completo:', value)}
/>
// OTP de 6 dígitos con autocompletado móvil
<PinInput
length={6}
type="number"
oneTimeCode
onComplete={handleOtpSubmit}
/>
// PIN enmascarado alfanumérico
<PinInput
length={5}
type="alphanumeric"
mask
placeholder="*"
value={pin}
onChange={setPin}
/>
```
## Notas
- Wrapper directo sobre `PinInput` de `@mantine/core` v9. Todas las props de Mantine PinInput son válidas.
- `oneTimeCode` genera `autocomplete="one-time-code"` para que el browser/SMS sugiera el código automáticamente.
- Cada campo avanza al siguiente automáticamente al completarse.
- Soporta navegación con teclado (flechas, backspace) entre campos.
+10
View File
@@ -0,0 +1,10 @@
import { PinInput as MantinePinInput, type PinInputProps as MantinePinInputProps } from '@mantine/core'
interface PinInputProps extends MantinePinInputProps {}
function PinInput(props: PinInputProps) {
return <MantinePinInput {...props} />
}
export { PinInput }
export type { PinInputProps }
+66
View File
@@ -0,0 +1,66 @@
---
name: popover
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Popover(props: PopoverProps): JSX.Element"
description: "Contenido flotante posicionado accesible con animaciones. Mantine Popover."
tags: [popover, component, ui, interactive, overlay, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core", react]
output: "Componente Popover que renderiza contenido flotante accesible posicionado automáticamente via Mantine Popover"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/popover.tsx"
props:
- name: open
type: "boolean"
required: false
description: "Estado controlado de apertura"
- name: defaultOpen
type: "boolean"
required: false
description: "Estado inicial de apertura (no controlado)"
- name: onOpenChange
type: "(open: boolean) => void"
required: false
description: "Callback cuando cambia el estado de apertura"
- name: sideOffset
type: "number"
required: false
description: "Distancia en px entre trigger y popover (default: 4)"
emits: [onOpenChange]
has_state: false
framework: react
variant: []
---
## Ejemplo
```tsx
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">Abrir</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverHeader>
<PopoverTitle>Configuracion</PopoverTitle>
<PopoverDescription>Ajusta tus preferencias.</PopoverDescription>
</PopoverHeader>
<div className="mt-4">
{/* contenido */}
</div>
</PopoverContent>
</Popover>
```
## Notas
Compuesto de: Popover (root), PopoverTrigger, PopoverContent, PopoverClose, PopoverHeader, PopoverTitle, PopoverDescription. El posicionamiento automatico lo maneja Mantine Popover. PopoverPortal es un no-op mantenido por compatibilidad.
+92
View File
@@ -0,0 +1,92 @@
import * as React from 'react'
import { Popover as MantinePopover, Box, Text } from '@mantine/core'
interface PopoverProps {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}
function Popover({ open, defaultOpen, onOpenChange, children }: PopoverProps) {
return (
<MantinePopover
opened={open}
defaultOpened={defaultOpen}
onChange={onOpenChange}
withArrow
shadow="md"
radius="md"
>
{children}
</MantinePopover>
)
}
function PopoverTrigger({ children }: React.ComponentProps<'div'>) {
return (
<MantinePopover.Target>
{React.isValidElement(children) ? (
children
) : (
<button type="button" data-slot="popover-trigger">{children}</button>
)}
</MantinePopover.Target>
)
}
function PopoverPortal({ children }: { children: React.ReactNode }) {
return <>{children}</>
}
function PopoverContent({ className, children, sideOffset, ...props }: React.ComponentProps<'div'> & { sideOffset?: number }) {
return (
<MantinePopover.Dropdown
data-slot="popover-content"
className={className}
{...props}
>
{children}
</MantinePopover.Dropdown>
)
}
function PopoverClose({ children, ...props }: React.ComponentProps<'button'>) {
return (
<button type="button" data-slot="popover-close" {...props}>
{children}
</button>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <Box data-slot="popover-header" mb="xs" className={className} {...props} />
}
function PopoverTitle({ className, ...props }: React.ComponentProps<'h4'>) {
return (
<Text
component="h4"
data-slot="popover-title"
fw={600}
size="sm"
className={className}
{...props}
/>
)
}
function PopoverDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<Text
component="p"
data-slot="popover-description"
size="sm"
c="dimmed"
className={className}
{...props}
/>
)
}
export { Popover, PopoverClose, PopoverContent, PopoverDescription, PopoverHeader, PopoverPortal, PopoverTitle, PopoverTrigger }

Some files were not shown because too many files have changed in this diff Show More