1
\$\begingroup\$

My goal has been to learn Polymer so I started developing a complex combo-box element.

I did my development on Plunker. Here is link to my plunk.

I'm happy with my .scss file, but I wouldn't be opposed to some constructive criticism there. That being said what I'm mainly looking for though is your thoughts on the following three topics:

  1. Am I structuring my combo-box.html template bindings in a bad way? If so how can I improve? I'm used to using knockout.js so some of my organization may come from my experience with it.

  2. Currently I'm using attributes to take in settings a consumer of combo-box can pass to the JavaScript logic. Is this the best way to achieve a method the user of combo-box can decide whether they want x and not y feature?

  3. Lastly I'm wondering if I could get some opinions on how I organized my code in combo-box.js. I started this learning experience with the idea that I didn't want any external dependencies besides polymer.js of course and the platform.js for polyfills that polymer.js depends on. That being said I structured my code in this basic manner:

    (function(Polymer, undefined) {
    
        // any global to this closure vars and functions
    
        // polyfill functions (this goes along with my goal of no using other libs)
    
        // fn that returns an object (in my mind I think of this like my model/view model
    
        // call into Polymer function and declaration of the element life cycle methods
    
    }(Polymer));
    

The three main points examples have been included below:

combo-box.html

<polymer-element name="combo-box">
    <template>
        <link rel="stylesheet"
            href="combo-box.css">
        <template bind="{{ comboBox }}">
            <div class="comboBoxWrap">
                <template bind="{{ elementAttributes }}">
                    <template if="{{ leftalignedicon }}">
                        <img src="{{ leftalignedicon }}"
                            class="leftAlignedIcon"
                            alt="leftAlignedIcon">
                        <input type="text"
                            class="leftAlignedIconInput"
                            value="{{ value }}"
                            on-focus="{{ toggleOptionsVisibility }}"
                            on-blur="{{ toggleOptionsVisibility }}"
                            on-keydown="{{ optionsInputKeydown }}">
                    </template>
                    <template if="{{ rightalignedicon }}">
                        <img src="{{ rightalignedicon }}"
                            class="rightAlignedIcon"
                            alt="rightAlignedIcon">
                        <input type="text"
                            class="rightAlignedIconInput"
                            value="{{ value }}"
                            on-focus="{{ toggleOptionsVisibility }}"
                            on-blur="{{ toggleOptionsVisibility }}"
                            on-keydown="{{ optionsInputKeydown }}">
                    </template>
                    <template if="{{ !leftalignedicon && !rightalignedicon }}">
                        <input type="text"
                            on-focus="{{ toggleOptionsVisibility }}"
                            on-blur="{{ toggleOptionsVisibility }}"
                            on-keydown="{{ optionsInputKeydown }}"
                            value="{{ value }}">
                    </template>
                </template>
                <template if="{{ showOptions }}">
                    <ul>
                        <template repeat="{{ opt in options }}">
                            <li class="{{ { active: opt.active } | tokenList }}"
                                on-mousedown="{{ optionClick }}"
                                on-mouseover="{{ onHovered }}" 
                                on-mouseout="{{ onUnhovered }}">{{ opt.text }}</li>
                        </template>
                    </ul>
                </template>
            </div>
        </template>
    </template>
    <script src="comboBox.js"></script>
</polymer-element>

combo-box.js

