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. 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) ## PASO 1: Consultar el registry (OBLIGATORIO)
@@ -56,11 +67,12 @@ apps/{nombre}/
package.json package.json
vite.config.ts vite.config.ts
tsconfig.json tsconfig.json
postcss.config.cjs
index.html index.html
src/ src/
main.tsx # Entry point main.tsx # Entry point con MantineProvider
App.tsx # Root con ThemeProvider + Router App.tsx # Root con Router
app.css # Tokens CSS — NUNCA hardcodear colores app.css # Minimal (font-smoothing solo)
features/ # Feature-based co-location features/ # Feature-based co-location
{feature}/ {feature}/
components/ # Componentes del feature components/ # Componentes del feature
@@ -87,21 +99,20 @@ apps/{nombre}/
"preview": "vite preview --host" "preview": "vite preview --host"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@mantine/core": "^9.0.0",
"class-variance-authority": "^0.7.1", "@mantine/hooks": "^9.0.0",
"clsx": "^2.1.1", "@mantine/notifications": "^9.0.0",
"lucide-react": "^0.577.0", "@phosphor-icons/react": "^2.1.10",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4"
"recharts": "^2.15.0",
"tailwind-merge": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0", "@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", "typescript": "~5.9.3",
"vite": "^8.0.0" "vite": "^8.0.0"
} }
@@ -109,10 +120,10 @@ apps/{nombre}/
``` ```
Agregar dependencias extras segun necesidad: Agregar dependencias extras segun necesidad:
- **Charts**: `@mantine/charts`, `recharts`
- **Tablas**: `@tanstack/react-table` - **Tablas**: `@tanstack/react-table`
- **Charts**: `recharts`
- **Iconos extra**: `@phosphor-icons/react`
- **Forms**: `react-hook-form`, `@hookform/resolvers`, `zod` - **Forms**: `react-hook-form`, `@hookform/resolvers`, `zod`
- **Dates**: `@mantine/dates`, `dayjs`
- **Router**: `react-router` o `@tanstack/react-router` - **Router**: `react-router` o `@tanstack/react-router`
- **State**: `zustand` (client state), `@tanstack/react-query` (server state) - **State**: `zustand` (client state), `@tanstack/react-query` (server state)
- **Wails**: los hooks de Wails ya estan en `@fn_library` (useWailsQuery, useWailsMutation, useWailsStream, useWailsEvent, WailsProvider) - **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 ```ts
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { resolve } from 'path' import { resolve } from 'path'
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
'@': resolve(__dirname, './src'), '@': resolve(__dirname, './src'),
@@ -134,6 +144,9 @@ export default defineConfig({
}, },
dedupe: ['react', 'react-dom'], dedupe: ['react', 'react-dom'],
}, },
css: {
postcss: resolve(__dirname, './postcss.config.cjs'),
},
build: { build: {
target: 'es2022', target: 'es2022',
rollupOptions: { 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 ### app.css base
```css ```css
@import "tailwindcss"; /* Minimal — Mantine handles all theming via MantineProvider */
html {
@theme inline { -webkit-font-smoothing: antialiased;
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif; -moz-osx-font-smoothing: grayscale;
--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;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -259,18 +196,33 @@ export default defineConfig({
} }
``` ```
### App.tsx base ### main.tsx base
```tsx ```tsx
import { ThemeProvider } from '@fn_library' import '@mantine/core/styles.css'
import '@mantine/notifications/styles.css'
import './app.css'
export default function App() { import React from 'react'
return ( import ReactDOM from 'react-dom/client'
<ThemeProvider defaultTheme="dark"> import { MantineProvider, createTheme } from '@mantine/core'
{/* Router y contenido aqui */} import { Notifications } from '@mantine/notifications'
</ThemeProvider> 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 ### Despues del scaffold
@@ -287,17 +239,16 @@ Para componentes nuevos que van al registry en `frontend/functions/`.
### Reglas de implementacion ### Reglas de implementacion
1. **Headless first**: usar `@base-ui/react` como primitivo si el componente es interactivo (dialog, select, tooltip, etc.) 1. **Mantine first**: wrappear componentes de Mantine. Solo crear desde cero si Mantine no tiene equivalente.
2. **CVA para variantes**: SIEMPRE usar `class-variance-authority` para definir variantes 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. **cn() para clases**: SIEMPRE usar `cn()` de `frontend/functions/core/cn.ts` para componer classNames 3. **CSS variables de Mantine**: si necesitas styles inline, usar `var(--mantine-color-*)`, `var(--mantine-spacing-*)`, etc.
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`) 4. **Iconos**: usar `@phosphor-icons/react`, no lucide-react ni @tabler/icons-react.
5. **Props tipadas**: usar `React.ComponentPropsWithoutRef<"element">` para HTML props spreading 5. **Props tipadas**: usar `React.ComponentPropsWithoutRef<"element">` para HTML props spreading.
6. **Accesibilidad**: 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 - 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 - `aria-invalid` + `aria-describedby` en inputs con error
- `role="status"` para loading states
- Focus management en modales/popovers - Focus management en modales/popovers
7. **Discriminated unions** cuando las props cambian segun variante: 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 ### Patron de archivo .tsx
```tsx ```tsx
import * as React from 'react' import { Select, type SelectProps } from '@mantine/core'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../core/cn'
const componentVariants = cva( // Re-export con defaults o logica adicional si necesario
'base-classes-here', // clases base interface MySelectProps extends Omit<SelectProps, 'xxx'> {
{
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 */
customProp?: string customProp?: string
} }
const Component = React.forwardRef<HTMLDivElement, ComponentProps>( function MySelect({ customProp, ...props }: MySelectProps) {
({ className, variant, size, customProp, ...props }, ref) => { return <Select {...props} />
return ( }
<div
ref={ref}
className={cn(componentVariants({ variant, size }), className)}
{...props}
/>
)
}
)
Component.displayName = 'Component'
export { Component, componentVariants } export { MySelect }
export type { ComponentProps } export type { MySelectProps }
``` ```
### Patron de archivo .md ### Patron de archivo .md
@@ -376,12 +292,12 @@ purity: impure
signature: "ComponentName(props: ComponentProps): JSX.Element" signature: "ComponentName(props: ComponentProps): JSX.Element"
description: "Descripcion concisa de que hace el componente" description: "Descripcion concisa de que hace el componente"
tags: [component, ui, ...] tags: [component, ui, ...]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" error_type: ""
imports: ["@base-ui/react", "class-variance-authority"] imports: ["@mantine/core"]
tested: false tested: false
tests: [] tests: []
test_file_path: "" test_file_path: ""
@@ -391,14 +307,10 @@ props:
type: "'default' | 'secondary'" type: "'default' | 'secondary'"
required: false required: false
description: "Estilo visual" description: "Estilo visual"
- name: className
type: "string"
required: false
description: "Clases CSS adicionales"
emits: [] emits: []
has_state: false has_state: false
framework: react framework: react
variant: [default, secondary] variant: [default]
--- ---
## Ejemplo ## Ejemplo
@@ -493,7 +405,7 @@ function useFeatureData() {
```tsx ```tsx
import { lazy, Suspense } from 'react' import { lazy, Suspense } from 'react'
import { Skeleton } from '@fn_library' import { Skeleton } from '@mantine/core'
const FeaturePage = lazy(() => import('./features/feature/components/FeaturePage')) const FeaturePage = lazy(() => import('./features/feature/components/FeaturePage'))
@@ -501,7 +413,7 @@ function AppRoutes() {
return ( return (
<Routes> <Routes>
<Route path="/feature" element={ <Route path="/feature" element={
<Suspense fallback={<Skeleton className="h-screen w-full" />}> <Suspense fallback={<Skeleton height="100vh" />}>
<FeaturePage /> <FeaturePage />
</Suspense> </Suspense>
} /> } />
@@ -517,14 +429,19 @@ function AppRoutes() {
Antes de dar por terminado cualquier trabajo frontend, verificar: Antes de dar por terminado cualquier trabajo frontend, verificar:
### Colores y estilos ### Colores y estilos
- [ ] CERO colores hardcodeados (no hex, no rgb, no oklch inline en componentes) - [ ] CERO colores hardcodeados en componentes (no hex, no rgb inline)
- [ ] Solo clases Tailwind mapeadas a CSS variables: `bg-primary`, `text-foreground`, `border-border`, etc. - [ ] Styling via props de Mantine (`size`, `color`, `variant`, `p`, `m`, `fw`, `gap`, etc.)
- [ ] `cn()` usado para merge de clases en todo componente - [ ] Si se necesitan styles inline, usar CSS variables de Mantine (`var(--mantine-color-*)`)
- [ ] CVA usado para variantes (no condicionales manuales con ternarios) - [ ] NO clases CSS manuales, NO Tailwind, NO cn(), NO CVA
### Componentes del registry ### Componentes del registry
- [ ] Verificado que no se esta recreando algo que ya existe en `@fn_library` (`frontend/functions/ui/`) - [ ] 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 ### TypeScript
- [ ] Props interfaces con `React.ComponentPropsWithoutRef` para HTML spreading - [ ] 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 - [ ] No `any` — usar `unknown` + type guards si es necesario
### Accesibilidad ### Accesibilidad
- [ ] Elementos semanticos (button, a, dialog — no div onClick) - [ ] Elementos semanticos (button, a — no div onClick)
- [ ] `aria-label` en botones de solo icono - [ ] `aria-label` en botones de solo icono
- [ ] `aria-invalid` + `aria-describedby` en inputs con validacion - [ ] `aria-invalid` + `aria-describedby` en inputs con validacion
- [ ] Focus trap en modales y popovers - [ ] Focus trap en modales y popovers
@@ -555,15 +472,15 @@ Antes de dar por terminado cualquier trabajo frontend, verificar:
## ANTI-PATRONES (nunca hacer) ## ANTI-PATRONES (nunca hacer)
1. **`<div onClick={...}>`** → usar `<button>` o Base-UI primitivo 1. **`<div onClick={...}>`** → usar `<button>` o componente Mantine
2. **`style={{ color: '#3b82f6' }}`** → usar `className="text-primary"` 2. **`style={{ color: '#3b82f6' }}`** → usar prop `c="blue"` o `var(--mantine-color-blue-6)`
3. **`import Button from './MyButton'`** cuando existe en la lib → usar `import { Button } from '@fn_library'` 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) 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 5. **`index.ts` en la raiz de `src/`** que re-exporta todo → mata tree-shaking
6. **`// @ts-ignore`** → arreglar el tipo 6. **`// @ts-ignore`** → arreglar el tipo
7. **CSS-in-JS runtime** (styled-components, emotion) → usar Tailwind 7. **CSS-in-JS runtime** (styled-components, emotion) → usar props de Mantine
8. **Instalar shadcn/ui como dependencia** → los componentes ya estan en el registry, usar `@fn_library` 8. **Tailwind, CVA, cn(), clsx** → usar props de Mantine y su style system
9. **Crear utilidades que ya existen**: `cn()`, `getSeriesColor()`, `ChartContainer`, `ThemeProvider` ya estan en `@fn_library` 9. **Crear utilidades que ya existen**: `getSeriesColor()`, `ChartContainer`, `DashboardLayout`, `DataTable` ya estan en `@fn_library`
10. **Colores de chart hardcodeados** → usar `--chart-1` a `--chart-5` o `getSeriesColor()` 10. **Colores de chart hardcodeados** → usar `@mantine/charts` color system o `getSeriesColor()`
$ARGUMENTS $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 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 = [ export const chartColors = [
'hsl(var(--chart-1, 220 70% 50%))', '#3b82f6',
'hsl(var(--chart-2, 160 60% 45%))', '#10b981',
'hsl(var(--chart-3, 30 80% 55%))', '#f59e0b',
'hsl(var(--chart-4, 280 65% 60%))', '#8b5cf6',
'hsl(var(--chart-5, 340 75% 55%))', '#ef4444',
] ]
export function getChartColor(index: number): string { 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'] const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899']
export function getSeriesColor(index: number, color?: string): string { export function getSeriesColor(index: number, color?: string): string {
return color || defaultColors[index % defaultColors.length] return color ?? defaultColors[index % defaultColors.length]!
} }
export { defaultColors } 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" version: "1.0.0"
purity: impure purity: impure
signature: "Accordion(props: AccordionProps): JSX.Element" signature: "Accordion(props: AccordionProps): JSX.Element"
description: "Secciones colapsables con animaciones. Base-UI Collapsible primitive. Composable: AccordionItem + AccordionTrigger + AccordionContent." description: "Secciones colapsables con animaciones. Mantine Accordion. Composable: AccordionItem + AccordionTrigger + AccordionContent."
tags: [accordion, collapsible, component, ui, interactive, base-ui] tags: [accordion, collapsible, component, ui, interactive, mantine]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" 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" output: "Componente Accordion que renderiza secciones colapsables con soporte para múltiples items abiertos simultáneamente"
tested: false tested: false
tests: [] tests: []
@@ -33,14 +33,14 @@ variant: []
## Ejemplo ## Ejemplo
```tsx ```tsx
<Accordion> <Accordion defaultValue="section-1">
<AccordionItem defaultOpen> <AccordionItem value="section-1">
<AccordionTrigger>Seccion 1</AccordionTrigger> <AccordionTrigger>Seccion 1</AccordionTrigger>
<AccordionContent> <AccordionContent>
Contenido de la primera seccion. Contenido de la primera seccion.
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem> <AccordionItem value="section-2">
<AccordionTrigger>Seccion 2</AccordionTrigger> <AccordionTrigger>Seccion 2</AccordionTrigger>
<AccordionContent> <AccordionContent>
Contenido de la segunda seccion. Contenido de la segunda seccion.
@@ -51,4 +51,4 @@ variant: []
## Notas ## 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 * as React from "react"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible" import { Accordion as MantineAccordion } from "@mantine/core"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "../core/cn"
interface AccordionItem { interface AccordionItem {
value: string value: string
@@ -19,61 +17,72 @@ interface AccordionProps {
children?: React.ReactNode 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 ( 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} {children}
</div> </MantineAccordion>
) )
} }
interface AccordionItemProps extends CollapsiblePrimitive.Root.Props { interface AccordionItemProps {
value: string
className?: string className?: string
children?: React.ReactNode
disabled?: boolean
} }
function AccordionItem({ className, ...props }: AccordionItemProps) { function AccordionItem({ className, value, children, ...props }: AccordionItemProps) {
return ( return (
<CollapsiblePrimitive.Root <MantineAccordion.Item
data-slot="accordion-item" data-slot="accordion-item"
className={cn("group/accordion-item", className)} value={value}
{...props} className={className}
/>
)
}
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
)}
{...props} {...props}
> >
{children} {children}
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform duration-200" /> </MantineAccordion.Item>
</CollapsiblePrimitive.Trigger>
) )
} }
function AccordionContent({ className, children, ...props }: CollapsiblePrimitive.Panel.Props) { function AccordionTrigger({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
return ( return (
<CollapsiblePrimitive.Panel <MantineAccordion.Control
data-slot="accordion-content" data-slot="accordion-trigger"
className={cn( className={className}
"overflow-hidden text-sm",
"data-open:animate-in data-open:fade-in-0",
"data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props} {...props}
> >
<div className="pb-4">{children}</div> {children}
</CollapsiblePrimitive.Panel> </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" version: "1.0.0"
purity: impure purity: impure
signature: "Alert(props: { variant?: 'default' | 'destructive' }): JSX.Element" 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." 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] tags: [alert, feedback, component, ui, notification, mantine]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" error_type: ""
imports: [react, class-variance-authority] imports: ["@mantine/core", react]
output: "Componente Alert que renderiza una alerta accesible con slots para título, descripción, icono y acción" output: "Componente Alert que renderiza una alerta accesible via Mantine Alert con slots para título, descripción y acción"
tested: false tested: false
tests: [] tests: []
test_file_path: "" test_file_path: ""
@@ -46,5 +46,5 @@ source_file: "frontend/src/components/ui/alert.tsx"
## Notas ## Notas
Exporta 4 subcomponentes composables via data-slot: Alert, AlertTitle, AlertDescription, AlertAction. 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: boton cerrar).
AlertAction se posiciona absolute top-right para acciones secundarias (ej: botón 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 * as React from 'react'
import { cva, type VariantProps } from "class-variance-authority" import { Alert as MantineAlert, Box, Text } from '@mantine/core'
import { cn } from "../core/cn"
const alertVariants = cva( type AlertVariant = 'default' | 'destructive'
"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" },
}
)
function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) { const variantColorMap: Record<AlertVariant, string | undefined> = {
return <div data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} /> default: undefined,
destructive: 'red',
} }
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { function Alert({
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} /> 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">) { function AlertTitle({ 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} /> return (
<Text
component="div"
data-slot="alert-title"
fw={500}
size="sm"
className={className}
{...props}
/>
)
} }
function AlertAction({ className, ...props }: React.ComponentProps<"div">) { function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="alert-action" className={cn("absolute top-2 right-2", className)} {...props} /> 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 } export { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants }
+2 -2
View File
@@ -8,12 +8,12 @@ purity: pure
signature: "analyticsPage(props: AnalyticsPageProps): ReactElement" signature: "analyticsPage(props: AnalyticsPageProps): ReactElement"
description: "Genera un dashboard de analytics completo con header, fila de KPIs con deltas y grid de charts configurables." 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] tags: [analytics, dashboard, kpi, charts, factory, composition, ui]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" error_type: ""
imports: [react] imports: [react, "@mantine/core"]
params: params:
- name: props - name: props
desc: "Configuración del dashboard: título, métricas con deltas, y lista de charts con span" 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 * as React from 'react'
import { cn } from '../core/cn' import { Stack, Group, Title, Text, Paper, SimpleGrid } from '@mantine/core'
interface MetricConfig { interface MetricConfig {
label: string label: string
@@ -34,67 +34,63 @@ export function analyticsPage({
metrics, metrics,
charts, charts,
actions, actions,
className,
}: AnalyticsPageProps): React.ReactElement { }: 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 ( return (
<div className={cn('space-y-6', className)}> <Stack gap="lg">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b pb-4"> <Group justify="space-between" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
<div className="space-y-1"> <Stack gap={4}>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1> <Title order={2}>{title}</Title>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>} {subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
</div> </Stack>
<div className="flex items-center gap-2"> <Group gap="xs">
{dateRange} {dateRange}
{actions} {actions}
</div> </Group>
</div> </Group>
{/* KPI Row */} {/* KPI Row */}
<div className={cn( <SimpleGrid cols={metricCols} spacing="md">
'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'
)}>
{metrics.map((metric, i) => ( {metrics.map((metric, i) => (
<div key={i} className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm"> <Paper key={i} p="md" withBorder shadow="xs">
<p className="text-sm text-muted-foreground">{metric.label}</p> <Text size="sm" c="dimmed">{metric.label}</Text>
<div className="mt-2 flex items-end justify-between gap-4"> <Group mt="xs" justify="space-between" align="flex-end" gap="md">
<div className="space-y-1"> <Stack gap={4}>
<p className="text-3xl font-bold tracking-tight">{metric.value}</p> <Text fz={30} fw={700} lh={1}>{metric.value}</Text>
{metric.delta && ( {metric.delta && (
<div className={cn( <Text
'flex items-center gap-1 text-sm font-medium', size="sm"
metric.delta.value === 0 ? 'text-muted-foreground' : fw={500}
metric.delta.isPositive ? 'text-green-600 dark:text-green-500' : c={metric.delta.value === 0 ? 'dimmed' : metric.delta.isPositive ? 'green' : 'red'}
'text-red-600 dark:text-red-500' >
)}> {metric.delta.value > 0 ? '+' : ''}{metric.delta.value}%
<span>{metric.delta.value > 0 ? '+' : ''}{metric.delta.value}%</span> </Text>
</div>
)} )}
</div> </Stack>
</div> </Group>
</div> </Paper>
))} ))}
</div> </SimpleGrid>
{/* Charts Grid */} {/* 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) => ( {charts.map((chart) => (
<div <Paper
key={chart.id} key={chart.id}
className={cn( p="md"
'rounded-xl border bg-card p-4 text-card-foreground shadow-sm', withBorder
chart.span === 2 && 'lg:col-span-2' 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} {chart.content}
</div> </Paper>
))} ))}
</div> </SimpleGrid>
</div> </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" version: "1.0.0"
purity: impure purity: impure
signature: "AreaChart(props: AreaChartProps): JSX.Element" signature: "AreaChart(props: AreaChartProps): JSX.Element"
description: "Gráfico de área Recharts con gradientes automáticos, multi-series, stacking y tooltips temáticos." description: "Gráfico de área @mantine/charts con gradientes automáticos, multi-series, stacking y tooltips."
tags: [chart, area, visualization, recharts, gradient, component, ui] tags: [chart, area, visualization, mantine, gradient, component, ui]
uses_functions: [cn_ts_core, chart_container_ts_ui, get_series_color_ts_core] uses_functions: [chart_container_ts_ui]
uses_types: [ChartSeries_ts_ui] uses_types: [ChartSeries_ts_ui]
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" error_type: ""
imports: [recharts] imports: ["@mantine/charts", "@mantine/core"]
output: "Componente JSX que renderiza un gráfico de área con gradientes, multi-series y tooltips temáticos" output: "Componente JSX que renderiza un gráfico de área con gradientes, multi-series y tooltips"
tested: false tested: false
tests: [] tests: []
test_file_path: "" test_file_path: ""
+29 -37
View File
@@ -1,9 +1,6 @@
import { import { AreaChart as MantineAreaChart } from '@mantine/charts'
AreaChart as RechartsAreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, import { Paper } from '@mantine/core'
} from 'recharts' import { type Series, getSeriesColor } from './chart_container'
import { ChartContainer, ChartTooltipContent, type Series, getSeriesColor } from './chart_container'
interface GradientConfig { from: string; to: string }
interface AreaChartProps { interface AreaChartProps {
data: Record<string, unknown>[] data: Record<string, unknown>[]
@@ -11,11 +8,10 @@ interface AreaChartProps {
yKey?: string yKey?: string
series?: Series[] series?: Series[]
stacked?: boolean stacked?: boolean
gradient?: GradientConfig | boolean gradient?: boolean
showGrid?: boolean showGrid?: boolean
showLegend?: boolean showLegend?: boolean
height?: number | string height?: number
className?: string
xAxisFormatter?: (value: unknown) => string xAxisFormatter?: (value: unknown) => string
yAxisFormatter?: (value: unknown) => string yAxisFormatter?: (value: unknown) => string
valueFormatter?: (value: number) => string valueFormatter?: (value: number) => string
@@ -23,40 +19,36 @@ interface AreaChartProps {
function AreaChartComponent({ function AreaChartComponent({
data, xKey, yKey, series, stacked = false, gradient = true, showGrid = true, 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(), valueFormatter = (v) => v.toLocaleString(),
}: AreaChartProps) { }: AreaChartProps) {
const areas = series const chartSeries = series
? series.map((s, i) => ({ dataKey: s.key, name: s.name, color: getSeriesColor(i, s.color) })) ? series.map((s, i) => ({ name: s.key, label: s.name, color: getSeriesColor(i, s.color) }))
: yKey ? [{ dataKey: yKey, name: yKey, color: getSeriesColor(0) }] : [] : yKey ? [{ name: yKey, label: yKey, color: getSeriesColor(0) }] : []
const gradientConfig: GradientConfig | null = gradient
? typeof gradient === 'object' ? gradient : { from: '', to: 'transparent' }
: null
return ( return (
<ChartContainer className={className} height={height}> <Paper p="md">
<RechartsAreaChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}> <MantineAreaChart
<defs> h={height}
{areas.map((area) => ( data={data}
<linearGradient key={area.dataKey} id={`gradient-${area.dataKey}`} x1="0" y1="0" x2="0" y2="1"> dataKey={xKey}
<stop offset="5%" stopColor={gradientConfig?.from || area.color} stopOpacity={0.8} /> series={chartSeries}
<stop offset="95%" stopColor={gradientConfig?.to || area.color} stopOpacity={0.1} /> type={stacked ? 'stacked' : 'default'}
</linearGradient> curveType="monotone"
))} withGradient={gradient}
</defs> gridAxis={showGrid ? 'xy' : 'none'}
{showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />} withLegend={showLegend}
<XAxis dataKey={xKey} tickFormatter={xAxisFormatter} className="text-xs fill-muted-foreground" /> withTooltip
<YAxis tickFormatter={yAxisFormatter} className="text-xs fill-muted-foreground" /> valueFormatter={valueFormatter}
<Tooltip content={<ChartTooltipContent valueFormatter={valueFormatter} />} cursor={{ stroke: 'hsl(var(--muted-foreground))', strokeDasharray: '3 3' }} /> xAxisProps={xAxisFormatter ? { tickFormatter: xAxisFormatter } : undefined}
{showLegend && <Legend />} yAxisProps={yAxisFormatter ? { tickFormatter: yAxisFormatter } : undefined}
{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} /> </Paper>
))}
</RechartsAreaChart>
</ChartContainer>
) )
} }
/** @deprecated Gradient is handled by Mantine's withGradient prop */
type GradientConfig = { from: string; to: string }
export const AreaChart = AreaChartComponent export const AreaChart = AreaChartComponent
export type { AreaChartProps, GradientConfig } 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" version: "1.0.0"
purity: impure purity: impure
signature: "Avatar(props: AvatarProps): JSX.Element" signature: "Avatar(props: AvatarProps): JSX.Element"
description: "Imagen de usuario circular con fallback a iniciales generadas automaticamente. 5 tamaños via CVA." description: "Imagen de usuario circular con fallback a iniciales generadas automaticamente. 5 tamaños via Mantine Avatar."
tags: [avatar, user, image, component, ui, cva] tags: [avatar, user, image, component, ui, mantine]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" error_type: ""
imports: ["class-variance-authority"] imports: ["@mantine/core"]
output: "Componente Avatar que renderiza imagen de usuario circular con fallback a iniciales generadas" output: "Componente Avatar que renderiza imagen de usuario circular con fallback a iniciales generadas"
tested: false tested: false
tests: [] tests: []
@@ -45,7 +45,7 @@ props:
required: false required: false
description: "Clases CSS adicionales" description: "Clases CSS adicionales"
emits: [] emits: []
has_state: true has_state: false
framework: react framework: react
variant: [xs, sm, md, lg, xl] variant: [xs, sm, md, lg, xl]
--- ---
@@ -68,4 +68,4 @@ variant: [xs, sm, md, lg, xl]
## Notas ## 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 * as React from 'react'
import { cva, type VariantProps } from "class-variance-authority" import { Avatar as MantineAvatar } from '@mantine/core'
import { cn } from "../core/cn"
const avatarVariants = cva( type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
"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" },
}
)
interface AvatarProps const sizeMap: Record<AvatarSize, string> = {
extends React.ComponentPropsWithoutRef<"span">, xs: 'sm',
VariantProps<typeof avatarVariants> { sm: 'sm',
md: 'md',
lg: 'lg',
xl: 'xl',
}
interface AvatarProps extends React.ComponentPropsWithoutRef<'div'> {
src?: string src?: string
alt?: string alt?: string
fallback?: string fallback?: string
initials?: string initials?: string
size?: AvatarSize
} }
function getInitials(name?: string): string { function getInitials(name?: string): string {
if (!name) return "?" if (!name) return '?'
const parts = name.trim().split(/\s+/) const parts = name.trim().split(/\s+/)
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase() const first = parts[0] ?? ''
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() 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>( /** Kept for backwards compatibility */
({ className, size, src, alt, fallback, initials, ...props }, ref) => { const avatarVariants = sizeMap
const [imgError, setImgError] = React.useState(false)
const showImage = src && !imgError const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
({ className, size = 'md', src, alt, fallback, initials, ...props }, ref) => {
const displayInitials = initials ?? getInitials(fallback ?? alt) const displayInitials = initials ?? getInitials(fallback ?? alt)
return ( return (
<span <MantineAvatar
ref={ref} ref={ref}
data-slot="avatar" data-slot="avatar"
className={cn(avatarVariants({ size }), className)} src={src}
alt={alt ?? ''}
size={sizeMap[size]}
radius="xl"
className={className}
{...props} {...props}
> >
{showImage ? ( {displayInitials}
<img </MantineAvatar>
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>
) )
} }
) )
Avatar.displayName = "Avatar" Avatar.displayName = 'Avatar'
export { Avatar, avatarVariants } export { Avatar, avatarVariants }
export type { AvatarProps } export type { AvatarProps, AvatarSize }
+5 -5
View File
@@ -6,14 +6,14 @@ domain: ui
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "Badge(props: BadgeProps & VariantProps<typeof badgeVariants>): JSX.Element" 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." 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] tags: [badge, status, component, ui, indicator, mantine]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" error_type: ""
imports: ["class-variance-authority"] imports: ["@mantine/core"]
output: "Componente Badge que renderiza un indicador visual con 10 variantes semánticas de estado" output: "Componente Badge que renderiza un indicador visual con 10 variantes semánticas de estado"
tested: false tested: false
tests: [] tests: []
@@ -46,4 +46,4 @@ source_file: "frontend/src/components/ui/badge.tsx"
## Notas ## 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 * as React from 'react'
import { cva, type VariantProps } from "class-variance-authority" import { Badge as MantineBadge } from '@mantine/core'
import { cn } from "../core/cn"
const badgeVariants = cva( type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link' | 'success' | 'warning' | 'error' | 'info'
"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!", type BadgeSize = 'default' | 'sm'
{
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",
},
}
)
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 ( return (
<span <MantineBadge
data-slot="badge" 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} {...props}
/> >
{children}
</MantineBadge>
) )
} }
export { Badge, badgeVariants } export { Badge, badgeVariants }
export type { BadgeProps, BadgeVariant, BadgeSize }
+6 -6
View File
@@ -6,15 +6,15 @@ domain: ui
version: "1.1.0" version: "1.1.0"
purity: impure purity: impure
signature: "BarChart(props: BarChartProps): JSX.Element" signature: "BarChart(props: BarChartProps): JSX.Element"
description: "Gráfico de barras Recharts con multi-series, orientación horizontal/vertical, tooltips temáticos y bordes redondeados." description: "Gráfico de barras @mantine/charts con multi-series, orientación horizontal/vertical y tooltips."
tags: [chart, bar, visualization, recharts, component, ui] tags: [chart, bar, visualization, mantine, component, ui]
uses_functions: [cn_ts_core, chart_container_ts_ui, get_series_color_ts_core] uses_functions: [chart_container_ts_ui]
uses_types: [ChartSeries_ts_ui] uses_types: [ChartSeries_ts_ui]
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" error_type: ""
imports: [recharts] imports: ["@mantine/charts", "@mantine/core"]
output: "Componente JSX que renderiza un gráfico de barras vertical u horizontal con multi-series y tooltips temáticos" output: "Componente JSX que renderiza un gráfico de barras vertical u horizontal con multi-series y tooltips"
tested: false tested: false
tests: [] tests: []
test_file_path: "" test_file_path: ""
@@ -54,4 +54,4 @@ source_file: "frontend/src/components/ui/charts/bar-chart.tsx"
## Notas ## 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 { import { BarChart as MantineBarChart } from '@mantine/charts'
BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, import { Paper } from '@mantine/core'
} from 'recharts' import { type Series, getSeriesColor } from './chart_container'
import { ChartContainer, ChartTooltipContent, type Series, getSeriesColor } from './chart_container'
interface BarChartProps { interface BarChartProps {
data: Record<string, unknown>[] data: Record<string, unknown>[]
@@ -11,8 +10,7 @@ interface BarChartProps {
horizontal?: boolean horizontal?: boolean
showGrid?: boolean showGrid?: boolean
showLegend?: boolean showLegend?: boolean
height?: number | string height?: number
className?: string
xAxisFormatter?: (value: unknown) => string xAxisFormatter?: (value: unknown) => string
yAxisFormatter?: (value: unknown) => string yAxisFormatter?: (value: unknown) => string
valueFormatter?: (value: number) => string valueFormatter?: (value: number) => string
@@ -20,32 +18,28 @@ interface BarChartProps {
function BarChartComponent({ function BarChartComponent({
data, xKey, yKey, series, horizontal = false, showGrid = true, showLegend = false, 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) { }: BarChartProps) {
const bars = series const chartSeries = series
? series.map((s, i) => ({ dataKey: s.key, name: s.name, fill: getSeriesColor(i, s.color) })) ? series.map((s, i) => ({ name: s.key, label: s.name, color: getSeriesColor(i, s.color) }))
: yKey ? [{ dataKey: yKey, name: yKey, fill: getSeriesColor(0) }] : [] : yKey ? [{ name: yKey, label: yKey, color: getSeriesColor(0) }] : []
return ( return (
<ChartContainer className={className} height={height}> <Paper p="md">
<RechartsBarChart data={data} layout={horizontal ? 'vertical' : 'horizontal'} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}> <MantineBarChart
{showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />} h={height}
{horizontal ? ( data={data}
<> dataKey={xKey}
<XAxis type="number" tickFormatter={yAxisFormatter} className="text-xs fill-muted-foreground" /> series={chartSeries}
<YAxis dataKey={xKey} type="category" tickFormatter={xAxisFormatter} width={80} className="text-xs fill-muted-foreground" /> orientation={horizontal ? 'vertical' : 'horizontal'}
</> gridAxis={showGrid ? 'xy' : 'none'}
) : ( withLegend={showLegend}
<> withTooltip
<XAxis dataKey={xKey} tickFormatter={xAxisFormatter} className="text-xs fill-muted-foreground" /> valueFormatter={valueFormatter}
<YAxis tickFormatter={yAxisFormatter} className="text-xs fill-muted-foreground" /> xAxisProps={xAxisFormatter ? { tickFormatter: xAxisFormatter } : undefined}
</> yAxisProps={yAxisFormatter ? { tickFormatter: yAxisFormatter } : undefined}
)} />
<Tooltip content={<ChartTooltipContent valueFormatter={valueFormatter} />} cursor={{ fill: 'hsl(var(--muted) / 0.3)' }} /> </Paper>
{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>
) )
} }
+5 -5
View File
@@ -6,14 +6,14 @@ domain: ui
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "Breadcrumb(props: BreadcrumbProps): JSX.Element" signature: "Breadcrumb(props: BreadcrumbProps): JSX.Element"
description: "Navegacion jerarquica con separadores, elipsis para paths largos y soporte para router links via asChild." description: "Navegacion jerarquica con separadores, elipsis para paths largos y soporte para router links via asChild. Mantine Anchor/Text."
tags: [breadcrumb, navigation, component, ui] tags: [breadcrumb, navigation, component, ui, mantine]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" 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" output: "Componente Breadcrumb que renderiza navegación jerárquica con separadores, elipsis y soporte para router links"
tested: false tested: false
tests: [] tests: []
@@ -69,4 +69,4 @@ variant: []
## Notas ## 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 * as React from "react"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react" import { Anchor, Text, Box } from "@mantine/core"
import { cn } from "../core/cn" import { IconChevronRight, IconDots } from "@tabler/icons-react"
function Breadcrumb({ ...props }: React.ComponentPropsWithoutRef<"nav">) { function Breadcrumb({ children, ...props }: React.ComponentPropsWithoutRef<"nav">) {
return <nav data-slot="breadcrumb" aria-label="breadcrumb" {...props} /> 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 ( return (
<ol <ol data-slot="breadcrumb-list" style={{ listStyle: "none", display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8, padding: 0, margin: 0 }} className={className} {...props}>
data-slot="breadcrumb-list" {children}
className={cn("flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5", className)} </ol>
{...props}
/>
) )
} }
function BreadcrumbItem({ className, ...props }: React.ComponentPropsWithoutRef<"li">) { function BreadcrumbItem({ className, children, ...props }: React.ComponentPropsWithoutRef<"li">) {
return ( return (
<li <li data-slot="breadcrumb-item" style={{ display: "flex", alignItems: "center", gap: 8 }} className={className} {...props}>
data-slot="breadcrumb-item" {children}
className={cn("inline-flex items-center gap-1.5", className)} </li>
{...props}
/>
) )
} }
@@ -35,31 +31,29 @@ function BreadcrumbLink({
}: React.ComponentPropsWithoutRef<"a"> & { asChild?: boolean }) { }: React.ComponentPropsWithoutRef<"a"> & { asChild?: boolean }) {
if (asChild) { if (asChild) {
return ( 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} {children}
</span> </Text>
) )
} }
return ( return (
<a <Anchor data-slot="breadcrumb-link" href={href} size="sm" className={className} {...props}>
data-slot="breadcrumb-link"
href={href}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
>
{children} {children}
</a> </Anchor>
) )
} }
function BreadcrumbPage({ className, ...props }: React.ComponentPropsWithoutRef<"span">) { function BreadcrumbPage({ className, ...props }: React.ComponentPropsWithoutRef<"span">) {
return ( return (
<span <Text
data-slot="breadcrumb-page" data-slot="breadcrumb-page"
component="span"
size="sm"
fw={500}
role="link" role="link"
aria-current="page" aria-current="page"
aria-disabled="true" aria-disabled="true"
className={cn("font-medium text-foreground", className)} className={className}
{...props} {...props}
/> />
) )
@@ -67,30 +61,34 @@ function BreadcrumbPage({ className, ...props }: React.ComponentPropsWithoutRef<
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) { function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
return ( return (
<li <Box
data-slot="breadcrumb-separator" data-slot="breadcrumb-separator"
component="li"
role="presentation" role="presentation"
aria-hidden="true" aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)} className={className}
style={{ display: "flex", alignItems: "center" }}
{...props} {...props}
> >
{children ?? <ChevronRightIcon />} {children ?? <IconChevronRight size={14} />}
</li> </Box>
) )
} }
function BreadcrumbEllipsis({ className, ...props }: React.ComponentPropsWithoutRef<"span">) { function BreadcrumbEllipsis({ className, ...props }: React.ComponentPropsWithoutRef<"span">) {
return ( return (
<span <Box
data-slot="breadcrumb-ellipsis" data-slot="breadcrumb-ellipsis"
component="span"
role="presentation" role="presentation"
aria-hidden="true" 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} {...props}
> >
<MoreHorizontalIcon className="size-4" /> <IconDots size={16} />
<span className="sr-only">More</span> <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>
</span> </Box>
) )
} }
+5 -5
View File
@@ -6,14 +6,14 @@ domain: ui
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "Button(props: ButtonProps & VariantProps<typeof buttonVariants>): JSX.Element" 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." description: "Botón accesible con 6 variantes (default, outline, secondary, ghost, destructive, link) y 8 tamaños. Mantine Button."
tags: [button, component, ui, interactive, cva] tags: [button, component, ui, interactive, mantine]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" error_type: ""
imports: ["@base-ui/react", "class-variance-authority"] imports: ["@mantine/core"]
output: "JSX.Element: botón renderizado con los estilos y comportamientos configurados" output: "JSX.Element: botón renderizado con los estilos y comportamientos configurados"
tested: false tested: false
tests: [] tests: []
@@ -51,4 +51,4 @@ source_file: "frontend/src/components/ui/button.tsx"
## Notas ## 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" type ButtonVariant = 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
import { cva, type VariantProps } from "class-variance-authority" type ButtonSize = 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
import { cn } from "../core/cn"
const buttonVariants = cva( const variantMap: Record<ButtonVariant, { variant: string; color?: string }> = {
"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", default: { variant: 'filled' },
{ outline: { variant: 'outline' },
variants: { secondary: { variant: 'light' },
variant: { ghost: { variant: 'subtle' },
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", destructive: { variant: 'filled', color: 'red' },
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", link: { variant: 'transparent' },
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", const sizeMap: Record<ButtonSize, { size: string; style?: React.CSSProperties }> = {
link: "text-primary underline-offset-4 hover:underline", default: { size: 'sm' },
}, xs: { size: 'xs' },
size: { sm: { size: 'xs' },
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", lg: { size: 'md' },
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs", icon: { size: 'sm', style: { width: 32, height: 32, padding: 0 } },
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem]", 'icon-xs': { size: 'xs', style: { width: 24, height: 24, padding: 0 } },
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", 'icon-sm': { size: 'xs', style: { width: 28, height: 28, padding: 0 } },
icon: "size-8", 'icon-lg': { size: 'md', style: { width: 36, height: 36, padding: 0 } },
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)]", }
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)]",
"icon-lg": "size-9", /** Kept for backwards compatibility — maps variant names to Mantine equivalents */
}, const buttonVariants = variantMap
},
defaultVariants: { interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {
variant: "default", variant?: ButtonVariant
size: "default", size?: ButtonSize
}, children?: React.ReactNode
} }
)
function Button({ function Button({
className, className,
variant = "default", variant = 'default',
size = "default", size = 'default',
style,
children,
...props ...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) { }: ButtonProps) {
const mv = variantMap[variant]
const ms = sizeMap[size]
return ( return (
<ButtonPrimitive <MantineButton
data-slot="button" 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} {...props}
/> >
{children}
</MantineButton>
) )
} }
export { Button, buttonVariants } 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" 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." 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] tags: [card, container, layout, component, ui, dashboard, dark]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
+46 -36
View File
@@ -1,89 +1,99 @@
import * as React from "react" import * as React from 'react'
import { cn } from "../core/cn" import { Paper, Box, Text } from '@mantine/core'
type CardVariant = "default" | "borderless" | "ghost" type CardVariant = 'default' | 'borderless' | 'ghost'
function Card({ function Card({
className, className,
size = "default", size = 'default',
variant = "default", variant = 'default',
children,
...props ...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm"; variant?: CardVariant }) { }: React.ComponentProps<'div'> & { size?: 'default' | 'sm'; variant?: CardVariant }) {
return ( return (
<div <Paper
data-slot="card" data-slot="card"
data-size={size} data-size={size}
data-variant={variant} data-variant={variant}
className={cn( withBorder={variant === 'default'}
"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", shadow={variant === 'default' ? 'xs' : undefined}
variant === "default" && "ring-1 ring-foreground/10", radius="md"
variant === "borderless" && "ring-0 shadow-none", p={size === 'sm' ? 'sm' : 'md'}
variant === "ghost" && "ring-0 shadow-none bg-transparent", bg={variant === 'ghost' ? 'transparent' : undefined}
className className={className}
)}
{...props} {...props}
/> >
{children}
</Paper>
) )
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <Box
data-slot="card-header" data-slot="card-header"
className={cn( pb="xs"
"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={className}
className
)}
{...props} {...props}
/> />
) )
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <Text
component="div"
data-slot="card-title" 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} {...props}
/> />
) )
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <Text
component="div"
data-slot="card-description" data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)} size="sm"
c="dimmed"
className={className}
{...props} {...props}
/> />
) )
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, style, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <Box
data-slot="card-action" 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} {...props}
/> />
) )
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <Box
data-slot="card-content" data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)} className={className}
{...props} {...props}
/> />
) )
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <Box
data-slot="card-footer" 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} {...props}
/> />
) )
+9 -7
View File
@@ -6,15 +6,15 @@ domain: ui
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "ChartContainer(props: { children: ReactNode; height?: number | string }): JSX.Element" 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." description: "Thin wrapper Paper y utilidades de colores/series para los charts @mantine/charts."
tags: [chart, container, recharts, base, visualization, component, ui] tags: [chart, container, mantine, base, visualization, component, ui]
uses_functions: [cn_ts_core, get_series_color_ts_core] uses_functions: []
uses_types: [ChartSeries_ts_ui] uses_types: [ChartSeries_ts_ui]
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" error_type: ""
imports: [recharts, react] imports: ["@mantine/core"]
output: "Componente ChartContainer que renderiza base responsive para gráficos Recharts con tooltip y legend temáticos" output: "Componente ChartContainer Paper wrapper y utilidades getSeriesColor/Series para charts Mantine"
tested: false tested: false
tests: [] tests: []
test_file_path: "" test_file_path: ""
@@ -40,11 +40,13 @@ source_file: "frontend/src/components/ui/charts/chart-base.tsx"
## Ejemplo ## Ejemplo
```tsx ```tsx
import { ChartContainer, getSeriesColor, type Series } from './chart_container'
<ChartContainer height={400}> <ChartContainer height={400}>
<RechartsLineChart data={data}>...</RechartsLineChart> <MantineLineChart ... />
</ChartContainer> </ChartContainer>
``` ```
## Notas ## 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 { Paper } from '@mantine/core'
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%))',
]
export const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899'] export const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899']
@@ -19,62 +9,25 @@ export interface Series {
} }
export function getSeriesColor(index: number, color?: string): string { export function getSeriesColor(index: number, color?: string): string {
return color || defaultColors[index % defaultColors.length] return color || defaultColors[index % defaultColors.length]!
} }
interface ChartContainerProps { interface ChartContainerProps {
children: React.ReactNode children: React.ReactNode
className?: string
height?: number | string height?: number | string
} }
export function ChartContainer({ children, className, height = 300 }: ChartContainerProps) { export function ChartContainer({ children, height = 300 }: ChartContainerProps) {
return ( return (
<div <Paper p="md" style={{ height, minWidth: 100, minHeight: typeof height === 'number' ? Math.min(height, 100) : 100 }}>
className={cn('w-full', className)} {children}
style={{ height, minWidth: 100, minHeight: typeof height === 'number' ? Math.min(height, 100) : 100 }} </Paper>
>
<ResponsiveContainer width="100%" height="100%">
{children as React.ReactElement}
</ResponsiveContainer>
</div>
) )
} }
interface ChartTooltipContentProps { /** @deprecated Mantine charts handle tooltips internally. Kept for index.ts compat. */
active?: boolean export function ChartTooltipContent() { return null }
payload?: Array<{ name: string; value: number; color: string; dataKey: string }> /** @deprecated Mantine charts handle tooltips internally. Kept for index.ts compat. */
label?: string export function ChartTooltip() { return null }
labelFormatter?: (label: string) => string /** @deprecated Mantine charts handle legends internally. Kept for index.ts compat. */
valueFormatter?: (value: number) => string export function ChartLegend() { return null }
}
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} />
}
+5 -5
View File
@@ -6,14 +6,14 @@ domain: ui
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "Checkbox(props: CheckboxProps): JSX.Element" signature: "Checkbox(props: CheckboxProps): JSX.Element"
description: "Input booleano accesible con label opcional y variante indeterminate. Base-UI Checkbox primitive." description: "Input booleano accesible con label opcional y variante indeterminate. Mantine Checkbox."
tags: [checkbox, component, ui, interactive, form, base-ui] tags: [checkbox, component, ui, interactive, form, mantine]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" 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" output: "Componente Checkbox que renderiza input booleano accesible con label opcional y estado indeterminate"
tested: false tested: false
tests: [] tests: []
@@ -70,4 +70,4 @@ variant: []
## Notas ## 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 MantineCheckbox } from "@mantine/core"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "../core/cn"
interface CheckboxProps extends CheckboxPrimitive.Root.Props { interface CheckboxProps {
label?: string label?: string
indeterminate?: boolean indeterminate?: boolean
className?: string className?: string
labelClassName?: string labelClassName?: string
checked?: boolean
defaultChecked?: boolean
disabled?: boolean
onCheckedChange?: (checked: boolean) => void
id?: string
} }
function Checkbox({ className, label, id, indeterminate, ...props }: CheckboxProps) { function Checkbox({ className, label, id, indeterminate, checked, defaultChecked, disabled, onCheckedChange, ...props }: CheckboxProps) {
const internalId = React.useId()
const checkboxId = id ?? internalId
return ( return (
<div className="flex items-center gap-2"> <MantineCheckbox
<CheckboxPrimitive.Root id={id}
id={checkboxId} data-slot="checkbox"
data-slot="checkbox" label={label}
className={cn( indeterminate={indeterminate}
"peer size-4 shrink-0 rounded border border-input bg-transparent transition-colors outline-none", checked={checked}
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50", defaultChecked={defaultChecked}
"data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground", disabled={disabled}
"data-indeterminate:border-primary data-indeterminate:bg-primary data-indeterminate:text-primary-foreground", onChange={(event) => onCheckedChange?.(event.currentTarget.checked)}
"disabled:pointer-events-none disabled:opacity-50", className={className}
className size="sm"
)} {...props}
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>
) )
} }
+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" 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." 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] tags: [command, search, combobox, component, ui, interactive]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" 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" output: "Componente Command que renderiza combobox de búsqueda y selección con filtrado reactivo, grupos e iconos"
tested: false tested: false
tests: [] tests: []
+70 -85
View File
@@ -1,8 +1,8 @@
import * as React from "react" import * as React from 'react'
import { SearchIcon, XIcon } from "lucide-react" import { TextInput, Text, Box, ScrollArea } from '@mantine/core'
import { cn } from "../core/cn" import { IconSearch } from '@tabler/icons-react'
interface CommandItem { interface CommandItemData {
value: string value: string
label: string label: string
description?: string description?: string
@@ -12,7 +12,7 @@ interface CommandItem {
} }
interface CommandProps { interface CommandProps {
items: CommandItem[] items: CommandItemData[]
value?: string value?: string
onValueChange?: (value: string) => void onValueChange?: (value: string) => void
placeholder?: string placeholder?: string
@@ -22,122 +22,107 @@ interface CommandProps {
listClassName?: string 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 ( return (
<div <TextInput
data-slot="command" data-slot="command-input"
className={cn("flex h-full w-full flex-col overflow-hidden rounded-xl bg-popover text-popover-foreground", className)} 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} {...props}
/> />
) )
} }
function CommandInput({ className, ...props }: React.ComponentPropsWithoutRef<"input">) { function CommandList({ className, children }: { className?: string; children?: React.ReactNode }) {
return ( return (
<div data-slot="command-input-wrapper" className="flex items-center border-b px-3"> <ScrollArea.Autosize mah={300} data-slot="command-list" className={className}>
<SearchIcon className="mr-2 size-4 shrink-0 text-muted-foreground" /> {children}
<input </ScrollArea.Autosize>
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>
) )
} }
function CommandList({ className, ...props }: React.ComponentPropsWithoutRef<"div">) { function CommandEmpty({ className, children }: { className?: string; children?: React.ReactNode }) {
return ( return (
<div <Text ta="center" c="dimmed" size="sm" py="xl" data-slot="command-empty" className={className}>
data-slot="command-list" {children}
className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className)} </Text>
{...props}
/>
) )
} }
function CommandEmpty({ className, ...props }: React.ComponentPropsWithoutRef<"div">) { function CommandGroup({ className, heading, children }: { className?: string; heading?: string; children?: React.ReactNode }) {
return ( return (
<div <Box data-slot="command-group" p={4} className={className}>
data-slot="command-empty" {heading && <Text size="xs" fw={500} c="dimmed" px="sm" py={6}>{heading}</Text>}
className={cn("py-6 text-center text-sm text-muted-foreground", className)} <div>{children}</div>
{...props} </Box>
/>
) )
} }
function CommandGroup({ className, heading, ...props }: React.ComponentPropsWithoutRef<"div"> & { heading?: string }) { function CommandSeparator({ className }: { className?: string }) {
return ( return <Box data-slot="command-separator" h={1} bg="var(--mantine-color-default-border)" mx={-4} className={className} />
<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, ...props }: React.ComponentPropsWithoutRef<"div">) { function CommandItem({ className, selected, disabled, onSelect, children }: {
return ( className?: string
<div
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
interface CommandItemProps extends React.ComponentPropsWithoutRef<"div"> {
selected?: boolean selected?: boolean
disabled?: boolean disabled?: boolean
onSelect?: () => void onSelect?: () => void
} children?: React.ReactNode
}) {
function CommandItem({ className, selected, disabled, onSelect, ...props }: CommandItemProps) {
return ( return (
<div <Box
data-slot="command-item" data-slot="command-item"
data-selected={selected} data-selected={selected}
aria-disabled={disabled} aria-disabled={disabled}
role="option" role="option"
aria-selected={selected} aria-selected={selected}
onClick={!disabled ? onSelect : undefined} onClick={!disabled ? onSelect : undefined}
className={cn( px="sm"
"relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none", py={6}
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground", style={{
"hover:bg-accent hover:text-accent-foreground", display: 'flex',
disabled && "pointer-events-none opacity-50", alignItems: 'center',
className gap: 8,
)} borderRadius: 'var(--mantine-radius-sm)',
{...props} 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">) { function CommandShortcut({ className, children }: { className?: string; children?: React.ReactNode }) {
return ( return <Text span size="xs" c="dimmed" ml="auto" className={className}>{children}</Text>
<span
data-slot="command-shortcut"
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
)
} }
function CommandSearch({ function CommandSearch({
items, items,
value, value,
onValueChange, onValueChange,
placeholder = "Search...", placeholder = 'Search...',
emptyMessage = "No results found.", emptyMessage = 'No results found.',
className, className,
}: CommandProps) { }: CommandProps) {
const [query, setQuery] = React.useState("") const [query, setQuery] = React.useState('')
const [selectedValue, setSelectedValue] = React.useState(value ?? "") const [selectedValue, setSelectedValue] = React.useState(value ?? '')
const filtered = React.useMemo(() => { const filtered = React.useMemo(() => {
if (!query) return items if (!query) return items
@@ -151,9 +136,9 @@ function CommandSearch({
}, [items, query]) }, [items, query])
const groups = React.useMemo(() => { const groups = React.useMemo(() => {
const map = new Map<string, CommandItem[]>() const map = new Map<string, CommandItemData[]>()
for (const item of filtered) { for (const item of filtered) {
const key = item.group ?? "" const key = item.group ?? ''
if (!map.has(key)) map.set(key, []) if (!map.has(key)) map.set(key, [])
map.get(key)!.push(item) map.get(key)!.push(item)
} }
@@ -185,10 +170,10 @@ function CommandSearch({
disabled={item.disabled} disabled={item.disabled}
onSelect={() => handleSelect(item.value)} onSelect={() => handleSelect(item.value)}
> >
{item.icon && <span className="shrink-0">{item.icon}</span>} {item.icon && <span>{item.icon}</span>}
<span>{item.label}</span> <span>{item.label}</span>
{item.description && ( {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> </CommandItem>
))} ))}
@@ -201,4 +186,4 @@ function CommandSearch({
} }
export { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSearch, CommandSeparator, CommandShortcut } 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" 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." 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] tags: [crud, page, table, form, factory, composition, ui]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" error_type: ""
imports: [react] imports: [react, "@mantine/core", "@tabler/icons-react"]
params: params:
- name: props - name: props
desc: "Configuración CRUD: título, datos, columnas de tabla, campos de formulario y callbacks para add/edit/delete" 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 * 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 { interface CrudField {
key: string key: string
@@ -37,83 +38,83 @@ export function crudPage<T extends Record<string, unknown>>({
onEdit, onEdit,
onDelete, onDelete,
actions, actions,
className,
}: CrudPageProps<T>): React.ReactElement { }: CrudPageProps<T>): React.ReactElement {
return ( return (
<div className={cn('space-y-6', className)}> <Stack gap="lg">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b pb-4"> <Group justify="space-between" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
<div className="space-y-1"> <Stack gap={4}>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1> <Title order={2}>{title}</Title>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>} {subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
</div> </Stack>
<div className="flex items-center gap-2"> <Group gap="xs">
{actions} {actions}
{onAdd && ( {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"> <Button size="xs" leftSection={<IconPlus size={16} />}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14M5 12h14"/></svg>
Add {title.replace(/s$/, '')} Add {title.replace(/s$/, '')}
</button> </Button>
)} )}
</div> </Group>
</div> </Group>
{/* Table */} {/* Table */}
<div className="rounded-lg border"> <Paper withBorder radius="md">
<table className="w-full caption-bottom text-sm"> <Table highlightOnHover>
<thead className="border-b bg-muted/50"> <Table.Thead>
<tr> <Table.Tr>
{columns.map((col) => ( {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} {col.label}
</th> </Table.Th>
))} ))}
{(onEdit || onDelete) && ( {(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> </Table.Tr>
</thead> </Table.Thead>
<tbody className="divide-y"> <Table.Tbody>
{data.length === 0 ? ( {data.length === 0 ? (
<tr> <Table.Tr>
<td colSpan={columns.length + (onEdit || onDelete ? 1 : 0)} className="h-24 text-center text-muted-foreground"> <Table.Td colSpan={columns.length + (onEdit || onDelete ? 1 : 0)}>
No items yet. <Center h={96}>
</td> <Text c="dimmed">No items yet.</Text>
</tr> </Center>
</Table.Td>
</Table.Tr>
) : ( ) : (
data.map((row, i) => ( data.map((row, i) => (
<tr key={i} className="hover:bg-muted/50"> <Table.Tr key={i}>
{columns.map((col) => ( {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] ?? '')} {col.render ? col.render(row[col.key], row) : String(row[col.key] ?? '')}
</td> </Table.Td>
))} ))}
{(onEdit || onDelete) && ( {(onEdit || onDelete) && (
<td className="px-4 py-3 text-right"> <Table.Td px="md" py="sm" ta="right">
<div className="flex justify-end gap-1"> <Group gap={4} justify="flex-end">
{onEdit && ( {onEdit && (
<button onClick={() => onEdit(row)} className="inline-flex size-7 items-center justify-center rounded-md hover:bg-muted"> <ActionIcon variant="subtle" size="sm" onClick={() => onEdit(row)}>
<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> <IconPencil size={14} />
</button> </ActionIcon>
)} )}
{onDelete && ( {onDelete && (
<button onClick={() => onDelete(row)} className="inline-flex size-7 items-center justify-center rounded-md text-destructive hover:bg-destructive/10"> <ActionIcon variant="subtle" color="red" size="sm" onClick={() => onDelete(row)}>
<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> <IconTrash size={14} />
</button> </ActionIcon>
)} )}
</div> </Group>
</td> </Table.Td>
)} )}
</tr> </Table.Tr>
)) ))
)} )}
</tbody> </Table.Tbody>
</table> </Table>
</div> </Paper>
{/* Form fields definition (for agent use — renders a form preview) */} {/* Form fields definition (for agent use) */}
<div className="hidden" data-slot="crud-form-schema" data-fields={JSON.stringify(fields)} /> <div style={{ display: 'none' }} data-slot="crud-form-schema" data-fields={JSON.stringify(fields)} />
</div> </Stack>
) )
} }
+2 -2
View File
@@ -8,12 +8,12 @@ purity: pure
signature: "dashboardLayout(props: DashboardLayoutProps): ReactElement" 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." 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] tags: [dashboard, layout, grid, factory, composition, ui]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" error_type: ""
imports: [react] imports: [react, "@mantine/core"]
params: params:
- name: props - name: props
desc: "Configuración de layout: número de columnas y array de widgets con id, título, contenido y span" 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 * as React from 'react'
import { cn } from '../core/cn' import { SimpleGrid, Paper, Text } from '@mantine/core'
interface DashboardWidget { interface DashboardWidget {
id: string id: string
@@ -16,51 +16,37 @@ interface DashboardLayoutProps {
className?: string className?: string
} }
const gapClasses = { sm: 'gap-2', md: 'gap-4', lg: 'gap-6' } const gapMap = { sm: 'xs', md: 'md', lg: 'lg' } as const
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',
}
export function dashboardLayout({ export function dashboardLayout({
widgets, widgets,
columns = 4, columns = 4,
gap = 'md', gap = 'md',
className,
}: DashboardLayoutProps): React.ReactElement { }: 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 ( 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) => ( {widgets.map((widget) => (
<div <Paper
key={widget.id} key={widget.id}
className={cn( p="md"
'rounded-xl border bg-card p-4 text-card-foreground shadow-sm', withBorder
spanClasses[widget.span || 1], shadow="xs"
rowSpanClasses[widget.rowSpan || 1] radius="md"
)} style={{
gridColumn: widget.span && widget.span > 1 ? `span ${widget.span}` : undefined,
gridRow: widget.rowSpan === 2 ? 'span 2' : undefined,
}}
> >
{widget.title && ( {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} {widget.content}
</div> </Paper>
))} ))}
</div> </SimpleGrid>
) )
} }
+2 -6
View File
@@ -8,12 +8,12 @@ purity: impure
signature: "DataTable(props: DataTableProps): JSX.Element" 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." 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] tags: [table, data, heatmap, dashboard, component, ui, format, visualization]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" 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" output: "Componente DataTable que renderiza tabla con sticky header, heatmap condicional y formato automático de datos"
tested: false tested: false
tests: [] tests: []
@@ -44,10 +44,6 @@ props:
type: "Error | null" type: "Error | null"
required: false required: false
description: "Error a mostrar si la carga falló." description: "Error a mostrar si la carga falló."
- name: className
type: "string"
required: false
description: "Clases CSS adicionales."
emits: [] emits: []
has_state: false has_state: false
framework: react framework: react
+39 -40
View File
@@ -1,5 +1,5 @@
import * as React from 'react' import * as React from 'react'
import { cn } from '../core/cn' import { Table, Text, Center, Loader } from '@mantine/core'
interface ColumnDef { interface ColumnDef {
key: string key: string
@@ -16,7 +16,6 @@ interface DataTableProps {
/** Column keys that should be colored by value intensity (heatmap). */ /** Column keys that should be colored by value intensity (heatmap). */
heatmapColumns?: string[] heatmapColumns?: string[]
maxHeight?: number | string maxHeight?: number | string
className?: string
loading?: boolean loading?: boolean
error?: Error | null error?: Error | null
} }
@@ -33,7 +32,7 @@ function formatCell(value: unknown, format?: string): string {
if (!isNaN(num)) { if (!isNaN(num)) {
if (format.includes('f')) { if (format.includes('f')) {
const match = format.match(/\.(\d+)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) let str = num.toFixed(d)
if (format.includes(',')) { if (format.includes(',')) {
str = Number(str).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d }) str = Number(str).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d })
@@ -51,7 +50,6 @@ function DataTableComponent({
columns, columns,
heatmapColumns = [], heatmapColumns = [],
maxHeight = 500, maxHeight = 500,
className,
loading = false, loading = false,
error = null, error = null,
}: DataTableProps) { }: DataTableProps) {
@@ -59,7 +57,7 @@ function DataTableComponent({
const effectiveColumns: ColumnDef[] = (columns && columns.length > 0) const effectiveColumns: ColumnDef[] = (columns && columns.length > 0)
? columns ? columns
: (data && data.length > 0) : (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 // Compute heatmap ranges per column
@@ -82,73 +80,74 @@ function DataTableComponent({
const num = Number(value) const num = Number(value)
if (isNaN(num)) return undefined if (isNaN(num)) return undefined
const t = (num - range.min) / (range.max - range.min) const t = (num - range.min) / (range.max - range.min)
// Dark blue (low) → bright blue (high)
const alpha = 0.1 + t * 0.55 const alpha = 0.1 + t * 0.55
return { backgroundColor: `rgba(59, 130, 246, ${alpha})` } return { backgroundColor: `rgba(59, 130, 246, ${alpha})` }
} }
const maxHeightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
if (loading && (!data || data.length === 0)) { if (loading && (!data || data.length === 0)) {
return ( return (
<div className={cn('flex items-center justify-center text-muted-foreground text-sm', className)} <Center h={200}>
style={{ height: 200 }}> <Loader size="sm" />
Loading... </Center>
</div>
) )
} }
if (error) { if (error) {
return ( return (
<div className={cn('flex items-center justify-center text-destructive text-sm', className)} <Center h={200}>
style={{ height: 200 }}> <Text size="sm" c="red">{error.message}</Text>
{error.message} </Center>
</div>
) )
} }
return ( return (
<div className={cn('overflow-auto', className)} style={{ maxHeight: maxHeightStyle }}> <Table.ScrollContainer minWidth={0} mah={maxHeight} type="scrollarea">
<table className="w-full text-sm"> <Table striped={false} highlightOnHover withTableBorder={false} withColumnBorders={false}>
<thead className="sticky top-0 bg-card z-10"> <Table.Thead style={{ position: 'sticky', top: 0, zIndex: 10, backgroundColor: 'var(--mantine-color-body)' }}>
<tr className="border-b border-border"> <Table.Tr>
{effectiveColumns.map(col => ( {effectiveColumns.map(col => (
<th <Table.Th
key={col.key} 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} {col.label}
</th> </Table.Th>
))} ))}
</tr> </Table.Tr>
</thead> </Table.Thead>
<tbody> <Table.Tbody>
{(data ?? []).map((row, i) => ( {(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 => { {effectiveColumns.map(col => {
const align = col.align ?? (typeof row[col.key] === 'number' ? 'right' : 'left') const align = col.align ?? (typeof row[col.key] === 'number' ? 'right' : 'left')
return ( return (
<td <Table.Td
key={col.key} key={col.key}
className={cn( style={{ textAlign: align, fontFamily: 'var(--mantine-font-family-monospace)', ...heatmapStyle(col.key, row[col.key]) }}
'py-1.5 px-3 font-mono text-xs', fz="xs"
align === 'right' && 'text-right', py={6}
align === 'center' && 'text-center', px="sm"
)}
style={heatmapStyle(col.key, row[col.key])}
> >
{formatCell(row[col.key], col.format)} {formatCell(row[col.key], col.format)}
</td> </Table.Td>
) )
})} })}
</tr> </Table.Tr>
))} ))}
</tbody> </Table.Tbody>
</table> </Table>
{(!data || data.length === 0) && ( {(!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" 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." 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] tags: [detail, page, entity, timeline, factory, composition, ui]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" error_type: ""
imports: [react] imports: [react, "@mantine/core", "@tabler/icons-react"]
params: params:
- name: props - name: props
desc: "Configuración de página de detalle: título, avatar, badge, tabs, timeline y campos de metadata" 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 * 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 { interface DetailField {
label: string label: string
@@ -38,96 +39,98 @@ interface DetailPageProps {
className?: string className?: string
} }
const variantDotColors = { const variantColors: Record<string, string> = {
default: 'bg-primary', default: 'blue',
success: 'bg-green-500', success: 'green',
warning: 'bg-amber-500', warning: 'yellow',
error: 'bg-red-500', error: 'red',
} }
export function detailPage({ export function detailPage({
title, subtitle, badge, avatar, actions, onBack, title, subtitle, badge, avatar, actions, onBack,
fields, tabs, activeTab, onTabChange, timeline, className, fields, tabs, activeTab, onTabChange, timeline,
}: DetailPageProps): React.ReactElement { }: DetailPageProps): React.ReactElement {
return ( return (
<div className={cn('space-y-6', className)}> <Stack gap="lg">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between border-b pb-4"> <Group justify="space-between" align="flex-start" pb="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
<div className="flex items-start gap-4"> <Group align="flex-start" gap="md">
{onBack && ( {onBack && (
<button onClick={onBack} className="mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-md hover:bg-muted"> <ActionIcon variant="subtle" size="sm" onClick={onBack} mt={4}>
<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> <IconChevronLeft size={16} />
</button> </ActionIcon>
)} )}
{avatar && <div className="size-12 shrink-0 overflow-hidden rounded-full bg-muted">{avatar}</div>} {avatar && (
<div className="space-y-1"> <Box
<div className="flex items-center gap-3"> w={48}
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1> 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} {badge}
</div> </Group>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>} {subtitle && <Text size="sm" c="dimmed">{subtitle}</Text>}
</div> </Stack>
</div> </Group>
{actions && <div className="flex items-center gap-2">{actions}</div>} {actions && <Group gap="xs">{actions}</Group>}
</div> </Group>
{/* Fields grid */} {/* 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) => ( {fields.map((field, i) => (
<div key={i} className={cn('space-y-1', field.span === 2 && 'md:col-span-2')}> <Box key={i} style={field.span === 2 ? { gridColumn: 'span 2' } : undefined}>
<p className="text-sm text-muted-foreground">{field.label}</p> <Stack gap={4}>
<div className="text-sm font-medium">{field.value}</div> <Text size="sm" c="dimmed">{field.label}</Text>
</div> <Text size="sm" fw={500}>{field.value}</Text>
</Stack>
</Box>
))} ))}
</div> </SimpleGrid>
{/* Tabs */} {/* Tabs */}
{tabs && tabs.length > 0 && ( {tabs && tabs.length > 0 && (
<div className="space-y-4"> <Stack gap="md">
<nav className="flex gap-4 border-b"> <Tabs value={activeTab} onChange={(v) => v && onTabChange?.(v)}>
{tabs.map((tab) => ( <Tabs.List>
<button {tabs.map((tab) => (
key={tab.value} <Tabs.Tab
type="button" key={tab.value}
onClick={() => onTabChange?.(tab.value)} value={tab.value}
className={cn( rightSection={tab.count !== undefined ? <Badge size="xs" variant="filled" circle>{tab.count}</Badge> : undefined}
'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}
)} </Tabs.Tab>
> ))}
{tab.label} </Tabs.List>
{tab.count !== undefined && ( </Tabs>
<span className="inline-flex h-5 items-center rounded-full bg-muted px-2 text-xs font-medium">{tab.count}</span>
)}
</button>
))}
</nav>
{tabs.find(t => t.value === activeTab)?.content} {tabs.find(t => t.value === activeTab)?.content}
</div> </Stack>
)} )}
{/* Timeline */} {/* Timeline */}
{timeline && timeline.length > 0 && ( {timeline && timeline.length > 0 && (
<div className="space-y-3"> <Stack gap="sm">
<h3 className="text-sm font-medium text-muted-foreground">Activity</h3> <Text size="sm" fw={500} c="dimmed">Activity</Text>
<div className="space-y-0"> <Timeline active={timeline.length - 1} bulletSize={12} lineWidth={2}>
{timeline.map((event, i) => ( {timeline.map((event) => (
<div key={event.id} className="flex gap-3 pb-4"> <Timeline.Item
<div className="flex flex-col items-center"> key={event.id}
<div className={cn('mt-1 size-2.5 rounded-full', variantDotColors[event.variant || 'default'])} /> color={variantColors[event.variant || 'default']}
{i < timeline.length - 1 && <div className="flex-1 w-px bg-border" />} title={<Text size="sm" fw={500}>{event.title}</Text>}
</div> >
<div className="flex-1 space-y-0.5 pb-2"> {event.description && <Text size="xs" c="dimmed">{event.description}</Text>}
<p className="text-sm font-medium">{event.title}</p> <Text size="xs" c="dimmed" opacity={0.7}>{event.timestamp}</Text>
{event.description && <p className="text-xs text-muted-foreground">{event.description}</p>} </Timeline.Item>
<p className="text-xs text-muted-foreground/70">{event.timestamp}</p>
</div>
</div>
))} ))}
</div> </Timeline>
</div> </Stack>
)} )}
</div> </Stack>
) )
} }
+6 -6
View File
@@ -6,15 +6,15 @@ domain: ui
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "Dialog(props: DialogRootProps): JSX.Element" 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)." 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] tags: [dialog, modal, overlay, component, ui, interactive, mantine]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" error_type: ""
imports: ["@base-ui/react", lucide-react, react] imports: ["@mantine/core", react]
output: "Componente Dialog que renderiza modal accesible con overlay blur, focus trap y sistema de slots composables" output: "Componente Dialog que renderiza modal accesible con focus trap y sistema de slots composables via Mantine Modal"
tested: false tested: false
tests: [] tests: []
test_file_path: "" test_file_path: ""
@@ -53,4 +53,4 @@ source_file: "frontend/src/components/ui/dialog.tsx"
## Notas ## 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 * as React from 'react'
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" import { Modal, Box, Text, Group } from '@mantine/core'
import { cn } from "../core/cn"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) { interface DialogProps {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
} }
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { const DialogContext = React.createContext<{
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> open: boolean
} setOpen: (open: boolean) => void
}>({ open: false, setOpen: () => {} })
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { function Dialog({ open: controlledOpen, onOpenChange, children }: DialogProps) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> const [internalOpen, setInternalOpen] = React.useState(false)
} const open = controlledOpen ?? internalOpen
const setOpen = React.useCallback(
function DialogClose({ ...props }: DialogPrimitive.Close.Props) { (v: boolean) => {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> onOpenChange?.(v)
} if (controlledOpen === undefined) setInternalOpen(v)
},
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) { [controlledOpen, onOpenChange],
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 DialogContent({ className, children, showCloseButton = true, ...props }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean }) {
return ( return (
<DialogPortal> <DialogContext.Provider value={{ open, setOpen }}>
<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}>
{children} {children}
</div> </DialogContext.Provider>
) )
} }
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { function DialogTrigger({ children, ...props }: React.ComponentProps<'button'>) {
return <DialogPrimitive.Title data-slot="dialog-title" className={cn("text-base leading-none font-medium", className)} {...props} /> 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) { function DialogPortal({ children }: { children: React.ReactNode }) {
return <DialogPrimitive.Description data-slot="dialog-description" className={cn("text-sm text-muted-foreground", className)} {...props} /> 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 } 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 purity: impure
signature: "DropdownMenu(props: DropdownMenuProps): JSX.Element" signature: "DropdownMenu(props: DropdownMenuProps): JSX.Element"
description: "Menu de acciones y contexto accesible con items, checkboxes, radios, separadores y submenus. Base-UI Menu primitive." 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] tags: [dropdown, menu, component, ui, interactive, overlay, mantine]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" 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" output: "Componente DropdownMenu que renderiza menú desplegable accesible con items, checkboxes, radios y submenus"
tested: false tested: false
tests: [] tests: []
+111 -173
View File
@@ -1,187 +1,125 @@
import * as React from "react" import * as React from 'react'
import { Menu as MenuPrimitive } from "@base-ui/react/menu" import { Menu, Text } from '@mantine/core'
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "../core/cn"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { function DropdownMenu({ children, ...props }: { children: React.ReactNode; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; modal?: boolean }) {
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) {
return ( return (
<DropdownMenuPortal> <Menu
<MenuPrimitive.Positioner opened={props.open}
data-slot="dropdown-menu-content" defaultOpened={props.defaultOpen}
sideOffset={sideOffset} onChange={props.onOpenChange}
className="z-50" withinPortal
{...props} shadow="md"
>
<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}
> >
{children} {children}
<ChevronRightIcon className="ml-auto" /> </Menu>
</MenuPrimitive.SubmenuTrigger>
) )
} }
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 ( return (
<MenuPrimitive.Portal> <Menu.Item
<MenuPrimitive.Positioner data-slot="dropdown-menu-sub-content" className="z-50" {...props}> className={className}
<MenuPrimitive.Popup onClick={props.onClick ?? props.onActivate}
className={cn( disabled={props.disabled}
"min-w-[8rem] overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-md", pl={inset ? 'xl' : undefined}
"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", {children}
className </Menu.Item>
)}
>
{props.children}
</MenuPrimitive.Popup>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
) )
} }
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 { export {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem, 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" 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." 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] tags: [form, field, label, error, component, ui, accessibility]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" error_type: ""
imports: [react] imports: ["@mantine/core"]
output: "Componente FormField que renderiza wrapper de campo con label, helper text, error y ARIA automáticos" output: "Componente FormField que renderiza wrapper de campo con label, helper text, error y ARIA automáticos"
tested: false tested: false
tests: [] tests: []
+23 -10
View File
@@ -1,5 +1,5 @@
import * as React from "react" import * as React from 'react'
import { cn } from "../core/cn" import { Box, Text } from '@mantine/core'
interface FormFieldProps { interface FormFieldProps {
label?: string label?: string
@@ -15,26 +15,39 @@ function FormField({ label, helperText, error, children, className }: FormFieldP
const helperId = `${id}-helper` const helperId = `${id}-helper`
const errorId = `${id}-error` 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) => { const childWithProps = React.Children.map(children, (child) => {
if (React.isValidElement(child)) { if (React.isValidElement(child)) {
return React.cloneElement(child as React.ReactElement<Record<string, unknown>>, { return React.cloneElement(child as React.ReactElement<Record<string, unknown>>, {
id: inputId, id: inputId,
"aria-invalid": error ? true : undefined, 'aria-invalid': error ? true : undefined,
"aria-describedby": describedBy, 'aria-describedby': describedBy,
error: error || undefined,
}) })
} }
return child return child
}) })
return ( return (
<div className={cn("flex flex-col gap-1.5", className)}> <Box className={className}>
{label && <label htmlFor={inputId} className="text-sm font-medium text-foreground">{label}</label>} {label && (
<Text component="label" htmlFor={inputId} size="sm" fw={500} mb={4} display="block">
{label}
</Text>
)}
{childWithProps} {childWithProps}
{helperText && !error && <p id={helperId} className="text-sm text-muted-foreground">{helperText}</p>} {helperText && !error && (
{error && <p id={errorId} className="text-sm text-destructive">{error}</p>} <Text id={helperId} size="sm" c="dimmed" mt={4}>
</div> {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 selectionColor?: string
} }
export interface ContextMenuTarget {
type: "node" | "edge" | "canvas"
id?: string
data?: GraphNode | GraphEdge
}
export interface GraphContainerProps { export interface GraphContainerProps {
data: GraphData data: GraphData
layout?: "organic" | "random" layout?: "organic" | "random"
@@ -58,6 +64,7 @@ export interface GraphContainerProps {
nodeTypes?: NodeType[] nodeTypes?: NodeType[]
onNodeClick?: (node: GraphNode) => void onNodeClick?: (node: GraphNode) => void
onNodeDoubleClick?: (node: GraphNode) => void onNodeDoubleClick?: (node: GraphNode) => void
onContextMenu?: (event: MouseEvent, target: ContextMenuTarget) => void
enableSelection?: boolean enableSelection?: boolean
selectionMode?: "single" | "multiple" selectionMode?: "single" | "multiple"
theme?: GraphTheme theme?: GraphTheme
@@ -84,6 +91,7 @@ function GraphContainer({
nodeTypes = [], nodeTypes = [],
onNodeClick, onNodeClick,
onNodeDoubleClick, onNodeDoubleClick,
onContextMenu,
theme: themeProp, theme: themeProp,
height = "100%", height = "100%",
className, className,
@@ -96,10 +104,30 @@ function GraphContainer({
[themeProp], [themeProp],
) )
// Build + render // Build + render — wait for container to have dimensions
const [ready, setReady] = React.useState(false)
React.useEffect(() => { React.useEffect(() => {
const el = containerRef.current const el = containerRef.current
if (!el) return 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 // Cleanup previous instance
if (sigmaRef.current) { if (sigmaRef.current) {
@@ -110,7 +138,7 @@ function GraphContainer({
const g = new Graph({ multi: true, type: "directed" }) const g = new Graph({ multi: true, type: "directed" })
graphRef.current = g 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) { for (const n of data.nodes) {
g.addNode(n.id, { g.addNode(n.id, {
label: n.label, label: n.label,
@@ -118,7 +146,7 @@ function GraphContainer({
y: n.y ?? (Math.random() - 0.5) * 10, y: n.y ?? (Math.random() - 0.5) * 10,
size: n.size ?? theme.nodeSize, size: n.size ?? theme.nodeSize,
color: n.color ?? theme.nodeColor, color: n.color ?? theme.nodeColor,
type: n.type, entityType: n.type,
}) })
} }
@@ -152,6 +180,7 @@ function GraphContainer({
// Render // Render
const renderer = new Sigma(g, el, { const renderer = new Sigma(g, el, {
allowInvalidContainer: true,
renderEdgeLabels: false, renderEdgeLabels: false,
defaultEdgeColor: theme.edgeColor, defaultEdgeColor: theme.edgeColor,
defaultNodeColor: theme.nodeColor, defaultNodeColor: theme.nodeColor,
@@ -174,13 +203,30 @@ function GraphContainer({
onNodeDoubleClick({ id: node, ...attrs } as unknown as GraphNode) 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 () => { return () => {
renderer.kill() renderer.kill()
sigmaRef.current = null sigmaRef.current = null
graphRef.current = null graphRef.current = null
} }
}, [data, layout, theme, onNodeClick, onNodeDoubleClick]) }, [data, layout, theme, onNodeClick, onNodeDoubleClick, onContextMenu, ready])
// Container background // Container background
const containerStyle: React.CSSProperties = { 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 { Input, InputGroup, InputIcon } from './input'
export { Label } from './label' export { Label } from './label'
export { KPICard } from './kpi_card' 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 { SimpleSelect } from './simple_select'
export type { SimpleSelectOption, SimpleSelectGroup, SimpleSelectOptions } from './simple_select' export type { SimpleSelectOption, SimpleSelectGroup, SimpleSelectOptions } from './simple_select'
export { Skeleton, SkeletonAvatar, SkeletonButton, SkeletonCard, SkeletonTable, SkeletonText } from './skeleton' 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 { DataTable } from './data_table'
export type { DataTableProps, ColumnDef } from './data_table' export type { DataTableProps, ColumnDef } from './data_table'
// Theme // Mantine Provider
export { ThemeProvider, useTheme, ThemeContext } from './theme_provider' export { FnMantineProvider } from './mantine_provider'
export type { ThemeProviderProps } from './theme_provider' export type { FnMantineProviderProps } from './mantine_provider'
export { applyTheme } from './apply_theme'
export type { Theme, ThemeColors } from './apply_theme'
// Page templates // Page templates
export { analyticsPage } from './analytics_page' export { analyticsPage } from './analytics_page'
@@ -82,14 +81,14 @@ export type { CheckboxProps } from './checkbox'
// Command // Command
export { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSearch, CommandSeparator, CommandShortcut } from './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 // Dropdown Menu
export { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from './dropdown_menu' export { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from './dropdown_menu'
// Pagination // Pagination
export { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from './pagination' export { Pagination } from './pagination'
export type { PaginationLinkProps } from './pagination' export type { PaginationProps } from './pagination'
// Popover // Popover
export { Popover, PopoverClose, PopoverContent, PopoverDescription, PopoverHeader, PopoverPortal, PopoverTitle, PopoverTrigger } from './popover' export { Popover, PopoverClose, PopoverContent, PopoverDescription, PopoverHeader, PopoverPortal, PopoverTitle, PopoverTrigger } from './popover'
@@ -123,3 +122,15 @@ export { useAnimatedCanvas } from './use_animated_canvas'
// Wails Provider // Wails Provider
export { WailsProvider } from './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" version: "1.0.0"
purity: impure purity: impure
signature: "Input(props: InputHTMLAttributes): JSX.Element" signature: "Input(props: InputHTMLAttributes): JSX.Element"
description: "Campo de entrada accesible con soporte para iconos, grupos, validación ARIA y estados disabled/invalid." 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] tags: [input, form, component, ui, interactive, mantine]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" 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" output: "Componente Input que renderiza campo de entrada accesible con soporte para iconos y validación ARIA"
tested: false tested: false
tests: [] tests: []
@@ -49,4 +49,4 @@ source_file: "frontend/src/components/ui/input.tsx"
## Notas ## 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 * as React from 'react'
import { Input as InputPrimitive } from "@base-ui/react/input" import { TextInput, Box } from '@mantine/core'
import { cn } from "../core/cn"
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({
className,
type,
...props
}: React.ComponentProps<typeof TextInput> & { type?: string }) {
return ( return (
<InputPrimitive <TextInput
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( size="sm"
"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", className={className}
"group-has-[data-slot=input-icon-start]/input-group:pl-9",
"group-has-[data-slot=input-icon-end]/input-group:pr-9",
className
)}
{...props} {...props}
/> />
) )
@@ -25,32 +24,34 @@ interface InputGroupProps {
function InputGroup({ children, className }: InputGroupProps) { function InputGroup({ children, className }: InputGroupProps) {
return ( return (
<div data-slot="input-group" className={cn("group/input-group relative", className)}> <Box data-slot="input-group" className={className}>
{children} {children}
</div> </Box>
) )
} }
interface InputIconProps { interface InputIconProps {
children: React.ReactNode children: React.ReactNode
position: "start" | "end" position: 'start' | 'end'
className?: string className?: string
} }
function InputIcon({ children, position, className }: InputIconProps) { function InputIcon({ children, position, className }: InputIconProps) {
return ( return (
<span <Box
data-slot={`input-icon-${position}`} data-slot={`input-icon-${position}`}
className={cn( component="span"
"pointer-events-none absolute top-1/2 -translate-y-1/2 text-muted-foreground [&_svg]:size-4", className={className}
position === "start" && "left-2.5", style={{
position === "end" && "right-2.5", display: 'inline-flex',
className alignItems: 'center',
)} pointerEvents: 'none',
}}
> >
{children} {children}
</span> </Box>
) )
} }
export { Input, InputGroup, InputIcon } export { Input, InputGroup, InputIcon }
export type { InputGroupProps, InputIconProps }
+1 -1
View File
@@ -8,7 +8,7 @@ purity: impure
signature: "KPICard(props: KPICardProps): JSX.Element" 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." 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] tags: [kpi, card, metrics, dashboard, component, ui, sparkline]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
+52 -43
View File
@@ -1,78 +1,87 @@
import * as React from 'react' import * as React from 'react'
import { cn } from '../core/cn' import { Paper, Text, Group, Stack, Box } from '@mantine/core'
type KPICardSize = 'sm' | 'default' | 'lg' type KPICardSize = 'sm' | 'default' | 'lg'
interface Delta { interface Delta {
value: number value: number
isPositive: boolean isPositive: boolean
/** Descriptive label before value, e.g. "Increased by" */
label?: string label?: string
/** Suffix after value, e.g. "vs yesterday" */
suffix?: string suffix?: string
} }
interface KPICardProps extends React.HTMLAttributes<HTMLDivElement> { interface KPICardProps extends React.HTMLAttributes<HTMLDivElement> {
label: string label: string
value: string | number value: string | number
/** Unit displayed next to value in smaller font, e.g. "k", "ms", "%" */
unit?: string unit?: string
delta?: Delta delta?: Delta
icon?: React.ReactNode icon?: React.ReactNode
/** Action slot rendered top-right, e.g. a menu button */
action?: React.ReactNode action?: React.ReactNode
/** Inline chart slot rendered to the right of the value */
chart?: React.ReactNode chart?: React.ReactNode
subtitle?: string subtitle?: string
size?: KPICardSize size?: KPICardSize
} }
const sizeStyles: Record<KPICardSize, { value: string; unit: string; label: string }> = { const valueSizes: Record<KPICardSize, string> = {
sm: { value: 'text-2xl font-bold', unit: 'text-base font-medium', label: 'text-xs' }, sm: '1.5rem',
default: { value: 'text-3xl font-bold', unit: 'text-lg font-medium', label: 'text-sm' }, default: '1.875rem',
lg: { value: 'text-4xl font-bold', unit: 'text-xl font-medium', label: 'text-base' }, 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>( const KPICard = React.forwardRef<HTMLDivElement, KPICardProps>(
({ label, value, unit, delta, icon, action, chart, subtitle, size = 'default', className, ...props }, ref) => { ({ label, value, unit, delta, icon, action, chart, subtitle, size = 'default', className, ...props }, ref) => {
const styles = sizeStyles[size]
const deltaColor = delta const deltaColor = delta
? delta.value === 0 ? 'text-muted-foreground' ? delta.value === 0 ? 'dimmed'
: delta.isPositive ? 'text-green-600 dark:text-green-500' : delta.isPositive ? 'teal'
: 'text-red-600 dark:text-red-500' : 'red'
: '' : undefined
return ( return (
<div ref={ref} className={cn('rounded-lg border bg-card p-4 text-card-foreground shadow-sm', className)} {...props}> <Paper ref={ref} withBorder shadow="xs" radius="md" p="md" className={className} {...props}>
<div className="flex items-start justify-between"> <Group justify="space-between" align="flex-start">
<div className="flex items-center gap-2"> <Group gap="xs" align="center">
{icon && <div className="text-muted-foreground">{icon}</div>} {icon && <Box c="dimmed">{icon}</Box>}
<div className="space-y-1"> <Stack gap={2}>
<p className={cn('text-muted-foreground', styles.label)}>{label}</p> <Text size={labelSizes[size]} c="dimmed">{label}</Text>
{subtitle && <p className="text-xs text-muted-foreground/80">{subtitle}</p>} {subtitle && <Text size="xs" c="dimmed" opacity={0.8}>{subtitle}</Text>}
</div> </Stack>
</div> </Group>
{action && <div className="text-muted-foreground">{action}</div>} {action && <Box c="dimmed">{action}</Box>}
</div> </Group>
<div className="mt-3 flex items-end justify-between gap-4">
<div className="space-y-1"> <Group justify="space-between" align="flex-end" mt="md" gap="lg">
<div className="flex items-baseline gap-1"> <Stack gap={4}>
<span className={cn('tracking-tight', styles.value)}>{value}</span> <Group gap={4} align="baseline">
{unit && <span className={cn('text-muted-foreground', styles.unit)}>{unit}</span>} <Text fw={700} style={{ fontSize: valueSizes[size], lineHeight: 1, letterSpacing: '-0.025em' }}>
</div> {value}
</Text>
{unit && <Text size={unitSizes[size]} c="dimmed" fw={500}>{unit}</Text>}
</Group>
{delta && ( {delta && (
<div className="flex items-center gap-1 text-xs text-muted-foreground"> <Group gap={4} align="center">
{delta.label && <span>{delta.label}</span>} {delta.label && <Text size="xs" c="dimmed">{delta.label}</Text>}
<span className={cn('font-medium', deltaColor)}> <Text size="xs" fw={500} c={deltaColor}>
{delta.isPositive ? '' : ''} {delta.value > 0 ? '+' : ''}{delta.value}{delta.label ? '' : '%'} {delta.isPositive ? '\u25B2' : '\u25BC'} {delta.value > 0 ? '+' : ''}{delta.value}{delta.label ? '' : '%'}
</span> </Text>
{delta.suffix && <span>{delta.suffix}</span>} {delta.suffix && <Text size="xs" c="dimmed">{delta.suffix}</Text>}
</div> </Group>
)} )}
</div> </Stack>
{chart && <div className="flex-shrink-0">{chart}</div>} {chart && <Box style={{ flexShrink: 0 }}>{chart}</Box>}
</div> </Group>
</div> </Paper>
) )
} }
) )
+4 -4
View File
@@ -6,14 +6,14 @@ domain: ui
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "Label(props: LabelHTMLAttributes): JSX.Element" signature: "Label(props: LabelHTMLAttributes): JSX.Element"
description: "Etiqueta de formulario accesible con soporte para estados disabled y peer-disabled." description: "Etiqueta de formulario accesible con soporte para estados disabled. Mantine Text con component=label."
tags: [label, form, component, ui] tags: [label, form, component, ui, mantine]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" error_type: ""
imports: ["react"] imports: ["@mantine/core"]
output: "Componente Label que renderiza etiqueta de formulario accesible con soporte para estados disabled" output: "Componente Label que renderiza etiqueta de formulario accesible con soporte para estados disabled"
tested: false tested: false
tests: [] tests: []
+9 -8
View File
@@ -1,14 +1,15 @@
import * as React from "react" import * as React from 'react'
import { cn } from "../core/cn" import { Text } from '@mantine/core'
function Label({ className, ...props }: React.ComponentProps<"label">) { function Label({ className, ...props }: React.ComponentProps<'label'>) {
return ( return (
<label <Text
component="label"
data-slot="label" data-slot="label"
className={cn( size="sm"
"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", fw={500}
className style={{ display: 'inline-flex', alignItems: 'center', gap: 8, userSelect: 'none' }}
)} className={className}
{...props} {...props}
/> />
) )
+5 -9
View File
@@ -6,15 +6,15 @@ domain: ui
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "LineChart(props: LineChartProps): JSX.Element" 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." 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, recharts, component, ui] tags: [chart, line, visualization, mantine, component, ui]
uses_functions: [cn_ts_core, chart_container_ts_ui, get_series_color_ts_core] uses_functions: [chart_container_ts_ui]
uses_types: [ChartSeries_ts_ui] uses_types: [ChartSeries_ts_ui]
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" error_type: ""
imports: [recharts] imports: ["@mantine/charts", "@mantine/core"]
output: "Componente LineChart que renderiza gráfico de líneas multi-series con zoom, curvas customizables y líneas de referencia" output: "Componente LineChart que renderiza gráfico de líneas multi-series con curvas customizables y líneas de referencia"
tested: false tested: false
tests: [] tests: []
test_file_path: "" test_file_path: ""
@@ -32,10 +32,6 @@ props:
type: "Series[]" type: "Series[]"
required: false required: false
description: "Series de datos" description: "Series de datos"
- name: zoomable
type: "boolean"
required: false
description: "Habilitar zoom brush"
- name: curveType - name: curveType
type: "'linear' | 'monotone' | 'step' | 'stepBefore' | 'stepAfter'" type: "'linear' | 'monotone' | 'step' | 'stepBefore' | 'stepAfter'"
required: false required: false
+31 -28
View File
@@ -1,8 +1,6 @@
import { import { LineChart as MantineLineChart } from '@mantine/charts'
LineChart as RechartsLineChart, Line, XAxis, YAxis, CartesianGrid, import { Paper } from '@mantine/core'
Tooltip, Legend, Brush, ReferenceLine, import { type Series, getSeriesColor } from './chart_container'
} from 'recharts'
import { ChartContainer, ChartTooltipContent, type Series, getSeriesColor } from './chart_container'
type CurveType = 'linear' | 'monotone' | 'step' | 'stepBefore' | 'stepAfter' type CurveType = 'linear' | 'monotone' | 'step' | 'stepBefore' | 'stepAfter'
@@ -15,9 +13,7 @@ interface LineChartProps {
showGrid?: boolean showGrid?: boolean
showLegend?: boolean showLegend?: boolean
showDots?: boolean showDots?: boolean
zoomable?: boolean height?: number
height?: number | string
className?: string
xAxisFormatter?: (value: unknown) => string xAxisFormatter?: (value: unknown) => string
yAxisFormatter?: (value: unknown) => string yAxisFormatter?: (value: unknown) => string
valueFormatter?: (value: number) => string valueFormatter?: (value: number) => string
@@ -26,30 +22,37 @@ interface LineChartProps {
function LineChartComponent({ function LineChartComponent({
data, xKey, yKey, series, curveType = 'monotone', showGrid = true, showLegend = false, 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 = [], valueFormatter = (v) => v.toLocaleString(), referenceLines = [],
}: LineChartProps) { }: LineChartProps) {
const lines = series const chartSeries = series
? series.map((s, i) => ({ dataKey: s.key, name: s.name, stroke: getSeriesColor(i, s.color) })) ? series.map((s, i) => ({ name: s.key, label: s.name, color: getSeriesColor(i, s.color) }))
: yKey ? [{ dataKey: yKey, name: yKey, stroke: getSeriesColor(0) }] : [] : 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 ( return (
<ChartContainer className={className} height={height}> <Paper p="md">
<RechartsLineChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: zoomable ? 30 : 10 }}> <MantineLineChart
{showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />} h={height}
<XAxis dataKey={xKey} tickFormatter={xAxisFormatter} className="text-xs fill-muted-foreground" /> data={data}
<YAxis tickFormatter={yAxisFormatter} className="text-xs fill-muted-foreground" /> dataKey={xKey}
<Tooltip content={<ChartTooltipContent valueFormatter={valueFormatter} />} cursor={{ stroke: 'hsl(var(--muted-foreground))', strokeDasharray: '3 3' }} /> series={chartSeries}
{showLegend && <Legend />} curveType={curveType}
{referenceLines.map((ref, i) => ( gridAxis={showGrid ? 'xy' : 'none'}
<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} /> withLegend={showLegend}
))} withTooltip
{lines.map((line) => ( withDots={showDots}
<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 }} /> valueFormatter={valueFormatter}
))} referenceLines={refLines}
{zoomable && <Brush dataKey={xKey} height={20} stroke="hsl(var(--primary))" fill="hsl(var(--muted))" tickFormatter={xAxisFormatter} />} xAxisProps={xAxisFormatter ? { tickFormatter: xAxisFormatter } : undefined}
</RechartsLineChart> yAxisProps={yAxisFormatter ? { tickFormatter: yAxisFormatter } : undefined}
</ChartContainer> />
</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" 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." 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] tags: [header, page, layout, navigation, component, ui]
uses_functions: [cn_ts_core] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "" 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" output: "Componente PageHeader que renderiza cabecera de página con título, acciones, tabs integrados y modo sticky"
tested: false tested: false
tests: [] tests: []

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