primitives icon indicating copy to clipboard operation
primitives copied to clipboard

[Dialog][Popover] Scrolling issue when Popover inside Dialog

Open hipstersmoothie opened this issue 3 years ago • 44 comments

Documentation

Relevant Radix Component(s)

Here in the docs it say to make Overlay a sibling to Content.

CleanShot 2022-02-15 at 11 02 22

This setup doesn't work if you have a scrollable popover in the dialog content. On this line a ref is passed to RemoveScroll but I think that by using just a ref that RemoveScroll doesn't consider the portalled element a part of the react tree.

In my own code I fixed this by putting my Content inside of my Overlay. After this change my scrollable popover could scroll again since RemoveScroll now has access to the react tree.

CleanShot 2022-02-15 at 11 07 04

hipstersmoothie avatar Feb 15 '22 19:02 hipstersmoothie

Both compositions are valid, for different use cases. In fact we have an example of a scrollable overlay here: https://www.radix-ui.com/docs/primitives/components/dialog#scrollable-overlay

benoitgrelard avatar Feb 15 '22 19:02 benoitgrelard

It's not overlay that I need scrollable though. I'll come up with a code sandbox to show what I'm talking about

hipstersmoothie avatar Feb 15 '22 19:02 hipstersmoothie

Here you can see if you follow all of the docs for both popover and dialog you can't scroll a popover in a dialog. I had to read through radix's source code and dependencies to figure out that I needed to wrap my content in the overlay. While both are valid I think the docs should steer people towards code+structure that will work out of the box without having to understand how the internals are actually working.

https://codesandbox.io/s/twilight-http-veu4gw?file=/App.js

hipstersmoothie avatar Feb 15 '22 19:02 hipstersmoothie

And if you're unwilling to change that I think the docs should at least detail why both composition are valid and what the use cases actually are.

hipstersmoothie avatar Feb 15 '22 19:02 hipstersmoothie

Oh sorry I misunderstood what you meant! That seems like a bug potentially, I'll take a look.

benoitgrelard avatar Feb 15 '22 19:02 benoitgrelard

Hmm, yep looks like it could be a RemoveScroll shards thing 🙈 we need a ticket to create our own react-tree-aware remove scroll at some stage.

jjenzz avatar Feb 15 '22 20:02 jjenzz

I'm running into the same issue. I'd like to adopt Radix for the design system I'm working on but we have some custom selects with a scrollable area (implemented as popover) which are rendered inside modals. Would it be possible to disable the scroll-blocking in the dialog components? Or just to enable scroll-blocking of the body since the rest is not scrollable because of the dialog overlay anyway.

dcastil avatar Apr 21 '22 13:04 dcastil

To anyone reading, I just found a workaround. The scroll removal is implemented in the Overlay component.

https://github.com/radix-ui/primitives/blob/6da75e0dbb2d1aebd2dae5a044c595bca39a2347/packages/react/dialog/src/Dialog.tsx#L201-L219

So replacing the Dialog.Overlay with a div does the trick. Just keep in mind that you need to block body scroll and deal with the scrollbar yourself with this workaround.

dcastil avatar Apr 21 '22 14:04 dcastil

Thanks @hipstersmoothie @dcastil ! It seems to fix my #1128 issue 🙏

davidavz avatar Jun 13 '22 14:06 davidavz

Ran into this as well, when using non-Radix UI bits inside a Dialog.

Would really be nice to have an option to disable it.

cdeutsch avatar Jun 13 '22 15:06 cdeutsch

Any news on this? scroll doesn't work on third parties with Radix dialog. it's apparently related to the <Modal.Overlay /> component. removing it solves the issue

giladv avatar Apr 30 '23 17:04 giladv

Yeah, this is still an issue for us

CarelessCourage avatar May 09 '23 10:05 CarelessCourage

This is still an issue, any idea on a potential fix?

Nhollas avatar May 12 '23 15:05 Nhollas

@Nhollas If you are using the Radix select, a possible workaround https://github.com/radix-ui/primitives/issues/2125#issuecomment-1545885362

joaom00 avatar May 12 '23 16:05 joaom00

i think this is something to do with the third-party remove-scroll lib that radix uses. for now you can set modal={false} on your Dialog. that might not be ideal for a11y (and will hide the overlay) but users will be able to scroll things for now at least.

jjenzz avatar May 23 '23 16:05 jjenzz

