reactjstailwind-css

ReactJS: component breaks inside a Dialog


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>
    );

Solution

  • Root cause

    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.

    Options

    Disable dialog's focus trapping (highly flawed, low effort)

    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).

    Override pointer events on select overlay (less flawed, low-effort)

    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">
    
    Align select implementation with shadcn/radix UI primitives (best approach)

    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.