refactor: migrate frontend from shadcn/Tailwind to Mantine v9

Reescribe todos los componentes UI para usar Mantine v9 en lugar de shadcn/Tailwind.
Elimina cn(), CVA, components.json, theme_provider custom y globals.css con Tailwind.
Añade 25+ componentes nuevos (AppShell, AuthForm, DatePickerInput, Dropzone, etc.)
y MantineProvider como wrapper estándar del sistema de temas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 23:46:44 +02:00
parent 4b2bb6998a
commit 97a3c84625
163 changed files with 6008 additions and 6310 deletions
+114 -197
View File
@@ -2,6 +2,17 @@
Eres un arquitecto frontend experto. Esta skill se activa cuando el usuario pide crear un proyecto frontend, una app con UI, un componente nuevo, o una feature frontend. Tu trabajo es garantizar que TODO el frontend se construya usando el sistema de funciones reutilizables del registry y las mejores practicas actuales.
## Stack
- **pnpm** — gestor de paquetes
- **React 19** — UI library
- **Vite 8** — build tool
- **Mantine v9** — component library + styling (props, no CSS manual)
- **Phosphor Icons** — `@phosphor-icons/react`
- **Recharts** — charts (via `@mantine/charts`)
**NO usar:** Tailwind, shadcn, CVA, clsx, cn(), lucide-react, styled-components, emotion, CSS-in-JS runtime.
---
## PASO 1: Consultar el registry (OBLIGATORIO)
@@ -56,11 +67,12 @@ apps/{nombre}/
package.json
vite.config.ts
tsconfig.json
postcss.config.cjs
index.html
src/
main.tsx # Entry point
App.tsx # Root con ThemeProvider + Router
app.css # Tokens CSS — NUNCA hardcodear colores
main.tsx # Entry point con MantineProvider
App.tsx # Root con Router
app.css # Minimal (font-smoothing solo)
features/ # Feature-based co-location
{feature}/
components/ # Componentes del feature
@@ -87,21 +99,20 @@ apps/{nombre}/
"preview": "vite preview --host"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"@mantine/core": "^9.0.0",
"@mantine/hooks": "^9.0.0",
"@mantine/notifications": "^9.0.0",
"@phosphor-icons/react": "^2.1.10",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"recharts": "^2.15.0",
"tailwind-merge": "^3.5.0"
"react-dom": "^19.2.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"tailwindcss": "^4.2.2",
"postcss": "^8.5.8",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"typescript": "~5.9.3",
"vite": "^8.0.0"
}
@@ -109,10 +120,10 @@ apps/{nombre}/
```
Agregar dependencias extras segun necesidad:
- **Charts**: `@mantine/charts`, `recharts`
- **Tablas**: `@tanstack/react-table`
- **Charts**: `recharts`
- **Iconos extra**: `@phosphor-icons/react`
- **Forms**: `react-hook-form`, `@hookform/resolvers`, `zod`
- **Dates**: `@mantine/dates`, `dayjs`
- **Router**: `react-router` o `@tanstack/react-router`
- **State**: `zustand` (client state), `@tanstack/react-query` (server state)
- **Wails**: los hooks de Wails ya estan en `@fn_library` (useWailsQuery, useWailsMutation, useWailsStream, useWailsEvent, WailsProvider)
@@ -122,11 +133,10 @@ Agregar dependencias extras segun necesidad:
```ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { resolve } from 'path'
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
@@ -134,6 +144,9 @@ export default defineConfig({
},
dedupe: ['react', 'react-dom'],
},
css: {
postcss: resolve(__dirname, './postcss.config.cjs'),
},
build: {
target: 'es2022',
rollupOptions: {
@@ -147,108 +160,32 @@ export default defineConfig({
})
```
### postcss.config.cjs base
```js
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};
```
### app.css base
```css
@import "tailwindcss";
@theme inline {
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
}
/* Dark theme (default) */
:root,
[data-theme="dark"] {
--background: oklch(8% 0.015 260);
--foreground: oklch(95% 0.01 260);
--muted: oklch(18% 0.02 260);
--muted-foreground: oklch(60% 0.02 260);
--border: oklch(15% 0.01 260);
--primary: oklch(65% 0.22 260);
--primary-foreground: oklch(98% 0.01 260);
--secondary: oklch(20% 0.02 260);
--secondary-foreground: oklch(95% 0.01 260);
--accent: oklch(18% 0.03 260);
--accent-foreground: oklch(95% 0.01 260);
--destructive: oklch(55% 0.22 25);
--destructive-foreground: oklch(98% 0.01 260);
--card: oklch(11% 0.015 260);
--card-foreground: oklch(95% 0.01 260);
--popover: oklch(12% 0.015 260);
--popover-foreground: oklch(95% 0.01 260);
--ring: oklch(65% 0.22 260);
--input: oklch(22% 0.02 260);
--radius: 0.5rem;
--chart-1: oklch(62% 0.19 260);
--chart-2: oklch(65% 0.2 155);
--chart-3: oklch(75% 0.18 85);
--chart-4: oklch(60% 0.22 25);
--chart-5: oklch(60% 0.2 300);
}
/* Light theme */
[data-theme="light"] {
--background: oklch(99% 0.005 260);
--foreground: oklch(15% 0.01 260);
--muted: oklch(95% 0.01 260);
--muted-foreground: oklch(45% 0.02 260);
--border: oklch(90% 0.01 260);
--primary: oklch(50% 0.22 260);
--primary-foreground: oklch(98% 0.01 260);
--secondary: oklch(95% 0.01 260);
--secondary-foreground: oklch(20% 0.01 260);
--accent: oklch(95% 0.02 260);
--accent-foreground: oklch(20% 0.01 260);
--destructive: oklch(55% 0.22 25);
--destructive-foreground: oklch(98% 0.01 260);
--card: oklch(100% 0 0);
--card-foreground: oklch(15% 0.01 260);
--popover: oklch(100% 0 0);
--popover-foreground: oklch(15% 0.01 260);
--ring: oklch(50% 0.22 260);
--input: oklch(90% 0.01 260);
--radius: 0.5rem;
--chart-1: oklch(55% 0.22 260);
--chart-2: oklch(55% 0.2 155);
--chart-3: oklch(65% 0.18 85);
--chart-4: oklch(55% 0.22 25);
--chart-5: oklch(55% 0.2 300);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
/* Minimal — Mantine handles all theming via MantineProvider */
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-reduced-motion: reduce) {
@@ -259,18 +196,33 @@ export default defineConfig({
}
```
### App.tsx base
### main.tsx base
```tsx
import { ThemeProvider } from '@fn_library'
import '@mantine/core/styles.css'
import '@mantine/notifications/styles.css'
import './app.css'
export default function App() {
return (
<ThemeProvider defaultTheme="dark">
{/* Router y contenido aqui */}
</ThemeProvider>
)
}
import React from 'react'
import ReactDOM from 'react-dom/client'
import { MantineProvider, createTheme } from '@mantine/core'
import { Notifications } from '@mantine/notifications'
import App from './App'
const theme = createTheme({
primaryColor: 'blue',
defaultRadius: 'md',
// Customize colors, fonts, etc. here
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<MantineProvider theme={theme} defaultColorScheme="dark">
<Notifications />
<App />
</MantineProvider>
</React.StrictMode>,
)
```
### Despues del scaffold
@@ -287,17 +239,16 @@ Para componentes nuevos que van al registry en `frontend/functions/`.
### Reglas de implementacion
1. **Headless first**: usar `@base-ui/react` como primitivo si el componente es interactivo (dialog, select, tooltip, etc.)
2. **CVA para variantes**: SIEMPRE usar `class-variance-authority` para definir variantes
3. **cn() para clases**: SIEMPRE usar `cn()` de `frontend/functions/core/cn.ts` para componer classNames
4. **CSS variables**: NUNCA hex/rgb/oklch inline en el componente — solo clases Tailwind que mapean a CSS variables (`bg-primary`, `text-muted-foreground`, `border-border`)
5. **Props tipadas**: usar `React.ComponentPropsWithoutRef<"element">` para HTML props spreading
1. **Mantine first**: wrappear componentes de Mantine. Solo crear desde cero si Mantine no tiene equivalente.
2. **Styling via props**: usar props de Mantine (`size`, `color`, `variant`, `p`, `m`, `fw`, `gap`, etc.) y el style system. NUNCA clases CSS manuales ni Tailwind.
3. **CSS variables de Mantine**: si necesitas styles inline, usar `var(--mantine-color-*)`, `var(--mantine-spacing-*)`, etc.
4. **Iconos**: usar `@phosphor-icons/react`, no lucide-react ni @tabler/icons-react.
5. **Props tipadas**: usar `React.ComponentPropsWithoutRef<"element">` para HTML props spreading.
6. **Accesibilidad**:
- Elementos semanticos: `<button>` para acciones, `<a>` para navegacion, `<dialog>` para modales
- Elementos semanticos: `<button>` para acciones, `<a>` para navegacion
- NUNCA `<div onClick>` para elementos interactivos
- `aria-label` o `aria-labelledby` en todo componente interactivo
- `aria-label` en botones de solo icono
- `aria-invalid` + `aria-describedby` en inputs con error
- `role="status"` para loading states
- Focus management en modales/popovers
7. **Discriminated unions** cuando las props cambian segun variante:
@@ -311,54 +262,19 @@ type Props = { size?: 'sm' | 'md' | 'lg'; children: React.ReactNode } & (
### Patron de archivo .tsx
```tsx
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../core/cn'
import { Select, type SelectProps } from '@mantine/core'
const componentVariants = cva(
'base-classes-here', // clases base
{
variants: {
variant: {
default: 'classes...',
secondary: 'classes...',
},
size: {
sm: 'classes...',
md: 'classes...',
lg: 'classes...',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
)
interface ComponentProps
extends React.ComponentPropsWithoutRef<'div'>,
VariantProps<typeof componentVariants> {
// props adicionales con JSDoc
/** Descripcion de la prop */
// Re-export con defaults o logica adicional si necesario
interface MySelectProps extends Omit<SelectProps, 'xxx'> {
customProp?: string
}
const Component = React.forwardRef<HTMLDivElement, ComponentProps>(
({ className, variant, size, customProp, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(componentVariants({ variant, size }), className)}
{...props}
/>
)
}
)
Component.displayName = 'Component'
function MySelect({ customProp, ...props }: MySelectProps) {
return <Select {...props} />
}
export { Component, componentVariants }
export type { ComponentProps }
export { MySelect }
export type { MySelectProps }
```
### Patron de archivo .md
@@ -376,12 +292,12 @@ purity: impure
signature: "ComponentName(props: ComponentProps): JSX.Element"
description: "Descripcion concisa de que hace el componente"
tags: [component, ui, ...]
uses_functions: [cn_ts_core]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@base-ui/react", "class-variance-authority"]
imports: ["@mantine/core"]
tested: false
tests: []
test_file_path: ""
@@ -391,14 +307,10 @@ props:
type: "'default' | 'secondary'"
required: false
description: "Estilo visual"
- name: className
type: "string"
required: false
description: "Clases CSS adicionales"
emits: []
has_state: false
framework: react
variant: [default, secondary]
variant: [default]
---
## Ejemplo
@@ -493,7 +405,7 @@ function useFeatureData() {
```tsx
import { lazy, Suspense } from 'react'
import { Skeleton } from '@fn_library'
import { Skeleton } from '@mantine/core'
const FeaturePage = lazy(() => import('./features/feature/components/FeaturePage'))
@@ -501,7 +413,7 @@ function AppRoutes() {
return (
<Routes>
<Route path="/feature" element={
<Suspense fallback={<Skeleton className="h-screen w-full" />}>
<Suspense fallback={<Skeleton height="100vh" />}>
<FeaturePage />
</Suspense>
} />
@@ -517,14 +429,19 @@ function AppRoutes() {
Antes de dar por terminado cualquier trabajo frontend, verificar:
### Colores y estilos
- [ ] CERO colores hardcodeados (no hex, no rgb, no oklch inline en componentes)
- [ ] Solo clases Tailwind mapeadas a CSS variables: `bg-primary`, `text-foreground`, `border-border`, etc.
- [ ] `cn()` usado para merge de clases en todo componente
- [ ] CVA usado para variantes (no condicionales manuales con ternarios)
- [ ] CERO colores hardcodeados en componentes (no hex, no rgb inline)
- [ ] Styling via props de Mantine (`size`, `color`, `variant`, `p`, `m`, `fw`, `gap`, etc.)
- [ ] Si se necesitan styles inline, usar CSS variables de Mantine (`var(--mantine-color-*)`)
- [ ] NO clases CSS manuales, NO Tailwind, NO cn(), NO CVA
### Componentes del registry
- [ ] Verificado que no se esta recreando algo que ya existe en `@fn_library` (`frontend/functions/ui/`)
- [ ] Componentes de `@fn_library` usados donde aplica: Alert, Badge, Button, Card, Dialog, Input, Label, Select, SimpleSelect, Skeleton, Sparkline, Tabs, Tooltip, FormField, PageHeader, ProgressBar, KPICard, ThemeProvider, DashboardLayout, DataTable, charts (AreaChart, BarChart, LineChart, PieChart, ChartContainer), hooks Wails (useWailsQuery, useWailsMutation, useWailsStream, useWailsEvent)
- [ ] Componentes de `@fn_library` usados donde aplica: Card, Select, SimpleSelect, KPICard, Sparkline, DashboardLayout, DataTable, charts, hooks Wails
- [ ] Componentes de Mantine usados directamente donde `@fn_library` no tiene wrapper: Button, TextInput, Table, Alert, Badge, Skeleton, Tabs, Tooltip, Group, Stack, Grid, Box, Paper, AppShell, Container
### Iconos
- [ ] Usando `@phosphor-icons/react` para iconos
- [ ] NO lucide-react, NO @tabler/icons-react
### TypeScript
- [ ] Props interfaces con `React.ComponentPropsWithoutRef` para HTML spreading
@@ -533,7 +450,7 @@ Antes de dar por terminado cualquier trabajo frontend, verificar:
- [ ] No `any` — usar `unknown` + type guards si es necesario
### Accesibilidad
- [ ] Elementos semanticos (button, a, dialog — no div onClick)
- [ ] Elementos semanticos (button, a — no div onClick)
- [ ] `aria-label` en botones de solo icono
- [ ] `aria-invalid` + `aria-describedby` en inputs con validacion
- [ ] Focus trap en modales y popovers
@@ -555,15 +472,15 @@ Antes de dar por terminado cualquier trabajo frontend, verificar:
## ANTI-PATRONES (nunca hacer)
1. **`<div onClick={...}>`** → usar `<button>` o Base-UI primitivo
2. **`style={{ color: '#3b82f6' }}`** → usar `className="text-primary"`
3. **`import Button from './MyButton'`** cuando existe en la lib → usar `import { Button } from '@fn_library'`
1. **`<div onClick={...}>`** → usar `<button>` o componente Mantine
2. **`style={{ color: '#3b82f6' }}`** → usar prop `c="blue"` o `var(--mantine-color-blue-6)`
3. **`import Button from './MyButton'`** cuando existe en Mantine → usar `import { Button } from '@mantine/core'`
4. **Estado global para todo** → segmentar: server state (React Query), client state (Zustand), form state (React Hook Form), URL state (search params)
5. **`index.ts` en la raiz de `src/`** que re-exporta todo → mata tree-shaking
6. **`// @ts-ignore`** → arreglar el tipo
7. **CSS-in-JS runtime** (styled-components, emotion) → usar Tailwind
8. **Instalar shadcn/ui como dependencia** → los componentes ya estan en el registry, usar `@fn_library`
9. **Crear utilidades que ya existen**: `cn()`, `getSeriesColor()`, `ChartContainer`, `ThemeProvider` ya estan en `@fn_library`
10. **Colores de chart hardcodeados** → usar `--chart-1` a `--chart-5` o `getSeriesColor()`
7. **CSS-in-JS runtime** (styled-components, emotion) → usar props de Mantine
8. **Tailwind, CVA, cn(), clsx** → usar props de Mantine y su style system
9. **Crear utilidades que ya existen**: `getSeriesColor()`, `ChartContainer`, `DashboardLayout`, `DataTable` ya estan en `@fn_library`
10. **Colores de chart hardcodeados** → usar `@mantine/charts` color system o `getSeriesColor()`
$ARGUMENTS
+9 -1
View File
@@ -1,3 +1,11 @@
En todos los frontends se usan los componentes de `@fn_library` (alias a `frontend/functions/ui/`) antes que elementos HTML nativos o librerias externas.
En todos los frontends se usa el sistema de temas basado en CSS variables (`--background`, `--foreground`, `--input`, `--border`, `--popover`, etc.) definidas en `app.css`. Los componentes deben leer estas variables para adaptarse al tema activo. Nunca hardcodear colores.
El sistema de UI es Mantine v9. Todos los componentes de @fn_library wrappean componentes de Mantine.
**Theming:** Cada app define su tema con `createTheme()` de `@mantine/core` y lo pasa a `MantineProvider` (o `FnMantineProvider` de @fn_library). No se usan CSS variables custom — Mantine genera las suyas automaticamente (`--mantine-color-*`).
**Styling:** No se usa Tailwind, CVA, cn(), ni clases CSS manuales. Los componentes se estilizan con props de Mantine (`size`, `color`, `variant`, `p`, `m`, `fw`, etc.) y el style system de Mantine.
**Iconos:** Se usa `@tabler/icons-react` (el set nativo de Mantine), no lucide-react.
**Layout:** Se usan los componentes de layout de Mantine: `Group`, `Stack`, `Grid`, `Flex`, `SimpleGrid`, `AppShell`, `Container`, `Box`, `Paper`.
-25
View File
@@ -1,25 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+6 -6
View File
@@ -1,11 +1,11 @@
export const chartColors = [
'hsl(var(--chart-1, 220 70% 50%))',
'hsl(var(--chart-2, 160 60% 45%))',
'hsl(var(--chart-3, 30 80% 55%))',
'hsl(var(--chart-4, 280 65% 60%))',
'hsl(var(--chart-5, 340 75% 55%))',
'#3b82f6',
'#10b981',
'#f59e0b',
'#8b5cf6',
'#ef4444',
]
export function getChartColor(index: number): string {
return chartColors[index % chartColors.length]
return chartColors[index % chartColors.length]!
}
-40
View File
@@ -1,40 +0,0 @@
---
name: cn
kind: function
lang: ts
domain: core
version: "1.0.0"
purity: pure
signature: "cn(...inputs: ClassValue[]): string"
description: "Combina clases CSS con clsx y resuelve conflictos Tailwind con tailwind-merge. Utilidad fundamental para composición de estilos."
tags: [css, tailwind, classname, merge, utility]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [clsx, tailwind-merge]
params:
- name: inputs
desc: "Clases CSS en cualquier formato: strings, arrays, objetos con condiciones booleanas"
output: "String con clases CSS combinadas y mergeadas, sin duplicados y conflictos Tailwind resueltos"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/cn.ts"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/lib/utils.ts"
---
## Ejemplo
```typescript
cn("px-4 py-2", "px-6") // "px-6 py-2" (tailwind-merge resuelve conflicto)
cn("text-red-500", false && "hidden") // "text-red-500" (clsx filtra falsy)
cn("rounded-lg", className) // composición con className externo
```
## Notas
Base de todo el sistema de estilos. Todos los componentes la usan para componer className.
-6
View File
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}
@@ -1,69 +0,0 @@
---
name: generate_theme_css
kind: function
lang: ts
domain: core
version: "1.0.0"
purity: pure
signature: "generateThemeCss(colors: Record<string, string>, selector?: string): string"
description: "Genera un bloque CSS con variables de tema a partir de un objeto de tokens. Convierte claves camelCase a kebab-case automaticamente. Pura — solo transforma datos, no accede al DOM."
tags: [theme, css, generator, pure]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: colors
desc: "Objeto con pares clave-valor de nombre variable CSS a valor de color"
- name: selector
desc: "Selector CSS donde inyectar variables (':root' por defecto)"
output: "String con bloque CSS completo conteniendo definiciones de variables de tema"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/generate_theme_css.ts"
---
## Ejemplo
```typescript
import { generateThemeCss } from './generate_theme_css'
import { themeConfigToColors } from './theme_config_to_colors'
import { darkTheme } from '../ui/themes'
// Generar CSS para inyectar en <style>
const colors = themeConfigToColors(darkTheme)
const css = generateThemeCss(colors)
// Output:
// :root {
// --background: oklch(8% 0.015 260);
// --foreground: oklch(95% 0.01 260);
// --card: oklch(12% 0.015 260);
// --card-foreground: oklch(95% 0.01 260);
// ...
// }
// Inyectar en el documento
const style = document.createElement('style')
style.textContent = css
document.head.appendChild(style)
// Generar para selector especifico (dark mode)
const darkCss = generateThemeCss(colors, '.dark')
// Output: .dark { --background: oklch(...); ... }
// Generar para multiples selectores
const lightCss = generateThemeCss(lightColors, ':root')
const darkCss2 = generateThemeCss(darkColors, ':root.dark')
const combined = [lightCss, darkCss2].join('\n\n')
```
## Notas
Funcion pura — sin acceso al DOM, sin side effects. Util para SSR, generacion de archivos CSS estaticos, o pre-generar temas en build time.
La conversion camelCase → kebab-case es simple (reemplaza mayusculas con `-` + minuscula). No maneja casos especiales como `backgroundColor``background-color`; los tokens del registry ya usan nombres semanticos directos (`background`, `cardForeground`, etc.).
Compone naturalmente con `themeConfigToColors` del registry: `generateThemeCss(themeConfigToColors(config))`.
@@ -1,23 +0,0 @@
/**
* Genera un bloque CSS con variables de tema para inyectar como &lt;style&gt; o
* escribir en un archivo .css.
*
* Convierte claves camelCase a kebab-case automaticamente:
* `cardForeground` → `--card-foreground`
*
* @param colors - Objeto con tokens de tema. Claves en camelCase, valores CSS.
* @param selector - Selector CSS donde aplicar las variables. Por defecto `:root`.
* @returns String CSS con el bloque completo.
*/
export function generateThemeCss(
colors: Record<string, string>,
selector: string = ':root',
): string {
const lines = Object.entries(colors)
.map(([key, value]) => {
const cssName = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)
return ` --${cssName}: ${value};`
})
.join('\n')
return `${selector} {\n${lines}\n}`
}
+1 -1
View File
@@ -1,7 +1,7 @@
const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899']
export function getSeriesColor(index: number, color?: string): string {
return color || defaultColors[index % defaultColors.length]
return color ?? defaultColors[index % defaultColors.length]!
}
export { defaultColors }
@@ -1,50 +0,0 @@
---
name: get_theme_tokens
kind: function
lang: ts
domain: core
version: "1.0.0"
purity: impure
signature: "getThemeTokens(): ThemeTokens"
description: "Lee todas las CSS variables de tema del documento y devuelve un objeto tipado con los valores computados desde :root. Util para pasar colores a APIs que no entienden CSS variables (canvas, sigma.js, D3)."
tags: [theme, css, tokens, runtime, dom]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
output: "Objeto ThemeTokens con todas las variables CSS de tema resueltas (colores, tipografía, espaciado)"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/get_theme_tokens.ts"
---
## Ejemplo
```typescript
import { getThemeTokens } from './get_theme_tokens'
const tokens = getThemeTokens()
// Pasar colores a sigma.js (que no soporta CSS variables)
const sigmaSettings = {
defaultNodeColor: tokens.primary,
defaultEdgeColor: tokens.muted,
labelColor: { color: tokens.foreground },
}
// Pasar colores a un canvas 2D
const ctx = canvas.getContext('2d')
ctx.fillStyle = tokens.background
ctx.strokeStyle = tokens.border
```
## Notas
Impura — accede a `document.documentElement` y `getComputedStyle`. Solo disponible en browser.
Los valores retornados son los valores sin procesar de las CSS variables (ej: `oklch(8% 0.015 260)`). Para obtener valores RGB computed (necesarios para algunas APIs), usar `getComputedColor`.
Funciona con cualquier tema activo: el resultado cambia automaticamente cuando se cambia el tema via `applyTheme`.
@@ -1,59 +0,0 @@
/** Tokens de tema leidos de las CSS variables activas en :root. */
export interface ThemeTokens {
background: string
foreground: string
card: string
cardForeground: string
popover: string
popoverForeground: string
primary: string
primaryForeground: string
secondary: string
secondaryForeground: string
muted: string
mutedForeground: string
accent: string
accentForeground: string
destructive: string
destructiveForeground: string
success: string
successForeground: string
border: string
input: string
ring: string
}
/**
* Lee todas las CSS variables de tema del documento y devuelve un objeto
* tipado con los valores computados desde :root.
*
* Util para pasar colores a APIs que no entienden CSS variables
* (canvas, sigma.js, D3, etc.).
*/
export function getThemeTokens(): ThemeTokens {
const style = getComputedStyle(document.documentElement)
const get = (name: string) => style.getPropertyValue(`--${name}`).trim()
return {
background: get('background'),
foreground: get('foreground'),
card: get('card'),
cardForeground: get('card-foreground'),
popover: get('popover'),
popoverForeground: get('popover-foreground'),
primary: get('primary'),
primaryForeground: get('primary-foreground'),
secondary: get('secondary'),
secondaryForeground: get('secondary-foreground'),
muted: get('muted'),
mutedForeground: get('muted-foreground'),
accent: get('accent'),
accentForeground: get('accent-foreground'),
destructive: get('destructive'),
destructiveForeground: get('destructive-foreground'),
success: get('success'),
successForeground: get('success-foreground'),
border: get('border'),
input: get('input'),
ring: get('ring'),
}
}
@@ -1,41 +0,0 @@
---
name: theme_config_to_colors
kind: function
lang: ts
domain: core
version: "1.0.0"
purity: pure
signature: "themeConfigToColors(config: ThemeConfig): ThemeColors"
description: "Convierte un ThemeConfig completo a ThemeColors plano para inyectar como CSS variables. Mapea tokens semánticos a variables CSS."
tags: [theme, colors, css-variables, conversion]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: config
desc: "Configuración de tema con propiedades semánticas de color"
output: "Objeto ThemeColors con variables CSS estandarizadas mapeadas de la config"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/core/theme_config_to_colors.ts"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/themes/types.ts"
---
## Ejemplo
```typescript
const colors = themeConfigToColors(darkThemeConfig)
// { background: '...', foreground: '...', primary: '...', ... }
```
## Notas
Puente entre el sistema de temas estructurado (ThemeConfig) y el sistema plano de CSS variables que consumen los componentes.
Depende de los tipos ThemeConfig y ThemeColors definidos en `frontend/types/ui/theme_config.ts`. El tipo aún no está indexado en la BD (pendiente añadir theme_config.md para que fn index lo registre).
@@ -1,49 +0,0 @@
import type { ThemeConfig, ThemeColors } from "../../types/ui/theme_config"
export function themeConfigToColors(config: ThemeConfig): ThemeColors {
const { colors } = config
return {
background: colors.background.default,
foreground: colors.foreground.default,
card: colors.surface.raised,
cardForeground: colors.foreground.default,
popover: colors.surface.overlay,
popoverForeground: colors.foreground.default,
primary: colors.brand.primary,
primaryForeground: colors.brand.primaryForeground,
secondary: colors.brand.secondary,
secondaryForeground: colors.brand.secondaryForeground,
muted: colors.background.muted,
mutedForeground: colors.foreground.muted,
accent: colors.brand.accent,
accentForeground: colors.brand.accentForeground,
destructive: colors.status.error,
destructiveForeground: colors.status.errorForeground,
success: colors.status.success,
successForeground: colors.status.successForeground,
warning: colors.status.warning,
warningForeground: colors.status.warningForeground,
info: colors.status.info,
infoForeground: colors.status.infoForeground,
surface: colors.surface.raised,
surfaceHover: colors.background.subtle,
overlay: colors.surface.overlay,
border: colors.border.default,
input: colors.border.default,
ring: colors.ring,
chart1: colors.chart[1],
chart2: colors.chart[2],
chart3: colors.chart[3],
chart4: colors.chart[4],
chart5: colors.chart[5],
sidebar: colors.sidebar.background,
sidebarForeground: colors.sidebar.foreground,
sidebarPrimary: colors.brand.primary,
sidebarPrimaryForeground: colors.brand.primaryForeground,
sidebarAccent: colors.sidebar.accent,
sidebarAccentForeground: colors.sidebar.accentForeground,
sidebarBorder: colors.sidebar.border,
sidebarRing: colors.sidebar.ring,
}
}
+8 -8
View File
@@ -6,14 +6,14 @@ domain: ui
version: "1.0.0"
purity: impure
signature: "Accordion(props: AccordionProps): JSX.Element"
description: "Secciones colapsables con animaciones. Base-UI Collapsible primitive. Composable: AccordionItem + AccordionTrigger + AccordionContent."
tags: [accordion, collapsible, component, ui, interactive, base-ui]
uses_functions: [cn_ts_core]
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: ["@base-ui/react/collapsible", "lucide-react"]
imports: ["@mantine/core"]
output: "Componente Accordion que renderiza secciones colapsables con soporte para múltiples items abiertos simultáneamente"
tested: false
tests: []
@@ -33,14 +33,14 @@ variant: []
## Ejemplo
```tsx
<Accordion>
<AccordionItem defaultOpen>
<Accordion defaultValue="section-1">
<AccordionItem value="section-1">
<AccordionTrigger>Seccion 1</AccordionTrigger>
<AccordionContent>
Contenido de la primera seccion.
</AccordionContent>
</AccordionItem>
<AccordionItem>
<AccordionItem value="section-2">
<AccordionTrigger>Seccion 2</AccordionTrigger>
<AccordionContent>
Contenido de la segunda seccion.
@@ -51,4 +51,4 @@ variant: []
## Notas
Cada AccordionItem es un Collapsible independiente — permite multiples items abiertos simultaneamente. Para exclusividad (solo uno abierto), manejar el estado externamente. El chevron rota 180 grados con [data-open]. Exports: Accordion, AccordionItem, AccordionTrigger, AccordionContent.
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.
+48 -39
View File
@@ -1,7 +1,5 @@
import * as React from "react"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "../core/cn"
import { Accordion as MantineAccordion } from "@mantine/core"
interface AccordionItem {
value: string
@@ -19,61 +17,72 @@ interface AccordionProps {
children?: React.ReactNode
}
function Accordion({ className, children, ...props }: React.ComponentProps<"div"> & AccordionProps) {
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 (
<div data-slot="accordion" className={cn("divide-y divide-border", className)} {...props}>
<MantineAccordion
data-slot="accordion"
className={className}
defaultValue={typeof defaultValue === "string" ? defaultValue : undefined}
>
{children}
</div>
</MantineAccordion>
)
}
interface AccordionItemProps extends CollapsiblePrimitive.Root.Props {
interface AccordionItemProps {
value: string
className?: string
children?: React.ReactNode
disabled?: boolean
}
function AccordionItem({ className, ...props }: AccordionItemProps) {
function AccordionItem({ className, value, children, ...props }: AccordionItemProps) {
return (
<CollapsiblePrimitive.Root
<MantineAccordion.Item
data-slot="accordion-item"
className={cn("group/accordion-item", className)}
{...props}
/>
)
}
function AccordionTrigger({ className, children, ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"flex w-full items-center justify-between py-4 text-sm font-medium transition-all outline-none",
"hover:underline focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:underline",
"disabled:pointer-events-none disabled:opacity-50",
"[&[data-open]>svg]:rotate-180",
className
)}
value={value}
className={className}
{...props}
>
{children}
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</CollapsiblePrimitive.Trigger>
</MantineAccordion.Item>
)
}
function AccordionContent({ className, children, ...props }: CollapsiblePrimitive.Panel.Props) {
function AccordionTrigger({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
return (
<CollapsiblePrimitive.Panel
data-slot="accordion-content"
className={cn(
"overflow-hidden text-sm",
"data-open:animate-in data-open:fade-in-0",
"data-closed:animate-out data-closed:fade-out-0",
className
)}
<MantineAccordion.Control
data-slot="accordion-trigger"
className={className}
{...props}
>
<div className="pb-4">{children}</div>
</CollapsiblePrimitive.Panel>
{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>
)
}
+77
View File
@@ -0,0 +1,77 @@
---
name: action_icon
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "FnActionIcon(props: FnActionIconProps): JSX.Element"
description: "Boton de icono con variantes, loading y tooltip opcional. Wrapper sobre Mantine ActionIcon."
tags: [mantine, button, icon, action, tooltip, component, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
framework: react
props:
- name: icon
type: "ReactNode"
required: true
description: "Icono a renderizar dentro del boton"
- name: variant
type: "'filled' | 'light' | 'outline' | 'transparent' | 'default' | 'subtle'"
required: false
description: "Variante visual del boton, default 'default'"
- name: size
type: "MantineSize | number"
required: false
description: "Tamano del boton"
- name: color
type: "MantineColor"
required: false
description: "Color del boton"
- name: onClick
type: "MouseEventHandler"
required: false
description: "Callback al hacer click"
- name: loading
type: "boolean"
required: false
description: "Muestra spinner de carga"
- name: disabled
type: "boolean"
required: false
description: "Deshabilita el boton"
- name: tooltip
type: "string"
required: false
description: "Si se provee, envuelve el boton en un Tooltip"
output: "Boton de icono con tooltip opcional, estados loading/disabled y multiples variantes"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/action_icon.tsx"
emits: []
has_state: false
variant: [filled, light, outline, transparent, default, subtle]
---
## Ejemplo
```tsx
import { FnActionIcon } from '@fn_library'
import { IconSettings } from '@tabler/icons-react'
<FnActionIcon
icon={<IconSettings size={18} />}
tooltip="Configuracion"
variant="light"
onClick={() => openSettings()}
/>
```
## Notas
Wrapper sobre Mantine `ActionIcon`. Si se provee `tooltip`, envuelve automaticamente en Mantine `Tooltip`. Compatible con iconos de `@tabler/icons-react`.
+47
View File
@@ -0,0 +1,47 @@
import * as React from 'react'
import { ActionIcon, Tooltip } from '@mantine/core'
import type { MantineSize, MantineColor } from '@mantine/core'
interface FnActionIconProps {
icon: React.ReactNode
variant?: 'filled' | 'light' | 'outline' | 'transparent' | 'default' | 'subtle'
size?: MantineSize | number
color?: MantineColor
onClick?: React.MouseEventHandler<HTMLButtonElement>
loading?: boolean
disabled?: boolean
tooltip?: string
}
function FnActionIcon({
icon,
variant = 'default',
size = 'md',
color,
onClick,
loading,
disabled,
tooltip,
}: FnActionIconProps) {
const button = (
<ActionIcon
variant={variant}
size={size}
color={color}
onClick={onClick}
loading={loading}
disabled={disabled}
>
{icon}
</ActionIcon>
)
if (tooltip) {
return <Tooltip label={tooltip}>{button}</Tooltip>
}
return button
}
export { FnActionIcon }
export type { FnActionIconProps }
+7 -7
View File
@@ -6,15 +6,15 @@ domain: ui
version: "1.0.0"
purity: impure
signature: "Alert(props: { variant?: 'default' | 'destructive' }): JSX.Element"
description: "Alerta accesible con variantes default y destructive. Sistema de slots para título, descripción, icono y acción."
tags: [alert, feedback, component, ui, notification]
uses_functions: [cn_ts_core]
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: [react, class-variance-authority]
output: "Componente Alert que renderiza una alerta accesible con slots para título, descripción, icono y acción"
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: ""
@@ -46,5 +46,5 @@ source_file: "frontend/src/components/ui/alert.tsx"
## Notas
Exporta 4 subcomponentes composables via data-slot: Alert, AlertTitle, AlertDescription, AlertAction.
El icono SVG se posiciona automáticamente en grid cuando es hijo directo de Alert.
AlertAction se posiciona absolute top-right para acciones secundarias (ej: botón cerrar).
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).
+59 -23
View File
@@ -1,34 +1,70 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../core/cn"
import * as React from 'react'
import { Alert as MantineAlert, Box, Text } from '@mantine/core'
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive: "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: { variant: "default" },
}
)
type AlertVariant = 'default' | 'destructive'
function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return <div data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
const variantColorMap: Record<AlertVariant, string | undefined> = {
default: undefined,
destructive: 'red',
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="alert-title" className={cn("font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", className)} {...props} />
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 AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="alert-description" className={cn("text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4", className)} {...props} />
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<Text
component="div"
data-slot="alert-title"
fw={500}
size="sm"
className={className}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="alert-action" className={cn("absolute top-2 right-2", 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 }
+2 -2
View File
@@ -8,12 +8,12 @@ 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: [cn_ts_core]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
imports: [react, "@mantine/core"]
params:
- name: props
desc: "Configuración del dashboard: título, métricas con deltas, y lista de charts con span"
+40 -44
View File
@@ -1,5 +1,5 @@
import * as React from 'react'
import { cn } from '../core/cn'
import { Stack, Group, Title, Text, Paper, SimpleGrid } from '@mantine/core'
interface MetricConfig {
label: string
@@ -34,67 +34,63 @@ export function analyticsPage({
metrics,
charts,
actions,
className,
}: 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 (
<div className={cn('space-y-6', className)}>
<Stack gap="lg">
{/* Header */}
<div className="flex items-center justify-between border-b pb-4">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
</div>
<div className="flex items-center gap-2">
<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}
</div>
</div>
</Group>
</Group>
{/* KPI Row */}
<div className={cn(
'grid gap-4',
metrics.length <= 2 ? 'grid-cols-1 md:grid-cols-2' :
metrics.length <= 3 ? 'grid-cols-1 md:grid-cols-3' :
'grid-cols-1 md:grid-cols-2 lg:grid-cols-4'
)}>
<SimpleGrid cols={metricCols} spacing="md">
{metrics.map((metric, i) => (
<div key={i} className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
<p className="text-sm text-muted-foreground">{metric.label}</p>
<div className="mt-2 flex items-end justify-between gap-4">
<div className="space-y-1">
<p className="text-3xl font-bold tracking-tight">{metric.value}</p>
<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 && (
<div className={cn(
'flex items-center gap-1 text-sm font-medium',
metric.delta.value === 0 ? 'text-muted-foreground' :
metric.delta.isPositive ? 'text-green-600 dark:text-green-500' :
'text-red-600 dark:text-red-500'
)}>
<span>{metric.delta.value > 0 ? '+' : ''}{metric.delta.value}%</span>
</div>
<Text
size="sm"
fw={500}
c={metric.delta.value === 0 ? 'dimmed' : metric.delta.isPositive ? 'green' : 'red'}
>
{metric.delta.value > 0 ? '+' : ''}{metric.delta.value}%
</Text>
)}
</div>
</div>
</div>
</Stack>
</Group>
</Paper>
))}
</div>
</SimpleGrid>
{/* Charts Grid */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
{charts.map((chart) => (
<div
<Paper
key={chart.id}
className={cn(
'rounded-xl border bg-card p-4 text-card-foreground shadow-sm',
chart.span === 2 && 'lg:col-span-2'
)}
p="md"
withBorder
shadow="xs"
radius="md"
style={chart.span === 2 ? { gridColumn: 'span 2' } : undefined}
>
<h3 className="mb-3 text-sm font-medium text-muted-foreground">{chart.title}</h3>
<Text size="sm" fw={500} c="dimmed" mb="sm">{chart.title}</Text>
{chart.content}
</div>
</Paper>
))}
</div>
</div>
</SimpleGrid>
</Stack>
)
}
+65
View File
@@ -0,0 +1,65 @@
---
name: app_shell
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "FnAppShell(props: FnAppShellProps): JSX.Element"
description: "Layout shell con header, navbar colapsable y area principal. Wrapper sobre Mantine AppShell."
tags: [mantine, layout, shell, navigation, component, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
framework: react
props:
- name: header
type: "ReactNode"
required: false
description: "Contenido del header superior"
- name: navbar
type: "ReactNode"
required: false
description: "Contenido del sidebar de navegacion"
- name: navbarWidth
type: "number"
required: false
description: "Ancho del navbar en px, default 250"
- name: navbarCollapsed
type: "boolean"
required: false
description: "Si el navbar esta colapsado"
- name: children
type: "ReactNode"
required: true
description: "Contenido principal del area main"
output: "Layout de aplicacion con header fijo, sidebar colapsable y area de contenido principal"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/app_shell.tsx"
emits: []
has_state: false
variant: []
---
## Ejemplo
```tsx
import { FnAppShell } from '@fn_library'
<FnAppShell
header={<Group px="md">Logo</Group>}
navbar={<NavLinks />}
navbarCollapsed={collapsed}
>
<MainContent />
</FnAppShell>
```
## Notas
Wrapper sobre Mantine `AppShell`. El header tiene altura fija de 60px. El navbar colapsa tanto en mobile como en desktop cuando `navbarCollapsed` es true. El breakpoint de responsive es `sm`.
+33
View File
@@ -0,0 +1,33 @@
import * as React from 'react'
import { AppShell } from '@mantine/core'
interface FnAppShellProps {
header?: React.ReactNode
navbar?: React.ReactNode
navbarWidth?: number
navbarCollapsed?: boolean
children: React.ReactNode
}
function FnAppShell({
header,
navbar,
navbarWidth = 250,
navbarCollapsed = false,
children,
}: FnAppShellProps) {
return (
<AppShell
header={header ? { height: 60 } : undefined}
navbar={navbar ? { width: navbarWidth, breakpoint: 'sm', collapsed: { mobile: navbarCollapsed, desktop: navbarCollapsed } } : undefined}
padding="md"
>
{header && <AppShell.Header>{header}</AppShell.Header>}
{navbar && <AppShell.Navbar p="md">{navbar}</AppShell.Navbar>}
<AppShell.Main>{children}</AppShell.Main>
</AppShell>
)
}
export { FnAppShell }
export type { FnAppShellProps }
-44
View File
@@ -1,44 +0,0 @@
---
name: apply_theme
kind: function
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "applyTheme(theme: Theme): void"
description: "Inyecta un tema como CSS variables en document.documentElement. Maneja clase dark automáticamente. Mapea 40 tokens semánticos."
tags: [theme, css-variables, apply, runtime, ui]
uses_functions: []
uses_types: [ThemeConfig_ts_ui]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: theme
desc: "Objeto Theme con nombre, label y colores a inyectar como CSS variables"
output: "Void - función impura que inyecta CSS variables en document.documentElement"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/apply_theme.tsx"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
source_license: "MIT"
source_file: "frontend/src/hooks/use-theme.tsx"
---
## Ejemplo
```typescript
import { applyTheme } from './apply_theme'
applyTheme({
name: 'dark',
label: 'Oscuro',
colors: themeConfigToColors(darkThemeConfig)
})
```
## Notas
Función impura (modifica el DOM). Mapea cada key de ThemeColors a una CSS variable. Temas oscuros (dark, midnight, sunset) añaden clase `dark` al root.
-111
View File
@@ -1,111 +0,0 @@
interface ThemeColors {
background: string
foreground: string
card: string
cardForeground: string
popover: string
popoverForeground: string
primary: string
primaryForeground: string
secondary: string
secondaryForeground: string
muted: string
mutedForeground: string
accent: string
accentForeground: string
destructive: string
destructiveForeground: string
success: string
successForeground: string
warning: string
warningForeground: string
info: string
infoForeground: string
surface: string
surfaceHover: string
overlay: string
border: string
input: string
ring: string
chart1: string
chart2: string
chart3: string
chart4: string
chart5: string
sidebar: string
sidebarForeground: string
sidebarPrimary: string
sidebarPrimaryForeground: string
sidebarAccent: string
sidebarAccentForeground: string
sidebarBorder: string
sidebarRing: string
}
interface Theme {
name: string
label: string
colors: ThemeColors
}
const cssVarMap: Record<keyof ThemeColors, string> = {
background: '--background',
foreground: '--foreground',
card: '--card',
cardForeground: '--card-foreground',
popover: '--popover',
popoverForeground: '--popover-foreground',
primary: '--primary',
primaryForeground: '--primary-foreground',
secondary: '--secondary',
secondaryForeground: '--secondary-foreground',
muted: '--muted',
mutedForeground: '--muted-foreground',
accent: '--accent',
accentForeground: '--accent-foreground',
destructive: '--destructive',
destructiveForeground: '--destructive-foreground',
success: '--success',
successForeground: '--success-foreground',
warning: '--warning',
warningForeground: '--warning-foreground',
info: '--info',
infoForeground: '--info-foreground',
surface: '--surface',
surfaceHover: '--surface-hover',
overlay: '--overlay',
border: '--border',
input: '--input',
ring: '--ring',
chart1: '--chart-1',
chart2: '--chart-2',
chart3: '--chart-3',
chart4: '--chart-4',
chart5: '--chart-5',
sidebar: '--sidebar',
sidebarForeground: '--sidebar-foreground',
sidebarPrimary: '--sidebar-primary',
sidebarPrimaryForeground: '--sidebar-primary-foreground',
sidebarAccent: '--sidebar-accent',
sidebarAccentForeground: '--sidebar-accent-foreground',
sidebarBorder: '--sidebar-border',
sidebarRing: '--sidebar-ring',
}
export function applyTheme(theme: Theme): void {
const root = document.documentElement
const colors = theme.colors
Object.entries(cssVarMap).forEach(([key, cssVar]) => {
const value = colors[key as keyof ThemeColors]
root.style.setProperty(cssVar, value)
})
if (theme.name === 'dark' || theme.name === 'midnight' || theme.name === 'sunset') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
}
export type { Theme, ThemeColors }
+5 -5
View File
@@ -6,15 +6,15 @@ domain: ui
version: "1.0.0"
purity: impure
signature: "AreaChart(props: AreaChartProps): JSX.Element"
description: "Gráfico de área Recharts con gradientes automáticos, multi-series, stacking y tooltips temáticos."
tags: [chart, area, visualization, recharts, gradient, component, ui]
uses_functions: [cn_ts_core, chart_container_ts_ui, get_series_color_ts_core]
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: [recharts]
output: "Componente JSX que renderiza un gráfico de área con gradientes, multi-series y tooltips temáticos"
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: ""
+29 -37
View File
@@ -1,9 +1,6 @@
import {
AreaChart as RechartsAreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
} from 'recharts'
import { ChartContainer, ChartTooltipContent, type Series, getSeriesColor } from './chart_container'
interface GradientConfig { from: string; to: string }
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>[]
@@ -11,11 +8,10 @@ interface AreaChartProps {
yKey?: string
series?: Series[]
stacked?: boolean
gradient?: GradientConfig | boolean
gradient?: boolean
showGrid?: boolean
showLegend?: boolean
height?: number | string
className?: string
height?: number
xAxisFormatter?: (value: unknown) => string
yAxisFormatter?: (value: unknown) => string
valueFormatter?: (value: number) => string
@@ -23,40 +19,36 @@ interface AreaChartProps {
function AreaChartComponent({
data, xKey, yKey, series, stacked = false, gradient = true, showGrid = true,
showLegend = false, height = 300, className, xAxisFormatter, yAxisFormatter,
showLegend = false, height = 300, xAxisFormatter, yAxisFormatter,
valueFormatter = (v) => v.toLocaleString(),
}: AreaChartProps) {
const areas = series
? series.map((s, i) => ({ dataKey: s.key, name: s.name, color: getSeriesColor(i, s.color) }))
: yKey ? [{ dataKey: yKey, name: yKey, color: getSeriesColor(0) }] : []
const gradientConfig: GradientConfig | null = gradient
? typeof gradient === 'object' ? gradient : { from: '', to: 'transparent' }
: null
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 (
<ChartContainer className={className} height={height}>
<RechartsAreaChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
<defs>
{areas.map((area) => (
<linearGradient key={area.dataKey} id={`gradient-${area.dataKey}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={gradientConfig?.from || area.color} stopOpacity={0.8} />
<stop offset="95%" stopColor={gradientConfig?.to || area.color} stopOpacity={0.1} />
</linearGradient>
))}
</defs>
{showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />}
<XAxis dataKey={xKey} tickFormatter={xAxisFormatter} className="text-xs fill-muted-foreground" />
<YAxis tickFormatter={yAxisFormatter} className="text-xs fill-muted-foreground" />
<Tooltip content={<ChartTooltipContent valueFormatter={valueFormatter} />} cursor={{ stroke: 'hsl(var(--muted-foreground))', strokeDasharray: '3 3' }} />
{showLegend && <Legend />}
{areas.map((area) => (
<Area key={area.dataKey} type="monotone" dataKey={area.dataKey} name={area.name} stroke={area.color} strokeWidth={2} fill={gradient ? `url(#gradient-${area.dataKey})` : area.color} fillOpacity={gradient ? 1 : 0.3} stackId={stacked ? 'stack' : undefined} />
))}
</RechartsAreaChart>
</ChartContainer>
<Paper p="md">
<MantineAreaChart
h={height}
data={data}
dataKey={xKey}
series={chartSeries}
type={stacked ? 'stacked' : 'default'}
curveType="monotone"
withGradient={gradient}
gridAxis={showGrid ? 'xy' : 'none'}
withLegend={showLegend}
withTooltip
valueFormatter={valueFormatter}
xAxisProps={xAxisFormatter ? { tickFormatter: xAxisFormatter } : undefined}
yAxisProps={yAxisFormatter ? { tickFormatter: yAxisFormatter } : undefined}
/>
</Paper>
)
}
/** @deprecated Gradient is handled by Mantine's withGradient prop */
type GradientConfig = { from: string; to: string }
export const AreaChart = AreaChartComponent
export type { AreaChartProps, GradientConfig }
+101
View File
@@ -0,0 +1,101 @@
---
name: auth_form
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "AuthForm(config: AuthFormConfig): ReactElement"
description: "Genera página de autenticación con toggle login/register, social buttons opcionales, campos extra en registro y validación. Basado en Mantine AuthenticationForm."
tags: [auth, login, register, form, page, ui, mantine, toggle]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core", "@mantine/hooks"]
has_state: true
framework: react
emits: [onSubmit]
params:
- name: title
desc: "Título principal que aparece en la cabecera del formulario (default: 'Welcome')"
- name: socialButtons
desc: "Lista de botones de login social, cada uno con label, icono opcional y callback onClick"
- name: extraFields
desc: "Campos de texto adicionales que se muestran únicamente en el modo registro (ej: nombre, empresa)"
- name: onSubmit
desc: "Callback invocado al enviar el formulario con type ('login'|'register'), email, password y valores de extraFields"
- name: defaultType
desc: "Modo inicial del formulario: 'login' (default) o 'register'"
- name: paperProps
desc: "Props de Mantine Paper para personalizar el contenedor (shadow, radius, p, etc.)"
output: "Página de autenticación completa con toggle login/register, campos email/password, botones sociales opcionales y campo de términos en registro"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/auth_form.tsx"
source_repo: ""
source_license: ""
source_file: ""
---
## Ejemplo
### Config mínima (solo login)
```tsx
import { AuthForm } from '@fn_library/auth_form'
function LoginPage() {
return (
<AuthForm
title="Acceder"
onSubmit={({ type, email, password }) => {
console.log(type, email, password)
}}
/>
)
}
```
### Con social buttons y campos extra en registro
```tsx
import { AuthForm } from '@fn_library/auth_form'
import { IconBrandGoogle, IconBrandGithub } from '@tabler/icons-react'
function AuthPage() {
return (
<AuthForm
title="fn_registry"
defaultType="register"
socialButtons={[
{ label: 'Google', icon: <IconBrandGoogle size={16} />, onClick: () => signInWithGoogle() },
{ label: 'GitHub', icon: <IconBrandGithub size={16} />, onClick: () => signInWithGitHub() },
]}
extraFields={[
{ name: 'name', label: 'Nombre completo', placeholder: 'Lucas García', required: true },
{ name: 'company', label: 'Empresa', placeholder: 'Acme Corp' },
]}
onSubmit={({ type, email, password, name, company }) => {
if (type === 'register') {
registerUser({ email, password, name, company })
} else {
loginUser({ email, password })
}
}}
/>
)
}
```
## Notas
Función con estado interno (useToggle, useState de @mantine/hooks). Gestiona el toggle entre login y register sin prop externa — el padre solo recibe el valor final via onSubmit.type.
Los `extraFields` solo se renderizan en modo register. El campo de términos y condiciones (Checkbox) también es exclusivo del registro.
Los `socialButtons` se renderizan con un Divider "O continúa con email" cuando están presentes. Sin socialButtons el Divider no aparece.
El campo `password` usa PasswordInput de Mantine, que incluye el toggle de visibilidad integrado.
+181
View File
@@ -0,0 +1,181 @@
import * as React from 'react'
import {
Anchor,
Button,
Checkbox,
Divider,
Group,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
Title,
Container,
type PaperProps,
} from '@mantine/core'
import { useToggle, upperFirst } from '@mantine/hooks'
interface SocialButtonConfig {
label: string
icon?: React.ReactNode
onClick?: () => void
}
interface ExtraFieldConfig {
name: string
label: string
placeholder?: string
required?: boolean
}
interface AuthFormSubmitValues {
type: 'login' | 'register'
email: string
password: string
[key: string]: unknown
}
interface AuthFormConfig {
/** Título principal de la página */
title?: string
/** Botones de autenticación social opcionales */
socialButtons?: SocialButtonConfig[]
/** Campos adicionales que se muestran solo en el modo registro */
extraFields?: ExtraFieldConfig[]
/** Callback invocado al enviar el formulario */
onSubmit?: (values: AuthFormSubmitValues) => void
/** Modo inicial: 'login' (default) o 'register' */
defaultType?: 'login' | 'register'
/** Props adicionales para el Paper contenedor */
paperProps?: PaperProps
}
function AuthForm({
title = 'Welcome',
socialButtons = [],
extraFields = [],
onSubmit,
defaultType = 'login',
paperProps,
}: AuthFormConfig): React.ReactElement {
const [type, toggle] = useToggle<'login' | 'register'>([
defaultType,
defaultType === 'login' ? 'register' : 'login',
])
const [email, setEmail] = React.useState('')
const [password, setPassword] = React.useState('')
const [terms, setTerms] = React.useState(true)
const [extraValues, setExtraValues] = React.useState<Record<string, string>>({})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit?.({ type, email, password, ...extraValues })
}
const handleExtraChange = (name: string, value: string) => {
setExtraValues((prev) => ({ ...prev, [name]: value }))
}
return (
<Container size={420} py={40}>
<Paper radius="md" p="lg" withBorder {...paperProps}>
<Title order={2} ta="center" mb="md">
{title}
</Title>
{socialButtons.length > 0 && (
<>
<Group grow mb="md" gap="xs">
{socialButtons.map((btn) => (
<Button
key={btn.label}
variant="default"
radius="xl"
leftSection={btn.icon}
onClick={btn.onClick}
>
{btn.label}
</Button>
))}
</Group>
<Divider label="O continúa con email" labelPosition="center" my="lg" />
</>
)}
<form onSubmit={handleSubmit}>
<Stack gap="sm">
{type === 'register' &&
extraFields.map((field) => (
<TextInput
key={field.name}
label={field.label}
placeholder={field.placeholder}
required={field.required}
value={extraValues[field.name] ?? ''}
onChange={(e) => handleExtraChange(field.name, e.currentTarget.value)}
radius="md"
/>
))}
<TextInput
required
label="Email"
placeholder="tu@email.com"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
radius="md"
/>
<PasswordInput
required
label="Contraseña"
placeholder="Tu contraseña"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
radius="md"
/>
{type === 'register' && (
<Checkbox
label="Acepto los términos y condiciones"
checked={terms}
onChange={(e) => setTerms(e.currentTarget.checked)}
/>
)}
</Stack>
<Group justify="space-between" mt="xl">
<Anchor
component="button"
type="button"
c="dimmed"
size="xs"
onClick={() => toggle()}
>
{type === 'register'
? '¿Ya tienes cuenta? Inicia sesión'
: '¿No tienes cuenta? Regístrate'}
</Anchor>
<Button type="submit" radius="xl">
{upperFirst(type)}
</Button>
</Group>
</form>
{type === 'register' && (
<Text c="dimmed" size="xs" ta="center" mt="md">
Al registrarte aceptas nuestra{' '}
<Anchor size="xs" href="#">
política de privacidad
</Anchor>
.
</Text>
)}
</Paper>
</Container>
)
}
export { AuthForm }
export type { AuthFormConfig, AuthFormSubmitValues, SocialButtonConfig, ExtraFieldConfig }
+136
View File
@@ -0,0 +1,136 @@
---
name: autocomplete
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Autocomplete(props: AutocompleteProps): JSX.Element"
description: "Input con sugerencias de autocompletado. Permite valores libres a diferencia de Select. Wrapper sobre Mantine Autocomplete."
tags: [autocomplete, input, form, suggestions, component, ui, interactive, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["@mantine/core"]
output: "Componente Autocomplete que renderiza input con dropdown de sugerencias filtradas"
props:
- name: data
type: "string[] | { value: string; label?: string; group?: string }[]"
required: true
description: "Lista de opciones a mostrar en el dropdown"
- name: value
type: "string"
required: false
description: "Valor controlado del input"
- name: onChange
type: "(value: string) => void"
required: false
description: "Callback al cambiar el valor del input"
- name: label
type: "string"
required: false
description: "Etiqueta visible encima del input"
- name: placeholder
type: "string"
required: false
description: "Texto placeholder cuando el input está vacío"
- name: clearable
type: "boolean"
required: false
description: "Muestra botón para limpiar el valor"
- name: loading
type: "boolean"
required: false
description: "Muestra spinner de carga en el input"
- name: disabled
type: "boolean"
required: false
description: "Deshabilita el input"
- name: limit
type: "number"
required: false
description: "Número máximo de sugerencias a mostrar en el dropdown"
- name: size
type: "'xs' | 'sm' | 'md' | 'lg' | 'xl'"
required: false
description: "Tamaño visual del input"
emits: [onChange]
has_state: true
framework: react
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/autocomplete.tsx"
---
## Ejemplo
```tsx
import { Autocomplete } from '@fn_library/autocomplete'
// Básico — lista de strings
function BasicAutocomplete() {
return (
<Autocomplete
label="País"
placeholder="Escribe para buscar..."
data={['Argentina', 'Brasil', 'Chile', 'Colombia', 'Uruguay']}
/>
)
}
// Con grupos
function GroupedAutocomplete() {
return (
<Autocomplete
label="Ciudad"
placeholder="Selecciona una ciudad"
data={[
{ value: 'Buenos Aires', group: 'Argentina' },
{ value: 'Rosario', group: 'Argentina' },
{ value: 'São Paulo', group: 'Brasil' },
{ value: 'Río de Janeiro', group: 'Brasil' },
]}
limit={5}
/>
)
}
// Con loading y clearable (búsqueda asíncrona)
function AsyncAutocomplete() {
const [value, setValue] = useState('')
const [data, setData] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const handleChange = async (val: string) => {
setValue(val)
if (val.length < 2) return
setLoading(true)
const results = await fetchSuggestions(val)
setData(results)
setLoading(false)
}
return (
<Autocomplete
label="Búsqueda"
placeholder="Escribe al menos 2 caracteres..."
value={value}
onChange={handleChange}
data={data}
loading={loading}
clearable
/>
)
}
```
## Notas
A diferencia de `Select`, `Autocomplete` permite que el usuario ingrese cualquier valor libre, no solo los de la lista. Ideal para búsquedas con sugerencias donde el valor final puede no estar en el dataset.
Cuando `data` contiene objetos con `group`, el dropdown agrupa visualmente las opciones bajo el encabezado del grupo.
El prop `limit` controla cuántas sugerencias se muestran simultáneamente (por defecto Mantine muestra todas). Útil para datasets grandes o búsquedas asíncronas donde se quiere limitar el ruido visual.
+10
View File
@@ -0,0 +1,10 @@
import { Autocomplete as MantineAutocomplete, type AutocompleteProps as MantineAutcompleteProps } from '@mantine/core'
interface AutocompleteProps extends MantineAutcompleteProps {}
function Autocomplete(props: AutocompleteProps) {
return <MantineAutocomplete {...props} />
}
export { Autocomplete }
export type { AutocompleteProps }
+6 -6
View File
@@ -6,14 +6,14 @@ 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 CVA."
tags: [avatar, user, image, component, ui, cva]
uses_functions: [cn_ts_core]
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: ["class-variance-authority"]
imports: ["@mantine/core"]
output: "Componente Avatar que renderiza imagen de usuario circular con fallback a iniciales generadas"
tested: false
tests: []
@@ -45,7 +45,7 @@ props:
required: false
description: "Clases CSS adicionales"
emits: []
has_state: true
has_state: false
framework: react
variant: [xs, sm, md, lg, xl]
---
@@ -68,4 +68,4 @@ variant: [xs, sm, md, lg, xl]
## Notas
Usa estado interno para manejar errores de carga de imagen (onError). 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.
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.
+33 -45
View File
@@ -1,69 +1,57 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../core/cn"
import * as React from 'react'
import { Avatar as MantineAvatar } from '@mantine/core'
const avatarVariants = cva(
"relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted font-medium text-muted-foreground select-none",
{
variants: {
size: {
xs: "size-6 text-xs",
sm: "size-8 text-sm",
md: "size-10 text-base",
lg: "size-12 text-lg",
xl: "size-16 text-xl",
},
},
defaultVariants: { size: "md" },
}
)
type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
interface AvatarProps
extends React.ComponentPropsWithoutRef<"span">,
VariantProps<typeof avatarVariants> {
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 "?"
if (!name) return '?'
const parts = name.trim().split(/\s+/)
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
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()
}
const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
({ className, size, src, alt, fallback, initials, ...props }, ref) => {
const [imgError, setImgError] = React.useState(false)
const showImage = src && !imgError
/** 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 (
<span
<MantineAvatar
ref={ref}
data-slot="avatar"
className={cn(avatarVariants({ size }), className)}
src={src}
alt={alt ?? ''}
size={sizeMap[size]}
radius="xl"
className={className}
{...props}
>
{showImage ? (
<img
src={src}
alt={alt ?? ""}
className="aspect-square size-full object-cover"
onError={() => setImgError(true)}
/>
) : (
<span data-slot="avatar-fallback" aria-hidden="true">
{displayInitials}
</span>
)}
</span>
{displayInitials}
</MantineAvatar>
)
}
)
Avatar.displayName = "Avatar"
Avatar.displayName = 'Avatar'
export { Avatar, avatarVariants }
export type { AvatarProps }
export type { AvatarProps, AvatarSize }
+5 -5
View File
@@ -6,14 +6,14 @@ 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."
tags: [badge, status, component, ui, indicator]
uses_functions: [cn_ts_core]
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: ["class-variance-authority"]
imports: ["@mantine/core"]
output: "Componente Badge que renderiza un indicador visual con 10 variantes semánticas de estado"
tested: false
tests: []
@@ -46,4 +46,4 @@ source_file: "frontend/src/components/ui/badge.tsx"
## Notas
Versión simplificada que usa span nativo en lugar de useRender de Base-UI. Mantiene todas las variantes y la composibilidad con cn().
Usa Mantine Badge internamente. Las 10 variantes se mapean a combinaciones de variant+color de Mantine (filled, light, outline, subtle, transparent).
+37 -35
View File
@@ -1,45 +1,47 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../core/cn"
import * as React from 'react'
import { Badge as MantineBadge } from '@mantine/core'
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: "bg-destructive/10 text-destructive [a]:hover:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
success: "bg-green-500/10 text-green-600 dark:bg-green-500/20 dark:text-green-400",
warning: "bg-yellow-500/10 text-yellow-600 dark:bg-yellow-500/20 dark:text-yellow-400",
error: "bg-red-500/10 text-red-600 dark:bg-red-500/20 dark:text-red-400",
info: "bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400",
},
size: {
default: "h-5 px-2 text-xs",
sm: "h-4 px-1.5 text-[10px]",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link' | 'success' | 'warning' | 'error' | 'info'
type BadgeSize = 'default' | 'sm'
interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
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]
function Badge({ className, variant = "default", size = "default", ...props }: BadgeProps) {
return (
<span
<MantineBadge
data-slot="badge"
className={cn(badgeVariants({ variant, size }), className)}
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 }
+6 -6
View File
@@ -6,15 +6,15 @@ domain: ui
version: "1.1.0"
purity: impure
signature: "BarChart(props: BarChartProps): JSX.Element"
description: "Gráfico de barras Recharts con multi-series, orientación horizontal/vertical, tooltips temáticos y bordes redondeados."
tags: [chart, bar, visualization, recharts, component, ui]
uses_functions: [cn_ts_core, chart_container_ts_ui, get_series_color_ts_core]
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: [recharts]
output: "Componente JSX que renderiza un gráfico de barras vertical u horizontal con multi-series y tooltips temáticos"
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: ""
@@ -54,4 +54,4 @@ source_file: "frontend/src/components/ui/charts/bar-chart.tsx"
## Notas
En modo `horizontal=true`: el layout de Recharts es `'vertical'`, YAxis recibe `dataKey={xKey}` con `type="category"` (categorías en eje Y), XAxis recibe `type="number"` (valores en eje X). El radius de las barras se ajusta a `[0, 4, 4, 0]` para redondear la punta derecha. Este intercambio de ejes es obligatorio — sin él las barras horizontales no se renderizan.
En modo `horizontal=true` se pasa `orientation="vertical"` a Mantine BarChart, que internamente intercambia los ejes.
+23 -29
View File
@@ -1,7 +1,6 @@
import {
BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
} from 'recharts'
import { ChartContainer, ChartTooltipContent, type Series, getSeriesColor } from './chart_container'
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>[]
@@ -11,8 +10,7 @@ interface BarChartProps {
horizontal?: boolean
showGrid?: boolean
showLegend?: boolean
height?: number | string
className?: string
height?: number
xAxisFormatter?: (value: unknown) => string
yAxisFormatter?: (value: unknown) => string
valueFormatter?: (value: number) => string
@@ -20,32 +18,28 @@ interface BarChartProps {
function BarChartComponent({
data, xKey, yKey, series, horizontal = false, showGrid = true, showLegend = false,
height = 300, className, xAxisFormatter, yAxisFormatter, valueFormatter = (v) => v.toLocaleString(),
height = 300, xAxisFormatter, yAxisFormatter, valueFormatter = (v) => v.toLocaleString(),
}: BarChartProps) {
const bars = series
? series.map((s, i) => ({ dataKey: s.key, name: s.name, fill: getSeriesColor(i, s.color) }))
: yKey ? [{ dataKey: yKey, name: yKey, fill: getSeriesColor(0) }] : []
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 (
<ChartContainer className={className} height={height}>
<RechartsBarChart data={data} layout={horizontal ? 'vertical' : 'horizontal'} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
{showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />}
{horizontal ? (
<>
<XAxis type="number" tickFormatter={yAxisFormatter} className="text-xs fill-muted-foreground" />
<YAxis dataKey={xKey} type="category" tickFormatter={xAxisFormatter} width={80} className="text-xs fill-muted-foreground" />
</>
) : (
<>
<XAxis dataKey={xKey} tickFormatter={xAxisFormatter} className="text-xs fill-muted-foreground" />
<YAxis tickFormatter={yAxisFormatter} className="text-xs fill-muted-foreground" />
</>
)}
<Tooltip content={<ChartTooltipContent valueFormatter={valueFormatter} />} cursor={{ fill: 'hsl(var(--muted) / 0.3)' }} />
{showLegend && <Legend />}
{bars.map((bar) => <Bar key={bar.dataKey} dataKey={bar.dataKey} name={bar.name} fill={bar.fill} radius={horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]} />)}
</RechartsBarChart>
</ChartContainer>
<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>
)
}
+5 -5
View File
@@ -6,14 +6,14 @@ 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."
tags: [breadcrumb, navigation, component, ui]
uses_functions: [cn_ts_core]
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: ["lucide-react"]
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: []
@@ -69,4 +69,4 @@ variant: []
## Notas
Exports: Breadcrumb (nav), BreadcrumbList (ol), BreadcrumbItem (li), BreadcrumbLink (a con asChild), BreadcrumbPage (span aria-current=page), BreadcrumbSeparator (ChevronRight por defecto, customizable), BreadcrumbEllipsis (MoreHorizontal). BreadcrumbLink acepta asChild para usar con Link de React Router o Next.js.
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.
+34 -36
View File
@@ -1,28 +1,24 @@
import * as React from "react"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
import { cn } from "../core/cn"
import { Anchor, Text, Box } from "@mantine/core"
import { IconChevronRight, IconDots } from "@tabler/icons-react"
function Breadcrumb({ ...props }: React.ComponentPropsWithoutRef<"nav">) {
return <nav data-slot="breadcrumb" aria-label="breadcrumb" {...props} />
function Breadcrumb({ children, ...props }: React.ComponentPropsWithoutRef<"nav">) {
return <nav data-slot="breadcrumb" aria-label="breadcrumb" {...props}>{children}</nav>
}
function BreadcrumbList({ className, ...props }: React.ComponentPropsWithoutRef<"ol">) {
function BreadcrumbList({ className, children, ...props }: React.ComponentPropsWithoutRef<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn("flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5", className)}
{...props}
/>
<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, ...props }: React.ComponentPropsWithoutRef<"li">) {
function BreadcrumbItem({ className, children, ...props }: React.ComponentPropsWithoutRef<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
<li data-slot="breadcrumb-item" style={{ display: "flex", alignItems: "center", gap: 8 }} className={className} {...props}>
{children}
</li>
)
}
@@ -35,31 +31,29 @@ function BreadcrumbLink({
}: React.ComponentPropsWithoutRef<"a"> & { asChild?: boolean }) {
if (asChild) {
return (
<span data-slot="breadcrumb-link" className={cn("transition-colors hover:text-foreground", className)} {...(props as React.ComponentPropsWithoutRef<"span">)}>
<Text data-slot="breadcrumb-link" component="span" size="sm" className={className} {...(props as React.ComponentPropsWithoutRef<"span">)}>
{children}
</span>
</Text>
)
}
return (
<a
data-slot="breadcrumb-link"
href={href}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
>
<Anchor data-slot="breadcrumb-link" href={href} size="sm" className={className} {...props}>
{children}
</a>
</Anchor>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentPropsWithoutRef<"span">) {
return (
<span
<Text
data-slot="breadcrumb-page"
component="span"
size="sm"
fw={500}
role="link"
aria-current="page"
aria-disabled="true"
className={cn("font-medium text-foreground", className)}
className={className}
{...props}
/>
)
@@ -67,30 +61,34 @@ function BreadcrumbPage({ className, ...props }: React.ComponentPropsWithoutRef<
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
return (
<li
<Box
data-slot="breadcrumb-separator"
component="li"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
className={className}
style={{ display: "flex", alignItems: "center" }}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
{children ?? <IconChevronRight size={14} />}
</Box>
)
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentPropsWithoutRef<"span">) {
return (
<span
<Box
data-slot="breadcrumb-ellipsis"
component="span"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
style={{ display: "flex", width: 36, height: 36, alignItems: "center", justifyContent: "center" }}
className={className}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More</span>
</span>
<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>
)
}
+5 -5
View File
@@ -6,14 +6,14 @@ 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. Base-UI primitivo con CVA."
tags: [button, component, ui, interactive, cva]
uses_functions: [cn_ts_core]
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: ["@base-ui/react", "class-variance-authority"]
imports: ["@mantine/core"]
output: "JSX.Element: botón renderizado con los estilos y comportamientos configurados"
tested: false
tests: []
@@ -51,4 +51,4 @@ source_file: "frontend/src/components/ui/button.tsx"
## Notas
Componente base del sistema. Usa Base-UI Button primitive para accesibilidad completa (keyboard, ARIA). CVA para gestión type-safe de variantes.
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.
+51 -39
View File
@@ -1,52 +1,64 @@
"use client"
import * as React from 'react'
import { Button as MantineButton } from '@mantine/core'
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../core/cn"
type ButtonVariant = 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
type ButtonSize = 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline: "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem]",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)]",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)]",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
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",
variant = 'default',
size = 'default',
style,
children,
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
}: ButtonProps) {
const mv = variantMap[variant]
const ms = sizeMap[size]
return (
<ButtonPrimitive
<MantineButton
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
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 }
+1 -1
View File
@@ -8,7 +8,7 @@ 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: [cn_ts_core]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
+46 -36
View File
@@ -1,89 +1,99 @@
import * as React from "react"
import { cn } from "../core/cn"
import * as React from 'react'
import { Paper, Box, Text } from '@mantine/core'
type CardVariant = "default" | "borderless" | "ghost"
type CardVariant = 'default' | 'borderless' | 'ghost'
function Card({
className,
size = "default",
variant = "default",
size = 'default',
variant = 'default',
children,
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm"; variant?: CardVariant }) {
}: React.ComponentProps<'div'> & { size?: 'default' | 'sm'; variant?: CardVariant }) {
return (
<div
<Paper
data-slot="card"
data-size={size}
data-variant={variant}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
variant === "default" && "ring-1 ring-foreground/10",
variant === "borderless" && "ring-0 shadow-none",
variant === "ghost" && "ring-0 shadow-none bg-transparent",
className
)}
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">) {
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
<Box
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
pb="xs"
className={className}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
<Text
component="div"
data-slot="card-title"
className={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
fw={600}
size="sm"
className={className}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
<Text
component="div"
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
size="sm"
c="dimmed"
className={className}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
function CardAction({ className, style, ...props }: React.ComponentProps<'div'>) {
return (
<div
<Box
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
style={{ position: 'absolute', top: 0, right: 0, ...style }}
className={className}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
<Box
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
className={className}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
<Box
data-slot="card-footer"
className={cn("flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3", className)}
pt="sm"
mt="auto"
style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}
className={className}
{...props}
/>
)
+9 -7
View File
@@ -6,15 +6,15 @@ domain: ui
version: "1.0.0"
purity: impure
signature: "ChartContainer(props: { children: ReactNode; height?: number | string }): JSX.Element"
description: "Base para todos los charts Recharts: container responsive, tooltip temático, legend y utilidades de colores por serie."
tags: [chart, container, recharts, base, visualization, component, ui]
uses_functions: [cn_ts_core, get_series_color_ts_core]
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: [recharts, react]
output: "Componente ChartContainer que renderiza base responsive para gráficos Recharts con tooltip y legend temáticos"
imports: ["@mantine/core"]
output: "Componente ChartContainer Paper wrapper y utilidades getSeriesColor/Series para charts Mantine"
tested: false
tests: []
test_file_path: ""
@@ -40,11 +40,13 @@ source_file: "frontend/src/components/ui/charts/chart-base.tsx"
## Ejemplo
```tsx
import { ChartContainer, getSeriesColor, type Series } from './chart_container'
<ChartContainer height={400}>
<RechartsLineChart data={data}>...</RechartsLineChart>
<MantineLineChart ... />
</ChartContainer>
```
## Notas
Exporta: ChartContainer, ChartTooltipContent, ChartTooltip, ChartLegend, chartColors, defaultColors, getSeriesColor, Series.
Exporta: ChartContainer, defaultColors, getSeriesColor, Series. Wrapper fino sobre Mantine Paper para layout uniforme de charts.
+12 -59
View File
@@ -1,14 +1,4 @@
import * as React from 'react'
import { cn } from '../core/cn'
import { ResponsiveContainer, Tooltip as RechartsTooltip, Legend as RechartsLegend } from 'recharts'
export const chartColors = [
'hsl(var(--chart-1, 220 70% 50%))',
'hsl(var(--chart-2, 160 60% 45%))',
'hsl(var(--chart-3, 30 80% 55%))',
'hsl(var(--chart-4, 280 65% 60%))',
'hsl(var(--chart-5, 340 75% 55%))',
]
import { Paper } from '@mantine/core'
export const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899']
@@ -19,62 +9,25 @@ export interface Series {
}
export function getSeriesColor(index: number, color?: string): string {
return color || defaultColors[index % defaultColors.length]
return color || defaultColors[index % defaultColors.length]!
}
interface ChartContainerProps {
children: React.ReactNode
className?: string
height?: number | string
}
export function ChartContainer({ children, className, height = 300 }: ChartContainerProps) {
export function ChartContainer({ children, height = 300 }: ChartContainerProps) {
return (
<div
className={cn('w-full', className)}
style={{ height, minWidth: 100, minHeight: typeof height === 'number' ? Math.min(height, 100) : 100 }}
>
<ResponsiveContainer width="100%" height="100%">
{children as React.ReactElement}
</ResponsiveContainer>
</div>
<Paper p="md" style={{ height, minWidth: 100, minHeight: typeof height === 'number' ? Math.min(height, 100) : 100 }}>
{children}
</Paper>
)
}
interface ChartTooltipContentProps {
active?: boolean
payload?: Array<{ name: string; value: number; color: string; dataKey: string }>
label?: string
labelFormatter?: (label: string) => string
valueFormatter?: (value: number) => string
}
export function ChartTooltipContent({
active, payload, label,
labelFormatter = (l) => l,
valueFormatter = (v) => v.toLocaleString(),
}: ChartTooltipContentProps) {
if (!active || !payload?.length) return null
return (
<div className="rounded-lg border bg-background p-2 shadow-md">
<p className="mb-1 text-sm font-medium">{labelFormatter(label || '')}</p>
<div className="space-y-0.5">
{payload.map((entry, index) => (
<div key={index} className="flex items-center gap-2 text-sm">
<div className="size-2.5 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="text-muted-foreground">{entry.name}:</span>
<span className="font-medium">{valueFormatter(entry.value)}</span>
</div>
))}
</div>
</div>
)
}
export function ChartTooltip(props: React.ComponentProps<typeof RechartsTooltip>) {
return <RechartsTooltip content={<ChartTooltipContent />} cursor={{ fill: 'hsl(var(--muted) / 0.3)' }} {...props} />
}
export function ChartLegend(props: React.ComponentProps<typeof RechartsLegend>) {
return <RechartsLegend wrapperStyle={{ paddingTop: 16 }} {...props} />
}
/** @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 }
+5 -5
View File
@@ -6,14 +6,14 @@ domain: ui
version: "1.0.0"
purity: impure
signature: "Checkbox(props: CheckboxProps): JSX.Element"
description: "Input booleano accesible con label opcional y variante indeterminate. Base-UI Checkbox primitive."
tags: [checkbox, component, ui, interactive, form, base-ui]
uses_functions: [cn_ts_core]
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: ["@base-ui/react/checkbox", "class-variance-authority"]
imports: ["@mantine/core"]
output: "Componente Checkbox que renderiza input booleano accesible con label opcional y estado indeterminate"
tested: false
tests: []
@@ -70,4 +70,4 @@ variant: []
## Notas
Usa Base-UI Checkbox primitive para accesibilidad completa (keyboard, ARIA). El estado indeterminate se muestra con un guion horizontal. El id se genera automaticamente con useId si no se provee.
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.
+21 -65
View File
@@ -1,76 +1,32 @@
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "../core/cn"
import { Checkbox as MantineCheckbox } from "@mantine/core"
interface CheckboxProps extends CheckboxPrimitive.Root.Props {
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, ...props }: CheckboxProps) {
const internalId = React.useId()
const checkboxId = id ?? internalId
function Checkbox({ className, label, id, indeterminate, checked, defaultChecked, disabled, onCheckedChange, ...props }: CheckboxProps) {
return (
<div className="flex items-center gap-2">
<CheckboxPrimitive.Root
id={checkboxId}
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded border border-input bg-transparent transition-colors outline-none",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
"data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground",
"data-indeterminate:border-primary data-indeterminate:bg-primary data-indeterminate:text-primary-foreground",
"disabled:pointer-events-none disabled:opacity-50",
className
)}
indeterminate={indeterminate}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current"
>
{indeterminate ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
className="size-3"
>
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
{label && (
<label
htmlFor={checkboxId}
data-slot="checkbox-label"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 cursor-pointer select-none"
>
{label}
</label>
)}
</div>
<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}
/>
)
}
+88
View File
@@ -0,0 +1,88 @@
---
name: chip
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Chip(props: ChipProps): JSX.Element"
description: "Chip seleccionable con variantes filled/outline/light. ChipGroup para selección simple o múltiple. Wrapper sobre Mantine Chip."
tags: [chip, toggle, selection, component, ui, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
output: "Componente Chip que renderiza un elemento seleccionable tipo badge, con ChipGroup para gestionar selección simple o múltiple entre varios chips"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/chip.tsx"
props:
- name: checked
type: "boolean"
required: false
description: "Estado seleccionado del chip (controlled)"
- name: onChange
type: "(checked: boolean) => void"
required: false
description: "Callback al cambiar el estado del chip"
- name: variant
type: "'filled' | 'outline' | 'light'"
required: false
description: "Estilo visual del chip"
- name: color
type: "string"
required: false
description: "Color del chip cuando está seleccionado"
- name: size
type: "'xs' | 'sm' | 'md' | 'lg' | 'xl'"
required: false
description: "Tamaño del chip"
- name: children
type: "React.ReactNode"
required: true
description: "Contenido del chip — texto o elemento"
emits: [onChange]
has_state: true
framework: react
variant: [filled, outline, light]
---
## Ejemplo
```tsx
import { Chip, ChipGroup } from '@fn_library'
// Chip individual controlado
<Chip checked={active} onChange={setActive}>
Activo
</Chip>
// ChipGroup selección simple (una sola opción)
<ChipGroup value={selected} onChange={setSelected}>
<Chip value="react">React</Chip>
<Chip value="vue">Vue</Chip>
<Chip value="svelte">Svelte</Chip>
</ChipGroup>
// ChipGroup selección múltiple
<ChipGroup multiple value={tags} onChange={setTags}>
<Chip value="frontend">Frontend</Chip>
<Chip value="backend">Backend</Chip>
<Chip value="devops">DevOps</Chip>
</ChipGroup>
// Con variante y color custom
<Chip variant="outline" color="teal" size="lg">
Destacado
</Chip>
```
## Notas
- Wrapper directo sobre `Chip` de `@mantine/core` v9. Todas las props de Mantine Chip son válidas.
- `ChipGroup` es un alias de `MantineChip.Group` — gestiona el estado de selección entre chips hijos.
- En `ChipGroup` sin `multiple`, `value` es `string`. Con `multiple`, `value` es `string[]`.
- Internamente cada `Chip` renderiza un checkbox/radio accesible oculto con label visual.
+12
View File
@@ -0,0 +1,12 @@
import { Chip as MantineChip, type ChipProps as MantineChipProps } from '@mantine/core'
interface ChipProps extends MantineChipProps {}
function Chip(props: ChipProps) {
return <MantineChip {...props} />
}
const ChipGroup = MantineChip.Group
export { Chip, ChipGroup }
export type { ChipProps }
+104
View File
@@ -0,0 +1,104 @@
---
name: color_input
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "ColorInput(props: ColorInputProps): JSX.Element"
description: "Selector de color con picker, swatches predefinidos y eye dropper. Soporta hex, rgb, hsl con alpha. Wrapper sobre Mantine ColorInput."
tags: [color, picker, input, form, component, ui, interactive, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/color_input.tsx"
framework: react
has_state: true
props:
- name: format
type: "\"hex\" | \"hexa\" | \"rgb\" | \"rgba\" | \"hsl\" | \"hsla\""
required: false
description: "Formato de color a usar en el valor. Por defecto hex."
- name: swatches
type: "string[]"
required: false
description: "Lista de colores predefinidos mostrados como swatches en el picker."
- name: withEyeDropper
type: "boolean"
required: false
description: "Muestra el boton eye dropper para seleccionar color de la pantalla."
- name: withPicker
type: "boolean"
required: false
description: "Muestra el color picker interactivo. Por defecto true."
- name: value
type: "string"
required: false
description: "Valor controlado del color en el formato especificado."
- name: onChange
type: "(value: string) => void"
required: false
description: "Callback invocado cuando el color cambia."
- name: label
type: "React.ReactNode"
required: false
description: "Etiqueta del campo."
- name: placeholder
type: "string"
required: false
description: "Placeholder del input de texto."
- name: disabled
type: "boolean"
required: false
description: "Deshabilita el input."
- name: size
type: "\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\""
required: false
description: "Tamanio del componente."
emits: [onChange, onChangeEnd]
output: "Componente ColorInput que renderiza input de color con picker desplegable y swatches"
params: []
---
## Ejemplo
```tsx
import { ColorInput } from '@fn_library/color_input'
// Basico con hex (default)
<ColorInput
label="Color primario"
placeholder="#228be6"
/>
// Con swatches predefinidos
<ColorInput
label="Color de marca"
swatches={['#2e2e2e', '#868e96', '#fa5252', '#e64980', '#be4bdb', '#228be6', '#15aabf', '#12b886', '#40c057', '#82c91e', '#fab005', '#fd7e14']}
swatchesPerRow={7}
placeholder="Elige un color"
/>
// Con rgba y eye dropper
<ColorInput
label="Color con transparencia"
format="rgba"
withEyeDropper
value="rgba(34, 139, 230, 0.5)"
onChange={(value) => console.log(value)}
/>
```
## Notas
Wrapper directo sobre `ColorInput` de Mantine v9. Hereda todas las props de Mantine sin restricciones.
El eye dropper (`withEyeDropper`) solo funciona en browsers que soporten la EyeDropper API (Chrome/Edge). En Firefox no aparece el boton automaticamente.
Cuando `format` es `hex` o `hsl`, el valor no incluye canal alpha. Para transparencia usar `hexa`, `rgba` o `hsla`.
+10
View File
@@ -0,0 +1,10 @@
import { ColorInput as MantineColorInput, type ColorInputProps as MantineColorInputProps } from '@mantine/core'
interface ColorInputProps extends MantineColorInputProps {}
function ColorInput(props: ColorInputProps) {
return <MantineColorInput {...props} />
}
export { ColorInput }
export type { ColorInputProps }
+2 -2
View File
@@ -8,12 +8,12 @@ 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: [cn_ts_core]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["lucide-react"]
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: []
+70 -85
View File
@@ -1,8 +1,8 @@
import * as React from "react"
import { SearchIcon, XIcon } from "lucide-react"
import { cn } from "../core/cn"
import * as React from 'react'
import { TextInput, Text, Box, ScrollArea } from '@mantine/core'
import { IconSearch } from '@tabler/icons-react'
interface CommandItem {
interface CommandItemData {
value: string
label: string
description?: string
@@ -12,7 +12,7 @@ interface CommandItem {
}
interface CommandProps {
items: CommandItem[]
items: CommandItemData[]
value?: string
onValueChange?: (value: string) => void
placeholder?: string
@@ -22,122 +22,107 @@ interface CommandProps {
listClassName?: string
}
function Command({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
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 (
<div
data-slot="command"
className={cn("flex h-full w-full flex-col overflow-hidden rounded-xl bg-popover text-popover-foreground", className)}
<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 CommandInput({ className, ...props }: React.ComponentPropsWithoutRef<"input">) {
function CommandList({ className, children }: { className?: string; children?: React.ReactNode }) {
return (
<div data-slot="command-input-wrapper" className="flex items-center border-b px-3">
<SearchIcon className="mr-2 size-4 shrink-0 text-muted-foreground" />
<input
data-slot="command-input"
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none",
"placeholder:text-muted-foreground",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
<ScrollArea.Autosize mah={300} data-slot="command-list" className={className}>
{children}
</ScrollArea.Autosize>
)
}
function CommandList({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
function CommandEmpty({ className, children }: { className?: string; children?: React.ReactNode }) {
return (
<div
data-slot="command-list"
className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className)}
{...props}
/>
<Text ta="center" c="dimmed" size="sm" py="xl" data-slot="command-empty" className={className}>
{children}
</Text>
)
}
function CommandEmpty({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
function CommandGroup({ className, heading, children }: { className?: string; heading?: string; children?: React.ReactNode }) {
return (
<div
data-slot="command-empty"
className={cn("py-6 text-center text-sm text-muted-foreground", className)}
{...props}
/>
<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 CommandGroup({ className, heading, ...props }: React.ComponentPropsWithoutRef<"div"> & { heading?: string }) {
return (
<div data-slot="command-group" className={cn("overflow-hidden p-1 text-foreground", className)}>
{heading && (
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">{heading}</div>
)}
<div {...props} />
</div>
)
function CommandSeparator({ className }: { className?: string }) {
return <Box data-slot="command-separator" h={1} bg="var(--mantine-color-default-border)" mx={-4} className={className} />
}
function CommandSeparator({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
return (
<div
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
interface CommandItemProps extends React.ComponentPropsWithoutRef<"div"> {
function CommandItem({ className, selected, disabled, onSelect, children }: {
className?: string
selected?: boolean
disabled?: boolean
onSelect?: () => void
}
function CommandItem({ className, selected, disabled, onSelect, ...props }: CommandItemProps) {
children?: React.ReactNode
}) {
return (
<div
<Box
data-slot="command-item"
data-selected={selected}
aria-disabled={disabled}
role="option"
aria-selected={selected}
onClick={!disabled ? onSelect : undefined}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none",
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground",
"hover:bg-accent hover:text-accent-foreground",
disabled && "pointer-events-none opacity-50",
className
)}
{...props}
/>
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, ...props }: React.ComponentPropsWithoutRef<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
)
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.",
placeholder = 'Search...',
emptyMessage = 'No results found.',
className,
}: CommandProps) {
const [query, setQuery] = React.useState("")
const [selectedValue, setSelectedValue] = React.useState(value ?? "")
const [query, setQuery] = React.useState('')
const [selectedValue, setSelectedValue] = React.useState(value ?? '')
const filtered = React.useMemo(() => {
if (!query) return items
@@ -151,9 +136,9 @@ function CommandSearch({
}, [items, query])
const groups = React.useMemo(() => {
const map = new Map<string, CommandItem[]>()
const map = new Map<string, CommandItemData[]>()
for (const item of filtered) {
const key = item.group ?? ""
const key = item.group ?? ''
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(item)
}
@@ -185,10 +170,10 @@ function CommandSearch({
disabled={item.disabled}
onSelect={() => handleSelect(item.value)}
>
{item.icon && <span className="shrink-0">{item.icon}</span>}
{item.icon && <span>{item.icon}</span>}
<span>{item.label}</span>
{item.description && (
<span className="ml-auto text-xs text-muted-foreground">{item.description}</span>
<Text span size="xs" c="dimmed" ml="auto">{item.description}</Text>
)}
</CommandItem>
))}
@@ -201,4 +186,4 @@ function CommandSearch({
}
export { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSearch, CommandSeparator, CommandShortcut }
export type { CommandItem, CommandProps }
export type { CommandItemData, CommandProps }
+2 -2
View File
@@ -8,12 +8,12 @@ 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: [cn_ts_core]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
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"
+50 -49
View File
@@ -1,5 +1,6 @@
import * as React from 'react'
import { cn } from '../core/cn'
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
@@ -37,83 +38,83 @@ export function crudPage<T extends Record<string, unknown>>({
onEdit,
onDelete,
actions,
className,
}: CrudPageProps<T>): React.ReactElement {
return (
<div className={cn('space-y-6', className)}>
<Stack gap="lg">
{/* Header */}
<div className="flex items-center justify-between border-b pb-4">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
</div>
<div className="flex items-center gap-2">
<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 className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary px-2.5 text-sm font-medium text-primary-foreground hover:bg-primary/80">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14M5 12h14"/></svg>
<Button size="xs" leftSection={<IconPlus size={16} />}>
Add {title.replace(/s$/, '')}
</button>
</Button>
)}
</div>
</div>
</Group>
</Group>
{/* Table */}
<div className="rounded-lg border">
<table className="w-full caption-bottom text-sm">
<thead className="border-b bg-muted/50">
<tr>
<Paper withBorder radius="md">
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
{columns.map((col) => (
<th key={String(col.key)} className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
<Table.Th key={String(col.key)} fz="sm" fw={500} c="dimmed" px="md" py="sm">
{col.label}
</th>
</Table.Th>
))}
{(onEdit || onDelete) && (
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">Actions</th>
<Table.Th ta="right" fz="sm" fw={500} c="dimmed" px="md" py="sm">Actions</Table.Th>
)}
</tr>
</thead>
<tbody className="divide-y">
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.length === 0 ? (
<tr>
<td colSpan={columns.length + (onEdit || onDelete ? 1 : 0)} className="h-24 text-center text-muted-foreground">
No items yet.
</td>
</tr>
<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) => (
<tr key={i} className="hover:bg-muted/50">
<Table.Tr key={i}>
{columns.map((col) => (
<td key={String(col.key)} className="px-4 py-3 align-middle">
<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] ?? '')}
</td>
</Table.Td>
))}
{(onEdit || onDelete) && (
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1">
<Table.Td px="md" py="sm" ta="right">
<Group gap={4} justify="flex-end">
{onEdit && (
<button onClick={() => onEdit(row)} className="inline-flex size-7 items-center justify-center rounded-md hover:bg-muted">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(row)}>
<IconPencil size={14} />
</ActionIcon>
)}
{onDelete && (
<button onClick={() => onDelete(row)} className="inline-flex size-7 items-center justify-center rounded-md text-destructive hover:bg-destructive/10">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => onDelete(row)}>
<IconTrash size={14} />
</ActionIcon>
)}
</div>
</td>
</Group>
</Table.Td>
)}
</tr>
</Table.Tr>
))
)}
</tbody>
</table>
</div>
</Table.Tbody>
</Table>
</Paper>
{/* Form fields definition (for agent use — renders a form preview) */}
<div className="hidden" data-slot="crud-form-schema" data-fields={JSON.stringify(fields)} />
</div>
{/* Form fields definition (for agent use) */}
<div style={{ display: 'none' }} data-slot="crud-form-schema" data-fields={JSON.stringify(fields)} />
</Stack>
)
}
+2 -2
View File
@@ -8,12 +8,12 @@ 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: [cn_ts_core]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
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"
+18 -32
View File
@@ -1,5 +1,5 @@
import * as React from 'react'
import { cn } from '../core/cn'
import { SimpleGrid, Paper, Text } from '@mantine/core'
interface DashboardWidget {
id: string
@@ -16,51 +16,37 @@ interface DashboardLayoutProps {
className?: string
}
const gapClasses = { sm: 'gap-2', md: 'gap-4', lg: 'gap-6' }
const spanClasses: Record<number, string> = {
1: 'col-span-1',
2: 'col-span-1 md:col-span-2',
3: 'col-span-1 md:col-span-2 lg:col-span-3',
4: 'col-span-1 md:col-span-2 lg:col-span-4',
}
const rowSpanClasses: Record<number, string> = {
1: 'row-span-1',
2: 'row-span-2',
}
const gapMap = { sm: 'xs', md: 'md', lg: 'lg' } as const
export function dashboardLayout({
widgets,
columns = 4,
gap = 'md',
className,
}: DashboardLayoutProps): React.ReactElement {
const gridCols: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
}
return (
<div className={cn('grid', gridCols[columns], gapClasses[gap], className)}>
<SimpleGrid
cols={{ base: 1, md: Math.min(columns, 2), lg: columns }}
spacing={gapMap[gap]}
>
{widgets.map((widget) => (
<div
<Paper
key={widget.id}
className={cn(
'rounded-xl border bg-card p-4 text-card-foreground shadow-sm',
spanClasses[widget.span || 1],
rowSpanClasses[widget.rowSpan || 1]
)}
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 && (
<h3 className="mb-3 text-sm font-medium text-muted-foreground">{widget.title}</h3>
<Text size="sm" fw={500} c="dimmed" mb="sm">{widget.title}</Text>
)}
{widget.content}
</div>
</Paper>
))}
</div>
</SimpleGrid>
)
}
+2 -6
View File
@@ -8,12 +8,12 @@ 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: [cn_ts_core]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
imports: [react, "@mantine/core"]
output: "Componente DataTable que renderiza tabla con sticky header, heatmap condicional y formato automático de datos"
tested: false
tests: []
@@ -44,10 +44,6 @@ props:
type: "Error | null"
required: false
description: "Error a mostrar si la carga falló."
- name: className
type: "string"
required: false
description: "Clases CSS adicionales."
emits: []
has_state: false
framework: react
+39 -40
View File
@@ -1,5 +1,5 @@
import * as React from 'react'
import { cn } from '../core/cn'
import { Table, Text, Center, Loader } from '@mantine/core'
interface ColumnDef {
key: string
@@ -16,7 +16,6 @@ interface DataTableProps {
/** Column keys that should be colored by value intensity (heatmap). */
heatmapColumns?: string[]
maxHeight?: number | string
className?: string
loading?: boolean
error?: Error | null
}
@@ -33,7 +32,7 @@ function formatCell(value: unknown, format?: string): string {
if (!isNaN(num)) {
if (format.includes('f')) {
const match = format.match(/\.(\d+)f/)
const d = match ? parseInt(match[1]) : 0
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 })
@@ -51,7 +50,6 @@ function DataTableComponent({
columns,
heatmapColumns = [],
maxHeight = 500,
className,
loading = false,
error = null,
}: DataTableProps) {
@@ -59,7 +57,7 @@ function DataTableComponent({
const effectiveColumns: ColumnDef[] = (columns && columns.length > 0)
? columns
: (data && data.length > 0)
? Object.keys(data[0]).map(k => ({ key: k, label: k }))
? Object.keys(data[0]!).map(k => ({ key: k, label: k }))
: []
// Compute heatmap ranges per column
@@ -82,73 +80,74 @@ function DataTableComponent({
const num = Number(value)
if (isNaN(num)) return undefined
const t = (num - range.min) / (range.max - range.min)
// Dark blue (low) → bright blue (high)
const alpha = 0.1 + t * 0.55
return { backgroundColor: `rgba(59, 130, 246, ${alpha})` }
}
const maxHeightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
if (loading && (!data || data.length === 0)) {
return (
<div className={cn('flex items-center justify-center text-muted-foreground text-sm', className)}
style={{ height: 200 }}>
Loading...
</div>
<Center h={200}>
<Loader size="sm" />
</Center>
)
}
if (error) {
return (
<div className={cn('flex items-center justify-center text-destructive text-sm', className)}
style={{ height: 200 }}>
{error.message}
</div>
<Center h={200}>
<Text size="sm" c="red">{error.message}</Text>
</Center>
)
}
return (
<div className={cn('overflow-auto', className)} style={{ maxHeight: maxHeightStyle }}>
<table className="w-full text-sm">
<thead className="sticky top-0 bg-card z-10">
<tr className="border-b border-border">
<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 => (
<th
<Table.Th
key={col.key}
className="text-left py-1.5 px-3 text-xs font-medium text-muted-foreground uppercase tracking-wider whitespace-nowrap"
style={{ whiteSpace: 'nowrap' }}
fz="xs"
fw={500}
c="dimmed"
tt="uppercase"
py={6}
px="sm"
>
{col.label}
</th>
</Table.Th>
))}
</tr>
</thead>
<tbody>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(data ?? []).map((row, i) => (
<tr key={i} className="border-b border-border hover:bg-accent/50 transition-colors">
<Table.Tr key={i}>
{effectiveColumns.map(col => {
const align = col.align ?? (typeof row[col.key] === 'number' ? 'right' : 'left')
return (
<td
<Table.Td
key={col.key}
className={cn(
'py-1.5 px-3 font-mono text-xs',
align === 'right' && 'text-right',
align === 'center' && 'text-center',
)}
style={heatmapStyle(col.key, row[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)}
</td>
</Table.Td>
)
})}
</tr>
</Table.Tr>
))}
</tbody>
</table>
</Table.Tbody>
</Table>
{(!data || data.length === 0) && (
<p className="text-center text-muted-foreground text-sm py-8">No data</p>
<Center py="xl">
<Text size="sm" c="dimmed">No data</Text>
</Center>
)}
</div>
</Table.ScrollContainer>
)
}
+145
View File
@@ -0,0 +1,145 @@
---
name: date_picker_input
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "DatePickerInput(props: DatePickerInputProps): JSX.Element"
description: "Selector de fecha con input y calendario desplegable. Soporta fecha simple, múltiple y rango. Wrapper sobre Mantine DatePickerInput."
tags: [date, picker, calendar, form, component, ui, interactive, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/dates"]
output: "Componente DatePickerInput que renderiza input con calendario para selección de fechas"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/date_picker_input.tsx"
props:
- name: type
type: "'default' | 'multiple' | 'range'"
required: false
description: "Modo de selección — fecha simple, múltiples fechas, o rango de fechas"
- name: value
type: "DateValue | DateValue[] | [DateValue, DateValue] | null"
required: false
description: "Fecha o fechas seleccionadas (controlled)"
- name: onChange
type: "(value: DateValue | DateValue[] | [DateValue, DateValue] | null) => void"
required: false
description: "Callback al cambiar la selección de fecha"
- name: valueFormat
type: "string"
required: false
description: "Formato de la fecha mostrada en el input (ej: 'DD/MM/YYYY')"
- name: clearable
type: "boolean"
required: false
description: "Permite limpiar la selección de fecha"
- name: label
type: "string"
required: false
description: "Label del campo"
- name: placeholder
type: "string"
required: false
description: "Texto cuando no hay fecha seleccionada"
- name: minDate
type: "Date"
required: false
description: "Fecha mínima seleccionable"
- name: maxDate
type: "Date"
required: false
description: "Fecha máxima seleccionable"
- name: disabled
type: "boolean"
required: false
description: "Deshabilitar el selector"
- name: size
type: "'xs' | 'sm' | 'md' | 'lg' | 'xl'"
required: false
description: "Tamaño del componente"
emits: [onChange]
has_state: true
framework: react
variant: [default]
params:
- name: props
desc: "Props del componente DatePickerInput — incluye type (modo de selección), value, onChange, valueFormat, clearable, label, placeholder, minDate, maxDate, disabled y size"
---
## Ejemplo
```tsx
import { DatePickerInput, DatePicker } from '@fn_library'
import { useState } from 'react'
// Fecha simple
function SingleDateExample() {
const [value, setValue] = useState<Date | null>(null)
return (
<DatePickerInput
label="Fecha de inicio"
placeholder="Selecciona una fecha"
value={value}
onChange={setValue}
clearable
/>
)
}
// Rango de fechas
function RangeDateExample() {
const [range, setRange] = useState<[Date | null, Date | null]>([null, null])
return (
<DatePickerInput
type="range"
label="Periodo"
placeholder="Selecciona un rango"
value={range}
onChange={setRange}
valueFormat="DD/MM/YYYY"
/>
)
}
// Múltiples fechas
function MultipleDateExample() {
const [dates, setDates] = useState<Date[]>([])
return (
<DatePickerInput
type="multiple"
label="Días seleccionados"
placeholder="Selecciona fechas"
value={dates}
onChange={setDates}
minDate={new Date()}
/>
)
}
// DatePicker inline (sin input)
function InlineDateExample() {
const [value, setValue] = useState<Date | null>(null)
return (
<DatePicker
value={value}
onChange={setValue}
/>
)
}
```
## Notas
- Wrapper directo sobre `DatePickerInput` y `DatePicker` de `@mantine/dates` v9. Todas las props de Mantine son válidas.
- Requiere importar `@mantine/dates/styles.css` — este wrapper ya lo incluye.
- El prop `type` controla el modo: `'default'` (fecha simple), `'multiple'` (varias fechas), `'range'` (rango con inicio y fin).
- `DatePicker` es el calendario inline sin input — útil para formularios donde el calendario debe estar siempre visible.
- `valueFormat` acepta tokens de dayjs (ej: `'DD/MM/YYYY'`, `'MMMM D, YYYY'`).
- Re-exporta también `DatePicker` de `@mantine/dates` con el mismo patrón de wrapper.
@@ -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 }
+2 -2
View File
@@ -8,12 +8,12 @@ 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: [cn_ts_core]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
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"
+70 -67
View File
@@ -1,5 +1,6 @@
import * as React from 'react'
import { cn } from '../core/cn'
import { Stack, Group, Title, Text, ActionIcon, Box, Tabs, Badge, Timeline, SimpleGrid } from '@mantine/core'
import { IconChevronLeft } from '@tabler/icons-react'
interface DetailField {
label: string
@@ -38,96 +39,98 @@ interface DetailPageProps {
className?: string
}
const variantDotColors = {
default: 'bg-primary',
success: 'bg-green-500',
warning: 'bg-amber-500',
error: 'bg-red-500',
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, className,
fields, tabs, activeTab, onTabChange, timeline,
}: DetailPageProps): React.ReactElement {
return (
<div className={cn('space-y-6', className)}>
<Stack gap="lg">
{/* Header */}
<div className="flex items-start justify-between border-b pb-4">
<div className="flex items-start gap-4">
<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 && (
<button onClick={onBack} className="mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-md hover:bg-muted">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m15 18-6-6 6-6"/></svg>
</button>
<ActionIcon variant="subtle" size="sm" onClick={onBack} mt={4}>
<IconChevronLeft size={16} />
</ActionIcon>
)}
{avatar && <div className="size-12 shrink-0 overflow-hidden rounded-full bg-muted">{avatar}</div>}
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{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}
</div>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
</div>
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
</Group>
{subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
</Stack>
</Group>
{actions && <Group gap="xs">{actions}</Group>}
</Group>
{/* Fields grid */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
{fields.map((field, i) => (
<div key={i} className={cn('space-y-1', field.span === 2 && 'md:col-span-2')}>
<p className="text-sm text-muted-foreground">{field.label}</p>
<div className="text-sm font-medium">{field.value}</div>
</div>
<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>
))}
</div>
</SimpleGrid>
{/* Tabs */}
{tabs && tabs.length > 0 && (
<div className="space-y-4">
<nav className="flex gap-4 border-b">
{tabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => onTabChange?.(tab.value)}
className={cn(
'inline-flex items-center gap-2 border-b-2 px-1 pb-3 text-sm font-medium transition-colors',
activeTab === tab.value ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'
)}
>
{tab.label}
{tab.count !== undefined && (
<span className="inline-flex h-5 items-center rounded-full bg-muted px-2 text-xs font-medium">{tab.count}</span>
)}
</button>
))}
</nav>
<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}
</div>
</Stack>
)}
{/* Timeline */}
{timeline && timeline.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">Activity</h3>
<div className="space-y-0">
{timeline.map((event, i) => (
<div key={event.id} className="flex gap-3 pb-4">
<div className="flex flex-col items-center">
<div className={cn('mt-1 size-2.5 rounded-full', variantDotColors[event.variant || 'default'])} />
{i < timeline.length - 1 && <div className="flex-1 w-px bg-border" />}
</div>
<div className="flex-1 space-y-0.5 pb-2">
<p className="text-sm font-medium">{event.title}</p>
{event.description && <p className="text-xs text-muted-foreground">{event.description}</p>}
<p className="text-xs text-muted-foreground/70">{event.timestamp}</p>
</div>
</div>
<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>
))}
</div>
</div>
</Timeline>
</Stack>
)}
</div>
</Stack>
)
}
+6 -6
View File
@@ -6,15 +6,15 @@ domain: ui
version: "1.0.0"
purity: impure
signature: "Dialog(props: DialogRootProps): JSX.Element"
description: "Diálogo modal accesible con overlay blur, animaciones, close button y sistema de slots (header, footer, title, description)."
tags: [dialog, modal, overlay, component, ui, interactive]
uses_functions: [cn_ts_core]
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: ["@base-ui/react", lucide-react, react]
output: "Componente Dialog que renderiza modal accesible con overlay blur, focus trap y sistema de slots composables"
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: ""
@@ -53,4 +53,4 @@ source_file: "frontend/src/components/ui/dialog.tsx"
## Notas
10 subcomponentes exportados. Base-UI Dialog primitive para accesibilidad completa (focus trap, escape, click outside).
10 subcomponentes exportados. Mantine Modal para accesibilidad completa (focus trap, escape, click outside). DialogPortal y DialogOverlay son no-ops mantenidos por compatibilidad.
+119 -58
View File
@@ -1,73 +1,134 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "../core/cn"
import { XIcon } from "lucide-react"
import * as React from 'react'
import { Modal, Box, Text, Group } from '@mantine/core'
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
interface DialogProps {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
const DialogContext = React.createContext<{
open: boolean
setOpen: (open: boolean) => void
}>({ open: false, setOpen: () => {} })
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn("fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", className)}
{...props}
/>
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],
)
}
function DialogContent({ className, children, showCloseButton = true, ...props }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean }) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn("fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" className="absolute top-2 right-2 inline-flex size-7 items-center justify-center rounded-md hover:bg-muted">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
}
function DialogFooter({ className, children, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="dialog-footer" className={cn("-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end", className)} {...props}>
<DialogContext.Provider value={{ open, setOpen }}>
{children}
</div>
</DialogContext.Provider>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return <DialogPrimitive.Title data-slot="dialog-title" className={cn("text-base leading-none font-medium", className)} {...props} />
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 DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return <DialogPrimitive.Description data-slot="dialog-description" className={cn("text-sm text-muted-foreground", className)} {...props} />
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 }
+3 -3
View File
@@ -7,13 +7,13 @@ 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, base-ui]
uses_functions: [cn_ts_core]
tags: [dropdown, menu, component, ui, interactive, overlay, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@base-ui/react/menu", "lucide-react"]
imports: ["@mantine/core"]
output: "Componente DropdownMenu que renderiza menú desplegable accesible con items, checkboxes, radios y submenus"
tested: false
tests: []
+111 -173
View File
@@ -1,187 +1,125 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "../core/cn"
import * as React from 'react'
import { Menu, Text } from '@mantine/core'
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuContent({ className, sideOffset = 4, ...props }: MenuPrimitive.Positioner.Props) {
function DropdownMenu({ children, ...props }: { children: React.ReactNode; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; modal?: boolean }) {
return (
<DropdownMenuPortal>
<MenuPrimitive.Positioner
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className="z-50"
{...props}
>
<MenuPrimitive.Popup
className={cn(
"min-w-[8rem] overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-md",
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
>
{props.children}
</MenuPrimitive.Popup>
</MenuPrimitive.Positioner>
</DropdownMenuPortal>
)
}
function DropdownMenuItem({ className, inset, ...props }: MenuPrimitive.Item.Props & { inset?: boolean }) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors",
"focus:bg-accent focus:text-accent-foreground",
"data-disabled:pointer-events-none data-disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({ className, children, checked, ...props }: MenuPrimitive.CheckboxItem.Props) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors",
"focus:bg-accent focus:text-accent-foreground",
"data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex size-4 items-center justify-center">
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon className="size-4" />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
}
function DropdownMenuRadioItem({ className, children, ...props }: MenuPrimitive.RadioItem.Props) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors",
"focus:bg-accent focus:text-accent-foreground",
"data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex size-4 items-center justify-center">
<MenuPrimitive.RadioItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({ className, inset, ...props }: MenuPrimitive.GroupLabel.Props & { inset?: boolean }) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
className={cn("px-2 py-1.5 text-xs font-medium text-muted-foreground", inset && "pl-8", className)}
{...props}
/>
)
}
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({ className, inset, children, ...props }: MenuPrimitive.SubmenuTrigger.Props & { inset?: boolean }) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors",
"focus:bg-accent focus:text-accent-foreground data-popup-open:bg-accent data-popup-open:text-accent-foreground",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
<Menu
opened={props.open}
defaultOpened={props.defaultOpen}
onChange={props.onOpenChange}
withinPortal
shadow="md"
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
</Menu>
)
}
function DropdownMenuSubContent({ className, ...props }: MenuPrimitive.Positioner.Props) {
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 (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner data-slot="dropdown-menu-sub-content" className="z-50" {...props}>
<MenuPrimitive.Popup
className={cn(
"min-w-[8rem] overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-md",
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
>
{props.children}
</MenuPrimitive.Popup>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
<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,
+118
View File
@@ -0,0 +1,118 @@
---
name: dropzone
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "Dropzone(props: DropzoneProps): JSX.Element"
description: "Zona de drag-and-drop para archivos con estados idle/accept/reject, límite de tamaño y tipos MIME. Wrapper sobre Mantine Dropzone."
tags: [dropzone, upload, drag-drop, file, component, ui, interactive, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/dropzone"]
output: "Componente Dropzone que renderiza área de arrastrar y soltar archivos con feedback visual"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/dropzone.tsx"
props:
- name: onDrop
type: "(files: File[]) => void"
required: false
description: "Callback ejecutado cuando el usuario suelta archivos aceptados"
- name: onReject
type: "(files: FileRejection[]) => void"
required: false
description: "Callback ejecutado cuando el usuario suelta archivos rechazados (tipo o tamaño inválido)"
- name: accept
type: "Record<string, string[]>"
required: false
description: "Tipos MIME aceptados. Usar IMAGE_MIME_TYPE o MIME_TYPES para constantes predefinidas"
- name: maxSize
type: "number"
required: false
description: "Tamaño máximo de archivo en bytes"
- name: multiple
type: "boolean"
required: false
description: "Permite seleccionar múltiples archivos a la vez"
- name: loading
type: "boolean"
required: false
description: "Muestra estado de carga y desactiva la interacción"
- name: disabled
type: "boolean"
required: false
description: "Desactiva el dropzone"
- name: children
type: "React.ReactNode"
required: true
description: "Contenido interno, generalmente compuesto con DropzoneAccept, DropzoneReject y DropzoneIdle"
emits: [onDrop, onReject]
has_state: true
framework: react
variant: []
---
## Ejemplo
```tsx
import {
Dropzone,
DropzoneAccept,
DropzoneReject,
DropzoneIdle,
IMAGE_MIME_TYPE,
} from '@fn_library/dropzone'
import { IconPhoto, IconUpload, IconX } from '@tabler/icons-react'
import { Group, Text } from '@mantine/core'
function ImageUploader() {
return (
<Dropzone
onDrop={(files) => console.log('Archivos aceptados:', files)}
onReject={(files) => console.log('Archivos rechazados:', files)}
accept={IMAGE_MIME_TYPE}
maxSize={5 * 1024 ** 2}
>
<Group justify="center" gap="xl" mih={120} style={{ pointerEvents: 'none' }}>
<DropzoneAccept>
<IconUpload size={52} stroke={1.5} />
</DropzoneAccept>
<DropzoneReject>
<IconX size={52} stroke={1.5} />
</DropzoneReject>
<DropzoneIdle>
<IconPhoto size={52} stroke={1.5} />
</DropzoneIdle>
<div>
<Text size="xl" inline>
Arrastra imágenes aquí o haz clic para seleccionar
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
Máximo 5 MB por imagen
</Text>
</div>
</Group>
</Dropzone>
)
}
```
## Notas
El prop `onDrop` tiene un default vacío (`() => {}`) para que el componente sea válido sin handler. Siempre sobreescribirlo en uso real.
Sub-componentes exportados:
- `DropzoneAccept` — visible cuando el archivo arrastrado es aceptado (tipo y tamaño válidos)
- `DropzoneReject` — visible cuando el archivo es rechazado
- `DropzoneIdle` — visible en estado de reposo
- `DropzoneFullScreen` — captura drops en cualquier parte de la pantalla
Constantes de tipos MIME exportadas:
- `IMAGE_MIME_TYPE` — imágenes comunes (png, jpg, gif, webp, etc.)
- `MIME_TYPES` — objeto con claves por tipo (pdf, csv, xlsx, mp4, etc.)
+20
View File
@@ -0,0 +1,20 @@
import { Dropzone as MantineDropzone, IMAGE_MIME_TYPE, MIME_TYPES } from '@mantine/dropzone'
import type { DropzoneProps as MantineDropzoneProps } from '@mantine/dropzone'
import '@mantine/dropzone/styles.css'
interface DropzoneProps extends Partial<MantineDropzoneProps> {
children: React.ReactNode
}
function Dropzone({ children, ...props }: DropzoneProps) {
return <MantineDropzone onDrop={() => {}} {...props}>{children}</MantineDropzone>
}
// Re-export sub-components and constants
const DropzoneAccept = MantineDropzone.Accept
const DropzoneReject = MantineDropzone.Reject
const DropzoneIdle = MantineDropzone.Idle
const DropzoneFullScreen = MantineDropzone.FullScreen
export { Dropzone, DropzoneAccept, DropzoneReject, DropzoneIdle, DropzoneFullScreen, IMAGE_MIME_TYPE, MIME_TYPES }
export type { DropzoneProps }
+103
View File
@@ -0,0 +1,103 @@
---
name: empty_state
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "EmptyState(props: EmptyStateProps): JSX.Element"
description: "Placeholder para listas y tablas vacías con icono, título, descripción y acción opcional. Tabler Icons por defecto."
tags: [empty-state, placeholder, no-data, component, ui, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core", "@tabler/icons-react"]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/empty_state.tsx"
framework: react
has_state: false
emits: [onAction]
props:
- name: icon
type: "React.ReactNode"
required: false
description: "Icono a mostrar. Default: IconInbox de @tabler/icons-react."
- name: title
type: "string"
required: false
description: "Título del empty state. Default: 'No data found'."
- name: description
type: "string"
required: false
description: "Descripción explicativa. Default: 'There are no items to display yet.'."
- name: actionLabel
type: "string"
required: false
description: "Texto del botón de acción. Se muestra solo si también hay onAction."
- name: onAction
type: "() => void"
required: false
description: "Callback del botón de acción. Se muestra solo si también hay actionLabel."
- name: size
type: "MantineSize"
required: false
description: "Tamaño general del componente. Afecta el icono, texto y botón. Default: 'md'."
- name: children
type: "React.ReactNode"
required: false
description: "Contenido custom renderizado debajo de la descripción y antes del botón."
output: "Componente EmptyState centrado con icono, mensaje y botón de acción para estados sin datos"
params:
- name: props
desc: "Props del componente EmptyState"
---
## Ejemplo
```tsx
import { EmptyState } from '@fn_library/empty_state'
// Default — sin datos
<EmptyState />
// Con acción
<EmptyState
title="No functions found"
description="Try adjusting your search or create a new function."
actionLabel="Create function"
onAction={() => navigate('/new')}
/>
// Con icono custom
import { IconDatabase } from '@tabler/icons-react'
<EmptyState
icon={<IconDatabase size={48} stroke={1.5} />}
title="No databases connected"
description="Connect a database to start querying data."
size="lg"
/>
// Dentro de una Card
import { Card } from '@mantine/core'
import { EmptyState } from '@fn_library/empty_state'
<Card withBorder p="xl">
<EmptyState
title="No results"
description="Your query returned no rows."
size="sm"
/>
</Card>
```
## Notas
El tamaño del icono escala con `size`: xs=32, sm=40, md=48, lg=64, xl=80.
El orden del heading (`Title order`) es 5 para xs/sm y 4 para md/lg/xl.
El botón usa `variant="light"` de Mantine — hereda el color primario del tema.
`children` se renderiza entre la descripción y el botón, útil para filtros o links adicionales.
+55
View File
@@ -0,0 +1,55 @@
import * as React from 'react'
import { Button, Stack, Text, Title, type MantineSize } from '@mantine/core'
import { IconInbox } from '@tabler/icons-react'
interface EmptyStateProps {
/** Icono a mostrar (default: IconInbox) */
icon?: React.ReactNode
/** Título */
title?: string
/** Descripción */
description?: string
/** Texto del botón de acción */
actionLabel?: string
/** Callback del botón */
onAction?: () => void
/** Tamaño general */
size?: MantineSize
/** Contenido custom debajo de la descripción */
children?: React.ReactNode
}
function EmptyState({
icon,
title = 'No data found',
description = 'There are no items to display yet.',
actionLabel,
onAction,
size = 'md',
children,
}: EmptyStateProps) {
const iconSize = size === 'xs' ? 32 : size === 'sm' ? 40 : size === 'lg' ? 64 : size === 'xl' ? 80 : 48
return (
<Stack align="center" gap="sm" py="xl">
<Text c="dimmed" style={{ opacity: 0.5 }}>
{icon || <IconInbox size={iconSize} stroke={1.5} />}
</Text>
<Title order={size === 'xs' || size === 'sm' ? 5 : 4} ta="center">
{title}
</Title>
<Text c="dimmed" size={size} ta="center" maw={400}>
{description}
</Text>
{children}
{actionLabel && onAction && (
<Button variant="light" size={size} onClick={onAction} mt="xs">
{actionLabel}
</Button>
)}
</Stack>
)
}
export { EmptyState }
export type { EmptyStateProps }
+75
View File
@@ -0,0 +1,75 @@
---
name: error_page
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "ErrorPage(config: ErrorPageConfig): JSX.Element"
description: "Genera página de error con código grande, título, descripción y acciones. Soporta 404, 500, 403 y cualquier código custom."
tags: [error, 404, 500, page, empty-state, ui, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react, "@mantine/core"]
params:
- name: code
desc: "Código de error numérico o string a mostrar prominentemente (404, 500, 403, o cualquier valor custom)"
- name: title
desc: "Título del error mostrado bajo el código. Default: 'Page not found'"
- name: description
desc: "Descripción explicativa del error. Default: mensaje genérico de página no encontrada"
- name: actionLabel
desc: "Texto del botón de acción principal. Default: 'Go back to home'"
- name: onAction
desc: "Callback invocado al pulsar el botón de acción principal"
- name: extraActions
desc: "Nodos React adicionales renderizados junto al botón principal (botones secundarios, links, etc.)"
output: "Página de error centrada con código prominente, mensaje descriptivo y botones de acción"
has_state: false
framework: react
emits: [onAction]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/error_page.tsx"
---
## Ejemplo
```tsx
import { ErrorPage } from '@fn_library/error_page'
import { Button } from '@mantine/core'
// 404 con defaults
<ErrorPage onAction={() => navigate('/')} />
// 500 custom
<ErrorPage
code={500}
title="Internal Server Error"
description="Something went wrong on our end. Please try again later or contact support."
actionLabel="Retry"
onAction={() => window.location.reload()}
/>
// 403 con acciones extra
<ErrorPage
code={403}
title="Access Denied"
description="You don't have permission to view this page. Contact your administrator to request access."
actionLabel="Go to Dashboard"
onAction={() => navigate('/dashboard')}
extraActions={
<Button variant="outline" size="md" onClick={() => navigate('/login')}>
Switch Account
</Button>
}
/>
```
## Notas
El código de error se muestra con `fz={120}` y opacidad reducida (0.25) para crear un efecto visual de fondo sin distraer del mensaje. Acepta `number | string` para soportar códigos custom como "503" o "Maintenance".
+56
View File
@@ -0,0 +1,56 @@
import * as React from 'react'
import { Button, Container, Group, Stack, Text, Title } from '@mantine/core'
interface ErrorPageConfig {
/** Código de error (404, 500, 403, etc.) */
code?: number | string
/** Título del error */
title?: string
/** Descripción del error */
description?: string
/** Texto del botón de acción */
actionLabel?: string
/** Callback del botón */
onAction?: () => void
/** Acciones extra además del botón principal */
extraActions?: React.ReactNode
}
function ErrorPage({
code = 404,
title = 'Page not found',
description = 'The page you are looking for does not exist. You may have mistyped the address, or the page has been moved to another URL.',
actionLabel = 'Go back to home',
onAction,
extraActions,
}: ErrorPageConfig) {
return (
<Container py={80}>
<Stack align="center" gap="md">
<Text
fz={120}
fw={900}
c="dimmed"
style={{ lineHeight: 1, opacity: 0.25 }}
>
{code}
</Text>
<Title order={2} ta="center">
{title}
</Title>
<Text c="dimmed" size="lg" ta="center" maw={500}>
{description}
</Text>
<Group mt="md">
<Button size="md" onClick={onAction}>
{actionLabel}
</Button>
{extraActions}
</Group>
</Stack>
</Container>
)
}
export { ErrorPage }
export type { ErrorPageConfig }
+102
View File
@@ -0,0 +1,102 @@
---
name: file_input
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "FileInput(props: FileInputProps): JSX.Element"
description: "Input de archivos con soporte para múltiples archivos, tipos aceptados y botón de limpiar. Wrapper sobre Mantine FileInput."
tags: [file, upload, input, form, component, ui, interactive, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/file_input.tsx"
framework: react
has_state: true
emits: [onChange]
props:
- name: multiple
type: "boolean"
required: false
description: "Permite seleccionar múltiples archivos"
- name: accept
type: "string"
required: false
description: "Tipos MIME o extensiones aceptadas (ej: 'image/*', '.pdf,.docx')"
- name: clearable
type: "boolean"
required: false
description: "Muestra botón para limpiar el archivo seleccionado"
- name: value
type: "File | File[] | null"
required: false
description: "Valor controlado del input"
- name: onChange
type: "(value: File | File[] | null) => void"
required: false
description: "Callback que se dispara al seleccionar o limpiar un archivo"
- name: placeholder
type: "string"
required: false
description: "Texto mostrado cuando no hay archivo seleccionado"
- name: label
type: "string"
required: false
description: "Etiqueta visible sobre el input"
- name: disabled
type: "boolean"
required: false
description: "Deshabilita el input"
- name: size
type: "'xs' | 'sm' | 'md' | 'lg' | 'xl'"
required: false
description: "Tamaño del componente"
params:
- name: props
desc: "Props de FileInput: archivo(s) seleccionado(s), tipos aceptados, modo múltiple, estado controlado y apariencia"
output: "Componente FileInput que renderiza input para selección de archivos con preview del nombre"
---
## Ejemplo
```tsx
import { FileInput } from '@fn_library'
// Archivo único
<FileInput
label="Subir documento"
placeholder="Selecciona un archivo"
clearable
/>
// Múltiples archivos
<FileInput
label="Subir archivos"
placeholder="Selecciona uno o varios archivos"
multiple
clearable
/>
// Solo imágenes
<FileInput
label="Subir imagen"
placeholder="Selecciona una imagen"
accept="image/*"
clearable
/>
```
## Notas
Wrapper directo sobre `FileInput` de `@mantine/core`. Acepta todas las props de Mantine sin restricciones.
Para `multiple: true`, el tipo de `value` y `onChange` cambia a `File[] | null` automáticamente gracias al tipado genérico de Mantine.
El prop `clearable` añade un ícono de X que permite vaciar la selección sin reabrir el explorador de archivos.
+10
View File
@@ -0,0 +1,10 @@
import { FileInput as MantineFileInput, type FileInputProps as MantineFileInputProps } from '@mantine/core'
interface FileInputProps extends MantineFileInputProps {}
function FileInput(props: FileInputProps) {
return <MantineFileInput {...props} />
}
export { FileInput }
export type { FileInputProps }
+2 -2
View File
@@ -8,12 +8,12 @@ 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: [cn_ts_core]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
imports: ["@mantine/core"]
output: "Componente FormField que renderiza wrapper de campo con label, helper text, error y ARIA automáticos"
tested: false
tests: []
+23 -10
View File
@@ -1,5 +1,5 @@
import * as React from "react"
import { cn } from "../core/cn"
import * as React from 'react'
import { Box, Text } from '@mantine/core'
interface FormFieldProps {
label?: string
@@ -15,26 +15,39 @@ function FormField({ label, helperText, error, children, className }: FormFieldP
const helperId = `${id}-helper`
const errorId = `${id}-error`
const describedBy = [helperText ? helperId : null, error ? errorId : null].filter(Boolean).join(" ") || undefined
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,
'aria-invalid': error ? true : undefined,
'aria-describedby': describedBy,
error: error || undefined,
})
}
return child
})
return (
<div className={cn("flex flex-col gap-1.5", className)}>
{label && <label htmlFor={inputId} className="text-sm font-medium text-foreground">{label}</label>}
<Box className={className}>
{label && (
<Text component="label" htmlFor={inputId} size="sm" fw={500} mb={4} display="block">
{label}
</Text>
)}
{childWithProps}
{helperText && !error && <p id={helperId} className="text-sm text-muted-foreground">{helperText}</p>}
{error && <p id={errorId} className="text-sm text-destructive">{error}</p>}
</div>
{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>
)
}
+50 -4
View File
@@ -49,6 +49,12 @@ export interface GraphTheme {
selectionColor?: string
}
export interface ContextMenuTarget {
type: "node" | "edge" | "canvas"
id?: string
data?: GraphNode | GraphEdge
}
export interface GraphContainerProps {
data: GraphData
layout?: "organic" | "random"
@@ -58,6 +64,7 @@ export interface GraphContainerProps {
nodeTypes?: NodeType[]
onNodeClick?: (node: GraphNode) => void
onNodeDoubleClick?: (node: GraphNode) => void
onContextMenu?: (event: MouseEvent, target: ContextMenuTarget) => void
enableSelection?: boolean
selectionMode?: "single" | "multiple"
theme?: GraphTheme
@@ -84,6 +91,7 @@ function GraphContainer({
nodeTypes = [],
onNodeClick,
onNodeDoubleClick,
onContextMenu,
theme: themeProp,
height = "100%",
className,
@@ -96,10 +104,30 @@ function GraphContainer({
[themeProp],
)
// Build + render
// 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) {
@@ -110,7 +138,7 @@ function GraphContainer({
const g = new Graph({ multi: true, type: "directed" })
graphRef.current = g
// Add nodes
// 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,
@@ -118,7 +146,7 @@ function GraphContainer({
y: n.y ?? (Math.random() - 0.5) * 10,
size: n.size ?? theme.nodeSize,
color: n.color ?? theme.nodeColor,
type: n.type,
entityType: n.type,
})
}
@@ -152,6 +180,7 @@ function GraphContainer({
// Render
const renderer = new Sigma(g, el, {
allowInvalidContainer: true,
renderEdgeLabels: false,
defaultEdgeColor: theme.edgeColor,
defaultNodeColor: theme.nodeColor,
@@ -174,13 +203,30 @@ function GraphContainer({
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])
}, [data, layout, theme, onNodeClick, onNodeDoubleClick, onContextMenu, ready])
// Container background
const containerStyle: React.CSSProperties = {
+20 -9
View File
@@ -8,7 +8,8 @@ export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, Di
export { Input, InputGroup, InputIcon } from './input'
export { Label } from './label'
export { KPICard } from './kpi_card'
export { Select, SelectContent, SelectGroup, SelectGroupLabel, SelectItem, SelectPortal, SelectSeparator, SelectTrigger, SelectValue } from './select'
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'
@@ -37,11 +38,9 @@ export type { Series } from './chart_container'
export { DataTable } from './data_table'
export type { DataTableProps, ColumnDef } from './data_table'
// Theme
export { ThemeProvider, useTheme, ThemeContext } from './theme_provider'
export type { ThemeProviderProps } from './theme_provider'
export { applyTheme } from './apply_theme'
export type { Theme, ThemeColors } from './apply_theme'
// Mantine Provider
export { FnMantineProvider } from './mantine_provider'
export type { FnMantineProviderProps } from './mantine_provider'
// Page templates
export { analyticsPage } from './analytics_page'
@@ -82,14 +81,14 @@ export type { CheckboxProps } from './checkbox'
// Command
export { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSearch, CommandSeparator, CommandShortcut } from './command'
export type { CommandProps } 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, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from './pagination'
export type { PaginationLinkProps } from './pagination'
export { Pagination } from './pagination'
export type { PaginationProps } from './pagination'
// Popover
export { Popover, PopoverClose, PopoverContent, PopoverDescription, PopoverHeader, PopoverPortal, PopoverTitle, PopoverTrigger } from './popover'
@@ -123,3 +122,15 @@ export { useAnimatedCanvas } from './use_animated_canvas'
// Wails Provider
export { WailsProvider } from './wails_provider'
// New Mantine components
export { FnAppShell } from './app_shell'
export { FnStepper } from './stepper'
export { FnTimeline } from './timeline'
export { FnActionIcon } from './action_icon'
export { FnNumberInput } from './number_input'
export { FnSegmentedControl } from './segmented_control'
export { FnLoadingOverlay } from './loading_overlay'
export { FnRingProgress } from './ring_progress'
export { FnNavLink } from './nav_link'
export { FnIndicator } from './indicator'
+77
View File
@@ -0,0 +1,77 @@
---
name: indicator
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "FnIndicator(props: FnIndicatorProps): JSX.Element"
description: "Badge indicador posicionado sobre un elemento hijo. Wrapper sobre Mantine Indicator."
tags: [mantine, indicator, badge, notification, dot, component, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
framework: react
props:
- name: children
type: "ReactNode"
required: true
description: "Elemento sobre el cual se posiciona el indicador"
- name: color
type: "MantineColor"
required: false
description: "Color del indicador, default red"
- name: size
type: "number"
required: false
description: "Tamano del dot en px, default 10"
- name: position
type: "'top-start' | 'top-center' | 'top-end' | 'middle-start' | 'middle-center' | 'middle-end' | 'bottom-start' | 'bottom-center' | 'bottom-end'"
required: false
description: "Posicion del indicador, default top-end"
- name: processing
type: "boolean"
required: false
description: "Animacion de pulso, default false"
- name: disabled
type: "boolean"
required: false
description: "Oculta el indicador, default false"
- name: label
type: "ReactNode"
required: false
description: "Contenido dentro del indicador (numero, texto)"
output: "Elemento hijo con un dot/badge indicador posicionado en una esquina"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/indicator.tsx"
emits: []
has_state: false
variant: []
---
## Ejemplo
```tsx
import { FnIndicator } from '@fn_library'
import { FnActionIcon } from '@fn_library'
import { IconBell } from '@tabler/icons-react'
{/* Dot simple */}
<FnIndicator processing>
<FnActionIcon icon={<IconBell size={18} />} />
</FnIndicator>
{/* Con contador */}
<FnIndicator label={5} size={16} color="blue">
<Avatar src="user.png" />
</FnIndicator>
```
## Notas
Wrapper sobre Mantine `Indicator`. El `processing` prop agrega una animacion de pulso al dot. Si se provee `label`, el indicador se agranda para mostrar contenido. `disabled` oculta el indicador sin desmontar el componente.
+39
View File
@@ -0,0 +1,39 @@
import * as React from 'react'
import { Indicator } from '@mantine/core'
import type { MantineColor } from '@mantine/core'
interface FnIndicatorProps {
children: React.ReactNode
color?: MantineColor
size?: number
position?: 'top-start' | 'top-center' | 'top-end' | 'middle-start' | 'middle-center' | 'middle-end' | 'bottom-start' | 'bottom-center' | 'bottom-end'
processing?: boolean
disabled?: boolean
label?: React.ReactNode
}
function FnIndicator({
children,
color = 'red',
size = 10,
position = 'top-end',
processing = false,
disabled = false,
label,
}: FnIndicatorProps) {
return (
<Indicator
color={color}
size={size}
position={position}
processing={processing}
disabled={disabled}
label={label}
>
{children}
</Indicator>
)
}
export { FnIndicator }
export type { FnIndicatorProps }
+5 -5
View File
@@ -6,14 +6,14 @@ 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."
tags: [input, form, component, ui, interactive]
uses_functions: [cn_ts_core]
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: ["@base-ui/react", "react"]
imports: ["@mantine/core"]
output: "Componente Input que renderiza campo de entrada accesible con soporte para iconos y validación ARIA"
tested: false
tests: []
@@ -49,4 +49,4 @@ source_file: "frontend/src/components/ui/input.tsx"
## Notas
Exporta Input, InputGroup e InputIcon. InputGroup detecta automáticamente la presencia de iconos y ajusta padding del Input.
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.
+23 -22
View File
@@ -1,18 +1,17 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "../core/cn"
import * as React from 'react'
import { TextInput, Box } from '@mantine/core'
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
function Input({
className,
type,
...props
}: React.ComponentProps<typeof TextInput> & { type?: string }) {
return (
<InputPrimitive
<TextInput
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"group-has-[data-slot=input-icon-start]/input-group:pl-9",
"group-has-[data-slot=input-icon-end]/input-group:pr-9",
className
)}
size="sm"
className={className}
{...props}
/>
)
@@ -25,32 +24,34 @@ interface InputGroupProps {
function InputGroup({ children, className }: InputGroupProps) {
return (
<div data-slot="input-group" className={cn("group/input-group relative", className)}>
<Box data-slot="input-group" className={className}>
{children}
</div>
</Box>
)
}
interface InputIconProps {
children: React.ReactNode
position: "start" | "end"
position: 'start' | 'end'
className?: string
}
function InputIcon({ children, position, className }: InputIconProps) {
return (
<span
<Box
data-slot={`input-icon-${position}`}
className={cn(
"pointer-events-none absolute top-1/2 -translate-y-1/2 text-muted-foreground [&_svg]:size-4",
position === "start" && "left-2.5",
position === "end" && "right-2.5",
className
)}
component="span"
className={className}
style={{
display: 'inline-flex',
alignItems: 'center',
pointerEvents: 'none',
}}
>
{children}
</span>
</Box>
)
}
export { Input, InputGroup, InputIcon }
export type { InputGroupProps, InputIconProps }
+1 -1
View File
@@ -8,7 +8,7 @@ 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: [cn_ts_core]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
+52 -43
View File
@@ -1,78 +1,87 @@
import * as React from 'react'
import { cn } from '../core/cn'
import { Paper, Text, Group, Stack, Box } from '@mantine/core'
type KPICardSize = 'sm' | 'default' | 'lg'
interface Delta {
value: number
isPositive: boolean
/** Descriptive label before value, e.g. "Increased by" */
label?: string
/** Suffix after value, e.g. "vs yesterday" */
suffix?: string
}
interface KPICardProps extends React.HTMLAttributes<HTMLDivElement> {
label: string
value: string | number
/** Unit displayed next to value in smaller font, e.g. "k", "ms", "%" */
unit?: string
delta?: Delta
icon?: React.ReactNode
/** Action slot rendered top-right, e.g. a menu button */
action?: React.ReactNode
/** Inline chart slot rendered to the right of the value */
chart?: React.ReactNode
subtitle?: string
size?: KPICardSize
}
const sizeStyles: Record<KPICardSize, { value: string; unit: string; label: string }> = {
sm: { value: 'text-2xl font-bold', unit: 'text-base font-medium', label: 'text-xs' },
default: { value: 'text-3xl font-bold', unit: 'text-lg font-medium', label: 'text-sm' },
lg: { value: 'text-4xl font-bold', unit: 'text-xl font-medium', label: 'text-base' },
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 styles = sizeStyles[size]
const deltaColor = delta
? delta.value === 0 ? 'text-muted-foreground'
: delta.isPositive ? 'text-green-600 dark:text-green-500'
: 'text-red-600 dark:text-red-500'
: ''
? delta.value === 0 ? 'dimmed'
: delta.isPositive ? 'teal'
: 'red'
: undefined
return (
<div ref={ref} className={cn('rounded-lg border bg-card p-4 text-card-foreground shadow-sm', className)} {...props}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<p className={cn('text-muted-foreground', styles.label)}>{label}</p>
{subtitle && <p className="text-xs text-muted-foreground/80">{subtitle}</p>}
</div>
</div>
{action && <div className="text-muted-foreground">{action}</div>}
</div>
<div className="mt-3 flex items-end justify-between gap-4">
<div className="space-y-1">
<div className="flex items-baseline gap-1">
<span className={cn('tracking-tight', styles.value)}>{value}</span>
{unit && <span className={cn('text-muted-foreground', styles.unit)}>{unit}</span>}
</div>
<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 && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{delta.label && <span>{delta.label}</span>}
<span className={cn('font-medium', deltaColor)}>
{delta.isPositive ? '' : ''} {delta.value > 0 ? '+' : ''}{delta.value}{delta.label ? '' : '%'}
</span>
{delta.suffix && <span>{delta.suffix}</span>}
</div>
<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>
)}
</div>
{chart && <div className="flex-shrink-0">{chart}</div>}
</div>
</div>
</Stack>
{chart && <Box style={{ flexShrink: 0 }}>{chart}</Box>}
</Group>
</Paper>
)
}
)
+4 -4
View File
@@ -6,14 +6,14 @@ domain: ui
version: "1.0.0"
purity: impure
signature: "Label(props: LabelHTMLAttributes): JSX.Element"
description: "Etiqueta de formulario accesible con soporte para estados disabled y peer-disabled."
tags: [label, form, component, ui]
uses_functions: [cn_ts_core]
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: ["react"]
imports: ["@mantine/core"]
output: "Componente Label que renderiza etiqueta de formulario accesible con soporte para estados disabled"
tested: false
tests: []
+9 -8
View File
@@ -1,14 +1,15 @@
import * as React from "react"
import { cn } from "../core/cn"
import * as React from 'react'
import { Text } from '@mantine/core'
function Label({ className, ...props }: React.ComponentProps<"label">) {
function Label({ className, ...props }: React.ComponentProps<'label'>) {
return (
<label
<Text
component="label"
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
size="sm"
fw={500}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, userSelect: 'none' }}
className={className}
{...props}
/>
)
+5 -9
View File
@@ -6,15 +6,15 @@ domain: ui
version: "1.0.0"
purity: impure
signature: "LineChart(props: LineChartProps): JSX.Element"
description: "Gráfico de líneas Recharts con multi-series, 5 tipos de curva, zoom brush, líneas de referencia, tooltips temáticos."
tags: [chart, line, visualization, recharts, component, ui]
uses_functions: [cn_ts_core, chart_container_ts_ui, get_series_color_ts_core]
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: [recharts]
output: "Componente LineChart que renderiza gráfico de líneas multi-series con zoom, curvas customizables y líneas de referencia"
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: ""
@@ -32,10 +32,6 @@ props:
type: "Series[]"
required: false
description: "Series de datos"
- name: zoomable
type: "boolean"
required: false
description: "Habilitar zoom brush"
- name: curveType
type: "'linear' | 'monotone' | 'step' | 'stepBefore' | 'stepAfter'"
required: false
+31 -28
View File
@@ -1,8 +1,6 @@
import {
LineChart as RechartsLineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, Brush, ReferenceLine,
} from 'recharts'
import { ChartContainer, ChartTooltipContent, type Series, getSeriesColor } from './chart_container'
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'
@@ -15,9 +13,7 @@ interface LineChartProps {
showGrid?: boolean
showLegend?: boolean
showDots?: boolean
zoomable?: boolean
height?: number | string
className?: string
height?: number
xAxisFormatter?: (value: unknown) => string
yAxisFormatter?: (value: unknown) => string
valueFormatter?: (value: number) => string
@@ -26,30 +22,37 @@ interface LineChartProps {
function LineChartComponent({
data, xKey, yKey, series, curveType = 'monotone', showGrid = true, showLegend = false,
showDots = true, zoomable = false, height = 300, className, xAxisFormatter, yAxisFormatter,
showDots = true, height = 300, xAxisFormatter, yAxisFormatter,
valueFormatter = (v) => v.toLocaleString(), referenceLines = [],
}: LineChartProps) {
const lines = series
? series.map((s, i) => ({ dataKey: s.key, name: s.name, stroke: getSeriesColor(i, s.color) }))
: yKey ? [{ dataKey: yKey, name: yKey, stroke: getSeriesColor(0) }] : []
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 (
<ChartContainer className={className} height={height}>
<RechartsLineChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: zoomable ? 30 : 10 }}>
{showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />}
<XAxis dataKey={xKey} tickFormatter={xAxisFormatter} className="text-xs fill-muted-foreground" />
<YAxis tickFormatter={yAxisFormatter} className="text-xs fill-muted-foreground" />
<Tooltip content={<ChartTooltipContent valueFormatter={valueFormatter} />} cursor={{ stroke: 'hsl(var(--muted-foreground))', strokeDasharray: '3 3' }} />
{showLegend && <Legend />}
{referenceLines.map((ref, i) => (
<ReferenceLine key={i} y={ref.y} stroke={ref.color || 'hsl(var(--muted-foreground))'} strokeDasharray="3 3" label={ref.label ? { value: ref.label, position: 'right' } : undefined} />
))}
{lines.map((line) => (
<Line key={line.dataKey} type={curveType} dataKey={line.dataKey} name={line.name} stroke={line.stroke} strokeWidth={2} dot={showDots ? { r: 3, fill: line.stroke } : false} activeDot={{ r: 5, fill: line.stroke }} />
))}
{zoomable && <Brush dataKey={xKey} height={20} stroke="hsl(var(--primary))" fill="hsl(var(--muted))" tickFormatter={xAxisFormatter} />}
</RechartsLineChart>
</ChartContainer>
<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>
)
}
+58
View File
@@ -0,0 +1,58 @@
---
name: loading_overlay
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "FnLoadingOverlay(props: FnLoadingOverlayProps): JSX.Element"
description: "Overlay de carga con blur y opacidad configurable. Wrapper sobre Mantine LoadingOverlay."
tags: [mantine, loading, overlay, spinner, component, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
framework: react
props:
- name: visible
type: "boolean"
required: true
description: "Controla visibilidad del overlay"
- name: loaderSize
type: "number | string"
required: false
description: "Tamano del loader/spinner"
- name: overlayBlur
type: "number"
required: false
description: "Intensidad del blur del fondo, default 2"
- name: overlayOpacity
type: "number"
required: false
description: "Opacidad del overlay, default 0.5"
output: "Overlay semi-transparente con spinner centrado que cubre el contenedor padre"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/loading_overlay.tsx"
emits: []
has_state: false
variant: []
---
## Ejemplo
```tsx
import { FnLoadingOverlay } from '@fn_library'
<Box pos="relative">
<FnLoadingOverlay visible={loading} overlayBlur={3} />
<DataTable data={rows} />
</Box>
```
## Notas
Wrapper sobre Mantine `LoadingOverlay`. El contenedor padre necesita `position: relative` para que el overlay se posicione correctamente. Usa `loaderProps` y `overlayProps` internamente para mapear las props simplificadas.
+26
View File
@@ -0,0 +1,26 @@
import { LoadingOverlay } from '@mantine/core'
interface FnLoadingOverlayProps {
visible: boolean
loaderSize?: number | string
overlayBlur?: number
overlayOpacity?: number
}
function FnLoadingOverlay({
visible,
loaderSize,
overlayBlur = 2,
overlayOpacity = 0.5,
}: FnLoadingOverlayProps) {
return (
<LoadingOverlay
visible={visible}
loaderProps={loaderSize ? { size: loaderSize } : undefined}
overlayProps={{ blur: overlayBlur, backgroundOpacity: overlayOpacity }}
/>
)
}
export { FnLoadingOverlay }
export type { FnLoadingOverlayProps }
+59
View File
@@ -0,0 +1,59 @@
---
name: mantine_provider
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "FnMantineProvider({ children, theme?, defaultColorScheme? })"
description: "Provider raiz de Mantine para apps del registry. Wrappea MantineProvider con Notifications incluido. Importa los CSS de @mantine/core, charts y notifications."
tags: [mantine, provider, theme, react]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["@mantine/core", "@mantine/notifications", "@mantine/charts"]
framework: react
props:
- name: children
desc: "contenido de la app"
- name: theme
desc: "tema Mantine creado con createTheme() — colores, fuentes, radio, etc."
- name: defaultColorScheme
desc: "esquema de color por defecto: 'dark' | 'light' | 'auto'"
output: "arbol React envuelto en MantineProvider con notificaciones"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/mantine_provider.tsx"
---
## Ejemplo
```tsx
import { createTheme, MantineColorsTuple } from '@mantine/core'
import { FnMantineProvider } from '@fn_library'
const brand: MantineColorsTuple = [
'#e5f3ff', '#cde2ff', '#9ac2ff', '#64a0ff', '#3884fe',
'#1d72fe', '#0063ff', '#0058e4', '#004ecd', '#0043b5'
]
const theme = createTheme({
colors: { brand },
primaryColor: 'brand',
})
function App() {
return (
<FnMantineProvider theme={theme} defaultColorScheme="dark">
{/* Tu app aqui */}
</FnMantineProvider>
)
}
```
## Notas
Reemplaza ThemeProvider + applyTheme del sistema anterior. Las apps definen su propio tema con `createTheme()` y lo pasan como prop. Los CSS de Mantine se importan una sola vez aqui.
@@ -0,0 +1,29 @@
import '@mantine/core/styles.css'
import '@mantine/charts/styles.css'
import '@mantine/notifications/styles.css'
import * as React from 'react'
import { MantineProvider, type MantineThemeOverride, type MantineColorScheme } from '@mantine/core'
import { Notifications } from '@mantine/notifications'
interface FnMantineProviderProps {
children: React.ReactNode
theme?: MantineThemeOverride
defaultColorScheme?: MantineColorScheme
}
function FnMantineProvider({
children,
theme,
defaultColorScheme = 'dark',
}: FnMantineProviderProps) {
return (
<MantineProvider theme={theme} defaultColorScheme={defaultColorScheme}>
<Notifications position="top-right" />
{children}
</MantineProvider>
)
}
export { FnMantineProvider }
export type { FnMantineProviderProps }
+111
View File
@@ -0,0 +1,111 @@
---
name: multi_select
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "MultiSelect(props: MultiSelectProps): JSX.Element"
description: "Selector múltiple con búsqueda, pills y límite de selecciones. Wrapper sobre Mantine MultiSelect."
tags: [multi-select, form, dropdown, pills, component, ui, interactive, mantine]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
output: "Componente MultiSelect que renderiza dropdown con selección múltiple, búsqueda y pills"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/multi_select.tsx"
props:
- name: data
type: "string[] | { value: string; label: string; disabled?: boolean }[]"
required: true
description: "Opciones del selector — strings o objetos {value, label}"
- name: value
type: "string[]"
required: false
description: "Valores seleccionados (controlled)"
- name: onChange
type: "(value: string[]) => void"
required: false
description: "Callback al cambiar la selección"
- name: searchable
type: "boolean"
required: false
description: "Permite buscar entre opciones"
- name: clearable
type: "boolean"
required: false
description: "Muestra botón para limpiar toda la selección"
- name: maxValues
type: "number"
required: false
description: "Número máximo de valores seleccionables"
- name: placeholder
type: "string"
required: false
description: "Texto cuando no hay selección"
- name: label
type: "string"
required: false
description: "Label del campo"
- name: disabled
type: "boolean"
required: false
description: "Deshabilitar el selector"
- name: size
type: "'xs' | 'sm' | 'md' | 'lg' | 'xl'"
required: false
description: "Tamaño del componente"
emits: [onChange]
has_state: true
framework: react
variant: [default]
---
## Ejemplo
```tsx
import { MultiSelect } from '@fn_library'
// Opciones simples
<MultiSelect
label="Frameworks favoritos"
placeholder="Elige uno o más"
data={['React', 'Vue', 'Angular', 'Svelte']}
/>
// Con búsqueda y clearable
<MultiSelect
label="Tecnologías"
placeholder="Busca y selecciona"
searchable
clearable
value={selected}
onChange={setSelected}
data={[
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
{ value: 'ts', label: 'TypeScript' },
]}
/>
// Máximo de selecciones
<MultiSelect
label="Elige hasta 2"
placeholder="Máximo 2"
maxValues={2}
data={['Opción A', 'Opción B', 'Opción C', 'Opción D']}
/>
```
## Notas
- Wrapper directo sobre `MultiSelect` de `@mantine/core` v9. Todas las props de Mantine MultiSelect son válidas.
- A diferencia de `Select`, `value` es `string[]` y `onChange` recibe `string[]`.
- Las selecciones se muestran como pills dentro del input, eliminables con clic.
- `maxValues` limita cuántos items pueden seleccionarse — el dropdown bloquea más selecciones al alcanzar el límite.
- Soporta `searchable` para filtrar opciones y `clearable` para un botón de limpiar todo.
+10
View File
@@ -0,0 +1,10 @@
import { MultiSelect as MantineMultiSelect, type MultiSelectProps as MantineMultiSelectProps } from '@mantine/core'
interface MultiSelectProps extends MantineMultiSelectProps {}
function MultiSelect(props: MultiSelectProps) {
return <MantineMultiSelect {...props} />
}
export { MultiSelect }
export type { MultiSelectProps }
+80
View File
@@ -0,0 +1,80 @@
---
name: nav_link
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "FnNavLink(props: FnNavLinkProps): JSX.Element"
description: "Link de navegacion con icono, descripcion y anidamiento. Wrapper sobre Mantine NavLink."
tags: [mantine, navigation, link, sidebar, menu, component, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
framework: react
props:
- name: label
type: "string"
required: true
description: "Texto principal del link"
- name: description
type: "string"
required: false
description: "Texto secundario debajo del label"
- name: icon
type: "ReactNode"
required: false
description: "Icono a la izquierda del label"
- name: active
type: "boolean"
required: false
description: "Estado activo/seleccionado"
- name: onClick
type: "MouseEventHandler"
required: false
description: "Callback al hacer click"
- name: href
type: "string"
required: false
description: "URL para navegacion como anchor"
- name: children
type: "ReactNode"
required: false
description: "NavLinks hijos para crear arbol anidado"
- name: opened
type: "boolean"
required: false
description: "Controla si los hijos estan expandidos"
- name: defaultOpened
type: "boolean"
required: false
description: "Estado inicial de expansion de hijos"
output: "Link de navegacion con highlight activo, icono y soporte para sub-items colapsables"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/nav_link.tsx"
emits: []
has_state: false
variant: []
---
## Ejemplo
```tsx
import { FnNavLink } from '@fn_library'
import { IconHome, IconSettings } from '@tabler/icons-react'
<FnNavLink label="Home" icon={<IconHome size={16} />} active />
<FnNavLink label="Settings" icon={<IconSettings size={16} />} defaultOpened>
<FnNavLink label="General" />
<FnNavLink label="Seguridad" />
</FnNavLink>
```
## Notas
Wrapper sobre Mantine `NavLink`. Soporta anidamiento -- pasar `FnNavLink` como children crea un arbol colapsable. El `icon` se mapea a `leftSection` internamente. Ideal para uso dentro de `FnAppShell` navbar.
+44
View File
@@ -0,0 +1,44 @@
import * as React from 'react'
import { NavLink } from '@mantine/core'
interface FnNavLinkProps {
label: string
description?: string
icon?: React.ReactNode
active?: boolean
onClick?: React.MouseEventHandler<HTMLButtonElement>
href?: string
children?: React.ReactNode
opened?: boolean
defaultOpened?: boolean
}
function FnNavLink({
label,
description,
icon,
active,
onClick,
href,
children,
opened,
defaultOpened,
}: FnNavLinkProps) {
return (
<NavLink
label={label}
description={description}
leftSection={icon}
active={active}
onClick={onClick}
href={href}
opened={opened}
defaultOpened={defaultOpened}
>
{children}
</NavLink>
)
}
export { FnNavLink }
export type { FnNavLinkProps }
+92
View File
@@ -0,0 +1,92 @@
---
name: number_input
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "FnNumberInput(props: FnNumberInputProps): JSX.Element"
description: "Input numerico con min/max, step, prefijo y sufijo. Wrapper sobre Mantine NumberInput."
tags: [mantine, input, number, form, component, ui]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
framework: react
props:
- name: value
type: "number | string"
required: false
description: "Valor actual del input"
- name: onChange
type: "(value: number | string) => void"
required: false
description: "Callback cuando cambia el valor"
- name: min
type: "number"
required: false
description: "Valor minimo permitido"
- name: max
type: "number"
required: false
description: "Valor maximo permitido"
- name: step
type: "number"
required: false
description: "Incremento/decremento por click"
- name: label
type: "string"
required: false
description: "Etiqueta del input"
- name: description
type: "string"
required: false
description: "Texto descriptivo debajo del input"
- name: error
type: "string"
required: false
description: "Mensaje de error"
- name: placeholder
type: "string"
required: false
description: "Placeholder del input"
- name: prefix
type: "string"
required: false
description: "Texto prefijo dentro del input (ej: $)"
- name: suffix
type: "string"
required: false
description: "Texto sufijo dentro del input (ej: kg)"
output: "Input numerico con controles de incremento, validacion y decoradores de texto"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/number_input.tsx"
emits: []
has_state: false
variant: []
---
## Ejemplo
```tsx
import { FnNumberInput } from '@fn_library'
<FnNumberInput
label="Precio"
value={price}
onChange={setPrice}
min={0}
max={10000}
step={0.01}
prefix="$"
description="Precio unitario en USD"
/>
```
## Notas
Wrapper sobre Mantine `NumberInput`. Soporta prefix/suffix para decorar el valor visualmente. Los controles de incremento/decremento respetan min/max/step.
+48
View File
@@ -0,0 +1,48 @@
import { NumberInput } from '@mantine/core'
interface FnNumberInputProps {
value?: number | string
onChange?: (value: number | string) => void
min?: number
max?: number
step?: number
label?: string
description?: string
error?: string
placeholder?: string
prefix?: string
suffix?: string
}
function FnNumberInput({
value,
onChange,
min,
max,
step,
label,
description,
error,
placeholder,
prefix,
suffix,
}: FnNumberInputProps) {
return (
<NumberInput
value={value}
onChange={onChange}
min={min}
max={max}
step={step}
label={label}
description={description}
error={error}
placeholder={placeholder}
prefix={prefix}
suffix={suffix}
/>
)
}
export { FnNumberInput }
export type { FnNumberInputProps }
+2 -2
View File
@@ -8,12 +8,12 @@ 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: [cn_ts_core]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [react]
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: []

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