(function(Polymer, undefined) {
    var pxSuffix = 'px';

    function getNodeIndex(node) {
        var index = -1;

        while ((node = node.previousSibling)) {
            if (node.nodeType != 3 || !/^\s*$/.test(node.data)) {
                index++;
            }
        }
        return index;
    }

    function getJSON(url, callback) {
        var request = new XMLHttpRequest();
        request.open('GET', url, true);

        request.onreadystatechange = function() {
            if (this.readyState === 4){
                if (this.status >= 200 && this.status < 400){
                    // Success!
                    callback(JSON.parse(this.responseText));
                }
                else {
                    // Error :(
                    callback(this);
                }
            }
        };

        request.send();
        request = null;
    }

    // constructor function for a new ComboBox
    function ComboBox(customElement) {
        var comboBoxModel = {
            value: '',
            options: [],
            showOptions: false,
            elementAttributes: {}
        };
        var i = 0;
        var currentAttr;
        var request;
        var historyprop = 'history';

        // take the html attributes from customElement
        // and augment them onto our comboBoxModel
        for (i; i < customElement.attributes.length; i++) {
           currentAttr = customElement.attributes[i];
           comboBoxModel.elementAttributes[currentAttr.nodeName] = currentAttr.nodeValue;
        }


        // get the data for our comboBox
        if(typeof comboBoxModel.elementAttributes.history !== 'undefined') {
            if(typeof comboBoxModel.elementAttributes.historyprop !== 'undefined') {
                historyprop = comboBoxModel.elementAttributes.historyprop;
            }
            getJSON(
                comboBoxModel.elementAttributes.history,
                function(responseObj) {
                    var iObj;
                    i = 0;
                    for (i; i < responseObj[historyprop].length; i++) {
                        iObj = responseObj[historyprop][i];
                        iObj.active = false;
                        comboBoxModel.options.push(iObj);
                    }
                }
            );
        }

        return comboBoxModel;
    }


    Polymer('combo-box', {
        created: function() {
            this.comboBox = ComboBox(this);
        },
        ready: function() {
            // I don't love this solution for icons in the input box, but this is what I got
            var shadowRoot = this.shadowRoot;
            var inputEl = shadowRoot.querySelector("input[type='text'");
            var imgEl = shadowRoot.querySelector('img');
            var imgHeight;
            var imgWidth;
            var inputHeight;
            var inputWidth;
            var heightDiffInHalf;

            if(inputEl !== null && imgEl !== null ) {
                imgHeight = imgEl.clientHeight;
                imgWidth = imgEl.clientWidth;
                inputHeight = inputEl.offsetHeight;
                inputWidth = inputEl.offsetWidth;
                heightDiffInHalf = (inputHeight - imgHeight) / 2;

                // set the img height to be the offsetTop of the input and center it within the input
                imgEl.style.top = inputEl.offsetTop + heightDiffInHalf + pxSuffix;

                // Depending on left vs right set the position of the img
                if(this.comboBox.elementAttributes.leftalignedicon || this.comboBox.elementAttributes.rightalignedicon) {
                    if(this.comboBox.elementAttributes.leftalignedicon) {
                        imgEl.style.left = inputEl.offsetLeft + pxSuffix;

                        inputEl.style.paddingLeft = imgWidth + pxSuffix;
                    }

                    if(this.comboBox.elementAttributes.rightalignedicon) {
                        imgEl.style.left = (inputEl.offsetLeft + inputWidth) + pxSuffix;
                        inputEl.style.paddingRight = imgWidth + pxSuffix;
                    }
                }
            }
        },
        attributeChanged: function(attrName, oldVal, newVal) {
            var _newVal = this.getAttribute(attrName);
            console.log('attributeChanged');
            console.log(attrName, 'old: ' + oldVal, 'newVal:', newVal);
            console.log('_newVal: ' + _newVal)
        },
        toggleOptionsVisibility: function(e) {
            // in the docs it says to get the model by doing:
            // var model = e.target.templateInstance.model;
            // BUT this seems to work
            // using self for setTimeout
            var self = this;

            self.comboBox.showOptions = !this.comboBox.showOptions;
        },
        optionsInputKeydown: function(e) {
            var keyCode = e.keyCode || e.which;
            var i = 0;
            var optionsLen = this.comboBox.options.length;
            var activeIndex = -1;

            // find the active option index
            for(i; i < optionsLen; i++) {
                if(this.comboBox.options[i].active === true){
                   activeIndex = i;
                   break;
                }
            }

            if(keyCode === 13) {
                // Enter
                console.log('Option chosen (Enter)... execute user defined callback');
                console.log(activeIndex);
            }
            else if(keyCode === 38) {
                // Up arrow
                if(activeIndex > 0 && activeIndex < optionsLen) {
                    this.comboBox.options[activeIndex].active = false;
                    this.comboBox.options[activeIndex - 1].active = true;
                }
                else {
                    if(activeIndex === 0) {
                        this.comboBox.options[activeIndex].active = false;
                    }
                    this.comboBox.options[optionsLen - 1].active = true;
                }
            }
            else if(keyCode === 40){
                // Down arrow
                if(activeIndex > -1 && activeIndex < (optionsLen - 1)) {
                    this.comboBox.options[activeIndex].active = false;
                    this.comboBox.options[activeIndex + 1].active = true;
                }
                else {
                    if(activeIndex === (optionsLen - 1)) {
                        this.comboBox.options[activeIndex].active = false;
                    }
                    this.comboBox.options[0].active = true;
                }
            }
        },
        onHovered: function(event, detail, sender) {
            var i = 0;
            var hoveredIndex = getNodeIndex(sender);

            for(i; i < this.comboBox.options.length; i++) {
                this.comboBox.options[i].active = false;
            }
            this.comboBox.options[hoveredIndex].active = true;
        },
        onUnhovered: function(event, detail, sender) {
            var hoveredIndex = getNodeIndex(sender);
            this.comboBox.options[hoveredIndex].active = false;
        },
        optionClick: function(event, detail, sender) {
            console.log('Option chosen (Click)... execute user defined callback');
            console.log(getNodeIndex(sender));
        }
    });


}(Polymer));

