0

I am working on a multi-page web app in HTML/CSS/JS + jQuery. I am trying to build a sidebar component that expands and collapses on mouseover / mouseout. The sidebar has links to other pages on the application. The expand/collapse is working nicely, except for a very specific use case, and I'm totally stumped.

I'm using a variable mini to keep track of whether the sidebar is in collapsed (mini) or expanded state. I dynamically update the sidebar width based on whether mini is true or false, so when the user moves the mouse over the sidebar, mini = false and the sidebar expands...and when the user moves the mouse out of the sidebar, mini = true and the sidebar collapses.

The bug:

  1. The user hovers mouse over sidebar to expand it

  2. The user clicks on a link and gets routed to a new page

  3. The user keeps the mouse hovering over the sidebar, so that when they land on the new page, the mouse is still over the sidebar

  4. Now, the expand/collapse function is working inversely, so that when the user moves the mouse away, the menu expands, and when the user moves the mouse into the div, the menu collapses and disappears. Frustrating!

I think what's happening is that the sidebar is built in the collapsed state on page load by default. Somehow I need to be able to determine whether the user has the mouse hovering inside the sidebar div on page load, so I can build the sidebar accordingly. I'd super appreciate any ideas! See below for my code. I'm not able to simulate the page routing, but the overall idea should be clear. In my code example, the sidebar functions are working as intended, so I've included screen grabs of what happens in the actual application with the page routing enabled. See below. Thanks!

enter image description here enter image description here

var mini = true;
const logo = $("#nav-logo");
let activeState = false;
let activePage;
let iconsArr = [
  "#dashboard-icon",
  "#projects-icon",
  "#assess-icon",
  "#config-icon",
];

let listItemsArr = [
  "#dashboard-list-item",
  "#projects-list-item",
  "#assess-list-item",
  "#config-list-item",
];

$(function() {
  attachClickListeners();
});

const toggleSidebar = () => {
  if (mini) {
    // sidebar
    $("#mySidebar").css("width", "225px");
    // main
    $("#main").css("margin-left", "225px");
    // logo
    $("#nav-logo").attr("src", "https://www.creativefabrica.com/wp-content/uploads/2018/11/Company-business-generic-logo-by-DEEMKA-STUDIO.jpg");
    $("#nav-logo").css("width", "120px");
    // Logo text
    $("#logo-text-container").show();
    // list item styling
    $(".list-item").css("padding-left", "12px");
    $(".list-item").css("margin-bottom", "4px");
    $(".list-item-text").show();
    // Active state and page
    if (activePage != undefined) {
      // Remove active state from non-active items
      listItemsArr.forEach((item) => {
        if (item[1] != activePage[0]) {
          $(item).removeClass("active");
        }
      });

      // Add active class
      $(`#${activePage}-icon`).removeClass("active");
      $(`#${activePage}-list-item`).addClass("active");
    }

    // mini variable
    this.mini = false;
  } else {
    // sidebar
    $("#mySidebar").css("width", "60px");
    // main
    $("#main").css("margin-left", "60px");
    // logo
    $("#nav-logo").attr("src", "https://www.creativefabrica.com/wp-content/uploads/2018/11/Company-business-generic-logo-by-DEEMKA-STUDIO.jpg");
    $("#nav-logo").css("width", "30px");
    // logo text
    $("#logo-text-container").hide();
    // list item styling
    $(".list-item").css("padding-left", "0px");
    $(".list-item").css("margin-bottom", "6px");
    $(".list-item-text").hide();

    // Active state and page
    if (activePage != undefined) {
      // Active state and page
      if (activePage != undefined) {
        // Remove active state from non-active items
        iconsArr.forEach((item) => {
          if (item[1] != activePage[0]) {
            $(item).removeClass("active");
          }
        });

        // Add active class to active item
        $(`#${activePage}-icon`).addClass("active");
        $(`#${activePage}-list-item`).removeClass("active");
      }
    }

    // mini variable
    this.mini = true;
  }
};

