react-arborist icon indicating copy to clipboard operation
react-arborist copied to clipboard

Dynamic height / scrollbars

Open patrik-u opened this issue 1 year ago • 20 comments

The scrollbar is a bit ugly. Is it possible to have the Tree adjust its height dynamically instead of having it fixed and let the parent container handle the scrollbars? Or alternatively be able to style the scrollbars so that it e.g. they are only visible on mouse hover?

patrik-u avatar Jan 07 '23 18:01 patrik-u

I managed to solve this by calculating height based on number of nodes in the tree and adjusting the height of the Tree component accordingly.

patrik-u avatar Jan 07 '23 19:01 patrik-u

I usually make the height of the tree the same height as it's parent using a ResizeObserver on the parent, the passing the width/height to the tree.

If you make the tree height the same height as all the nodes, you will loose the row virtualization that makes it render fast. For example, if you have 10,000 nodes and the tree height is set to 10,000 x rowHeight, you'll be rendering all 10,000 of them in the tree. Whereas if the height is only 500, you'll only be rendering 20-30.

I would welcome an example where the scrollbar is custom styled though.

jameskerr avatar Jan 07 '23 20:01 jameskerr

Hi @jameskerr Could you please post some code example of how you achieve this? I've been wrestling with this for around 8 hours now, and see some strange results when I try to do this. I've written a few React hooks, tried native JavaScript, but I seem to be getting inconsistent results (using ResizeObserver). I actually also get the same inconsistencies when writing regular event listeners on the window.resize as well. Basically, the height of the tree seems to be applied slightly higher than the value ResizeObserver surfaces. And in an inconsistent way. I tried debouncing, but there was still the same issue.

Here is a video is what happens when I calculate the height of the Treeviews container in my project (using ResizeObserver) ...

https://user-images.githubusercontent.com/925095/224335639-7ccd7bbd-892b-4679-9f23-d93518dc2539.mov

You can see that more than 1 calculation is performed. The number next to Deal name is the height that the hook (which performs the ResizeObserver) logic returns (and the height the tree is then set to be). I'm really not sure why multiple calculations are being performed. It should just be 1, to bring the treeviews height in synch with that of the parent. I wondered whether it could be borders, but like I say, the heights are sometimes are out of synch by 2px, sometimes 20px, sometimes 80px. Sometimes the code even gets caught in an infinite loop, with the treeview seemingly increasing the height of the parent, then that triggers a re-calculation and so on. So, I'm looking to find out how to ensure the ResizeObserver reported height (which is always correct, I've checked this) is sent down to the treeview, in a way whereby it brings the treeviews height and that of its parent in synch with one another.

The React hook I'm using is here:

// NPM imports
import useResizeObserver from "@react-hook/resize-observer";
import { MutableRefObject, useLayoutEffect, useRef, useState } from "react";

interface Size {
  width: number;
  height: number;
}

export default function useElementSize<T extends HTMLElement = HTMLDivElement>(): [MutableRefObject<T | null>, Size] {
  const target = useRef<T | null>(null);
  const [size, setSize] = useState<Size>({
    width: 0,
    height: 0,
  });

  useLayoutEffect(() => {
    target.current && setSize(target.current.getBoundingClientRect());
  }, [target]);

  useResizeObserver(target, (entry) => {
    const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0];
    setSize({ width, height });
  });

  return [target, size];
}

And the use of this is super simple ...

import useElementSize from "scripts/hooks/useElementSize";

const [treeviewRef, { height: testHeight }] = useElementSize();

<div className={styles["treeview-outer"]} ref={treeviewRef}>
    <Treeview height={testHeight} />
</div>

<Treeview> is merely a component that takes the height prop and passes it down to Tree, like this:

import { NodeApi, NodeRendererProps, Tree } from "react-arborist";

