You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
594 lines
21 KiB
594 lines
21 KiB
define([ |
|
"dojo/aspect", |
|
"dojo/_base/declare", // declare |
|
"dojo/dom-attr", // domAttr.get |
|
"dojo/keys", |
|
"dojo/_base/lang", // lang.clone lang.hitch |
|
"dojo/query", // query |
|
"dojo/regexp", // regexp.escapeString |
|
"dojo/sniff", // has("ie") |
|
"./DataList", |
|
"./_TextBoxMixin", // defines _TextBoxMixin.selectInputText |
|
"./_SearchMixin" |
|
], function(aspect, declare, domAttr, keys, lang, query, regexp, has, DataList, _TextBoxMixin, SearchMixin){ |
|
|
|
// module: |
|
// dijit/form/_AutoCompleterMixin |
|
|
|
var AutoCompleterMixin = declare("dijit.form._AutoCompleterMixin", SearchMixin, { |
|
// summary: |
|
// A mixin that implements the base functionality for `dijit/form/ComboBox`/`dijit/form/FilteringSelect` |
|
// description: |
|
// All widgets that mix in dijit/form/_AutoCompleterMixin must extend `dijit/form/_FormValueWidget`. |
|
// tags: |
|
// protected |
|
|
|
// item: Object |
|
// This is the item returned by the dojo/store/api/Store implementation that |
|
// provides the data for this ComboBox, it's the currently selected item. |
|
item: null, |
|
|
|
// autoComplete: Boolean |
|
// If user types in a partial string, and then tab out of the `<input>` box, |
|
// automatically copy the first entry displayed in the drop down list to |
|
// the `<input>` field |
|
autoComplete: true, |
|
|
|
// highlightMatch: String |
|
// One of: "first", "all" or "none". |
|
// |
|
// If the ComboBox/FilteringSelect opens with the search results and the searched |
|
// string can be found, it will be highlighted. If set to "all" |
|
// then will probably want to change `queryExpr` parameter to '*${0}*' |
|
// |
|
// Highlighting is only performed when `labelType` is "text", so as to not |
|
// interfere with any HTML markup an HTML label might contain. |
|
highlightMatch: "first", |
|
|
|
// labelAttr: String? |
|
// The entries in the drop down list come from this attribute in the |
|
// dojo.data items. |
|
// If not specified, the searchAttr attribute is used instead. |
|
labelAttr: "", |
|
|
|
// labelType: String |
|
// Specifies how to interpret the labelAttr in the data store items. |
|
// Can be "html" or "text". |
|
labelType: "text", |
|
|
|
// Flags to _HasDropDown to limit height of drop down to make it fit in viewport |
|
maxHeight: -1, |
|
|
|
// For backwards compatibility let onClick events propagate, even clicks on the down arrow button |
|
_stopClickEvents: false, |
|
|
|
_getCaretPos: function(/*DomNode*/ element){ |
|
// khtml 3.5.2 has selection* methods as does webkit nightlies from 2005-06-22 |
|
var pos = 0; |
|
if(typeof(element.selectionStart) == "number"){ |
|
// FIXME: this is totally borked on Moz < 1.3. Any recourse? |
|
pos = element.selectionStart; |
|
}else if(has("ie")){ |
|
// in the case of a mouse click in a popup being handled, |
|
// then the document.selection is not the textarea, but the popup |
|
// var r = document.selection.createRange(); |
|
// hack to get IE 6 to play nice. What a POS browser. |
|
var tr = element.ownerDocument.selection.createRange().duplicate(); |
|
var ntr = element.createTextRange(); |
|
tr.move("character", 0); |
|
ntr.move("character", 0); |
|
try{ |
|
// If control doesn't have focus, you get an exception. |
|
// Seems to happen on reverse-tab, but can also happen on tab (seems to be a race condition - only happens sometimes). |
|
// There appears to be no workaround for this - googled for quite a while. |
|
ntr.setEndPoint("EndToEnd", tr); |
|
pos = String(ntr.text).replace(/\r/g, "").length; |
|
}catch(e){ |
|
// If focus has shifted, 0 is fine for caret pos. |
|
} |
|
} |
|
return pos; |
|
}, |
|
|
|
_setCaretPos: function(/*DomNode*/ element, /*Number*/ location){ |
|
location = parseInt(location); |
|
_TextBoxMixin.selectInputText(element, location, location); |
|
}, |
|
|
|
_setDisabledAttr: function(/*Boolean*/ value){ |
|
// Additional code to set disabled state of ComboBox node. |
|
// Overrides _FormValueWidget._setDisabledAttr() or ValidationTextBox._setDisabledAttr(). |
|
this.inherited(arguments); |
|
this.domNode.setAttribute("aria-disabled", value ? "true" : "false"); |
|
}, |
|
|
|
_onKey: function(/*Event*/ evt){ |
|
// summary: |
|
// Handles keyboard events |
|
|
|
if(evt.charCode >= 32){ |
|
return; |
|
} // alphanumeric reserved for searching |
|
|
|
var key = evt.charCode || evt.keyCode; |
|
|
|
// except for cutting/pasting case - ctrl + x/v |
|
if(key == keys.ALT || key == keys.CTRL || key == keys.META || key == keys.SHIFT){ |
|
return; // throw out spurious events |
|
} |
|
|
|
var pw = this.dropDown; |
|
var highlighted = null; |
|
this._abortQuery(); |
|
|
|
// _HasDropDown will do some of the work: |
|
// |
|
// 1. when drop down is not yet shown: |
|
// - if user presses the down arrow key, call loadDropDown() |
|
// 2. when drop down is already displayed: |
|
// - on ESC key, call closeDropDown() |
|
// - otherwise, call dropDown.handleKey() to process the keystroke |
|
this.inherited(arguments); |
|
|
|
if(evt.altKey || evt.ctrlKey || evt.metaKey){ |
|
return; |
|
} // don't process keys with modifiers - but we want shift+TAB |
|
|
|
if(this._opened){ |
|
highlighted = pw.getHighlightedOption(); |
|
} |
|
switch(key){ |
|
case keys.PAGE_DOWN: |
|
case keys.DOWN_ARROW: |
|
case keys.PAGE_UP: |
|
case keys.UP_ARROW: |
|
// Keystroke caused ComboBox_menu to move to a different item. |
|
// Copy new item to <input> box. |
|
if(this._opened){ |
|
this._announceOption(highlighted); |
|
} |
|
evt.stopPropagation(); |
|
evt.preventDefault(); |
|
break; |
|
|
|
case keys.ENTER: |
|
// prevent submitting form if user presses enter. Also |
|
// prevent accepting the value if either Next or Previous |
|
// are selected |
|
if(highlighted){ |
|
// only stop event on prev/next |
|
if(highlighted == pw.nextButton){ |
|
this._nextSearch(1); |
|
// prevent submit |
|
evt.stopPropagation(); |
|
evt.preventDefault(); |
|
break; |
|
}else if(highlighted == pw.previousButton){ |
|
this._nextSearch(-1); |
|
// prevent submit |
|
evt.stopPropagation(); |
|
evt.preventDefault(); |
|
break; |
|
} |
|
// prevent submit if ENTER was to choose an item |
|
evt.stopPropagation(); |
|
evt.preventDefault(); |
|
}else{ |
|
// Update 'value' (ex: KY) according to currently displayed text |
|
this._setBlurValue(); // set value if needed |
|
this._setCaretPos(this.focusNode, this.focusNode.value.length); // move cursor to end and cancel highlighting |
|
} |
|
// fall through |
|
|
|
case keys.TAB: |
|
var newvalue = this.get('displayedValue'); |
|
// if the user had More Choices selected fall into the |
|
// _onBlur handler |
|
if(pw && (newvalue == pw._messages["previousMessage"] || newvalue == pw._messages["nextMessage"])){ |
|
break; |
|
} |
|
if(highlighted){ |
|
this._selectOption(highlighted); |
|
} |
|
// fall through |
|
|
|
case keys.ESCAPE: |
|
if(this._opened){ |
|
this._lastQuery = null; // in case results come back later |
|
this.closeDropDown(); |
|
} |
|
break; |
|
} |
|
}, |
|
|
|
_autoCompleteText: function(/*String*/ text){ |
|
// summary: |
|
// Fill in the textbox with the first item from the drop down |
|
// list, and highlight the characters that were |
|
// auto-completed. For example, if user typed "CA" and the |
|
// drop down list appeared, the textbox would be changed to |
|
// "California" and "ifornia" would be highlighted. |
|
|
|
var fn = this.focusNode; |
|
|
|
// IE7: clear selection so next highlight works all the time |
|
_TextBoxMixin.selectInputText(fn, fn.value.length); |
|
// does text autoComplete the value in the textbox? |
|
var caseFilter = this.ignoreCase ? 'toLowerCase' : 'substr'; |
|
if(text[caseFilter](0).indexOf(this.focusNode.value[caseFilter](0)) == 0){ |
|
var cpos = this.autoComplete ? this._getCaretPos(fn) : fn.value.length; |
|
// only try to extend if we added the last character at the end of the input |
|
if((cpos + 1) > fn.value.length){ |
|
// only add to input node as we would overwrite Capitalisation of chars |
|
// actually, that is ok |
|
fn.value = text;//.substr(cpos); |
|
// visually highlight the autocompleted characters |
|
_TextBoxMixin.selectInputText(fn, cpos); |
|
} |
|
}else{ |
|
// text does not autoComplete; replace the whole value and highlight |
|
fn.value = text; |
|
_TextBoxMixin.selectInputText(fn); |
|
} |
|
}, |
|
|
|
_openResultList: function(/*Object*/ results, /*Object*/ query, /*Object*/ options){ |
|
// summary: |
|
// Callback when a search completes. |
|
// description: |
|
// 1. generates drop-down list and calls _showResultList() to display it |
|
// 2. if this result list is from user pressing "more choices"/"previous choices" |
|
// then tell screen reader to announce new option |
|
var wasSelected = this.dropDown.getHighlightedOption(); |
|
this.dropDown.clearResultList(); |
|
if(!results.length && options.start == 0){ // if no results and not just the previous choices button |
|
this.closeDropDown(); |
|
return; |
|
} |
|
this._nextSearch = this.dropDown.onPage = lang.hitch(this, function(direction){ |
|
results.nextPage(direction !== -1); |
|
this.focus(); |
|
}); |
|
|
|
// Fill in the textbox with the first item from the drop down list, |
|
// and highlight the characters that were auto-completed. For |
|
// example, if user typed "CA" and the drop down list appeared, the |
|
// textbox would be changed to "California" and "ifornia" would be |
|
// highlighted. |
|
|
|
this.dropDown.createOptions( |
|
results, |
|
options, |
|
lang.hitch(this, "_getMenuLabelFromItem") |
|
); |
|
|
|
// show our list (only if we have content, else nothing) |
|
this._showResultList(); |
|
|
|
// #4091: |
|
// tell the screen reader that the paging callback finished by |
|
// shouting the next choice |
|
if("direction" in options){ |
|
if(options.direction){ |
|
this.dropDown.highlightFirstOption(); |
|
}else if(!options.direction){ |
|
this.dropDown.highlightLastOption(); |
|
} |
|
if(wasSelected){ |
|
this._announceOption(this.dropDown.getHighlightedOption()); |
|
} |
|
}else if(this.autoComplete && !this._prev_key_backspace |
|
// when the user clicks the arrow button to show the full list, |
|
// startSearch looks for "*". |
|
// it does not make sense to autocomplete |
|
// if they are just previewing the options available. |
|
&& !/^[*]+$/.test(query[this.searchAttr].toString())){ |
|
this._announceOption(this.dropDown.containerNode.firstChild.nextSibling); // 1st real item |
|
} |
|
}, |
|
|
|
_showResultList: function(){ |
|
// summary: |
|
// Display the drop down if not already displayed, or if it is displayed, then |
|
// reposition it if necessary (reposition may be necessary if drop down's height changed). |
|
this.closeDropDown(true); |
|
this.openDropDown(); |
|
this.domNode.setAttribute("aria-expanded", "true"); |
|
}, |
|
|
|
loadDropDown: function(/*Function*/ /*===== callback =====*/){ |
|
// Overrides _HasDropDown.loadDropDown(). |
|
// This is called when user has pressed button icon or pressed the down arrow key |
|
// to open the drop down. |
|
this._startSearchAll(); |
|
}, |
|
|
|
isLoaded: function(){ |
|
// signal to _HasDropDown that it needs to call loadDropDown() to load the |
|
// drop down asynchronously before displaying it |
|
return false; |
|
}, |
|
|
|
closeDropDown: function(){ |
|
// Overrides _HasDropDown.closeDropDown(). Closes the drop down (assuming that it's open). |
|
// This method is the callback when the user types ESC or clicking |
|
// the button icon while the drop down is open. It's also called by other code. |
|
this._abortQuery(); |
|
if(this._opened){ |
|
this.inherited(arguments); |
|
this.domNode.setAttribute("aria-expanded", "false"); |
|
} |
|
}, |
|
|
|
_setBlurValue: function(){ |
|
// if the user clicks away from the textbox OR tabs away, set the |
|
// value to the textbox value |
|
// #4617: |
|
// if value is now more choices or previous choices, revert |
|
// the value |
|
var newvalue = this.get('displayedValue'); |
|
var pw = this.dropDown; |
|
if(pw && (newvalue == pw._messages["previousMessage"] || newvalue == pw._messages["nextMessage"])){ |
|
this._setValueAttr(this._lastValueReported, true); |
|
}else if(typeof this.item == "undefined"){ |
|
// Update 'value' (ex: KY) according to currently displayed text |
|
this.item = null; |
|
this.set('displayedValue', newvalue); |
|
}else{ |
|
if(this.value != this._lastValueReported){ |
|
this._handleOnChange(this.value, true); |
|
} |
|
this._refreshState(); |
|
} |
|
// Remove aria-activedescendant since it may not be removed if they select with arrows then blur with mouse |
|
this.focusNode.removeAttribute("aria-activedescendant"); |
|
}, |
|
|
|
_setItemAttr: function(/*item*/ item, /*Boolean?*/ priorityChange, /*String?*/ displayedValue){ |
|
// summary: |
|
// Set the displayed valued in the input box, and the hidden value |
|
// that gets submitted, based on a dojo.data store item. |
|
// description: |
|
// Users shouldn't call this function; they should be calling |
|
// set('item', value) |
|
// tags: |
|
// private |
|
var value = ''; |
|
if(item){ |
|
if(!displayedValue){ |
|
displayedValue = this.store._oldAPI ? // remove getValue() for 2.0 (old dojo.data API) |
|
this.store.getValue(item, this.searchAttr) : item[this.searchAttr]; |
|
} |
|
value = this._getValueField() != this.searchAttr ? this.store.getIdentity(item) : displayedValue; |
|
} |
|
this.set('value', value, priorityChange, displayedValue, item); |
|
}, |
|
|
|
_announceOption: function(/*Node*/ node){ |
|
// summary: |
|
// a11y code that puts the highlighted option in the textbox. |
|
// This way screen readers will know what is happening in the |
|
// menu. |
|
|
|
if(!node){ |
|
return; |
|
} |
|
// pull the text value from the item attached to the DOM node |
|
var newValue; |
|
if(node == this.dropDown.nextButton || |
|
node == this.dropDown.previousButton){ |
|
newValue = node.innerHTML; |
|
this.item = undefined; |
|
this.value = ''; |
|
}else{ |
|
var item = this.dropDown.items[node.getAttribute("item")]; |
|
newValue = (this.store._oldAPI ? // remove getValue() for 2.0 (old dojo.data API) |
|
this.store.getValue(item, this.searchAttr) : item[this.searchAttr]).toString(); |
|
this.set('item', item, false, newValue); |
|
} |
|
// get the text that the user manually entered (cut off autocompleted text) |
|
this.focusNode.value = this.focusNode.value.substring(0, this._lastInput.length); |
|
// set up ARIA activedescendant |
|
this.focusNode.setAttribute("aria-activedescendant", domAttr.get(node, "id")); |
|
// autocomplete the rest of the option to announce change |
|
this._autoCompleteText(newValue); |
|
}, |
|
|
|
_selectOption: function(/*DomNode*/ target){ |
|
// summary: |
|
// Menu callback function, called when an item in the menu is selected. |
|
this.closeDropDown(); |
|
if(target){ |
|
this._announceOption(target); |
|
} |
|
this._setCaretPos(this.focusNode, this.focusNode.value.length); |
|
this._handleOnChange(this.value, true); |
|
// Remove aria-activedescendant since the drop down is no loner visible |
|
// after closeDropDown() but _announceOption() adds it back in |
|
this.focusNode.removeAttribute("aria-activedescendant"); |
|
}, |
|
|
|
_startSearchAll: function(){ |
|
this._startSearch(''); |
|
}, |
|
|
|
_startSearchFromInput: function(){ |
|
this.item = undefined; // undefined means item needs to be set |
|
this.inherited(arguments); |
|
}, |
|
|
|
_startSearch: function(/*String*/ key){ |
|
// summary: |
|
// Starts a search for elements matching key (key=="" means to return all items), |
|
// and calls _openResultList() when the search completes, to display the results. |
|
if(!this.dropDown){ |
|
var popupId = this.id + "_popup", |
|
dropDownConstructor = lang.isString(this.dropDownClass) ? |
|
lang.getObject(this.dropDownClass, false) : this.dropDownClass; |
|
this.dropDown = new dropDownConstructor({ |
|
onChange: lang.hitch(this, this._selectOption), |
|
id: popupId, |
|
dir: this.dir, |
|
textDir: this.textDir |
|
}); |
|
} |
|
this._lastInput = key; // Store exactly what was entered by the user. |
|
this.inherited(arguments); |
|
}, |
|
|
|
_getValueField: function(){ |
|
// summary: |
|
// Helper for postMixInProperties() to set this.value based on data inlined into the markup. |
|
// Returns the attribute name in the item (in dijit/form/_ComboBoxDataStore) to use as the value. |
|
return this.searchAttr; |
|
}, |
|
|
|
//////////// INITIALIZATION METHODS /////////////////////////////////////// |
|
|
|
postMixInProperties: function(){ |
|
this.inherited(arguments); |
|
if(!this.store && this.srcNodeRef){ |
|
var srcNodeRef = this.srcNodeRef; |
|
// if user didn't specify store, then assume there are option tags |
|
this.store = new DataList({}, srcNodeRef); |
|
|
|
// if there is no value set and there is an option list, set |
|
// the value to the first value to be consistent with native Select |
|
// Firefox and Safari set value |
|
// IE6 and Opera set selectedIndex, which is automatically set |
|
// by the selected attribute of an option tag |
|
// IE6 does not set value, Opera sets value = selectedIndex |
|
if(!("value" in this.params)){ |
|
var item = (this.item = this.store.fetchSelectedItem()); |
|
if(item){ |
|
var valueField = this._getValueField(); |
|
// remove getValue() for 2.0 (old dojo.data API) |
|
this.value = this.store._oldAPI ? this.store.getValue(item, valueField) : item[valueField]; |
|
} |
|
} |
|
} |
|
}, |
|
|
|
postCreate: function(){ |
|
// summary: |
|
// Subclasses must call this method from their postCreate() methods |
|
// tags: |
|
// protected |
|
|
|
// find any associated label element and add to ComboBox node. |
|
var label = query('label[for="' + this.id + '"]'); |
|
if(label.length){ |
|
if(!label[0].id){ |
|
label[0].id = this.id + "_label"; |
|
} |
|
this.domNode.setAttribute("aria-labelledby", label[0].id); |
|
|
|
} |
|
this.inherited(arguments); |
|
aspect.after(this, "onSearch", lang.hitch(this, "_openResultList"), true); |
|
}, |
|
|
|
_getMenuLabelFromItem: function(/*Item*/ item){ |
|
var label = this.labelFunc(item, this.store), |
|
labelType = this.labelType; |
|
// If labelType is not "text" we don't want to screw any markup ot whatever. |
|
if(this.highlightMatch != "none" && this.labelType == "text" && this._lastInput){ |
|
label = this.doHighlight(label, this._lastInput); |
|
labelType = "html"; |
|
} |
|
return {html: labelType == "html", label: label}; |
|
}, |
|
|
|
doHighlight: function(/*String*/ label, /*String*/ find){ |
|
// summary: |
|
// Highlights the string entered by the user in the menu. By default this |
|
// highlights the first occurrence found. Override this method |
|
// to implement your custom highlighting. |
|
// tags: |
|
// protected |
|
|
|
var |
|
// Add (g)lobal modifier when this.highlightMatch == "all" and (i)gnorecase when this.ignoreCase == true |
|
modifiers = (this.ignoreCase ? "i" : "") + (this.highlightMatch == "all" ? "g" : ""), |
|
i = this.queryExpr.indexOf("${0}"); |
|
find = regexp.escapeString(find); // escape regexp special chars |
|
//If < appears in label, and user presses t, we don't want to highlight the t in the escaped "<" |
|
//first find out every occurrences of "find", wrap each occurrence in a pair of "\uFFFF" characters (which |
|
//should not appear in any string). then html escape the whole string, and replace '\uFFFF" with the |
|
//HTML highlight markup. |
|
return this._escapeHtml(label.replace( |
|
new RegExp((i == 0 ? "^" : "") + "(" + find + ")" + (i == (this.queryExpr.length - 4) ? "$" : ""), modifiers), |
|
'\uFFFF$1\uFFFF')).replace( |
|
/\uFFFF([^\uFFFF]+)\uFFFF/g, '<span class="dijitComboBoxHighlightMatch">$1</span>' |
|
); // returns String, (almost) valid HTML (entities encoded) |
|
}, |
|
|
|
_escapeHtml: function(/*String*/ str){ |
|
// TODO Should become dojo.html.entities(), when exists use instead |
|
// summary: |
|
// Adds escape sequences for special characters in XML: `&<>"'` |
|
str = String(str).replace(/&/gm, "&").replace(/</gm, "<") |
|
.replace(/>/gm, ">").replace(/"/gm, """); //balance" |
|
return str; // string |
|
}, |
|
|
|
reset: function(){ |
|
// Overrides the _FormWidget.reset(). |
|
// Additionally reset the .item (to clean up). |
|
this.item = null; |
|
this.inherited(arguments); |
|
}, |
|
|
|
labelFunc: function(item, store){ |
|
// summary: |
|
// Computes the label to display based on the dojo.data store item. |
|
// item: Object |
|
// The item from the store |
|
// store: dojo/store/api/Store |
|
// The store. |
|
// returns: |
|
// The label that the ComboBox should display |
|
// tags: |
|
// private |
|
|
|
// Use toString() because XMLStore returns an XMLItem whereas this |
|
// method is expected to return a String (#9354). |
|
// Remove getValue() for 2.0 (old dojo.data API) |
|
return (store._oldAPI ? store.getValue(item, this.labelAttr || this.searchAttr) : |
|
item[this.labelAttr || this.searchAttr]).toString(); // String |
|
}, |
|
|
|
_setValueAttr: function(/*String*/ value, /*Boolean?*/ priorityChange, /*String?*/ displayedValue, /*item?*/ item){ |
|
// summary: |
|
// Hook so set('value', value) works. |
|
// description: |
|
// Sets the value of the select. |
|
this._set("item", item || null); // value not looked up in store |
|
if(value == null /* or undefined */){ |
|
value = ''; |
|
} // null translates to blank |
|
this.inherited(arguments); |
|
} |
|
}); |
|
|
|
if(has("dojo-bidi")){ |
|
AutoCompleterMixin.extend({ |
|
_setTextDirAttr: function(/*String*/ textDir){ |
|
// summary: |
|
// Setter for textDir, needed for the dropDown's textDir update. |
|
// description: |
|
// Users shouldn't call this function; they should be calling |
|
// set('textDir', value) |
|
// tags: |
|
// private |
|
this.inherited(arguments); |
|
// update the drop down also (_ComboBoxMenuMixin) |
|
if(this.dropDown){ |
|
this.dropDown._set("textDir", textDir); |
|
} |
|
} |
|
}); |
|
} |
|
|
|
return AutoCompleterMixin; |
|
});
|
|
|