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 `` box, // automatically copy the first entry displayed in the drop down list to // the `` 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 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, '$1' ); // 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, """); //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); } } }); });