581 lines
21 KiB
JavaScript
581 lines
21 KiB
JavaScript
define("dijit/form/_AutoCompleterMixin", [
|
|
"dojo/data/util/filter", // patternToRegExp
|
|
"dojo/_base/declare", // declare
|
|
"dojo/dom-attr", // domAttr.get
|
|
"dojo/_base/event", // event.stop
|
|
"dojo/keys",
|
|
"dojo/_base/lang", // lang.clone lang.hitch
|
|
"dojo/query", // query
|
|
"dojo/regexp", // regexp.escapeString
|
|
"dojo/sniff", // has("ie")
|
|
"dojo/string", // string.substitute
|
|
"./DataList",
|
|
"../registry", // registry.byId
|
|
"./_TextBoxMixin", // defines _TextBoxMixin.selectInputText
|
|
"./_SearchMixin"
|
|
], function(filter, declare, domAttr, event, keys, lang, query, regexp, has, string,
|
|
DataList, registry, _TextBoxMixin, SearchMixin){
|
|
|
|
// module:
|
|
// dijit/form/_AutoCompleterMixin
|
|
|
|
return 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 win.doc.selection is not the textarea, but the popup
|
|
// var r = win.doc.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);
|
|
}
|
|
event.stop(evt);
|
|
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);
|
|
event.stop(evt); // prevent submit
|
|
break;
|
|
}else if(highlighted == pw.previousButton){
|
|
this._nextSearch(-1);
|
|
event.stop(evt); // prevent submit
|
|
break;
|
|
}
|
|
event.stop(evt); // prevent submit if ENTER was to choose an item
|
|
}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");
|
|
this.focusNode.removeAttribute("aria-activedescendant");
|
|
}
|
|
},
|
|
|
|
_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();
|
|
}
|
|
},
|
|
|
|
_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);
|
|
},
|
|
|
|
_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.focusNode.removeAttribute("aria-activedescendant");
|
|
this.textbox.setAttribute("aria-owns",popupId); // associate popup with textbox
|
|
}
|
|
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){
|
|
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);
|
|
this.connect(this, "onSearch", "_openResultList");
|
|
},
|
|
|
|
_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 occurences of "find", wrap each occurence 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);
|
|
},
|
|
_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);
|
|
}
|
|
}
|
|
});
|
|
});
|