kit icon indicating copy to clipboard operation
kit copied to clipboard

Hash links to new pages don't focus the correct element

Open Rich-Harris opened this issue 3 years ago • 14 comments

Describe the bug

If you click a link like <a href="#foo">, the browser will focus an element like <h2 id="foo">, which in effect (since <h2> elements aren't typically focusable) actually means that pressing Tab will focus the next focusable element after the <h2>.

But if you click a link like <a href="/other#foo">, SvelteKit will navigate to /other and scroll to #foo, but it won't focus the element.

Reproduction

https://stackblitz.com/edit/sveltejs-kit-template-default-nrjdx2

Logs

No response

System Info

System:
    OS: macOS 12.0.1
    CPU: (10) arm64 Apple M1 Max
    Memory: 79.22 MB / 32.00 GB
    Shell: 5.8 - /bin/zsh
  Binaries:
    Node: 16.13.1 - ~/.nvm/versions/node/v16.13.1/bin/node
    npm: 8.1.2 - ~/.nvm/versions/node/v16.13.1/bin/npm
  Browsers:
    Chrome: 98.0.4758.80
    Firefox: 96.0.3
    Safari: 15.1
  npmPackages:
    @sveltejs/adapter-auto: workspace:* => 1.0.0-next.18 
    @sveltejs/kit: workspace:* => 1.0.0-next.260 
    @sveltejs/site-kit: ^2.0.2 => 2.0.2 
    svelte: ^3.43.0 => 3.44.2

Severity

serious, but I can work around it

Additional Information

No response

Rich-Harris avatar Feb 07 '22 23:02 Rich-Harris

Maybe related #3636

bluwy avatar Feb 08 '22 07:02 bluwy

Still believe it's related to the problem I mentioned in https://github.com/sveltejs/kit/issues/3105. And I don't think it's covered together with the scrolling.

As for what we can manage focus. We can't just focus() the target element as it might not be focusable. And it's not the same behaviour as traditional hash links in modern browsers. There's a Sequential Focus Navigation Start Point concept. I think this blog from google explains it quite well.

Unfortunately, there seems to be no way to programmatically set the Sequential Focus Navigation Start Point. These are the possible workaround I can think of:

  1. Make the target focusable, focus it and revert it. the downside is it would cause the focus ring to show up. And it's not the same as the normal hash link behaviour. But it doesn't create any dummy element.
  2. Append a dummy element before the target element if the target element isn't focusable. focus it and remove it.
  3. Append a <a href="#target" ></a> tag to the document body and call the click() method. This would probably be the most consistent with a traditional hash link. But we might need to prevent event propagation on it?

jasonlyu123 avatar Feb 10 '22 03:02 jasonlyu123

I came here to post an article about SFNSP I just stumbled upon — https://hiddedevries.nl/en/blog/2017-04-24-where-focus-goes-when-following-in-page-links — but it basically just reiterates what @jasonlyu123 posted.

All three strategies seem viable:

  • Demo 1. The one caveat I can think of is that if for some reason the target element had a focus handler, it would be activated incorrectly.
  • Demo 2.
  • Demo 3. I don't think we need to worry about navigation handlers refiring since the click would navigate to the current link, and the router would discard the navigation, but we would want to check thoroughly

Rich-Harris avatar Mar 11 '22 16:03 Rich-Harris

Just spitballing, but would it be possible for us to manually set the hash after navigation? Something like:

  • User clicks href="/route#target-hash", so we capture the link and route
  • Instead of history.pushState(state, '', '/route#target-hash') we do history.pushState(state, '', '/route')
  • Render route
  • hash_navigating = true; location.replace('#target-hash'); (not actually hash_navigating but something similar)

mrkishi avatar Mar 11 '22 17:03 mrkishi

Apparently even a simple location.replace('#'); location.replace('#component-format-script-context-module'); instead of focus() works on desktop Chromium and Firefox, but tests on other browsers are needed. Are there known downsides to this?

mrkishi avatar Mar 12 '22 16:03 mrkishi

Yeah. seems like only calling location.replace without deferring the hash navigation would also work. Look at the spec, It seems like it's mostly going to be the same behaviour as link navigation. location.hash setter won't work though. It's explicitly mentioned in the spec that it should return early. Maybe that's why I thought the location API won't work before.

I found some downside with navigation approaches. The history entry would be replaced. So the new history entry won't have a PopStateEvent.state. The navigation will be aborted. We can probably call history.replaceState again. Any downside with this?

If this turns out to work in safari and we can solve the history problems. It seems like a good approach to do.

jasonlyu123 avatar Mar 15 '22 08:03 jasonlyu123

location.replace will also make CSS :target work after navigation. Currently :target doesn't work when navigated to a new route with a hash.

whataboutpereira avatar May 31 '22 10:05 whataboutpereira

Is this issue open for contributors? Can I open a PR for this?

Bruce-Hopkins avatar Sep 23 '22 15:09 Bruce-Hopkins

The reproduction link is outdated, todos section of it is throwing error. @Rich-Harris , is this bug still exists?

mefaba avatar Oct 29 '22 16:10 mefaba

For anyone interested, this works for me as a temporary workaround:

onMount(() => {
    const hash = window.location.hash.slice(1);
    if (hash) {
        window.location.hash = hash;
    }
});

Edit: apparently that's not how the spec says it behaves, however, it works fine for me.

hrueger avatar Feb 05 '23 12:02 hrueger

location.replace will also make CSS :target work after navigation. Currently :target doesn't work when navigated to a new route with a hash.

I noticed there is data-sveltekit-replacestate now, but :target still doesn't work when using it.

Previous code:

const state = history.state;
location.replace(navigation.to.url);
history.replaceState(state, '', location.href);

Using data-sveltekit-replacestate I still need location.replace(navigation.to.url) for :target to work.

whataboutpereira avatar Aug 10 '23 11:08 whataboutpereira

location.replace will also make CSS :target work after navigation. Currently :target doesn't work when navigated to a new route with a hash.

I noticed there is data-sveltekit-replacestate now, but :target still doesn't work when using it.

Previous code:

const state = history.state;
location.replace(navigation.to.url);
history.replaceState(state, '', location.href);

Using data-sveltekit-replacestate I still need location.replace(navigation.to.url) for :target to work.

After more testing it looks like there's no need for neither data-sveltekit-replacestate nor history.replaceState(state, '', location.href);. Just location.replace(navigation.to.url); suffices to make :target work.

whataboutpereira avatar Aug 10 '23 13:08 whataboutpereira

After more testing it looks like there's no need for neither data-sveltekit-replacestate nor history.replaceState(state, '', location.href);. Just location.replace(navigation.to.url); suffices to make :target work.

Alas, Firefox still needs manual replaceState(). Chrome seemed fine without.

whataboutpereira avatar Aug 10 '23 19:08 whataboutpereira

  1. Append a tag to the document body and call the click() method. This would probably be the most consistent with a traditional hash link. But we might need to prevent event propagation on it?

This doesn't seem to work on macOS Safari. Might be because of Safari's behaviour of waiting for the actual user's first click and not recognising programmatic clicks. Unless we leave Safari out, this option does pretty well.

teemingc avatar Oct 11 '23 15:10 teemingc