Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

AngularJS Show typeahead on button click

I am using the typeahead directive in AngularJS and it works fine. However, I would like to have a button outside of the input that when clicked would show the typeahead dropdown. Here is a snippet of what I am after...

<li class="input">
   <input focus-me="click" ng-model="something" 
    typeahead="state for state in Suggestions | filter:$viewValue:stateComparator" typeahead-focus typeahead-focus-first="false" typeahead-on-select="updateTagInput(newTagName)">
   <a href="" ng-click="openTypeAhead()">Open</a>
</li>
like image 341
kgalb Avatar asked Jul 22 '15 13:07

kgalb


1 Answers

Ok, I am having an absolutely terrible time trying to create a JSFiddle or even a Plunkr for this, so I will just give you the code for this directive.

This directive originally comes from..

This epic Bootstrap library!

..and I stole it and played with it. If you would like to use it, you will need the "Bootstrap" (its really a subset of angular directives) library that I linked to. You can make your own subset of this library, but I am not entirely sure of all of the dependencies my directive has as I am using the entire library in my project. Basically, you need any directive that starts with "typeahead".

As you can see, I have named the directive wwTypeahead (that "ww" is for WebWanderer!). It is a very easy to use directive and it works just like the original.

<input 
    class="form-control" 
    type="text" 
    spellcheck="false" 
    ng-model="selection" 
    ng-trim="false" 
    placeholder="Search Here" 
    ww-typeahead="key as key.label for key in list" 
    typeahead-on-select="selectionMade($item, $model, $label)" 
    typeahead-min-length="0" 
/>

The really important part to note is the attribute typeahead-min-length="0" which has really been the heart of many discussions online. I managed to make that work.

This directive is meant to take the place of the typeahead directive in the library I linked to. Your typeahead list will be shown on focus of your input box. No, the list does not show on the click of a button, but hopefully getting there will be baby-steps from here. If you need help implementing that, I will be happy to help.

