JavaScript
vue.js
Materialize
vue.js2

【Vue.js 2.0】コンポーネントの作り方(Materialize.css利用)

More than 1 year has passed since last update.

いまさらですが、Vue.jsのコンポーネントの作り方調べたので記事書きます。
今回は、Vue.js 2.0・Materializeを使って実装します。

コンポーネントを作る

コンポーネントの基本的な作り方はこちらを参考にしてください。(英語。日本語のページは2.0に追い付いていない様子)
https://vuejs.org/v2/guide/components.html

ざっくり、下記でコンポーネントが作れます。

  • template定義
  • props定義
  • コンポーネント登録 (Vue.component('my-component',

Button

手始めに下記のButtonをコンポーネントとして作ります。
http://materializecss.com/buttons.html

button.js
    var Button = Vue.extend({
        props: {
            id: String,
            href: String,
            icon: String,
            flat: Boolean,
            floating: Boolean,
            'class': String,
            disabled: Boolean,
            tabindex: {
                type: Number,
                default: 0,
            },
        },
        template: '<a ' +
                    ':id="id" ' +
                    ':class="classNames" ' +
                    ':href="href" ' +
                    'v-on:click="onClick" ' +
                    'v-on:keyup.enter="onClick"' +
                    ':tabindex="disabled ? -1 : tabindex"' +
                    ':disabled=" disabled ? \'disabled\' : null "' +
                    ' >' +
                    '<i v-if="icon" class="material-icons left">{{icon}}</i>' +
                    '<slot><!-- ラベル--></slot>' +
                '</a>',
        methods: {
            onClick: function(e) {
                if (this.$el.hasAttribute('disabled')) {
                    return;
                }
                this.$emit('click', e);
            }
        },
        computed: {
            classNames: function() {
                var classes = {
                    'waves-effect': true,
                    'waves-light': !this.flat,
                    'btn': !this.flat && !this.floating,
                    'btn-flat': this.flat,
                    'btn-floating': this.floating,
                };
                if (this.class) {
                    classes[this.class] = true;
                }

                return classes;
            },
        },
    });
    Vue.component('m-button', Button);

説明

materializecssのbuttonをテンプレートにして、ちょっとしたOption(icon・flat・floating)を属性で指定できるようにしました。
あとは、disabledでclickが伝播されないようにしたり、enterでclick動作をさせたりといった感じです。

  • icon
    下記の実装で制御しています。

    '<i v-if="icon" class="material-icons left">{{icon}}</i>' +
    

    v-if でicon指定があれば i タグが現れるようにしています。
    これでicon属性を指定すればmaterial-iconsがleftで表示されるようになります。

  • flat・floating
    下記の実装で制御しています。

            ':class="classNames" ' +
    
    //・・・
    
    classNames: function() {
        var classes = {
            'waves-effect': true,
            'waves-light': !this.flat,
            'btn': !this.flat && !this.floating,
            'btn-flat': this.flat,
            'btn-floating': this.floating,
        };
        //・・・・
    
        return classes;
    },
    

    flatfloating の指定によってどのclassを指定するかを制御しています。

  • click伝播とdisabled

    'v-on:click="onClick" ' +
    

    aタグをclickしたらonClickをcallさせます。

    methods: {
        onClick: function(e) {
            if (this.$el.hasAttribute('disabled')) {
                return;
            }
            this.$emit('click', e);
        }
    },
    

    onClick内でdisabledがあれば終了。
    disabledが無ければthis.$emit('click', e);でclickを伝播させます。
    これでコンポーネントを使うとき属性にv-on:clickをつけることでイベントをフックできるようになります。

利用

利用側.html
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Cache-Control" content="no-cache" />
<meta http-equiv="expires" content="0" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta http-equiv="content-script-type" content="text/javascript" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no" />

<title>Vueでコンポーネント作るよ</title>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.97.8/css/materialize.min.css" rel="stylesheet" />

<script type="text/javascript" src="https://code.jquery.com/jquery-2.2.4.js" ></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.97.8/js/materialize.js"></script>
<script type="text/javascript" src="https://npmcdn.com/vue/dist/vue.js"></script>

<script type="text/javascript" src="button.js"></script>

</head>
<body>
    <div id="main">
        <div class="row">
            <div class="col s12 center"><h4>Buttons</h4></div>
            <div class="col s12 m6 l3 center">
                <m-button v-on:click="onButtonClick" >CAPTION</m-button>
            </div>
            <div class="col s12 m6 l3 center">
                <m-button icon="thumb_up" v-on:click="onButtonClick" >ICON</m-button>
            </div>
            <div class="col s12 m6 l3 center">
                <m-button flat v-on:click="onButtonClick" >flat</m-button>
            </div>
            <div class="col s12 m6 l3 center">
                floating
                <m-button floating icon="thumb_up" v-on:click="onButtonClick" >floating</m-button>
            </div>
            <div class="col s12 m6 l3 center">
                <m-button disabled v-on:click="onButtonClick" >disabled</m-button>
            </div>
            <div class="col s12 m6 l3 center">
                <m-button class="blue" v-on:click="onButtonClick" >class</m-button>
            </div>
        </div>
    </div>
    <script type="text/javascript" >
    /**
     * アプリケーション本体
     */
    new Vue({
        el: '#main',
        methods: {
            onButtonClick: function() {
                alert('click!!');
            },
        },
    });
    </script>
</body>
</html>

できあがるのは下図のような形
clickするとalert('click!!');が呼ばれる。

image

Dropdown

次はDropdownをコンポーネントとして作ります。
http://materializecss.com/dropdown.html

    //http://qiita.com/coa00@github/items/679b0b5c7c468698d53f
    function getUniqueStr(myStrong) {
        var strong = 1000;
        if (myStrong) { strong = myStrong; }
        return new Date().getTime().toString(16) + Math.floor(strong * Math.random()).toString(16);
    }

    var DropdownButton = Button.extend({
        props: {
            dropdownId: {
                type: String,
                default: function() {
                    return getUniqueStr() + '-dropitems';
                },
            },
        },
        template: '<div>' +
                    '<a ' +
                        ':id="id" ' +
                        'ref="button" ' +
                        ':class="classNames" ' +
                        ':tabindex="disabled ? -1 : tabindex"' +
                        ':disabled=" disabled ? \'disabled\' : null "' +
                        ':data-activates="dropdownId" ' +
                        ' >' +
                        '<i v-if="icon" class="material-icons left">{{icon}}</i>' +
                        '<slot name="label" ><!-- ラベル--></slot>' +
                    '</a>' +
                    '<ul ref="dropdown" :id="dropdownId" class="dropdown-content" >' +
                        '<slot ><!-- Dropdowns--></slot>' +
                    '</ul>' +
                '</div>',
        mounted: function() {
            $(this.$refs.button).dropdown();
        },
        methods: {
            open: function() {
                $(this.$refs.button).dropdown('open');
            },
            close: function() {
                $(this.$refs.button).dropdown('close');
            },
        },
    });
    var DropItem = Vue.extend({
        props: {
            id: String,
            divider: Boolean,
            href: String,
        },
        template: '<li :id="id" :class="classNames">' +
                    '<a ' +
                        'v-if="!divider" ' +
                        'ref="anchor" ' +
                        ':href="href" ' +
                        'v-on:click="onClick"' +
                        '>' +
                        '<slot ><!-- ラベル--></slot>' +
                    '</a>' +
                '</li>',
        methods: {
            onClick: function(e) {
                this.$emit('click', e);
            }
        },
        computed: {
            classNames: function() {
                return {
                    divider: this.divider,
                };
            },
        },
    });
    Vue.component('m-dropdown-button', DropdownButton);
    Vue.component('m-dropitem', DropItem);

説明

今回はdropdow-buttonとdropitemで分けました。

あと、DropdownButtonは先に作成したButtonを継承しています。

var DropdownButton = Button.extend({
  • dropdownId
    buttonのaタグとdropdownのulタグを紐づけるためのidです。
    指定がないときのために乱数をはめてます。
    乱数ロジックはこちらを参考にしました。

    //http://qiita.com/coa00@github/items/679b0b5c7c468698d53f
    function getUniqueStr(myStrong) {
        var strong = 1000;
        if (myStrong) { strong = myStrong; }
        return new Date().getTime().toString(16) + Math.floor(strong * Math.random()).toString(16);
    }
    
    //・・・・
        props: {
            dropdownId: {
                type: String,
                default: function() {
                    return getUniqueStr() + '-dropitems';
                },
            },
    
  • mountedでdropdown化
    materializecssでdropdown化するには$(hoge).dropdown();を呼ばないといけません。
    Vue.js2のmountedのときにこれを呼び出します。

        mounted: function() {
            $(this.$refs.button).dropdown();
        },
    

利用

利用側.html
        <div class="row">
            <div class="col s12 center"><h4>Dropdown Buttons</h4></div>
            <div class="col s12 m6 l3 center">
                <m-dropdown-button>
                    <span slot="label">CAPTION</span>
                    <m-dropitem v-on:click="onButtonClick" >Item1</m-dropitem>
                    <m-dropitem divider ></m-dropitem>
                    <m-dropitem v-on:click="onButtonClick" >Item2</m-dropitem>
                </m-dropdown-button>
            </div>
            <div class="col s12 m6 l3 center">
                <m-dropdown-button icon="thumb_up" >
                    <span slot="label">ICON</span>
                    <m-dropitem v-on:click="onButtonClick" >Item1</m-dropitem>
                    <m-dropitem divider ></m-dropitem>
                    <m-dropitem v-on:click="onButtonClick" >Item2</m-dropitem>
                </m-dropdown-button>
            </div>
            <div class="col s12 m6 l3 center">
                floating
                <m-dropdown-button floating icon="thumb_up" >
                    <m-dropitem v-on:click="onButtonClick" >Item1</m-dropitem>
                    <m-dropitem divider ></m-dropitem>
                    <m-dropitem v-on:click="onButtonClick" >Item2</m-dropitem>
                </m-dropdown-button>
            </div>
        </div>

image
image

Input

次はInputです。
http://materializecss.com/forms.html

    //Materialize.updateTextFields()を最後の1回だけ実行する
    var updateTextFieldsTimeoutId;
    var updateTextFields = function() {
        if (updateTextFieldsTimeoutId) {
            clearTimeout(updateTextFieldsTimeoutId);
        }
        updateTextFieldsTimeoutId = setTimeout(function() {
            Materialize.updateTextFields();
        }, 100);
    };
    var isNumber = function(s) {
        if ('' === s) {
            return true;
        }
        return !isNaN(s - 0);
    };

    var numseparate = function(num) {
        if (isNumber(num)) {
            num = num + '';
            var spl = num.split('.');
            spl[0] = spl[0].replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,');

            return 1 === spl.length ? spl[0] : spl[0] + '.' + spl[1];
        } else {
            return num;
        }
    };
    var numunseparate = function(numstr) {
        if ('' === numstr) {
            return '';
        }
        var num = (numstr + '').trim().replace(/,/g, '') - 0;
        return isNaN(num) ? numstr : num;
    };

    var Input = Vue.extend({
        props: {
            id: String,
            value: {
                type: [String, Number],
                default: ''
            },
            label: String,
            icon: String,
            placeholder: String,
            required: Boolean,
            readonly: Boolean,
            type: {
                type: String,
                default: 'text',
            },
        },
        // https://vuejs.org/v2/guide/components.html#Form-Input-Components-using-Custom-Events
        template: '<div class="input-field" >' +
                    '<i v-if="icon" class="material-icons prefix">{{icon}}</i>' +
                    '<input ' +
                        ':id="id" ' +
                        'ref="input" ' +
                        ':type="type === \'number\' ? \'text\' : type" ' +
                        ':placeholder="placeholder" ' +
                        ':required="required ? \'true\' : null" ' +
                        ':aria-required="required ? \'true\' : null" ' +
                        ':readonly="readonly ? \'true\' : null" ' +
                        ':class="inputClassNames" ' +
                        'v-on:input="updateValue($event.target.value)" ' +
                        'v-on:blur="onBlur" ' +
                        'v-on:focus="onFocus" ' +
                        '/>' +
                    '<label ref="label" :for="id" >{{label}}</label>' +
                '</div>',

        watch: {
            value: function(val, oldVal) {
                if (document.activeElement === this.$refs.input) {
                    return;
                }
                this.$refs.input.value = this.valueToDisplay(val);
            },
        },
        methods: {
            updateValue: function(value) {
                this.$emit('input', this.inputToValue(value));
            },
            onBlur: function(e) {
                this.blurChangeInputValue(this.$refs.input);
                this.$emit('blur', e);
            },
            onFocus: function(e) {
                this.focusChangeInputValue(this.$refs.input);
                this.$emit('focus', e);
            },
            validate: function() {
                var value = this.inputToValue(this.$refs.input.value);

                if (this.required && (null === value || 'undefined' === typeof value || '' === value)) {
                    this.setInvalid('empty');
                    return false;
                }
                if ('number' === this.type && !isNumber(value)) {
                    this.setInvalid('not a number');
                    return false;
                }
                this.$refs.input.classList.remove('invalid');
                this.$emit('validate', value, this);
                return this.$refs.input.classList.contains('invalid');
            },
            setInvalid: function(msg) {
                if (msg) {
                    this.$refs.label.setAttribute('data-error', msg);
                    this.$refs.label.classList.add('active');
                    this.$refs.input.classList.add('invalid');
                }
            },
            valueToDisplay: function(value) {
                if ('number' === this.type) {
                    return numseparate(value);
                }
                return value;
            },
            inputToValue: function(value) {
                if ('number' === this.type) {
                    return numunseparate(value);
                }
                return value;
            },
            blurChangeInputValue: function(input) {
                if ('number' === this.type) {
                    var num = input.value.trim() - 0;
                    if (!isNaN(num)) {
                        input.value = numseparate(num);
                    }
                }
            },
            focusChangeInputValue: function(input) {
                if ('number' === this.type) {
                    input.value = this.inputToValue(input.value);
                }
            },
        },
        mounted: function() {
            this.$refs.input.value = this.valueToDisplay(this.value);
            updateTextFields();
            //materializeのvalidationに無理やり乗せる
            this.$refs.input['$ validate $'] = this.validate.bind(this);

            //なぜか再レンダリングされるので抑制(良い方法がわからない。1度再レンダリングさせると起きなくなったりするので、1回だけ再レンダリングさせる)
            setTimeout(function() {
                var val = this.value;
                this.$emit('input', val + '-');//適当な値に変更
                this.$emit('input', val);
            }.bind(this), 100);
        },
        computed: {
            inputClassNames: function() {
                return {
                    'validate': true,
                    'right-align': 'number' === this.type,
                };
            },
        },
    });
    Vue.component('m-input', Input);

    $(document).ready(function() {
        //materializeのvalidationを無理やり拡張する
        var origValidateField = window.validate_field;
        window.validate_field = function($obj) { //eslint-disable-line camelcase
            origValidateField($obj);

            if ($obj[0]['$ validate $']) {
                $obj[0]['$ validate $']();
            }
        };
    });

説明

(なんだこれ?的な記述の説明は後回しです。)

  • v-modelを利用できるようにする
    こちらに記載があります。
    https://vuejs.org/v2/guide/components.html#Form-Input-Components-using-Custom-Events
    valueのプロパティを用意するとv-modelで指定された実際の値が取り出せ、
    値の変更時にthis.$emit('input', 新規値)をcallすることでmodelに値を返せるようです。

    ただ今回type=numberの場合にカンマ区切りにするということがやりたかったので、少し大変な目にあいました。

    難しいことしないのであれば下記のような記述で問題ないと思います。

       v-bind:value="value"\
       v-on:input="$emit(\'input\', $event.target.value)"\
    

    一応、処理上の注意ですが、v-modelが外部から指定されない時、$emit('input'・・しても、プロパティvalueの値は変わらないようなので、mountedwatch.value以外は常にinput.valueから値を取得して処理するのが良さげです。

    //var value = this.value; これだと値が変わってないことがあるのでまずい。
    var value = this.$refs.input.value;
    
  • numberとカンマ区切り
    (全然materializecss関係ないです)
    このコンポーネントはtype="number"の場合、数値をカンマ区切りするコンポーネントに変わります。
    カンマ区切りにするというのもあり、':type="type === \'number\' ? \'text\' : type" ' +の記述で、
    内部ではtype="text"に戻しています。

    カンマ区切りにするためにinputに直接値をbindできないのでvalueToDisplayメソッドを通します。
    また、inputにフォーカス中はカンマ区切りを解除するというのもあり、
    v-bind:value="valueToDisplay(value)"にしてしまうと、入力中にmodelから再bindされるとき、入力中なのにカンマ区切りに戻るという事象が発生するので、
    input値のセットは、mountedwatch.valueで行うことにしました。watch.valueはフォーカス中であればinputの値を書き換えないという制御を入れています。

    var numseparate = function(num) {
        if (isNumber(num)) {
            num = num + '';
            var spl = num.split('.');
            spl[0] = spl[0].replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,');
    
            return 1 === spl.length ? spl[0] : spl[0] + '.' + spl[1];
        } else {
            return num;
        }
    };
    //・・・・
        watch: {
            value: function(val, oldVal) {
                if (document.activeElement === this.$refs.input) {
                    return;
                }
                this.$refs.input.value = this.valueToDisplay(val);
            },
        },
    //・・・・
            valueToDisplay: function(value) {
                if ('number' === this.type) {
                    return numseparate(value);
                }
                return value;
            },
    //・・・・
        mounted: function() {
            this.$refs.input.value = this.valueToDisplay(this.value);
    

    あとはフォーカスしたらカンマ区切りが外れたり、フォーカス抜けたらカンマ区切りに戻したりしてます。

  • Materialize.updateTextFields()
    Materialize.updateTextFields()はdocument全体にかかる方法しかないようで、何度も呼びだしたくないので、
    遅延させて最後の1回だけcallさせるようにしました。

    アプリ側から任意のタイミングで呼び出せるならこの記述はいらないと思いますが、コンポーネントとしては、「アプリで書く」というルールは微妙なので、mountedから呼びだすようにしています。

    var updateTextFieldsTimeoutId;
    var updateTextFields = function() {
        if (updateTextFieldsTimeoutId) {
            clearTimeout(updateTextFieldsTimeoutId);
        }
        updateTextFieldsTimeoutId = setTimeout(function() {
            Materialize.updateTextFields();
        }, 100);
    };
    //・・・
        mounted: function() {
            this.$refs.input.value = this.valueToDisplay(this.value);
            updateTextFields();
    
  • validation
    Materializeのvalidationに乗せて独自のvalidationをかけたかったので黒魔術で対応しました。

        mounted: function() {
            //・・・
            //materializeのvalidationに無理やり乗せる
            this.$refs.input['$ validate $'] = this.validate.bind(this);
    
    //・・・
    $(document).ready(function() {
        //materializeのvalidationを無理やり拡張する
        var origValidateField = window.validate_field;
        window.validate_field = function($obj) { //eslint-disable-line camelcase
            origValidateField($obj);
    
            if ($obj[0]['$ validate $']) {
                $obj[0]['$ validate $']();
            }
        };
    });
    

    input要素にvalidateメソッドを乗せて、materializeから呼ばれる、window.validate_fieldを上書きし、
    input要素に乗せたvalidateメソッドを実行するようにしました。

    これで、必須チェックや数値チェック、またv-on:validateでvalidateを拡張することができるようになりました。

  • 悩み
    下記部分の上手い回避方法がわからず悩んでいます。

            //なぜか再レンダリングされるので抑制(良い方法がわからない。1度再レンダリングさせると起きなくなったりするので、1回だけ再レンダリングさせる)
            setTimeout(function() {
                var val = this.value;
                this.$emit('input', val + '-');//適当な値に変更
                this.$emit('input', val);
            }.bind(this), 100);
    

利用

利用側.html
        <div class="row">
            <div class="col s12 center"><h4>Inputs</h4></div>
            <div class="col s12 m6 l3" style="height: 120px;">
                <m-input label="input" v-model="inputValue1" ></m-input>
                {{inputValue1}}
            </div>
            <div class="col s12 m6 l3" style="height: 120px;">
                <m-input placeholder="placeholder" label="placeholder" v-model="inputValue2" ></m-input>
                {{inputValue2}}
            </div>
            <div class="col s12 m6 l3" style="height: 120px;">
                <m-input type="number" label="number" v-model="inputValue3" ></m-input>
                {{inputValue3}}
            </div>
            <div class="col s12 m6 l3" style="height: 120px;">
                <m-input required label="required" v-model="inputValue4" ></m-input>
                {{inputValue4}}
            </div>
            <div class="col s12 m6 l3" style="height: 120px;">
                <m-input readonly label="readonly" v-model="inputValue5" ></m-input>
            </div>
            <div class="col s12 m6 l3" style="height: 120px;">
                <m-input icon="account_circle" label="icon" v-model="inputValue6" ></m-input>
                {{inputValue6}}
            </div>
            <div class="col s12 m6 l3" style="height: 120px;">
                <m-input label="validate" v-model="inputValue7" v-on:validate="validate" ></m-input>
                {{inputValue7}}
            </div>
        </div>
    /**
     * アプリケーション本体
     */
    new Vue({
        el: '#main',
        data: {
            inputValue1: '',
            inputValue2: '',
            inputValue3: '1234',
            inputValue4: '',
            inputValue5: '読み取り専用',
            inputValue6: '',
            inputValue7: '',
        },
        methods: {
            validate: function(val, input) {
                input.setInvalid('エラーメッセージ');
            },
        },
    });

image
image

Datepicker

    var timezoneOffsetMs = new Date().getTimezoneOffset() * 60000;
    var localToUTC = function(date) {
        if (!date) {
            return date;
        }
        var utc = date.getTime() - timezoneOffsetMs;
        return new Date(utc);
    };
    var utcToLocal = function(utc) {
        if (!utc) {
            return utc;
        }
        var local = utc.getTime() + timezoneOffsetMs;
        return new Date(local);
    };
    var utcFormat = function(date, format) {
        if (!date) {
            return '';
        }
        if (isNaN(date)) {
            return '';
        }
        if (!format) {
            return date.toISOString();
        } else {
            //参考:http://qiita.com/osakanafish/items/c64fe8a34e7221e811d0
            format = format.replace(/yyyy/g, date.getUTCFullYear());
            format = format.replace(/MM/g, ('0' + (date.getUTCMonth() + 1)).slice(-2));
            format = format.replace(/dd/g, ('0' + date.getUTCDate()).slice(-2));
            format = format.replace(/hh/g, ('0' + date.getUTCHours()).slice(-2));
            format = format.replace(/mm/g, ('0' + date.getUTCMinutes()).slice(-2));
            format = format.replace(/ss/g, ('0' + date.getUTCSeconds()).slice(-2));
            if (format.match(/S/g)) {
                var milliseconds = ('00' + date.getUTCMilliseconds()).slice(-3);
                var length = format.match(/S/g).length;
                for (var i = 0; i < length; i++) {
                    format = format.replace(/S/, milliseconds.substring(i, i + 1));
                }
            }
            return format;
        }
    };
    var dateparse = function(str) {
        return utcToLocal(new Date(str));
    };
    var dateformat = function(date, format) {
        return utcFormat(localToUTC(date), format);
    };
    var Datepicker = Input.extend({
        props: {
            value: {
                type: Date,
                default: function() {
                    var d = new Date();
                    return new Date(d.getFullYear(), d.getMonth(), d.getDate());
                },
            },
        },
        template: '<div class="input-field" >' +
                    '<input ' +
                        ':id="id" ' +
                        'ref="input" ' +
                        'type="text" ' +
                        ':placeholder="placeholder" ' +
                        ':required="required ? \'true\' : null" ' +
                        ':aria-required="required ? \'true\' : null" ' +
                        ':readonly="readonly ? \'true\' : null" ' +
                        ':class="inputClassNames" ' +
                        'v-on:input="updateValue($event.target.value)" ' +
                        'v-on:blur="onBlur" ' +
                        'v-on:focus="onFocus" ' +
                        '/>' +
                    '<label ref="label" :for="id" >{{label}}</label>' +
                '</div>',
        watch: {
            readonly: function(val) {
                var picker = $(this.$el).find('.datepicker').pickadate('picker');
                if (val) {
                    picker.stop();
                    this.$refs.input.setAttribute('readonly', true);
                } else {
                    picker.start();
                    this.$refs.input.removeAttribute('readonly', true);
                }
            },
        },
        methods: {
            valueToDisplay: function(value) {
                if (!value) {
                    return '';
                }
                return dateformat(value, 'yyyy/MM/dd');
            },
            inputToValue: function(value) {
                if (!value) {
                    return null;
                }
                return dateparse(value);
            },
            blurAdjustInput: function(input) {
                //nop
            },
            focusAdjustInput: function(input) {
                //nop
            },
        },
        mounted: function() {
            var picker = $(this.$el).find('.datepicker').pickadate({
                selectMonths: true, // Creates a dropdown to control month
                selectYears: 15 // Creates a dropdown of 15 years to control year
            }).pickadate('picker');
            picker.on('set', function(e) {
                var d = new Date(e.select);
                if (isNaN(d)) {
                    d = null;
                }
                this.$refs.input.value = this.valueToDisplay(d);
                this.$emit('input', d);
            }.bind(this));
            this.$el.appendChild(picker.$root[0]);
            picker.set('select', this.value ? this.value.getTime() : new Date(NaN));

            if (this.readonly) {
                picker.stop();
                this.$refs.input.setAttribute('readonly', true);
            }
        },
        computed: {
            inputClassNames: function() {
                var classNames = Input.options.computed.inputClassNames.call(this);
                classNames.datepicker = true;
                return classNames;
            },
        },
    });

    Vue.component('m-datepicker', Datepicker);

説明

Input継承して作りました。
あとはpickadate.jsのAPI調べながら頑張ってます。

Dateのフォーマットロジックはこちら参考にさせていただきました。

利用

利用側.html
        <div class="row">
            <div class="col s12 center"><h4>Datepickers</h4></div>
            <div class="col s12 m6 l3" style="height: 120px;">
                <m-datepicker label="datepicker" v-model="datepickerValue1" ></m-datepicker>
                {{datepickerValue1 | date.format('yyyy年MM月dd日')}}
            </div>
            <div class="col s12 m6 l3" style="height: 120px;">
                <m-datepicker label="init null value" v-model="datepickerValue2" ></m-datepicker>
                {{datepickerValue2 | date.format('yyyy年MM月dd日')}}
            </div>
            <div class="col s12 m6 l3" style="height: 120px;">
                <m-datepicker required label="required" v-model="datepickerValue3" ></m-datepicker>
                {{datepickerValue3 | date.format('yyyy年MM月dd日')}}
            </div>
            <div class="col s12 m6 l3" style="height: 120px;">
                <m-datepicker readonly label="readonly" v-model="datepickerValue4" ></m-datepicker>
            </div>
        </div>
    // new Date(2000, 0, 1) | date.format('yyyy/MM/dd') -> '2000/01/01'
    Vue.filter('date.format', dateformat);

    /**
     * アプリケーション本体
     */
    new Vue({
        el: '#main',
        data: {
            datepickerValue1: undefined,
            datepickerValue2: null,
            datepickerValue3: null,
            datepickerValue4: undefined,
        },
    });

↑Vue.filter登録してます。

image
image
image

Select

    var Select = Vue.extend({
        props: {
            id: String,
            value: {
                type: [String, Number],
                default: null,
            },
            label: String,
            required: Boolean,
            number: Boolean,
            disabled: Boolean,
        },
        template: '<div class="input-field">' +
                '   <select ' +
                    ':id="id" ' +
                    'ref="select" ' +
                    ':required="required ? \'true\' : null" ' +
                    ':aria-required="required ? \'true\' : null" ' +
                    ':disabled="disabled ? \'true\' : null" ' +
                    'class="validate" ' +
                    '>' +
                        '<slot />' +
                    '</select>' +
                    '<label ref="label" :for="id" >{{label}}</label>' +
                '</div>',
        watch: {
            value: function(newValue, oldValue) {
                var $sel = $(this.$refs.select);
                if ($sel.val() !== newValue) {
                    $sel.val(newValue);
                    this.materialSelect();
                }
            },
        },
        methods: {
            updateValue: function(value) {
                value = this.selectToValue(value);
                //v-modelが無いとき値が当たらないので設定
                this.$emit('input', value);
                this.validate();
            },
            validate: function() {
                var value = this.selectToValue($(this.$refs.select).val());
                if (this.required && !value) {
                    if ($(this.$el).find('select option').length) {
                        this.setInvalid('empty');
                    } else {
                        this.setInvalid('選択可能な値がありません');
                    }
                    this.reflectInvalid();
                    return false;
                }
                this.$refs.select.classList.remove('invalid');
                this.$emit('validate', value, this);
                this.reflectInvalid();
                return this.$refs.select.classList.contains('invalid');
            },
            setInvalid: function(msg) {
                if (msg) {
                    this.$refs.label.setAttribute('data-error', msg);
                    this.$refs.select.classList.add('invalid');
                }
            },
            getDisplayValue: function() {
                var $sel = $(this.$el).find('select');
                if (-1 === $sel[0].selectedIndex) {
                    $sel[0].selectedIndex = 0;
                }
                return $sel.val();
            },
            selectToValue: function(value) {
                if (this.number) {
                    return !isNaN(value - 0) ? value - 0 : value;
                }
                return value;
            },
            reflectInvalid: function() {
                //http://stackoverflow.com/questions/34248898/how-to-validate-select-option-for-materialize-dropdown
                $(this.$el).find('.error_note').remove();
                if (this.$refs.select.classList.contains('invalid')) {
                    var inputTarget = $(this.$el).find('input.select-dropdown');
                    inputTarget.css({
                        'border-color': '#EA454B',
                        'box-shadow': '0 1px 0 0 #EA454B'
                    });
                    inputTarget.after(
                        '<span class="error_note" style="color: #EA454B;font-size: 12px; position: absolute; top: 49px;">' +
                        this.$refs.label.getAttribute('data-error') +
                        '</span>'
                    );
                }
            },
            materialSelect: function() {
                var $sel = $(this.$refs.select);
                $sel.material_select();
                var inputTarget = $(this.$el).find('input.select-dropdown');
                inputTarget[0]['$ validate $'] = this.validate.bind(this);
            },
        },
        mounted: function() {
            var $sel = $(this.$refs.select);
            $sel.val(this.value);
            //表示内容と違うものが選択されている
            if (this.value !== this.getDisplayValue()) {
                this.value = this.selectToValue(this.getDisplayValue());
            }
            this.materialSelect();

            $sel.on('change', function() {
                this.updateValue($(this.$refs.select).val());
            }.bind(this));


        },
        updated: function() {
            var $sel = $(this.$refs.select);
            $sel.val(this.value);
            if (this.value !== this.getDisplayValue()) {
                this.updateValue(this.getDisplayValue());
            }
            this.materialSelect();
            this.reflectInvalid();
        },
    });
    Vue.component('m-select', Select);

説明

v-modelを反映させる方法はInputと同じような感じです。
$sel.on('change', function() {からthis.$emit('input', value);に伝播するようにしてます。

  • validate
    こちらを参考にvalidate出来るようにしてみました。

            reflectInvalid: function() {
                //http://stackoverflow.com/questions/34248898/how-to-validate-select-option-for-materialize-dropdown
                $(this.$el).find('.error_note').remove();
                if (this.$refs.select.classList.contains('invalid')) {
                    var inputTarget = $(this.$el).find('input.select-dropdown');
                    inputTarget.css({
                        'border-color': '#EA454B',
                        'box-shadow': '0 1px 0 0 #EA454B'
                    });
                    inputTarget.after(
                        '<span class="error_note" style="color: #EA454B;font-size: 12px; position: absolute; top: 49px;">' +
                        this.$refs.label.getAttribute('data-error') +
                        '</span>'
                    );
                }
            },
    
  • material_select()
    再レンダリングされたとき、$(this.$refs.select).material_select();を呼ばないと反映されないので念のためupdatedから呼んでます。

        methods: {
            materialSelect: function() {
                var $sel = $(this.$refs.select);
                $sel.material_select();
                //・・・
            },
        //・・・
        updated: function() {
            var $sel = $(this.$refs.select);
            $sel.val(this.value);
            if (this.value !== this.getDisplayValue()) {
                this.updateValue(this.getDisplayValue());
            }
            this.materialSelect();
            //・・・
        },
    

    valueが変更されたときも同様に$(this.$refs.select).material_select();が必要なのでwatchで呼び出しています。

        watch: {
            value: function(newValue, oldValue) {
                var $sel = $(this.$refs.select);
                if ($sel.val() !== newValue) {
                    $sel.val(newValue);
                    this.materialSelect();
                }
            },
        },
    

利用

利用側.html
        <div class="row">
            <div class="col s12 center"><h4>Selects</h4></div>
            <div class="col s12 m6 l3" style="height: 120px;">
                <m-select label="select" v-model="selectValue1" >
                    <option value="1">Item1</option>
                    <option value="2">Item2</option>
                    <option value="3">Item3</option>
                    <option value="4">Item4</option>
                </m-select>
                {{selectValue1}}
            </div>
            <div class="col s12 m6 l3" style="height: 120px;">
                <m-select required label="required" v-model="selectValue2" >
                    <option value="">Empty</option>
                    <option value="1">Item1</option>
                    <option value="2">Item2</option>
                    <option value="3">Item3</option>
                    <option value="4">Item4</option>
                </m-select>
                {{selectValue2}}
            </div>
            <div class="col s12 m6 l3" style="height: 120px;">
                <m-select required label="required empty items" >
                </m-select>
            </div>
            <div class="col s12 m6 l3" style="height: 120px;">
                <m-select number label="number" v-model="selectValue3" >
                    <option value="1">Item1</option>
                    <option value="2">Item2</option>
                    <option value="3">Item3</option>
                    <option value="4">Item4</option>
                </m-select>
                {{selectValue3}} : typeof {{typeof selectValue3}}
            </div>
            <div class="col s12 m6 l3" style="height: 120px;">
                <m-select disabled label="disabled" v-model="selectValue4" >
                    <option value="1">Item1</option>
                    <option value="2">Item2</option>
                    <option value="3">Item3</option>
                    <option value="4">Item4</option>
                </m-select>
                {{selectValue4}}
            </div>
        </div>
    /**
     * アプリケーション本体
     */
    new Vue({
        el: '#main',
        data: {
            selectValue1: '1',
            selectValue2: '',
            selectValue3: 3,
            selectValue4: '4',
        },
    });

image

image

Collection(パート1)

次はCollectionですがベストな方法がわからず悩んでいます。
http://materializecss.com/collections.html

    var Collection = Vue.extend({
        props: {
            items: Array,
            itemComponent: {
                type: Object,
                default: function() {
                    return {
                        template: '<span>{{ item }} </span>',
                        props: {
                            item: String,
                        },
                    };
                },
            },
        },
        //https://github.com/vuejs/vue/issues/2511
        template: '<ul :class="{collection:true, \'with-header\': !!$slots.header}">' +
                    '<li v-if="$slots.header" class="collection-header">' +
                        '<slot name="header"></slot>' +
                    '</li>' +
                    '<li class="collection-item" v-for="item in items" >' +
                        '<component :is="itemComponent" :item="item"></component>' +
                    '</li>' +
                '</ul>',
    });
    Vue.component('m-collection', Collection);

説明

コンポーネント内部の描画を外から与えてさらにこれをv-ifで回すには小技が必要です。
下記を見るとcomponentタグを使えってことみたいなのでこれはcomponentタグで実装しています。
https://github.com/vuejs/vue/issues/2511
https://jsfiddle.net/simplesmiler/5p420nbe/

            itemComponent: {
                type: Object,
                default: function() {
                    return {
                        template: '<span>{{ item }} </span>',
                        props: {
                            item: String,
                        },
                    };
                },
            },
//・・・
                    '<li class="collection-item" v-for="item in items" >' +
                        '<component :is="itemComponent" :item="item"></component>' +
                    '</li>' +

この場合、利用側は下記のようになります。

利用

利用側.html
        <div class="row">
            <div class="col s12 center"><h4>Collections</h4></div>
            <div class="col s12 m6 l3">
                Normal<br>
                <m-collection :items="collectionItems1" ></m-collection>
            </div>
            <div class="col s12 m6 l3">
                CustomItems<br>
                <m-collection :item-component="$options.components.collectionItem" :items="collectionItems1" ></m-collection>
            </div>
            <div class="col s12 m6 l3">
                Header<br>
                <m-collection :items="collectionItems1" >
                    <h4 slot="header">Header</h4>
                </m-collection>
            </div>
            <div class="col s12 m6 l3">
                header2<br>
                <m-collection :items="collectionItems1" >
                    <h4 slot="header"><i class="material-icons">grade</i>Header<i class="material-icons">grade</i></h4>
                </m-collection>
            </div>
        </div>
    /**
     * アプリケーション本体
     */
    new Vue({
        el: '#main',
        data: {
            collectionItems1: [
                'item1',
                'item2',
                'item3',
            ],

        },
        components: {
            collectionItem: {
                // script templateも可 `template: '#collectionitem-template',` など
                template: '<div>{{ item }}<a href="#!" class="secondary-content"><i class="material-icons" v-on:click="onClick" >send</i></a></div>',
                props: {
                    item: String
                },
                methods: {
                    onClick: function() {
                        alert('click arrow!!');
                    },
                },
            },
        },
        methods: {
            onClickItem: function(item) {
                alert('click!!' + item);
            },
        },
    });

image

htmlに子要素が書かれてないのが気持ち悪いというか、html見ただけではどうなるのかさっぱりわからないのがいけてない気がします。
ということで次を試しました。

Collection(パート2)

    var Collection2 = Vue.extend({
        props: {
        },
        render: function(h) {
            var items = [];
            if (this.$slots.header) {
                items.push(this.$createElement('li', {
                    class: {
                        'collection-header': true,
                    }
                }, this.$slots.header));
            }
            if (this.$slots.default) {
                this.$slots.default.forEach(function(slot) {
                    if (!slot.tag && (!slot.text || !slot.text.trim())) {
                        return;
                    }
                    //わたってきたslotをliタグで囲む
                    items.push(this.$createElement('li', {
                        class: {
                            'collection-item': true,
                        }
                    }, [slot]));
                }.bind(this));
            }
            return this.$createElement('ul', {
                class: {
                    collection: true,
                    'with-header': !!this.$slots.header,
                }
            }, items);
        },
    });
    Vue.component('m-collection2', Collection2);

説明

templateを使わずrenderという技を使っています。

先に「利用」を見たほうがわかりやすいですが、
this.$slots.defaultに内側に指定されたdom(VNode)のリストが入ってくるので、これでループして構築します。

        render: function(h) {
            var items = [];
            //・・・
            if (this.$slots.default) {
                this.$slots.default.forEach(function(slot) {
                    if (!slot.tag && (!slot.text || !slot.text.trim())) {
                        return;
                    }
                    //わたってきたslotをliタグで囲む
                    items.push(this.$createElement('li', {
                        class: {
                            'collection-item': true,
                        }
                    }, [slot]));
                }.bind(this));
            }
            return this.$createElement('ul', {
                //・・・ 
            }, items);
        //・・・

v-forはコンポーネントではなく呼び出し側で実装してもらいます。

利用

利用側.html
        <div class="row">
            <div class="col s12 center"><h4>Collections2</h4></div>
            <div class="col s12 m6 l3">
                Normal<br>
                <m-collection2>
                    <span v-for="item in collectionItems1">{{item}}</span>
                </m-collection2>
            </div>
            <div class="col s12 m6 l3">
                CustomItems<br>
                <m-collection2 >
                    <div v-for="item in collectionItems1">{{ item }}<a href="#!" class="secondary-content"><i class="material-icons" v-on:click="onClickItem(item)" >send</i></a></div>
                </m-collection2>
            </div>
            <div class="col s12 m6 l3">
                Header<br>
                <m-collection2>
                    <h4 slot="header">header</h4>
                    <span v-for="item in collectionItems1">{{item}}</span>
                </m-collection2>
            </div>
            <div class="col s12 m6 l3">
                header2<br>
                <m-collection2 >
                    <h4 slot="header"><i class="material-icons">grade</i>Header<i class="material-icons">grade</i></h4>
                    <div v-for="item in collectionItems1">{{ item }}<a href="#!" class="secondary-content"><i class="material-icons" v-on:click="onClickItem(item)" >send</i></a></div>
                </m-collection2>
            </div>
        </div>

image

同じ感じになりました。
htmlを見ただけでどういう構造化がわかりやすいという意味では、こちらのほうが良いかもしれません。

さらに下記のようにArrayのデータが無くても利用できるようになります。

                <m-collection2>
                    <span >Item1</span>
                    <div >Item2<a href="#!" class="secondary-content"><i class="material-icons" v-on:click="onClickItem('item2')" >send</i></a></div>
                </m-collection2>

image

ただrender使い始めるとメンテナンスがつらそうな予感はしています。

もうわけわからなくなってきたのでもう一つ。

Collection(パート3)

    var Collection3 = Vue.extend({
        props: {
        },
        components: {
            renderItem: {
                props: {
                    vnode: Object,
                },
                render: function() {
                    return this.vnode;
                }
            }
        },
        template: '<ul :class="{collection:true, \'with-header\': !!$slots.header}">' +
                    '<li v-if="$slots.header" class="collection-header">' +
                        '<slot name="header"></slot>' +
                    '</li>' +
                    '<li class="collection-item" v-for="vnode in $slots.default" v-if="vnode.tag || (vnode.text && vnode.text.trim())" >' +
                        '<render-item :vnode="vnode" ></render-item>' +
                    '</li>' +
                '</ul>',
    });
    Vue.component('m-collection3', Collection3);

説明

パート2と同じ動きのはずです。
renderでの実装範囲を小さくしてみました。VNodeを描画してくれるコンポーネントを内部用に作成して対応しました。
これはさすがにVuejsのバージョンアップ等ですぐに殺されそうです。

利用

利用側.html
        <div class="row">
            <div class="col s12 center"><h4>Collections3</h4></div>
            <div class="col s12 m6 l3">
                Normal<br>
                <m-collection3>
                    <span v-for="item in collectionItems1">{{item}}</span>
                </m-collection3>
            </div>
            <div class="col s12 m6 l3">
                CustomItems<br>
                <m-collection3 >
                    <div v-for="item in collectionItems1">{{ item }}<a href="#!" class="secondary-content"><i class="material-icons" v-on:click="onClickItem(item)" >send</i></a></div>
                </m-collection3>
            </div>
            <div class="col s12 m6 l3">
                Header<br>
                <m-collection3>
                    <h4 slot="header">header</h4>
                    <span v-for="item in collectionItems1">{{item}}</span>
                </m-collection3>
            </div>
            <div class="col s12 m6 l3">
                header2<br>
                <m-collection3 >
                    <h4 slot="header"><i class="material-icons">grade</i>Header<i class="material-icons">grade</i></h4>
                    <div v-for="item in collectionItems1">{{ item }}<a href="#!" class="secondary-content"><i class="material-icons" v-on:click="onClickItem(item)" >send</i></a></div>
                </m-collection3>
            </div>
        </div>

image

実物

今回のソースはGitHubにアップしてghpagesで動かせるようにしています。

DEMO

https://ota-meshi.github.io/vue_components_example_materialize/

GitHub

https://github.com/ota-meshi/vue_components_example_materialize