Skip to content

Commit

Permalink
Handle RouterLink with fragment properly (#641)
Browse files Browse the repository at this point in the history
RouterLinks with target inside page navigation don't go to server.
RouterLinks with fragment trigger scroll to fragment and hashchangeevent.
Does not handle initial page scroll to fragment, that will be handled by prerending.

Fixes #339
  • Loading branch information
pleku authored and Artur- committed Apr 29, 2016
1 parent ab37677 commit bd96d52
Show file tree
Hide file tree
Showing 6 changed files with 446 additions and 25 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Copyright 2000-2016 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.client.hummingbird;

import java.util.Objects;

import com.google.web.bindery.event.shared.HandlerRegistration;
import com.vaadin.client.Registry;
import com.vaadin.client.communication.ResponseHandlingEndedEvent;

import elemental.client.Browser;

/**
* Handler that makes sure that scroll to fragment and hash change event work
* when there has been navigation via {@link RouterLinkHandler router link} to a
* path with fragment.
* <p>
* This class will trigger scroll to fragment and hash change event once the
* response from server has been processed, but only if the server did not
* override the location.
*
* @author Vaadin Ltd
*/
public class FragmentHandler {

private final String previousHref;
private final String newHref;

private HandlerRegistration handlerRegistration;

/**
* Creates a new fragment handler for the given locations.
*
* @param previousHref
* the href before the navigation
* @param newHref
* the href being navigated into
*/
public FragmentHandler(String previousHref, String newHref) {
assert previousHref != null;
assert newHref != null;

this.previousHref = previousHref;
this.newHref = newHref;
}

/**
* Adds a request response tracker to the given registry for making sure the
* fragment is handled correctly if the location has not been updated during
* the response.
*
* @param registry
* the registry to bind to
*/
public void bind(Registry registry) {
handlerRegistration = registry.getRequestResponseTracker()
.addResponseHandlingEndedHandler(this::onResponseHandlingEnded);
}

private void onResponseHandlingEnded(
ResponseHandlingEndedEvent responseHandlingEndedEvent) {
assert handlerRegistration != null;

String currentHref = Browser.getWindow().getLocation().getHref();

if (Objects.equals(currentHref, newHref)) {
// trigger possible scroll to fragment identifier
Browser.getWindow().getLocation().replace(newHref);
// fire fragment change event
fireHashChangeEvent(previousHref, newHref);
}

handlerRegistration.removeHandler();
}

/*
* This method is used instead because Elemental's
* HashChangeEvent.initHashChange gives errors.
*/
private static native void fireHashChangeEvent(String oldUrl, String newUrl)
/*-{
var event = new HashChangeEvent('hashchange', {
'view': window,
'bubbles': true,
'cancelable': false,
'oldURL': oldUrl,
'newURL': newUrl
});
window.dispatchEvent(event);
}-*/;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
*/
package com.vaadin.client.hummingbird;

import java.util.Objects;

import com.vaadin.client.Console;
import com.vaadin.client.Registry;
import com.vaadin.client.URIResolver;
Expand Down Expand Up @@ -62,45 +64,117 @@ private static void handleClick(Registry registry, Event clickEvent) {
return;
}

String href = getRouterLinkHref(clickEvent);
if (href != null) {
String baseURI = ((Element) clickEvent.getCurrentTarget())
.getOwnerDocument().getBaseURI();

// verify that the link is actually for this application
if (!href.startsWith(baseURI)) {
// ain't nobody going to see this log
Console.warn("Should not use "
+ ApplicationConstants.ROUTER_LINK_ATTRIBUTE
+ " attribute for an external link.");
return;
}
String href = getValidLinkHref(clickEvent);
if (href == null) {
return;
}

String baseURI = ((Element) clickEvent.getCurrentTarget())
.getOwnerDocument().getBaseURI();

// verify that the link is actually for this application
if (!href.startsWith(baseURI)) {
// ain't nobody going to see this log
Console.warn("Should not use "
+ ApplicationConstants.ROUTER_LINK_ATTRIBUTE
+ " attribute for an external link.");
return;
}

Browser.getWindow().getHistory().pushState(null, null, href);
String location = URIResolver.getBaseRelativeUri(baseURI, href);

clickEvent.preventDefault();
if (location.contains("#")) {
// make sure fragment event gets fired after response
new FragmentHandler(Browser.getWindow().getLocation().getHref(),
href).bind(registry);

String location = URIResolver.getBaseRelativeUri(baseURI, href);
sendServerNavigationEvent(registry, location, null);
// don't send hash to server
location = location.split("#", 2)[0];
}

clickEvent.preventDefault();
Browser.getWindow().getHistory().pushState(null, null, href);
sendServerNavigationEvent(registry, location, null);
}

/**
* Gets the link href for the given event. If the event target or the link
* href is not a valid routerlink, or is only inside page navigation (just
* fragment change), <code>null</code> will be returned instead.
*
* @param clickEvent
* the click event for the link
* @return the link href or <code>null</code> there is no valid href
*/
private static String getValidLinkHref(Event clickEvent) {
AnchorElement anchor = getRouterLink(clickEvent);
if (anchor == null) {
return null;
}
String href = anchor.getHref();
if (href == null || isInsidePageNavigation(anchor)) {
return null;
}
return href;
}

/**
* Checks whether the given anchor links within the current page.
*
* @param anchor
* the link to check
* @return <code>true</code> if links inside current page,
* <code>false</code> if not
*/
private static boolean isInsidePageNavigation(AnchorElement anchor) {
return isInsidePageNavigation(anchor.getPathname(), anchor.getHash());
}

/**
* Checks whether the given path and hash are for navigating inside the same
* page as the current one.
* <p>
* If the paths are different, it is always outside the current page
* navigation.
* <p>
* If the paths are the same, then it is inside the current page navigation
* unless the hashes are the same too; then it is considered reloading the
* current page.
*
* @param path
* the path to check against
* @param hash
* the hash to check against
* @return <code>true</code> if the given location is for navigating inside
* the current page, <code>false</code> if not
*/
public static boolean isInsidePageNavigation(String path, String hash) {
String currentPath = Browser.getWindow().getLocation().getPathname();
String currentHash = Browser.getWindow().getLocation().getHash();
assert currentPath != null : "window.location.path should never be null";
assert currentHash != null : "window.location.hash should never be null";
// if same path it is always inside page unless fragment same, then it
// is reload
return Objects.equals(currentPath, path)
&& !Objects.equals(currentHash, hash);
}

/**
* Gets the target of a router link, if a router link was found between the
* click target and the event listener.
* Gets the anchor element, if a router link was found between the click
* target and the event listener.
*
* @param clickEvent
* the click event
* @return the target (href) of the link if a link was found, null otherwise
* @return the target anchor if found, <code>null</code> otherwise
*/
private static String getRouterLinkHref(Event clickEvent) {
private static AnchorElement getRouterLink(Event clickEvent) {
assert "click".equals(clickEvent.getType());

Element target = (Element) clickEvent.getTarget();
EventTarget eventListenerElement = clickEvent.getCurrentTarget();
while (target != eventListenerElement) {
if (isRouterLinkAnchorElement(target)) {
return ((AnchorElement) target).getHref();
return (AnchorElement) target;
}
target = target.getParentElement();
}
Expand All @@ -113,7 +187,8 @@ private static String getRouterLinkHref(Event clickEvent) {
*
* @param target
* the element to check
* @return true if the element is a routerlink, false otherwise
* @return <code>true</code> if the element is a routerlink,
* <code>false</code> otherwise
*/
private static boolean isRouterLinkAnchorElement(Element target) {
return "a".equalsIgnoreCase(target.getTagName()) && target
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.vaadin.hummingbird.uitest.ui;

import com.vaadin.hummingbird.dom.Element;
import com.vaadin.hummingbird.dom.ElementFactory;
import com.vaadin.ui.History.HistoryStateChangeHandler;

public class FragmentLinkView extends AbstractDivView {

public FragmentLinkView() {
Element bodyElement = getElement();
bodyElement.getStyle().set("margin", "1em");

Element scrollLocator = ElementFactory.createDiv()
.setAttribute("id", "scrollLocator")
.setTextContent("Scroll locator");
scrollLocator.getStyle().set("position", "fixed").set("top", "0")
.set("right", "0");

Element placeholder = ElementFactory.createDiv("Hash Change Events")
.setAttribute("id", "placeholder");

bodyElement.appendChild(scrollLocator, placeholder, new Element("p"));

Element scrollToLink = ElementFactory.createRouterLink(
"/view/com.vaadin.hummingbird.uitest.ui.FragmentLinkView#Scroll_Target",
"Scroller link");
Element scrollToLink2 = ElementFactory.createRouterLink(
"/view/com.vaadin.hummingbird.uitest.ui.FragmentLinkView#Scroll_Target2",
"Scroller link 2");
Element scrollToLinkAnotherView = ElementFactory.createRouterLink(
"/view/com.vaadin.hummingbird.uitest.ui.FragmentLinkView2#Scroll_Target",
"Scroller link with different view");
Element linkThatIsOverridden = ElementFactory.createRouterLink(
"./override#Scroll_Target", "Link that server overrides");

Element scrollTarget = ElementFactory.createHeading1("Scroll Target")
.setAttribute("id", "Scroll_Target");
Element scrollTarget2 = ElementFactory.createHeading2("Scroll Target 2")
.setAttribute("id", "Scroll_Target2");

bodyElement.appendChild(scrollToLink, new Element("p"), scrollToLink2,
new Element("p"), scrollToLinkAnotherView, new Element("p"),
linkThatIsOverridden, new Element("p"), createSpacer(),
scrollTarget, createSpacer(), scrollTarget2, createSpacer());

}

@Override
protected void onAttach() {
getUI().get().getPage()
.executeJavaScript("var i = 0;"
+ "window.addEventListener('hashchange', function(event) {"
+ "var x = document.createElement('span');"
+ "x.textContent = ' ' + i;" + "i++;"
+ "x.class = 'hashchange';"
+ "document.getElementById('placeholder').appendChild(x);},"
+ " false);");

HistoryStateChangeHandler current = getUI().get().getPage().getHistory()
.getHistoryStateChangeHandler();
getUI().get().getPage().getHistory()
.setHistoryStateChangeHandler(event -> {
if (event.getLocation().equals("override")) {
event.getSource().replaceState(null,
"overridden#Scroll_Target2");
} else {
current.onHistoryStateChange(event);
}
});
}

private Element createSpacer() {
Element spacer = ElementFactory.createDiv().setTextContent("spacer");
spacer.getStyle().set("height", "1000px");
return spacer;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.vaadin.hummingbird.uitest.ui;

import com.vaadin.hummingbird.dom.Element;

public class FragmentLinkView2 extends FragmentLinkView {

public FragmentLinkView2() {
getElement().insertChild(0, new Element("div").setTextContent("VIEW 2")
.setAttribute("id", "view2"));
}

@Override
protected void onAttach() {
// do not call super onAttach since it adds a hashchangelistener
}
}
Loading

0 comments on commit bd96d52

Please sign in to comment.