sábado, 28 de febrero de 2026

Dialog de shadcn/ui conteniendo un button

 Tengo un button de HTML como JSX dentro del contenido de un Dialog de shadcn/ui. Quiero que cada vez que hago click en el button se actualice un estado del contenedor y que el Dialog no se cierre. Pero en cambio, cada vez que hago click en el button el Dialog se cierra.


Entiendo perfectamente el problema. El comportamiento que experimentas se debe generalmente a que, dentro de los componentes de diálogo (como Radix UI, que usa Shadcn), ciertos elementos como botones dentro de un DialogFooter o el uso accidental de DialogClose provocan el cierre automático. Además, si el botón está dentro de un formulario, un evento de submit no controlado también podría interferir.

Para solucionar esto, debemos asegurar que el botón tenga el atributo type="button" explícito y que usemos e.preventDefault() y e.stopPropagation() para evitar que el evento "burbujee" hacia los controladores de cierre del Dialog.

Voy a generarte un ejemplo funcional que demuestra cómo mantener el estado y el diálogo abiertos simultáneamente.

Dialog de Shadcn con Estado Persistente
Feb 28, 8:09 PM

import React, { useState, useEffect } from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X, Plus, RotateCcw } from "lucide-react";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

// --- Utilidades de Estilo ---
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

// --- Componentes de Dialog (Versión Simplificada de Shadcn) ---
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;

const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));

const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-slate-800 bg-slate-900 p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground text-slate-400">
<X className="h-4 w-4" />
<span className="sr-only">Cerrar</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));

// --- Aplicación Principal ---
export default function App() {
const [contador, setContador] = useState(0);

const incrementarContador = (e) => {
// IMPORTANTE: Prevenir que el evento se propague a componentes padres
// que podrían estar escuchando clics para cerrar el diálogo.
e.preventDefault();
e.stopPropagation();
setContador((prev) => prev + 1);
};

const reiniciarContador = (e) => {
e.preventDefault();
e.stopPropagation();
setContador(0);
};

return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-950 text-white font-sans p-4">
<div className="text-center space-y-4 max-w-md">
<h1 className="text-3xl font-bold tracking-tight">Solución de Persistencia</h1>
<p className="text-slate-400">
Haz clic en el botón de abajo para abrir el diálogo y probar el incremento del contador sin que se cierre la ventana.
</p>
<Dialog>
<DialogTrigger asChild>
<button className="px-6 py-3 bg-indigo-600 hover:bg-indigo-500 rounded-lg font-medium shadow-lg transition-all active:scale-95">
Abrir Panel de Control
</button>
</DialogTrigger>

<DialogContent>
<div className="space-y-4">
<div className="flex flex-col space-y-1.5 text-center sm:text-left">
<h2 className="text-lg font-semibold leading-none tracking-tight text-white">
Contador Interno
</h2>
<p className="text-sm text-slate-400">
Este botón actualiza el estado local del contenedor.
</p>
</div>

<div className="flex flex-col items-center justify-center p-8 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-5xl font-mono font-bold text-indigo-400">
{contador}
</span>
<span className="text-xs uppercase tracking-widest text-slate-500 mt-2">
Clicks registrados
</span>
</div>

<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-slate-800">
{/* REGLA DE ORO:
1. type="button" para evitar comportamiento de submit.
2. onClick con stopPropagation.
*/}
<button
type="button"
onClick={incrementarContador}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-md text-sm font-medium transition-colors"
>
<Plus className="h-4 w-4" />
Incrementar
</button>

<button
type="button"
onClick={reiniciarContador}
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-md text-sm font-medium transition-colors"
>
<RotateCcw className="h-4 w-4" />
Reset
</button>
</div>

<p className="text-[10px] text-slate-500 italic text-center">
El diálogo solo se cerrará si haces clic en la 'X' o fuera del recuadro.
</p>
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
}


