UNPKG

dijit

Version:

Dijit provides a complete collection of user interface controls based on Dojo, giving you the power to create web applications that are highly optimized for usability, performance, internationalization, accessibility, but above all deliver an incredible u

595 lines (534 loc) 21.6 kB
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 "&lt;" //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, "&amp;").replace(/</gm, "&lt;") .replace(/>/gm, "&gt;").replace(/"/gm, "&quot;"); //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; });