4

As best I'm aware, there are two ways to change the hash at the end of the URL. window.location.hash and history.pushState. However, history.pushState does not trigger the CSS :target pseudo-class so that's out.

An answer to "Modifying document.location.hash without page scrolling" demonstrates a method to modify location.hash without scrolling, but this workaround fails to trigger the :target pseudo-class on the element with a matching ID.

Below is a simple example. Two links work as expected: :target is triggered and the tabs display, but they'll also scroll into view if necessary. Two links don't work: :target isn't triggered, but scrolling is prevented.

(function() {
  function setHash(event) {
    event.preventDefault();
    var decoy = document.querySelector(".dummy-tabpanel");
    var tabId = event.currentTarget.getAttribute('href');
    var id = tabId.replace( /^#/, '' );
    var tabPanel = document.getElementById(id);
    
    decoy.style.top = document.body.scrollTop + 'px';
    
    tabPanel.setAttribute('id', '');
    decoy.setAttribute('id', id);
    
    window.location.hash = tabId;
    
    decoy.setAttribute('id', '');
    tabPanel.setAttribute('id', id);
    
    return false;
  }
  
  function setHashDirectly(event) {
    event.preventDefault();
    var tabId = event.currentTarget.getAttribute('href');
    window.location.hash = tabId;
    return false;
  }
  
  var tabLinks = document.querySelectorAll('a[href^="#"]');
  
  for (var tabLink of tabLinks) {
    tabLink.addEventListener("click", tabLink.classList.contains('direct') ? setHashDirectly : setHash );
  }
})()
.tabs {
  margin-top: 300px; // attempt to make scrolling necessary
}

.tabs [role="tabpanel"] {
  display: none;
}

.tabs [role="tabpanel"]:target {
  display: block;
}

.dummy-tabpanel {
  position: absolute;
  visibility: hidden;
}
<nav>
  <ul>
    <li>
      <a href="#one">one (doesn't scroll or trigger <code>:target</code>)</a>
    </li>
    <li>
      <a href="#two">two (doesn't scroll or trigger <code>:target</code>)</a>
    </li>
    <li>
      <a href="#three" class="direct">three (triggers <code>:target</code> but scrolls)</a>
    </li>
    <li>
      <a href="#four" class="direct">four (triggers <code>:target</code> but scrolls)</a>
    </li>
  </ul>
</nav>
<div class="tabs">
  <div role="tabpanel" id="one">
    this is the first tab (won't display)
  </div>
  <div role="tabpanel" id="two">
    this is the two tab (won't display)
  </div>
  <div role="tabpanel" id="three">
    this is the third tab (should display)
  </div>
  <div role="tabpanel" id="four">
    this is the forth tab (should display)
  </div>
</div>
<div class="dummy-tabpanel"></div>

Is there some way to get the "best of both worlds," i.e. change the window hash without scrolling the page and have CSS :target triggered?

Note: I've tested this on Chrome 64 and Firefox 58 on OS X 10.13.

Tom
  • 6,947
  • 7
  • 46
  • 76
  • You can always just add a class or ID to each element and use css on that – vityavv Mar 07 '18 at 21:37
  • So in other words, you wish to reveal hidden content with a link via `:target`, ***but*** you don't want to jump to the `:target` destination? – zer00ne Mar 07 '18 at 23:40
  • @zer00ne exactly, but I'd like to avoid adding and removing classes or modifying inline styles – Tom Mar 08 '18 at 01:17

1 Answers1

1

Actually, history.pushState probably should update the CSS :target selector, since using the browser back and forward buttons to navigate back and forward after using history.pushState does indeed update it. It has an open bug in webkit, and there is talk of standardizing the behavior to be one way or the other, rather than the current inconsistent behavior. If they choose to make it update the CSS, then your code will just work as-is. If they choose to make it not update the CSS, then browsers will have to change the behavior of the back and forward buttons to not update the CSS for entries created by history.pushState, or possibly any, which seems like an obviously wrong move.

So, your code may work without changes in the future, but as of right now you have to work around this by calling history.pushState and then navigating back and forward for the user. Here's a solution based on a solution by laughinghan on GitHub:

function pushHashAndFixTargetSelector(hash) {
    history.pushState({}, document.title, hash); //called as you would normally
    const onpopstate = window.onpopstate; //store the old event handler to restore it later
    window.onpopstate = function() { //this will be called when we call history.back()
        window.onpopstate = onpopstate; //restore the original handler
        history.forward(); //go forward again to update the CSS
    };
    history.back(); //go back to trigger the above function
}

Theoretically, this workaround will keep working even after they standardize the intended behavior.

LB--
  • 2,506
  • 1
  • 38
  • 76