radix-ui / primitives

Radix Primitives is an open-source UI component library for building high-quality, accessible design systems and web apps. Maintained by @workos.
https://radix-ui.com/primitives
MIT License
15.43k stars 774 forks source link

[Accordion][Collapsible][Tabs][…] Consider adding an option to control how `Content` is mounted/unmounted over time/activation cycle #1155

Open Kilian opened 2 years ago

Kilian commented 2 years ago

Feature request

Overview

For the tabs I would like an option to keep the inactive tab contents around in the DOM. For components with a heavy mounting cost, or components with internal state, the current solution is suboptimal. Instead of unmounting, tabs could be set to aria-hidden.

Examples in other libraries

Reach works like this: https://reach.tech/tabs/

Who does this impact? Who is this for?

Anyone using the tabs for components with internal state.

Additional context

n/a


Edit (April 5th 2022): People tend to ask for one or the other, so the gist is for us to see if we can provide an option to cater for both.

jjenzz commented 2 years ago

We had a previous discussion about all this for reference https://github.com/radix-ui/primitives/discussions/855

Notably a "lazy" state which could be useful where the tab contents start unmounted but remain mounted when opened and then closed https://github.com/radix-ui/primitives/discussions/855#discussioncomment-1621945

salvadorgonmo commented 1 year ago

Hi, just looping back on this. I saw you mentioned about the previous discussion as reference, but is there a conclusion over this request? Seems that both behavior are useful, so I can think of this as a prop like hasDestructiveBehavior being acting as flag, and then if the flag is enabled then the elements are going to be unmounted from the DOM, if not enabled simply hiding them but not removing them from the DOM.

Asking about this cause we are using this tabs and we have some text edit boxes implemented on our site that are placed over the tabs content that needs to retain its state if the other tab content is active, like when you are writing a big amount of text and it gets lost because of changing the tab. Happy to contribute to add this change if needed :)

Thanks in advance!

aboveyunhai commented 1 year ago

Based on @jjenzz 's solution from https://github.com/radix-ui/primitives/discussions/855#discussioncomment-1626807 , You can keep tab content mounted and visually hidden by

  const [tab, setActiveTab] = useState("0");
  <Accordion value={tab} onValueChange={setActiveTab}>
    // ...
     <Accordion.Content forceMount={true} hidden={activeTab !== tab} />
   // ...
  </Accordion>

The problem is that because now the visibility is controlled by hidden={activeTab !== tab}, we lost the animation of collapsing, From a user's perspective, this will be simply viewed as buggy or laggy. So we still end up lifting the heavy computation above the Accordion component. I'm actually not sure how to resolve this problem. Apologize for the tagging, @jjenzz is there anyway we can utilize the current tool to enable the collapse animation to further expand your solution as a workaround in this case?

rubby-c commented 5 months ago

For those that use forceMount, you also have to add an onPointerLeave on the NavigationMenuPrimitive.Viewport primitive. This way the content won't close when your mouse goes out of it.

sebi75 commented 5 months ago

For those that use forceMount, you also have to add an onPointerLeave on the NavigationMenuPrimitive.Viewport primitive. This way the content won't close when your mouse goes out of it.

I personally don't have this issue. Animations would be nice though

ajayvignesh01 commented 5 months ago

You could also use the state data attribute as the conditional. I personally find this a bit cleaner than having useState.

<Tabs.Content
  value='tab-1'
  forceMount
  className='data-[state=inactive]:hidden'
>
  {children}
</Tabs.Content>
Loque- commented 5 months ago

It would be great to have an option for preserving content on Accordion Content hide

You could also use the state data attribute as the conditional. I personally find this a bit cleaner than having useState.

<Tabs.Content
  value='tab-1'
  forceMount
  className='data-[state=inactive]:hidden'
>
  {children}
</Tabs.Content>

How do you use this? Super new to Radix, and would also like to preserve content when the accordion is collapsed without having to useState. Thank you!

kenny019 commented 5 months ago

It would be great to have an option for preserving content on Accordion Content hide

You could also use the state data attribute as the conditional. I personally find this a bit cleaner than having useState.

<Tabs.Content
  value='tab-1'
  forceMount
  className='data-[state=inactive]:hidden'
>
  {children}
</Tabs.Content>

How do you use this? Super new to Radix, and would also like to preserve content when the accordion is collapsed without having to useState. Thank you!

For Accordion in tailwind it should be data-[state=closed]:hidden instead of inactive for it to work.

tim-soft commented 3 months ago

How can this work with Navigation Menu (and also maintain the animations)? Having the content rendered when closed is really important for websites where SEO is a concern

Lets say I have a basic menu with two Content components

<NavigationMenuPrimitive.List>
    <NavigationMenuPrimitive.Item>
        <NavigationMenuPrimitive.Trigger />
        <NavigationMenuPrimitive.Content forceMount className="data-[state=closed]:hidden">
            {/* Menu 1 */}
        </NavigationMenuPrimitive.Content>
    </NavigationMenuPrimitive.Item>

    <NavigationMenuPrimitive.Item>
        <NavigationMenuPrimitive.Trigger />
        <NavigationMenuPrimitive.Content forceMount className="data-[state=closed]:hidden">
            {/* Menu 2 */}
        </NavigationMenuPrimitive.Content>
    </NavigationMenuPrimitive.Item>
