はじめに
vue.jsでタグ・エディターを作ってみました。
に引き続いて今度はvue.jsに移植です。
デモ: http://hnakamur.github.io/vue.tag-editor.js/
ソース: https://github.com/hnakamur/vue.tag-editor.js/tree/8285cbc5fb9003050f739392f3eae73d9181bb65
アプリケーションのhtmlとjsは以下の通りです。
<!DOCTYPE html>
<html>
<meta charset="utf-8"/>
<title>tag editor custom component example</title>
<link rel="stylesheet" href="css/vue.tag-editor.css">
<style>
#demo {
width: 200px;
}
</style>
<body>
<div id="demo"><div v-component="tag-editor-field" v-with="tagEditorOptions"></div></div>
<script src="http://cdnjs.cloudflare.com/ajax/libs/vue/0.9.3/vue.min.js"></script>
<script src="js/vue.tag-editor.js"></script>
<script src="js/app.js"></script>
</body>
</html>
var demo = new Vue({
el: '#demo',
data: {
tagEditorOptions: {
id: 'tagEditor',
tags: ['JavaScript', 'MVVM', 'Vue.js']
}
}
});
実装で試行錯誤した箇所(←イマイチな実装)の解説
vue.jsの使い方をまだよくわかっていないので、とりあえず見つけた方法で実現しましたが、もっといい方法がありそうな気がしています。
コンポーネントへのオプションの渡し方
ここはGrid Component ExampleのHTMLを参考にしました。v-component
でコンポーネントを指定しつつv-with
でapp.jsのdataのキーを指定しています。
子供のHTML要素の参照方法その1
このタグ・エディターはタグ入力中に文字列に応じてinput要素の幅を調節するようにしてあります。
適切な幅を計算するためにposition: absoluteで画面外に置いたdivに文字列を設定して幅を取得しinput要素の幅に設定するということをしています。
vue.jsだとCSSやスタイルを設定するにはdataにモデルを用意させてデータバインドすればいいのですが、幅を取得するにはdiv要素への参照が必要です。
自分でDOMをたどれば出来ることは出来るのでしょうが、DOMの構造に依存するのは嬉しくないですし、なるべくvue.jsで用意されている仕組みで実現したいところです。
Accessing Child Componentsに子供のコンポーネントを参照する方法が書かれているので、まずはこれを使ってみました。
<div id="parent">
<div v-component="user-profile" v-ref="profile"></div>
</div>
のように、子供をコンポーネントにしてv-componentで呼び出しつつ、v-refを指定すると、コンポーネントのメソッド内ではthis.$.profile
のように参照できます。
コンポーネントで生成したHTMLはデフォルトではv-componentを指定したdivの子供の要素になります。
replaceをtrueに指定すれば、v-componentを指定したdivを置き換えてHTML要素を作ることが出来ます。
しかし、その場合v-refはv-componentを指定したdivではなく、コンポーネント内のdiv要素の方に指定しないとJS側から参照できませんでした。
Vue.component('tag-editer-tag-measure', {
data: {
text: ''
},
replace: true,
template:
'<div class="tag-editor-tag tag-editor-measure" v-ref="tagMeasure">' +
'<div class="tag-editor-text" v-text="text"></div>' +
'<a class="tag-editor-delete">x</a>' +
'</div>'
});
ただ、この方式だとコンポーネント側にv-refの名前を指定するので、名前が固定になってしまうのがイマイチです。ですが、tag-editer-tag-measureはこれ自体でアプリケーションからコンポーネントとして使うわけではないですし、タグ・エディターの中では1つしか存在しないので、今回はこの方式で一旦妥協することにしました。
子供のHTML要素の参照方法その2
今度は入力用のinput要素からのkeydown, keyup, blurを処理する時の話です。
上の方法その1では幅計測用のdiv要素をコンポーネント化しましたが、単体でコンポーネントとして使うものではないのに、要素を参照するためだけにコンポーネント化していてイマイチです。
ということで今度はコンポーネント化せずにid属性を付けてdocument.getElementById()で参照するようにしてみました。
Vue.component('tag-editor-field', {
replace: true,
template:
'<div id="{{id}}" class="tag-editor-field" v-on="click: onClick">' +
'<div v-component="tag-editer-tag-measure"></div>' +
'<div v-repeat="tags" class="tag-editor-tag"><div class="tag-editor-text">{{$value}}</div><a class="tag-editor-delete" v-on="click: onClickDelete">x</a></div>' +
'<input class="tag-editor-input" id="{{inputID}}" v-style="width: inputWidth + \'px\'" v-model="inputVal" v-on="' +
' blur: onBlur,' +
' keydown: mayDeleteLastTag | key 8,' +
' keyup: onKeyup' +
'"></input>' +
'</div>',
data: {
inputVal: '',
inputWidth: 0,
sepRegex: /[, ]+/
},
computed: {
inputID: {
$get: function() {
return this.$el.id + "-input";
}
}
},
ready: function() {
this.adjustInputWidth('');
},
methods: {
onClickDelete: function(e) {
this.tags.splice(e.targetVM.$index, 1);
},
onClick: function(e) {
document.getElementById(this.inputID).focus();
},
onBlur: function(e) {
this.mayInsertTags();
},
mayDeleteLastTag: function(e) {
if (!this.inputVal) {
this.tags.pop();
}
},
onKeyup: function(e) {
var val = this.inputVal;
if (val && this.sepRegex.test(val)) {
this.mayInsertTags();
} else {
this.adjustInputWidth(val);
}
},
mayInsertTags: function() {
// We need to split tag text with separators
// because text pasted from clipboard may contain those.
var tags = this.inputVal.split(this.sepRegex), i, len, tag;
this.inputVal = '';
for (i = 0, len = tags.length; i < len; i++) {
tag = tags[i];
if (tag && this.tags.indexOf(tag) === -1) {
this.tags.push(tag);
}
}
this.adjustInputWidth('');
},
adjustInputWidth: function(val) {
var tagMeasure = this.$.tagMeasure;
tagMeasure.text = val + 'WW';
this.inputWidth = tagMeasure.$el.clientWidth;
}
}
});
input要素のid属性はComputed Propertiesを使って、コンポーネントのid要素に-inputを追加したものとしています。
コンポーネント本体のid要素は、本来の使い方なら
<div id="tagEditor" v-component="tag-editor-field" v-with="tagEditorOptions"></div>
のようにv-componentを指定した要素に書けば、JSではthis.$el.idで参照できます。
が、replace: trueで作成した場合はこれも消えてしまうので、コンポーネントのdiv要素にid属性をつけています。その値をdataのtagEditorOptionsのidで指定しています。
とはいえ、このやり方もちょっと無理矢理な感があります。
すっきり書けている箇所の説明
データバインドの恩恵に預かれる箇所はすっきりシンプルに書けています。
タグの☓ボタンをクリックした時の処理
onClickDeleteではe.targetVM.$indexでv-repeatで繰り返した中で何番目かが取得できるので、配列からその要素を削除するだけでOKです。
入力した文字列に応じてinput要素の幅を調整する処理
adjustInputWidthではthis.$.tagMeasure
で幅計測用のdiv要素を取得して、textプロパティで文字列を設定し、tagMeasure.$el.clientWidth
で幅を取得しています。それをコンポーネントのinputWidthプロパティに設定することでv-style経由でinput要素の幅を調整しています。
移植をサボった箇所
jQuery版とBackbone.js版では幅計測用のdiv要素からフォント情報を取得してinput要素に設定しています。
これはinput要素で入力中のフォントとタグになった後のフォントを同じにしたほうが、入力していて違和感がないと思ったからです。
今回のvue.js版では実装が面倒になってきたので、div要素とinput要素の両方のフォント情報をCSSから同じ値で設定するようにしました。
ちなみに、フォントの指定はCSSでのフォント指定について考える(2014年) - DTP Transit(Mac OS X, OS X Mavericks, Web Fonts, Web制作, iPhone, フォント)の旧ブラウザ対応フルセットを真似させていただきました。ありがとうございます!
まとめ
データバインドで実現できる処理はスッキリ書ける。
replace: trueで作った子供のHTML要素を参照するためのもっといい方法を見つけたいところ。