mefechoel / svelte-navigator

Simple, accessible routing for Svelte
Other
502 stars 39 forks source link

Route to fragment does not work #39

Closed hidde closed 2 years ago

hidde commented 3 years ago

Describe the bug

It looks like a Link that routes to something on the same page does not actually work.

To Reproduce

  1. Add a Link component to all pages in your app, with in its to something that refers to an anchor on a specific page, say /about#digital, for instance <Link to="/about#digital"/>
  2. Open in browser and click the Link

REPL

Expected behavior

Page jumps to #digital on /about

Extra info

In a tool I work on, we go around this by manually focusing the fragment and scrolling to it (running a function called honourFragmentIdLinks, onMount of a page).

This does the job, although it breaks if no navigation to a different page is happening (as that requires no mounting).

mefechoel commented 3 years ago

Hey there! Yes, this is actually a common problem with spa routers (see package for react router, providing scroll to hash functionallity)... This is because they use the browser's history api under the hood. Calling history.pushState with a url with a fragment, does not cause the browser to jump to the referenced element.

I'm not sure I want to fix this in the router, because it would be really hard to get all use cases to work correctly. When loading content lazily on route change for example, the router would have no way of knowing, when that content arrives and when to scroll to the element referenced by the url hash.

For your case however I think a helper component should do the trick. As you said, only running the check on mount won't work, but by subscribing to the router's location you'll get informed of an update on every navigation:

<!-- App.svelte -->
<Router>
  <!--
    This wrapper component listens to navigations and scrolls to
    elements referenced in url fragments.
    Let me know if you can think of a better name for it...
  -->
  <ScrollToFragment>
    <!-- The rest of your app... -->
  </ScrollToFragment>
</Router>
<!-- ScrollToFragment.svelte -->
<script>
  import { onMount, tick } from 'svelte';
  import { useLocation } from 'svelte-navigator';

  const location = useLocation();

  onMount(() => {
    // Don't use the `$` shorthand for store subscription here, since
    // we only want to run this when the app has mounted, not
    // when the component is created
    const unsubscribe = location.subscribe(async (newLocation) => {
      // If there's no hash, we don't need to scroll anywhere
      if (!newLocation.hash) return;
      // We need to wait for svelte to update the dom before atempting to
      // scroll to a specific element
      await tick();
      // Get the element referenced by the fragment. `location.hash`
      // always starts with `#` when a hash exists, so we already have a
      // valid id selector
      const fragmentReference = document.querySelector(newLocation.hash);
      // If the fragment does not reference an element, we can ignore it
      if (!fragmentReference) return;
      fragmentReference.scrollIntoView();
      // Set a tabindex, so the element can be focused
      if (!fragmentReference.hasAttribute('tabindex')) {
        fragmentReference.setAttribute('tabindex', '-1');
        // Remove the tabindex on blur to prevent weird jumpy browser behaviour
        fragmentReference.addEventListener(
          'blur',
          () => fragmentReference.removeAttribute('tabindex'),
          { once: true },
        );
      }
      fragmentReference.focus();
      // By setting location.hash explictly, we ensure :target
      // selectors in CSS will work as expected
      window.location.hash = newLocation.hash;
    });
    return unsubscribe;
  });
</script>

<slot />

I tried this setup with the example you provided and it worked just fine there. I hope it'll work in your project as well.

marcus-wishes commented 2 years ago

It did work for mine, thank you very much!