いまさらですが、Vue.jsのコンポーネントの作り方調べたので記事書きます。
今回は、Vue.js 2.0・Materializeを使って実装します。
-
Materialize
http://materializecss.com/
コンポーネントを作る
コンポーネントの基本的な作り方はこちらを参考にしてください。(英語。日本語のページは2.0に追い付いていない様子)
https://vuejs.org/v2/guide/components.html
ざっくり、下記でコンポーネントが作れます。
- template定義
- props定義
- コンポーネント登録 (
Vue.component('my-component',
)
Button
手始めに下記のButtonをコンポーネントとして作ります。
http://materializecss.com/buttons.html
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; },
flat
・floating
の指定によってどの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
をつけることでイベントをフックできるようになります。
利用
<!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!!');
が呼ばれる。
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(); },
利用
<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>
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
の値は変わらないようなので、mounted
とwatch.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値のセットは、mounted
とwatch.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);
利用
<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('エラーメッセージ');
},
},
});
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のフォーマットロジックはこちら参考にさせていただきました。
利用
<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登録してます。
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(); } }, },
利用
<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',
},
});
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>' +
この場合、利用側は下記のようになります。
利用
<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);
},
},
});
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
はコンポーネントではなく呼び出し側で実装してもらいます。
利用
<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>
同じ感じになりました。
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>
ただ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のバージョンアップ等ですぐに殺されそうです。
利用
<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>
実物
今回のソースはGitHubにアップしてghpagesで動かせるようにしています。
DEMO
GitHub