</NavigationMenuPrimitive.List>

The first problem is both menus are visible in the DOM when the menu is closed Adding data-[state=closed]:hidden as a class hides the content when all menus are closed, but are all menus visible when any menu is open

Edit: for anyone looking for a cheap hack so links are in the page during SSR... here's a cheap hack, I'm not saying it's great

Force mount the content and keep it hidden until the user interacts w/ the menu for the first time, then revert to default behavior (forceMount off, remove hidden style) It makes sure links/text are crawlable by google bot but looks "normal" when human beings interact w/ the menus

const [forceMount, setForceMount] = React.useState(true);

return (
<NavigationMenuPrimitive.Root  onValueChange={() => setForceMount(false)}>
    <NavigationMenuPrimitive.List>
        <NavigationMenuPrimitive.Item>
            <NavigationMenuPrimitive.Trigger />
            <NavigationMenuPrimitive.Content  {...(forceMount && { forceMount })} className={cn(forceMount && "hidden")}>
                {/* Menu 1 */}
            </NavigationMenuPrimitive.Content>
        </NavigationMenuPrimitive.Item>

        <NavigationMenuPrimitive.Item>
            <NavigationMenuPrimitive.Trigger />
            <NavigationMenuPrimitive.Content  {...(forceMount && { forceMount })} className={cn(forceMount && "hidden")}>
                {/* Menu 2 */}
            </NavigationMenuPrimitive.Content>
        </NavigationMenuPrimitive.Item>
    </NavigationMenuPrimitive.List>
</NavigationMenuPrimitive.Root>
);
seans84 commented 3 months ago

Simple solution, don't use React.

lucasfontesgaspareto commented 2 months ago

You could also use the state data attribute as the conditional. I personally find this a bit cleaner than having useState.

<Tabs.Content
  value='tab-1'
  forceMount
  className='data-[state=inactive]:hidden'
>
  {children}
</Tabs.Content>

thats why i choose react

divinsmathew commented 2 months ago

With forceMount and some tweaks in CSS, I was able to do this without sacrificing animations.

Radix already calculates content height and stores it in --radix-collapsible-content-height for us. But when accordion is open, this value is lost. So the idea is to preserve the value so that the Content div will always have a starting and ending height to transition.

.accordion--content {
   height: 0;
   &[data-state="open"] {
      height: auto;
   }
}

We need to store the expanded height of the content to a React state variable.

useEffect(() => {
  if (!ref.current) {
    return;
  }

  const currentHeight = ref.current.clientHeight;
  const height = Math.max(collapsedHeight, currentHeight);

  setCollapsedHeight(height);
}, [collapsedHeight, ref]);

Now, we can restore the lost --radix-collapsible-content-height in JS.

<RadixAccordion.Content
   className={ACCORDION_CLASSES.content}
   forceMount
   style={{
       "--radix-collapsible-content-height": `${collapsibleHeight}px`,
   }}
>
   {content}
</RadixAccordion.Content>
fernandezremi commented 2 months ago
const MegaMenu = () => {
  const [initialMount, setInitialMount] = useState<true | undefined>(true);

  useEffect(() => {
    setInitialMount(undefined);
  }, []);

  return (
    <NavigationMenu>
      <NavigationMenuList>
        <NavigationMenuItem className="[&>div]:hidden">
          <NavigationMenuTrigger>Getting started</NavigationMenuTrigger>
          <NavigationMenuContent forceMount={initialMount}>
             {/* Menu 1 */}
          </NavigationMenuContent>
        </NavigationMenuItem>
        <NavigationMenuItem className="[&>div]:hidden">
          <NavigationMenuTrigger>Components</NavigationMenuTrigger>
          <NavigationMenuContent forceMount={initialMount}>
              {/* Menu 2 */}
          </NavigationMenuContent>
        </NavigationMenuItem>
      </NavigationMenuList>
    </NavigationMenu>
  );
};

A possible workaround is to force the component to mount on first render and remove the property after the page loads.

On my project this practice works and the content of the two menus is well rendered in the first html !

className="[&>div]:hidden" on NavigationMenuItem property prevents mounted components from being displayed on first render.

If we only use the CSS class and force the menus to be mounted at any time, the content is displayed in the first HTML rendering but all the menus opens when hovering over a single menu

Tell me if you have another idea

Eylen commented 1 month ago

Is there any option to have this functionality for Dialog?

We're currently using Vaul which is a wrapper over Radix Dialog and we would like to have this functionality to avoid recreating the DOM each time the drawer is open

mugavri commented 1 week ago

@Kilian

Looking for an answer to this question too

fabiancgc12 commented 1 week ago

I found a way to do it with pure css for the accordions when forceMount is true, I'm using Shadcn but i think the code should also work for normal radix

.accordion {
  display: grid;
  grid-template-rows: 0fr;
  overflow: hidden;
  transition: grid-template-rows 200ms ease !important;
  animation: unset;
}

.accordion[data-state="open"] {
  grid-template-rows: 1fr;
}

.accordion > div {
  min-height: 0px;
}

demo

explanation: