source: Dev/trunk/src/client/dijit/form/_AutoCompleterMixin.js

Last change on this file was 483, checked in by hendrikvanantwerpen, 11 years ago

Added Dojo 1.9.3 release.

File size: 21.0 KB
Line 
1define([
2        "dojo/aspect",
3        "dojo/_base/declare", // declare
4        "dojo/dom-attr", // domAttr.get
5        "dojo/keys",
6        "dojo/_base/lang", // lang.clone lang.hitch
7        "dojo/query", // query
8        "dojo/regexp", // regexp.escapeString
9        "dojo/sniff", // has("ie")
10        "./DataList",
11        "./_TextBoxMixin", // defines _TextBoxMixin.selectInputText
12        "./_SearchMixin"
13], function(aspect, declare, domAttr, keys, lang, query, regexp, has, DataList, _TextBoxMixin, SearchMixin){
14
15        // module:
16        //              dijit/form/_AutoCompleterMixin
17
18        var AutoCompleterMixin = declare("dijit.form._AutoCompleterMixin", SearchMixin, {
19                // summary:
20                //              A mixin that implements the base functionality for `dijit/form/ComboBox`/`dijit/form/FilteringSelect`
21                // description:
22                //              All widgets that mix in dijit/form/_AutoCompleterMixin must extend `dijit/form/_FormValueWidget`.
23                // tags:
24                //              protected
25
26                // item: Object
27                //              This is the item returned by the dojo/store/api/Store implementation that
28                //              provides the data for this ComboBox, it's the currently selected item.
29                item: null,
30
31                // autoComplete: Boolean
32                //              If user types in a partial string, and then tab out of the `<input>` box,
33                //              automatically copy the first entry displayed in the drop down list to
34                //              the `<input>` field
35                autoComplete: true,
36
37                // highlightMatch: String
38                //              One of: "first", "all" or "none".
39                //
40                //              If the ComboBox/FilteringSelect opens with the search results and the searched
41                //              string can be found, it will be highlighted.  If set to "all"
42                //              then will probably want to change `queryExpr` parameter to '*${0}*'
43                //
44                //              Highlighting is only performed when `labelType` is "text", so as to not
45                //              interfere with any HTML markup an HTML label might contain.
46                highlightMatch: "first",
47
48                // labelAttr: String?
49                //              The entries in the drop down list come from this attribute in the
50                //              dojo.data items.
51                //              If not specified, the searchAttr attribute is used instead.
52                labelAttr: "",
53
54                // labelType: String
55                //              Specifies how to interpret the labelAttr in the data store items.
56                //              Can be "html" or "text".
57                labelType: "text",
58
59                // Flags to _HasDropDown to limit height of drop down to make it fit in viewport
60                maxHeight: -1,
61
62                // For backwards compatibility let onClick events propagate, even clicks on the down arrow button
63                _stopClickEvents: false,
64
65                _getCaretPos: function(/*DomNode*/ element){
66                        // khtml 3.5.2 has selection* methods as does webkit nightlies from 2005-06-22
67                        var pos = 0;
68                        if(typeof(element.selectionStart) == "number"){
69                                // FIXME: this is totally borked on Moz < 1.3. Any recourse?
70                                pos = element.selectionStart;
71                        }else if(has("ie")){
72                                // in the case of a mouse click in a popup being handled,
73                                // then the document.selection is not the textarea, but the popup
74                                // var r = document.selection.createRange();
75                                // hack to get IE 6 to play nice. What a POS browser.
76                                var tr = element.ownerDocument.selection.createRange().duplicate();
77                                var ntr = element.createTextRange();
78                                tr.move("character", 0);
79                                ntr.move("character", 0);
80                                try{
81                                        // If control doesn't have focus, you get an exception.
82                                        // Seems to happen on reverse-tab, but can also happen on tab (seems to be a race condition - only happens sometimes).
83                                        // There appears to be no workaround for this - googled for quite a while.
84                                        ntr.setEndPoint("EndToEnd", tr);
85                                        pos = String(ntr.text).replace(/\r/g, "").length;
86                                }catch(e){
87                                        // If focus has shifted, 0 is fine for caret pos.
88                                }
89                        }
90                        return pos;
91                },
92
93                _setCaretPos: function(/*DomNode*/ element, /*Number*/ location){
94                        location = parseInt(location);
95                        _TextBoxMixin.selectInputText(element, location, location);
96                },
97
98                _setDisabledAttr: function(/*Boolean*/ value){
99                        // Additional code to set disabled state of ComboBox node.
100                        // Overrides _FormValueWidget._setDisabledAttr() or ValidationTextBox._setDisabledAttr().
101                        this.inherited(arguments);
102                        this.domNode.setAttribute("aria-disabled", value ? "true" : "false");
103                },
104
105                _onKey: function(/*Event*/ evt){
106                        // summary:
107                        //              Handles keyboard events
108
109                        if(evt.charCode >= 32){
110                                return;
111                        } // alphanumeric reserved for searching
112
113                        var key = evt.charCode || evt.keyCode;
114
115                        // except for cutting/pasting case - ctrl + x/v
116                        if(key == keys.ALT || key == keys.CTRL || key == keys.META || key == keys.SHIFT){
117                                return; // throw out spurious events
118                        }
119
120                        var pw = this.dropDown;
121                        var highlighted = null;
122                        this._abortQuery();
123
124                        // _HasDropDown will do some of the work:
125                        //
126                        //      1. when drop down is not yet shown:
127                        //              - if user presses the down arrow key, call loadDropDown()
128                        //      2. when drop down is already displayed:
129                        //              - on ESC key, call closeDropDown()
130                        //              - otherwise, call dropDown.handleKey() to process the keystroke
131                        this.inherited(arguments);
132
133                        if(evt.altKey || evt.ctrlKey || evt.metaKey){
134                                return;
135                        } // don't process keys with modifiers  - but we want shift+TAB
136
137                        if(this._opened){
138                                highlighted = pw.getHighlightedOption();
139                        }
140                        switch(key){
141                                case keys.PAGE_DOWN:
142                                case keys.DOWN_ARROW:
143                                case keys.PAGE_UP:
144                                case keys.UP_ARROW:
145                                        // Keystroke caused ComboBox_menu to move to a different item.
146                                        // Copy new item to <input> box.
147                                        if(this._opened){
148                                                this._announceOption(highlighted);
149                                        }
150                                        evt.stopPropagation();
151                                        evt.preventDefault();
152                                        break;
153
154                                case keys.ENTER:
155                                        // prevent submitting form if user presses enter. Also
156                                        // prevent accepting the value if either Next or Previous
157                                        // are selected
158                                        if(highlighted){
159                                                // only stop event on prev/next
160                                                if(highlighted == pw.nextButton){
161                                                        this._nextSearch(1);
162                                                        // prevent submit
163                                                        evt.stopPropagation();
164                                                        evt.preventDefault();
165                                                        break;
166                                                }else if(highlighted == pw.previousButton){
167                                                        this._nextSearch(-1);
168                                                        // prevent submit
169                                                        evt.stopPropagation();
170                                                        evt.preventDefault();
171                                                        break;
172                                                }
173                                                // prevent submit if ENTER was to choose an item
174                                                evt.stopPropagation();
175                                                evt.preventDefault();
176                                        }else{
177                                                // Update 'value' (ex: KY) according to currently displayed text
178                                                this._setBlurValue(); // set value if needed
179                                                this._setCaretPos(this.focusNode, this.focusNode.value.length); // move cursor to end and cancel highlighting
180                                        }
181                                // fall through
182
183                                case keys.TAB:
184                                        var newvalue = this.get('displayedValue');
185                                        //      if the user had More Choices selected fall into the
186                                        //      _onBlur handler
187                                        if(pw && (newvalue == pw._messages["previousMessage"] || newvalue == pw._messages["nextMessage"])){
188                                                break;
189                                        }
190                                        if(highlighted){
191                                                this._selectOption(highlighted);
192                                        }
193                                // fall through
194
195                                case keys.ESCAPE:
196                                        if(this._opened){
197                                                this._lastQuery = null; // in case results come back later
198                                                this.closeDropDown();
199                                        }
200                                        break;
201                        }
202                },
203
204                _autoCompleteText: function(/*String*/ text){
205                        // summary:
206                        //              Fill in the textbox with the first item from the drop down
207                        //              list, and highlight the characters that were
208                        //              auto-completed. For example, if user typed "CA" and the
209                        //              drop down list appeared, the textbox would be changed to
210                        //              "California" and "ifornia" would be highlighted.
211
212                        var fn = this.focusNode;
213
214                        // IE7: clear selection so next highlight works all the time
215                        _TextBoxMixin.selectInputText(fn, fn.value.length);
216                        // does text autoComplete the value in the textbox?
217                        var caseFilter = this.ignoreCase ? 'toLowerCase' : 'substr';
218                        if(text[caseFilter](0).indexOf(this.focusNode.value[caseFilter](0)) == 0){
219                                var cpos = this.autoComplete ? this._getCaretPos(fn) : fn.value.length;
220                                // only try to extend if we added the last character at the end of the input
221                                if((cpos + 1) > fn.value.length){
222                                        // only add to input node as we would overwrite Capitalisation of chars
223                                        // actually, that is ok
224                                        fn.value = text;//.substr(cpos);
225                                        // visually highlight the autocompleted characters
226                                        _TextBoxMixin.selectInputText(fn, cpos);
227                                }
228                        }else{
229                                // text does not autoComplete; replace the whole value and highlight
230                                fn.value = text;
231                                _TextBoxMixin.selectInputText(fn);
232                        }
233                },
234
235                _openResultList: function(/*Object*/ results, /*Object*/ query, /*Object*/ options){
236                        // summary:
237                        //              Callback when a search completes.
238                        // description:
239                        //              1. generates drop-down list and calls _showResultList() to display it
240                        //              2. if this result list is from user pressing "more choices"/"previous choices"
241                        //                      then tell screen reader to announce new option
242                        var wasSelected = this.dropDown.getHighlightedOption();
243                        this.dropDown.clearResultList();
244                        if(!results.length && options.start == 0){ // if no results and not just the previous choices button
245                                this.closeDropDown();
246                                return;
247                        }
248                        this._nextSearch = this.dropDown.onPage = lang.hitch(this, function(direction){
249                                results.nextPage(direction !== -1);
250                                this.focus();
251                        });
252
253                        // Fill in the textbox with the first item from the drop down list,
254                        // and highlight the characters that were auto-completed. For
255                        // example, if user typed "CA" and the drop down list appeared, the
256                        // textbox would be changed to "California" and "ifornia" would be
257                        // highlighted.
258
259                        this.dropDown.createOptions(
260                                results,
261                                options,
262                                lang.hitch(this, "_getMenuLabelFromItem")
263                        );
264
265                        // show our list (only if we have content, else nothing)
266                        this._showResultList();
267
268                        // #4091:
269                        //              tell the screen reader that the paging callback finished by
270                        //              shouting the next choice
271                        if("direction" in options){
272                                if(options.direction){
273                                        this.dropDown.highlightFirstOption();
274                                }else if(!options.direction){
275                                        this.dropDown.highlightLastOption();
276                                }
277                                if(wasSelected){
278                                        this._announceOption(this.dropDown.getHighlightedOption());
279                                }
280                        }else if(this.autoComplete && !this._prev_key_backspace
281                                // when the user clicks the arrow button to show the full list,
282                                // startSearch looks for "*".
283                                // it does not make sense to autocomplete
284                                // if they are just previewing the options available.
285                                && !/^[*]+$/.test(query[this.searchAttr].toString())){
286                                this._announceOption(this.dropDown.containerNode.firstChild.nextSibling); // 1st real item
287                        }
288                },
289
290                _showResultList: function(){
291                        // summary:
292                        //              Display the drop down if not already displayed, or if it is displayed, then
293                        //              reposition it if necessary (reposition may be necessary if drop down's height changed).
294                        this.closeDropDown(true);
295                        this.openDropDown();
296                        this.domNode.setAttribute("aria-expanded", "true");
297                },
298
299                loadDropDown: function(/*Function*/ /*===== callback =====*/){
300                        // Overrides _HasDropDown.loadDropDown().
301                        // This is called when user has pressed button icon or pressed the down arrow key
302                        // to open the drop down.
303                        this._startSearchAll();
304                },
305
306                isLoaded: function(){
307                        // signal to _HasDropDown that it needs to call loadDropDown() to load the
308                        // drop down asynchronously before displaying it
309                        return false;
310                },
311
312                closeDropDown: function(){
313                        // Overrides _HasDropDown.closeDropDown().  Closes the drop down (assuming that it's open).
314                        // This method is the callback when the user types ESC or clicking
315                        // the button icon while the drop down is open.  It's also called by other code.
316                        this._abortQuery();
317                        if(this._opened){
318                                this.inherited(arguments);
319                                this.domNode.setAttribute("aria-expanded", "false");
320                        }
321                },
322
323                _setBlurValue: function(){
324                        // if the user clicks away from the textbox OR tabs away, set the
325                        // value to the textbox value
326                        // #4617:
327                        //              if value is now more choices or previous choices, revert
328                        //              the value
329                        var newvalue = this.get('displayedValue');
330                        var pw = this.dropDown;
331                        if(pw && (newvalue == pw._messages["previousMessage"] || newvalue == pw._messages["nextMessage"])){
332                                this._setValueAttr(this._lastValueReported, true);
333                        }else if(typeof this.item == "undefined"){
334                                // Update 'value' (ex: KY) according to currently displayed text
335                                this.item = null;
336                                this.set('displayedValue', newvalue);
337                        }else{
338                                if(this.value != this._lastValueReported){
339                                        this._handleOnChange(this.value, true);
340                                }
341                                this._refreshState();
342                        }
343                        // Remove aria-activedescendant since it may not be removed if they select with arrows then blur with mouse
344                        this.focusNode.removeAttribute("aria-activedescendant");
345                },
346
347                _setItemAttr: function(/*item*/ item, /*Boolean?*/ priorityChange, /*String?*/ displayedValue){
348                        // summary:
349                        //              Set the displayed valued in the input box, and the hidden value
350                        //              that gets submitted, based on a dojo.data store item.
351                        // description:
352                        //              Users shouldn't call this function; they should be calling
353                        //              set('item', value)
354                        // tags:
355                        //              private
356                        var value = '';
357                        if(item){
358                                if(!displayedValue){
359                                        displayedValue = this.store._oldAPI ? // remove getValue() for 2.0 (old dojo.data API)
360                                                this.store.getValue(item, this.searchAttr) : item[this.searchAttr];
361                                }
362                                value = this._getValueField() != this.searchAttr ? this.store.getIdentity(item) : displayedValue;
363                        }
364                        this.set('value', value, priorityChange, displayedValue, item);
365                },
366
367                _announceOption: function(/*Node*/ node){
368                        // summary:
369                        //              a11y code that puts the highlighted option in the textbox.
370                        //              This way screen readers will know what is happening in the
371                        //              menu.
372
373                        if(!node){
374                                return;
375                        }
376                        // pull the text value from the item attached to the DOM node
377                        var newValue;
378                        if(node == this.dropDown.nextButton ||
379                                node == this.dropDown.previousButton){
380                                newValue = node.innerHTML;
381                                this.item = undefined;
382                                this.value = '';
383                        }else{
384                                var item = this.dropDown.items[node.getAttribute("item")];
385                                newValue = (this.store._oldAPI ? // remove getValue() for 2.0 (old dojo.data API)
386                                        this.store.getValue(item, this.searchAttr) : item[this.searchAttr]).toString();
387                                this.set('item', item, false, newValue);
388                        }
389                        // get the text that the user manually entered (cut off autocompleted text)
390                        this.focusNode.value = this.focusNode.value.substring(0, this._lastInput.length);
391                        // set up ARIA activedescendant
392                        this.focusNode.setAttribute("aria-activedescendant", domAttr.get(node, "id"));
393                        // autocomplete the rest of the option to announce change
394                        this._autoCompleteText(newValue);
395                },
396
397                _selectOption: function(/*DomNode*/ target){
398                        // summary:
399                        //              Menu callback function, called when an item in the menu is selected.
400                        this.closeDropDown();
401                        if(target){
402                                this._announceOption(target);
403                        }
404                        this._setCaretPos(this.focusNode, this.focusNode.value.length);
405                        this._handleOnChange(this.value, true);
406                        // Remove aria-activedescendant since the drop down is no loner visible
407                        // after closeDropDown() but _announceOption() adds it back in
408                        this.focusNode.removeAttribute("aria-activedescendant");
409                },
410
411                _startSearchAll: function(){
412                        this._startSearch('');
413                },
414
415                _startSearchFromInput: function(){
416                        this.item = undefined; // undefined means item needs to be set
417                        this.inherited(arguments);
418                },
419
420                _startSearch: function(/*String*/ key){
421                        // summary:
422                        //              Starts a search for elements matching key (key=="" means to return all items),
423                        //              and calls _openResultList() when the search completes, to display the results.
424                        if(!this.dropDown){
425                                var popupId = this.id + "_popup",
426                                        dropDownConstructor = lang.isString(this.dropDownClass) ?
427                                                lang.getObject(this.dropDownClass, false) : this.dropDownClass;
428                                this.dropDown = new dropDownConstructor({
429                                        onChange: lang.hitch(this, this._selectOption),
430                                        id: popupId,
431                                        dir: this.dir,
432                                        textDir: this.textDir
433                                });
434                        }
435                        this._lastInput = key; // Store exactly what was entered by the user.
436                        this.inherited(arguments);
437                },
438
439                _getValueField: function(){
440                        // summary:
441                        //              Helper for postMixInProperties() to set this.value based on data inlined into the markup.
442                        //              Returns the attribute name in the item (in dijit/form/_ComboBoxDataStore) to use as the value.
443                        return this.searchAttr;
444                },
445
446                //////////// INITIALIZATION METHODS ///////////////////////////////////////
447
448                postMixInProperties: function(){
449                        this.inherited(arguments);
450                        if(!this.store){
451                                var srcNodeRef = this.srcNodeRef;
452                                // if user didn't specify store, then assume there are option tags
453                                this.store = new DataList({}, srcNodeRef);
454
455                                // if there is no value set and there is an option list, set
456                                // the value to the first value to be consistent with native Select
457                                // Firefox and Safari set value
458                                // IE6 and Opera set selectedIndex, which is automatically set
459                                // by the selected attribute of an option tag
460                                // IE6 does not set value, Opera sets value = selectedIndex
461                                if(!("value" in this.params)){
462                                        var item = (this.item = this.store.fetchSelectedItem());
463                                        if(item){
464                                                var valueField = this._getValueField();
465                                                // remove getValue() for 2.0 (old dojo.data API)
466                                                this.value = this.store._oldAPI ? this.store.getValue(item, valueField) : item[valueField];
467                                        }
468                                }
469                        }
470                },
471
472                postCreate: function(){
473                        // summary:
474                        //              Subclasses must call this method from their postCreate() methods
475                        // tags:
476                        //              protected
477
478                        // find any associated label element and add to ComboBox node.
479                        var label = query('label[for="' + this.id + '"]');
480                        if(label.length){
481                                if(!label[0].id){
482                                        label[0].id = this.id + "_label";
483                                }
484                                this.domNode.setAttribute("aria-labelledby", label[0].id);
485
486                        }
487                        this.inherited(arguments);
488                        aspect.after(this, "onSearch", lang.hitch(this, "_openResultList"), true);
489                },
490
491                _getMenuLabelFromItem: function(/*Item*/ item){
492                        var label = this.labelFunc(item, this.store),
493                                labelType = this.labelType;
494                        // If labelType is not "text" we don't want to screw any markup ot whatever.
495                        if(this.highlightMatch != "none" && this.labelType == "text" && this._lastInput){
496                                label = this.doHighlight(label, this._lastInput);
497                                labelType = "html";
498                        }
499                        return {html: labelType == "html", label: label};
500                },
501
502                doHighlight: function(/*String*/ label, /*String*/ find){
503                        // summary:
504                        //              Highlights the string entered by the user in the menu.  By default this
505                        //              highlights the first occurrence found. Override this method
506                        //              to implement your custom highlighting.
507                        // tags:
508                        //              protected
509
510                        var
511                        // Add (g)lobal modifier when this.highlightMatch == "all" and (i)gnorecase when this.ignoreCase == true
512                                modifiers = (this.ignoreCase ? "i" : "") + (this.highlightMatch == "all" ? "g" : ""),
513                                i = this.queryExpr.indexOf("${0}");
514                        find = regexp.escapeString(find); // escape regexp special chars
515                        //If < appears in label, and user presses t, we don't want to highlight the t in the escaped "&lt;"
516                        //first find out every occurences of "find", wrap each occurence in a pair of "\uFFFF" characters (which
517                        //should not appear in any string). then html escape the whole string, and replace '\uFFFF" with the
518                        //HTML highlight markup.
519                        return this._escapeHtml(label.replace(
520                                new RegExp((i == 0 ? "^" : "") + "(" + find + ")" + (i == (this.queryExpr.length - 4) ? "$" : ""), modifiers),
521                                '\uFFFF$1\uFFFF')).replace(
522                                /\uFFFF([^\uFFFF]+)\uFFFF/g, '<span class="dijitComboBoxHighlightMatch">$1</span>'
523                        ); // returns String, (almost) valid HTML (entities encoded)
524                },
525
526                _escapeHtml: function(/*String*/ str){
527                        // TODO Should become dojo.html.entities(), when exists use instead
528                        // summary:
529                        //              Adds escape sequences for special characters in XML: `&<>"'`
530                        str = String(str).replace(/&/gm, "&amp;").replace(/</gm, "&lt;")
531                                .replace(/>/gm, "&gt;").replace(/"/gm, "&quot;"); //balance"
532                        return str; // string
533                },
534
535                reset: function(){
536                        // Overrides the _FormWidget.reset().
537                        // Additionally reset the .item (to clean up).
538                        this.item = null;
539                        this.inherited(arguments);
540                },
541
542                labelFunc: function(item, store){
543                        // summary:
544                        //              Computes the label to display based on the dojo.data store item.
545                        // item: Object
546                        //              The item from the store
547                        // store: dojo/store/api/Store
548                        //              The store.
549                        // returns:
550                        //              The label that the ComboBox should display
551                        // tags:
552                        //              private
553
554                        // Use toString() because XMLStore returns an XMLItem whereas this
555                        // method is expected to return a String (#9354).
556                        // Remove getValue() for 2.0 (old dojo.data API)
557                        return (store._oldAPI ? store.getValue(item, this.labelAttr || this.searchAttr) :
558                                item[this.labelAttr || this.searchAttr]).toString(); // String
559                },
560
561                _setValueAttr: function(/*String*/ value, /*Boolean?*/ priorityChange, /*String?*/ displayedValue, /*item?*/ item){
562                        // summary:
563                        //              Hook so set('value', value) works.
564                        // description:
565                        //              Sets the value of the select.
566                        this._set("item", item || null); // value not looked up in store
567                        if(value == null /* or undefined */){
568                                value = '';
569                        } // null translates to blank
570                        this.inherited(arguments);
571                }
572        });
573
574        if(has("dojo-bidi")){
575                AutoCompleterMixin.extend({
576                        _setTextDirAttr: function(/*String*/ textDir){
577                                // summary:
578                                //              Setter for textDir, needed for the dropDown's textDir update.
579                                // description:
580                                //              Users shouldn't call this function; they should be calling
581                                //              set('textDir', value)
582                                // tags:
583                                //              private
584                                this.inherited(arguments);
585                                // update the drop down also (_ComboBoxMenuMixin)
586                                if(this.dropDown){
587                                        this.dropDown._set("textDir", textDir);
588                                }
589                        }
590                });
591        }
592
593        return AutoCompleterMixin;
594});
Note: See TracBrowser for help on using the repository browser.