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