modal={false} is the path I took as well. I had composed the library's components in a custom Dialog component. Here's a simple suggestion to substitute the component's overlay (creatively named Overlay2). Even though this is far from pretty, it “works well enough”. The styles applied to Overlay2 are identical to what I had applied to radix' Overlay.

// Above code removed for brevity

export default ({
  children,
  description,
  hasCloseButton = true,
  title,
  trigger,
  ...props
}: ComponentProps) => {
  const [open, setOpen] = useState(false)

  useEffect(() => {
    const body = document.querySelector('body')
    if (!body) return

    if (open) {
      body.style.overflow = 'hidden'
    } else {
      body.style.overflow = ''
    }
  }, [open])

  return (
    <Dialog.Root {...props} open={open} onOpenChange={setOpen}>
      <Dialog.Trigger asChild>{trigger}</Dialog.Trigger>
      {open && <StyledDialog.Overlay2 data-state={open ? 'open' : 'closed'} />}
      <StyledDialog.Content>
        {title && <StyledDialog.Title>{title}</StyledDialog.Title>}
        {description && (
          <StyledDialog.Description>{description}</StyledDialog.Description>
        )}
        {children}
        {hasCloseButton && (
          <StyledDialog.Close asChild>
            <Button shape="square" variant="flat">
              <XMarkIcon className="icon" />
            </Button>
          </StyledDialog.Close>
        )}
      </StyledDialog.Content>
    </Dialog.Root>
  )
}
// Using emotion

export const Overlay2 = styled.div({
  '&[data-state="closed"]': {
    animation: `${FadeFromTo({ from: 0.75, to: 0 })} 200ms ease-in`,
  },
  '&[data-state="open"]': {
    animation: `${FadeFromTo({ from: 0, to: 0.75 })} 300ms ease-out`,
  },
  backgroundColor: theme.color.gray['500'],
  inset: 0,
  opacity: 0.75,
  position: 'fixed',
  zIndex: 10002,
})

gruschis avatar May 25 '23 19:05 gruschis

Another possible workaround is to render popover content directly inside dialog content and not in portal. So instead of:

<Popover.Portal>
    <Popover.Content />
</Popover.Portal>

you can do:

<Popover.Content />

For sure it doesn't cover all cases because some popovers have to be rendered in portal, but it has covered my case and works well. This solution doesn't force you to set modal={false} on <Dialog.Root />

deivuss331 avatar Sep 01 '23 08:09 deivuss331

To anyone reading, I just found a workaround. The scroll removal is implemented in the Overlay component.

https://github.com/radix-ui/primitives/blob/6da75e0dbb2d1aebd2dae5a044c595bca39a2347/packages/react/dialog/src/Dialog.tsx#L201-L219

So replacing the Dialog.Overlay with a div does the trick. Just keep in mind that you need to block body scroll and deal with the scrollbar yourself with this workaround.

This was the best solution for me for now as it doesn't remove the overlay. It also fixed an issue where padding was getting added to the body of my application whenever the overlay was showing.

Thanks @dcastil

aslaker avatar Sep 21 '23 18:09 aslaker

Possible Solution

I think it's possible to use the reference of a wrapper or another one that is inside the modal based on this:

imagen imagen

Radix

const ExampleOfAnyPopoverInsideModal = () => {
    const containerRef = useRef<HTMLDivElement>(null);

    return (
        <div ref={containerRef}>
            <Popover.Root>
                <Popover.Trigger />
                <Popover.Anchor />
                <Popover.Portal>
                    <Popover.Content container={containerRef.current}>
                        <Popover.Close />
                        <Popover.Arrow />
                    </Popover.Content>
                </Popover.Portal>
            </Popover.Root>
        </div>
    );
};

Shadcn:


const ExampleOfAnyPopoverInsideModal = () => {
    const containerRef = useRef<HTMLDivElement>(null);
    const [open, setOpen] = useState(false);

    return (
        <div ref={containerRef}>
            <Popover open={open} onOpenChange={setOpen}>
                <PopoverTrigger asChild>
                    <Button
                        variant="outline"
                        role="combobox"
                        aria-expanded={open}
                        className="w-full justify-between"
                    >
                        <span className="truncate">Something</span>
                        <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
                    </Button>
                </PopoverTrigger>
                <PopoverContent
                    className="p-0"
                    style={{
                        width: containerRef.current?.offsetWidth
                    }}
                    container={containerRef.current}
                >
                    <Command>
                        //Command with scroll inside :)
                    </Command>
                </PopoverContent>
          </Popover>
      </div>
    );
};
// ...
// Added container prop to PopoverContent
const PopoverContent = React.forwardRef<
    React.ElementRef<typeof PopoverPrimitive.Content>,
    React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
        container?: HTMLElement | null;
    }
