reactjstypescriptnext.jsresponsive-designtoolbar

How to make responsive Toolbar (with collapsible overflowing buttons) without using setTimeout()?


I'm creating a toolbar for my richTextEditor (tiptap). I wanted the buttons overflowing editors width to hide under "more" button. I came across reddit post with flow explained, but I'm struggling with implementation. Reddit post.

return (
    <nav
      className={cn("flex flex-grow gap-1 rounded-md bg-white p-1", className)}
      ref={mainToolbarRef}
    >
      <TooltipProvider>
        {/* Color Picker */}
        {isButtonVisible("color") && (
          <Tooltip>
          ...
          <Tooltip/>
       /* Headings */}
        {isButtonVisible("heading") && (
          <Popover>
            ...
          </Popover>
        )}

then there is "more" button with copy of this whole list and conditional on MoreVisible I'm checking there !isButtonVisible && etc.

I was trying to use useEffect, useLayoutEffect and useRef to handle width measurements and adjustments. My approach looked like this:

 const mainToolbarRef = useRef<HTMLElement>(null);
  const [visibleButtons, setVisibleButtons] = useState<ButtonName[]>([]);
  const [showMore, setShowMore] = useState(false);

  useLayoutEffect(() => {
    const button_name_list = [...BUTTON_NAMES_LIST];
    const doAdapt = () => {
      const mainToolbar = mainToolbarRef.current;
      if (mainToolbar) {
        setVisibleButtons(button_name_list);

        let isOverflowing = false;
        const buttonsArray = Array.from(mainToolbar.children);

        buttonsArray.forEach((button) => {
          if (!isFullyVisible(button as HTMLElement)) {
            isOverflowing = true;
            return false;
          }
        });

        if (isOverflowing) {
          const tempVisibleButtons: ButtonName[] = [];

          buttonsArray.forEach((button, index) => {
            const btn = button as HTMLElement;
            if (isFullyVisible(btn)) {
              tempVisibleButtons.push(button_name_list[index]!);
            }
          });

          setVisibleButtons(tempVisibleButtons);
          setShowMore(true);
        } else {
          setShowMore(false);
        }
      }
    };

    const isFullyVisible = (element: HTMLElement) => {
      const rect = element.getBoundingClientRect();
      const mainToolbarRect = mainToolbarRef.current?.getBoundingClientRect();
      return rect.right <= (mainToolbarRect!.right - 40 || 0);
    };

    window.addEventListener("resize", doAdapt);
    doAdapt();

    return () => {
      window.removeEventListener("resize", doAdapt);
    };
  }, [mainToolbarRef]);

  // if in visibleButtons
  const isButtonVisible = (buttonName: ButtonName) => {
    return visibleButtons.includes(buttonName);
  };

The main error was that on first load my toolbar wouldn't adapt. I had to resize window to trigger adapt function properly. I did some checking and I know that on first run of doAdapt()

        mainToolbar.children // type HTMLCollection, 11 elements
        const buttonsArray = Array.from(mainToolbar.children) // 0 elements, empty list

buttonsArray was not properly created.

Now I managed to create something like that.

onst button_name_list = useMemo(() => [...BUTTON_NAMES_LIST], []);

  const doAdapt = useCallback(() => {
    const mainToolbar = mainToolbarRef.current;
    if (mainToolbar) {
      setVisibleButtons([...BUTTON_NAMES_LIST]);

      let isOverflowing = false;
      const buttonsArray = Array.from(mainToolbar.children);

      buttonsArray.forEach((button) => {
        if (!isFullyVisible(button as HTMLElement)) {
          isOverflowing = true;
          return false;
        }
      });

      if (isOverflowing) {
        const tempVisibleButtons: ButtonName[] = [];

        buttonsArray.forEach((button, index) => {
          const btn = button as HTMLElement;
          if (isFullyVisible(btn)) {
            tempVisibleButtons.push(BUTTON_NAMES_LIST[index]!);
          }
        });

        setVisibleButtons(tempVisibleButtons);
        setShowMore(true);
      } else {
        setShowMore(false);
      }
    }
  }, [mainToolbarRef]);

  const isFullyVisible = (element: HTMLElement) => {
    const rect = element.getBoundingClientRect();
    const mainToolbarRect = mainToolbarRef.current?.getBoundingClientRect();
    return rect.right <= (mainToolbarRect!.right - 40 || 0);
  };

  useLayoutEffect(() => {
    const handleResize = () => {
      doAdapt();
    };

    window.addEventListener("resize", handleResize);

    // Delay the first call to doAdapt to ensure elements are mounted
    const timer = setTimeout(() => {
      doAdapt();
    }, 100);

    return () => {
      window.removeEventListener("resize", handleResize);
      clearTimeout(timer);
    };
  }, [doAdapt]);

  useLayoutEffect(() => {
    if (mainToolbarRef.current) {
      doAdapt();
    }

    window.addEventListener("resize", doAdapt);

    return () => {
      window.removeEventListener("resize", doAdapt);
    };
  }, [doAdapt]);

  const isButtonVisible = (buttonName: ButtonName) => {
    return visibleButtons.includes(buttonName);
  };

It works but I feel like it's awful and should look like that

Please help guys :>


Solution

  • The core of the issue is that you have a sort of "chicken and egg" problem.

    You currently guard the buttons with the isButtonVisible check. If this returns false then the entire button will not render. The result of isButtonVisible is determined by the logic which measures if the button fits. However, it can't measure something which is not there.

    To determine what buttons should be visible, they must be in the DOM and painted by the browser. Otherwise, there is nothing to measure. For this reason, on mount mainToolbarRef.current.children is empty, so the overflow check can't be performed. To take the measurements you need, the elements have to actually be rendered in the first place.

    The reason it appears to work on resize is that you currently call setVisibleButtons([...BUTTON_NAMES_LIST]); inside of doAdapt, which sets them all to visible again. I suspect this was a previous fix/hack, but it was a fix to the symptom and not the root cause of the issue.

    This will actually only make them visible again a short time later since the state setting is not immediately effective (React queues the update).

    Because resize events often occur as many events in quick succession as the user drags their mouse to resize the window, a future event then experiences the "all visible" DOM state, and it is able to measure the buttons. This means that the resize behaviour is also a little broken, with it responding to n+1 events later than it actually should. It probably also quickly flip-flops between good and bad states, and it's only due to how React batches update that it appears to work ok on resize under certain conditions. If there was only a single resize event like on a mobile device portrait/landscape orientation change, it's probably broken currently in that case too.

    But if they have to be in the DOM to know if they should appear, then how do we control their visibility?

    Essentially you need to render them all of the time, but control their visibility using the CSS visibility property conditionally set to hidden or visible. Note, display: none won't work here either as that means they are still not painted. visibility has the unique property that even if it has visibility: hidden, it is still internally inserted into the DOM flow. You can see this by hovering over the button when it's in this state in dev tools -- it still has dimensions and is "present".

    So we first need to remove the render condition on each button within the toolbar and move this to control the visibility CSS property. For example:

    {isButtonVisible('color') && (
      <Tooltip>
        <TooltipTrigger asChild>
    

    Becomes:

    <Tooltip>
      <TooltipTrigger
        asChild
        style={{
          visibility: isButtonVisible('color') ? 'visible' : 'hidden',
        }}
      >
    

    Note you may want to use CSS classes to do this instead. Note, we do not change the guards of the buttons that exist within the overflow menu. They can stay the same, since we are not measuring those. Only the ones inside the toolbar directly need this change.

    Also note that the "triple dot" view more icon itself, does also need this change since it is within the toolbar. And we also need to make a change to the CSS here to position it correctly now the other buttons are "rendered but hidden" such that it is not pushed off the edge of the screen:

    {/* More */}
    <Popover>
      <Tooltip>
      <TooltipTrigger
        asChild
        className="h-9 w-9 shrink-0 cursor-pointer rounded-md px-2.5 transition-colors hover:bg-muted hover:text-muted-foreground"
        style={{
          position: 'absolute',
          right: 0,
          visibility: showMore ? 'visible' : 'hidden',
         }}
      >
    

    Finally, we can clean up the resize logic to remove previous hacks around visibility that are no longer needed.

      const mainToolbarRef = useRef<HTMLElement>(null);
      const [visibleButtons, setVisibleButtons] = useState<ButtonName[]>([]);
      const [showMore, setShowMore] = useState(false);
    
      const doAdapt = useCallback(() => {
        const mainToolbar = mainToolbarRef.current;
        if (mainToolbar) {
          const buttonsArray = Array.from(mainToolbar.children);
    
          const tempVisibleButtons: ButtonName[] = [];
    
          buttonsArray.forEach((button, index) => {
            const btn = button as HTMLElement;
            if (isFullyVisible(btn)) {
              tempVisibleButtons.push(BUTTON_NAMES_LIST[index]!);
            }
          });
    
          setVisibleButtons(tempVisibleButtons);
          setShowMore(tempVisibleButtons.length < buttonsArray.length - 1);
        }
      }, [mainToolbarRef]);
    
      const isFullyVisible = (element: HTMLElement) => {
        const rect = element.getBoundingClientRect();
        const mainToolbarRect = mainToolbarRef.current?.getBoundingClientRect();
        return rect.right <= (mainToolbarRect!.right - 40 || 0);
      };
    
      useLayoutEffect(() => {
        doAdapt();
    
        window.addEventListener('resize', doAdapt);
    
        return () => {
          window.removeEventListener('resize', doAdapt);
        };
      }, [doAdapt]);
    
      const isButtonVisible = (buttonName: ButtonName) => {
        return visibleButtons.includes(buttonName);
      };
    
    

    The main changes here are that we no longer have the timeout, and we no longer set all buttons to visible again at any point. There is no need, as they are now measurable at all times no matter if they are in the visible button list at that time, or not. I also did a bit of minor cleanup to avoid looping multiple times unnecessarily by determining the show more button visibility dynamically based on array lengths.

    I've put this all together into a working StackBlitz. This works correctly both on mount and on resize (including fixing the aforementioned hidden "resize" bugs that lay in wait).