const attachClickListeners = () => {
  $("#dashboard-list-item").off();
  $("#dashboard-list-item").on("click", () => {
    if (!activeState) {
      activeState = true;
    }
    toggleActiveState("#dashboard-icon");
    activePage = "dashboard";
  });

  $("#projects-list-item").off();
  $("#projects-list-item").on("click", () => {
    if (!activeState) {
      activeState = true;
    }
    toggleActiveState("#projects-icon");
    activePage = "projects";
  });

  $("#assess-list-item").off();
  $("#assess-list-item").on("click", () => {
    if (!activeState) {
      activeState = true;
    }
    toggleActiveState("#assess-icon");
    activePage = "assess";
  });

  $("#config-list-item").off();
  $("#config-list-item").on("click", () => {
    if (!activeState) {
      activeState = true;
    }
    toggleActiveState("#config-icon");
    activePage = "config";
  });
};

const toggleActiveState = (id) => {
  let element = $(id);

  iconsArr.forEach((item) => {
    if (item === id) {
      $(element).addClass("active");
    } else {
      $(item).removeClass("active");
    }
  });
};
.active {
  background: lightblue;
}

main .sidebar {
  position: absolute;
  top: 0;
  right: 25px;
  font-size: 36px;
  margin-left: 50px;
}

#main {
  padding: 16px;
  margin-left: 85px;
  transition: margin-left 0.5s;
}

body {
  font-family: "Poppins", sans-serif;
  font-size: 14px;
  font-weight: 400;
}

.sidebar {
  height: 100vh;
  width: 60px;
  position: fixed;
  transition: 0.5s ease;
  left: 0;
  top: 0;
  /* padding-left: 15px; */
  padding-top: 9px;
  background-color: #fafafa;
  border-right: 1px solid #e6e6e6;
  white-space: nowrap;
  overflow-x: hidden;
  z-index: 1;
}

#nav-logo,
#logo-text {
  transition: 0.5s ease;
}

.body-text {
  height: 100%;
  width: 100%;
  margin-left: 250px;
  padding-top: 1px;
  padding-left: 25px;
}

#nav-logo {
  width: 30px;
  margin-left: 15px;
}

#logo-text-container {
  margin-left: 30px;
  display: none;
}

#logo-text {
  font-size: 18px;
  margin-block-start: 0em;
}

.list-item {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  cursor: pointer;
  border: 1px solid transparent;
  border-radius: 100px;
  margin-bottom: 7px;
}

.list-item:hover {
  background: lightblue;
}

.list-item-text {
  font-size: 14px;
  margin-top: 15px !important;
  display: none;
}

.li-text-margin-left {
  margin-left: 7px;
}

#add-assessment-list-item-text {
  margin-left: 4px;
}

#projects-list-item-text {
  margin-left: 1px;
}

#nav-menu-items {
  padding-inline-start: 7px;
  width: 206px;
  transition: 0.5s ease;
}

#nav-menu-items i {
  font-size: 1.2rem;
  /* margin-right: 0.7rem; */
  padding: 10px 10px;
  border-radius: 60px;
}

.list-item-text {
  margin-block-start: 0.2em;
}
<link href="https://kit.fontawesome.com/ee3b09a28a.css" rel="stylesheet" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<!-- Sidebar -->
<div id="mySidebar" class="sidebar" onmouseover="toggleSidebar()" onmouseout="toggleSidebar()">
  <!-- Expanded Logo Image -->
  <div id="logo-image-container">
    <img src="https://www.creativefabrica.com/wp-content/uploads/2018/11/Company-business-generic-logo-by-DEEMKA-STUDIO.jpg" id="nav-logo" />
  </div>
  <!-- /Expanded Logo Image -->
  <!--  Expanded Logo Image Text -->
  <div id="logo-text-container">
    <p id="logo-text">Logo Text</p>
  </div>
  <!--  /Expanded Logo Image Text -->

  <!-- Menu Items -->
  <ul id="nav-menu-items">
    <li class="list-item" id="dashboard-list-item">
      <i class="fa-duotone fa-table-columns" id="dashboard-icon"></i>
      <p class="list-item-text li-text-margin-left">Dashboard</p>
    </li>

    <li class="list-item" id="projects-list-item">
      <i class="fa-duotone fa-rectangle-history-circle-user" id="projects-icon"></i>
      <p class="list-item-text" id="projects-list-item-text">Projects</p>
    </li>

    <li class="list-item" id="assess-list-item">
      <i class="fa-duotone fa-address-card" id="assess-icon"></i>
      <p class="list-item-text" id="add-assessment-list-item-text">
        Add Assessment
      </p>
    </li>

    <li class="list-item" id="config-list-item">
      <i class="fa-duotone fa-folder-gear" id="config-icon"></i>
      <p class="list-item-text li-text-margin-left">Configuration</p>
    </li>
  </ul>
  <!-- /Menu Items -->