export const Treeview: React.FC<ITreeview> = ({ dataAttributes = {}, height }) => {

...
return (
    <Tree
        height={height}
        indent={18}
        initialData={treeData}
        ...
)

If you could provide some help with this (via the provision of code for how you use ResizeObserver), that would be appreciated.

(I'm using v3.0.2)

Many thanks


FYI, I've just stripped everything right back, to just your "The simplest tree" example from https://github.com/brimdata/react-arborist and with everything inline in a single page, and it's still the same effect. It's nothing to do with re-rendering, I checked that. It's a real odd one. Alas, if the treeview doesn't work Re: resizing, I think I'll have to revert to something like rc-tree. I do like using react-arborist though, so I'm hoping you can help.

martinburford avatar Mar 10 '23 13:03 martinburford

Debugging stuff like this is hard. Dang. I’ll give this a more thorough read and post and example, but I’ve had to make the parent component have styles like min-height: 0 and flex: 1

jameskerr avatar Mar 11 '23 02:03 jameskerr

hey @jameskerr can you provide an example, better in documentation.

AtiqGauri avatar May 17 '23 07:05 AtiqGauri

I would also love some better documentation on this.

emmaivarsson avatar Jun 19 '23 09:06 emmaivarsson

It would be really nice to have the possibility to send a maxHeight and then for the table to be of height auto otherwise

emmaivarsson avatar Jun 19 '23 10:06 emmaivarsson

Is it posible to use the height of the parent. I am not setting any height but my issue is that there is a lot of space at the bottom of the tree.

Example my parent height is 252 however the tree is 500. Yes I could set the heigh manually but my parent window is drainable and resizable.

parent: PanelWindow.tsx

return (
    <Draggable
      handle=".handle"
      grid={[25, 25]}
      scale={1}
      defaultPosition={{ x: positionX, y: positionY }}
    >
      <div className={className} style={{ width, height }}>
        {isVisible && (
          <ResizableBox
            style={{ height: initialHeight, width: initialWidth }}
            width={width}
            height={height}
            minConstraints={[250, 250]}
            maxConstraints={[window.innerWidth * 0.9, window.innerHeight * 0.9]}
            resizeHandles={["se", "s", "e"]}
            onResize={handleResize}
          >
            <Card style={{ width, height, boxSizing: "content-box" }}>
              <CardDescription className="handle text-center" ref={headerRef}>
                <XCircle
                  className="absolute top-0 left-0"
                  size={"15px"}
                  onClick={handleDismiss}
                />
                {panelTitle}
              </CardDescription>
              <div
                style={{
                  height: `calc(100% - ${headerHeight}px)`,
                  overflow: "auto",
                }}
              >
                {children}
              </div>
            </Card>
          </ResizableBox>
        )}
      </div>
    </Draggable>
  );

ScenePanel.tsx

function ScenePanel() {
  const viewRef = useRef<HTMLDivElement>(null);
  const getViewHeight = () => viewRef.current?.clientHeight || 0;

  useEffect(() => {
    // You can now use getViewHeight() to get the height of the view
    const height = getViewHeight();
    console.log("View height:", height);
  }, []);
  
  return (
    <div>
      <PanelWindow
        isVisible={true}
        className="scene"
        panelTitle="Scenes"
        onDismiss={() => {}}
        positionY={0}
        positionX={0}
      >
        <div ref={viewRef}>
          <Tree initialData={demoData}>{Node}</Tree>
        </div>
      </PanelWindow>
    </div>
  );
}

The ScenePanel size is showing 500 while the parent is only 252.

https://github.com/brimdata/react-arborist/assets/64275658/2fe5f2d1-b02b-496d-98d9-d726cf739c56

elmcapp avatar Jul 27 '23 14:07 elmcapp

Yes, I usually make the parent expand to fill it's parent (either with flex: 1 or the css below).

height: 100%;
min-height: 0;

Then I add a ref to it with this package https://github.com/ZeeCoder/use-resize-observer

That hook will return the height and width of the parent whenever it changes. You then pass these numbers to the Tree.

const { ref, width, height } = useResizeObserver();

<div className="parent" ref={ref}>
  <Tree height={height} width={width} />
</div>

jameskerr avatar Jul 27 '23 16:07 jameskerr

Also having some trouble getting this to work the way I want. Is it possible to disable scrolling for the tree and have another component take over that role? For example we have a ScrollArea implementation from Radix UI that we could use. Or would this screw with the virtualization?

I have tried getting the same behavior as the Cities example, where horizontal scrolling is disabled and text has ellipsis overflow instead by using the FillFlexPanel, but I cannot get the horizontal scrollbar to be hidden with overflow-x: "hidden" since the root element of the tree seems to have overflow: "auto" hard-coded into it?

krillboi avatar Jul 31 '23 14:07 krillboi

Ok so in that case, you'll need to set overflow hidden on your Node renderer. Handle the ellipses in the Node Renderer as well.

The tree component should fill its container, and the tree wrapper should have overflow scroll, but then the nodes should be width: 100% with overflow: hidden.

I've never used custom scrollbars in my app, so if you get it working and want to throw up an example, I could publish that in an FAQ somewhere.

jameskerr avatar Jul 31 '23 16:07 jameskerr

It looks like it could be possible to use custom scrollbars if the outerElementType of the react-window implementation was able to be swapped out: https://github.com/bvaughn/react-window/issues/110#issuecomment-469061213.

In the example they are using react-custom-scrollbars so I don't see why another component such as ScrollArea from Radix shouldn't work.

krillboi avatar Aug 02 '23 08:08 krillboi

Cool! Thanks for researching that.

jameskerr avatar Aug 02 '23 18:08 jameskerr

Да, я обычно расширяю родительский элемент, чтобы заполнить его (либо с помощью flex: 1, либо с помощью CSS ниже).

height: 100%;
min-height: 0;

Затем я добавляю к нему ссылку с помощью этого пакета https://github.com/ZeeCoder/use-resize-observer .

Этот крючок будет возвращать высоту и ширину родительского элемента всякий раз, когда он изменяется. Затем вы передаете эти числа в Дерево.

const { ref, width, height } = useResizeObserver();

<div className="parent" ref={ref}>
  <Tree height={height} width={width} />
</div>

I used your example as a solution to the problem, but the height of the tree is still 500px (I think this is the default)

const {ref, height } = useResizeObserver()

return (
        <div ref={ref} style={{minHeight: "0", height: "100%"}}>
                <Tree data={structure}
                    indent={24}
                    height={height}
                    width="auto"
                    rowHeight={66}
                    idAccessor='uuid'
                >
                    {children}
                </Tree>
        </div>
    )

image

MalyugaSensei avatar Sep 04 '23 09:09 MalyugaSensei

You'll need to make sure that the parent is filling its container. It needs to have flex: 1; on it or grid-template-row 1fr or something to make it fill the container.

jameskerr avatar Sep 05 '23 20:09 jameskerr

Yes, I tried using flex, but it also doesn't give any result. I tried to reproduce this code in codesandbox to make sure on whose side the problem is. Maybe I don't understand correctly how flex works https://codesandbox.io/s/nostalgic-carlos-2l38qd?file=/src/App.js

MalyugaSensei avatar Sep 06 '23 06:09 MalyugaSensei

For those using Tailwind, I was able to apply custom styles to the scrollbar with this nifty Tailwind plugin: https://github.com/adoxography/tailwind-scrollbar

Add the Tailwind classes to the Tree component. For example:

<Tree
  className="scrollbar hover:scrollbar-thumb-red-500 scrollbar-thumb-red-300 scrollbar-track-blue-500"
  ...
>

JohnAmadeo avatar Sep 11 '23 03:09 JohnAmadeo

I had issues using useResizeObserver where it would resize the height down the way, but if you resized back up it wouldn't fire the resize events. Managed to fix it by setting height = {height -1 } in the tree component. the - 1 was just enough to stop the tree from locking the height!

theearlofsandwich avatar Nov 25 '23 09:11 theearlofsandwich

Has anyone managed to make the height of a Tree dynamic? Will there be any updates on this issue?

MalyugaSensei avatar Nov 27 '23 14:11 MalyugaSensei

I did get dynamic height working but it wasn't easy.

The principal is that you want:

  1. The list to expand to fit the contents of the tree.
  2. but to not exceed the contents of some container it is contained within.
  3. The virtual list inside to continue to fill all available space.

Essentially we want our tree height to be equal to min(container height, total calculated tree height).

One limitation that is difficult to get around is that we cannot access the calculated tree height easily without changing react-arborist's code. However we can get to it by polling for it within the Tree component - unfortunately there is no other good way to figure out WHEN this becomes available on the DOM. This would be a lot easier with code that let's you subscribe to changes on the total calculated tree height!

I will extrapolate from my working version to show the principals at work:

First set up the DOM & some refs.

const screenRef = useRef(); // where you want to fill the available space of
const containerRef = useRef(); // A div immediately above the tree (may be able to skip this actually (?) and do everything off the screenRef)
const [treeHeight, setTreeHeight] = useState();
<div css={css`height: 100vh; display: flex; flex-direction: column}` ref={screenRef}>
  <div css={css`height: fit-content;`} ref={containerRef}>
    <Tree height={treeHeight} data={...}>
  </div>
</div>

Then let's introduce the polling code.

// 500 = arbitrary starting height
const [treeContentsHeight, setTreeContentsHeight] = useState(500);
const [screenHeight, setScreenHeight] = useState(500);
useEffect(() => {
    // This observer 
    const observer = new ResizeObserver(entries => {
      const height = entries[0].contentRect.height;
      setTreeContentsHeight(height);
    });
    // This tree has the correct total height calculated so we want to watch it using ResizeObserver
    let innerTreeElement;
    let pollId;
    const poll = () => {
      pollId = setTimeout(() => {
        innerTreeElement = containerRef.current?.firstChild?.firstChild?.firstChild;
        if (innerTreeElement) {
          clearTimeout(pollId);
          observer.observe(innerTreeElement);
        } else {
          pollId = poll();
        }
      }, 30);
    };
    poll();
    return () => {
      observer.disconnect();
      clearTimeout(pollId);
    };
  }, []);

Finally, whenever the treeContentsHeight is updated, we can set the containerHeight, and make sure it's updated on resize as well.

  useEffect(() => {
    const resizeContainer = (): void => {
      const screenRefHeight = screenRefHeight.current.getBoundingClientRect().height;
      setTreeHeight(Math.min(screenRefHeight, treeContentsHeight));
    };
    resizeContainer();
    window.addEventListener('resize', resizeContainer);
    return () => window.removeEventListener('resize', resizeContainer);
  }, [treeContentsHeight]);

danielgormly avatar Feb 27 '24 23:02 danielgormly

@danielgormly, you are simply a genius! Thank you, your method helped me implement the dynamic height of the tree container. In my case, height 100vh was drawing extra space after Tree , but I set height to treeContentsHeight and the height actually became dynamic

MalyugaSensei avatar Apr 24 '24 08:04 MalyugaSensei