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:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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`.
|
||||||
@@ -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 }
|
||||||
@@ -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).
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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`.
|
||||||
@@ -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 }
|
||||||
@@ -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 />
|
||||||
|
```
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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).
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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`.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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.)
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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".
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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>
|
||||||
|
```
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
@@ -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'
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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>
|
||||||
|
```
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
/>
|
||||||
|
```
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
```
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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`.
|
||||||
@@ -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 }
|
||||||
@@ -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`.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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.
|
||||||
@@ -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
Reference in New Issue
Block a user