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:
2026-04-03 03:23:32 +02:00
parent 619a56c567
commit 35bcb63300
29 changed files with 2537 additions and 0 deletions
+53
View File
@@ -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.
+81
View File
@@ -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 }
+70
View File
@@ -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.
+69
View File
@@ -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 }
+71
View File
@@ -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.
+97
View File
@@ -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 }
+72
View File
@@ -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.
+79
View File
@@ -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 }
+81
View File
@@ -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.
+204
View File
@@ -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 }
+73
View File
@@ -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.
+201
View File
@@ -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,
}
+121
View File
@@ -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'
+61
View File
@@ -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.
+100
View File
@@ -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 }
+65
View File
@@ -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.
+57
View File
@@ -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 }
+60
View File
@@ -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.
+61
View File
@@ -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 }
+71
View File
@@ -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.
+118
View File
@@ -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 }
+82
View File
@@ -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.
+84
View File
@@ -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 }
+67
View File
@@ -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.
+66
View File
@@ -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 }
+66
View File
@@ -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.
+47
View File
@@ -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 }
+90
View File
@@ -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.
+170
View File
@@ -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 }