Puntos clave de la solución:

  1. Atributo type="button": Por defecto, los botones dentro de ciertos contextos (como formularios o bibliotecas de UI que inyectan formularios invisibles) pueden actuar como submit. Al especificar type="button", eliminamos cualquier comportamiento predeterminado de envío.

  2. e.stopPropagation(): Este es el paso más importante. Radix UI (la base de Shadcn) a veces escucha eventos de clic en niveles superiores para manejar cierres. Detener la propagación evita que el Dialog "se entere" de que hiciste clic en un botón que no es de cierre.

  3. Evitar el DialogClose: Asegúrate de que tu botón no esté envuelto por un componente <DialogClose />, ya que este componente inyecta automáticamente la lógica de cierre al hacer clic en su hijo.

He aplicado estas correcciones en el archivo generado para que puedas probar la interacción directamente. Sugerencias adicionales: revisa si el botón no está dentro de un elemento <a> o un DialogFooter que tenga comportamientos de cierre inyectados por CSS o JS.

Glifo de Checkbox para estado=indeterminate

Quiero que el glyph para el estado indeterminate de un Checkbox de shadcn/ui con Radix UI sea una raya horizontal.


Para lograr que el checkbox de shadcn/ui (que utiliza Radix UI) muestre una raya horizontal en el estado indeterminate, debemos modificar el componente para que detecte dicho estado y renderice un icono diferente al de verificación (Check).

He preparado una versión completa y funcional del componente Checkbox junto con un ejemplo de uso para que puedas probarlo directamente.

Checkbox de Shadcn con Estado Indeterminado
Feb 28, 1:45 PM

import React, { useState, useEffect } from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check, Minus } from "lucide-react"
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

// Utilidad para manejar clases de Tailwind
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

/**
* Componente Checkbox modificado para soportar el glifo de raya horizontal
* en estado 'indeterminate'.
*/
const Checkbox = React.forwardRef(({ className, checked, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=indeterminate]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:text-primary-foreground",
className
)}
checked={checked}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
{checked === "indeterminate" ? (
<Minus className="h-3.5 w-3.5 stroke-[3]" />
) : (
<Check className="h-3.5 w-3.5 stroke-[3]" />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName

// --- Ejemplo de Implementación ---

const App = () => {
// El estado puede ser true, false o "indeterminate"
const [status, setStatus] = useState("indeterminate")

const toggleStatus = () => {
if (status === true) setStatus(false)
else if (status === false) setStatus("indeterminate")
else setStatus(true)
}

return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-950 text-white p-6">
<div className="bg-slate-900 p-10 rounded-xl border border-slate-800 shadow-xl space-y-6 w-full max-w-md">
<h2 className="text-xl font-semibold text-center mb-4">Estado del Checkbox</h2>
<div className="flex items-center space-x-3 p-4 bg-slate-800/50 rounded-lg border border-slate-700">
<Checkbox
id="terms"
checked={status}
onCheckedChange={(val) => setStatus(val)}
/>
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Aceptar términos y condiciones
</label>
</div>

<div className="grid grid-cols-1 gap-2">
<button
onClick={toggleStatus}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-md text-sm transition-colors"
>
Alternar estado (Ciclo: Indet {'->'} On {'->'} Off)
</button>
<div className="text-center mt-4">
<span className="text-xs text-slate-400 uppercase tracking-widest">Valor actual:</span>
<code className="block mt-1 text-blue-400 font-mono text-lg">
{JSON.stringify(status)}
</code>
</div>
</div>

<div className="pt-4 border-t border-slate-700">
<p className="text-xs text-slate-500 text-center">
Nota: Se usa el icono <strong>Minus</strong> de Lucide cuando el valor es <code className="text-slate-300">"indeterminate"</code>.
</p>
</div>
</div>
</div>
)
}

export default App


He realizado los siguientes cambios clave para que funcione:

  1. Icono Condicional: Dentro de CheckboxPrimitive.Indicator, he añadido una lógica que verifica si checked === "indeterminate". Si es así, renderiza el icono Minus (la raya horizontal); de lo contrario, renderiza el Check.

  2. Estilos de Datos: He añadido data-[state=indeterminate]:bg-primary en las clases de Tailwind del Root. Radix UI asigna automáticamente este atributo data-state cuando el prop checked es exactamente el string "indeterminate".

  3. Grosor del Trazo: He usado stroke-[3] en el componente Minus para que visualmente tenga un peso similar al icono de verificación por defecto de Radix.

¿Deseas que ajustemos también el comportamiento de los checkboxes "hijos" para que actualicen automáticamente al padre?