diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c9cb4eeb..913894c0 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -83,7 +83,7 @@ fn-registry/ python/functions/ # .py + .md por funcion Python python/types/ # .py + .md por tipo Python bash/functions/ # .sh + .md por funcion Bash (core, infra, io, shell) - frontend/ # pnpm + vite + react + tailwind + shadcn + frontend/ # pnpm + vite + react + mantine frontend/functions/ # .tsx/.ts + .md (core para TS puro, ui para componentes React) frontend/types/ # .ts + .md por tipo registry/ # Paquete Go: modelos, SQLite, parser, indexer, validacion, migraciones diff --git a/.claude/commands/frontend.md b/.claude/commands/frontend.md index 2678b3a6..d9ad9df0 100644 --- a/.claude/commands/frontend.md +++ b/.claude/commands/frontend.md @@ -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 ( - - {/* Router y contenido aqui */} - - ) -} +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( + + + + + + , +) ``` ### 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: ` + )} - - + + {/* Table */} -
- - - + +
+ + {columns.map((col) => ( - + ))} {(onEdit || onDelete) && ( - + Actions )} - - - + + + {data.length === 0 ? ( - - - + + +
+ No items yet. +
+
+
) : ( data.map((row, i) => ( - + {columns.map((col) => ( - + ))} {(onEdit || onDelete) && ( - + + )} - + )) )} - -
+ {col.label} - Actions
- No items yet. -
+ {col.render ? col.render(row[col.key], row) : String(row[col.key] ?? '')} - -
+ + {onEdit && ( - + onEdit(row)}> + + )} {onDelete && ( - + onDelete(row)}> + + )} -
-
-
+ + + - {/* Form fields definition (for agent use — renders a form preview) */} -
-
+ {/* Form fields definition (for agent use) */} +
+ ) } diff --git a/frontend/functions/ui/dashboard_layout.md b/frontend/functions/ui/dashboard_layout.md index 947a8c36..8a8f2945 100644 --- a/frontend/functions/ui/dashboard_layout.md +++ b/frontend/functions/ui/dashboard_layout.md @@ -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" diff --git a/frontend/functions/ui/dashboard_layout.tsx b/frontend/functions/ui/dashboard_layout.tsx index a3d3b89e..d542653c 100644 --- a/frontend/functions/ui/dashboard_layout.tsx +++ b/frontend/functions/ui/dashboard_layout.tsx @@ -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 = { - 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 = { - 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 = { - 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 ( -
+ {widgets.map((widget) => ( -
1 ? `span ${widget.span}` : undefined, + gridRow: widget.rowSpan === 2 ? 'span 2' : undefined, + }} > {widget.title && ( -

{widget.title}

+ {widget.title} )} {widget.content} -
+ ))} -
+ ) } diff --git a/frontend/functions/ui/data_table.md b/frontend/functions/ui/data_table.md index f6b2440b..c10e82f2 100644 --- a/frontend/functions/ui/data_table.md +++ b/frontend/functions/ui/data_table.md @@ -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 diff --git a/frontend/functions/ui/data_table.tsx b/frontend/functions/ui/data_table.tsx index f76dbe49..385708ea 100644 --- a/frontend/functions/ui/data_table.tsx +++ b/frontend/functions/ui/data_table.tsx @@ -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 ( -
- Loading... -
+
+ +
) } if (error) { return ( -
- {error.message} -
+
+ {error.message} +
) } return ( -
- - - + +
+ + {effectiveColumns.map(col => ( - + ))} - - - + + + {(data ?? []).map((row, i) => ( - + {effectiveColumns.map(col => { const align = col.align ?? (typeof row[col.key] === 'number' ? 'right' : 'left') return ( - + ) })} - + ))} - -
{col.label} -
{formatCell(row[col.key], col.format)} -
+ + {(!data || data.length === 0) && ( -

No data

+
+ No data +
)} -
+ ) } diff --git a/frontend/functions/ui/date_picker_input.md b/frontend/functions/ui/date_picker_input.md new file mode 100644 index 00000000..fde1c8d5 --- /dev/null +++ b/frontend/functions/ui/date_picker_input.md @@ -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(null) + return ( + + ) +} + +// Rango de fechas +function RangeDateExample() { + const [range, setRange] = useState<[Date | null, Date | null]>([null, null]) + return ( + + ) +} + +// Múltiples fechas +function MultipleDateExample() { + const [dates, setDates] = useState([]) + return ( + + ) +} + +// DatePicker inline (sin input) +function InlineDateExample() { + const [value, setValue] = useState(null) + return ( + + ) +} +``` + +## 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. diff --git a/frontend/functions/ui/date_picker_input.tsx b/frontend/functions/ui/date_picker_input.tsx new file mode 100644 index 00000000..f69bf0cd --- /dev/null +++ b/frontend/functions/ui/date_picker_input.tsx @@ -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 +} + +function DatePicker(props: DatePickerProps) { + return +} + +export { DatePickerInput, DatePicker } +export type { DatePickerInputProps, DatePickerProps } diff --git a/frontend/functions/ui/detail_page.md b/frontend/functions/ui/detail_page.md index 00dab53a..49b1acd9 100644 --- a/frontend/functions/ui/detail_page.md +++ b/frontend/functions/ui/detail_page.md @@ -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" diff --git a/frontend/functions/ui/detail_page.tsx b/frontend/functions/ui/detail_page.tsx index 86bae0d4..e8d577a5 100644 --- a/frontend/functions/ui/detail_page.tsx +++ b/frontend/functions/ui/detail_page.tsx @@ -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 = { + 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 ( -
+ {/* Header */} -
-
+ + {onBack && ( - + + + )} - {avatar &&
{avatar}
} -
-
-