/*
    NOTE:

    The following directive is a modification of the
    Angular typeahead directive. The normal directives,
    unfortunately, do not allow matching on 0 length values
    and the user may want a returned list of all values during
    the lack of input.

    This directives was taken from ... 

        http://angular-ui.github.io/bootstrap/  

    ..and modified.
*/
angular.module('ui.directives', []).directive('wwTypeahead', ['$compile', '$parse', '$q', '$timeout', '$document', '$position', 'typeaheadParser',
function($compile, $parse, $q, $timeout, $document, $position, typeaheadParser)
{
    var HOT_KEYS = [9, 13, 27, 38, 40];

    return {
        require:'ngModel',
        link:function(originalScope, element, attrs, modelCtrl)
        {
            //SUPPORTED ATTRIBUTES (OPTIONS)

            //minimal no of characters that needs to be entered before typeahead kicks-in
            //var minSearch = originalScope.$eval(attrs.typeaheadMinLength) || 1;
            var testEval = originalScope.$eval(attrs.typeaheadMinLength);
            var minSearch = !isNaN(parseFloat(testEval)) && isFinite(testEval) || 1;

            //minimal wait time after last character typed before typehead kicks-in
            var waitTime = originalScope.$eval(attrs.typeaheadWaitMs) || 0;

            //should it restrict model values to the ones selected from the popup only?
            var isEditable = originalScope.$eval(attrs.typeaheadEditable) !== false;

            //binding to a variable that indicates if matches are being retrieved asynchronously
            var isLoadingSetter = $parse(attrs.typeaheadLoading).assign || angular.noop;

            //a callback executed when a match is selected
            var onSelectCallback = $parse(attrs.typeaheadOnSelect);

            var inputFormatter = attrs.typeaheadInputFormatter ? $parse(attrs.typeaheadInputFormatter) : undefined;

            //INTERNAL VARIABLES

            //model setter executed upon match selection
            var $setModelValue = $parse(attrs.ngModel).assign;

            //expressions used by typeahead
            var parserResult = typeaheadParser.parse(attrs.cmcTypeahead);


            //pop-up element used to display matches
            var popUpEl = angular.element('<typeahead-popup></typeahead-popup>');
            popUpEl.attr({
                matches: 'matches',
                active: 'activeIdx',
                select: 'select(activeIdx)',
                query: 'query',
                position: 'position'
            });
            //custom item template
            if(angular.isDefined(attrs.typeaheadTemplateUrl))
            {
                popUpEl.attr('template-url', attrs.typeaheadTemplateUrl);
            }

            //create a child scope for the typeahead directive so we are not polluting original scope
            //with typeahead-specific data (matches, query etc.)
            var scope = originalScope.$new();
            originalScope.$on('$destroy', function()
            {
                scope.$destroy();
            });

            var resetMatches = function()
            {
                scope.matches = [];
                scope.activeIdx = -1;
            };

            var getMatchesAsync = function(inputValue)
            {
                var matchParsePrefix = originalScope.$eval(attrs.typeaheadParsePrefix);
                var locals = {
                    $viewValue: inputValue.indexOf(matchParsePrefix) === 0 ? inputValue.substring(matchParsePrefix.length, (inputValue.length + 1)) : inputValue
                };
                isLoadingSetter(originalScope, true);
                $q.when(parserResult.source(scope, locals)).then(function(matches)
                {
                    //it might happen that several async queries were in progress if a user were typing fast
                    //but we are interested only in responses that correspond to the current view value
                    //if(matches && inputValue === modelCtrl.$viewValue)

                    /*
                        Ehh.. that didn't seem to work when I "cleared" the input box
                    */
                    if(matches)
                    {
                        if(matches.length > 0)
                        {
                            scope.activeIdx = 0;
                            scope.matches.length = 0;

                            //transform labels
                            for(var i = 0; i < matches.length; i++)
                            {
                                locals[parserResult.itemName] = matches[i];
                                scope.matches.push({
                                    label: parserResult.viewMapper(scope, locals),
                                    model: matches[i]
                                });
                            }

                            scope.query = inputValue;
                            //position pop-up with matches - we need to re-calculate its position each time we are opening a window
                            //with matches as a pop-up might be absolute-positioned and position of an input might have changed on a page
                            //due to other elements being rendered
                            scope.position = $position.position(element);
                            scope.position.top = scope.position.top + element.prop('offsetHeight');

                        }
                        else if(minSearch === 0)
                        {
                            resetMatches();//temp
                        }
                        else
                        {
                            resetMatches();
                        }
                        isLoadingSetter(originalScope, false);
                    }
                }, function()
                {
                    resetMatches();
                    isLoadingSetter(originalScope, false);
                });
            };

            resetMatches();

            /*
                Can't figure out how to make this work...*/
            if(attrs.hasOwnProperty('typeaheadBindMatchReloader'))
            {
                $parse(attrs.typeaheadBindMatchReloader).assign(scope, function()
                {
                    getMatchesAsync(element[0].value);
                });
            }




            //we need to propagate user's query so we can higlight matches
            scope.query = undefined;

            //Declare the timeout promise var outside the function scope so that stacked calls can be cancelled later 
            var timeoutPromise;

            //plug into $parsers pipeline to open a typeahead on view changes initiated from DOM
            //$parsers kick-in on all the changes coming from the view as well as manually triggered by $setViewValue
            modelCtrl.$parsers.unshift(function(inputValue)
            {
                resetMatches();
                if((inputValue && inputValue.length >= minSearch)
                || minSearch === 0)
                {
                    if(waitTime > 0)
                    {
                        if(timeoutPromise)
                        {
                            $timeout.cancel(timeoutPromise);//cancel previous timeout
                        }

                        timeoutPromise = $timeout(function()
                        {
                            getMatchesAsync(inputValue);
                        }, waitTime);
                    }
                    else
                    {
                        getMatchesAsync(inputValue);
                    }
                }

                if(isEditable)
                {
                    return inputValue;
                }
                else
                {
                    modelCtrl.$setValidity('editable', false);
                    return undefined;
                }
            });

            modelCtrl.$formatters.push(function(modelValue)
            {
                var candidateViewValue, emptyViewValue;
                var locals = {};

                if(inputFormatter)
                {
                    locals['$model'] = modelValue;
                    return inputFormatter(originalScope, locals);
                }
                else
                {
                    //it might happen that we don't have enough info to properly render input value
                    //we need to check for this situation and simply return model value if we can't apply custom formatting
                    locals[parserResult.itemName] = modelValue;
                    candidateViewValue = parserResult.viewMapper(originalScope, locals);
                    locals[parserResult.itemName] = undefined;
                    emptyViewValue = parserResult.viewMapper(originalScope, locals);

                    return candidateViewValue!== emptyViewValue ? candidateViewValue : modelValue;
                }
            });

            scope.select = function(activeIdx)
            {
                //called from within the $digest() cycle
                var locals = {};
                var model, item;

                locals[parserResult.itemName] = item = scope.matches[activeIdx].model;
                model = parserResult.modelMapper(originalScope, locals);
                $setModelValue(originalScope, model);
                modelCtrl.$setValidity('editable', true);

                onSelectCallback(originalScope, {
                    $item: item,
                    $model: model,
                    $label: parserResult.viewMapper(originalScope, locals)
                });

                resetMatches();

                //return focus to the input element if a mach was selected via a mouse click event
                element[0].focus();
            };

            //bind keyboard events: arrows up(38) / down(40), enter(13) and tab(9), esc(27)
            element.bind('keydown', function(evt)
            {
                //typeahead is open and an "interesting" key was pressed
                if(scope.matches.length === 0 || HOT_KEYS.indexOf(evt.which) === -1)
                    return;

                evt.preventDefault();

                if(evt.which === 40)
                {
                    scope.activeIdx = (scope.activeIdx + 1) % scope.matches.length;
                    scope.$digest();
                }
                else if(evt.which === 38)
                {
                    scope.activeIdx = (scope.activeIdx ? scope.activeIdx : scope.matches.length) - 1;
                    scope.$digest();
                }
                else if(evt.which === 13 || evt.which === 9)
                {
                    scope.$apply(function()
                    {
                        scope.select(scope.activeIdx);
                    });
                }
                else if(evt.which === 27)
                {
                    evt.stopPropagation();
                    resetMatches();
                    scope.$digest();
                }
            });

            // Keep reference to click handler to unbind it.
            var dismissClickHandler = function(evt)
            {
                if(element[0] !== evt.target)
                {
                    resetMatches();
                    scope.$digest();
                }
                else
                {
                    getMatchesAsync(element[0].value);
                }
            };

            $document.bind('click', dismissClickHandler);

            originalScope.$on('$destroy', function()
            {
                $document.unbind('click', dismissClickHandler);
            });

            element.after($compile(popUpEl)(scope));
        }
    };
}]);

Call To Action:

Somebody PLEASE make a working example of this typeahead directive! I would forever be in debt to you! (well, not really but it would make me very happy)

DISCLAIMER:

I understand that this answer is in no way orthodox. I did not provide the askee (askee?) with a direct answer to the question, yet I did provide the tools that I believe are needed to get to his/her answer. I understand that I should spend the time to make a working example, but I am a very busy man and simply wished to share my work with the community, as I have seen this question asked too many times while I sit back and hold the answer. Please let me know if you have any issues, questions, or complications. I am happy to help.

Thanks!

like image 108
WebWanderer Avatar answered Sep 21 '22 16:09

WebWanderer