/** @module deliteful/list/PageableList */
define([
"dcl/dcl",
"delite/register",
"dojo/string",
"requirejs-dplugins/Promise!",
"requirejs-dplugins/jquery!attributes/classes",
"decor/sniff",
"./List",
"./Renderer",
"delite/handlebars!./List/_PageLoaderRenderer.html",
"requirejs-dplugins/i18n!./List/nls/Pageable"
], function (dcl, register, string, Promise, $, has, List, Renderer, template, messages) {
/*
* A clickable renderer that initiate the loading of a page in a pageable list.
* It renders an item that has the following properties:
* - loadMessage: the label to display when a page is not currently loading
* - loadingMessage: the label to display when a page is loading
*/
var _PageLoaderRenderer = register("d-list-loader", [HTMLElement, Renderer], {
/*
* The CSS class of the widget
* @member {string}
* @default "d-list-loader"
*/
baseClass: "d-list-loader",
/*
* Indicates whether or not a page is currently loading.
* @member {boolean}
*/
loading: false,
/*
* HTML element that wraps a progress indicator and an optional label in the render node
* @member {HTMLElement} _PageLoaderRenderer#_button
* @private
*/
/*
* A progress indicator to report that the loader is currently loading a page
* @member {module:deliteful/ProgressIndicator} _PageLoaderRenderer#_progressIndicator
* @private
*/
/*
* An HTML element that displays a label for the loader
* @member {HTMLElement} _PageLoaderRenderer#_label
* @private
*/
/*
* The list that the PageLoader loads data for
* @member {module:deliteful/list/List} _PageLoaderRenderer#_list
* @private
*/
//////////// Widget life cycle ///////////////////////////////////////
postRender: function () {
// summary:
// set the click event handler
this.on("click", this._load.bind(this));
},
template: template,
//////////// Public methods ///////////////////////////////////////
/*
* Executed before loading a page.
* Callback to be implemented by user of the widget
* @method _PageLoaderRenderer#beforeLoading
* @abstract
*/
/*
* Performs the actual loading of a page.
* Callback to be implemented by user of the widget.
* It MUST return a promise that is fulfilled when the load operation is finished.
* @method _PageLoaderRenderer#performLoading
* @abstract
*/
/*
* Executed after loading a page.
* Callback to be implemented by user of the widget
* @method _PageLoaderRenderer#afterLoading
* @abstract
*/
//////////// Private methods ///////////////////////////////////////
/*
* Handle click events on the widget.
* If a loading is already in progress, this method
* return undefined. In the other case, it starts a loading
* and returns a Promise that resolves when the loading
* has completed.
* @returns {Promise} or null
* @private
*/
_load: function () {
if (this._list.hasAttribute("aria-busy")) { return; }
this.beforeLoading();
this.loading = true;
var self = this;
return new Promise(function (resolve, reject) {
// defer execution so that the new style / class is correctly applied on iOS
self.defer(function () {
self.performLoading().then(function () {
self.loading = false;
this.afterLoading();
resolve();
}.bind(this), function (error) {
self.loading = false;
this.afterLoading();
reject(error);
self._queryError(error);
});
});
});
}
});
/**
* A widget that renders a scrollable list of items and provides paging.
*
* This widget allows displaying the content of a list in pages of items instead of rendering
* all items at once.
*
* See the {@link https://github.com/ibm-js/deliteful/tree/master/docs/list/PageableList.md user documentation}
* for more details.
*
* @class module:deliteful/list/PageableList
* @augments module:deliteful/list/List
*/
return register("d-pageable-list", [HTMLElement, List], /** @lends module:deliteful/list/PageableList# */ {
/**
* if > 0, enable paging while defining the number of items to display per page.
* @member {number}
* @default 0
*/
pageLength: 0,
/**
* The maximum number of pages to display at the same time. If a new page is loaded while
* the maximum number of pages is already displayed, another page will be unloaded from the list
* to make room for the new page.
* @member {number}
* @default 0
*/
maxPages: 0,
/**
* The message displayed on the previous page loader when it can be clicked
* to load the previous page. This message can contains placeholder for the
* List attributes to be replaced by their runtime value. For example, the
* message can include the value of the pageLength attribute by using the
* placeholder `${pageLength}`.
* @member {string}
* @default localized version of "Click to load ${pageLength} more items"
*/
loadPreviousMessage: messages["default-load-message"],
/**
* The message displayed on the next page loader when it can be clicked
* to load the next page. This message can contains placeholder for the
* List attributes to be replaced by their runtime value. For example, the
* message can include the value of the pageLength attribute by using the
* placeholder `${pageLength}`.
* @member {string}
* @default localized version of "Click to load ${pageLength} more items"
*/
loadNextMessage: messages["default-load-message"],
/**
* Indicates whether or not to use auto paging. If true, automatically loads the next or previous page when
* the scrolling reaches the bottom or the top of the list content.
* @member {boolean}
* @default false
*/
autoPaging: false,
_setAutoPagingAttr: function (value) {
this._set("autoPaging", value);
if (this._autoPagingHandle) {
this._autoPagingHandle.remove();
this._autoPagingHandle = null;
}
if (value) {
this._autoPagingHandle = this.on("scroll", this._scrollHandler.bind(this), this);
}
},
/**
* Indicates whether or not to hide the content of the list when loading a new page.
* If true, the content of the list is hidden by a loading panel (displaying a progress
* indicator and an optional label defined with the property loadingMessage)
* while its content is updated with a new page of data.
* This attribute is ignored if autoPaging is true.
* @member {boolean}
* @default false
*/
hideOnPageLoad: false,
/**
* The collection of items from which pages are extracted
* @member {boolean} module:deliteful/list/Pageable#_collection
* @private
*/
_collection: null,
/**
* Handle for the auto paging scroll handler (Object with a remove method)
* @member {module:deliteful/list/Pageable} module:deliteful/list/Pageable#_autoPagingHandle
* @private
*/
/**
* Spec of the range to use to load a page.
* @member {Object} module:deliteful/list/Pageable#_rangeSpec
* @private
*/
/**
* The next page loader.
* @member {_PageLoaderRenderer} module:deliteful/list/Pageable#_nextPageLoader
* @private
*/
/**
* The previous page loader.
* @member {_PageLoaderRenderer} module:deliteful/list/Pageable#_previousPageLoader
* @private
*/
/**
* Wheter or not the list is currently scrolled at the top or the bottom
* @member {boolean} module:deliteful/list/Pageable#_atExtremity
* @private
*/
/**
* One entry per page currently loaded. Each entry contains an array
* of the ids of the items displayed in the page.
* @member {Array[]} module:deliteful/list/Pageable#_idPages
* @private
*/
/**
* Index of the first item currently loaded.
* @member {number}
* @default -1
* @private
*/
_firstLoaded: -1,
/**
* Index of the last item currently loaded.
* @member {number}
* @default -1
* @private
*/
_lastLoaded: -1,
//////////// delite/Store methods ///////////////////////////////////////
refreshRendering: function (props) {
if (this.pageLength > 0) {
if ("_collection" in props && this._collection) {
// Initial loading of the list
if (this._dataLoaded) {
this._setBusy(true, true);
this._empty();
props.pageLength = true;
}
this._idPages = [];
this._loadNextPage().then(function () {
this._setBusy(false);
this._dataLoaded = true;
}.bind(this), function (error) {
this._setBusy(false);
this._queryError(error);
}.bind(this));
}
// Update page loader messages as they may depend on any property of the List
if (this._previousPageLoader) {
this._previousPageLoader.item = {
loadMessage: string.substitute(this.loadPreviousMessage, this),
loadingMessage: this.loadingMessage
};
this._previousPageLoader.deliver();
}
if (this._nextPageLoader) {
this._nextPageLoader.item = {
loadMessage: string.substitute(this.loadNextMessage, this),
loadingMessage: this.loadingMessage
};
this._nextPageLoader.deliver();
}
}
},
processCollection: dcl.superCall(function (sup) {
return function (collection) {
if (this.pageLength === 0) {
sup.apply(this, arguments);
}
this._collection = collection;
if (this.pageLength !== 0) {
this.emit("query-success", { renderItems: this.renderItems, cancelable: false, bubbles: true });
}
};
}),
//////////// Private methods ///////////////////////////////////////
/**
* Adds or removes the identity of an item in idPages
* @param {boolean} add true to add, false to remove
* @param {number} index Index of the item in the tracked collection
* @param {Object} identity Identity to add (only if add is true)
* @private
*/
_updateIdPages: function (add, index, identity) {
var pageFirstIndex = this._firstLoaded;
for (var pageIndex = 0; pageIndex < this._idPages.length; pageIndex++) {
var pageLastIndex = pageFirstIndex + this._idPages[pageIndex].length - 1;
if (index >= pageFirstIndex && index <= pageLastIndex) {
if (add) {
this._idPages[pageIndex].splice(index - pageFirstIndex, 0, identity);
} else {
this._idPages[pageIndex].splice(index - pageFirstIndex, 1);
}
break;
} else {
pageFirstIndex += this._idPages[pageIndex].length;
}
}
},
/**
* Loads the next page of items if available.
* @private
*/
_loadNextPage: function () {
if (!this._rangeSpec) {
this._rangeSpec = {
start: 0,
count: this.pageLength
};
}
if (this._nextPageLoader) {
this._rangeSpec.start = this._lastLoaded + 1;
this._rangeSpec.count = this.pageLength;
}
var results = this._collection.fetchRange({start: this._rangeSpec.start,
end: this._rangeSpec.start + this._rangeSpec.count});
return results.then(function (items) {
var page = items.map(function (item) {
return this.itemToRenderItem(item);
}, this);
if (page.length) {
var idPage = page.map(function (item) {
return this.getIdentity(item);
}, this);
if (this._firstLoaded < 0) {
this._firstLoaded = this._rangeSpec.start;
}
this._lastLoaded = this._rangeSpec.start + idPage.length - 1;
this._idPages.push(idPage);
}
this._nextPageReadyHandler(page);
// TODO: May need to force repaint here,
// at least on iOS (iPad 4, iOS 7.0.6). TEST ON OTHER DEVICES ???!!!
}.bind(this));
},
/**
* Loads the previous page of items if available.
* @private
*/
_loadPreviousPage: function () {
this._rangeSpec.count = this.pageLength;
this._rangeSpec.start = this._firstLoaded - this.pageLength;
if (this._rangeSpec.start < 0) {
this._rangeSpec.count += this._rangeSpec.start;
this._rangeSpec.start = 0;
}
var results = this._collection.fetchRange({start: this._rangeSpec.start,
end: this._rangeSpec.start + this._rangeSpec.count});
return results.then(function (items) {
var page = items.map(function (item) {
return this.itemToRenderItem(item);
}, this);
if (page.length) {
var i;
var idPage = page.map(function (item) {
return this.getIdentity(item);
}, this);
var previousPageIds = this._idPages[0];
for (i = 0; i < idPage.length; i++) {
if (previousPageIds.indexOf(idPage[i]) >= 0) {
// remove the duplicate (happens if an element was deleted before the first one displayed)
page.splice(i, 1);
idPage.splice(i, 1);
i--;
}
}
this._firstLoaded = this._rangeSpec.start;
this._idPages.unshift(idPage);
}
this._previousPageReadyHandler(page);
}.bind(this));
},
/**
* Unloads a page.
* @param {boolean} first true to unload the first page, false to unload the last one.
* @private
*/
_unloadPage: function (first) {
var idPage, i;
if (first) {
idPage = this._idPages.shift();
for (i = 0; i < idPage.length; i++) {
this._removeRenderer(this.getItemRendererByIndex(0), true);
this._firstLoaded++;
}
if (idPage.length && !this._previousPageLoader) {
this._createPreviousPageLoader();
}
// if the next page is also empty, unload it too
if (this._idPages.length && !this._idPages[0].length) {
this._unloadPage(first);
}
} else {
idPage = this._idPages.pop();
for (i = 0; i < idPage.length; i++) {
this._removeRenderer(this.getRendererByItemId(idPage[i]), true);
this._lastLoaded--;
}
if (idPage.length && !this._nextPageLoader) {
this._createNextPageLoader();
}
// if the previous page is also empty, unload it too
if (this._idPages.length && !this._idPages[this._idPages.length - 1].length) {
this._unloadPage(first);
}
}
},
/**
* Function to call when the previous page has been loaded.
* @param {Object[]} items the items in the previous page.
* @private
*/
_previousPageReadyHandler: function (items) {
var renderer = this._getFirstVisibleRenderer();
var nextRenderer = renderer.nextElementSibling;
if (this.navigatedDescendant) {
if (renderer && this._previousPageLoader && this._previousPageLoader.loading) {
this.navigateTo(renderer.renderNode);
}
}
this._renderNewItems(items, true);
if (this.maxPages && this._idPages.length > this.maxPages) {
this._unloadPage(false);
}
if (this._firstLoaded === 0) {
// no more previous page
this._previousPageLoader.destroy();
this._previousPageLoader = null;
} else {
this._previousPageLoader.placeAt(this, "first");
}
// the renderer may have been destroyed and replaced by another one (categorized lists)
if (renderer._destroyed) {
renderer = nextRenderer;
}
if (renderer) {
var previous = renderer.previousElementSibling;
if (previous && previous.renderNode) {
var currentActiveElement = this.navigatedDescendant ? null : this.ownerDocument.activeElement;
this.navigateTo(previous.renderNode);
// scroll the focused node to the top of the screen.
// To avoid flickering, we do not wait for a focus event
// to confirm that the child has indeed been focused.
this.scrollBy({y: this.getTopDistance(previous)});
if (currentActiveElement) {
currentActiveElement.focus();
}
}
}
},
/*jshint maxcomplexity: 11*/
/**
* Function to call when the next page has been loaded.
* @param {Object[]} items the items in the next page.
* @private
*/
_nextPageReadyHandler: function (items) {
var renderer = this._getLastVisibleRenderer();
if (this.navigatedDescendant) {
if (renderer) {
this.navigateTo(renderer.renderNode);
}
}
this._renderNewItems(items, false);
if (this.maxPages && this._idPages.length > this.maxPages) {
this._unloadPage(true);
}
if (this._nextPageLoader) {
if (items.length !== this._rangeSpec.count) {
// no more next page
this._nextPageLoader.destroy();
this._nextPageLoader = null;
} else {
this._nextPageLoader.placeAt(this);
}
} else {
if (items.length === this._rangeSpec.count) {
this._createNextPageLoader();
}
}
if (renderer) {
var next = renderer.nextElementSibling;
if (next && next.renderNode) {
var currentActiveElement = this.navigatedDescendant ? null : this.ownerDocument.activeElement;
this.navigateTo(next.renderNode);
// scroll the focused node to the bottom of the screen.
// To avoid flickering, we do not wait for a focus event
// to confirm that the child has indeed been focused.
this.scrollBy({y: this.getBottomDistance(next)});
if (currentActiveElement) {
currentActiveElement.focus();
}
}
}
},
/*jshint maxcomplexity: 10*/
/**
* Returns the last renderer that is visible in the scroll viewport.
* @private
*/
_getLastVisibleRenderer: function () {
var renderer = this._getLastRenderer();
while (renderer) {
if (this.getBottomDistance(renderer) <= 0) {
break;
}
renderer = renderer.previousElementSibling;
}
return renderer;
},
/**
* Returns the first renderer that is visible in the scroll viewport.
* @private
*/
_getFirstVisibleRenderer: function () {
var renderer = this._getFirstRenderer();
while (renderer) {
if (this.getTopDistance(renderer) >= 0) {
break;
}
renderer = renderer.nextElementSibling;
}
return renderer;
},
//////////// Event handlers ///////////////////////////////////////
/**
* Handler for scroll events (auto paging).
* @private
*/
_scrollHandler: function () {
if (this.isTopScroll()) {
if (!this._atExtremity && this._previousPageLoader) {
this._previousPageLoader._load();
}
this._atExtremity = true;
} else if (this.isBottomScroll()) {
if (!this._atExtremity && this._nextPageLoader) {
this._nextPageLoader._load();
}
this._atExtremity = true;
} else {
this._atExtremity = false;
}
},
//////////// Page loaders creation ///////////////////////////////////////
/**
* Creates the next page loader widget
* @private
*/
_createNextPageLoader: function () {
/* jshint newcap: false*/
this._nextPageLoader = new _PageLoaderRenderer({
item: {
loadMessage: string.substitute(this.loadNextMessage, this),
loadingMessage: this.loadingMessage
},
beforeLoading: function () {
var showLoadingPanel = this.hideOnPageLoad && !this.autoPaging;
this._setBusy(true, showLoadingPanel);
}.bind(this),
afterLoading: function () {
this._setBusy(false);
}.bind(this),
performLoading: function () {
return this._loadNextPage();
}.bind(this),
_list: this
});
this._nextPageLoader.placeAt(this);
},
/**
* Creates the previous page loader widget
* @private
*/
_createPreviousPageLoader: function () {
/* jshint newcap: false*/
this._previousPageLoader = new _PageLoaderRenderer({
item: {
loadMessage: string.substitute(this.loadPreviousMessage, this),
loadingMessage: this.loadingMessage
},
beforeLoading: function () {
var showLoadingPanel = this.hideOnPageLoad && !this.autoPaging;
this._setBusy(true, showLoadingPanel);
}.bind(this),
afterLoading: function () {
this._setBusy(false);
}.bind(this),
performLoading: function () {
return this._loadPreviousPage();
}.bind(this),
_list: this
});
this._previousPageLoader.placeAt(this, "first");
},
//////////// List methods overriding ///////////////////////////////////////
itemRemoved: dcl.superCall(function (sup) {
return function (index) {
if (this.pageLength > 0) {
if (this._firstLoaded <= index && index <= this._lastLoaded) {
// Remove the item id in _idPages
this._updateIdPages(false, index);
sup.call(this, index - this._firstLoaded);
}
if (index < this._firstLoaded) {
this._firstLoaded--;
}
if (index <= this._lastLoaded) {
this._lastLoaded--;
}
if (this._firstLoaded === 0 && this._previousPageLoader) {
this._previousPageLoader.destroy();
this._previousPageLoader = null;
}
} else {
sup.apply(this, arguments);
}
};
}),
itemAdded: dcl.superCall(function (sup) {
return function (index, item) {
if (this.pageLength > 0) {
if (this._firstLoaded < index && index <= this._lastLoaded) {
// Add the item id in _idPages
this._updateIdPages(true, index, this.getIdentity(item));
this._lastLoaded++;
sup.call(this, index - this._firstLoaded, item);
} else if (index <= this._firstLoaded) {
this._firstLoaded++;
this._lastLoaded++;
if (!this._previousPageLoader) {
this._createPreviousPageLoader();
}
} else if (index > this._lastLoaded) {
if (!this._nextPageLoader) {
this._createNextPageLoader();
}
}
} else {
sup.apply(this, arguments);
}
};
}),
itemUpdated: dcl.superCall(function (sup) {
return function (index, item) {
if (this.pageLength > 0) {
if (this._firstLoaded < index && index <= this._lastLoaded) {
sup.call(this, index - this._firstLoaded, item);
}
} else {
sup.apply(this, arguments);
}
};
}),
_empty: dcl.superCall(function (sup) {
return function () {
sup.call(this, arguments);
if (this.pageLength > 0) {
this._nextPageLoader = null;
this._previousPageLoader = null;
this._rangeSpec = null;
this._untrack();
this._firstLoaded = this._lastLoaded = -1;
}
};
}),
_getNextRenderer: dcl.superCall(function (sup) {
// make sure that no page loader is returned
return function (renderer, /*jshint unused:vars*/dir) {
var value = sup.apply(this, arguments);
if ((this._nextPageLoader && value === this._nextPageLoader)
|| (this._previousPageLoader && value === this._previousPageLoader)) {
value = null;
}
return value;
};
}),
_spaceKeydownHandler: dcl.superCall(function (sup) {
// Handle action key on page loaders
return function (event) {
if (this._nextPageLoader && this._nextPageLoader.contains(event.target)) {
event.preventDefault();
this._nextPageLoader._load();
} else if (this._previousPageLoader && this._previousPageLoader.contains(event.target)) {
event.preventDefault();
this._previousPageLoader._load();
} else {
sup.apply(this, arguments);
}
};
}),
handleSelection: dcl.superCall(function (sup) {
// page loader should never be selected when clicked
return function (event) {
var renderer = this.getEnclosingRenderer(event.target);
if (renderer === this._nextPageLoader || renderer === this._previousPageLoader) {
return;
} else {
sup.apply(this, arguments);
}
};
})
});
});