Example of use/configuration

<!DOCTYPE html>
<html>
    <head>
        <title>combo-box</title>
    </head>
    <body>
        <h1>combo-box</h1>

        <!-- Polymer -->
        <script src="//cdnjs.cloudflare.com/ajax/libs/polymer/0.3.4/platform.js"></script>
        <script src="//cdnjs.cloudflare.com/ajax/libs/polymer/0.3.4/polymer.js"></script>

        <!-- combo-box -->
        <link rel="import" href="combo-box.html">

        <!--
        Attribute Documentation:

        history: A path/url for an ajax request to return correctly formatted JSON
        historyProp: Property name that corresponds to the array of history options
            (Optional: Defaults to 'history')
        leftAlignedIcon: Path/url for an image to use as an icon in the left side of the input box
        rightAlignedIcon: Path/url for an image to use as an icon in the right side of the input box
        -->
        <combo-box history="history.json" historyProp="myHistory" ></combo-box>
        <combo-box history="history.json"
            historyProp="myHistory"
            leftAlignedIcon="https://cdn1.iconfinder.com/data/icons/google_jfk_icons_by_carlosjj/16/search.png">
        </combo-box>
        <combo-box history="history.json"
            historyProp="myHistory"
            rightAlignedIcon="https://cdn2.iconfinder.com/data/icons/snipicons/500/search-16.png">
        </combo-box>
    </body>
</html>
\$\endgroup\$
1
  • \$\begingroup\$ @Vogel612 Edit coming up! \$\endgroup\$ – edhedges Jul 16 '14 at 13:19
1
\$\begingroup\$

Just wanted to let you know that

function getNodeIndex(node) {
    var index = -1;

    while ((node = node.previousSibling)) {
        if (node.nodeType != 3 || !/^\s*$/.test(node.data)) {
            index++;
        }
    }
    return index;
}

Could be

function getNodeIndex(node) {
  return Array.prototype.indexOf.call(e.childNodes, someChildEl);
}

This works on any browser that supports Polymer.

Also this seems odd:

    toggleOptionsVisibility: function(e) {
        // in the docs it says to get the model by doing:
        // var model = e.target.templateInstance.model;
        // BUT this seems to work
        // using self for setTimeout
        var self = this;

        self.comboBox.showOptions = !this.comboBox.showOptions;
    },

Is the comment outdated? This should simply be

    toggleOptionsVisibility: function(e) {
        this.comboBox.showOptions = !this.comboBox.showOptions;
    },

There is some serious copy pastage between // Up arrow and // Down arrow

Finally,from a philosophical perspective, it seems that your code could be much leaner if combobox had an activeIndex property which has the index of the active item. This way you don't need to loop over the elements all the time to activate/de-activate/check activation/ check de-activation etc..

\$\endgroup\$
1
  • 1
    \$\begingroup\$ The toggleOptionsVisibility is in fact out of date. The first two lines of comments are there for myself to remember what the docs say, but I'm getting the same behavior using this. And earlier I had a setTimeout that did some stuff and was using self to be able to access the correct this in the setTimeouts scope. Thank you for the activeIndex idea at the model level I don't know why I didn't think of that... \$\endgroup\$ – edhedges Jul 16 '14 at 15:57

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.