>(
    (
        { className, container, align = "center", sideOffset = 4, ...props },
        ref
    ) => (
        <PopoverPrimitive.Portal container={container}>
            <PopoverPrimitive.Content
                ref={ref}
                align={align}
                sideOffset={sideOffset}
                className={cn(
                    "z-50 w-72 rounded-lg border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
                    className
                )}
                {...props}
            />
        </PopoverPrimitive.Portal>
    )
);
// ...

illodev avatar Sep 29 '23 17:09 illodev

A handy tip to anyone implementing the solution from @illodev - if this will seem like devs have to pass the ref as props all the way through a bunch of components, it may help to store the ref in a Context on the styled Dialog component for your popover component to read.

cssinate avatar Jan 10 '24 21:01 cssinate

This helped me a lot! @illodev

davidmr163 avatar Jan 24 '24 16:01 davidmr163

I think solution suggested by @illodev defeats the purpose of portal, it's really no different than just removing the portal, and that's easier.

ozguruysal avatar Mar 07 '24 13:03 ozguruysal

@ozguruysal that's correct.

benoitgrelard avatar Mar 07 '24 14:03 benoitgrelard

The solution by @illodev also does not work if your Dialog.Content has overflow-auto set - since the div with the containerRef is rendered inside of it, the popover won't be rendered outside of the dialog.

swingthrough avatar Mar 13 '24 10:03 swingthrough

Worth mentioning that this also happens to other components that renders in portal. i.e When rendering a react-select with menuPortalTarget in the radix dialog.

zeroliu avatar Mar 25 '24 19:03 zeroliu

@benoitgrelard Is it possible to get a review on this PR? It sounds like it would solve this issue and it's been open for quite a while now so it would be good to get it merged. Thanks! https://github.com/radix-ui/primitives/pull/2250

kieranm avatar Mar 25 '24 19:03 kieranm

also just ran into this as well!

rmnoon avatar Apr 24 '24 00:04 rmnoon

facing this issue.. any other workarounds, other than removing portal from popover and DialogPrimitive.Overlay ? I would like to keep those.

vasyaqwe avatar Apr 26 '24 11:04 vasyaqwe

Possible Solution

I think it's possible to use the reference of a wrapper or another one that is inside the modal based on this:

imagen imagen

Radix

const ExampleOfAnyPopoverInsideModal = () => {
    const containerRef = useRef<HTMLDivElement>(null);

    return (
        <div ref={containerRef}>
            <Popover.Root>
                <Popover.Trigger />
                <Popover.Anchor />
                <Popover.Portal>
                    <Popover.Content container={containerRef.current}>
                        <Popover.Close />
                        <Popover.Arrow />
                    </Popover.Content>
                </Popover.Portal>
            </Popover.Root>
        </div>
    );
};

Shadcn:

const ExampleOfAnyPopoverInsideModal = () => {
    const containerRef = useRef<HTMLDivElement>(null);
    const [open, setOpen] = useState(false);

    return (
        <div ref={containerRef}>
            <Popover open={open} onOpenChange={setOpen}>
                <PopoverTrigger asChild>
                    <Button
                        variant="outline"
                        role="combobox"
                        aria-expanded={open}
                        className="w-full justify-between"
                    >
                        <span className="truncate">Something</span>
                        <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
                    </Button>
                </PopoverTrigger>
                <PopoverContent
                    className="p-0"
                    style={{
                        width: containerRef.current?.offsetWidth
                    }}
                    container={containerRef.current}
                >
                    <Command>
                        //Command with scroll inside :)
                    </Command>
                </PopoverContent>
          </Popover>
      </div>
    );
};
// ...
// Added container prop to PopoverContent
const PopoverContent = React.forwardRef<
    React.ElementRef<typeof PopoverPrimitive.Content>,
    React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
        container?: HTMLElement | null;
    }
>(
    (
        { className, container, align = "center", sideOffset = 4, ...props },
        ref
    ) => (
        <PopoverPrimitive.Portal container={container}>
            <PopoverPrimitive.Content
                ref={ref}
                align={align}
                sideOffset={sideOffset}
                className={cn(
                    "z-50 w-72 rounded-lg border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
                    className
                )}
                {...props}
            />
        </PopoverPrimitive.Portal>
    )
);
// ...

ILY <3

sergical avatar May 10 '24 18:05 sergical