feat: nuevos componentes UI — accordion, avatar, breadcrumb, checkbox, command, dropdown, pagination, popover, radio, sheet, select, switch, textarea, toast
Componentes React accesibles basados en Radix UI con soporte de temas via CSS variables. Incluye barrel export en index.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: accordion
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Accordion(props: AccordionProps): JSX.Element"
|
||||
description: "Secciones colapsables con animaciones. Base-UI Collapsible primitive. Composable: AccordionItem + AccordionTrigger + AccordionContent."
|
||||
tags: [accordion, collapsible, component, ui, interactive, base-ui]
|
||||
uses_functions: [cn_ts_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@base-ui/react/collapsible", "lucide-react"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/accordion.tsx"
|
||||
props:
|
||||
- name: className
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Clases CSS adicionales para el contenedor"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: []
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<Accordion>
|
||||
<AccordionItem defaultOpen>
|
||||
<AccordionTrigger>Seccion 1</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Contenido de la primera seccion.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem>
|
||||
<AccordionTrigger>Seccion 2</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Contenido de la segunda seccion.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,81 @@
|
||||
import * as React from "react"
|
||||
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
interface AccordionItem {
|
||||
value: string
|
||||
trigger: React.ReactNode
|
||||
content: React.ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface AccordionProps {
|
||||
items?: AccordionItem[]
|
||||
type?: "single" | "multiple"
|
||||
defaultValue?: string | string[]
|
||||
className?: string
|
||||
itemClassName?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
function Accordion({ className, children, ...props }: React.ComponentProps<"div"> & AccordionProps) {
|
||||
return (
|
||||
<div data-slot="accordion" className={cn("divide-y divide-border", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AccordionItemProps extends CollapsiblePrimitive.Root.Props {
|
||||
className?: string
|
||||
}
|
||||
|
||||
function AccordionItem({ className, ...props }: AccordionItemProps) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Root
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||
</CollapsiblePrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({ className, children, ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="pb-4">{children}</div>
|
||||
</CollapsiblePrimitive.Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }
|
||||
export type { AccordionItem as AccordionItemData, AccordionProps }
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: avatar
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Avatar(props: AvatarProps): JSX.Element"
|
||||
description: "Imagen de usuario circular con fallback a iniciales generadas automaticamente. 5 tamaños via CVA."
|
||||
tags: [avatar, user, image, component, ui, cva]
|
||||
uses_functions: [cn_ts_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["class-variance-authority"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/avatar.tsx"
|
||||
props:
|
||||
- name: src
|
||||
type: "string"
|
||||
required: false
|
||||
description: "URL de la imagen"
|
||||
- name: alt
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Texto alternativo de la imagen"
|
||||
- name: fallback
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Nombre completo del que extraer iniciales (ej: 'Juan Perez' -> 'JP')"
|
||||
- name: initials
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Iniciales explicitas para el fallback (sobrescribe fallback)"
|
||||
- name: size
|
||||
type: "'xs' | 'sm' | 'md' | 'lg' | 'xl'"
|
||||
required: false
|
||||
description: "Tamanio del avatar (default: md)"
|
||||
- name: className
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Clases CSS adicionales"
|
||||
emits: []
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [xs, sm, md, lg, xl]
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
// Con imagen
|
||||
<Avatar src="https://example.com/user.jpg" alt="Juan Perez" size="md" />
|
||||
|
||||
// Con fallback a iniciales
|
||||
<Avatar fallback="Juan Perez" size="lg" />
|
||||
|
||||
// Iniciales explicitas
|
||||
<Avatar initials="JD" size="sm" />
|
||||
|
||||
// Maneja error de imagen automaticamente
|
||||
<Avatar src="/broken-url.jpg" fallback="Maria Garcia" />
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa 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.
|
||||
@@ -0,0 +1,69 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
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" },
|
||||
}
|
||||
)
|
||||
|
||||
interface AvatarProps
|
||||
extends React.ComponentPropsWithoutRef<"span">,
|
||||
VariantProps<typeof avatarVariants> {
|
||||
src?: string
|
||||
alt?: string
|
||||
fallback?: string
|
||||
initials?: string
|
||||
}
|
||||
|
||||
function getInitials(name?: string): string {
|
||||
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 Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
|
||||
({ className, size, src, alt, fallback, initials, ...props }, ref) => {
|
||||
const [imgError, setImgError] = React.useState(false)
|
||||
const showImage = src && !imgError
|
||||
const displayInitials = initials ?? getInitials(fallback ?? alt)
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
data-slot="avatar"
|
||||
className={cn(avatarVariants({ size }), 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>
|
||||
)
|
||||
}
|
||||
)
|
||||
Avatar.displayName = "Avatar"
|
||||
|
||||
export { Avatar, avatarVariants }
|
||||
export type { AvatarProps }
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: breadcrumb
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Breadcrumb(props: BreadcrumbProps): JSX.Element"
|
||||
description: "Navegacion jerarquica con separadores, elipsis para paths largos y soporte para router links via asChild."
|
||||
tags: [breadcrumb, navigation, component, ui]
|
||||
uses_functions: [cn_ts_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["lucide-react"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/breadcrumb.tsx"
|
||||
props:
|
||||
- name: className
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Clases CSS adicionales"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: []
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/">Inicio</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/docs">Documentacion</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Componentes</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
// Con elipsis para paths largos
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/">Inicio</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbEllipsis />
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Pagina actual</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Exports: Breadcrumb (nav), BreadcrumbList (ol), 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.
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as React from "react"
|
||||
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentPropsWithoutRef<"nav">) {
|
||||
return <nav data-slot="breadcrumb" aria-label="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentPropsWithoutRef<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
className,
|
||||
href,
|
||||
asChild,
|
||||
children,
|
||||
...props
|
||||
}: 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">)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
data-slot="breadcrumb-link"
|
||||
href={href}
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentPropsWithoutRef<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-current="page"
|
||||
aria-disabled="true"
|
||||
className={cn("font-medium text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({ className, ...props }: React.ComponentPropsWithoutRef<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator }
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: checkbox
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Checkbox(props: CheckboxProps): JSX.Element"
|
||||
description: "Input booleano accesible con label opcional y variante indeterminate. Base-UI Checkbox primitive."
|
||||
tags: [checkbox, component, ui, interactive, form, base-ui]
|
||||
uses_functions: [cn_ts_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@base-ui/react/checkbox", "class-variance-authority"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/checkbox.tsx"
|
||||
props:
|
||||
- name: label
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Texto de etiqueta visible junto al checkbox"
|
||||
- name: indeterminate
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Estado indeterminate (guion) en vez de checked/unchecked"
|
||||
- name: checked
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Estado controlado del checkbox"
|
||||
- name: defaultChecked
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Estado inicial no controlado"
|
||||
- name: disabled
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Deshabilita el checkbox"
|
||||
- name: onCheckedChange
|
||||
type: "(checked: boolean) => void"
|
||||
required: false
|
||||
description: "Callback cuando cambia el estado"
|
||||
emits: [onCheckedChange]
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: []
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
// Basico
|
||||
<Checkbox label="Acepto los terminos" />
|
||||
|
||||
// Controlado
|
||||
<Checkbox
|
||||
label="Seleccionar todos"
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onCheckedChange={setAllSelected}
|
||||
/>
|
||||
|
||||
// Sin label
|
||||
<Checkbox checked={isActive} onCheckedChange={setIsActive} />
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa 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.
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||
import { CheckboxIndicator } from "@base-ui/react/checkbox"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
interface CheckboxProps extends CheckboxPrimitive.Root.Props {
|
||||
label?: string
|
||||
indeterminate?: boolean
|
||||
className?: string
|
||||
labelClassName?: string
|
||||
}
|
||||
|
||||
function Checkbox({ className, label, id, indeterminate, ...props }: CheckboxProps) {
|
||||
const internalId = React.useId()
|
||||
const checkboxId = id ?? internalId
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckboxPrimitive.Root
|
||||
id={checkboxId}
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer size-4 shrink-0 rounded border border-input bg-transparent transition-colors outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
"data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground",
|
||||
"data-indeterminate:border-primary data-indeterminate:bg-primary data-indeterminate:text-primary-foreground",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
indeterminate={indeterminate}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxIndicator
|
||||
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>
|
||||
)}
|
||||
</CheckboxIndicator>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
export type { CheckboxProps }
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: command
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Command(props: CommandProps): JSX.Element"
|
||||
description: "Combobox de busqueda y seleccion estilo cmdk. Filtra items por query, soporta grupos, iconos y shortcuts. Incluye CommandSearch para uso de una linea."
|
||||
tags: [command, search, combobox, component, ui, interactive]
|
||||
uses_functions: [cn_ts_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["lucide-react"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/command.tsx"
|
||||
props:
|
||||
- name: items
|
||||
type: "CommandItem[]"
|
||||
required: true
|
||||
description: "Array de items con value, label, description, icon, disabled, group"
|
||||
- name: value
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Valor seleccionado (controlado)"
|
||||
- name: onValueChange
|
||||
type: "(value: string) => void"
|
||||
required: false
|
||||
description: "Callback al seleccionar un item"
|
||||
- name: placeholder
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Placeholder del input de busqueda (default: Search...)"
|
||||
- name: emptyMessage
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Mensaje cuando no hay resultados (default: No results found.)"
|
||||
emits: [onValueChange]
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: []
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
// Uso simple con CommandSearch
|
||||
const items = [
|
||||
{ value: "react", label: "React", group: "Frameworks" },
|
||||
{ value: "vue", label: "Vue", group: "Frameworks" },
|
||||
{ value: "typescript", label: "TypeScript", group: "Lenguajes" },
|
||||
]
|
||||
|
||||
<CommandSearch
|
||||
items={items}
|
||||
placeholder="Buscar tecnologia..."
|
||||
onValueChange={(val) => console.log(val)}
|
||||
/>
|
||||
|
||||
// Composable para mayor control
|
||||
<Command>
|
||||
<CommandInput placeholder="Buscar..." value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
<CommandList>
|
||||
<CommandEmpty>Sin resultados.</CommandEmpty>
|
||||
<CommandGroup heading="Sugerencias">
|
||||
<CommandItem selected={selected === "1"} onSelect={() => setSelected("1")}>
|
||||
Opcion 1
|
||||
<CommandShortcut>⌘K</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Implementacion propia (sin dependencia de cmdk) usando primitivos HTML nativos. CommandSearch es el wrapper de alto nivel con filtrado reactivo integrado. El filtrado es case-insensitive sobre label, description y value. Los grupos se renderizan en orden de aparicion en items.
|
||||
@@ -0,0 +1,204 @@
|
||||
import * as React from "react"
|
||||
import { SearchIcon, XIcon } from "lucide-react"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
interface CommandItem {
|
||||
value: string
|
||||
label: string
|
||||
description?: string
|
||||
icon?: React.ReactNode
|
||||
disabled?: boolean
|
||||
group?: string
|
||||
}
|
||||
|
||||
interface CommandProps {
|
||||
items: CommandItem[]
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
placeholder?: string
|
||||
emptyMessage?: string
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
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 CommandInput({ className, ...props }: React.ComponentPropsWithoutRef<"input">) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="flex items-center border-b px-3">
|
||||
<SearchIcon className="mr-2 size-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none",
|
||||
"placeholder:text-muted-foreground",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-empty"
|
||||
className={cn("py-6 text-center text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({ className, heading, ...props }: React.ComponentPropsWithoutRef<"div"> & { heading?: string }) {
|
||||
return (
|
||||
<div data-slot="command-group" className={cn("overflow-hidden p-1 text-foreground", className)}>
|
||||
{heading && (
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">{heading}</div>
|
||||
)}
|
||||
<div {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-separator"
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface CommandItemProps extends React.ComponentPropsWithoutRef<"div"> {
|
||||
selected?: boolean
|
||||
disabled?: boolean
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
function CommandItem({ className, selected, disabled, onSelect, ...props }: CommandItemProps) {
|
||||
return (
|
||||
<div
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 CommandSearch({
|
||||
items,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = "Search...",
|
||||
emptyMessage = "No results found.",
|
||||
className,
|
||||
}: CommandProps) {
|
||||
const [query, setQuery] = React.useState("")
|
||||
const [selectedValue, setSelectedValue] = React.useState(value ?? "")
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
if (!query) return items
|
||||
const q = query.toLowerCase()
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
item.description?.toLowerCase().includes(q) ||
|
||||
item.value.toLowerCase().includes(q)
|
||||
)
|
||||
}, [items, query])
|
||||
|
||||
const groups = React.useMemo(() => {
|
||||
const map = new Map<string, CommandItem[]>()
|
||||
for (const item of filtered) {
|
||||
const key = item.group ?? ""
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(item)
|
||||
}
|
||||
return map
|
||||
}, [filtered])
|
||||
|
||||
const handleSelect = (val: string) => {
|
||||
setSelectedValue(val)
|
||||
onValueChange?.(val)
|
||||
}
|
||||
|
||||
return (
|
||||
<Command className={className}>
|
||||
<CommandInput
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<CommandList>
|
||||
{filtered.length === 0 ? (
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
) : (
|
||||
Array.from(groups.entries()).map(([group, groupItems]) => (
|
||||
<CommandGroup key={group} heading={group || undefined}>
|
||||
{groupItems.map((item) => (
|
||||
<CommandItem
|
||||
key={item.value}
|
||||
selected={selectedValue === item.value}
|
||||
disabled={item.disabled}
|
||||
onSelect={() => handleSelect(item.value)}
|
||||
>
|
||||
{item.icon && <span className="shrink-0">{item.icon}</span>}
|
||||
<span>{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">{item.description}</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
)
|
||||
}
|
||||
|
||||
export { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSearch, CommandSeparator, CommandShortcut }
|
||||
export type { CommandItem, CommandProps }
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: dropdown_menu
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "DropdownMenu(props: DropdownMenuProps): JSX.Element"
|
||||
description: "Menu de acciones y contexto accesible con items, checkboxes, radios, separadores y submenus. Base-UI Menu primitive."
|
||||
tags: [dropdown, menu, component, ui, interactive, overlay, base-ui]
|
||||
uses_functions: [cn_ts_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@base-ui/react/menu", "lucide-react"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/dropdown_menu.tsx"
|
||||
props:
|
||||
- name: open
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Estado controlado de apertura"
|
||||
- name: defaultOpen
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Estado inicial de apertura"
|
||||
- name: onOpenChange
|
||||
type: "(open: boolean) => void"
|
||||
required: false
|
||||
description: "Callback cuando cambia el estado"
|
||||
- name: modal
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Comportamiento modal (default: true)"
|
||||
emits: [onOpenChange]
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: []
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">Acciones</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onActivate={() => console.log("Perfil")}>
|
||||
Perfil
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuCheckboxItem checked={showBookmarks} onCheckedChange={setShowBookmarks}>
|
||||
Marcadores
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Mas opciones</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem>Opcion A</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Exports: DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuPortal.
|
||||
@@ -0,0 +1,201 @@
|
||||
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"
|
||||
|
||||
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) {
|
||||
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}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({ className, ...props }: MenuPrimitive.Positioner.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner data-slot="dropdown-menu-sub-content" className="z-50" {...props}>
|
||||
<MenuPrimitive.Popup
|
||||
className={cn(
|
||||
"min-w-[8rem] overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
|
||||
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</MenuPrimitive.Popup>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Barrel export — @fn_library
|
||||
// Primitives
|
||||
export { Alert, AlertTitle, AlertDescription } from './alert'
|
||||
export { Badge, badgeVariants } from './badge'
|
||||
export { Button, buttonVariants } from './button'
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent } from './card'
|
||||
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from './dialog'
|
||||
export { Input, InputGroup, InputIcon } from './input'
|
||||
export { Label } from './label'
|
||||
export { KPICard } from './kpi_card'
|
||||
export { Select, SelectContent, SelectGroup, SelectGroupLabel, SelectItem, SelectPortal, SelectSeparator, SelectTrigger, SelectValue } from './select'
|
||||
export { SimpleSelect } from './simple_select'
|
||||
export type { SimpleSelectOption, SimpleSelectGroup, SimpleSelectOptions } from './simple_select'
|
||||
export { Skeleton, SkeletonAvatar, SkeletonButton, SkeletonCard, SkeletonTable, SkeletonText } from './skeleton'
|
||||
export { Sparkline } from './sparkline'
|
||||
export type { SparklineProps, SparklineVariant } from './sparkline'
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs'
|
||||
export { Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger } from './tooltip'
|
||||
export { FormField } from './form_field'
|
||||
export type { FormFieldProps } from './form_field'
|
||||
export { PageHeader } from './page_header'
|
||||
export { ProgressBar } from './progress_bar'
|
||||
|
||||
// Charts
|
||||
export { AreaChart } from './area_chart'
|
||||
export type { AreaChartProps, GradientConfig } from './area_chart'
|
||||
export { BarChart } from './bar_chart'
|
||||
export type { BarChartProps } from './bar_chart'
|
||||
export { LineChart } from './line_chart'
|
||||
export type { LineChartProps, CurveType } from './line_chart'
|
||||
export { PieChart } from './pie_chart'
|
||||
export type { PieChartProps } from './pie_chart'
|
||||
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, getSeriesColor } from './chart_container'
|
||||
export type { Series } from './chart_container'
|
||||
|
||||
// Data
|
||||
export { DataTable } from './data_table'
|
||||
export type { DataTableProps, ColumnDef } from './data_table'
|
||||
|
||||
// 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'
|
||||
|
||||
// Page templates
|
||||
export { analyticsPage } from './analytics_page'
|
||||
export type { AnalyticsPageProps, MetricConfig, ChartConfig } from './analytics_page'
|
||||
export { crudPage } from './crud_page'
|
||||
export type { CrudPageProps, CrudField } from './crud_page'
|
||||
export { dashboardLayout } from './dashboard_layout'
|
||||
export type { DashboardWidget, DashboardLayoutProps } from './dashboard_layout'
|
||||
export { detailPage } from './detail_page'
|
||||
export type { DetailPageProps, DetailField, DetailTab, TimelineEvent } from './detail_page'
|
||||
export { settingsPage } from './settings_page'
|
||||
export type { SettingsPageProps, SettingSection, SettingField } from './settings_page'
|
||||
|
||||
// Hooks — Wails
|
||||
export { useWailsQuery } from './use_wails_query'
|
||||
export type { UseWailsQueryOptions, UseWailsQueryResult } from './use_wails_query'
|
||||
export { useWailsMutation } from './use_wails_mutation'
|
||||
export type { UseWailsMutationOptions, UseWailsMutationResult } from './use_wails_mutation'
|
||||
export { useWailsStream, useWailsLogs } from './use_wails_stream'
|
||||
export type { UseWailsStreamOptions, UseWailsStreamResult } from './use_wails_stream'
|
||||
export { useWailsEvent, useWailsEmit } from './use_wails_event'
|
||||
export type { UseWailsEventOptions, UseWailsEventResult } from './use_wails_event'
|
||||
|
||||
// Accordion
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './accordion'
|
||||
export type { AccordionProps } from './accordion'
|
||||
|
||||
// Avatar
|
||||
export { Avatar, avatarVariants } from './avatar'
|
||||
export type { AvatarProps } from './avatar'
|
||||
|
||||
// Breadcrumb
|
||||
export { Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from './breadcrumb'
|
||||
|
||||
// Checkbox
|
||||
export { Checkbox } from './checkbox'
|
||||
export type { CheckboxProps } from './checkbox'
|
||||
|
||||
// Command
|
||||
export { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSearch, CommandSeparator, CommandShortcut } from './command'
|
||||
export type { 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'
|
||||
|
||||
// Popover
|
||||
export { Popover, PopoverClose, PopoverContent, PopoverDescription, PopoverHeader, PopoverPortal, PopoverTitle, PopoverTrigger } from './popover'
|
||||
|
||||
// Radio Group
|
||||
export { RadioGroup, RadioGroupItem } from './radio_group'
|
||||
export type { RadioGroupItemProps } from './radio_group'
|
||||
|
||||
// Sheet
|
||||
export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, sheetVariants } from './sheet'
|
||||
export type { SheetContentProps } from './sheet'
|
||||
|
||||
// Switch
|
||||
export { SwitchToggle } from './switch_toggle'
|
||||
export type { SwitchToggleProps } from './switch_toggle'
|
||||
|
||||
// Textarea
|
||||
export { Textarea } from './textarea'
|
||||
export type { TextareaProps } from './textarea'
|
||||
|
||||
// Toast
|
||||
export { Toast, ToastProvider, ToastViewport, toastVariants, useToast } from './toast'
|
||||
export type { ToastEntry, ToastProps, ToastViewportProps } from './toast'
|
||||
|
||||
// Hooks — Canvas
|
||||
export { useAnimatedCanvas } from './use_animated_canvas'
|
||||
|
||||
// Wails Provider
|
||||
export { WailsProvider } from './wails_provider'
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: pagination
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Pagination(props: PaginationProps): JSX.Element"
|
||||
description: "Controles de navegacion de paginas con Previous/Next, numeros de pagina, elipsis y estado activo."
|
||||
tags: [pagination, navigation, component, ui]
|
||||
uses_functions: [cn_ts_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["lucide-react", "./button"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/pagination.tsx"
|
||||
props:
|
||||
- name: className
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Clases CSS adicionales"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: []
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious href="/page/1" />
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationLink href="/page/1">1</PaginationLink>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationLink href="/page/2" isActive>2</PaginationLink>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationLink href="/page/3">3</PaginationLink>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext href="/page/3" />
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Exports: Pagination (nav), PaginationContent (ul), PaginationItem (li), PaginationLink (a con isActive/disabled), PaginationPrevious, PaginationNext, PaginationEllipsis. PaginationLink reutiliza buttonVariants para consistencia visual. Componente presentacional — el manejo del estado de pagina queda en el consumidor.
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||
import { cn } from "../core/cn"
|
||||
import { buttonVariants } from "./button"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
data-slot="pagination"
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
disabled?: boolean
|
||||
size?: "icon" | "default" | "sm" | "lg"
|
||||
} & React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({ className, isActive, disabled, size = "icon", ...props }: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
data-slot="pagination-link"
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
aria-disabled={disabled}
|
||||
className={cn(
|
||||
buttonVariants({ variant: isActive ? "outline" : "ghost", size }),
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
isActive && "border-border font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({ className, ...props }: React.ComponentProps<"a">) {
|
||||
return (
|
||||
<PaginationLink
|
||||
data-slot="pagination-previous"
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 pl-2", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({ className, ...props }: React.ComponentProps<"a">) {
|
||||
return (
|
||||
<PaginationLink
|
||||
data-slot="pagination-next"
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 pr-2", className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="pagination-ellipsis"
|
||||
aria-hidden
|
||||
className={cn("flex size-8 items-center justify-center text-muted-foreground", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious }
|
||||
export type { PaginationLinkProps }
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: popover
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Popover(props: PopoverProps): JSX.Element"
|
||||
description: "Contenido flotante posicionado accesible con animaciones. Base-UI Popover primitive."
|
||||
tags: [popover, component, ui, interactive, overlay, base-ui]
|
||||
uses_functions: [cn_ts_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@base-ui/react/popover"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/popover.tsx"
|
||||
props:
|
||||
- name: open
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Estado controlado de apertura"
|
||||
- name: defaultOpen
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Estado inicial de apertura (no controlado)"
|
||||
- name: onOpenChange
|
||||
type: "(open: boolean) => void"
|
||||
required: false
|
||||
description: "Callback cuando cambia el estado de apertura"
|
||||
- name: sideOffset
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Distancia en px entre trigger y popover (default: 4)"
|
||||
emits: [onOpenChange]
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: []
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline">Abrir</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>Configuracion</PopoverTitle>
|
||||
<PopoverDescription>Ajusta tus preferencias.</PopoverDescription>
|
||||
</PopoverHeader>
|
||||
<div className="mt-4">
|
||||
{/* contenido */}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Compuesto de: Popover (root), PopoverTrigger, PopoverContent (positioner + popup), PopoverClose, PopoverHeader, PopoverTitle, PopoverDescription. El posicionamiento automatico lo maneja Base-UI. Animaciones con data-open/data-closed.
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverPortal({ ...props }: PopoverPrimitive.Portal.Props) {
|
||||
return <PopoverPrimitive.Portal data-slot="popover-portal" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({ className, sideOffset = 4, ...props }: PopoverPrimitive.Positioner.Props) {
|
||||
return (
|
||||
<PopoverPortal>
|
||||
<PopoverPrimitive.Positioner
|
||||
data-slot="popover-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn("z-50", className)}
|
||||
{...props}
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
className={cn(
|
||||
"w-72 rounded-xl border bg-popover p-4 text-popover-foreground shadow-md outline-none",
|
||||
"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"
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</PopoverPrimitive.Popup>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverClose({ ...props }: PopoverPrimitive.Close.Props) {
|
||||
return <PopoverPrimitive.Close data-slot="popover-close" {...props} />
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="popover-header" className={cn("flex flex-col gap-1.5", className)} {...props} />
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<"h4">) {
|
||||
return <h4 data-slot="popover-title" className={cn("text-sm font-semibold leading-none", className)} {...props} />
|
||||
}
|
||||
|
||||
function PopoverDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return <p data-slot="popover-description" className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverClose, PopoverContent, PopoverDescription, PopoverHeader, PopoverPortal, PopoverTitle, PopoverTrigger }
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: radio_group
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "RadioGroup(props: RadioGroupProps): JSX.Element"
|
||||
description: "Grupo de opciones exclusivas accesible. Base-UI RadioGroup + Radio primitives."
|
||||
tags: [radio, radio-group, component, ui, interactive, form, base-ui]
|
||||
uses_functions: [cn_ts_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@base-ui/react/radio-group", "@base-ui/react/radio"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/radio_group.tsx"
|
||||
props:
|
||||
- name: value
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Valor seleccionado (controlado)"
|
||||
- name: defaultValue
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Valor inicial (no controlado)"
|
||||
- name: onValueChange
|
||||
type: "(value: string) => void"
|
||||
required: false
|
||||
description: "Callback al cambiar seleccion"
|
||||
- name: disabled
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Deshabilita todo el grupo"
|
||||
- name: orientation
|
||||
type: "'horizontal' | 'vertical'"
|
||||
required: false
|
||||
description: "Orientacion del grupo"
|
||||
emits: [onValueChange]
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: []
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<RadioGroup defaultValue="option-a">
|
||||
<RadioGroupItem value="option-a" label="Opcion A" />
|
||||
<RadioGroupItem value="option-b" label="Opcion B" />
|
||||
<RadioGroupItem value="option-c" label="Opcion C" disabled />
|
||||
</RadioGroup>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
RadioGroup es el contenedor (Base-UI RadioGroup). RadioGroupItem es cada opcion individual (Base-UI Radio). El id de cada item se genera con useId si no se provee.
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react"
|
||||
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
|
||||
import { Radio } from "@base-ui/react/radio"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
|
||||
return (
|
||||
<RadioGroupPrimitive
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface RadioGroupItemProps extends Radio.Root.Props {
|
||||
label?: string
|
||||
className?: string
|
||||
labelClassName?: string
|
||||
}
|
||||
|
||||
function RadioGroupItem({ className, label, id, labelClassName, ...props }: RadioGroupItemProps) {
|
||||
const internalId = React.useId()
|
||||
const itemId = id ?? internalId
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio.Root
|
||||
id={itemId}
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"aspect-square size-4 rounded-full 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",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Radio.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<span className="block size-2 rounded-full bg-primary" />
|
||||
</Radio.Indicator>
|
||||
</Radio.Root>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={itemId}
|
||||
data-slot="radio-group-label"
|
||||
className={cn("text-sm font-medium leading-none cursor-pointer select-none", labelClassName)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
export type { RadioGroupItemProps }
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: sheet
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Sheet(props: SheetProps): JSX.Element"
|
||||
description: "Panel lateral deslizante (drawer) accesible con variantes de lado y animaciones. Base-UI Dialog con posicionamiento lateral via CVA."
|
||||
tags: [sheet, drawer, panel, component, ui, interactive, overlay, base-ui, cva]
|
||||
uses_functions: [cn_ts_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@base-ui/react/dialog", "class-variance-authority", "lucide-react"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/sheet.tsx"
|
||||
props:
|
||||
- name: side
|
||||
type: "'top' | 'bottom' | 'left' | 'right'"
|
||||
required: false
|
||||
description: "Lado desde el que aparece el panel (default: right)"
|
||||
- name: showCloseButton
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Muestra el boton de cierre (default: true)"
|
||||
- name: open
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Estado controlado de apertura"
|
||||
- name: onOpenChange
|
||||
type: "(open: boolean) => void"
|
||||
required: false
|
||||
description: "Callback cuando cambia el estado"
|
||||
emits: [onOpenChange]
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [top, bottom, left, right]
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline">Abrir panel</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Editar perfil</SheetTitle>
|
||||
<SheetDescription>Realiza cambios en tu perfil.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="py-4">
|
||||
{/* contenido del panel */}
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline">Cancelar</Button>
|
||||
</SheetClose>
|
||||
<Button>Guardar</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Reutiliza Base-UI Dialog para el comportamiento modal. Las animaciones de deslizamiento usan slide-in-from-* de Tailwind. CVA gestiona las variantes de lado. Exports: Sheet, SheetTrigger, SheetContent, SheetClose, SheetPortal, SheetOverlay, SheetHeader, SheetFooter, SheetTitle, SheetDescription.
|
||||
@@ -0,0 +1,118 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
function Sheet({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50",
|
||||
"data-open:animate-in data-open:fade-in-0",
|
||||
"data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 flex flex-col gap-4 bg-background p-6 shadow-lg transition ease-in-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-open:animate-in data-open:slide-in-from-top data-closed:animate-out data-closed:slide-out-to-top",
|
||||
bottom: "inset-x-0 bottom-0 border-t data-open:animate-in data-open:slide-in-from-bottom data-closed:animate-out data-closed:slide-out-to-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-open:animate-in data-open:slide-in-from-left data-closed:animate-out data-closed:slide-out-to-left sm:max-w-sm",
|
||||
right: "inset-y-0 right-0 h-full w-3/4 border-l data-open:animate-in data-open:slide-in-from-right data-closed:animate-out data-closed:slide-out-to-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: { side: "right" },
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends DialogPrimitive.Popup.Props,
|
||||
VariantProps<typeof sheetVariants> {
|
||||
showCloseButton?: boolean
|
||||
}
|
||||
|
||||
function SheetContent({ className, children, side = "right", showCloseButton = true, ...props }: SheetContentProps) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="sheet-close-button"
|
||||
className="absolute top-4 right-4 inline-flex size-7 items-center justify-center rounded-md opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5", className)} {...props} />
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-base font-semibold leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({ className, ...props }: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, sheetVariants }
|
||||
export type { SheetContentProps }
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: simple_select
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "SimpleSelect(props: SimpleSelectProps): JSX.Element"
|
||||
description: "Select simplificado que acepta un array plano o agrupado de opciones. Wrapper sobre Select con API declarativa."
|
||||
tags: [select, dropdown, form, component, ui, simple]
|
||||
uses_functions: [cn_ts_core, select_ts_ui]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [react]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/simple_select.tsx"
|
||||
props:
|
||||
- name: value
|
||||
type: "string"
|
||||
required: true
|
||||
description: "Valor seleccionado actualmente"
|
||||
- name: onValueChange
|
||||
type: "(value: string) => void"
|
||||
required: true
|
||||
description: "Callback cuando cambia la seleccion"
|
||||
- name: options
|
||||
type: "SimpleSelectOption[] | SimpleSelectGroup[]"
|
||||
required: true
|
||||
description: "Opciones planas o agrupadas"
|
||||
- name: placeholder
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Texto cuando no hay seleccion"
|
||||
- name: disabled
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Deshabilita el select"
|
||||
- name: size
|
||||
type: "'sm' | 'default'"
|
||||
required: false
|
||||
description: "Tamano del trigger"
|
||||
- name: className
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Clases CSS adicionales"
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [default, sm]
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
import { SimpleSelect } from '@fn_library'
|
||||
|
||||
// Opciones planas
|
||||
const options = [
|
||||
{ value: 'a', label: 'Opcion A' },
|
||||
{ value: 'b', label: 'Opcion B' },
|
||||
]
|
||||
|
||||
<SimpleSelect value={selected} onValueChange={setSelected} options={options} />
|
||||
|
||||
// Opciones agrupadas
|
||||
const grouped = [
|
||||
{ group: 'Frutas', items: [{ value: 'apple', label: 'Manzana' }] },
|
||||
{ group: 'Verduras', items: [{ value: 'carrot', label: 'Zanahoria' }] },
|
||||
]
|
||||
|
||||
<SimpleSelect value={selected} onValueChange={setSelected} options={grouped} />
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- Detecta automaticamente si las opciones son planas o agrupadas via type guard `isGrouped`.
|
||||
- Wrapper sobre `Select` del registry — toda la logica de Base-UI y accesibilidad viene del componente base.
|
||||
- Soporta items deshabilitados individualmente con `disabled: true` en cada opcion.
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "../core/cn"
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectGroup,
|
||||
SelectGroupLabel,
|
||||
} from "./select"
|
||||
|
||||
export interface SimpleSelectOption {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface SimpleSelectGroup {
|
||||
group: string
|
||||
items: SimpleSelectOption[]
|
||||
}
|
||||
|
||||
export type SimpleSelectOptions = SimpleSelectOption[] | SimpleSelectGroup[]
|
||||
|
||||
interface SimpleSelectProps {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
options: SimpleSelectOptions
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
size?: 'sm' | 'default'
|
||||
className?: string
|
||||
}
|
||||
|
||||
function isGrouped(options: SimpleSelectOptions): options is SimpleSelectGroup[] {
|
||||
return options.length > 0 && 'group' in options[0]
|
||||
}
|
||||
|
||||
function SimpleSelect({
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
placeholder = "Select...",
|
||||
disabled = false,
|
||||
size = 'default',
|
||||
className,
|
||||
}: SimpleSelectProps) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
size === 'sm' && 'h-7 text-xs px-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isGrouped(options)
|
||||
? options.map(g => (
|
||||
<SelectGroup key={g.group}>
|
||||
<SelectGroupLabel>{g.group}</SelectGroupLabel>
|
||||
{g.items.map(item => (
|
||||
<SelectItem key={item.value} value={item.value} disabled={item.disabled}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))
|
||||
: (options as SimpleSelectOption[]).map(item => (
|
||||
<SelectItem key={item.value} value={item.value} disabled={item.disabled}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export { SimpleSelect }
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: switch_toggle
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "SwitchToggle(props: SwitchToggleProps): JSX.Element"
|
||||
description: "Toggle on/off accesible con label opcional a izquierda o derecha. Base-UI Switch primitive."
|
||||
tags: [switch, toggle, component, ui, interactive, form, base-ui]
|
||||
uses_functions: [cn_ts_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["@base-ui/react/switch"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/switch_toggle.tsx"
|
||||
props:
|
||||
- name: label
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Texto de etiqueta visible junto al switch"
|
||||
- name: labelPosition
|
||||
type: "'left' | 'right'"
|
||||
required: false
|
||||
description: "Posicion del label respecto al switch (default: right)"
|
||||
- name: checked
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Estado controlado del toggle"
|
||||
- name: defaultChecked
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Estado inicial no controlado"
|
||||
- name: disabled
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Deshabilita el toggle"
|
||||
- name: onCheckedChange
|
||||
type: "(checked: boolean) => void"
|
||||
required: false
|
||||
description: "Callback cuando cambia el estado"
|
||||
emits: [onCheckedChange]
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: []
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
// Label a la derecha (default)
|
||||
<SwitchToggle label="Notificaciones" defaultChecked />
|
||||
|
||||
// Label a la izquierda
|
||||
<SwitchToggle label="Modo oscuro" labelPosition="left" checked={dark} onCheckedChange={setDark} />
|
||||
|
||||
// Solo switch sin label
|
||||
<SwitchToggle checked={enabled} onCheckedChange={setEnabled} />
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa Base-UI Switch primitive. El thumb se traslada con translate-x via Tailwind. El id se genera con useId si no se provee para conectar el label.
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
interface SwitchToggleProps extends SwitchPrimitive.Root.Props {
|
||||
label?: string
|
||||
labelPosition?: "left" | "right"
|
||||
className?: string
|
||||
}
|
||||
|
||||
function SwitchToggle({ className, label, labelPosition = "right", id, ...props }: SwitchToggleProps) {
|
||||
const internalId = React.useId()
|
||||
const switchId = id ?? internalId
|
||||
|
||||
const switchEl = (
|
||||
<SwitchPrimitive.Root
|
||||
id={switchId}
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors outline-none",
|
||||
"bg-input focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
"data-checked:bg-primary",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block size-4 rounded-full bg-background shadow-sm ring-0 transition-transform",
|
||||
"translate-x-0 data-checked:translate-x-4"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
|
||||
if (!label) return switchEl
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{labelPosition === "left" && (
|
||||
<label
|
||||
htmlFor={switchId}
|
||||
data-slot="switch-label"
|
||||
className="text-sm font-medium leading-none cursor-pointer select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{switchEl}
|
||||
{labelPosition === "right" && (
|
||||
<label
|
||||
htmlFor={switchId}
|
||||
data-slot="switch-label"
|
||||
className="text-sm font-medium leading-none cursor-pointer select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { SwitchToggle }
|
||||
export type { SwitchToggleProps }
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: textarea
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Textarea(props: TextareaProps): JSX.Element"
|
||||
description: "Input multilinea accesible con auto-resize opcional. Patron identico a Input para consistencia de estilos."
|
||||
tags: [textarea, component, ui, interactive, form]
|
||||
uses_functions: [cn_ts_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/textarea.tsx"
|
||||
props:
|
||||
- name: autoResize
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Ajusta la altura automaticamente al contenido (default: false)"
|
||||
- name: placeholder
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Texto placeholder"
|
||||
- name: disabled
|
||||
type: "boolean"
|
||||
required: false
|
||||
description: "Deshabilita el textarea"
|
||||
- name: rows
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Numero de filas visibles iniciales"
|
||||
- name: className
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Clases CSS adicionales"
|
||||
emits: [onChange, onFocus, onBlur]
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: []
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
// Basico
|
||||
<Textarea placeholder="Escribe aqui..." rows={4} />
|
||||
|
||||
// Con auto-resize
|
||||
<Textarea autoResize placeholder="Crece automaticamente..." />
|
||||
|
||||
// Controlado
|
||||
<Textarea value={text} onChange={(e) => setText(e.target.value)} />
|
||||
|
||||
// Con validacion
|
||||
<Textarea aria-invalid={!!error} />
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa forwardRef para compatibilidad con form libraries. El auto-resize ajusta style.height en cada cambio — por eso requiere has_state: true. Aplica las mismas clases de foco y validacion que Input para consistencia visual.
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
interface TextareaProps extends React.ComponentPropsWithoutRef<"textarea"> {
|
||||
autoResize?: boolean
|
||||
}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, autoResize = false, onChange, ...props }, ref) => {
|
||||
const internalRef = React.useRef<HTMLTextAreaElement>(null)
|
||||
const resolvedRef = (ref as React.RefObject<HTMLTextAreaElement>) ?? internalRef
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (autoResize && resolvedRef.current) {
|
||||
resolvedRef.current.style.height = "auto"
|
||||
resolvedRef.current.style.height = `${resolvedRef.current.scrollHeight}px`
|
||||
}
|
||||
onChange?.(e)
|
||||
},
|
||||
[autoResize, onChange, resolvedRef]
|
||||
)
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={resolvedRef}
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"min-h-[80px] w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm transition-colors outline-none",
|
||||
"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",
|
||||
"dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
autoResize && "resize-none overflow-hidden",
|
||||
className
|
||||
)}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
export type { TextareaProps }
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: toast
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Toast(props: ToastProps): JSX.Element"
|
||||
description: "Notificaciones temporales con variantes semanticas (success, error, warning, info), iconos automaticos, auto-dismiss y provider con hook useToast."
|
||||
tags: [toast, notification, alert, component, ui, interactive, cva]
|
||||
uses_functions: [cn_ts_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["class-variance-authority", "lucide-react"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/toast.tsx"
|
||||
props:
|
||||
- name: variant
|
||||
type: "'default' | 'success' | 'error' | 'warning' | 'info'"
|
||||
required: false
|
||||
description: "Variante semantica con icono automatico (default: default)"
|
||||
- name: title
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Titulo de la notificacion"
|
||||
- name: description
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Texto descriptivo secundario"
|
||||
- name: action
|
||||
type: "React.ReactNode"
|
||||
required: false
|
||||
description: "Accion opcional (boton, link) debajo del contenido"
|
||||
- name: onClose
|
||||
type: "() => void"
|
||||
required: false
|
||||
description: "Callback al cerrar. Muestra el boton X si se provee."
|
||||
- name: duration
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Duracion en ms antes del auto-dismiss (default: 5000, 0 = persistente)"
|
||||
emits: [onClose]
|
||||
has_state: true
|
||||
framework: react
|
||||
variant: [default, success, error, warning, info]
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
// 1. Envolver la app con el provider
|
||||
<ToastProvider position="bottom-right">
|
||||
<App />
|
||||
</ToastProvider>
|
||||
|
||||
// 2. Usar el hook en cualquier componente
|
||||
function MyComponent() {
|
||||
const { toast } = useToast()
|
||||
|
||||
return (
|
||||
<Button onClick={() => toast({
|
||||
variant: "success",
|
||||
title: "Guardado",
|
||||
description: "Los cambios se guardaron correctamente.",
|
||||
})}>
|
||||
Guardar
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// Toast con accion
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Error al guardar",
|
||||
description: "Intenta de nuevo.",
|
||||
action: <Button size="sm" variant="outline">Reintentar</Button>,
|
||||
duration: 0, // persistente hasta cerrar manualmente
|
||||
})
|
||||
|
||||
// Toast individual sin provider
|
||||
<Toast variant="info" title="Informacion" description="Texto descriptivo" onClose={() => {}} />
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Arquitectura: Toast (componente visual puro), ToastViewport (contenedor posicionado fixed), ToastProvider (context + logica de estado), useToast (hook consumidor). Los iconos son automaticos segun variante: CheckCircle2 (success), AlertCircle (error), AlertTriangle (warning), Info (info). El border-l-4 diferencia visualmente cada variante. ToastProvider acepta position con 6 posiciones predefinidas.
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { XIcon, CheckCircle2Icon, AlertCircleIcon, AlertTriangleIcon, InfoIcon } from "lucide-react"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
const toastVariants = cva(
|
||||
"group/toast pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden rounded-xl border p-4 pr-8 shadow-lg transition-all",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground border-border",
|
||||
success: "bg-background border-l-4 border-l-green-500 border-border text-foreground",
|
||||
error: "bg-background border-l-4 border-l-destructive border-border text-foreground",
|
||||
warning: "bg-background border-l-4 border-l-yellow-500 border-border text-foreground",
|
||||
info: "bg-background border-l-4 border-l-blue-500 border-border text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
}
|
||||
)
|
||||
|
||||
const variantIcons: Record<string, React.ReactNode> = {
|
||||
success: <CheckCircle2Icon className="mt-0.5 size-4 shrink-0 text-green-500" />,
|
||||
error: <AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />,
|
||||
warning: <AlertTriangleIcon className="mt-0.5 size-4 shrink-0 text-yellow-500" />,
|
||||
info: <InfoIcon className="mt-0.5 size-4 shrink-0 text-blue-500" />,
|
||||
}
|
||||
|
||||
interface ToastProps
|
||||
extends React.ComponentPropsWithoutRef<"div">,
|
||||
VariantProps<typeof toastVariants> {
|
||||
title?: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const Toast = React.forwardRef<HTMLDivElement, ToastProps>(
|
||||
({ className, variant = "default", title, description, action, onClose, children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="toast"
|
||||
data-variant={variant}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
{variant && variant !== "default" && variantIcons[variant]}
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
{title && (
|
||||
<div data-slot="toast-title" className="text-sm font-semibold leading-none">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div data-slot="toast-description" className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
{action && <div data-slot="toast-action" className="mt-2">{action}</div>}
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
data-slot="toast-close"
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 inline-flex size-5 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Toast.displayName = "Toast"
|
||||
|
||||
interface ToastViewportProps extends React.ComponentPropsWithoutRef<"div"> {
|
||||
position?: "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"
|
||||
}
|
||||
|
||||
const positionClasses: Record<NonNullable<ToastViewportProps["position"]>, string> = {
|
||||
"top-left": "top-4 left-4",
|
||||
"top-center": "top-4 left-1/2 -translate-x-1/2",
|
||||
"top-right": "top-4 right-4",
|
||||
"bottom-left": "bottom-4 left-4",
|
||||
"bottom-center": "bottom-4 left-1/2 -translate-x-1/2",
|
||||
"bottom-right": "bottom-4 right-4",
|
||||
}
|
||||
|
||||
function ToastViewport({ className, position = "bottom-right", ...props }: ToastViewportProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="toast-viewport"
|
||||
className={cn(
|
||||
"fixed z-[100] flex max-h-screen w-full max-w-sm flex-col gap-2 p-4",
|
||||
positionClasses[position],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ToastEntry = ToastProps & {
|
||||
id: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface ToastProviderContextValue {
|
||||
toasts: ToastEntry[]
|
||||
toast: (props: Omit<ToastEntry, "id">) => string
|
||||
dismiss: (id: string) => void
|
||||
dismissAll: () => void
|
||||
}
|
||||
|
||||
const ToastContext = React.createContext<ToastProviderContextValue | null>(null)
|
||||
|
||||
function ToastProvider({ children, position = "bottom-right" }: { children: React.ReactNode; position?: ToastViewportProps["position"] }) {
|
||||
const [toasts, setToasts] = React.useState<ToastEntry[]>([])
|
||||
|
||||
const dismiss = React.useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const dismissAll = React.useCallback(() => {
|
||||
setToasts([])
|
||||
}, [])
|
||||
|
||||
const toast = React.useCallback(
|
||||
(props: Omit<ToastEntry, "id">) => {
|
||||
const id = Math.random().toString(36).slice(2)
|
||||
const duration = props.duration ?? 5000
|
||||
setToasts((prev) => [...prev, { ...props, id }])
|
||||
if (duration > 0) {
|
||||
setTimeout(() => dismiss(id), duration)
|
||||
}
|
||||
return id
|
||||
},
|
||||
[dismiss]
|
||||
)
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, toast, dismiss, dismissAll }}>
|
||||
{children}
|
||||
<ToastViewport position={position}>
|
||||
{toasts.map((t) => {
|
||||
const { id, duration: _duration, ...rest } = t
|
||||
return (
|
||||
<Toast key={id} {...rest} onClose={() => dismiss(id)} />
|
||||
)
|
||||
})}
|
||||
</ToastViewport>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const ctx = React.useContext(ToastContext)
|
||||
if (!ctx) throw new Error("useToast must be used within ToastProvider")
|
||||
return ctx
|
||||
}
|
||||
|
||||
export { Toast, ToastProvider, ToastViewport, toastVariants, useToast }
|
||||
export type { ToastEntry, ToastProps, ToastViewportProps }
|
||||
Reference in New Issue
Block a user