Ir al contenido

Botones y acciones

Los cinco componentes con los que el usuario dispara acciones: el botón suelto, los dos menús, el selector de opción única y la barra de acciones masivas.

El botón de toda la vida. Extiende React.ButtonHTMLAttributes<HTMLButtonElement>, así que onClick, type, form, aria-* y demás pasan directos al <button>.

import { Button } from '@openfactu/ui';
<Button onClick={guardar}>Guardar</Button>

Seis, y ninguna usa variantes dark:: los tokens ya cambian de valor según el modo, de forma que el botón sigue al tema del tenant sea cual sea.

<Button variant="primary">Primary</Button>
<Button variant="accent">Accent</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="danger">Danger</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="outline">Outline</Button>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
<Button isLoading>Guardando…</Button>
<Button disabled>Deshabilitado</Button>

isLoading pinta un spinner delante del texto y deshabilita el botón, así que no hace falta combinarlo con disabled para evitar el doble envío.

No hay prop icon: el icono va como hijo. El botón ya es un inline-flex con gap-1.5, así que se alinea solo.

import { Plus, Trash2 } from 'lucide-react';
<Button>
<Plus className="h-3.5 w-3.5" />
Nueva factura
</Button>
<Button variant="danger">
<Trash2 className="h-3.5 w-3.5" />
Eliminar
</Button>
Prop Tipo Por defecto Descripción
variant 'primary' | 'accent' | 'secondary' | 'danger' | 'ghost' | 'outline' 'primary' Estilo del botón
size 'sm' | 'md' | 'lg' 'md' Tamaño
isLoading boolean Muestra un spinner y deshabilita el botón
…resto ButtonHTMLAttributes onClick, type, disabled, className

Menú desplegable anclado a un disparador. El disparador es cualquier nodo que le pases como hijo; el menú se renderiza en un portal a document.body, así que no lo recorta ningún overflow.

import { DropdownMenu, Button } from '@openfactu/ui';
import { Pencil, Copy, Download, Trash2 } from 'lucide-react';
const acciones = [
{ label: 'Editar', icon: <Pencil className="h-3.5 w-3.5" />, onClick: editar },
{ label: 'Duplicar', icon: <Copy className="h-3.5 w-3.5" />, onClick: duplicar },
{ label: 'Exportar PDF', icon: <Download className="h-3.5 w-3.5" />, disabled: true },
{
label: 'Eliminar',
icon: <Trash2 className="h-3.5 w-3.5" />,
destructive: true,
separatorBefore: true,
onClick: eliminar,
},
];
<DropdownMenu items={acciones}>
<Button variant="secondary">Acciones</Button>
</DropdownMenu>

destructive pinta el ítem en rojo y separatorBefore le mete una línea encima, que es como se separa «Eliminar» del resto sin declarar separadores sueltos.

import { MoreHorizontal } from 'lucide-react';
<DropdownMenu items={acciones}>
<button
type="button"
className="p-1.5 rounded-[2px] text-[var(--fg-muted)] hover:text-accent transition-colors"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenu>
<DropdownMenu items={acciones} align="end">
<Button variant="secondary">Alineado a la derecha</Button>
</DropdownMenu>

El disparador es tabulable. Con el menú cerrado, Enter, Espacio o lo abren; abierto, / recorren los ítems saltándose los deshabilitados, Enter o Espacio activan el que esté marcado y Escape cierra. También se cierra al hacer scroll.

Prop Tipo Por defecto Descripción
items DropdownMenuItem[] Ítems del menú
children ReactNode Disparador; se envuelve en un span clicable
align 'start' | 'end' 'start' Alineación del menú respecto al disparador
disabled boolean false Desactiva el disparador
className string Clases del disparador
menuClassName string Clases del panel del menú

El menú tiene un ancho mínimo de 180 px.

DropdownMenuItem es el shape compartido por los tres menús de la librería: el desplegable, el contextual y las rowActions de Table.

Prop Tipo Por defecto Descripción
label string Texto del ítem
icon ReactNode Icono a la izquierda
onClick () => void Acción al activarlo
destructive boolean Lo pinta en rojo (--k-danger-fg)
disabled boolean Lo desactiva y lo salta al navegar con el teclado
separatorBefore boolean Dibuja una línea encima del ítem

El menú del click derecho. Se posiciona en las coordenadas del cursor, se recorta contra el borde de la ventana para no salirse, y se cierra con click fuera, scroll, resize o Escape.

Casi siempre se usa a través de useContextMenu, que se encarga del estado:

import { useContextMenu } from '@openfactu/ui';
import { Pencil, Copy, FolderInput, Trash2 } from 'lucide-react';
function ListaDeFacturas() {
const { contextMenu, openContextMenu } = useContextMenu();
return (
<>
<div
onContextMenu={(e) =>
openContextMenu(e, [
{ label: 'Editar', icon: <Pencil className="h-3.5 w-3.5" /> },
{ label: 'Duplicar', icon: <Copy className="h-3.5 w-3.5" /> },
{
label: 'Mover a…',
icon: <FolderInput className="h-3.5 w-3.5" />,
submenu: [
{ label: 'Borradores' },
{ label: 'Archivadas' },
{ label: 'Papelera', destructive: true },
],
},
{
label: 'Eliminar',
icon: <Trash2 className="h-3.5 w-3.5" />,
destructive: true,
separatorBefore: true,
},
])
}
>
Click derecho aquí
</div>
{contextMenu}
</>
);
}

