/** * @ignore * Input wrapper for ComboBox component. * @author yiminghe@gmail.com */ KISSY.add("combobox/control", function (S, Node, Control, ComboBoxRender, Menu, undefined) { var ComboBox, KeyCode = Node.KeyCode; /** * KISSY ComboBox. * xclass: 'combobox'. * @extends KISSY.Component.Control * @class KISSY.ComboBox */ ComboBox = Control.extend({ // user's input text. // for restore after press esc key // if update input when press down or up key _savedValue: null, /** * 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.getValueForAutocomplete(), 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 */ self.on('click', onMenuItemClick, self); self.get('menu').onRendered(function (menu) { onMenuAfterRenderUI(self, menu); }); }, destructor: function () { this.get('menu').destroy(); }, /** * get value * @protected */ getValueForAutocomplete: function () { return this.get('value'); }, /** * set value * @protected * @method * @member KISSY.ComboBox */ setValueFromAutocomplete: function (value, setCfg) { this.set('value', value, setCfg); }, // buffer/bridge between check timer and change logic '_onSetValue': function (v, e) { var self = this, value; // only trigger menu when timer cause change if (e.causedByTimer) { value = self['getValueForAutocomplete'](); // undefined means invalid input value if (value === undefined) { self.set("collapsed", true); return; } self._savedValue = value; self.sendRequest(value); } else { self.get('input').val(v); } }, handleFocusInternal: function () { var self = this, placeholderEl; if (self.get('invalidEl')) { setInvalid(self, false); } if (placeholderEl = self.get("placeholderEl")) { placeholderEl.hide(); } }, handleBlurInternal: function (e) { var self = this, placeholderEl = self.get("placeholderEl"); self.callSuper(e); delayHide(self); if (self.get('invalidEl')) { self.validate(function (error, val) { if (error) { if (!self.get("focused") && val == self.get('value')) { setInvalid(self, error); } } else { setInvalid(self, false); } }); } if (placeholderEl && !self.get('value')) { placeholderEl.show(); } }, handleMouseDownInternal: function (e) { var self = this, target, trigger; self.callSuper(e); target = e.target; trigger = self.get("trigger"); if (trigger && (trigger[0] == target || trigger.contains(target))) { if (self.get('collapsed')) { // fetch data self.focus(); self.sendRequest(''); } else { // switch from open to collapse self.set('collapsed', true); } e.preventDefault(); } }, handleKeyDownInternal: function (e) { var self = this, updateInputOnDownUp, input, keyCode = e.keyCode, highlightedItem, handledByMenu, menu = self.get("menu"); input = self.get("input"); updateInputOnDownUp = self.get("updateInputOnDownUp"); if (menu.get("visible")) { highlightedItem = menu.get("highlightedItem"); // https://github.com/kissyteam/kissy/issues/371 // combobox: input should be involved in key press sequence if (updateInputOnDownUp && highlightedItem) { var menuChildren = menu.get('children'); if (keyCode == KeyCode.DOWN && highlightedItem == getFirstEnabledItem(menuChildren.concat().reverse()) || keyCode == KeyCode.UP && highlightedItem == getFirstEnabledItem(menuChildren) ) { self.setValueFromAutocomplete(self._savedValue); highlightedItem.set('highlighted', false); return true; } } handledByMenu = menu.handleKeyDownInternal(e); highlightedItem = menu.get("highlightedItem"); // esc if (keyCode == KeyCode.ESC) { self.set("collapsed", true); if (updateInputOnDownUp) { // combobox will change input value // but it does not need to reload data // restore original user's input text self.setValueFromAutocomplete(self._savedValue); } return true; } if (updateInputOnDownUp && S.inArray(keyCode, [KeyCode.DOWN, KeyCode.UP])) { // update menu's active value to input just for show self.setValueFromAutocomplete(highlightedItem.get("textContent")); } // tab // if menu is open and an menuitem is highlighted, see as click/enter if (keyCode == KeyCode.TAB && highlightedItem) { // click highlightedItem highlightedItem.handleClickInternal(); // only prevent focus change in multiple mode if (self.get("multiple")) { return true; } } return handledByMenu; } else if (keyCode == KeyCode.DOWN || keyCode == KeyCode.UP) { // re-fetch, consider multiple input var v = self.getValueForAutocomplete(); if (v !== undefined) { self.sendRequest(v); return true; } } return undefined; }, validate: function (callback) { var self = this, validator = self.get('validator'), val = self.getValueForAutocomplete(); if (validator) { validator(val, function (error) { callback(error, val); }); } else { callback(false, val); } }, /** * 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, el = self.$el, menu = self.get('menu'); if (v) { menu.hide(); } else { // 保证显示前已经 bind 好 menu 事件 clearDismissTimer(self); if (!menu.get("visible")) { if (self.get("matchElWidth")) { menu.render(); var menuEl = menu.get('el'); var borderWidth = (parseInt(menuEl.css('borderLeftWidth')) || 0) + (parseInt(menuEl.css('borderRightWidth')) || 0); menu.set("width", el[0].offsetWidth - borderWidth); } menu.show(); } } } }, { ATTRS: { /** * Input element of current combobox. * @type {KISSY.NodeList} * @property input */ /** * @ignore */ input: { }, /** * initial value for input * @cfg {String} inputValue */ /** * @ignore */ value: { value: '', sync: 0, view: 1 }, /** * trigger arrow element * @ignore */ trigger: { }, /** * placeholder * @cfg {String} placeholder */ /** * @ignore */ placeholder: { view: 1 }, /** * label for placeholder in ie * @ignore */ placeholderEl: { }, /** * custom validation function * @type Function * @property validator */ /** * @ignore */ validator: { }, /** * invalid tag el * @ignore */ invalidEl: { }, allowTextSelection: { value: true }, /** * Whether show combobox trigger. * Defaults to: true. * @cfg {Boolean} hasTrigger */ /** * @ignore */ hasTrigger: { value: true, 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: { }, getter: function (v) { if (!v.isControl) { v.xclass = v.xclass || 'popupmenu'; v = this.createComponent(v); this.setInternal('menu', v); } return v; }, setter: function (m) { if (m.isControl) { m.setInternal('parent', this); var align = { node: this.$el, points: ["bl", "tl"], overflow: { adjustX: 1, adjustY: 1 } }; S.mix(m.get('align'), align, false); } } }, /** * Whether combobox menu is hidden. * @type {Boolean} * @property collapsed */ /** * @ignore */ collapsed: { view: 1, value: true }, /** * 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: { }, /** * whether highlight item when item content is same with user input. * Defaults to: true * @cfg {Boolean} highlightMatchItem */ /** * @ignore */ highlightMatchItem: { value: true }, xrender: { value: ComboBoxRender } }, xclass: 'combobox' }); // #----------------------- private start function getFirstEnabledItem(children) { for (var i = 0; i < children.length; i++) { if (!children[i].get('disabled')) { return children[i]; } } return null; } function onMenuFocusout() { var combobox = this; delayHide(combobox); } function onMenuFocusin() { var combobox = this; // different event sequence // ie fire focusin blur // others fire blur focusin setTimeout(function () { clearDismissTimer(combobox); }, 0); } function onMenuMouseOver() { var combobox = this; // trigger el focus self.focus(); // prevent menu from hiding clearDismissTimer(combobox); } function onMenuMouseDown() { var combobox = this; // consider multi-input // input.val(self.get('value')); // force change event for cursor keep combobox.setValueFromAutocomplete(combobox.getValueForAutocomplete(), { force: 1 }); } function onMenuAfterRenderUI(self, menu) { var contentEl; var input = self.get('input'); var el = menu.get('el'); contentEl = menu.get("contentEl"); input.attr("aria-owns", el.attr('id')); // menu has input! el.on("focusout", onMenuFocusout, self); el.on("focusin", onMenuFocusin, self); contentEl.on("mouseover", onMenuMouseOver, self); // cause valuechange // if click menuitem while chinese input is open(xu -> '') contentEl.on('mousedown', onMenuMouseDown, self); } function onMenuItemClick(e) { var item = e.target, self = this, textContent; if (item.isMenuItem) { textContent = item.get('textContent'); self.setValueFromAutocomplete(textContent); self._savedValue = textContent; self.set("collapsed", true); } } function setInvalid(self, error) { var $el = self.$el, cls = self.view.getBaseCssClasses('invalid'), invalidEl = self.get("invalidEl"); if (error) { $el.addClass(cls); invalidEl.attr("title", error); invalidEl.show(); } else { $el.removeClass(cls); invalidEl.hide(); } } function delayHide(self) { if (self._focusoutDismissTimer) { return; } self._focusoutDismissTimer = setTimeout(function () { // ie6 bug if (self._focusoutDismissTimer) { self.set("collapsed", true); } }, // ie6 needs longer timeout 50); } function clearDismissTimer(self) { var t; if (t = self._focusoutDismissTimer) { clearTimeout(t); self._focusoutDismissTimer = null; } } function onValueChange(e) { this.set('value', e.newVal, { data: { causedByTimer: 1 } }); } function renderData(data) { var self = this, v, children = [], val, matchVal, highlightedItem, i, menu = self.get("menu"); data = self['normalizeData'](data); var start = S.now(); menu.removeChildren(true); if (highlightedItem = menu.get('highlightedItem')) { highlightedItem.set('highlighted', false); } if (data && data.length) { for (i = 0; i < data.length; i++) { v = data[i]; menu.addChild(v); } children = menu.get('children'); // make menu item (which textContent is same as input) active val = self['getValueForAutocomplete'](); if (self.get('highlightMatchItem')) { for (i = 0; i < children.length; i++) { if (children[i].get("textContent") == val) { children[i].set('highlighted', true); 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")) { children[i].set('highlighted', true); break; } } } self.set("collapsed", false); } else { self.set("collapsed", true); } } // #------------------------private end return ComboBox; }, { requires: [ 'node', 'component/control', './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 都没问题,关键是键盘结合鼠标时怎么个处理?或者不考虑算了! **/