Threaded View

  1. #1
    Sencha User
    Join Date
    Nov 2008
    Location
    Lyon, France
    Posts
    215
    Vote Rating
    5
    christophe.geiser will become famous soon enough

      1  

    Default Ext.ux.ComboFieldBox - intuitive multi select combobox for 4.1

    Ext.ux.ComboFieldBox - intuitive multi select combobox for 4.1


    Hi all
    Very largely inspired from Ext.ux.form.field.BoxSelect, but thanks to ver 4.1, with much less code.

    Ext.ux.ComboFieldBox extends Ext.form.ComboBox. It renders a View, bound to a store in sync with the comboBox selected values, into its inputEl. Much of the logic is just handled by the View itself.

    latest release available on GitHub or download from here.
    dual licenced under: WTFPL or MIT. Choose whichever suit your needs.

    Simple test page (icons on items are not going to be displayed there): here

    Features:
    - ability to add new items based on user input (forceSelection should be set to false and createNewOnEnter to true)
    - user-input based store filtering
    - auto selection of filtered remaining item (when typeAhead = true)
    - manage icons for the boundlist and the boxes if an iconClsField config is set.
    - keyboard-based selection and navigation
    - second trigger button clears all selected items
    - as it extends ComboBox, all comboBox config should be properly handled.

    [01-05-12: UPDATES]
    release of 0.9 with bug fix and new set of features

    [03-05-12: UPDATES]
    release of 0.9a. bug bix.
    fixed:
    - problem onTrigger1Cls and onTrigger2Cls are null.
    - boxes should not be clickable when field is disabled
    still open: error appearing when keydown and not selection. it is linked to this bug

    [12-05-12: UPDATES]
    release of 0.9b. bug fix; added WTFPL and MIT license.
    fixed:
    - </li> missing in ComboView tpl,
    - dependency detection of ComboView (Ext.require: ['Ext.ux.ComboView']),
    - removing 2 globals (boxKeyNav, iconClsField),
    - added arguments to callParent whenever applicable,
    - hide emptyText on focus,
    - correctly refresh boundList when no records in store (added picker.preserveScrollOnRefresh = true),
    - remove store filters on focus,
    - value of hilighted list item is set on tab when typeAhead is true;

    [16-05-12: UPDATES]
    release of 0.9c. code change and bug fix;
    change:
    - key navigation simplified,
    - css modified (output from scss + white font for selection),
    - better use of dataView selection,
    - more flexible approach for comboView template;
    fixed:
    - indexOf not supported for IE,
    - change width of comboView inputEl on focus to allow proper rendering when not enough space;

    [20-05-12: UPDATES]
    release 1.0. source added on GitHub

    Screenshot.png
    It seems to work well in Chrome and FF, not tested in other browsers. Suggestions to improve this extension are welcome.

    Thanks a lot to all for your comments, feedbacks and tests. Very useful to help improve this extension ; )

    Hope this is usefull,
    Cheers,
    C.


    Code:
    /**
     *   ComboView
     */
     Ext.define('Ext.ux.ComboView',
        {extend : 'Ext.view.View', 
             alias : 'widget.comboview', 
        /**
          * @cfg {Boolean} maxLength
          * maximum length for viewItems. If text is longer, it gets 'ellipsisied'.  
          */
        maxLength: 18,
        /**
          * @cfg {Boolean} removeOnDblClick
          * true to unselect viewItem on double click  
          */
        removeOnDblClick: true,
        /**
          * @cfg {Boolean} inputWidth
          * width for the inputfield  
          */
        inputWidth: 40,
        itemSelector: 'li.x-boxselect-item',
        closeCls: 'x-boxselect-item-close',
         /**
         * Set Xtemplate fot the ComboView (called if me.tpl is not existing)
         * @returns {Ext.XTemplate} Returns template 
         */
        setTpl: function() {
             var me = this,
                field = me.field,
                displayField = field.displayField,
                descField = field.descField,
                iconClsField = field.iconClsField;
            me.tpl = new Ext.XTemplate(
                '<ul class="x-boxselect-list {fieldCls} {typeCls}">',
                    '{[this.empty(values)]}',
                    '<tpl for=".">', 
                        '<li class="x-boxselect-item ', 
                        iconClsField ? ('x-boxselect-icon {' + iconClsField + '}"') : '"', 
                        descField ? ('data-qtitle="{' + displayField + '}" data-qtip="{' + descField + '}">') : '>', 
                        '<div class="x-tab-close-btn ', me.closeCls, '"></div>', 
                        '<div class="x-boxselect-item-text">{[this.ellipsis(values.', displayField, ')]}</div>', 
                        '<div class="x-tab-close-btn ', me.closeCls, '"></div>', 
                    '</li>', 
                '</tpl>', 
                '<li class="x-boxselect-input"><input style="width:10px;"/></li>', // need this to manage focus; width of input is larger in createNewOnEnter is set to true
            '</ul>', {
                compiled: true,
                disableFormats: true,
                length: me.maxLength,
                ellipsis: function (txt) {
                    return Ext.String.ellipsis(txt, this.length)
                },
                emptyText: me.emptyText,
                empty : function(values) {
                    return   '<span class="empty">' + (values.length  ? '' : this.emptyText )+ '</span>' 
                }
            })
            delete me.emptyText;
            return me.tpl;
        },
        initComponent: function () {
            var me = this;
            if (!me.tpl) {me.tpl= me.setTpl()};
            if (!me.selModel) {
                me.selModel = {enableKeyNav: false};
            }
            me.callParent(arguments)
        },
        renderSelectors: {
            inputEl: 'input',
            emptyEl: 'span.empty'
        },
        getFocusEl: function () {
            return this.inputEl
        },
       addFocusListener: function (force) {
            var me = this,  focusEl;
            if (!me.focusListenerAdded) {
                me.callParent(); // force argument only valid in ComboView
                    me.field.el.on({
                        click: me.field.onFocus,
                        scope: me.field
                    })
            }
            if ((focusEl = me.getFocusEl()) && force) {
                focusEl.on({
                    focus: me.field.onFocus,
                    blur: me.field.onBlur,
                    scope: me.field
                });
           }
        }, 
        onItemClick: function (r, h, i, e, o) {
            if (e.getTarget('.' + this.closeCls)) {
                return this.onDataChange(r, 'remove')
            }
            this.highlightItem(h)
        },
        onItemDblClick: function (r, h, i, e, o) {
            if (this.removeOnDblClick) {
                this.onDataChange(r, 'remove')
            }
        },
        onDataChange: function (r, action) {
            var me = this;
            if(me.field.readOnly || me.field.disabled) {return}
            if (action == 'remove') {
                me.store.remove(r)
            }
            me.field.setStoreValues()
        },
        listeners: {
            refresh: {
                fn: function () { 
                    var me = this;
                            this.applyRenderSelectors();
                        this.addFocusListener(this);
                }
            }
        },
        onDestroy: function () {
            var me = this,
                focusEl;
            if (focusEl = me.getFocusEl()) {
                focusEl.clearListeners()
            }
        }
    /*::::*/
    });
    
    
    /**
     *   ComboFieldBox
     */
     Ext.define('Ext.ux.ComboFieldBox',
        {extend : 'Ext.form.field.ComboBox', 
        alias : 'widget.combofieldbox', 
        requires: ['Ext.ux.ComboView'],
        multiSelect: true,
        /**
          * @cfg
         * maximum height for inputEl. 
         */
        maxHeight: 150,
        /**
         * @cfg
         * name of field used for description/tooltip
         */
        descField: null,
        /**
         * @cfg
         * config object passed to the view 
         * viewCfg: {},
         */
        /**
         * @cfg {String} iconClsField
         * The underlying iconCls field name to bind to this ComboBox.
         * iconClsField: '',
         */
        /**
         * @cfg {Boolean} createNewOnEnter
         * When forceSelection is false, new records can be created by the user. This configuration
         * option has no effect if forceSelection is true, which is the default.
         */
        createNewOnEnter: false,
        /**
         * @cfg {Boolean} forceSelection
         * override parent config. If force selection is set to false and    
         */
        forceSelection: true, 
        /**
         * @cfg {Boolean} selectOnTab
         * Whether the Tab key should select the currently highlighted item.
         */
        selectOnTab : false,
        /**
         * @cfg {String} trigger1Cls
         * css class for the first trigger. To have just one trigger acting like in usual combo, set trigger1Cls to null. First trigger clears all values
         */
        trigger1Cls    : Ext.baseCSSPrefix + 'form-clear-trigger',
        /**
         * @cfg {String} trigger2Cls
         * css class for the second trigger. To have just one trigger, set trigger1Cls to null.
         */
        trigger2Cls    : Ext.baseCSSPrefix + 'form-combo-trigger',
        
        /**
         * @cfg {String} listIconCls
         * css class to use when an iconClsField is set. This class is injected into getInnerTpl method when constructing the comboBox boundList
         */
        listIconCls : 'x-boundlist-icon',
        fieldSubTpl: [
            '<div class="{hiddenDataCls}" role="presentation"></div>',
            '<div id="{id}"',
                '<tpl if="readOnly"> readonly="readonly"</tpl>',
                '<tpl if="disabled"> disabled="disabled"</tpl>',
                '<tpl if="tabIdx"> tabIndex="{tabIdx}"</tpl>',
                '<tpl if="name"> name="{name}"</tpl>',
                '<tpl if="fieldStyle"> style="{fieldStyle}"</tpl>',
                '<tpl if="placeholder"> placeholder="{placeholder}"</tpl>',
                '<tpl if="size"> size="{size}"</tpl>',
                'class="{fieldCls} {typeCls} x-boxselect" autocomplete="off" />',
            '</div>',
        {
            compiled: true,
            disableFormats: true
        }
        ],
        getSubTplData: function () {
            var me = this,
                fieldStyle = me.getFieldStyle(),
                ret = me.callParent(arguments);
            ret.fieldStyle = (fieldStyle || '') + ';overflow:auto;height:'+ (me.height ? (me.height + 'px;') : 'auto;') + (me.maxHeight ? ('max-height:' + me.maxHeight + 'px;') : '');
            delete me.height; //need to delete height for the correct component height to be recalculated on layout. 
            return ret;
        },
        alignPicker: function () {
            var me = this,
                picker = me.getPicker(),
                w =  me.triggerWidth;  
            me.callParent(arguments);
            if (me.isExpanded && me.matchFieldWidth) {
                picker.setWidth(me.bodyEl.getWidth() -  (me.trigger2Cls ? (2 * w) : w));
            }
        },
        initComponent: function () {
            var me = this;
            if(!me.trigger1Cls) {
                me.onTrigger1Click = null;
                me.trigger2Cls = null;
            }
            me.getValueStore();
               var selModel = me.multiSelect ? {selModel: {mode: 'SIMPLE', enableKeyNav: false}} : {selModel: {mode: 'SINGLE',enableKeyNav: false}};
            //me.listConfig = Ext.apply(me.listConfig || {}, {selModel: {mode: me.multiSelect ? 'SIMPLE' : 'SINGLE', enableKeyNav: false}});
            me.listConfig = Ext.apply(me.listConfig || {}, selModel);
            if(me.iconClsField || me.descField) {Ext.apply(me.listConfig, {getInnerTpl: function(displayField) {
                            return '<div data-qtip="{' +me.descField +'}" class="'+ ((me.iconClsField && me.listIconCls) ? me.listIconCls :'') +' {'+me.iconClsField + '}">{' + me.displayField +'}</div>';
                            }
                        })
                    };
            me.callParent(arguments);
        },
        onTrigger1Click : function() {
            var me = this;
            me.setValue("");
            me.collapse();
        },
        setValueStore: function(store) {
            this.valueStore = store;
        },
        getValueStore: function() {
            var me = this;
             return me.valueStore || (me.valueStore = me.createValueStore());
        },
        createValueStore: function() {
            return this.valueStore = new Ext.data.Store({
                        model: this.store.model
                });
        },
        /**
        * get all field values from value store and re-set combobox values
        */
        setStoreValues: function() {
            var me = this, 
                st = me.getValueStore();
            me.setValue(st.data.extractValues(me.valueField || st.valueField, 'data'));
            me.syncSelection();   
        },
        getValueModels: function () {
            return this.valueModels || [];
        },
        afterSetValue: function (action){
            var me = this;
            me.valueStore.removeAll();
            me.valueStore.add(me.getValueModels());
            if (me.isExpanded) {
                me.alignPicker();
            }
            me.syncSelection();
               me.updateLayout();
        },
        assertValue: Ext.emptyFn,
        setValue: function (value, action) {
            var me = this;
            if(me.tempValue) {
                var picker = me.getPicker(),
                    oldPr = picker.preserveScrollOnRefresh;
                value = Ext.Array.unique(value.concat(me.tempValue))
                var val = me.store.data.extractValues(me.valueField, 'data');
                if(me.typeAhead && (me.store.getCount() == 1)) {
                    var v = me.store.getAt(0).get(me.valueField);
                    me.tempMulti != true ? value = [v] : value.push(v);
                    me._needCollapse = true;
                }
                me.store.data.addAll(Ext.Array.filter(me.valueStore.data.items, function(i) {return (Ext.Array.indexOf(val,i.data[me.valueField]) < 0)}))
                picker.preserveScrollOnRefresh = true;
                if(me.picker.refresh) {me.picker.refresh()}; 
                picker.preserveScrollOnRefresh = oldPr;
            }
            me.callParent([value, false]);
            me.afterSetValue(action)
        },
        getRawValue: function () {
            return Ext.value(this.rawValue, '');
        },
        doRawQuery: function() {
             var me = this,
                qe;
             if(me.view && me.typeAhead && (qe = me.view.inputEl.getValue())) {
                me.tempValue = me.value;
                me.tempMulti = me.multiSelect;
                me.multiSelect = true; 
                this.doQuery(qe, false, true);
                me.multiSelect = me.tempMulti; 
                delete me.tempMulti;
                delete me.tempValue;
                if(me._needCollapse){
                    me.collapse();
                    delete me._needCollapse;                
                }
                else {
                    me.onExpand();
                    me._preventClear = true;
                    me.view.inputEl.focus();
                    me.view.inputEl.dom.value=qe;
                    delete me._preventClear;
                }
            }
        },
        onBlur: function() {
            var me = this;
            me.view.inputEl.dom.value ='';
            me.view.inputEl.setWidth(10);
            if(me.view.emptyEl) {me.view.emptyEl.show()};
        }, 
        onFocus: function() {
            var me = this,
                view = me.view;
            me.callParent(arguments);
            view.inputEl.setWidth(view.inputWidth)
               if(me._preventClear != true) {
                me.store.clearFilter();
                if(me.picker && me.picker.refresh) {me.picker.refresh()};
            }
            if(view.emptyEl) {
                view.emptyEl.setVisibilityMode(Ext.dom.AbstractElement.DISPLAY)
                view.emptyEl.hide()
            }
            me.view.focus();
        },
        
        buildKeyNav: function() {
             var me = this,
                selectOnTab = me.selectOnTab,
                picker = me.getPicker();
            return  new Ext.view.BoundListKeyNav(picker.el, {
                    boundList: picker,
                    forceKeyDown: true,
                    tab: function(e) {
                        if (selectOnTab || me.typeAhead) {
                            this.selectHighlighted(e);
                        }
                           me.onTriggerClick()
                        return true
                    }, 
                    esc: function(e) {
                        me.onTriggerClick()
                    }
                });
        },
        onExpand: function() {
            var me = this,
                keyNav = me.listKeyNav,
                selectOnTab = me.selectOnTab,
                picker = me.getPicker();
            // Handle BoundList navigation from the input field. Insert a tab listener specially to enable selectOnTab.
            if(!keyNav){   keyNav = me.listKeyNav = me.buildKeyNav()}
            // While list is expanded, stop tab monitoring from Ext.form.field.Trigger so it doesn't short-circuit selectOnTab
            if (selectOnTab) {
              me.ignoreMonitorTab = true;
               }
            Ext.defer(keyNav.enable, 3, keyNav); //wait a bit so it doesn't react to the down arrow opening the picker
            picker.focus();
            if(picker.getNode){picker.highlightItem(picker.getNode(0))};
        },
        onCollapse: function() {
            var me = this;
            me.callParent(arguments);
            me.view.focus();
        },
        createRecord: function(rawValue) {
               var me= this, rec = {};
            rec[me.valueField] = rawValue;
            rec[me.displayField] = rawValue;
            return rec
        },
        afterComponentLayout : function() {
            var me = this;
            me.callParent(arguments);
            if (!me.view) {
                var selectBoxOnTab = me.selectBoxOnTab,
                del = function(e) {
                    if(me.readOnly || me.disabled || !me.editable || me.view.inputEl.dom.value ) {return}
                    var selected = selModel.getSelection()[0];
                    if(selected) {
                        var idx = Ext.Array.indexOf(me.view,me.view.getNode(selected));
                        selModel.onNavKey.call(selModel, 1)
                        me.getValueStore().remove(selected)       
                        me.setStoreValues();
                        selModel.select(idx);
                        me.view.focus()
                    }
                    return true;
                };
                me.view = new Ext.ux.ComboView(Ext.apply({
                    store: me.valueStore,
                    emptyText: me.emptyText || '',
                    field: me,
                    renderTo: me.inputEl
                }, me.viewCfg));
               var selModel = me.view.selModel; 
               var boxKeyNav=  me.boxKeyNav = new Ext.view.BoundListKeyNav(me.view.el, {
                    boundList: me.view,
                    forceKeyDown: true,
                    down : function(e) {
                        if(me.isExpanded && me.view.inputEl.getValue()) {return me.picker.focus()}
                        me.onTriggerClick();
                    },
                    right: function(e) {
                        selModel.onNavKey.call(selModel, +1)    
                    },
                    left: function(e) {
                        selModel.onNavKey.call(selModel, -1)
                    },
                    enter: function(e) {
                        if(me.readOnly || me.disabled || !me.editable) {return}
                        if (me.multiSelect && me.createNewOnEnter == true && e.getKey() == e.ENTER  && (rawValue = e.target.value) && (!Ext.isEmpty(rawValue))) {
                             rec = me.store.findExact(me.valueField, rawValue);
                             if(rec < 0) {
                                rec= me.store.add(me.createRecord(rawValue))
                             }
                             me.getValueStore().add(rec)       
                             me.setStoreValues()
                        }
                        me.view.focus()
                    },
                    tab: function(e) {
                        if(me.isExpanded && e.target.value){
                            me.picker.focus()                    
                        }
                        return true
                    },
                    del:del,
                    space: del
                });
                Ext.defer(boxKeyNav.enable, 1, boxKeyNav);
            }
        },
        onDestroy: function() {
            var me = this;
            if(me.view) {Ext.destroy(me.view, me.boxKeyNav)}
            me.callParent(arguments);
        }
    });
    Last edited by christophe.geiser; 20 May 2012 at 11:12 AM. Reason: release 1.0 on GitHub