</div>
<!-- /Sidebar -->

<!-- Main -->
<div id="main">
  <h2>Open/Collapse Sidebar on Hover</h2>
  <p>Hover over any part of the sidebar to open the it.</p>
  <p>To close the sidebar, move your mouse out of the sidebar.</p>
</div>
<!-- /Main -->

2
  • 1
    Right now you don't differentiate between a mouseover and a mouseout when you call toggleSidebar. You could pass a parameter to toggleSidebar - make mouseOver call toggleSidebar('over') and mouseOut call toggleSidebar('out'). Then inside the toggleSidebar only take action if the parameter is over and mini is false, or the parameter is out and mini is true. Commented Jan 20, 2024 at 7:53
  • Wow, this is brilliant..can't believe I missed this. Thank you so much. Commented Jan 21, 2024 at 18:08

2 Answers 2

1

An expand class name can be used as an alternative to passing a parameter and using the mini variable. The following solution removes the hardcoded CSS style settings in your JavaScript and moves it all to CSS, therefore, simplifying code.

CSS Grid for layout

The solution uses CSS Grid as the layout mechanism for the sidebar and the main content. This allows for setting a specific sidebar width without having to match/set any properties for the main content element. Your code sample sets margin-left on the main element to the value of the sidebar width in the expanded/collapsed state.

Detecting mouse hover inside sidebar on page load

The user keeps the mouse hovering over the sidebar, so that when they land on the new page, the mouse is still over the sidebar ... Somehow I need to be able to determine whether the user has the mouse hovering inside the sidebar div on page load, so I can build the sidebar accordingly.

Per the following Stack Overflow posts, retrieving the mouse/pointer position to set the initial sidebar expand/collapse state is not possible on page load using document.elementFromPoint(x,y) or document.elementsFromPoint(x,y) (i.e. the plural version). The pointer requires movement to trigger a pointer move event in order to retrieve the x, y pointer coordinates.

let sidebarEl;

document.addEventListener("DOMContentLoaded", init);

function init() {
  sidebarEl = document.querySelector("#mySidebar");

  const pageId = document.querySelector("#main").getAttribute("data-page-id");
  const selector = `.list-item[data-page-id="${pageId}"]`;
  const activeLiEl = sidebarEl.querySelector(selector);
  if (activeLiEl) {
    activeLiEl.classList.add("active");
  }

  // https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events
  // "Pointer events are DOM events that are fired for
  // a pointing device. They are designed to create a single
  // DOM event model to handle pointing input devices such as
  // a mouse, pen/stylus or touch (such as one or more fingers)."
  sidebarEl.addEventListener("pointerenter", toggleSidebar);
  sidebarEl.addEventListener("pointerleave", toggleSidebar);

  sidebarEl.querySelectorAll(".list-item").forEach(el => {
    el.addEventListener("click", listItemClick);
  });

  document.querySelector("#toggle-sidebar-overlap")
    .addEventListener("click", toggleSidebarOverlap);
}

function toggleSidebar(e) {
  if (sidebarEl.classList.contains("expand")) {
    // Collapse sidebar
    sidebarEl.classList.remove("expand");
  } else {
    // Expand sidebar
    sidebarEl.classList.add("expand");
  }
}

