Simultaneously opening the second submenu and closing the previous submenu with a height transition results in the second submenu being positioned off-screen

Sample: https://codesandbox.io/p/sandbox/navbar-k2szsq (or refer to the code snippet below)

In my navbar, I have implemented multiple submenus with a height transition when the user opens or closes a submenu. However, only one submenu can be open at a time. This means that if a user tries to open another submenu without closing the first one, the previous one automatically closes. Here lies the challenge:

When the user opens a submenu located below the previously opened submenu, both having considerable content height and undergoing height transitions simultaneously, it causes the page to scroll down away from the newly opened submenu (please run the code snippet below and follow these steps: First open "Submenu 1" and leave it open, then open "Submenu 2" to observe the issue mentioned).

Is there an elegant solution to address this problem? Waiting for both transitions to end before scrolling to the new submenu may not provide a good user experience. Similarly, closing the previous submenu first and waiting for it to close before opening the new one is not desirable either. So, is there a smooth and user-friendly solution to simultaneously close the previous submenu and open the new one with a height transition, while ensuring that the new submenu remains on screen (at least making its button visible at the top edge of the navbar)?

generateNavbar(".navbar");

$(".submenu-title").on("click", function() {
  const $submenuTitle = $(this);
  const $submenu = $submenuTitle.closest(".submenu");
  $submenu.siblings(".submenu.opened").each(function() {
    toggleSubmenu($(this), false);
  });
  toggleSubmenu($submenu, !$submenu.hasClass("opened"));
});

$(".submenu-content").on("transitionend", function() {
  const $submenuContent = $(this);

  if ($submenuContent.hasClass("opened")) {
    $submenuContent.css("height", "");
  }
});

function toggleSubmenu($submenu, isOpen) {
  const $submenuContent = $submenu.find(".submenu-content");

  if (isOpen) {
    $submenuContent.css("height", $submenuContent.get(0).scrollHeight);
  } else {
    $submenuContent.css("height", $submenuContent.get(0).scrollHeight);
    // reflow
    $submenuContent.get(0).offsetHeight;
    $submenuContent.css("height", 0);
  }

  $submenu.toggleClass("opened");
}

function generateNavbar(selector) {
  // data
  const submenusItemsCount = [
    36, 27, 14, 1, 6, 3, 23, 50, 21, 4, 5, 50, 3, 12, 4, 4, 6, 9, 20, 2,
  ];

  const submenus = submenusItemsCount.map(
    (submenuItemsCount, submenuIndex) => ({
      title: `Submenu ${submenuIndex + 1}`,
      items: [...new Array(submenuItemsCount)].map(
        (_, itemIndex) =>
        `Submenu ${submenuIndex + 1} - Item ${itemIndex + 1}`
      ),
    })
  );

  // dom
  const $navbar = $(selector);

  const $submenus = submenus.map((submenu) => {
    const $submenu = $('<div class="submenu" data-submenu />');
    const $submenuTitle = $(
      `<div class="submenu-title" data-submenu-title>${submenu.title}</div>`
    );
    const $submenuContent = $(
      `<div class="submenu-content" style="height: 0" data-submenu-content />`
    );
    submenu.items.map((submenuItem) => {
      $submenuContent.append(
        `<div class="submenu-item" data-submenu-item>${submenuItem}</div>`
      );
    });

    $submenu.append($submenuTitle);
    $submenu.append($submenuContent);

    return $submenu;
  });

  $navbar.html($submenus);
}
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.backdrop {
  position: fixed;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  top: 0;
  left: 0;
}

.navbar {
  position: fixed;
  width: 100%;
  height: 80%;
  z-index: 100;
  background-color: #fff;
  bottom: 0;
  left: 0;
  overflow-y: scroll;
}

.submenu-title {
  display: flex;
  align-items: center;
  padding: 16px 8px;
  width: 100%;
  height: 50%;
  border-bottom: 1px solid #9e9e9e;
  cursor: pointer;
}

