Knockout with jQuery Autocomplete and function for Source with Custom matching

1,772 views
Skip to first unread message

nevf

unread,
Jan 31, 2012, 11:14:26 PM1/31/12
to knock...@googlegroups.com
Following on from  the discussion Knockout with JQuery UI Autocomplete (Resurrected)  and my post there I thought I'd start a new thread with the code I've written to enable use of the jQuery Auto-complete widget with the ability to use a function for the array source, without a selection affecting same, along a custom auto-complete matcher function and adding a drop-down button whose icon changes in the JS vs. the markup which Ryan used in his example. I've tried to model the UI behaviour on that used by the Chosen.js <select> widget with options provided using jqAutoNewItem and added a Sort option.

Overall this works well. One issue I've yet to solve is that it doesn't highlight the current selection when the list drops-down. jQuery Auto-complete doesn't include this capability however I think it may still be possible.

Another point to note is in cases where you are using this in a foreach template a large number of iterations you will want to extract the: var mapped = ko.utils.arrayMap( .. ) code and create the source array just once for each different source as this can take a serious performance hit. It would in fact be even better if the auto-complete widget was only created when the user clicks on the <input> element.

Hopefully this is of use to someone.

    /** jqAuto -- jQuery UI Auto-complete Binding. The main binding should contain additional options to pass to autocomplete.
     *   jqAutoSource -- the array of choices. Can be an array of strings or objects.
     *   jqAutoSourceFunc -- function which supplies array of choices. NF 14/01/2012
     *   jqAutoValue -- the model property to write the selected value to.
     *
     *  Optional params used when the source is an array of objects.
     *   jqAutoSourceValue -- the model property to use for the value.
     *      ex. 'id' for { id: xx, name: yy } - where source is an array of objects. n/u where source is a plain array of strings.
     *      This is optionsValue in the KO "options" binding used for <select> impl.
     *   jqAutoSourceInputValue -- the model property that should be displayed in the input box. Uses jqAutoSourceValue if not spec'd.
     *      ex. 'name' for { id: xx, name: yy } - where source is an array of objects. n/u where source is a plain array of strings.
     *      This is optionsText in the KO "options" binding used for <select> impl.
     *   jqAutoSourceLabel -- the property that should be displayed in the possible choices list. Uses jqAutoSourceInputValue if not spec'd.
     *   jqAutoSort -- 'a - ascending or d - descending' to sort array of objects by jqAutoSourceInputValue.
     *   jqAutoNewItem -- 't' to allow new items to be created, 'r' to restore the item to it's orginal value if it doesn't exist,
     *                    'c' to clear the item if the value doesn't exist
     */
    ko.bindingHandlers.jqAuto = {
        init: function( element, valueAccessor, allBindingsAccessor, viewModel ){
            // console.profile();
            var options = valueAccessor() || {},
                allBindings = allBindingsAccessor(),
                unwrap = ko.utils.unwrapObservable,
                modelValue = allBindings.jqAutoValue,
                source = allBindings.jqAutoSource,
                sourceFunc = allBindings.jqAutoSourceFunc,
                valueProp = allBindings.jqAutoSourceValue,     // 18/01/2012
                inputValueProp = allBindings.jqAutoSourceInputValue || valueProp,   // 18/01/2012
                labelProp = allBindings.jqAutoSourceLabel || inputValueProp,      // 18/01/2012
                newItem = allBindings.jqAutoNewItem,            // 19/01/2012
                onUpdate = allBindings.jqonUpdate,
                $elem = $(element);

            if ( !modelValue ){  // There were problems where button_target and image_target were missing from various items. Presumeably from older versions! I've fixed these in addtosection_items(). 19/01/2012
                // console.log( 'NH: !modelValue - jqAutoValueItem', allBindings.jqAutoValueItem, ', jqAutoValueItemName:', allBindings.jqAutoValueItemName, ', elem:', $elem );
                console.log( 'NH: !modelValue - jqAutoValueItem', ', elem:', $elem );
                debugger;     // TODO: temp
            }

            // function that is shared by both select and change event handlers
            function writeValueToModel( valueToWrite ){
                console.assert( valueToWrite || valueToWrite == '' );
                console.assert( modelValue );
                if ( !modelValue)    // see other test & cmt above.
                    debugger;     // TODO: temp
                if ( ko.isWriteableObservable( modelValue ) ) {
                   modelValue( valueToWrite );
                } else {  // write to non-observable
                    if ( allBindings['_ko_property_writers'] && allBindings['_ko_property_writers']['jqAutoValue'] )
                        allBindings['_ko_property_writers']['jqAutoValue']( valueToWrite );
                    else
                        console.assert( false );
                }

                // If a jqonUpdate function has been spec'd call same. Used to display the image when auto-complete value changes.
                if ( onUpdate && valueToWrite.length > 0 )     // could test newItem = 'c' here. rem: Don't want 'There is no selected image to show' 
                    onUpdate( valueToWrite );
            }

            // Returns the matching source[ item ] for the current input value.
            // @param value value to check of null for element value.
            // @param newItem == 'r' to restore orgiginal element value if spec'd / current value doesn't exist,
            //        newItem == 'c' to clear element value if spec'd / current value doesn't exist.
            var value_exists = function( value, newItem ){
                var currentValue = value || $.trim( $elem.val() );
                var matchingItem = null;
                if ( currentValue.length ){
                    matchingItem = ko.utils.arrayFirst( unwrap( source ), function( item ){
                        return unwrap( inputValueProp ? ( $.isFunction( inputValueProp ) ? inputValueProp( item ) : item[inputValueProp] ) : item ) === currentValue;    // 18/01/2012
                    });
                }
                // console.log( 'In ko.bindingHandlers.jqAuto() value_exists() - value:', currentValue, ', matchingItem:', matchingItem );
                if ( !matchingItem ){
                    if ( newItem == 'r' ){
                        var originalValue = $elem.data( 'originalValue' );
                        $elem.val( originalValue );
                        // writeValueToModel( originalValue ); // doesn't update DOM if property unchanged. And no need as VM property value hasn't changed.
                    }
                    else
                    if ( newItem == 'c' ){      // We could add another option 'C' that does: if ( currentValue.length == 0 ) clear else Restore.
                        writeValueToModel( '' );    // We must clear the vm property. ie. User wants it cleared.
                        $elem.val( '' );  // see cmt re. writeValueToModel() above.
                    }
                }
                return matchingItem;
            }

            // If source is an array of objects then convert same to a DO array.
            var matcher_source;
            if ( valueProp ){
                var mappedSource = ko.dependentObservable( function() {
                    var mapped = ko.utils.arrayMap( unwrap( source ), function( item ){
                        var result = {};
                        // text to display in auto-complete list
                        result.label = labelProp ? unwrap( $.isFunction( labelProp ) ? labelProp( item ) : item[labelProp] ) : unwrap( item ).toString();
                        // text to display in input box
                        result.value = inputValueProp ? unwrap( $.isFunction( inputValueProp ) ? inputValueProp( item ) : item[inputValueProp] ) : unwrap( item ).toString();
                        // store in model
                        result.actualValue = valueProp ? unwrap( item[valueProp] ) : item;
                        return result;
                    });

                    if ( allBindings.jqAutoSort ){
                        mapped.sort( function( a, b ){
                            var auc = a.value.toUpperCase(), buc = b.value.toUpperCase();
                            var res = ( allBindings.jqAutoSort == 'a' ) ? ( auc < buc ? -1 : auc === buc ? 0 : 1 ) : ( auc < buc ? 1 : auc === buc ? 0 : -1 );
                            return res;
                        });
                    }
                    return mapped;
                });

                // whenever the items that make up the source are updated, make sure that autocomplete knows it
                mappedSource.subscribe( function( newValue ){
                    console.log( 'In mappedSource.subscribe() newValue:', newValue );      // is this ever called. TODO: TEST:
                   $elem.autocomplete( "option", "source", newValue );
                });

                matcher_source = mappedSource;   // mustn't touch 'source'. It's used by options.change().
            }
            else
                matcher_source = source;

            options.source = sourceFunc || source;

            $elem.data( 'source', matcher_source );    // use jq .data to pass source to ac_matcher()


            // We add a drop-down open/close button when the auto-complete is created.
            // Orig implementation did this in markup, but this is better.
            options.create = function( event, ui ){
                $( "<button> </button>" )
                .attr( "tabIndex", -1 )
                .insertAfter( element )
                .button({
                  icons: { primary: "ui-icon-triangle-1-s" },
                  text: false
                })
                .removeClass( "ui-corner-all" )
                .addClass( "ui-corner-right ui-button-icon jqui_ac_btn" )
                .click( function( event ){
                    ac_open();
                    return false;  // stop event bubble/propogation.
                })
                .hover( function(){
                    $(this).removeAttr('title');    // otherwise hover displays an empty tip.
                });
            }

            // flip the button icon.
            options.open = function( event, ui ){
                var $el_button = $( $elem.next()[0] );
                $el_button.button( { icons: { primary: "ui-icon-triangle-1-n" } } );
            }
        
            options.close = function( event, ui ){
                var $el_button = $( $elem.next()[0] );
                $el_button.button( { icons: { primary: "ui-icon-triangle-1-s" } } );
            }

            // on a selection write the proper value to the model
            options.select = function( event, ui ){
                writeValueToModel( ui.item.actualValue ? ui.item.actualValue : ui.item ? ui.item.value : null );    // .actualValue is used when the source is an array of objects. See mappedSource() above.
            };

            // on a change, make sure that it is a valid value or clear out the model value
            options.change = function( event, ui ){
                if ( newItem == 'r' || newItem == 'c' )
                    value_exists( null, newItem );
                else
                if ( newItem == 't' ){  // add new value. note: where source is an array of objects this probably doesn't make much sense as we don't add a complete item. jQ-UI Auto-complete doesn't allow adding new items.
                    writeValueToModel( $.trim( $elem.val() ) );
                }
            }

            $elem.on( 'keydown', function( event ){
                if ( event.keyCode == '13' ){
                    // Do zip when Enter is pressed. ref: <A href="sulkb://kb=Javascript,Fid=5869,Rid=5880">Auto-complete requirements</A>
                    return false;    // prevent bubble etc.
                }
            });

            $elem.on( 'keyup', function( event ){
                if ( event.keyCode == '8' || event.keyCode == 46 ){    // Del keyCode is 46.
                    var currentValue = $.trim( $elem.val() );    // rem: we have value after BS with keyup.
                    if ( currentValue.length == 0 ){      // if input is now empty open the ac list.
                        $elem.qtip( 'destroy' );
                        ac_open( $elem );
                    }
                }
            });

            // Called by ac_matcher() when auto-complete list has been built.
            $elem.on( 'matches', function( event, value, matches ){
                if ( matches )
                    $elem.qtip( 'destroy' );    // Clear any 'no_matches' qtip
                else  // entered value doesn't exist in the auto-complete list.
                    $elem.qtip({
                        content: 'No results match "' + value + '"',
                        position: { my: 'bottom center', at: 'top center' },
                        show: { event: false, ready: true }
                    });
                return false;
            });

            // initialize autocomplete. When element gets focus drop down the auto-complete list.
            $elem.autocomplete( options ).click( function(){ ac_open( $(this), 'click' ); return false; } );

            // console.profileEnd();
        },
        update: function(element, valueAccessor, allBindingsAccessor, viewModel) {
            // update value based on a model change
           var allBindings = allBindingsAccessor(),
               unwrap = ko.utils.unwrapObservable,
               modelValue = unwrap( allBindings.jqAutoValue ) || '',
               valueProp = allBindings.jqAutoSourceValue,                           // model property to use for the value. ex. 'id' for { id: xx, name: yy }
               inputValueProp = allBindings.jqAutoSourceInputValue || valueProp;    // model property that should be displayed in the input box. ex. 'name' for { id: xx, name: yy }
        
           // if we are writing a different property to the input than we are writing to the model, then locate the object  to get it's property value
           if ( valueProp && inputValueProp !== valueProp ){
               var source = unwrap( allBindings.jqAutoSource ) || [];
               // search source arrary for the spec'd item. ex. item.id == current_item.id. rem: modelValue = null if no match.
               modelValue = ko.utils.arrayFirst( source, function( item ){
                     return unwrap( item[valueProp] ) === modelValue;
               });
           }

           // update the element with the value that should be shown in the input.
           // rem: For our use inputValueProp is: function(item){ return item.pagename().value; } vs. a property name.
            var value = !modelValue ? '' : ( modelValue && inputValueProp !== valueProp ? unwrap( $.isFunction( inputValueProp ) ? inputValueProp( modelValue ) : modelValue[inputValueProp] ) : modelValue.toString() );
            $(element).data( 'originalValue', value );    // use jq .data to save originalValue for value_exists().
            $(element).val( value );
        }
    }

    // Give the jQueryUI Auto-complete the items we want it to display and complete against.
    var ac_matcher = function( req, response ){
        var $elem = $(this.element[0]);
        var source = $elem.data('source');    // Get the auto-complete source. set in ko.bindingHandlers.jqAuto() init() above.
        // console.log( 'ac_matcher req: ', req, ', this:', this, ', source:', source() );

        var re = $.ui.autocomplete.escapeRegex( req.term );   // req.term is the text in the <input> element.
        var matcher = new RegExp( "^" + re, "i" );

        // run the re over the source array returning a new array with items that match
        // and passing that to the response() callback. This is what get's displayed
        // in the auto-complete list.
        var results = $.grep( source(), function( item ){
            return matcher.test( item.value ? item.value : item );     // if source is an array of objects then we match against item.value. See mappedSource() above.
        });
        $elem.trigger( 'matches', [req.term, results.length] );      // inform user as req'd.
        response( results );
    }

   
    // Open the auto-complete drop down list.
    // @param $input is the input element when called for how = click etc. (see above) or undefined for drop-down button click.
    var ac_open = function( $input, how ){
        var drop_down_btn_click = false;
        if ( !$input ){
            var $el_button = $( event.srcElement ).parent();
            $input =  $( $el_button.prev()[0] );
            drop_down_btn_click = true;     // called for drop down button click.
        }
        // console.log( "In ac_open() $input: ", $input, ' how:', how );

       // close if already visible
        if ( $input.autocomplete("widget").is(":visible") ){
            // console.log("close, drop_down_btn_click:", drop_down_btn_click);
            if ( drop_down_btn_click || how == 'click' ){  // don't close for onfocus() triggered by 'drop-down' button click $input.focus() call below.
                $input.autocomplete( "close" );
            }
        }else{
            var minlength = $input.autocomplete( "option", "minLength" );  // save current minLength
            $input.autocomplete( "option", { minLength: 0 } ).autocomplete( "search", "" );   // set minLength to 0 and open drop-down. If we don't set it to 0 and input is blank then button does zip.
                // TODO: highlight current item (if any). 'search' alone shows current item. 'search + ""' shows entire list. 'search + " "' displays zip?
            if ( drop_down_btn_click )
                $input.focus();
            $input.autocomplete( "option", { minLength: minlength } );    // restore
        }
    }

---------------------

nevf

unread,
Feb 5, 2012, 3:30:55 PM2/5/12
to knock...@googlegroups.com
Following on my comment re.  extracting the: var mapped = ko.utils.arrayMap( .. ) . Upon further investigation (profiling) it turned out this wasn't an issue at all as the mapping only occurs when the auto-complete is opened. 

However what was a major performance issue was creating a large number of jQuery Auto-Complete widgets. I have a template that is repeated over 350 times and it includes two auto-complete's. This was taking around 2minutes to render, with the bulk of the time doing CSS stuff while creating  jQuery Auto-Complete widgets. I've updating the code so the auto-complete's are only created when the input field gets focus and is destroyed again when no longer needed. This bought the render time down to < 20 seconds.

------------

Mallegrissimo

unread,
Feb 27, 2012, 3:47:21 PM2/27/12
to knock...@googlegroups.com
Great, would you give an live example?
I got an error, saying $elem.on('matches'....   on is undefined
Reply all
Reply to author
Forward
0 new messages