Un ítem con submenu abre un segundo panel al pasar el ratón. Solo un nivel: si declaras submenu, se ignora su onClick.

Prop Tipo Por defecto Descripción
x number Coordenada horizontal del cursor
y number Coordenada vertical del cursor
items ContextMenuItem[] Ítems del menú
onClose () => void Se llama al cerrar

ContextMenuItem es un ítem de menú más submenu?: ContextMenuItem[]. El menú mide 220 px de ancho.

Devuelve Tipo Descripción
contextMenu ReactNode Renderizar siempre; es null con el menú cerrado
openContextMenu (event: React.MouseEvent, items: ContextMenuItem[]) => void Pasar a onContextMenu; hace preventDefault y abre en el cursor
closeContextMenu () => void Cierra el menú a mano

Selector de opción única con las opciones pegadas en una sola pieza. Sirve para filtrar o parametrizar lo que se está viendo: el periodo de un panel, la escala de un calendario, la capa de un mapa.

import { SegmentedControl } from '@openfactu/ui';
const [ambito, setAmbito] = React.useState<'active' | 'all'>('active');
<SegmentedControl
size="sm"
uppercase
aria-label="Ámbito del periodo"
value={ambito}
onChange={setAmbito}
options={[
{ value: 'active', label: 'Periodo activo' },
{ value: 'all', label: 'Histórico' },
]}
/>
{/* solid — la opción activa se rellena con el acento */}
<SegmentedControl
aria-label="Escala del calendario"
value={escala}
onChange={setEscala}
options={[
{ value: 'week', label: 'Semana' },
{ value: 'month', label: 'Mes' },
{ value: 'quarter', label: 'Trimestre', disabled: true },
]}
/>
{/* raised — pastilla elevada sobre una pista, sin color de marca */}
<SegmentedControl
variant="raised"
aria-label="Modo del editor"
value={modo}
onChange={setModo}
options={[
{ value: 'visual', label: 'Visual', icon: <LayoutGrid className="h-3.5 w-3.5" /> },
{ value: 'code', label: 'Avanzado', icon: <ListIcon className="h-3.5 w-3.5" /> },
]}
/>
<SegmentedControl
fullWidth
aria-label="Vista"
value={vista}
onChange={setVista}
options={[
{ value: 'list', label: 'Lista', icon: <ListIcon className="h-3.5 w-3.5" /> },
{ value: 'grid', label: 'Tarjetas', icon: <LayoutGrid className="h-3.5 w-3.5" /> },
{ value: 'cal', label: 'Calendario', icon: <Calendar className="h-3.5 w-3.5" /> },
]}
/>

Hay un solo punto de tabulación en todo el grupo, como manda el patrón de grupo de radio: dentro se navega con ///, que además cambian el valor, y con Inicio/Fin para ir a los extremos.

Prop Tipo Por defecto Descripción
options SegmentedOption<T>[] Opciones
value T Opción activa
onChange (value: T) => void Cambio de opción
size 'sm' | 'md' 'md' Tamaño
variant 'solid' | 'raised' 'solid' solid rellena con el acento; raised es una pastilla elevada
uppercase boolean false Versales con tracking, para barras compactas
fullWidth boolean false Reparte las opciones por todo el ancho
disabled boolean false Desactiva el grupo entero
aria-label string Nombre accesible del grupo
className string Clases del contenedor
Prop Tipo Descripción
value T Valor de la opción
label ReactNode Etiqueta
icon ReactNode Icono a la izquierda
disabled boolean Desactiva solo esa opción
title string Nombre accesible cuando la opción es solo un icono

La barra que aparece encima de una Table cuando hay filas seleccionadas: el conteo, un enlace para deseleccionar y un hueco a la derecha para las acciones.

import { BulkActionsBar, Button } from '@openfactu/ui';
<BulkActionsBar count={seleccionadas.size} onClear={() => setSeleccionadas(new Set())}>
<Button variant="secondary" onClick={exportar}>Exportar</Button>
<Button variant="danger" onClick={eliminar}>Eliminar</Button>
</BulkActionsBar>

Es sticky arriba, de modo que sigue visible mientras se recorre una tabla larga.

<BulkActionsBar count={3} label="líneas seleccionadas" onClear={limpiar}>
<Button onClick={agrupar}>Agrupar en un albarán</Button>
</BulkActionsBar>
Prop Tipo Por defecto Descripción
count number Filas seleccionadas. Con 0 no renderiza nada
onClear () => void Limpia la selección. Sin él no se pinta el enlace «Deseleccionar»
children ReactNode Acciones, alineadas a la derecha
label string 'seleccionados' Texto tras el número
className string Clases del contenedor