.submenu-content {
  background-color: #f0f0f0;
  transition: height 325ms ease;
  overflow: hidden;
}

.submenu-item {
  padding: 16px 8px;
  height: 50px;
  border-bottom: 1px solid #9e9e9e;
  cursor: pointer;
}
<div class="backdrop"></div>
<div class="navbar">Loading...</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>

Answer №1

I have implemented a callback function for the ToggleSubMenu to handle the transition event, which triggers when the transition is completed.

$(document).ready(function() {
    generateNavbar(".navbar");

    $(".submenu-title").on("click", function() {
        const $submenuTitle = $(this);
        const $submenu = $submenuTitle.closest(".submenu");
        const navbar = $('.navbar');

        $submenu.siblings(".submenu.opened").each(function() {
            toggleSubmenu($(this), false);
        });

        toggleSubmenu($submenu, !$submenu.hasClass("opened"), function(openedSubmenu) {
            if (openedSubmenu.hasClass("opened")) {
                const offsetTop = openedSubmenu.offset().top - navbar.offset().top + navbar.scrollTop();
                navbar.animate({ scrollTop: offsetTop }, 325);
            }
        });
    });

    function toggleSubmenu($submenu, isOpen, callback) {
        const $submenuContent = $submenu.find(".submenu-content");

        if (isOpen) {
            $submenuContent.css("height", $submenuContent.get(0).scrollHeight);
            $submenu.addClass("opened");
        } else {
            $submenuContent.css("height", $submenuContent.get(0).scrollHeight);
            // reflow
            $submenuContent.get(0).offsetHeight;
            $submenuContent.css("height", 0);
            $submenu.removeClass("opened");
        }

        $submenuContent.off('transitionend').on('transitionend', function() {
            if (typeof callback === 'function') {
                callback($submenu);
            }
        });
    }

    function generateNavbar(selector) {
        const submenusItemsCount = [
            36, 27, 14, 1, 6, 3, 23, 50, 21, 4, 5, 50, 3, 12, 4, 4, 6, 9, 20, 2,
        ];

        const submenus = submenusItemsCount.map(
            (submenuItemsCount, submenuIndex) => ({
                title: `Submenu ${submenuIndex + 1}`,
                items: [...new Array(submenuItemsCount)].map(
                    (_, itemIndex) =>
                    `Submenu ${submenuIndex + 1} - Item ${itemIndex + 1}`
                ),
            })
        );

        const $navbar = $(selector);
        const $submenus = submenus.map((submenu) => {
            const $submenu = $('<div class="submenu" data-submenu />');
            const $submenuTitle = $(`<div class="submenu-title" data-submenu-title>${submenu.title}</div>`);
            const $submenuContent = $(`<div class="submenu-content" style="height: 0" data-submenu-content />`);
            submenu.items.map((submenuItem) => {
                $submenuContent.append(
                    `<div class="submenu-item" data-submenu-item>${submenuItem}</div>`
                );
            });

            $submenu.append($submenuTitle);
            $submenu.append($submenuContent);

            return $submenu;
        });

        $navbar.html($submenus);
    }
});
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}

.backdrop {
position: fixed;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
top: 0;
left: 0;
}

.navbar {
position: fixed;
width: 100%;
height: 80%;
z-index: 100;
background-color: #fff;
bottom: 0;
left: 0;
overflow-y: scroll;
}

.submenu-title {
display: flex;
align-items: center;
padding: 16px 8px;
width: 100%;
height: 50%;
border-bottom: 1px solid #9e9e9e;
cursor: pointer;
}

.submenu-content {
background-color: #f0f0f0;
transition: height 325ms ease;
overflow: hidden;
}

