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 **/