Store active menu item as HTML5 history state information

Introduce new interface to allow behaviors to handle state in the HTML5 history and adapt the behavior implementation.

refs #9761
This commit is contained in:
Matthias Jentsch 2015-08-25 16:51:55 +02:00
parent 675a130787
commit 47d8b45e6a
2 changed files with 204 additions and 40 deletions

View File

@ -4,8 +4,6 @@
"use strict";
var activeMenuId;
Icinga.Behaviors = Icinga.Behaviors || {};
var Navigation = function (icinga) {
@ -17,49 +15,91 @@
this.on('mouseenter', '#menu > nav > ul > li', this.menuTitleHovered, this);
this.on('mouseleave', '#sidebar', this.leaveSidebar, this);
this.on('rendered', this.onRendered, this);
/**
* The DOM-Path of the active item
*
* @see getDomPath
*
* @type {null|Array}
*/
this.active = null;
/**
* The DOM-Path of the hovered item
*
* @see getDomPath
*
* @type {null|Array}
*/
this.hovered = null;
/**
* @type {HTMLElement}
*/
this.element = null;
};
Navigation.prototype = new Icinga.EventListener();
/**
* Apply the menu selection and hovering according to the current state
*
* @param evt {Object} The event context
*/
Navigation.prototype.onRendered = function(evt) {
var self = evt.data.self;
// get original source element of the rendered-event
var el = evt.target;
if (activeMenuId) {
// restore old menu state
$('#menu li.active', el).removeClass('active');
var $selectedMenu = $('#' + activeMenuId).addClass('active');
var $outerMenu = $selectedMenu.parent().closest('li');
if ($outerMenu.size()) {
$outerMenu.addClass('active');
}
this.element = evt.target;
/*
Recreate the html content of the menu item to force the browser to update the layout, or else
the link would only be visible as active after another click or page reload in Gecko and WebKit.
if (! self.active) {
// There is no stored menu item, therefore it is assumed that this is the first rendering
// of the navigation after the page has been opened.
fixes #7897
*/
$selectedMenu.html($selectedMenu.html());
} else {
// store menu state
var $menus = $('#menu li.active', el);
// initialise the menu selected by the backend as active.
var $menus = $('#menu li.active', evt.target);
if ($menus.size()) {
activeMenuId = $menus[0].id;
$menus.find('li.active').first().each(function () {
activeMenuId = this.id;
$menus.each(function () {
self.setActive($(this));
});
} else {
// if no item is marked as active, try to select the menu from the current URL
self.setActiveByUrl($('#col1').data('icingaUrl'));
}
}
// restore hovered menu after auto-reload
if (self.hovered) {
var hovered = self.icinga.utils.getElementByDomPath(self.hovered);
self.refresh();
};
/**
* Re-render the menu selection and menu hovering according to the current state
*/
Navigation.prototype.refresh = function() {
// restore selection to current active element
if (this.active) {
var $el = $(this.icinga.utils.getElementByDomPath(this.active));
this.setActive($el);
/*
* Recreate the html content of the menu item to force the browser to update the layout, or else
* the link would only be visible as active after another click or page reload in Gecko and WebKit.
*
* fixes #7897
*/
$el.html($el.html());
}
// restore hovered menu to current hovered element
if (this.hovered) {
var hovered = this.icinga.utils.getElementByDomPath(this.hovered);
if (hovered) {
self.hoverElement($(hovered));
this.hoverElement($(hovered));
}
}
};
/**
* Handle a link click in the menu
*
* @param event
*/
Navigation.prototype.linkClicked = function(event) {
var $a = $(this);
var href = $a.attr('href');
@ -71,10 +111,8 @@
if (href.match(/#/)) {
// ...it may be a menu section without a dedicated link.
// Switch the active menu item:
self.setActive($a);
$li = $a.closest('li');
$('#menu .active').removeClass('active');
$li.addClass('active');
activeMenuId = $($li).attr('id');
if ($li.hasClass('hover')) {
$li.removeClass('hover');
}
@ -86,7 +124,7 @@
return;
}
} else {
activeMenuId = $(event.target).closest('li').attr('id');
self.setActive($(event.target));
}
// update target url of the menu container to the clicked link
var $menu = $('#menu');
@ -95,9 +133,54 @@
$menu.data('icinga-url', menuDataUrl);
};
/**
* Activate a menu item based on the current URL
*
* Activate a menu item that is an exact match or fall back to items that match the base URL
*
* @param url {String} The url to match
*/
Navigation.prototype.setActiveByUrl = function(url) {
this.resetActive();
// try to active the first item that has an exact URL match
this.setActive($('#menu [href="' + url + '"]'));
// some urls may have custom filters which won't match any menu item. In that case, activate the first
// item that matches *just* the path.
if (! this.active) {
this.setActive($('#menu [href="' + this.icinga.utils.parseUrl(url).path + '"]').first());
}
// if no item to the base action exists, activate at least the first URL that matches the base path
if (! this.active) {
this.setActive($('#menu [href^="' + this.icinga.utils.parseUrl(url).path + '"]').first());
}
};
/**
* Remove all active elements
*/
Navigation.prototype.clear = function() {
$('#menu li.active', this.element).removeClass('active');
};
/**
* Select all menu items in the selector as active and unfold surrounding menus when necessary
*
* @param $item {jQuery} The jQuery selector
*/
Navigation.prototype.select = function($item) {
// support selecting the url of the menu entry
$item = $item.closest('li');
// select the current item
var $selectedMenu = $item.addClass('active');
// unfold the containing menu
var $outerMenu = $selectedMenu.parent().closest('li');
if ($outerMenu.size()) {
$outerMenu.addClass('active');
}
};
/**
@ -106,14 +189,69 @@
* @param $el {jQuery} A selector pointing to the active element
*/
Navigation.prototype.setActive = function($el) {
$el.closest('li').addClass('active');
$el.parents('li').addClass('active');
activeMenuId = $el.closest('li').attr('id');
this.clear();
this.select($el);
if ($el.closest('li')[0]) {
this.active = this.icinga.utils.getDomPath($el.closest('li')[0]);
} else {
this.active = null;
}
// TODO: push to history
};
/**
* Get the currently active element
*
* @returns {null|HTMLElement}
*/
Navigation.prototype.getActive = function () {
if (! this.active) {
return null;
}
return this.icinga.utils.getElementByDomPath(this.active);
};
/**
* Reset the active element to nothing
*/
Navigation.prototype.resetActive = function() {
$('#menu .active').removeClass('active');
activeMenuId = null;
this.clear();
this.active = null;
};
/**
* Called when the history changes
*
* @param url The url of the new state
* @param data The active menu item of the new state
*/
Navigation.prototype.onPopState = function (url, data) {
// 1. get selection data and set active menu
console.log('popstate:', data);
if (data) {
var active = this.icinga.utils.getElementByDomPath(data);
if (!active) {
this.logger.fail(
'Could not restore active menu from history, path in DOM not found.',
data,
url
);
return;
}
this.setActive($(active));
} else {
this.resetActive();
}
};
/**
* Called when the current state gets pushed onto the history, can return a value
* to be preserved as the current state
*
* @returns {null|Array} The currently active menu item
*/
Navigation.prototype.onPushState = function () {
return this.active;
};
Navigation.prototype.menuTitleHovered = function(event) {

View File

@ -110,7 +110,26 @@
return;
}
this.lastPushUrl = url;
window.history.pushState({icinga: true}, null, url);
window.history.pushState(
this.getBehaviorState(),
null,
url
);
},
/**
* Fetch the current state of all JS behaviors that need history support
*
* @return {Object} A key-value map, mapping behavior names to state
*/
getBehaviorState: function () {
var data = {};
$.each(this.icinga.behaviors, function (i, behavior) {
if (behavior.onPushState instanceof Function) {
data[i] = behavior.onPushState();
}
});
return data;
},
/**
@ -143,6 +162,13 @@
self.lastPushUrl = location.href;
self.applyLocationBar();
// notify behaviors of the state change
$.each(this.icinga.behaviors, function (i, behavior) {
if (behavior.onPopState instanceof Function) {
behavior.onPopState(location.href, history.state[i]);
}
});
},
applyLocationBar: function (onload) {