.submenu-item {
padding: 16px 8px;
height: 50px;
border-bottom: 1px solid #9e9e9e;
cursor: pointer;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<div class="backdrop"></div>
<div class="navbar">Loading...</div>

Answer №2

generateNavbar(".navbar");

$(".submenu-title").on("click", function() {
  const $submenuTitle = $(this);
  const $submenu = $submenuTitle.closest(".submenu");

  // Calculate the offset before the transition
  const initialOffset = $submenuTitle.offset().top - $('.navbar').scrollTop();

  // Toggle the current submenu and close others
  $submenu.siblings(".submenu.opened").each(function() {
    toggleSubmenu($(this), false);
  });
  toggleSubmenu($submenu, !$submenu.hasClass("opened"));

  // Adjust scroll position after transition over
  $submenu.one('transitionend', function() {
    if ($submenu.hasClass("opened")) {
      const currentOffset = $submenuTitle.offset().top - $('.navbar').scrollTop();
      const scrollPosition = $('.navbar').scrollTop();
      $('.navbar').scrollTop(scrollPosition + (currentOffset - initialOffset));
    }
  });
});

function toggleSubmenu($submenu, isOpen) {
  const $submenuContent = $submenu.find(".submenu-content");

  if (isOpen) {
    $submenuContent.css("height", $submenuContent.get(0).scrollHeight);
  } else {
    $submenuContent.css("height", $submenuContent.get(0).scrollHeight);
    // reflow
    $submenuContent.get(0).offsetHeight;
    $submenuContent.css("height", 0);
  }

  $submenu.toggleClass("opened");
}

function generateNavbar(selector) {
  // data
  const submenusItemsCount = [
    36, 27, 14, 1, 6, 3, 23, 50, 21, 4, 5, 50, 3, 12, 4, 4, 6, 9, 20, 2,
  ];

  const submenus = submenusItemsCount.map(
    (submenuItemsCount, submenuIndex) => ({
      title: `Submenu ${submenuIndex + 1}`,
      items: [...new Array(submenuItemsCount)].map(
        (_, itemIndex) =>
        `Submenu ${submenuIndex + 1} - Item ${itemIndex + 1}`
      ),
    })
  );

  // dom
  const $navbar = $(selector);

  const $submenus = submenus.map((submenu) => {
    const $submenu = $('<div class="submenu" data-submenu />');
    const $submenuTitle = $(
      `<div class="submenu-title" data-submenu-title>${submenu.title}</div>`
    );
    const $submenuContent = $(
      `<div class="submenu-content" style="height: 0" data-submenu-content />`
    );
    submenu.items.map((submenuItem) => {
      $submenuContent.append(
        `<div class="submenu-item" data-submenu-item>${submenuItem}</div>`
      );
    });

    $submenu.append($submenuTitle);
    $submenu.append($submenuContent);

    return $submenu;
  });

  $navbar.html($submenus);
}
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.backdrop {
  position: fixed;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  top: 0;
  left: 0;
}

.navbar {
  position: fixed;
  width: 100%;
  height: 80%;
  z-index: 100;
  background-color: #fff;
  bottom: 0;
  left: 0;
  overflow-y: scroll;
}

.submenu-title {
  display: flex;
  align-items: center;
  padding: 16px 8px;
  width: 100%;
  height: 50%;
  border-bottom: 1px solid #9e9e9e;
  cursor: pointer;
}

.submenu-content {
  background-color: #f0f0f0;
  transition: height 325ms ease;
  overflow: hidden;
}

.submenu-item {
  padding: 16px 8px;
  height: 50px;
  border-bottom: 1px solid #9e9e9e;
  cursor: pointer;
}
<div class="backdrop"></div>
<div class="navbar">Loading...</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>

calculated the distance of the submenu from the top of the screen before any opening or closing initialOffset the opening and closing transitions of the submenus as usual toggleSubmenu after the transitions over adjust the scroll position based on the new position of the submenu scrollTop

Answer №3

I made a few adjustments to your code, instead of directly toggling the submenu in your code, I created a variable called isOpening to keep track of whether it is opening or closing. I also introduced a new function called scrollIntoViewIfNeeded which checks if the submenu is outside of the viewport and scrolls to it if needed.

    <script>
  generateNavbar(".navbar");

  $(".submenu-title").on("click", function () {
    const $submenuTitle = $(this);
    const $submenu = $submenuTitle.closest(".submenu");
    const isOpening = !$submenu.hasClass("opened");

    $submenu.siblings(".submenu.opened").each(function () {
        toggleSubmenu($(this), false);
    });
    toggleSubmenu($submenu, isOpening);

    if (isOpening) {
        // Wait for the transition to complete before scrolling
        $submenu.find(".submenu-content").one("transitionend", function () {
            scrollIntoViewIfNeeded($submenuTitle);
        });
    }
  });

  function scrollIntoViewIfNeeded($element) {
    const rect = $element[0].getBoundingClientRect();
    if (rect.top < 0 || rect.bottom > window.innerHeight) {
      $element[0].scrollIntoView({ behavior: "smooth", block: "start" });
    }
  }

  $(".submenu-content").on("transitionend", function () {
    const $submenuContent = $(this);

    if ($submenuContent.hasClass("opened")) {
      $submenuContent.css("height", "");
    }
  });

  function toggleSubmenu($submenu, isOpen) {
    const $submenuContent = $submenu.find(".submenu-content");

    if (isOpen) {
      $submenuContent.css("height", $submenuContent.get(0).scrollHeight);
    } else {
      $submenuContent.css("height", $submenuContent.get(0).scrollHeight);
      // reflow
      $submenuContent.get(0).offsetHeight;
      $submenuContent.css("height", 0);
    }

    $submenu.toggleClass("opened");
  }

  function generateNavbar(selector) {
    // data
    const submenusItemsCount = [
      36, 27, 14, 1, 6, 3, 23, 50, 21, 4, 5, 50, 3, 12, 4, 4, 6, 9, 20, 2,
    ];

    const submenus = submenusItemsCount.map(
      (submenuItemsCount, submenuIndex) => ({
        title: `Submenu ${submenuIndex + 1}`,
        items: [...new Array(submenuItemsCount)].map(
          (_, itemIndex) =>
            `Submenu ${submenuIndex + 1} - Item ${itemIndex + 1}`
        ),
      })
    );

    // dom
    const $navbar = $(selector);

    const $submenus = submenus.map((submenu) => {
      const $submenu = $('<div class="submenu" data-submenu />');
      const $submenuTitle = $(
        `<div class="submenu-title" data-submenu-title>${submenu.title}</div>`
      );
      const $submenuContent = $(
        `<div class="submenu-content" style="height: 0" data-submenu-content />`
      );
      submenu.items.map((submenuItem) => {
        $submenuContent.append(
          `<div class="submenu-item" data-submenu-item>${submenuItem}</div>`
        );
      });

      $submenu.append($submenuTitle);
      $submenu.append($submenuContent);

      return $submenu;
    });

    $navbar.html($submenus);
  }

  
</script>`

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

When the button is not clicked, the function method gets invoked in React

When I call the initiateVideoCall method first and have a button called turnOff, it seems to load first without clicking the button. I'm having trouble understanding the issue here. Can someone please help me? Thanks in advance. const constraints = {& ...

Insert round sphere shapes in the corners of the cells on the Table

I am working on a GUI representation table that features cells with spheres in the corner. Additionally, the footer contains a plus symbol (font-awesome icon) in the corner. I have attempted to place the sphere so that it aligns only inside the cell. Here ...

Choose Your Preferences When the Page Loads

I have a dropdown menu where I can select multiple options at once without checkboxes. When the page loads, I want all of the options to be pre-selected by default. I am aware that I can use $(document).ready() to trigger actions after the page has load ...

Assign the value in the text box to the PHP session variable

Currently, I am developing a PHP "interactive text game" as a part of my assignment project. My task involves creating a user interface where users input information (such as character names) into text boxes that appear sequentially as they complete the p ...

Ways to display additional selected values in a select menu

I have a basic form on my website that includes options like: Select 1 and Select 2. I would like to set it up so that if I select value Home in Select 1, the value in Select 2 changes to something like residence. How can I achieve this using a database? ...

What steps should I take to incorporate Google AdSense advertisements onto my website?

After developing a website using HTML and PHP, I attempted to incorporate Adsense ads. However, upon inserting the ad code between the body tags, the ads failed to display on the site. How can I resolve this issue? ...

Transitioning from LESS to SASS involves adapting responsive breakpoints

Adapting from Less to Sass has been my current focus. I have defined responsive breakpoints in Less variables: /* Breakpoints */ @break1:~"(max-width: 570px)"; @break2:~"(min-width: 571px) and (max-width: 1002px)"; @break3:~"(min-width: 1003px)"; These ...

Struggling to implement .indexOf() in conjunction with .filter()

Hello, I'm new to JavaScript and ES6. Currently, I am working on a react-native app that utilizes Firebase and Redux. One of my action creators acts as a search bar function to fetch data from Firebase. Here's the code I have so far: export cons ...

How can I make col-8 and col-4 display next to each other?

Here is the code snippet I recently submitted: <section class="main-container"> <div class="grid-row no-gutters"> <div class="column-8"> <img class="full-width-img& ...

Providing parameters to a dynamic component within NextJS

I am dynamically importing a map component using Next.js and I need to pass data to it through props. const MapWithNoSSR = dynamic(() => import("../Map"), { ssr: false, loading: () => <p>...</p>, }); Can anyone sugges ...

Creating a segmented button utilizing HTML, CSS, and Bootstrap

To create a segment button using only HTML, CSS, and Bootstrap, refer to the example in the image below. It's important to understand the difference between toggle and segment buttons. A toggle button switches states on click, while a segment button c ...

Step-by-step guide on building an admin dashboard for your React application

Recently, I built an online store website using React. Currently, the data is being loaded from a local .json file. However, I am in need of creating an admin panel to allow the site administrator to manage and update cards and data on their own. Is there ...

Ways to stop flask from automatically opening links in a new tab

I created a header that contains different links to various sections of my web application: <div class="collapse navbar-collapse" id="navbarSupportedContent"> <!-- Left --> <ul class="navbar-nav mr-auto"> <li ...

Spin a Material UI LinearProgress

I'm attempting to create a graph chart using Material UI with the LinearProgress component and adding some custom styling. My goal is to rotate it by 90deg. const BorderLinearProgressBottom = withStyles((theme) => ({ root: { height: 50, b ...

Direction of Agm: Display the panel within a separate component

I have a unique setup where I have divided my page into two components, with one taking up 70% of the space and the other occupying 30%. The first component contains a map while the second one is meant to display information on how to reach a destination ( ...

There are no headers present in the response from apollo-client

I am currently utilizing a graphql api along with a vue.js frontend that incorporates the apollo client for fetching data from the backend. This setup has been operating smoothly thus far. In each response header, the server sends back a new JWT-Token whi ...

"Choose" with the icon permanently attached

I am looking to customize a select element to choose a month. I want the dropdown list to display only the name of the months (e.g., January, February). However, in the selected value box of the select element, I would like to show a FontAwesome icon repre ...

showing the upload preview and disabling automatic uploading in Dropzone with React

Currently, when the user clicks the upload button and selects a file, it automatically uploads the file. However, I do not want this automatic upload to happen. Instead, I want to display the selected files to the user first before uploading them. I need ...

Using the React Hook useCallback with no dependencies

Is it beneficial to utilize useCallback without dependencies for straightforward event handlers? Take, for instance: const MyComponent = React.memo(() => { const handleClick = useCallback(() => { console.log('clicked'); }, []); ...

Halt the iteration once you reach the initial item in the array

I am encountering a challenge with this for loop. My goal is to extract the most recent order of "customers" and save it in my database. However, running this loop fetches both the failed order and the recent order. for (var i = 0; i < json.length; ...