function listItemClick() {
  const pageId = this.getAttribute("data-page-id");
  console.log(`Go to page ${pageId}`);
  if (pageId) {
    window.location.href = `/${pageId}`;
  }
}

function toggleSidebarOverlap() {
  const method = document.querySelector("body")
    .classList.contains("overlapSidebar") ? "remove" : "add";
  document.querySelector("body").classList[method]("overlapSidebar");
}
:root {
  --sidebar-collapsed-width: 60px;
  --active-bg-color: lightblue;
  --hover-bg-color: #9ebfca;
}

body {
  font-family: "Poppins", sans-serif;
  font-size: 14px;
  font-weight: 400;
  height: 100vh;
  margin: 0;
  padding: 0;
}


/* -----------------------------------
    Sidebar and content layout
*/

body {
  display: grid;
  grid-auto-flow: column;
  grid-template-columns: auto 1fr;
}

body.overlapSidebar {
  grid-template-columns: var(--sidebar-collapsed-width) 1fr;
}


/* -----------------------------------
    Sidebar
*/

.sidebar {
  width: var(--sidebar-collapsed-width);
  font-size: 36px;
  transition: 0.5s ease;
  padding-top: 9px;
  background-color: #fafafa;
  border-right: 1px solid #e6e6e6;
  white-space: nowrap;
  overflow-x: hidden;
  z-index: 1;
}

.sidebar>ul {
  padding: 7px;
}


/* -----------------------------------
    Sidebar logo image and logo text
*/

#nav-logo,
#logo-text {
  transition: 0.5s ease;
}

#logo-image-container {
  display: flex;
}

#nav-logo {
  width: 30px;
  margin-left: 15px;
}

#logo-text-container {
  margin-left: 30px;
  display: none;
}

#logo-text {
  font-size: 18px;
  margin-block-start: 0em;
}


/* -----------------------------------
    Sidebar list item
*/

.sidebar .list-item {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  cursor: pointer;
  border: 1px solid transparent;
  border-radius: 100px;
  margin-bottom: 7px;
}

.sidebar .list-item:hover {
  background-color: var(--hover-bg-color);
}

.sidebar .list-item i {
  font-size: 1.2rem;
  padding: 10px 10px;
  border-radius: 60px;
}

.sidebar .list-item.active i {
  background-color: var(--active-bg-color);
}

.sidebar .list-item p {
  display: none;
  font-size: 14px;
  margin-top: 15px !important;
  margin-block-start: 0.2em;
}


/* -----------------------------------
    Sidebar expanded styles.
    The following CSS rules override style
    settings previously set.
*/

.sidebar.expand {
  width: 225px;
}

.sidebar.expand #nav-logo {
  width: 120px;
}

.sidebar.expand #logo-text-container,
.sidebar.expand .list-item p {
  display: block;
}

.sidebar.expand .list-item {
  padding-left: 12px;
  margin-bottom: 4px;
}

.sidebar.expand .list-item.active {
  background-color: var(--active-bg-color);
}

.sidebar.expand .list-item.active i {
  background-color: unset;
}


/* -----------------------------------
    Main content
*/

#main {
  padding: 16px;
}

.body-text {
  height: 100%;
  width: 100%;
  margin-left: 250px;
  padding-top: 1px;
  padding-left: 25px;
}
<link href="https://kit.fontawesome.com/ee3b09a28a.css" rel="stylesheet" />

