Search code examples
javascriptcssreactjsmouseeventsettimeout

Conditionally displaying React components with hover and timeouts


I ran into a tricky React layout problem. It's a simple enough problem, just hard to explain so I tried to be as explicit as possible. I have some data which maps individual components like this:

map => <TableRow name={props.name} actionOne={props.someAction} />

Name Type Show/Hide Controls Buttons (hidden by default)
John Deere Tractor show/hide <div style="hidden"><button id="actionOneButton"></div>
John Smith Person show/hide <div style="hidden"><button id="actionOneButton"></div>

The control buttons are hidden by default. The idea here is to show the button tools only when the user dwells on a certain row OR if the user decides to have some of the control buttons permanently visible on certain rows

We need two things to happen:

  1. When entering a table and hovering over a row, there is a 500msec delay, after which the action buttons only for that row appear. If at this point after that we move the cursor up or down to different rows within the, there is no delay, and the newly hovered rows reveal their corresponding button immediately and the previously shown button in the previous row is hidden immediately

  2. When a user moves the cursor outside the table, there is a countdown of 500msec. During this time, a button that's last been displayed on a row, stays displayed. Once the timer is up, the row which last had a revealed button, now hides it. Think of it as "show on hover," but with a 500msec delay upon entering and exiting the table.

Almost done!

  1. One caveat: clicking on show link will display the button in that row permanently, while the hide link returns the button to the original condition by hiding it. (We're back to #1) Manually shown buttons stick around permanently until closed, at which point they behave the same as at the start.

Important note: If the cursor exits the table and then hovers back inside the table before the "exit timer" ticks down:

  • Previously highlighted row displaying buttons remains visible while the cursor is outside the table; it is then hidden when the exit timeout of 500ms is reached.
  • Meanwhile, as the above is timing out and is about to disappear, the cursor which has now entered the table again initiates a 500ms count to show the hidden button of the row it happens to have re-entered at. At this point #1 times out and hides and if hovered over from inside the table, would appear instantly as per the first set of criteria in the beginning: any hidden button in a row instantly shows up if the "entry" 500ms gate is passed.

Questions

I have some loose ideas but what comes to mind in designing something like this so that all of the state and timeouts (is that even the way to go) are encapsulated in a maximum of two components - something like a table and rows?

How do I design this component functionality? Am I barking up the wrong tree here and can the show/hide be done by clever CSS?


Solution

  • I believe this could be a possible approach to a working solution. With some more tweaking, I think it can achieve the required behavior you outlined.

    This captures the last element the cursor left the table on, from the list of rows, by using the mouse coordinate at the point of leaving the table. In the example the exited element will remain visible, but you can decide how to handle it.

    [...table.children[0].children].forEach(tr => {
        tr.classList.remove('exited');
        if(evt.offsetY >= tr.offsetTop 
           && evt.offsetY <= tr.offsetTop + tr.clientHeight
         ){
           tr.classList.add('exited'); // in react you could instead set this element to state.
         }
      });
    

    Hope it won't be an issue translating to JSX. You can keep a reference to the table's DOM element with refObject.

    const table = document.getElementById("table");
    let hoverTimer = 0;
    
    table.addEventListener("mouseenter", () => {
      clearTimeout(hoverTimer);
      hoverTimer = setTimeout(() => 
        table.classList.add('active'),
        500
      );
    });
    
    table.addEventListener("mouseleave", (evt) => {
      [...table.children[0].children].forEach(tr => {
        tr.classList.remove('exited');
        if(evt.offsetY >= tr.offsetTop 
           && evt.offsetY <= tr.offsetTop + tr.clientHeight
         ){
           tr.classList.add('exited');
         }
      });
      
      clearTimeout(hoverTimer);
      hoverTimer = setTimeout(() => 
        table.classList.remove('active'),
        500
      );
    });
    tr.table-row button.action-btn {
      pointer-events: none;
    }
    
    table.active tr.table-row:hover button.action-btn {
      pointer-events: auto;
    }
    
    tr.table-row {
      opacity: 0;
      transition: opacity 0.5s;
    }
    
    table.active tr.table-row:hover {
      opacity: 1;
    }
    
    tr.exited {
      opacity: 1;
    }
    <table id="table">
    <tr class="table-row"><td><button class="action-btn">click me!</button></td></tr>
    <tr class="table-row"><td><button class="action-btn">click me!</button></td></tr>
    <tr class="table-row"><td><button class="action-btn">click me!</button></td></tr>
    <tr class="table-row"><td><button class="action-btn">click me!</button></td></tr>
    <tr class="table-row"><td><button class="action-btn">click me!</button></td></tr>
    <tr class="table-row"><td><button class="action-btn">click me!</button></td></tr>
    <tr class="table-row"><td><button class="action-btn">click me!</button></td></tr>
    <tr class="table-row"><td><button class="action-btn">click me!</button></td></tr>
    <tr class="table-row"><td><button class="action-btn">click me!</button></td></tr>
    <tr class="table-row"><td><button class="action-btn">click me!</button></td></tr>
    </table>