/** @module deliteful/list/List */
define([
"dcl/dcl",
"delite/register",
"requirejs-dplugins/jquery!attributes/classes",
"delite/CustomElement",
"delite/Selection",
"delite/KeyNav",
"delite/StoreMap",
"delite/Scrollable",
"./ItemRenderer",
"./CategoryRenderer",
"./_LoadingPanel",
"delite/theme!./List/themes/{{theme}}/List.css"
], function (dcl, register, $, CustomElement,
Selection, KeyNav, StoreMap, Scrollable, ItemRenderer, CategoryRenderer, LoadingPanel) {
/**
* A widget that renders a scrollable list of items.
*
* The List widget renders a scrollable list of items that are retrieved from a Store.
* Its custom element tag is `d-list`.
*
* See the {@link https://github.com/ibm-js/deliteful/tree/master/docs/list/List.md user documentation}
* for more details.
*
* @class module:deliteful/list/List
* @augments module:delite/Selection
* @augments module:delite/KeyNav
* @augments module:delite/StoreMap
* @augments module:delite/Scrollable
*/
return register("d-list", [HTMLElement, Selection, KeyNav, StoreMap, Scrollable],
/** @lends module:deliteful/list/List# */ {
/**
* Dojo object store that contains the items to render in the list.
* If no value is provided for this attribute, the List will initialize
* it with an internal store implementation. Note that this internal store
* implementation ignores any query options and returns all the items from
* the store, in the order they were added to the store.
* @member {module:dstore/Store} module:deliteful/list/List#store
* @default null
*/
/**
* Query to pass to the store to retrieve the items to render in the list.
* @member {Object} module:deliteful/list/List#query
* @default {}
*/
/**
* The widget class to use to render list items.
* It MUST extend {@link module:deliteful/list/ItemRenderer deliteful/list/ItemRenderer}.
* @member {module:deliteful/list/ItemRenderer}
* @default module:deliteful/list/ItemRenderer
*/
itemRenderer: ItemRenderer,
/**
* The widget class to use to render category headers when the list items are categorized.
* It MUST extend {@link module:deliteful/list/CategoryRenderer deliteful/list/CategoryRenderer}.
* @member {module:deliteful/list/CategoryRenderer}
* @default module:deliteful/list/CategoryRenderer
*/
categoryRenderer: CategoryRenderer,
/**
* Default mapping between the attribute of the item retrieved from the store
* and the label attribute expected by the default renderer
* @member {string}
* @default "label"
*/
labelAttr: "label",
/**
* Default mapping between the attribute of the item retrieved from the store
* and the icon attribute expected by the default renderer
* @member {string}
* @default "iconclass"
*/
iconclassAttr: "iconclass",
/**
* Default mapping between the attribute of the item retrieved from the store
* and the righttext attribute expected by the default renderer
* @member {string}
* @default "righttext"
*/
righttextAttr: "righttext",
/**
* Default mapping between the attribute of the item retrieved from the store
* and the righticon attribute expected by the default renderer
* @member {string}
* @default "righticonclass"
*/
righticonclassAttr: "righticonclass",
/**
* Name of the list item attribute that define the category of a list item.
* If falsy and categoryFunc is also falsy, the list is not categorized.
* @member {string}
* @default ""
*/
categoryAttr: "",
/**
* A function (or the name of a function) that extracts the category of a list item
* from the following input parameters:
* - `item`: the list item from the store
* - `store`: the store
* If falsy and `categoryAttr` is also falsy, the list is not categorized.
* see {@link module:delite/StoreMap delite/StoreMap}
* @member
* @default null
*/
categoryFunc: null,
/**
* The base class that defines the style of the list.
* Available values are:
* - `"d-list"` (default): render a list with no rounded corners and no left and right margins;
* - `"d-round-rect-list"`: render a list with rounded corners and left and right margins.
* @member {string}
* @default "d-list"
*/
baseClass: "d-list",
// By default, letter search is one character only, so that it does not interfere with pressing
// the SPACE key to (de)select an item.
multiCharSearchDuration: 0,
setAttribute: dcl.superCall(function (sup) {
return function (attr, value) {
sup.apply(this, arguments);
if (attr === "role") {
this._applyRole(value);
}
};
}),
/**
* Defines the scroll direction: `"vertical"` for a scrollable List, `"none"` for a non scrollable List.
* @member {string} module:deliteful/list/List#scrollDirection
* @default "vertical"
*/
_setScrollDirectionAttr: function (value) {
if (value !== "vertical" && value !== "none") {
throw new TypeError("'"
+ value
+ "' not supported for scrollDirection, keeping the previous value of '"
+ this.scrollDirection
+ "'");
} else {
this._set("scrollDirection", value);
}
},
/**
* Defines the selection mode: `"none"` (not allowed if `role=listbox`), `"radio"`, `"single"`
* or `"multiple"`.
* @member {string} module:deliteful/list/List#selectionMode
* @default "none", or "single" if `role=listbox`.
*/
_setSelectionModeAttr: dcl.superCall(function (sup) {
return function (value) {
if (this.getAttribute("role") === "listbox" && value === "none") {
throw new TypeError("selectionMode 'none' is invalid for an aria listbox, "
+ "keeping the previous value of '" + this.selectionMode + "'");
} else {
sup.apply(this, arguments);
}
};
}),
/**
* The selection mode for list items (see {@link module:delite/Selection delite/Selection}).
* @member {string}
* @default "none"
*/
selectionMode: "none",
/**
* Optional message to display, with a progress indicator, when
* the list is loading its content.
* @member {string}
* @default ""
*/
loadingMessage: "",
// CSS classes internally referenced by the List widget
_cssClasses: {
cell: "d-list-cell",
selected: "d-selected",
selectable: "d-selectable",
multiselectable: "d-multiselectable"
},
/**
* A panel that hides the content of the widget when shown, and displays a progress indicator
* and an optional message.
* @member {module:deliteful/list/_LoadingPanel} module:deliteful/list/List#_loadingPanel
* @private
*/
/**
* Handle for the selection click event handler
* @member {Function} module:deliteful/list/List#_selectionClickHandle
* @private
*/
/**
* Previous focus child before the list loose focus
* @member {Element} module:deliteful/list/List#_previousFocusedChild
* @private
*/
/**
* Flag set to a truthy value once the items have been loaded from the store
* @member {boolean} module:deliteful/list/List#_dataLoaded
* @private
*/
render: function () {
// Aria attributes
var currentRole = this.getAttribute("role");
if (currentRole) {
this._applyRole(currentRole);
} else {
this.setAttribute("role", "grid");
}
// Might be overriden at the cell (renderer renderNode) level when developing custom renderers
this.setAttribute("aria-readonly", "true");
},
attachedCallback: dcl.superCall(function (sup) {
return function () {
// Using dcl.superCall() to break the default dcl.chainAfter() chaining
// so that this code runs before StoreMap.attachedCallback()
// search for custom elements to populate the store
this._setBusy(true, true);
this.on("query-error", function () { this._setBusy(false, true); }.bind(this));
sup.call(this);
};
}),
refreshRendering: function (props) {
// List attributes have been updated.
/*jshint maxcomplexity:11*/
if ("selectionMode" in props) {
// Update aria attributes
$(this).removeClass(this._cssClasses.selectable);
$(this).removeClass(this._cssClasses.multiselectable);
this.removeAttribute("aria-multiselectable");
if (this.selectionMode === "none") {
// update aria-selected attribute on unselected items
for (var i = 0; i < this.children.length; i++) {
var child = this.children[i];
if (child.renderNode // no renderNode for the loading panel child
&& child.renderNode.hasAttribute("aria-selected")) {
child.renderNode.removeAttribute("aria-selected");
$(child).removeClass(this._cssClasses.selected);
}
}
} else {
if (this.selectionMode === "single" || this.selectionMode === "radio") {
$(this).addClass(this._cssClasses.selectable);
} else {
$(this).addClass(this._cssClasses.multiselectable);
this.setAttribute("aria-multiselectable", "true");
}
// update aria-selected attribute on unselected items
for (i = 0; i < this.children.length; i++) {
child = this.children[i];
if (child.tagName.toLowerCase() === this.itemRenderer.tag
&& child.renderNode // no renderNode for the loading panel child
&& !child.renderNode.hasAttribute("aria-selected")) {
child.renderNode.setAttribute("aria-selected", "false");
$(child).removeClass(this._cssClasses.selected); // TODO: NOT NEEDED ?
}
}
}
}
},
/*jshint maxcomplexity:10*/
computeProperties: function (props) {
// List attributes have been updated.
/*jshint maxcomplexity:12*/
if ("selectionMode" in props) {
if (this.selectionMode === "none") {
if (this._selectionClickHandle) {
this._selectionClickHandle.remove();
this._selectionClickHandle = null;
}
} else {
if (!this._selectionClickHandle) {
this._selectionClickHandle = this.on("click", this.handleSelection.bind(this));
}
}
}
if ("itemRenderer" in props
|| (this._isCategorized()
&& ("categoryAttr" in props || "categoryFunc" in props || "categoryRenderer" in props))) {
if (this._dataLoaded) {
this._setBusy(true, true);
// trigger a reload of the list
this.notifyCurrentValue("source");
}
}
},
destroy: function () {
// Remove reference to the list in the default store
if (this.source && this.source.list) {
this.source.list = null;
}
this._hideLoadingPanel();
},
deliver: dcl.superCall(function (sup) {
return function () {
// Deliver pending changes to the list and its renderers
sup.apply(this, arguments);
var renderers = this.querySelectorAll(this.itemRenderer.tag + ", " + this.categoryRenderer.tag);
for (var i = 0; i < renderers.length; i++) {
renderers.item(i).deliver();
}
};
}),
//////////// Public methods ///////////////////////////////////////
/**
* Returns the item renderers displayed by the list.
* @returns {NodeList}
*/
getItemRenderers: function () {
return this.querySelectorAll(this.itemRenderer.tag);
},
/**
* Returns the renderer currently displaying an item with a specific id, or
* null if there is no renderer displaying an item with this id.
* @param {Object} id The id of the item displayed by the renderer.
* @returns {module:deliteful/list/Renderer}
*/
getRendererByItemId: function (id) {
var renderers = this.getItemRenderers();
for (var i = 0; i < renderers.length; i++) {
var renderer = renderers.item(i);
if (this.getIdentity(renderer.item) === id) {
return renderer;
}
}
return null;
},
/**
* Returns the item renderer at a specific index in the List, or null if there is no
* renderer at this index.
* @param {number} index The item renderer at the index (first item renderer index is 0).
* @returns {module:deliteful/list/ItemRenderer}
*/
getItemRendererByIndex: function (index) {
return index >= 0 ? this.getItemRenderers().item(index) : null;
},
/**
* Returns the index of an item renderer in the List, or -1 if
* the item renderer is not found in the list.
* @param {Object} renderer The item renderer.
* @returns {number}
*/
getItemRendererIndex: function (renderer) {
var result = -1;
if (renderer.item) {
var id = this.getIdentity(renderer.item);
var nodeList = this.getItemRenderers();
for (var i = 0; i < nodeList.length; i++) {
var currentRenderer = nodeList.item(i);
if (this.getIdentity(currentRenderer.item) === id) {
result = i;
break;
}
}
}
return result;
},
/**
* Returns the renderer enclosing a dom node, or null
* if there is none.
* @param {Element} node The dom node.
* @returns {module:deliteful/list/Renderer}
*/
getEnclosingRenderer: function (node) {
var currentNode = node;
while (currentNode) {
if (currentNode.parentNode && currentNode.parentNode === this) {
break;
}
currentNode = currentNode.parentNode;
}
return currentNode;
},
//////////// delite/Selection implementation ///////////////////////////////////////
/**
* Updates renderers when the selection has changed.
* @param {Object[]} items The items which renderers must be updated.
* @protected
*/
updateRenderers: function (items) {
if (this.selectionMode !== "none") {
for (var i = 0; i < items.length; i++) {
var currentItem = items[i];
var renderer = this.getRendererByItemId(this.getIdentity(currentItem));
if (renderer) {
var itemSelected = !!this.isSelected(currentItem);
renderer.renderNode.setAttribute("aria-selected", itemSelected ? "true" : "false");
$(renderer).toggleClass(this._cssClasses.selected, itemSelected);
}
}
}
},
/**
* Always returns true so that no keyboard modifier is needed when selecting / deselecting items.
* @param {Event} event
* @return {boolean}
* @protected
*/
hasSelectionModifier: function (/*jshint unused: vars*/event) {
return true;
},
/**
* Event handler that performs items (de)selection.
* @param {Event} event The event the handler was called for.
* @returns {boolean} `true` if the event has been handled, that is if the
* event target has an enclosing item renderer. Returns `false` otherwise.
* @protected
*/
handleSelection: function (/*Event*/event) {
var eventRenderer = this.getEnclosingRenderer(event.target);
if (eventRenderer) {
if (!this.isCategoryRenderer(eventRenderer)) {
this.selectFromEvent(event, eventRenderer.item, eventRenderer, true);
}
return true;
}
return false;
},
//////////// Private methods ///////////////////////////////////////
/*jshint maxcomplexity:12*/
_applyRole: function (role) {
if (role === "listbox") {
// TODO: also this codes work specifically when switching between grid and listbox.
// If we're going to support list, we'll need something a little different
var nodes = this.querySelectorAll(".d-list-cell[role='gridcell']");
for (var i = 0; i < nodes.length; i++) {
nodes[i].setAttribute("role", "option");
}
nodes = this.querySelectorAll(".d-list-item[role='row']");
for (i = 0; i < nodes.length; i++) {
nodes[i].removeAttribute("role");
}
if (this._isCategorized()) {
nodes = this.querySelectorAll(".d-list-category[role='row']");
for (i = 0; i < nodes.length; i++) {
nodes[i].removeAttribute("role");
}
}
} else {
nodes = this.querySelectorAll(".d-list-cell[role='option']");
for (i = 0; i < nodes.length; i++) {
nodes[i].setAttribute("role", "gridcell");
}
nodes = this.getItemRenderers();
for (i = 0; i < nodes.length; i++) {
nodes[i].setAttribute("role", "row");
}
if (this._isCategorized()) {
nodes = this.querySelectorAll(".d-list-category");
for (i = 0; i < nodes.length; i++) {
nodes[i].setAttribute("role", "row");
}
}
}
},
/*jshint maxcomplexity:10*/
/**
* Sets the "busy" status of the widget.
* @param {boolean} status true if the list is busy loading and rendering its data.
* false otherwise.
* @param {boolean} hideContent true if the list should hide its content when it is busy,
* false otherwise
* @private
*/
_setBusy: function (status, hideContent) {
if (status) {
this.setAttribute("aria-busy", "true");
if (hideContent) {
this._showLoadingPanel();
}
} else {
this.removeAttribute("aria-busy");
this._hideLoadingPanel();
}
},
/**
* Shows the loading panel
* @private
*/
_showLoadingPanel: function () {
if (!this._loadingPanel) {
this._loadingPanel = new LoadingPanel({message: this.loadingMessage});
if (this.children[0] !== undefined) {
this.insertBefore(this._loadingPanel, this.children[0]);
} else {
this.appendChild(this._loadingPanel);
}
this._loadingPanel.attachedCallback();
}
},
/**
* Hides the loading panel
* @private
*/
_hideLoadingPanel: function () {
if (this._loadingPanel) {
this._loadingPanel.destroy();
this._loadingPanel = null;
}
},
/**
* Returns whether the list is categorized or not.
* @private
*/
_isCategorized: function () {
return this.categoryAttr || this.categoryFunc;
},
/**
* Destroys all children of the list and empty it
* @private
*/
_empty: function () {
this.findCustomElements(this).forEach(function (w) {
if (w.destroy) {
w.destroy();
}
});
this.innerHTML = "";
this._previousFocusedChild = null;
},
//////////// Renderers life cycle ///////////////////////////////////////
/**
* Renders new items within the list widget.
* @param {Object[]} items The new items to render.
* @param {boolean} atTheTop If true, the new items are rendered at the top of the list.
* If false, they are rendered at the bottom of the list.
* @private
*/
_renderNewItems: function (/*Array*/ items, /*boolean*/atTheTop) {
if (!this.firstElementChild || this.firstElementChild === this._loadingPanel) {
this.appendChild(this._createRenderers(items, 0, items.length, null));
} else {
if (atTheTop) {
if (this._isCategorized()) {
var firstRenderer = this._getFirstRenderer();
if (this.isCategoryRenderer(firstRenderer)
&& items[items.length - 1].category === firstRenderer.item.category) {
// Remove the category renderer on top before adding the new items
this._removeRenderer(firstRenderer);
}
}
this.insertBefore(this._createRenderers(items, 0, items.length, null),
this.firstElementChild);
} else {
this.appendChild(this._createRenderers(items, 0, items.length,
this._getLastRenderer().item));
}
}
// start renderers
this.findCustomElements(this).forEach(function (w) {
w.attachedCallback();
});
},
/**
* Creates renderers for a list of items (including the category renderers if the list
* is categorized).
* @param {Object[]} items Array An array that contains the items to create renderers for.
* @param {number} fromIndex The index of the first item in the array of items
* (no renderer will be created for the items before this index).
* @param {number} count The number of items to use from the array of items, starting
* from the fromIndex position
* (no renderer will be created for the items that follows).
* @param {Object} previousItem The item that precede the first one for which a renderer will be created.
* This is only usefull for categorized lists.
* @return {DocumentFragment} A DocumentFragment that contains the renderers.
* The startup method of the renderers has not been called at this point.
* @private
*/
_createRenderers: function (items, fromIndex, count, previousItem) {
var currentIndex = fromIndex,
currentItem, toIndex = fromIndex + count - 1;
var documentFragment = this.ownerDocument.createDocumentFragment();
for (; currentIndex <= toIndex; currentIndex++) {
currentItem = items[currentIndex];
if (this._isCategorized()
&& (!previousItem || currentItem.category !== previousItem.category)) {
documentFragment.appendChild(this._createCategoryRenderer(currentItem));
}
documentFragment.appendChild(this._createItemRenderer(currentItem));
previousItem = currentItem;
}
return documentFragment;
},
/**
* Add an item renderer to the List, updating category renderers if needed.
* This method calls the startup method on the renderer after it has been
* added to the List.
* @param {module:deliteful/list/ItemRenderer} The renderer to add to the list.
* @param {number} atIndex The index (not counting category renderers) where to add
* the item renderer in the list.
* @private
*/
_addItemRenderer: function (renderer, atIndex) {
var spec = this._getInsertSpec(renderer, atIndex);
if (spec.nodeRef) {
this.insertBefore(renderer, spec.nodeRef);
if (spec.addCategoryAfter) {
var categoryRenderer = this._createCategoryRenderer(spec.nodeRef.item);
this.insertBefore(categoryRenderer, spec.nodeRef);
categoryRenderer.attachedCallback();
}
} else {
this.appendChild(renderer);
}
if (spec.addCategoryBefore) {
categoryRenderer = this._createCategoryRenderer(renderer.item);
this.insertBefore(categoryRenderer, renderer);
categoryRenderer.attachedCallback();
}
renderer.attachedCallback();
},
/**
* Get a specification for the insertion of an item renderer in the list.
* @param {module:deliteful/list/ItemRenderer} renderer The renderer to add to the list.
* @param {number} atIndex The index (not counting category renderers) where to add
* the item renderer in the list.
* @return {Object} an object that contains the following attributes:
* - nodeRef: the node before which to insert the item renderer
* - addCategoryBefore: true if a category header should be inserted before the item renderer
* - addCategoryAfter: true if a category header should be inserted after the item renderer
* @private
*/
_getInsertSpec: function (renderer, atIndex) {
var result = {nodeRef: atIndex >= 0 ? this.getItemRendererByIndex(atIndex) : null,
addCategoryBefore: false,
addCategoryAfter: false};
if (this._isCategorized()) {
var previousRenderer = result.nodeRef
? this._getNextRenderer(result.nodeRef, -1)
: this._getLastRenderer();
if (!previousRenderer) {
result.addCategoryBefore = true;
} else {
if (!this._sameCategory(renderer, previousRenderer)) {
if (this.isCategoryRenderer(previousRenderer)) {
result.nodeRef = previousRenderer;
previousRenderer = this._getNextRenderer(previousRenderer, -1);
if (!previousRenderer
|| (previousRenderer && !this._sameCategory(renderer, previousRenderer))) {
result.addCategoryBefore = true;
}
} else {
result.addCategoryBefore = true;
}
}
}
if (result.nodeRef
&& !this.isCategoryRenderer(result.nodeRef)
&& !this._sameCategory(result.nodeRef, renderer)) {
result.addCategoryAfter = true;
}
}
return result;
},
/*jshint maxcomplexity:12*/
/**
* Removes a renderer from the List, updating category renderers if needed.
* @param {module:deliteful/list/Renderer} renderer The renderer to remove from the list.
* @param {boolean} keepSelection Set to true if the renderer item should not be removed
* from the list of selected items.
* @private
*/
_removeRenderer: function (renderer, keepSelection) {
if (this._isCategorized() && !this.isCategoryRenderer(renderer)) {
// remove the previous category header if necessary
var previousRenderer = this._getNextRenderer(renderer, -1);
if (previousRenderer && this.isCategoryRenderer(previousRenderer)) {
var nextRenderer = this._getNextRenderer(renderer, 1);
if (!nextRenderer || !this._sameCategory(renderer, nextRenderer)) {
this._removeRenderer(previousRenderer);
}
}
}
// Update focus if necessary
if (this._getFocusedRenderer() === renderer) {
var nextFocusRenderer = this._getNextRenderer(renderer, 1) || this._getNextRenderer(renderer, -1);
if (nextFocusRenderer) {
this.navigateTo(nextFocusRenderer.renderNode);
}
}
if (!keepSelection && !this.isCategoryRenderer(renderer) && this.isSelected(renderer.item)) {
// deselect the item before removing the renderer
this.selectFromEvent(null, renderer.item, renderer, true);
}
// remove and destroy the renderer
if (this._previousFocusedChild && this.getEnclosingRenderer(this._previousFocusedChild) === renderer) {
this._previousFocusedChild = null;
}
this.removeChild(renderer);
renderer.destroy();
},
/*jshint maxcomplexity:10*/
/**
* Creates a renderer instance for an item.
* @param {Object} item The item to render.
* @returns {module:deliteful/list/ItemRenderer} An instance of item renderer that renders the item.
* @private
*/
_createItemRenderer: function (item) {
var renderer = new this.itemRenderer({item: item, tabindex: "-1"});
if (this.selectionMode !== "none") {
var itemSelected = !!this.isSelected(item);
renderer.renderNode.setAttribute("aria-selected", itemSelected ? "true" : "false");
$(renderer).toggleClass(this._cssClasses.selected, itemSelected);
}
return renderer;
},
/**
* Creates a category renderer instance for an item.
* @param {Object} item The item which category to render.
* @returns {module:deliteful/list/CategoryRenderer} An instance of category renderer
* that renders the category of the item.
* @private
*/
_createCategoryRenderer: function (item) {
return new this.categoryRenderer({item: item, tabindex: "-1"});
},
/**
* Returns whether a renderer is a category renderer or not>.
* @param {module:deliteful/list/Renderer} renderer The renderer to test.
* @return {boolean}
*/
isCategoryRenderer: function (/*deliteful/list/Renderer*/renderer) {
return renderer.tagName.toLowerCase() === this.categoryRenderer.tag;
},
/**
* Returns whether two renderers have the same category or not.
* @param {module:deliteful/list/Renderer} renderer1 The first renderer.
* @param {module:deliteful/list/Renderer} renderer2 The second renderer.
* @private
*/
_sameCategory: function (renderer1, renderer2) {
return renderer1.item.category === renderer2.item.category;
},
/**
* Returns the renderer that comes immediately after of before another one.
* @param {module:deliteful/list/Renderer} renderer The renderer immediately before or after the one to return.
* @param {number} dir 1 to return the renderer that comes immediately after renderer, -1 to
* return the one that comes immediately before.
* @returns {module:deliteful/list/Renderer}
* @private
*/
_getNextRenderer: function (renderer, dir) {
if (dir >= 0) {
return renderer.nextElementSibling;
} else {
return renderer.previousElementSibling;
}
},
/**
* Returns the first renderer in the list.
* @returns {module:deliteful/list/Renderer}
* @private
*/
_getFirstRenderer: function () {
return this.querySelector(this.itemRenderer.tag + ", " + this.categoryRenderer.tag);
},
/**
* Returns the last renderer in the list.
* @returns {module:deliteful/list/Renderer}
* @private
*/
_getLastRenderer: function () {
var renderers = this
.querySelectorAll(this.itemRenderer.tag + ", " + this.categoryRenderer.tag);
return renderers.length ? renderers.item(renderers.length - 1) : null;
},
////////////delite/Store implementation ///////////////////////////////////////
/**
* Populate the list using the items retrieved from the store.
* @param {Object[]} items items retrieved from the store.
* @protected
* @fires module:delite/Store#query-success
*/
initItems: function (items) {
this._empty();
this._renderNewItems(items, false);
this._setBusy(false, true);
this._dataLoaded = true;
this.emit("query-success", { renderItems: items, cancelable: false, bubbles: true });
},
/**
* Function to call when an item is removed from the store, to update
* the content of the list widget accordingly.
* @param {number} index The index of the render item to remove.
* @param {Object[]} renderItems Ignored by this implementation.
* @param {boolean} keepSelection Set to true if the item should not be removed from the list of selected items.
* @protected
*/
itemRemoved: function (index, renderItems, keepSelection) {
var renderer = this.getItemRendererByIndex(index);
if (renderer) {
this._removeRenderer(renderer, keepSelection);
}
},
/**
* Function to call when an item is added to the store, to update
* the content of the list widget accordingly.
* @param {number} index The index where to add the render item.
* @param {Object} renderItem The render item to be added.
* @param {Object[]} renderItems Ignored by this implementation.
* @private
*/
itemAdded: function (index, renderItem, /*jshint unused:vars*/renderItems) {
var newRenderer = this._createItemRenderer(renderItem);
this._addItemRenderer(newRenderer, index);
},
/**
* Function to call when an item is updated in the store, to update
* the content of the list widget accordingly.
* @param {number} index The index of the render item to update.
* @param {Object} renderItem The render item data the render item must be updated with.
* @param {Object[]} renderItems Ignored by this implementation.
* @protected
*/
itemUpdated: function (index, renderItem, /*jshint unused:vars*/renderItems) {
var renderer = this.getItemRendererByIndex(index);
if (renderer) {
renderer.item = renderItem;
}
},
itemMoved: function (previousIndex, newIndex, renderItem, renderItems) {
// summary:
// Function to call when an item is moved in the store, to update
// the content of the list widget accordingly.
// previousIndex: Number
// The previous index of the render item.
// newIndex: Number
// The new index of the render item.
// renderItem: Object
// The render item to be moved.
// renderItems: Array
// Ignored by this implementation.
// tags:
// protected
this.itemRemoved(previousIndex, renderItems, true);
this.itemAdded(newIndex, renderItem, renderItems);
},
//////////// delite/Scrollable extension ///////////////////////////////////////
/**
* Returns the distance between the top of a node and
* the top of the scrolling container.
* @param {Node} node the node
* @protected
*/
getTopDistance: function (node) {
// Need to use Math.round for IE
return Math.round(node.offsetTop - this.getCurrentScroll().y);
},
/**
* Returns the distance between the bottom of a node and
* the bottom of the scrolling container.
* @param {Node} node the node
* @protected
*/
getBottomDistance: function (node) {
var clientRect = this.getBoundingClientRect();
// Need to use Math.round for IE
return Math.round(node.offsetTop +
node.offsetHeight -
this.getCurrentScroll().y -
(clientRect.bottom - clientRect.top));
},
//////////// delite/KeyNav implementation ///////////////////////////////////////
// Keyboard navigation is based on WAI ARIA Pattern for Grid:
// http://www.w3.org/TR/2013/WD-wai-aria-practices-20130307/#grid
/**
* @private
*/
descendantSelector: function (child) {
var enclosingRenderer = this.getEnclosingRenderer(child);
return !enclosingRenderer ||
(this.getAttribute("role") === "listbox" && this.isCategoryRenderer(enclosingRenderer)) ?
false :
$(child).hasClass(this._cssClasses.cell) || child.hasAttribute("navindex");
},
/**
* @method
* Handle keydown events
* @private
*/
_keynavKeyDownHandler: dcl.before(function (evt) {
if (!evt.defaultPrevented) {
if ((evt.key === "Spacebar" && !this._searchTimer)) {
this._spaceKeydownHandler(evt);
} else {
if (this.getAttribute("role") !== "listbox") {
this._gridKeydownHandler(evt);
}
}
}
}),
focus: function () {
// Focus the previously focused child of the first visible grid cell
if (this._previousFocusedChild) {
this.navigateTo(this._previousFocusedChild);
} else {
var cell = this._getFirst();
if (cell) {
while (cell) {
if (this.getTopDistance(cell) >= 0) {
break;
}
var nextRenderer = cell.parentNode.nextElementSibling;
cell = nextRenderer ? nextRenderer.renderNode : null;
}
this.navigateTo(cell);
}
}
},
/**
* @method
* Called on "delite-deactivated" event, stores a reference to the focused child.
* @private
*/
_keynavDeactivatedHandler: dcl.superCall(function (sup) {
return function () {
this._previousFocusedChild = this.navigatedDescendant;
sup.call(this);
};
}),
// Page Up/Page down key support
/**
* Returns the first cell in the list.
* @private
* @returns {Element}
*/
_getFirst: function () {
var first = this.querySelector("." + this._cssClasses.cell);
if (first && this.getAttribute("role") === "listbox"
&& this.isCategoryRenderer(this.getEnclosingRenderer(first))) {
first = this.getNext(first, 1);
}
return first;
},
/**
* Returns the last cell in the list.
* @private
* @returns {Element}
*/
_getLast: function () {
// summary:
var cells = this.querySelectorAll("." + this._cssClasses.cell);
var last = cells.length ? cells.item(cells.length - 1) : null;
if (last && this.getAttribute("role") === "listbox"
&& this.isCategoryRenderer(this.getEnclosingRenderer(last))) {
last = this.getNext(last, -1);
}
return last;
},
// Simple arrow key support.
downKeyHandler: function (evt) {
if (this.navigatedDescendant && this.navigatedDescendant.hasAttribute("navindex")) {
return;
}
var focusedRenderer = this._getFocusedRenderer();
var next = null;
if (focusedRenderer) {
next = focusedRenderer.nextElementSibling;
if (next && this.getAttribute("role") === "listbox" && this.isCategoryRenderer(next)) {
next = next.nextElementSibling;
}
}
this.navigateTo(next ? next.renderNode : this._getFirst(), false, evt);
},
upKeyHandler: function (evt) {
if (this.navigatedDescendant && this.navigatedDescendant.hasAttribute("navindex")) {
return;
}
var focusedRenderer = this._getFocusedRenderer();
var next = null;
if (focusedRenderer) {
next = focusedRenderer.previousElementSibling;
if (next && this.getAttribute("role") === "listbox" && this.isCategoryRenderer(next)) {
next = next.previousElementSibling;
}
}
this.navigateTo(next ? next.renderNode : this._getLast(), false, evt);
},
// Remap Page Up -> Home and Page Down -> End
pageUpKeyHandler: function (evt) {
this.navigateToFirst(evt);
},
pageDownKeyHandler: function (evt) {
this.navigateToLast(evt);
},
getNext: function (child, dir) {
if (child === this) {
return dir > 0 ? this._getFirst() : this._getLast();
}
// Letter key navigation support.
var renderer = this.getEnclosingRenderer(child);
return dir > 0 ? renderer.nextElementSibling ? renderer.nextElementSibling.renderNode : this._getFirst() :
renderer.previousElementSibling ? renderer.previousElementSibling.renderNode : this._getLast();
},
//////////// Extra methods for Keyboard navigation ///////////////////////////////////////
/**
* Handles SPACE key keydown event
* @param {Event} evt the keydown event
* @private
*/
_spaceKeydownHandler: function (evt) {
if (this.selectionMode !== "none") {
if (this.handleSelection(evt)) {
evt.preventDefault();
} // else do not prevent-default, for the sake of use-cases
// such as Combobox where the target of the key event is an
// input element outside the List. In this use-case, delite/HasDropDown
// forwards "keydown" events to the List instance and prevent-defaults
// the event if any key handler prevent-defaults the event, which would
// forbid the user from entering space characters in the input element.
}
},
/*jshint maxcomplexity:13*/
/**
* Handles keydown events for the aria role grid
* @param {Event} evt the keydown event
* @private
*/
_gridKeydownHandler: function (evt) {
if (evt.key === "Enter" || evt.key === "F2") {
if (this.navigatedDescendant && !this.navigatedDescendant.hasAttribute("navindex")) {
// Enter Actionable Mode
// TODO: prevent default ONLY IF autoAction is false on the renderer ?
// See http://www.w3.org/TR/2013/WD-wai-aria-practices-20130307/#grid
evt.preventDefault();
this._enterActionableMode();
}
} else if (evt.key === "Tab") {
if (this.navigatedDescendant && this.navigatedDescendant.hasAttribute("navindex")) {
// We are in Actionable mode
evt.preventDefault();
var renderer = this._getFocusedRenderer();
var next = renderer[evt.shiftKey ? "getPrev" : "getNext"](this.navigatedDescendant);
while (!next) {
renderer = renderer[evt.shiftKey ? "previousElementSibling" : "nextElementSibling"]
|| this[evt.shiftKey ? "_getLast" : "_getFirst"]().parentNode;
next = renderer[evt.shiftKey ? "getLast" : "getFirst"]();
}
this.navigateTo(next);
}
} else if (evt.key === "Esc") {
// Leave Actionable mode
this._leaveActionableMode();
}
},
/*jshint maxcomplexity:10*/
/**
* @private
*/
_enterActionableMode: function () {
var focusedRenderer = this._getFocusedRenderer();
if (focusedRenderer) {
var next = focusedRenderer.getFirst();
if (next) {
this.navigateTo(next);
}
}
},
/**
* @private
*/
_leaveActionableMode: function () {
this.navigateTo(this._getFocusedRenderer().renderNode);
},
/**
* Returns the renderer that currently has the focused or is
* an ancestor of the focused node.
* @return {module:deliteful/list/Renderer}
* @private
*/
_getFocusedRenderer: function () {
return this.navigatedDescendant ? this.getEnclosingRenderer(this.navigatedDescendant) : null;
}
});
});