Abres un modal con un campo de selección (un combobox hecho con Popover), despliegas una lista larga y, al arrastrarla con el dedo en el móvil, no se desplaza. Ese mismo combobox fuera de un modal funciona bien. Este es el esquema habitual con componentes de shadcn/ui, que envuelven las primitivas de Radix UI:
<Dialog>
<DialogContent>
<Popover>
<PopoverTrigger>Elegir…</PopoverTrigger>
<PopoverContent>
{/* lista larga con overflow-y-auto */}
</PopoverContent>
</Popover>
</DialogContent>
</Dialog>
No hay ningún mensaje de error: la lista simplemente no responde al gesto. El artículo explica la causa, cómo comprobarla y cómo resolverla pasando el elemento del diálogo como container del Portal. También indica en qué versiones de Radix se reprodujo y en cuáles el problema ya no aparece.
Por qué pasa
Un Dialog de Radix es modal por defecto (el prop modal vale true). Para impedir que la página de fondo se desplace, su capa (Overlay) usa la librería react-remove-scroll. Según su documentación, esta librería desactiva el scroll fuera del nodo que envuelve y admite elementos desplazables anidados; para ello intercepta los eventos de rueda y táctiles con listeners no pasivos. En el código de @radix-ui/react-dialog 1.1.15 se le indica que el contenido del diálogo (DialogContent) es la zona permitida mediante la propiedad shards.
Un Popover no modal (por defecto, modal es false) y su Portal renderizan el contenido en document.body, que es el valor por defecto de container. Por tanto, la lista del popover queda fuera del subárbol DOM de DialogContent: para el bloqueo de scroll es "exterior" y sus gestos se cancelan.
En una prueba con emulación táctil en Chromium 155 (y también con la rueda del ratón), con @radix-ui/react-dialog y @radix-ui/react-popover en la versión 1.1.15, una lista de 40 elementos dentro del popover no se desplazó al arrastrarla: la posición de scroll siguió en 0. Al renderizar el popover dentro de DialogContent mediante container, la misma lista se desplazó con el gesto y con la rueda.
Dos observaciones más de esa prueba:
- Depende de la versión. El código de las versiones recientes de
react-dialogyreact-popoverregistra las capas anidadas como zonas permitidas del bloqueo (el código remite a la incidencia 3423 del repositorio de Radix). Sincontainer, la lista del popover se desplazó con las versiones 1.1.22, 1.2.0 y 1.3.0, y no lo hizo con 1.1.15, 1.1.21 y 1.1.23. Concontainerse desplazó en todas. Selectno falló en la prueba. La lista de unSelectdentro delDialogse desplazó sincontaineren las dos versiones comprobadas (react-select 2.2.6 y 2.4.0). El contenido delSelectinstala su propio bloqueo de scroll mientras está abierto, lo que es coherente con ese resultado. Si tu caso falla con unSelect, la solución de abajo también le aplica, aunque en esta prueba no se reprodujo el fallo con ese componente.
Solución
-
Comprueba tu versión y dónde se renderiza el popover. Revisa las versiones instaladas con
npm ls @radix-ui/react-dialog @radix-ui/react-popover. Después abre el popover y, en la consola:const envoltorio = document.querySelector("[data-radix-popper-content-wrapper]") console.log("renderizado en body:", envoltorio.parentElement === document.body)Si imprime
true, el popover está fuera del diálogo y puede verse afectado. -
Expón el elemento
DialogContentmediante un contexto. Guarda el nodo en un estado (no en unuseRef, para que los hijos se vuelvan a renderizar cuando el nodo exista) y compártelo por contexto. Esta es una versión delDialogContentde shadcn/ui, simplificada:import * as React from "react" import * as DialogPrimitive from "@radix-ui/react-dialog" const DialogContainerContext = React.createContext<HTMLElement | null>(null) /** Elemento DOM del DialogContent más cercano; null fuera de un diálogo. */ export function useDialogContainer() { return React.useContext(DialogContainerContext) } export const DialogContent = React.forwardRef< React.ComponentRef<typeof DialogPrimitive.Content>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> >(({ children, ...props }, forwardedRef) => { const [container, setContainer] = React.useState<HTMLElement | null>(null) const setRefs = React.useCallback( (node: HTMLDivElement | null) => { setContainer(node) if (typeof forwardedRef === "function") forwardedRef(node) else if (forwardedRef) forwardedRef.current = node }, [forwardedRef], ) return ( <DialogPrimitive.Portal> <DialogPrimitive.Overlay /> <DialogPrimitive.Content ref={setRefs} {...props}> <DialogContainerContext.Provider value={container}> {children} </DialogContainerContext.Provider> </DialogPrimitive.Content> </DialogPrimitive.Portal> ) }) DialogContent.displayName = "DialogContent"Este patrón sigue el ejemplo de contenedor personalizado de la documentación de Radix, que guarda el elemento con
useStatey lo pasa acontainer. -
Pasa el contenedor a los
Portal. En tuPopoverContent(y, si quieres, enSelectContent), en el mismo archivo o importandouseDialogContainer:import * as PopoverPrimitive from "@radix-ui/react-popover" import * as SelectPrimitive from "@radix-ui/react-select" export const PopoverContent = React.forwardRef< React.ComponentRef<typeof PopoverPrimitive.Content>, React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> >(({ sideOffset = 4, ...props }, ref) => { const container = useDialogContainer() return ( // Con container = null, Radix usa document.body (comportamiento por defecto). <PopoverPrimitive.Portal container={container}> <PopoverPrimitive.Content ref={ref} sideOffset={sideOffset} {...props} /> </PopoverPrimitive.Portal> ) }) PopoverContent.displayName = "PopoverContent" export const SelectContent = React.forwardRef< React.ComponentRef<typeof SelectPrimitive.Content>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> >(({ children, position = "popper", ...props }, ref) => { const container = useDialogContainer() return ( <SelectPrimitive.Portal container={container}> <SelectPrimitive.Content ref={ref} position={position} {...props}> <SelectPrimitive.Viewport>{children}</SelectPrimitive.Viewport> </SelectPrimitive.Content> </SelectPrimitive.Portal> ) }) SelectContent.displayName = "SelectContent"Fuera de un diálogo el contexto vale
nully elPortalusadocument.body, como antes: el mismo componente sirve dentro y fuera de modales. La posición no cambia: en la prueba, el popover quedó a la misma distancia del botón con y sincontainer, aunque el diálogo estaba centrado con una transformación CSS. -
Aplica lo mismo a los demás componentes con
Portalque uses dentro de modales.AlertDialogestá construido sobreDialog(en su código,AlertDialogusaDialog.Rootconmodalentrue), así que sus contenidos tienen el mismo bloqueo. Otros componentes con portal, como menús desplegables o tarjetas flotantes, no se probaron aquí.
Cómo verificar que quedó resuelto
- Repite el script del paso 1 con el popover abierto dentro de un diálogo:
renderizado en bodydebe serfalse. - Prueba en un dispositivo real o con la emulación táctil de las herramientas de desarrollo: arrastra la lista abierta y comprueba que se desplaza y que el fondo del diálogo no se mueve.
- Añade una prueba automática de contención. jsdom no implementa el scroll ni el layout, así que no puede comprobar el gesto, pero sí que el contenido del popover está dentro del diálogo. Usa
data-testid: el contenido delPopovertambién tienerole="dialog", de modo que con ambos abiertosgetByRole("dialog")falla por encontrar dos elementos.
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { expect, it } from "vitest"
import { DialogContent, PopoverContent } from "./dialog-container"
it("renderiza el popover dentro del DialogContent", async () => {
const user = userEvent.setup()
render(
<DialogPrimitive.Root defaultOpen>
<DialogContent data-testid="dialog-content">
<DialogPrimitive.Title>Título</DialogPrimitive.Title>
<DialogPrimitive.Description>Descripción</DialogPrimitive.Description>
<PopoverPrimitive.Root>
<PopoverPrimitive.Trigger>Abrir lista</PopoverPrimitive.Trigger>
<PopoverContent data-testid="popover-content">Opciones</PopoverContent>
</PopoverPrimitive.Root>
</DialogContent>
</DialogPrimitive.Root>,
)
await user.click(screen.getByRole("button", { name: "Abrir lista" }))
const dialog = screen.getByTestId("dialog-content")
const popover = await screen.findByTestId("popover-content")
expect(dialog.contains(popover)).toBe(true)
})
Si no activas globals: true en Vitest, Testing Library no limpia el DOM entre pruebas por sí sola: llama a cleanup en un afterEach del archivo de configuración, o la segunda prueba encontrará elementos duplicados. En esta prueba bastó con el entorno jsdom; si tu configuración falla por una API del navegador que jsdom no implementa (por ejemplo ResizeObserver), define un stub en ese mismo archivo.
Variantes y errores relacionados
El popover queda recortado por el diálogo
Dentro del DialogContent, el popover se coloca con posición fija, pero el diálogo es su bloque contenedor cuando tiene una transformación CSS (el centrado habitual con translate). Si además el DialogContent tiene un overflow distinto de visible (como overflow-y-auto, que muchos diálogos añaden para formularios largos), un popover más alto que el diálogo se recorta. En la prueba, con overflow-y: auto en el diálogo, la parte del popover fuera de sus límites no era alcanzable. Mueve el scroll a un contenedor interno y deja el DialogContent sin overflow: en la misma prueba, con el scroll en un div interno, el popover completo fue alcanzable.
Alternativa: Popover modal
Con <Popover modal>, la lista se desplazó en las versiones 1.1.15 y 1.1.23, con y sin container. Pero el popover pasa a ser modal, con los efectos que eso implica en foco y cierre, y esos comportamientos no se evaluaron. Para un combobox suele ser preferible el container.
Actualizar Radix
Según la prueba, subir a una versión que registre las capas anidadas (1.1.22, 1.2.0 o 1.3.0 entre las comprobadas) hace que el popover se desplace sin cambios en tu código. Como el comportamiento difiere entre versiones menores, deja el container si debes soportar varias.
Resumen
- Un
Dialogmodal bloquea el scroll fuera de su subárbol DOM, y unPopoverrenderizado endocument.bodyqueda fuera. - En las pruebas, el popover no se desplazó con react-dialog y react-popover 1.1.15, 1.1.21 y 1.1.23, y sí con 1.1.22, 1.2.0 y 1.3.0.
- La solución es exponer el nodo de
DialogContentpor contexto y pasarlo comocontaineralPortal; si esnull, Radix usadocument.body. - No dejes
overflowen el propioDialogContent, o el popover se recortará. - Prueba la contención con
data-testid, porque el contenido delPopovertambién tienerole="dialog".
Comentarios
Publicar un comentario