I'm using shadcn with different components. I have a custom MultiSelect
component that works fine in my main component:
<MultiSelect
id="zone"
placeholder="Zone search"
onSelectionChange={setZones}
endpoint="/zones/search"
/>
However, I created an EstateSelector
component, which is a dialog that also contains the same MultiSelect
component. Inside the dialog, the MultiSelect
dropdown opens, but I cannot select any items. Instead, clicks go through the dropdown and interact with elements underneath it, making them clickable instead.
In the video, you can see that:
In the main window, MultiSelect
works correctly, allowing item selection.
Inside the dialog, MultiSelect
opens, but I cannot select any items.
What could be causing this issue, and how can I fix it so that MultiSelect
works inside the dialog?
Here are my 2 components (with only the relative parts):
EstateSelector.jsx
return (
<>
<Dialog>
<DialogTrigger className="w-full">
<div className="flex justify-between h-9 w-full rounded-md border border-input bg-transparent px-2 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground md:text-sm">
<ChevronRight className="mt-1 size-4" />
</div>
</DialogTrigger>
<DialogContent className="lg:min-w-[800px] lg:min-h-[400px] min-w-full">
<DialogHeader>
<DialogTitle>{placeholder}</DialogTitle>
<DialogDescription className="pt-3">
<Command>
<div className="flex px-1 gap-1 pt-1 mb-2">
<Select
className="w-1/3"
onValueChange={setSelectedStatus}
>
<SelectTrigger className="w-[90%] overflow-hidden">
<SelectValue placeholder="Stato" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={null}>Tutti gli Stati</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<MultiSelect
id="zone"
placeholder="Zone search"
isMulti={false}
onSelectionChange={setSelectedZone}
endpoint="/zones/search"
></MultiSelect>
</div>
{loading && <CommandEmpty>Caricamento...</CommandEmpty>}
{error && <CommandEmpty>{error}</CommandEmpty>}
{!loading && !error && searchTerm && (
<CommandEmpty>Nessun immobile trovato.</CommandEmpty>
)}
<CommandGroup>
<CommandList>
{options.map((option) => (
<CommandItem
key={'estate_' + option.id}
value={`${option.id} ${option.titolo_interno}`}
onSelect={() => {
handleSelectChange({
id: option.id,
value: option.titolo_interno
});
}}
className={cn(
'',
selectedItems.some(item => item.id === option.id) ? 'bg-blue-200 hover:bg-blue-200' : 'hover:bg-gray-50'
)}
>
<Check
className={cn(
'absolute left-5 size-2 text-white bg-blue-500 p-0.5 rounded-full',
selectedItems.some(item => item.id === option.id) ? 'opacity-100' : 'opacity-0'
)}
/>
<img
src={`/storage/images/estates/${user.branch_id}/${option?.thumb?.thumb}`}
className="size-10 rounded"
loading="lazy"
onError={(e) => {
e.target.onerror = null;
e.target.src = `/images/${imagePlaceholder}`;
}}
alt={option.titolo_interno}
/>
<div className="flex gap-1">
<span className="ml-2 font-bold">{'#' + option.id}</span>: {option.titolo_interno}
</div>
</CommandItem>
))}
</CommandList>
</CommandGroup>
</Command>
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
</>
);
MultiSelect.jsx
return (
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between px-3"
>
<div className="flex gap-1 justify-start overflow-hidden">
{selectedItems?.length ? (
isMulti && selectedItems.length > cutItemsAfter ? (
<Badge variant="secondary">
{selectedItems.length} voci selezionate...
</Badge>
) : (
selectedItems.map((item, i) => (
<Badge
key={i}
onClick={(e) => {
e.stopPropagation();
removeItem(item.id);
}}
>
{item.value}
</Badge>
))
)
) : (
<span>{placeholder}...</span>
)}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-[380px] lg:min-w-[400px] p-0">
<Command>
<CommandInput
className="border-0 ring-0 focus:ring-0"
placeholder={placeholder + '...'}
value={endpoint ? searchTerm : undefined}
onValueChange={endpoint ? setSearchTerm : undefined}
/>
{loading && <CommandEmpty>Caricamento...</CommandEmpty>}
{error && <CommandEmpty>{error}</CommandEmpty>}
{!loading && !error && searchTerm && (
<CommandEmpty>Nessuna voce trovata.</CommandEmpty>
)}
<CommandList>
<CommandGroup>
{orderedOptions
.filter(option => !hideId || hideId !== option.id)
.map((option) => (
<CommandItem
onMouseOver={(e) => {
console.log(e);
}}
key={option.id}
value={`${option.id} ${option.label}`}
onSelect={() => {
handleSelectChange({ id: option.id, value: option.label });
if (!isMulti) setOpen(false); // Chiudi il popover in modalità single select
}}
className={cn(
'',
selectedItems.some(item => item.id === option.id)
? 'bg-blue-200 hover:bg-blue-200'
: 'hover:bg-gray-50'
)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedItems.some(item => item.id === option.id)
? 'opacity-100'
: 'opacity-0'
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
There is a high probability that the <Dialog>
component (or one of its coupled children like <DialogContent>
) is blocking events originating from DOM nodes that are not inside of itself.
This is a common mechanic used in dialogue implementations in order to implement a variety of dialogue behaviours, such as things behind it not being interactable, focus traps etc. It's easy for such implementations to accidentally not cater to cases that weren't envisaged initially.
But isn't MultiSelect
in the dialog anyway? In the React tree, yes, but in the DOM, it probably is not.
I can see you are likely already aware of this since your custom select component is using <Popper>
, but for the sake of clarity to other readers -- the overlay menu part of the select box is portaled to a DOM node directly on the <body>
element. This is standard practice for any kind of "floating" element that is attached to another (see libraries like floating ui), since it's required for such elements not to be clipped by some nested container.
Actually, the dialogue itself and/or the whole-screen "backdrop" of it is probably also portaled to some other high-level DOM node. The details of this are also closely coupled to the implementation around focus trapping
A typical implementation of <Dialog>
might add a global event listener on mount and trap all click events originating from somewhere other than its own DOM node and its children by using e.stopPropagation()
.
However, I can see by looking at the video that mouse events are also "falling through" to whatever is behind the overlay, which is indicative that the <Dialog>
is using another common approach whereby it sets the pointer-events
CSS property to none
on some high-level dom node (e.g. <body>
) and then enables pointer-events
again exclusively on its own DOM node.
After looking at the shadcn source, I see that it uses a variety of these tricks in combination, and you are at the mercy of their API as to what workarounds you have available to you.
If you were using shadcn's own select implementation, everything would likely work, as those hook into core library primitives (provided by radix-ui) that carefully orchestrate the layers across the various components that use it in order to cater for these cases.
Since you have your own impl, you don't get that for free, and that would be the "edge case" that shadcn isn't catering for. Your own "overlay" behaviours implemented in userland with <Popper>
are conflicting with shadcn's first-class implementation used in <Dialog>
, which doesn't know your impl even exists.
The most simple thing to try is to set the modal
prop to false:
<Dialog modal={false}>
This basically disables shadcn behaviours around trapping focus. However, this is a bit of a sledgehammer as there are reasons it has these behaviours in the first place (screen readers, not being able to click on stuff genuinely outside).
You could try adding pointer-events: auto
to your <PopoverContent>
inside your custom select. That would mean that it ignores the pointer-events: none
it is inheriting from elsewhere due to shadcn dialog behaviors.
There could be more bugs beyond this "fix" though, as shadcn is doing all sorts of other things around managing events.
<PopoverContent className="min-w-[380px] lg:min-w-[400px] p-0 pointer-events-auto">
It would be best to align your select component with shadcdn (and, by proxy, radix-ui). Doing so would mean that it can manage the layers effectively.
The most direct approach is to use the select components and customise those to what you desire. You would not call <Popper>
yourself since those primitives already handle that stuff.
If the shadcn select components are too opinionated for you, you can instead build up your own select using the underlying radix-ui select primitives, which are completely bare bones but will still handle the layers in tandem with the other shadcn components like dialogue.