1 /**
  2  * @fileOverview Input wrapper for ComboBox component.
  3  * @author yiminghe@gmail.com
  4  */
  5 KISSY.add("combobox/base", function (S, Node, Component, ComboBoxRender, _, Menu, undefined) {
  6     var ComboBox,
  7         $ = Node.all,
  8         KeyCodes = Node.KeyCodes,
  9         ALIGN = {
 10             points:["bl", "tl"],
 11             overflow:{
 12                 adjustX:1,
 13                 adjustY:1
 14             }
 15         },
 16         win = $(S.Env.host),
 17         SUFFIX = 'suffix';
 18 
 19     function getMenu(self, init) {
 20         var m = self.get("menu");
 21         if (m && m.xclass) {
 22             if (init) {
 23                 m = Component.create(m, self);
 24                 self.__set("menu", m);
 25             } else {
 26                 return null;
 27             }
 28         }
 29         return m;
 30     }
 31 
 32 
 33     function hideMenu(self) {
 34         var menu = getMenu(self);
 35         if (menu) {
 36             menu.hide();
 37         }
 38     }
 39 
 40     function alignMenuImmediately(self) {
 41         var menu = self.get("menu");
 42         var align = S.clone(menu.get("align"));
 43         align.node = self.get("el");
 44         S.mix(align, ALIGN, false);
 45         menu.set("align", align);
 46     }
 47 
 48     function alignWithTokenImmediately(self) {
 49         var inputDesc = getInputDesc(self),
 50             tokens = inputDesc.tokens,
 51             menu = self.get("menu"),
 52             cursorPosition = inputDesc.cursorPosition,
 53             tokenIndex = inputDesc.tokenIndex,
 54             tokenCursorPosition,
 55             cursorOffset,
 56             input = self.get("input");
 57         tokenCursorPosition = tokens.slice(0, tokenIndex).join("").length;
 58         if (tokenCursorPosition > 0) {
 59             // behind separator
 60             ++tokenCursorPosition;
 61         }
 62         input.prop("selectionStart", tokenCursorPosition);
 63         input.prop("selectionEnd", tokenCursorPosition);
 64         cursorOffset = input.prop("KsCursorOffset");
 65         input.prop("selectionStart", cursorPosition);
 66         input.prop("selectionEnd", cursorPosition);
 67         menu.set("xy", [cursorOffset.left, cursorOffset.top]);
 68     }
 69 
 70     function reposition() {
 71         var self = this,
 72             menu = getMenu(self);
 73         if (menu && menu.get("visible")) {
 74             if (self.get("multiple") && self.get("alignWithCursor")) {
 75                 alignWithTokenImmediately(self);
 76             } else {
 77                 alignMenuImmediately(self);
 78             }
 79         }
 80     }
 81 
 82     var repositionBuffer = S.buffer(reposition, 50);
 83 
 84 
 85     function delayHide() {
 86         var self = this;
 87         self._focusoutDismissTimer = setTimeout(function () {
 88             self.set("collapsed", true);
 89         }, 30);
 90     }
 91 
 92     function clearDismissTimer() {
 93         var self = this, t;
 94         if (t = self._focusoutDismissTimer) {
 95             clearTimeout(t);
 96             self._focusoutDismissTimer = null;
 97         }
 98     }
 99 
100 
101     function showMenu(self) {
102         var el = self.get("el"),
103             menu = getMenu(self, 1);
104         // 保证显示前已经 bind 好 menu 事件
105 
106         clearDismissTimer.call(self);
107         if (menu && !menu.get("visible")) {
108             // 先 render,监听 width 变化事件
109             menu.render();
110             self.bindMenu();
111             // 根据 el 自动调整大小
112             if (self.get("matchElWidth")) {
113                 menu.set("width", el.innerWidth());
114             }
115             menu.show();
116             reposition.call(self);
117             self.get("input").attr("aria-owns", menu.get("el")[0].id);
118         }
119     }
120 
121 
122     /**
123      * @name ComboBox
124      * @extends Component.Controller
125      * @class
126      * KISSY ComboBox.
127      * xclass: 'combobox'.
128      */
129     ComboBox = Component.Controller.extend(
130         /**
131          * @lends ComboBox#
132          */
133         {
134 
135             // user's input text
136             _savedInputValue:null,
137 
138             _stopNotify:0,
139 
140             bindUI:function () {
141                 var self = this,
142                     input = self.get("input");
143                 input.on("valuechange", onValueChange, self);
144 
145                 /**
146                  * @name ComboBox#afterCollapsedChange
147                  * @description fired after combobox 's collapsed attribute is changed.
148                  * @event
149                  * @param e
150                  * @param e.newVal current value
151                  * @param e.prevVal previous value
152                  */
153 
154             },
155 
156             bindMenu:function () {
157                 var self = this,
158                     el,
159                     contentEl,
160                     menu = self.get("menu");
161 
162                 menu.on("click", function (e) {
163                     var item = e.target;
164                     // stop valuechange event
165                     self._stopNotify = 1;
166                     self._selectItem(item);
167                     self.set("collapsed", true);
168                     setTimeout(
169                         function () {
170                             self._stopNotify = 0;
171                         },
172                         // valuechange interval
173                         50
174                     );
175                 });
176 
177                 win.on("resize", repositionBuffer, self);
178 
179 
180                 el = menu.get("el");
181                 contentEl = menu.get("contentEl");
182 
183                 el.on("focusout", delayHide, self);
184                 el.on("focusin", clearDismissTimer, self);
185 
186                 contentEl.on("mouseover", function () {
187                     // trigger el focus
188                     self.get("input")[0].focus();
189                     // prevent menu from hiding
190                     clearDismissTimer.call(self);
191                 });
192 
193 
194                 self.bindMenu = S.noop;
195             },
196 
197             _uiSetHasTrigger:function (v) {
198                 var self = this,
199                     trigger = self.get("trigger");
200                 if (v) {
201                     trigger.on("click", onTriggerClick, self);
202                     trigger.on("mousedown", onTriggerMouseDown);
203                 } else {
204                     trigger.detach("click", onTriggerClick, self);
205                     trigger.detach("mousedown", onTriggerMouseDown);
206                 }
207             },
208 
209             /**
210              * fetch comboBox list by value and show comboBox list
211              * @param {String} value value for fetching comboBox list
212              */
213             sendRequest:function (value) {
214                 var self = this,
215                     dataSource = self.get("dataSource");
216                 dataSource.fetchData(value, renderData, self);
217             },
218 
219             _uiSetCollapsed:function (v) {
220                 if (v) {
221                     hideMenu(this);
222                 } else {
223                     showMenu(this);
224                 }
225             },
226 
227             handleBlur:function () {
228                 var self = this;
229                 ComboBox.superclass.handleBlur.apply(self, arguments);
230                 delayHide.call(self);
231             },
232 
233             handleKeyEventInternal:function (e) {
234                 var self = this,
235                     input = self.get("input"),
236                     menu = getMenu(self);
237 
238                 if (!menu) {
239                     return;
240                 }
241 
242                 var updateInputOnDownUp = self.get("updateInputOnDownUp");
243 
244                 if (updateInputOnDownUp) {
245                     // combobox will change input value
246                     // but it does not need to reload data
247                     if (S.inArray(e.keyCode, [
248                         KeyCodes.UP,
249                         KeyCodes.DOWN,
250                         KeyCodes.ESC
251                     ])) {
252                         self._stopNotify = 1;
253                     } else {
254                         self._stopNotify = 0;
255                     }
256                 }
257 
258                 var activeItem;
259 
260                 if (menu.get("visible")) {
261                     var handledByMenu = menu.handleKeydown(e);
262 
263                     if (updateInputOnDownUp) {
264                         if (S.inArray(e.keyCode, [KeyCodes.DOWN, KeyCodes.UP])) {
265                             // update menu's active value to input just for show
266                             setValue(self, menu.get("activeItem").get("textContent"));
267                         }
268                     }
269                     // esc
270                     if (e.keyCode == KeyCodes.ESC) {
271                         self.set("collapsed", true);
272                         if (updateInputOnDownUp) {
273                             // restore original user's input text
274                             setValue(self, self._savedInputValue);
275                         }
276                         return true;
277                     }
278 
279                     // tab
280                     // if menu is open and an menuitem is highlighted, see as click/enter
281                     if (e.keyCode == KeyCodes.TAB) {
282                         if (activeItem = menu.get("activeItem")) {
283                             activeItem.performActionInternal();
284                             // only prevent focus change in multiple mode
285                             if (self.get("multiple")) {
286                                 return true;
287                             }
288                         }
289                     }
290                     return handledByMenu;
291                 } else if ((e.keyCode == KeyCodes.DOWN || e.keyCode == KeyCodes.UP)) {
292                     // re-fetch , consider multiple input
293                     S.log("refetch : " + getValue(self));
294                     self.sendRequest(getValue(self));
295                     return true;
296                 }
297             },
298 
299             _selectItem:function (item) {
300                 var self = this;
301                 if (item) {
302                     var textContent = item.get("textContent"),
303                         separatorType = self.get("separatorType");
304                     setValue(self, textContent + (separatorType == SUFFIX ? "" : " "));
305                     self._savedInputValue = textContent;
306                     /**
307                      * @name ComboBox#click
308                      * @description fired when user select from suggestion list
309                      * @event
310                      * @param e
311                      * @param e.target Selected menuItem
312                      */
313                     self.fire("click", {
314                         target:item
315                     });
316                 }
317             },
318 
319             destructor:function () {
320                 win.detach("resize", repositionBuffer, this);
321             }
322         },
323         {
324             ATTRS:/**
325              * @lends ComboBox#
326              */
327             {
328 
329                 /**
330                  * Input element of current combobox.
331                  * @type NodeList
332                  */
333                 input:{
334                     view:1
335                 },
336 
337                 trigger:{
338                     view:1
339                 },
340 
341                 /**
342                  * Whether show combobox trigger.
343                  * Default: true.
344                  * @type Boolean
345                  */
346                 hasTrigger:{
347                     view:1
348                 },
349 
350                 /**
351                  * ComboBox dropDown menuList
352                  * @type Menu.PopupMenu
353                  */
354                 menu:{
355                     value:{
356                         xclass:'popupmenu'
357                     },
358                     setter:function (m) {
359                         if (m instanceof Component.Controller) {
360                             m.__set("parent", this);
361                         }
362                     }
363                 },
364 
365                 /**
366                  * Whether combobox menu is hidden.
367                  * @type Boolean
368                  */
369                 collapsed:{
370                     view:1
371                 },
372 
373                 /**
374                  * dataSource for comboBox.
375                  * @type ComboBox.LocalDataSource|ComboBox.RemoteDataSource|Object
376                  */
377                 dataSource:{
378                     // 和 input 关联起来,input可以有很多,每个数据源可以不一样,但是 menu 共享
379                     setter:function (c) {
380                         return Component.create(c);
381                     }
382                 },
383 
384                 /**
385                  * maxItemCount max count of data to be shown
386                  * @type Number
387                  */
388                 maxItemCount:{
389                     value:99999
390                 },
391 
392                 /**
393                  * Whether drop down menu is same width with input.
394                  * Default: true.
395                  * @type {Boolean}
396                  */
397                 matchElWidth:{
398                     value:true
399                 },
400 
401                 /**
402                  * Format function to return array of
403                  * html/text/menu item attributes from array of data.
404                  * @type {Function}
405                  */
406                 format:{
407                 },
408 
409                 /**
410                  * Whether allow multiple input,separated by separator
411                  * Default : false
412                  * @type Boolean
413                  */
414                 multiple:{
415                 },
416 
417                 /**
418                  * Separator chars used to separator multiple inputs.
419                  * Default: ;,
420                  * @type String
421                  */
422                 separator:{
423                     value:",;"
424                 },
425 
426                 /**
427                  * Separator type.
428                  * After value( 'suffix' ) or before value( 'prefix' ).
429                  * @type String
430                  */
431                 separatorType:{
432                     value:SUFFIX
433                 },
434 
435                 /**
436                  * Whether whitespace is part of toke value.
437                  * Default true
438                  * @type Boolean
439                  * @private
440                  */
441                 whitespace:{
442                     valueFn:function () {
443                         return this.get("separatorType") == SUFFIX;
444                     }
445                 },
446 
447                 /**
448                  * Whether update input's value at keydown or up when combobox menu shows.
449                  * Default true
450                  * @type Boolean
451                  */
452                 updateInputOnDownUp:{
453                     value:true
454                 },
455 
456                 /**
457                  * If separator wrapped by literal chars,separator become normal chars.
458                  * Default : "
459                  * @type String
460                  */
461                 literal:{
462                     value:"\""
463                 },
464 
465                 /**
466                  * Whether align menu with individual token after separated by separator.
467                  * Default : false
468                  * @type Boolean
469                  */
470                 alignWithCursor:{
471                 },
472 
473                 /**
474                  * Whether or not the first row should be highlighted by default.
475                  * Default : false
476                  * @type Boolean
477                  */
478                 autoHighlightFirst:{
479                 },
480 
481                 xrender:{
482                     value:ComboBoxRender
483                 }
484             }
485         },
486         {
487             xclass:'combobox',
488             priority:10
489         }
490     );
491 
492 
493     // #----------------------- private start
494 
495 
496     function setValue(self, value) {
497         var input = self.get("input");
498         if (self.get("multiple")) {
499             var inputDesc = getInputDesc(self),
500                 tokens = inputDesc.tokens,
501                 tokenIndex = Math.max(0, inputDesc.tokenIndex),
502                 separator = self.get("separator"),
503                 cursorPosition,
504                 separatorType = self.get("separatorType"),
505                 token = tokens[tokenIndex];
506 
507             if (token && separator.indexOf(token.charAt(0)) != -1) {
508                 tokens[tokenIndex] = token.charAt(0);
509             } else {
510                 tokens[tokenIndex] = "";
511             }
512 
513             tokens[tokenIndex] += value;
514 
515             var nextToken = tokens[tokenIndex + 1];
516 
517             // appendSeparatorOnComplete if next token does not start with separator
518             if (separatorType == SUFFIX && (!nextToken || separator.indexOf(nextToken.charAt(0)) == -1 )) {
519                 tokens[tokenIndex] += separator.charAt(0);
520             }
521 
522             cursorPosition = tokens.slice(0, tokenIndex + 1).join("").length;
523 
524             input.val(tokens.join(""));
525 
526             input.prop("selectionStart", cursorPosition);
527             input.prop("selectionEnd", cursorPosition);
528         } else {
529             input.val(value);
530         }
531     }
532 
533 
534     /**
535      * Consider multiple mode , get token at current cursor position
536      */
537     function getValue(self) {
538         var input = self.get("input"),
539             inputVal = input.val();
540         if (self.get("multiple")) {
541             var inputDesc = getInputDesc(self);
542             var tokens = inputDesc.tokens,
543                 tokenIndex = inputDesc.tokenIndex;
544             var separator = self.get("separator");
545             var separatorType = self.get("separatorType");
546             var token = tokens[tokenIndex] || "";
547             // only if token starts with separator , then token has meaning!
548             // token can not be empty
549             if (token && separator.indexOf(token.charAt(0)) != -1) {
550                 // remove separator
551                 return token.substring(1);
552             }
553             // cursor is at the beginning of textarea
554             if (separatorType == SUFFIX && (tokenIndex == 0 || tokenIndex == -1)) {
555                 return token;
556             }
557             return undefined;
558         } else {
559             return inputVal;
560         }
561     }
562 
563 
564     function onValueChange() {
565         var self = this;
566         if (self._stopNotify) {
567             return;
568         }
569         var value = getValue(self);
570         if (value === undefined) {
571             self.set("collapsed", true);
572             return;
573         }
574         self._savedInputValue = value;
575         S.log("value change: " + value);
576         self.sendRequest(value);
577     }
578 
579     function renderData(data) {
580         var self = this,
581             v,
582             children = [],
583             val,
584             contents,
585             matchVal,
586             i,
587             menu = getMenu(self, 1);
588 
589 
590         menu.removeChildren(true);
591 
592         if (data && data.length) {
593             data = data.slice(0, self.get("maxItemCount"));
594             if (self.get("format")) {
595                 contents = self.get("format").call(self, getValue(self), data);
596             } else {
597                 contents = [];
598             }
599             for (i = 0; i < data.length; i++) {
600                 v = data[i];
601                 children.push(menu.addChild(S.mix({
602                     xclass:'menuitem',
603                     content:v,
604                     textContent:v,
605                     value:v
606                 }, contents[i])));
607             }
608             // make menu item (which textContent is same as input) active
609             val = getValue(self);
610             for (i = 0; i < children.length; i++) {
611                 if (children[i].get("textContent") == val) {
612                     menu.set("highlightedItem", children[i]);
613                     matchVal = true;
614                     break;
615                 }
616             }
617             // Whether or not the first row should be highlighted by default.
618             if (!matchVal && self.get("autoHighlightFirst")) {
619                 for (i = 0; i < children.length; i++) {
620                     if (!children[i].get("disabled")) {
621                         menu.set("highlightedItem", children[i]);
622                         break;
623                     }
624                 }
625             }
626             self.set("collapsed", false);
627         } else {
628             self.set("collapsed", true);
629         }
630     }
631 
632     function onTriggerClick() {
633         var self = this,
634             input = self.get("input");
635         if (!self.get('collapsed')) {
636             self.set('collapsed', true);
637         } else {
638             input[0].focus();
639             self.sendRequest('');
640         }
641     }
642 
643     function onTriggerMouseDown(e) {
644         e.preventDefault();
645     }
646 
647     function getInputDesc(self) {
648         var input = self.get("input"),
649             inputVal = input.val(),
650             tokens = [],
651             cache = [],
652             literal = self.get("literal"),
653             separator = self.get("separator"),
654             inLiteral = false,
655             whitespace = self.get("whitespace"),
656             cursorPosition = input.prop('selectionStart'),
657             tokenIndex = -1;
658 
659         for (var i = 0; i < inputVal.length; i++) {
660             var c = inputVal.charAt(i);
661             if (i == cursorPosition) {
662                 // current token index
663                 tokenIndex = tokens.length;
664             }
665             if (!inLiteral) {
666                 // whitespace is not part of token value
667                 // then separate
668                 if (!whitespace && /\s|\xa0/.test(c)) {
669                     tokens.push(cache.join(""));
670                     cache = [];
671                 }
672 
673                 if (separator.indexOf(c) != -1) {
674                     tokens.push(cache.join(""));
675                     cache = [];
676                 }
677             }
678             if (literal) {
679                 if (c == literal) {
680                     inLiteral = !inLiteral;
681                 }
682             }
683             cache.push(c);
684         }
685 
686         if (cache.length) {
687             tokens.push(cache.join(""));
688         }
689         if (tokenIndex == -1) {
690             tokenIndex = tokens.length - 1;
691         }
692         return {
693             tokens:tokens,
694             cursorPosition:cursorPosition,
695             tokenIndex:tokenIndex
696         };
697     }
698 
699     // #------------------------private end
700 
701     return ComboBox;
702 }, {
703     requires:[
704         'node',
705         'component',
706         './baseRender',
707         'input-selection',
708         'menu'
709     ]
710 });
711 
712 /**
713  *
714  * !TODO
715  *  - menubutton combobox 抽象提取 picker (extjs)
716  *
717  *
718  * 2012-05
719  * auto-complete menu 对齐当前输入位置
720  *  - http://kirblog.idetalk.com/2010/03/calculating-cursor-position-in-textarea.html
721  *  - https://github.com/kir/js_cursor_position
722  *
723  * 2012-04-01 可能 issue :
724  *  - 用户键盘上下键高亮一些选项,
725  *    input 值为高亮项的 textContent,那么点击 body 失去焦点,
726  *    到底要不要设置 selectedItem 为当前高亮项?
727  *    additional note:
728  *    1. tab 时肯定会把当前高亮项设置为 selectedItem
729  *    2. 鼠标时不会把高亮项的 textContent 设到 input 上去
730  *    1,2 都没问题,关键是键盘结合鼠标时怎么个处理?或者不考虑算了!
731  **/