{title}

+ {avatar && ( + + {avatar} + + )} + + + {title} {badge} -
- {subtitle &&

{subtitle}

} -
-
- {actions &&
{actions}
} -
+ + {subtitle && {subtitle}} +
+ + {actions && {actions}} + {/* Fields grid */} -
+ {fields.map((field, i) => ( -
-

{field.label}

-
{field.value}
-
+ + + {field.label} + {field.value} + + ))} -
+ {/* Tabs */} {tabs && tabs.length > 0 && ( -
- + + v && onTabChange?.(v)}> + + {tabs.map((tab) => ( + {tab.count} : undefined} + > + {tab.label} + + ))} + + {tabs.find(t => t.value === activeTab)?.content} -
+ )} {/* Timeline */} {timeline && timeline.length > 0 && ( -
-

Activity

-
- {timeline.map((event, i) => ( -
-
-
- {i < timeline.length - 1 &&
} -
-
-

{event.title}

- {event.description &&

{event.description}

} -

{event.timestamp}

-
-
+ + Activity + + {timeline.map((event) => ( + {event.title}} + > + {event.description && {event.description}} + {event.timestamp} + ))} -
-
+ + )} -
+ ) } diff --git a/frontend/functions/ui/dialog.md b/frontend/functions/ui/dialog.md index ae84dc09..1d7a0bf4 100644 --- a/frontend/functions/ui/dialog.md +++ b/frontend/functions/ui/dialog.md @@ -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. diff --git a/frontend/functions/ui/dialog.tsx b/frontend/functions/ui/dialog.tsx index 77c7d8fa..ef22cde4 100644 --- a/frontend/functions/ui/dialog.tsx +++ b/frontend/functions/ui/dialog.tsx @@ -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 +interface DialogProps { + open?: boolean + onOpenChange?: (open: boolean) => void + children: React.ReactNode } -function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { - return -} +const DialogContext = React.createContext<{ + open: boolean + setOpen: (open: boolean) => void +}>({ open: false, setOpen: () => {} }) -function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { - return -} - -function DialogClose({ ...props }: DialogPrimitive.Close.Props) { - return -} - -function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) { - return ( - +function Dialog({ open: controlledOpen, onOpenChange, children }: DialogProps) { + const [internalOpen, setInternalOpen] = React.useState(false) + const open = controlledOpen ?? internalOpen + const setOpen = React.useCallback( + (v: boolean) => { + onOpenChange?.(v) + if (controlledOpen === undefined) setInternalOpen(v) + }, + [controlledOpen, onOpenChange], ) -} - -function DialogContent({ className, children, showCloseButton = true, ...props }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean }) { return ( - - - - {children} - {showCloseButton && ( - - - Close - - )} - - - ) -} - -function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { - return
-} - -function DialogFooter({ className, children, ...props }: React.ComponentProps<"div">) { - return ( -
+ {children} -
+ ) } -function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { - return +function DialogTrigger({ children, ...props }: React.ComponentProps<'button'>) { + const { setOpen } = React.useContext(DialogContext) + return ( + + ) } -function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) { - return +function DialogPortal({ children }: { children: React.ReactNode }) { + return <>{children} +} + +function DialogClose({ children, ...props }: React.ComponentProps<'button'>) { + const { setOpen } = React.useContext(DialogContext) + return ( + + ) +} + +function DialogOverlay() { + return null +} + +function DialogContent({ + children, + showCloseButton = true, + className, + ...props +}: React.ComponentProps<'div'> & { showCloseButton?: boolean }) { + const { open, setOpen } = React.useContext(DialogContext) + return ( + setOpen(false)} + withCloseButton={showCloseButton} + radius="md" + padding="md" + size="sm" + centered + data-slot="dialog-content" + className={className} + {...props} + > + {children} + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return +} + +function DialogFooter({ className, children, ...props }: React.ComponentProps<'div'>) { + return ( + + {children} + + ) +} + +function DialogTitle({ className, children, ...props }: React.ComponentProps<'div'>) { + return ( + + {children} + + ) +} + +function DialogDescription({ className, children, ...props }: React.ComponentProps<'div'>) { + return ( + + {children} + + ) } export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } diff --git a/frontend/functions/ui/dropdown_menu.md b/frontend/functions/ui/dropdown_menu.md index b68da7ee..4f0be746 100644 --- a/frontend/functions/ui/dropdown_menu.md +++ b/frontend/functions/ui/dropdown_menu.md @@ -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: [] diff --git a/frontend/functions/ui/dropdown_menu.tsx b/frontend/functions/ui/dropdown_menu.tsx index 3fb24bea..5b755af4 100644 --- a/frontend/functions/ui/dropdown_menu.tsx +++ b/frontend/functions/ui/dropdown_menu.tsx @@ -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 -} - -function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { - return -} - -function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { - return -} - -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 ( - - - - {props.children} - - - - ) -} - -function DropdownMenuItem({ className, inset, ...props }: MenuPrimitive.Item.Props & { inset?: boolean }) { - return ( - - ) -} - -function DropdownMenuCheckboxItem({ className, children, checked, ...props }: MenuPrimitive.CheckboxItem.Props) { - return ( - - - - - - - {children} - - ) -} - -function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { - return -} - -function DropdownMenuRadioItem({ className, children, ...props }: MenuPrimitive.RadioItem.Props) { - return ( - - - - - - - {children} - - ) -} - -function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { - return -} - -function DropdownMenuLabel({ className, inset, ...props }: MenuPrimitive.GroupLabel.Props & { inset?: boolean }) { - return ( - - ) -} - -function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { - return ( - - ) -} - -function DropdownMenuSub({ ...props }: MenuPrimitive.Root.Props) { - return -} - -function DropdownMenuSubTrigger({ className, inset, children, ...props }: MenuPrimitive.SubmenuTrigger.Props & { inset?: boolean }) { - return ( - {children} - - + ) } -function DropdownMenuSubContent({ className, ...props }: MenuPrimitive.Positioner.Props) { +function DropdownMenuTrigger({ children, ...props }: { children: React.ReactNode; asChild?: boolean; className?: string }) { + return {children} +} + +function DropdownMenuPortal({ children }: { children?: React.ReactNode }) { + return <>{children} +} + +function DropdownMenuContent({ children, className }: { children?: React.ReactNode; className?: string; sideOffset?: number }) { + return {children} +} + +function DropdownMenuItem({ children, className, inset, ...props }: { + children?: React.ReactNode + className?: string + inset?: boolean + onClick?: () => void + onActivate?: () => void + disabled?: boolean +}) { return ( - - - - {props.children} - - - + + {children} + ) } +function DropdownMenuCheckboxItem({ children, className, checked, onCheckedChange, ...props }: { + children?: React.ReactNode + className?: string + checked?: boolean + onCheckedChange?: (checked: boolean) => void + disabled?: boolean +}) { + return ( + onCheckedChange?.(!checked)} + disabled={props.disabled} + leftSection={checked ? : } + > + {children} + + ) +} + +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 ( + + {children} + + ) +} + +function DropdownMenuGroup({ children }: { children?: React.ReactNode }) { + return <>{children} +} + +function DropdownMenuLabel({ children, className, inset }: { children?: React.ReactNode; className?: string; inset?: boolean }) { + return ( + + {children} + + ) +} + +function DropdownMenuSeparator({ className }: { className?: string }) { + return +} + +function DropdownMenuShortcut({ children, className }: { children?: React.ReactNode; className?: string }) { + return {children} +} + +function DropdownMenuSub({ children }: { children?: React.ReactNode }) { + return <>{children} +} + +function DropdownMenuSubTrigger({ children, className, inset }: { children?: React.ReactNode; className?: string; inset?: boolean }) { + return ( + + {children} + + ) +} + +function DropdownMenuSubContent({ children, className }: { children?: React.ReactNode; className?: string }) { + return {children} +} + export { DropdownMenu, DropdownMenuCheckboxItem, diff --git a/frontend/functions/ui/dropzone.md b/frontend/functions/ui/dropzone.md new file mode 100644 index 00000000..a3e8037e --- /dev/null +++ b/frontend/functions/ui/dropzone.md @@ -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" + 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 ( + console.log('Archivos aceptados:', files)} + onReject={(files) => console.log('Archivos rechazados:', files)} + accept={IMAGE_MIME_TYPE} + maxSize={5 * 1024 ** 2} + > + + + + + + + + + + +
+ + Arrastra imágenes aquí o haz clic para seleccionar + + + Máximo 5 MB por imagen + +
+
+
+ ) +} +``` + +## 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.) diff --git a/frontend/functions/ui/dropzone.tsx b/frontend/functions/ui/dropzone.tsx new file mode 100644 index 00000000..a46eedec --- /dev/null +++ b/frontend/functions/ui/dropzone.tsx @@ -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 { + children: React.ReactNode +} + +function Dropzone({ children, ...props }: DropzoneProps) { + return {}} {...props}>{children} +} + +// 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 } diff --git a/frontend/functions/ui/empty_state.md b/frontend/functions/ui/empty_state.md new file mode 100644 index 00000000..5f8fd703 --- /dev/null +++ b/frontend/functions/ui/empty_state.md @@ -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 + + +// Con acción + navigate('/new')} +/> + +// Con icono custom +import { IconDatabase } from '@tabler/icons-react' + +} + 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' + + + + +``` + +## 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. diff --git a/frontend/functions/ui/empty_state.tsx b/frontend/functions/ui/empty_state.tsx new file mode 100644 index 00000000..b57b176a --- /dev/null +++ b/frontend/functions/ui/empty_state.tsx @@ -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 ( + + + {icon || } + + + {title} + + + {description} + + {children} + {actionLabel && onAction && ( + + )} + + ) +} + +export { EmptyState } +export type { EmptyStateProps } diff --git a/frontend/functions/ui/error_page.md b/frontend/functions/ui/error_page.md new file mode 100644 index 00000000..8ba4b70a --- /dev/null +++ b/frontend/functions/ui/error_page.md @@ -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 + navigate('/')} /> + +// 500 custom + window.location.reload()} +/> + +// 403 con acciones extra + navigate('/dashboard')} + extraActions={ + + } +/> +``` + +## 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". diff --git a/frontend/functions/ui/error_page.tsx b/frontend/functions/ui/error_page.tsx new file mode 100644 index 00000000..b93dbb4a --- /dev/null +++ b/frontend/functions/ui/error_page.tsx @@ -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 ( + + + + {code} + + + {title} + + + {description} + + + + {extraActions} + + + + ) +} + +export { ErrorPage } +export type { ErrorPageConfig } diff --git a/frontend/functions/ui/file_input.md b/frontend/functions/ui/file_input.md new file mode 100644 index 00000000..1795a80c --- /dev/null +++ b/frontend/functions/ui/file_input.md @@ -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 + + +// Múltiples archivos + + +// Solo imágenes + +``` + +## 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. diff --git a/frontend/functions/ui/file_input.tsx b/frontend/functions/ui/file_input.tsx new file mode 100644 index 00000000..38a551de --- /dev/null +++ b/frontend/functions/ui/file_input.tsx @@ -0,0 +1,10 @@ +import { FileInput as MantineFileInput, type FileInputProps as MantineFileInputProps } from '@mantine/core' + +interface FileInputProps extends MantineFileInputProps {} + +function FileInput(props: FileInputProps) { + return +} + +export { FileInput } +export type { FileInputProps } diff --git a/frontend/functions/ui/form_field.md b/frontend/functions/ui/form_field.md index 6913ced7..3c4670a9 100644 --- a/frontend/functions/ui/form_field.md +++ b/frontend/functions/ui/form_field.md @@ -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: [] diff --git a/frontend/functions/ui/form_field.tsx b/frontend/functions/ui/form_field.tsx index d238aa43..0504161b 100644 --- a/frontend/functions/ui/form_field.tsx +++ b/frontend/functions/ui/form_field.tsx @@ -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>, { id: inputId, - "aria-invalid": error ? true : undefined, - "aria-describedby": describedBy, + 'aria-invalid': error ? true : undefined, + 'aria-describedby': describedBy, + error: error || undefined, }) } return child }) return ( -
- {label && } + + {label && ( + + {label} + + )} {childWithProps} - {helperText && !error &&

{helperText}

} - {error &&

{error}

} -
+ {helperText && !error && ( + + {helperText} + + )} + {error && ( + + {error} + + )} + ) } diff --git a/frontend/functions/ui/graph/index.tsx b/frontend/functions/ui/graph/index.tsx index e1fb584a..baac506e 100644 --- a/frontend/functions/ui/graph/index.tsx +++ b/frontend/functions/ui/graph/index.tsx @@ -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 = { diff --git a/frontend/functions/ui/index.ts b/frontend/functions/ui/index.ts index 9f674c01..906cf6c1 100644 --- a/frontend/functions/ui/index.ts +++ b/frontend/functions/ui/index.ts @@ -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' diff --git a/frontend/functions/ui/indicator.md b/frontend/functions/ui/indicator.md new file mode 100644 index 00000000..379f3be1 --- /dev/null +++ b/frontend/functions/ui/indicator.md @@ -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 */} + + } /> + + +{/* Con contador */} + + + +``` + +## 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. diff --git a/frontend/functions/ui/indicator.tsx b/frontend/functions/ui/indicator.tsx new file mode 100644 index 00000000..13d130c0 --- /dev/null +++ b/frontend/functions/ui/indicator.tsx @@ -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 ( + + {children} + + ) +} + +export { FnIndicator } +export type { FnIndicatorProps } diff --git a/frontend/functions/ui/input.md b/frontend/functions/ui/input.md index c0f197ba..b0ad72fe 100644 --- a/frontend/functions/ui/input.md +++ b/frontend/functions/ui/input.md @@ -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. diff --git a/frontend/functions/ui/input.tsx b/frontend/functions/ui/input.tsx index e5168797..1e45468f 100644 --- a/frontend/functions/ui/input.tsx +++ b/frontend/functions/ui/input.tsx @@ -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 & { type?: string }) { return ( - ) @@ -25,32 +24,34 @@ interface InputGroupProps { function InputGroup({ children, className }: InputGroupProps) { return ( -
+ {children} -
+ ) } interface InputIconProps { children: React.ReactNode - position: "start" | "end" + position: 'start' | 'end' className?: string } function InputIcon({ children, position, className }: InputIconProps) { return ( - {children} - + ) } export { Input, InputGroup, InputIcon } +export type { InputGroupProps, InputIconProps } diff --git a/frontend/functions/ui/kpi_card.md b/frontend/functions/ui/kpi_card.md index 90699eed..ef94bf34 100644 --- a/frontend/functions/ui/kpi_card.md +++ b/frontend/functions/ui/kpi_card.md @@ -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 diff --git a/frontend/functions/ui/kpi_card.tsx b/frontend/functions/ui/kpi_card.tsx index 4d774c55..7c31d0b3 100644 --- a/frontend/functions/ui/kpi_card.tsx +++ b/frontend/functions/ui/kpi_card.tsx @@ -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 { 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 = { - 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 = { + sm: '1.5rem', + default: '1.875rem', + lg: '2.25rem', +} + +const unitSizes: Record = { + sm: 'md', + default: 'lg', + lg: 'xl', +} + +const labelSizes: Record = { + sm: 'xs', + default: 'sm', + lg: 'md', } const KPICard = React.forwardRef( ({ 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 ( -
-
-
- {icon &&
{icon}
} -
-

{label}

- {subtitle &&

{subtitle}

} -
-
- {action &&
{action}
} -
-
-
-
- {value} - {unit && {unit}} -
+ + + + {icon && {icon}} + + {label} + {subtitle && {subtitle}} + + + {action && {action}} + + + + + + + {value} + + {unit && {unit}} + {delta && ( -
- {delta.label && {delta.label}} - - {delta.isPositive ? '▲' : '▼'} {delta.value > 0 ? '+' : ''}{delta.value}{delta.label ? '' : '%'} - - {delta.suffix && {delta.suffix}} -
+ + {delta.label && {delta.label}} + + {delta.isPositive ? '\u25B2' : '\u25BC'} {delta.value > 0 ? '+' : ''}{delta.value}{delta.label ? '' : '%'} + + {delta.suffix && {delta.suffix}} + )} -
- {chart &&
{chart}
} -
-
+ + {chart && {chart}} + + ) } ) diff --git a/frontend/functions/ui/label.md b/frontend/functions/ui/label.md index 7dd73b2b..08cad4bf 100644 --- a/frontend/functions/ui/label.md +++ b/frontend/functions/ui/label.md @@ -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: [] diff --git a/frontend/functions/ui/label.tsx b/frontend/functions/ui/label.tsx index 56bb58a6..0ab0f331 100644 --- a/frontend/functions/ui/label.tsx +++ b/frontend/functions/ui/label.tsx @@ -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 ( -