<!-- Sidebar -->
<div id="mySidebar" class="sidebar">
  <!-- Expanded Logo Image -->
  <div id="logo-image-container">
    <img src="https://www.creativefabrica.com/wp-content/uploads/2018/11/Company-business-generic-logo-by-DEEMKA-STUDIO.jpg" id="nav-logo" />
  </div>
  <!-- /Expanded Logo Image -->
  <!--  Expanded Logo Image Text -->
  <div id="logo-text-container">
    <p id="logo-text">Logo Text</p>
  </div>
  <!--  /Expanded Logo Image Text -->
  <!-- Menu Items -->
  <ul id="nav-menu-items">
    <li class="list-item" data-page-id="dashboard">
      <i class="fa-duotone fa-table-columns"></i>
      <p>Dashboard</p>
    </li>

    <li class="list-item" data-page-id="projects">
      <i class="fa-duotone fa-rectangle-history-circle-user"></i>
      <p>Projects</p>
    </li>

    <li class="list-item" data-page-id="assess">
      <i class="fa-duotone fa-address-card"></i>
      <p>Add Assessment</p>
    </li>

    <li class="list-item" data-page-id="config">
      <i class="fa-duotone fa-folder-gear"></i>
      <p>Configuration</p>
    </li>
  </ul>
  <!-- /Menu Items -->
</div>
<!-- /Sidebar -->

<!-- Main -->
<div id="main" data-page-id="dashboard">
  <h1>Dashboard</h1>
  <h2>Open/Collapse Sidebar on Hover</h2>
  <p>Hover over any part of the sidebar to open the it.</p>
  <p>To close the sidebar, move your mouse out of the sidebar.</p>
  <p>Click button to toggle how the sidebar expands: overlap main content or pushes main content to the right.
    <button id="toggle-sidebar-overlap">Toggle sidebar overlap</button>
</div>
<!-- /Main -->

Sign up to request clarification or add additional context in comments.

5 Comments

I learned a lot from your answer and the way you optimized the code! Thank you. As far as the mouseover bug, I'm finding that I still have the same issue with your optimized code, where the toggleSidebar function stops working properly if the mouse remains in the sidebar div during window relocation.
The issue of the sidebar not expanding when the pointer/mouse is over the sidebar on window relocation has to do with the browser (tested on Chrome, Edge, Firefox) not firing a pointer event on page load. See all of the discussions on this topic in the SO posts listed in my answer. This issue has been present for many years. I tried all of the workarounds like dispatching an pointer/mouse event on page load but the browser will not report the pointer position.
The pointer has to move at least 1px for the pointer event to trigger and for the sidebar to expand if the pointer if over the sidebar on page load.
Regarding the issue of the sidebar expand/collapse being reversed, I couldn't reproduce this scenario. I used the pointerenter and pointerleave events versus the mouseover and mouseout events in your original code. The mouseover will fire continuously when the mouse moves over list items in the sidebar. Add the line console.log("toggle sidebar"); in the toggleSidebar() function to see this behavior. Listening for a pointer enter and exit event resolves this.
Thanks for your reply here. I understand what you're saying about reading the pointer events and appreciated reading the links you sent. I was able to get this working by passing in a param to the toggleSidebar function. On pointerenter, I pass the param "enter", and on pointerleave, I pass the param "leave". This way, the function can just read the param and decide what to do accordingly. If the new page loads with the pointer still inside the sidebar div, then the function is triggered by the mouse leaving the div, and the param 'leave' is passed in, so the function knows what to do.
0

I got some helpful answers here. The solution proposed by @James in the first comment is the only thing that worked. I needed to pass a param to the toggleSidebar function that would track whether there was a pointerenter or pointerleave event and then behave accordingly.

While @DaveB's answer did not solve my bug, it did tremendously optimize my code!

Here's what my toggleSidebar function looks like now. Otherwise everything is the same as in DaveB's code.

function init() {
  // all the same code from Dave B's example, see below for the only change:

  $(sidebarEl).on("pointerenter", () => {
    toggleSidebar("enter");
  });
  $(sidebarEl).on("pointerleave", () => {
    toggleSidebar("leave");
  });
}

// This function now takes a param, and chooses whether to collapse
// or expand based on whether the param is 'enter' or 'leave'
function toggleSidebar(pointer) {
  if (pointer === "leave") {
      // Collapse sidebar
      sidebarState = "collapse";
      sidebarEl.classList.remove("expand");
  } else if (pointer === "enter") {
      // Expand sidebar
      sidebarState = "expand";
      sidebarEl.classList.add("expand");
  }
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.