/** * @ignore * Input wrapper for ComboBox component. * @author yiminghe@gmail.com */ KISSY.add("combobox/base", function (S, Node, Component, ComboBoxRender, Menu, undefined) { var ComboBox, $ = Node.all, KeyCodes = Node.KeyCodes, ALIGN = { points: ["bl", "tl"], overflow: { adjustX: 1, adjustY: 1 } }, win = $(S.Env.host); /** * KISSY ComboBox. * xclass: 'combobox'. * @extends KISSY.Component.Controller * @class KISSY.ComboBox */ ComboBox = Component.Controller.extend({ // user's input text _savedInputValue: null, _stopNotify: 0, /** * normalize returned data * @protected * @param data */ 'normalizeData': function (data) { var self = this, contents, v, i, c; if (data && data.length) { data = data.slice(0, self.get("maxItemCount")); if (self.get("format")) { contents = self.get("format").call(self, self['getValueInternal'](), data); } else { contents = []; } for (i = 0; i < data.length; i++) { v = data[i]; c = contents[i] = S.mix({ content: v, textContent: v, value: v }, contents[i]); } return contents; } return contents; }, bindUI: function () { var self = this, input = self.get("input"); input.on("valuechange", onValueChange, self); /** * fired after combobox 's collapsed attribute is changed. * @event afterCollapsedChange * @param e * @param e.newVal current value * @param e.prevVal previous value */ }, /** * get value * @protected */ getValueInternal: function () { return this.get('input').val(); }, /** * set value * @protected * @method * @member KISSY.ComboBox */ setValueInternal: function (value) { this.get('input').val(value); }, /** * align menu * @protected */ 'alignInternal': function () { var self = this, menu = self.get("menu"), align = S.clone(menu.get("align")); align.node = self.get("el"); S.mix(align, ALIGN, false); menu.set("align", align); }, handleFocus: function () { var self = this, placeholderEl; setInvalid(self, false); if (placeholderEl = self.get("placeholderEl")) { placeholderEl.hide(); } }, handleBlur: function () { var self = this, placeholderEl = self.get("placeholderEl"), input; ComboBox.superclass.handleBlur.apply(self, arguments); delayHide.call(self); input = self.get("input"); self.validate(function (error, val) { if (error) { if (!self.get("focused") && val == input.val()) { setInvalid(self, error); } } else { setInvalid(self, false); } }); if (placeholderEl && !input.val()) { placeholderEl.show(); } }, handleMouseDown: function (e) { var self = this, input, target, trigger , hasTrigger; ComboBox.superclass.handleMouseDown.apply(self, arguments); target = e.target; trigger = self.get("trigger"); hasTrigger = self.get('hasTrigger'); if (hasTrigger && (trigger[0] == target || trigger.contains(target))) { input = self.get("input"); if (self.get('collapsed')) { // fetch data input[0].focus(); self.sendRequest(''); } else { // switch from open to collapse self.set('collapsed', true); } e.preventDefault(); } }, handleKeyEventInternal: function (e) { var self = this, updateInputOnDownUp, input, activeItem, handledByMenu, menu = getMenu(self); if (!menu) { return undefined; } input = self.get("input"); updateInputOnDownUp = self.get("updateInputOnDownUp"); if (updateInputOnDownUp) { // combobox will change input value // but it does not need to reload data if (S.inArray(e.keyCode, [ KeyCodes.UP, KeyCodes.DOWN, KeyCodes.ESC ])) { self._stopNotify = 1; } else { self._stopNotify = 0; } } if (menu.get("visible")) { handledByMenu = menu['handleKeydown'](e); // esc if (e.keyCode == KeyCodes.ESC) { self.set("collapsed", true); if (updateInputOnDownUp) { // restore original user's input text self['setValueInternal'](self._savedInputValue); } return true; } activeItem = menu.get("activeItem"); if (updateInputOnDownUp && S.inArray(e.keyCode, [KeyCodes.DOWN, KeyCodes.UP])) { // update menu's active value to input just for show this.setValueInternal(activeItem.get("textContent")); } // tab // if menu is open and an menuitem is highlighted, see as click/enter if (e.keyCode == KeyCodes.TAB && activeItem) { // click activeItem activeItem.performActionInternal(); // only prevent focus change in multiple mode if (self.get("multiple")) { return true; } } return handledByMenu; } else if (e.keyCode == KeyCodes.DOWN || e.keyCode == KeyCodes.UP) { // re-fetch, consider multiple input // S.log("refetch : " + getValue(self)); self.sendRequest(this.getValueInternal()); return true; } return undefined; }, syncUI: function () { var self = this, input , inputValue; if (self.get("placeholder")) { input = self.get("input"); inputValue = self.get("inputValue"); if (inputValue != undefined) { input.val(inputValue); } if (!input.val()) { self.get("placeholderEl").show(); } } }, validate: function (callback) { var self = this, validator = self.get('validator'), val = self.get("input").val(); if (validator) { validator(val, function (error) { callback(error, val); }); } else { callback(false, val); } }, bindMenu: function () { var self = this, el, contentEl, menu = self.get("menu"); menu.on("click", function (e) { var item = e.target, textContent = item.get('textContent'); // stop valuechange event self._stopNotify = 1; self['setValueInternal'](textContent); self._savedInputValue = textContent; self.set("collapsed", true); setTimeout( function () { self._stopNotify = 0; }, // valuechange interval, hack 50 ); }); win.on("resize", self.__repositionBuffer = S.buffer(reposition, 50), self); el = menu.get("el"); contentEl = menu.get("contentEl"); // menu has input! el.on("focusout", delayHide, self); el.on("focusin", clearDismissTimer, self); contentEl.on("mouseover", onMenuMouseOver, self); self.bindMenu = S.noop; }, /** * fetch comboBox list by value and show comboBox list * @param {String} value value for fetching comboBox list */ sendRequest: function (value) { var self = this, dataSource = self.get("dataSource"); dataSource.fetchData(value, renderData, self); }, _onSetCollapsed: function (v) { var self = this; if (v) { hideMenu(self); } else { showMenu(self); } }, destructor: function () { var self = this, repositionBuffer = self.__repositionBuffer; win.detach("resize", repositionBuffer, self); repositionBuffer.stop(); } }, { ATTRS: { /** * Input element of current combobox. * @type {KISSY.NodeList} * @property input */ /** * @ignore */ input: { view: 1 }, /** * initial value for input * @cfg {String} inputValue */ /** * @ignore */ inputValue: { view: 1 }, /** * trigger arrow element * @ignore */ trigger: { view: 1 }, /** * placeholder * @cfg {String} placeholder */ /** * @ignore */ placeholder: { view: 1 }, /** * label for placeholder in ie * @ignore */ placeholderEl: { view: 1 }, /** * custom validation function * @type Function * @property validator */ /** * @ignore */ validator: { }, /** * invalid tag el * @ignore */ invalidEl: { view: 1 }, allowTextSelection: { value: true }, /** * Whether show combobox trigger. * Defaults to: true. * @cfg {Boolean} hasTrigger */ /** * @ignore */ hasTrigger: { view: 1 }, * ComboBox dropDown menuList or config * @cfg {KISSY.Menu.PopupMenu|Object} menu */ * ComboBox dropDown menuList or config * @property menu * @type {KISSY.Menu.PopupMenu} */ * @ignore */ menu: { value: {}, setter: function (m) { if (m && m['isController']) { m.setInternal("parent", this); } } }, defaultChildXClass: { value: 'popupmenu' }, /** * Whether combobox menu is hidden. * @type {Boolean} * @property collapsed */ /** * @ignore */ collapsed: { view: 1 }, /** * dataSource for comboBox. * @cfg {KISSY.ComboBox.LocalDataSource|KISSY.ComboBox.RemoteDataSource|Object} dataSource */ /** * @ignore */ dataSource: { // 和 input 关联起来,input可以有很多,每个数据源可以不一样,但是 menu 共享 }, /** * maxItemCount max count of data to be shown * @cfg {Number} maxItemCount */ /** * @ignore */ maxItemCount: { value: 99999 }, /** * Whether drop down menu is same width with input. * Defaults to: true. * @cfg {Boolean} matchElWidth */ /** * @ignore */ matchElWidth: { value: true }, /** * Format function to return array of * html/text/menu item attributes from array of data. * @cfg {Function} format */ /** * @ignore */ format: { }, /** * Whether update input's value at keydown or up when combobox menu shows. * Default to: true * @cfg {Boolean} updateInputOnDownUp */ /** * @ignore */ updateInputOnDownUp: { value: true }, /** * Whether or not the first row should be highlighted by default. * Defaults to: false * @cfg {Boolean} autoHighlightFirst */ /** * @ignore */ autoHighlightFirst: { }, xrender: { value: ComboBoxRender } } }, { xclass: 'combobox', priority: 10 } ); // #----------------------- private start function onMenuMouseOver() { var self = this; // trigger el focus self.get("input")[0].focus(); // prevent menu from hiding clearDismissTimer.call(self); } function setInvalid(self, error) { var el = self.get("el"), cls = self.get("prefixCls") + "combobox-invalid", invalidEl = self.get("invalidEl"); if (error) { el.addClass(cls); invalidEl.attr("title", error); invalidEl.show(); } else { el.removeClass(cls); invalidEl.hide(); } } function getMenu(self, init) { var m = self.get("menu"); if (m && !m['isController']) { if (init) { m = Component.create(m, self); self.setInternal("menu", m); } else { return null; } } return m; } function hideMenu(self) { var menu = getMenu(self); if (menu) { menu.hide(); } } function reposition() { var self = this, menu = getMenu(self); if (menu && menu.get("visible")) { self['alignInternal'](); } } function delayHide() { var self = this; self._focusoutDismissTimer = setTimeout(function () { self.set("collapsed", true); }, 30); } function clearDismissTimer() { var self = this, t; if (t = self._focusoutDismissTimer) { clearTimeout(t); self._focusoutDismissTimer = null; } } function showMenu(self) { var el = self.get("el"), menu = getMenu(self, 1); // 保证显示前已经 bind 好 menu 事件 clearDismissTimer.call(self); if (menu && !menu.get("visible")) { // 先 render,监听 width 变化事件 menu.render(); self.bindMenu(); // 根据 el 自动调整大小 if (self.get("matchElWidth")) { menu.set("width", el.innerWidth()); } menu.show(); self.get("input").attr("aria-owns", menu.get("el").attr('id')); } } function onValueChange() { var self = this, value; if (self._stopNotify) { return; } value = self['getValueInternal'](); if (value === undefined) { self.set("collapsed", true); return; } self._savedInputValue = value; // S.log("value change: " + value); self.sendRequest(value); } function renderData(data) { var self = this, v, children = [], val, matchVal, i, menu = getMenu(self, 1); data = self['normalizeData'](data); menu.removeChildren(true); menu.set("highlightedItem", null); if (data && data.length) { for (i = 0; i < data.length; i++) { v = data[i]; children.push(menu.addChild(v)); } // make menu item (which textContent is same as input) active val = self['getValueInternal'](); for (i = 0; i < children.length; i++) { if (children[i].get("textContent") == val) { menu.set("highlightedItem", children[i]); matchVal = true; break; } } // Whether or not the first row should be highlighted by default. if (!matchVal && self.get("autoHighlightFirst")) { for (i = 0; i < children.length; i++) { if (!children[i].get("disabled")) { menu.set("highlightedItem", children[i]); break; } } } self.set("collapsed", false); // 2012-12-28: in case autocomplete list becomes shorted or longer reposition.call(self); } else { self.set("collapsed", true); } } // #------------------------private end return ComboBox; }, { requires: [ 'node', 'component/base', './render', 'menu' ] }); /** * @ignore * * !TODO * - menubutton combobox 抽象提取 picker (extjs) * * * 2012-05 * auto-complete menu 对齐当前输入位置 * - http://kirblog.idetalk.com/2010/03/calculating-cursor-position-in-textarea.html * - https://github.com/kir/js_cursor_position * * 2012-04-01 可能 issue : * - 用户键盘上下键高亮一些选项, * input 值为高亮项的 textContent,那么点击 body 失去焦点, * 到底要不要设置 selectedItem 为当前高亮项? * additional note: * 1. tab 时肯定会把当前高亮项设置为 selectedItem * 2. 鼠标时不会把高亮项的 textContent 设到 input 上去 * 1,2 都没问题,关键是键盘结合鼠标时怎么个处理?或者不考虑算了! **/