/** @module deliteful/Select */
define([
"dcl/dcl",
"requirejs-dplugins/jquery!attributes/classes",
"decor/sniff",
"delite/register",
"delite/FormWidget",
"delite/StoreMap",
"delite/Selection",
"delite/handlebars!./Select/Select.html",
"delite/theme!./Select/themes/{{theme}}/Select.css"
], function (dcl, $, has, register,
FormWidget, StoreMap, Selection, template) {
/**
* A form-aware and store-aware widget leveraging the native HTML5 `<select>`
* element.
* It has the following characteristics:
* * The corresponding custom tag is `<d-select>`.
* * Allows to select one or more items among a number of options (in single
* or multiple selection mode; see `selectionMode`).
* * Store support (limitation: to avoid graphic glitches, the updates to the
* store should not be done while the native dropdown of the select is open).
* The attributes of data items used for the `label`, `value`, and `disabled`
* attributes of option elements can be customized using respectively the
* `labelAttr`, `valueAttr`, and `disabledAttr` properties, or using
* `labelFunc`, `valueFunc`, and `disabledFunc` properties (for details, see
* the documentation of the `delite/StoreMap` superclass).
* * Form support (inherits from `delite/FormWidget`).
* * The item rendering has the limitations of the `<option>` elements of the
* native `<select>`, in particular it is text-only.
*
* Remarks:
* * The option items must be added, removed or updated exclusively using
* the store API. Direct operations using the DOM API are not supported.
* * The handling of the selected options of the underlying native `<select>`
* must be done using the API inherited by deliteful/Select from delite/Selection.
*
* @example <caption>Using store custom element in markup</caption>
* JS:
* require(["deliteful/Select", "requirejs-domready/domReady!"],
* function () {
* });
* HTML:
* <d-select id="select">
* {text: "Option 1", value: "1"}
* ...
* </d-select>
* @example <caption>Using programmatically created store</caption>
* JS:
* require(["dstore/Memory", "dstore/Trackable",
* "deliteful/Select", "requirejs-domready/domReady!"],
* function (Memory, Trackable) {
* var store = new (Memory.createSubclass(Trackable))({});
* select1.source = store;
* store.add({text: "Option 1", value: "1"});
* ...
* });
* HTML:
* <d-select selectionMode="multiple" id="select"></d-select>
*
* @class module:deliteful/Select
* @augments module:delite/FormWidget
* @augments module:delite/StoreMap
* @augments module:delite/Selection
*/
return register("d-select", [HTMLElement, FormWidget, Selection, StoreMap],
// Have to keep StoreMap after Selection to get Store definition of getIdentity function
/** @lends module:deliteful/Select# */ {
// TODO: improve doc.
// Note: the properties `store` and `query` are inherited from delite/Store, and
// the property `disabled` is inherited from delite/FormWidget.
/**
* The number of rows that should be visible at one time when the widget
* is presented as a scrollable list box. Corresponds to the `size` attribute
* of the underlying native HTML `<select>`.
* @member {number}
* @default 0
*/
size: 0,
/**
* The name of the property of store items which contains the text
* of Select's options.
* @member {string}
* @default "text"
*/
textAttr: "text",
/**
* The name of the property of store items which contains the value
* of Select's options.
* @member {string}
* @default "value"
*/
valueAttr: "value",
/**
* The name of the property of store items which contains the disabled
* value of Select's options. To disable a given option, the `disabled`
* property of the corresponding data item must be set to a truthy value.
* Otherwise, the option is enabled if data item property is absent, or
* its value is falsy or the string "false".
* @member {string}
* @default "disabled"
*/
disabledAttr: "disabled",
baseClass: "d-select",
/**
* The chosen selection mode.
*
* Valid values are:
*
* 1. "single": Only one option can be selected at a time.
* 2. "multiple": Several options can be selected (by taping or using the
* control key modifier).
*
* Changing this value impacts the currently selected items to adapt the
* selection to the new mode. However, regardless of the selection mode,
* it is always possible to set several selected items using the
* `selectedItem` or `selectedItems` properties.
* The mode will be enforced only when using `setSelected` and/or
* `selectFromEvent` APIs.
*
* @member {string} module:deliteful/Select#selectionMode
* @default "single"
*/
// The purpose of the above pseudo-property is to adjust the documentation
// of selectionMode as provided by delite/Selection.
template: template,
afterFormResetCallback: function () {
this.valueNode.selectedIndex =
this.selectionMode === "single" ?
// First option selected in "single" selection mode, and
// no option selected in "multiple" mode
0 : -1;
this.value = this.valueNode.value;
},
postRender: function () {
// To provide graphic feedback for focus, react to focus/blur events
// on the underlying native select. The CSS class is used instead
// of the focus pseudo-class because the browsers give the focus
// to the underlying select, not to the widget.
this.on("focus", function (evt) {
$(this).toggleClass("d-select-focus", evt.type === "focus");
}.bind(this), this.valueNode);
this.on("blur", function (evt) {
$(this).toggleClass("d-select-focus", evt.type === "focus");
}.bind(this), this.valueNode);
// Keep delite/Selection's selectedItem/selectedItems in sync after
// interactive selection of options.
this.on("change", function (event) {
this._duringInteractiveSelection = true;
var selectedItems = this.selectedItems,
selectedOptions = this.valueNode.selectedOptions;
// HTMLSelectElement.selectedOptions is not present in all browsers...
// At least IE10/Win misses it. Hence:
if (selectedOptions === undefined) {
// Convert to array
var options = Array.prototype.slice.call(this.valueNode.options);
selectedOptions = options.filter(function (option) {
return option.selected;
});
} else {
// convert HTMLCollection into array (to be able to use array.indexOf)
selectedOptions = Array.prototype.slice.call(selectedOptions);
}
var nSelectedItems = selectedItems ? selectedItems.length : 0,
nSelectedOptions = selectedOptions ? selectedOptions.length : 0;
var i;
var selectedOption, selectedItem;
// Identify the options which changed their selection state. Two steps:
// Step 1. Search options previously selected (currently in widget.selectedItems)
// which are no longer selected in the native select.
for (i = 0; i < nSelectedItems; i++) {
selectedItem = selectedItems[i];
if (selectedOptions.indexOf(selectedItem.__visualItem) === -1) {
this.selectFromEvent(event, selectedItem, selectedItem.__visualItem, true);
}
}
// Step 2. Search options newly selected in the native select which are not
// present in the current selection (widget.selectedItems).
for (i = 0; i < nSelectedOptions; i++) {
selectedOption = selectedOptions[i];
if (selectedItems.indexOf(selectedOption.__dataItem) === - 1) {
this.selectFromEvent(event, selectedOption.__dataItem, selectedOption, true);
}
}
// Update widget's value after interactive selection
this._set("value", this.valueNode.value);
this._duringInteractiveSelection = false;
}.bind(this), this.valueNode);
// Thanks to the custom getter defined in deliteful/Select for widget's
// `value` property, there is no need to add code for keeping the
// property in sync after a form reset.
},
hasSelectionModifier: function () {
// Override of the method from delite/Selection because the
// default implementation is inappropriate: the "change" event
// has no key modifier.
return this.selectionMode === "multiple";
},
refreshRendering: function (props) {
/* jshint maxcomplexity: 13 */
if ("renderItems" in props) {
// Populate the select with the items retrieved from the store.
var renderItems = this.renderItems;
var n = renderItems ? renderItems.length : 0;
// TODO: CHECKME/IMPROVEME. Also called after adding, deleting or updating just one item.
// Worth optimizing to avoid recreating from scratch?
this.valueNode.innerHTML = ""; // Remove the existing options from the DOM
if (n > 0) {
var fragment = this.ownerDocument.createDocumentFragment();
var renderItem, option;
for (var i = 0; i < n; i++) {
renderItem = renderItems[i];
option = this.ownerDocument.createElement("option");
// to allow retrieving the data item from the option element
option.__dataItem = renderItem.__item; // __item is set by StoreMap.itemToRenderItem()
// to allow retrieving the option element from widget's selectedItems
// (which are data items, not render items).
option.__dataItem.__visualItem = option;
this.discardChanges(); // to avoid infinity loop
// According to http://www.w3.org/TR/html5/forms.html#the-option-element, we
// could use equivalently the label or the text IDL attribute of the option element.
// However, using the label attr. breaks the rendering in FF29/Win7!
// This is https://bugzilla.mozilla.org/show_bug.cgi?id=40545.
// Hence don't do
// option.label = renderItem.label;
// Instead:
if (renderItem.text !== undefined) { // optional
option.text = renderItem.text;
}
if (renderItem.value !== undefined) { // optional
option.setAttribute("value", renderItem.value);
} else if (has("ie") && renderItem.text !== undefined) { // #546
option.setAttribute("value", renderItem.text);
}
// The selection API (delite/Selection) needs to be called consistently
// for data items, not for render items.
// renderItem.__item is the data item instance for which
// StoreMap.itemToRenderItem() has created the render item.
// For now there is no public API for accessing it.
if (this.isSelected(renderItem.__item)) { // delite/Selection's API
option.setAttribute("selected", "true");
}
if (renderItem.disabled !== undefined &&
!!renderItem.disabled && renderItem.disabled !== "false") { // optional
// Note that for an enabled option the attribute must NOT be set
// (<option disabled="false"> is a disabled option!)
option.setAttribute("disabled", "true");
}
fragment.appendChild(option);
}
this.valueNode.appendChild(fragment);
if (this.selectionMode === "single") {
// Since there is no native "change" event initially, initialize
// the delite/Selection's selectedItem property with the currently
// selected option of the native select.
this.selectedItem =
this.valueNode.options[this.valueNode.selectedIndex].__dataItem;
} // else for the native multi-select: it does not have any
// option selected by default.
// Initialize widget's value
this._set("value", this.valueNode.value);
}
}
},
getIdentity: dcl.superCall(function (sup) {
return function (dataItem) {
return sup.call(this, dataItem);
};
}),
updateRenderers: function () {
// Override of delite/Selection's method.
// Trigger rerendering from scratch, in order to keep the rendering
// in sync with the selection state of items. This method gets called
// by delite/Selection after changes of selection state. However, the
// re-rendering must not be triggered while the user clicks items,
// because it would disturb user's interaction with a Select in
// multiple mode (#510): with more options than the available height, after
// scrolling and clicking an item, the rerendered Select may not have
// the same scroll amount as before the click, which isn't ergonomical.
// (Differently, in single selection mode, the popup closes right after
// the interactive selection.)
if (!this._duringInteractiveSelection) {
this.notifyCurrentValue("renderItems");
}
},
_setValueAttr: function (value) {
if (this.valueNode) {
this.valueNode.value = value;
}
this._set("value", value);
},
_setSelectionModeAttr: dcl.superCall(function (sup) {
// Override of the setter from delite/Selection to forbid the values
// "none" and "radio"
return function (value) {
if (value !== "single" && value !== "multiple") {
throw new TypeError("'" + value +
"' not supported for selectionMode; keeping the previous value of '" +
this.selectionMode + "'");
} else {
this._set("selectionMode", value);
}
sup.call(this, value);
};
})
});
});