注目の記事

HTML5 × CSS3 × jQueryを真面目に勉強 – #15 Evernote風タグ登録コンポーネントを作ってみた

2013.02.14

evernote_ui2

ちょっとした備忘録といったメモには Evernote を使っています。一時期に比べると使用頻度は少し落ちましたが、それでも使っています。地味なところですが、Evernote のタグ登録 UI が結構好きなのが理由の一つでもあります。

gmail_ui3

Web メールにおいては基本的に受信がメインで送信といっても殆どが返信で済んでしまっていますが、 Gmail を使っています。Gmail は非常に早いサイクルで機能や UI が刷新されまくっていますが、いつの頃から送信先、Cc、Bcc 入力のUIがまるでタグのような見た目とインタラクションになりました。この両者の UI はとても良く似ており、単純なカンマ区切りの文字列よりも視認性が高くて個人的に気に入っている UI の一つです。

そんな訳で、このタグ登録のUIコンポーネントを作ってみました。前回、jQuery UI Widget の作り方について学んだわけですが、コイツを駆使して作ってみたところ思いのほか分かりやすくシンプルに出来たので、ここに書き留めておくとします。

※ jQuery UI Widget の基礎知識をベースに進めていくので、「なにそれ?」という方はあらかじめ以下の記事を一読しておかれることをオススメ致します。

はじめに - タグ登録コンポーネント

まずはタグ登録コンポーネントの要件を大まかに書き出してみます。

  1. テキストインプットのようなコンテナにタグのような形状をしたキーワードを横並びで表示したい
  2. コンテナはタグ登録フォームも兼ねており、ここに入力したキーワードがタグとして登録されていく
  3. 入力可能欄は登録済みタグの末尾に位置する
  4. 入力されたキーワードは SpaceEnterTab, (※カンマ) 押下時に登録される
  5. 登録済みタグは BackSpace (※ Mac の場合はdelete)押下で末尾のタグから順に削除される
  6. オプションで Space を含めたタグの登録も許可する
  7. タグの重複登録は不可とする
  8. 登録済みタグ内に削除ボタンを配置し、クリックで対象のタグを削除する
  9. オプションでタグの登録数に上限を設定出来るようにする
  10. オプションで readonly 状態にすることが出来る

色々と盛り込んでしまったような印象がありますが、キーワードを入力してタグを生成するという最も基本的な部分さえ出来てしまえば、それ以外の要件はさほど苦労せずに実現できそうです。(※ そんな気がする、なんとなく…)

完成予想イメージはこちら

sampleimg-taginput-demo

ではこれより実装していく訳ですが、いきなり全ての機能を盛り込むと解説が非常にややこしくなるので、1つずつステップを踏んで進めていくとします。

実装1 - 基本機能

まずは要件の1 ~ 4にある最小限の機能だけ実装していきます。

1 | HTML をマークアップ

HTML から書いていきます。

taginput.html

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Widget - Tag input</title>
<link href='http://fonts.googleapis.com/css?family=Chivo' rel='stylesheet' type='text/css'>
<link rel="stylesheet" href="http://code.jquery.com/ui/1.10.0/themes/base/jquery-ui.css">
<link rel="stylesheet" href="../common/css/common.css">
<link rel="stylesheet" href="css/taginput.css">
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.min.js"></script>
<script src="http://code.jquery.com/ui/1.10.0/jquery-ui.js"></script>
<script src="js/taginput.js"></script>
</head>
<body>
	<header class="global-header">
		<p class="masthead">WAKAMSHA</p>
		<h1>Widget - Tag input</h1>
	</header><!-- /header -->
	<hr />
	<input id="tags" type="text" name="tags" value="Mick,Kieth" />
</body>
</html>

実際のウィジェット呼び出しに必要なのは20行目のみです。jQuery プラグインの使い勝手を高めるために HTML のマークアップは最小限に留め、その他必要な要素に関しては JavaScript 側で動的に生成するのがポイントです。

2 | JavaScriptの実装

taginput.js

;(function($) {
	// プラグインの定義
	$.widget('ui.taginput', {
		// 初期化処理として一番最初に呼び出される
		_create: function() {
			var self = this,
				element = self.element;
			// タグリスト要素を生成し、指定元の要素を隠す
			self.tagList = $('<ul/>').insertAfter(element);
			element.css('display', 'none');
			// タグ登録フォームを生成
			self.tagInput = $('<input type="text" class=""ui-widget-content" />');

			self.tagList
				.addClass('taginput')
				.addClass('ui-widget ui-widget-content ui-corner-all')
				.append($('<li class="taginput-input" />').append(self.tagInput))
				.click(function(e) {
					if (!$(this).hasClass('taginput-label')) {
						self.tagInput.focus();
					}
				});

			// keydownイベント時の処理を定義
			self.tagInput.keydown(function(e) {
				// Comma, Enter, Tab, Space
				if (e.which == $.ui.keyCode.COMMA || e.which == $.ui.keyCode.ENTER || e.which == $.ui.keyCode.TAB || e.which == $.ui.keyCode.SPACE) {
					self.generateTag(self._clearInput());
					return false;
				}
			}).blur(function(e) {
				self.generateTag(self._clearInput());
			});
		},

		generateTag: function(value) {
			var self = this;
			value = $.trim(value);
			if (value == '') {
				return false;
			}
			// Generate tag
			var label = $('<span class="tagit-label"/>').text(value),
				tag = $('<li/>')
					.addClass('taginput-tag')
					.addClass('ui-widget-content ui-state-default ui-corner-all')
					.append(label),
				removeBtn = $('<a href="#"><span class="text-icon">\xd7</span></a>').
					addClass('taginput-close')
					.click(function(e) {
						e.preventDefault();
						self.removeTag(tag);
					});
				tag.append(removeBtn);

			self.tagInput.val('');
			self.tagInput.parent().before(tag);
		},

		removeTag: function(tag) {
			var $tag = $(tag);
			$tag.remove();
		},

		_clearInput: function() {
			return $.trim(this.tagInput.val().replace(/^"(.*)"$/, '$1'));
		}
	});
})(jQuery);

コンポーネントを構成する要素を生成

jQuery UI Widget では初期化処理として一番最初に_createが呼び出され、ここでコンポーネントの構成に必要な要素を生成していきます。

タグはリストとして定義したいので、 self.tagIpnputとして <ul/> を生成しています。同時にこの要素はコンポーネント全体のコンテナの役割も担います。ウィジェット呼び出し時に指定された要素の element は直接使用しないので、非表示にしました。コレとは別にタグ登録用の入力フォーム self.tagInput を生成し、ui-widget-content クラスを付けています。その後 <li/> に含めてself.tagIpnputの一番後ろに追加します。セマンティック・ウェブという観点からは少々不自然な構造ですが、無理に固執して複雑な入れ子にするのも野暮なので、あえてこの構造にしました。

また、self.tagInput に対しては taginput クラスに加えて ui-widget, ui-widget-content, ui-corner-all クラスを付けています。これらは jQuery UI のテーマ用に定義されたクラスの一部であり、それぞれ以下の様な特徴を持っています。

class 説明
ui-widget ウィジェットを囲うコンテナ要素に使用します。このクラスではテーマローラーにて指定されたフォントファミリーとフォントサイズが適用されます。
ui-widget-content ウィジェットのコンテンツ領域となる要素に使用します。このクラスではテーマローラーにて指定された枠線、背景色、文字色などが子要素も含めて適用されます。
ui-corner-all 要素の四角に角丸を適用します。角丸の半径値は4pxです。

もっと詳しく知りたいという方は、以下のページをご参照ください。まとまった情報が載っているのは(※恐らく)ここくらいなので、とても貴重です。

keydown イベント時の処理

次に ,, Enter, Tab, Space 押下時のイベントをハンドリングし、これらのキーが押されたらタグを生成するメソッドを呼び出します。(※28行目)

jQuery では主要なキーコード(※全てではない)は定数として定義されているので、マジックナンバーに悩まされることなくキーコードを評価することが出来ます。

また、キーボード押下に加えて self.tagInput からフォーカスアウトしたタイミングでもタグ生成メソッドを呼び出すようにしています。(※31 ~ 33行目)

タグ生成処理

入力された文字列を引数として受け取り、トリム処理をしてからタグを構成する要素を生成します。ラベル要素とタグ削除ボタン要素を生成して <li/> で囲います。削除ボタンにはクリック時に removeTag を呼び出すイベントハンドリングを定義しておきます。(※50 ~ 53行目)

ここまででタグ登録コンポーネントの基本機能が実装出来ました。taginput.html に以下の呼び出し処理を記述したらブラウザで動きを確認してみましょう。

taginput.html (※JS部分のみ)

$(function() {
	$('#tags').taginput();
});

sampleimg-taginput1

実装2 - BackSpaceでタグを削除

先ほどのサンプルで主要な機能は出来上がっているので、ここから先はそれほど大きな実装はありません。

要件5にある Backspace でのタグ削除を実装します。

taginput.js

;(function($) {
	// プラグインの定義
	$.widget('ui.taginput', {
		// 初期化処理として一番最初に呼び出される
		_create: function() {
			・・・・・・
			// keydownイベント時の処理を定義
			self.tagInput.keydown(function(e) {
				// Backspace
				if (e.which == $.ui.keyCode.BACKSPACE && self.tagInput.val() == '') {
					self.removeTag(self._lastTag());
				}
				// Comma, Enter, Tab, Space
				if (e.which == $.ui.keyCode.COMMA || e.which == $.ui.keyCode.ENTER || e.which == $.ui.keyCode.TAB || e.which == $.ui.keyCode.SPACE) {
					self.generateTag(self._cleanedInput());
					return false;
				}
			}).blur(function(e) {
				self.generateTag(self._cleanedInput());
			});
		},
		・・・・・・
		// 一番後ろのタグを取得
		_lastTag: function() {
			return this.tagList.find('.taginput-tag:last');
		}
	});
})(jQuery);

入力フォームが空欄の状態で BackSpace が押下されると、一番後ろにあるタグを取得して削除メソッドを呼び出します。

実装3 - タグの重複チェック

重複したタグは登録できないように存在チェックする処理を加えます。また重複発見時に対象となるタグがどれか一目で分かるように目立たせる処理も加えてみるとします。

taginput.js

;(function($) {
	// プラグインの定義
	$.widget('ui.taginput', {
		・・・・・・
		generateTag: function(value) {
			var self = this;
			value = $.trim(value);
			if (value == '') {
				return false;
			}
			// 重複チェック
			if (self._isExist(value)) {
				var existingTag = self._findTagByLabel(value);
				existingTag.effect('shake');
				return false;
			}
			// Generate tag
			・・・・・・
		},
		・・・・・・
		// タグの存在チェック
		_isExist: function(name) {
			return this._findTagByLabel(name);
		},

		// 文字列からタグを探し出す
		_findTagByLabel: function(name) {
			var tag = null;
			this.tagList.find('.taginput-tag').each(function(index, value) {
				var $value = $(value),
					val = $value.find('.taginput-label').text();
				if (name == val) {
					tag = $value;
					return false;
				}
			});
			return tag;
		}
	});
})(jQuery);

14行目effect というメソッドを呼び出しています。ここでは引数に shake を渡すことで対象となる要素を左右に揺らして重複タグを目立たせています。

ちなみにこのEffectsは jQuery UI に用意されている機能の一つであり、Effects コンポーネントとして提供されています。jQuery UI をダウンロードする際にこの機能を含めるかどうか選択することができます。

effectCore

実装4 - オプションでタグの上限数を設定

登録可能なタグ数をオプションで設定できるようにしてみます。

taginput.js

;(function($) {
	// プラグインの定義
	$.widget('ui.taginput', {
		options: {
			tagLimit: 0
		},
		・・・・・・
		generateTag: function(value) {
			var self = this;
			value = $.trim(value);
			if (value == '') {
				return false;
			}
			// 重複チェック
			if (self._isExist(value)) {
				var existingTag = self._findTagByLabel(value);
				existingTag.effect('shake');
				return false;
			}

			// タグの上限数チェック
			var limit = self.options.tagLimit;
			if (limit && self.tagList.find('.taginput-tag').length >= limit) {
				var message = '登録可能なタグは ' + limit + ' コまでです。';
				alert(message);
				self.tagInput.val('');	// ※2013年2月16 修正
				return false;
			}

			// Generate tag
			・・・・・・
		},
		・・・・・・
	});
})(jQuery);

これといって難しいことは何もしていません。登録済みタグを全て取得して、その数がオプションで渡された数以上であればアラートメッセージを出して以降の処理を中断しています。

taginput.html (※JS部分のみ)

$(function() {
	$('#tags').taginput({
		tagLimit: 5
	});
});

実装5 - オプションでスペース入力を許可

状況によっては Space をタグの文字列に含めたいこともあります。Space でタグ登録せずに文字列に含めるかどうかをオプションで指定できる機能を実装してみます。

taginput.js

;(function($) {
	// プラグインの定義
	$.widget('ui.taginput', {
		options: {
			tagLimit: 0,
			allowSpaces: false
		},
		// 初期化処理として一番最初に呼び出される
		_create: function() {
			・・・・・・
			// keydownイベント時の処理を定義
			self.tagInput.keydown(function(e) {
				// Backspace
				if (e.which == $.ui.keyCode.BACKSPACE && self.tagInput.val() == '') {
					self.removeTag(self._lastTag());
				}
				// Comma, Enter, Tab, Space
				if (
					e.which == $.ui.keyCode.COMMA ||
					e.which == $.ui.keyCode.ENTER ||
					e.which == $.ui.keyCode.TAB ||
					(
						e.which == $.ui.keyCode.SPACE &&
						!self.options.allowSpaces)) {
					self.generateTag(self._cleanedInput());
					return false;
				}
			}).blur(function(e) {
				self.generateTag(self._cleanedInput());
			});
		},
		・・・・・・
	});
})(jQuery);

if 文の条件式がだいぶカオスになってしまいましたが、とりあえずこれで Space をタグに含めるかどうか評価することが出来ました。

taginput.html (※JS部分のみ)

$(function() {
	$('#tags').taginput({
		tagLimit: 5,
		allowSpaces: true
	});
});

実装6 - デフォルト値から初期表示タグを生成・生成したタグを element 要素の value 値に反映

予め登録済みのタグを初期値として表示させ、タグとして登録された値をウィジェット呼び出し元の要素に反映させてみます。

taginput.js

;(function($) {
	// プラグインの定義
	$.widget('ui.taginput', {
		options: {
			tagLimit: 0,
			allowSpaces: false
		},
		// 初期化処理として一番最初に呼び出される
		_create: function() {
			・・・・・・

			// デフォルト値から初期表示タグを生成
			var defaultValues = self.element.val().split(',');
			for (var i=0,len=defaultValues.length; i<len; i++) {
				self.generateTag(defaultValues[i]);
			}

			// keydownイベント時の処理を定義
			self.tagInput.keydown(function(e) {
				・・・・・・
		},

		generateTag: function(value) {
			・・・・・・
			// タグをelement要素のvalue値に反映
			var assignedTags = [];
			self.tagList.find('.taginput-label').each(function() {
				assignedTags.push($(this).text());
			});
			this.element.val(assignedTags.join(','));
		},
		・・・・・・
	});
})(jQuery);
[/javascript]
<p>まずデフォルト値の取得ですが、呼び出し元の要素である <span class="bold">element</span> から value 値を取得し、それらを配列に変換してループ処理にかけ、1つずつタグ生成メソッドを呼び出します。</p>
<p>タグが登録される度に登録済みのタグ要素を全て取得し、それらをカンマ区切りの文字列に変換して呼び出し元の要素の value 値に反映させています。</p>

<ul class="links">
	<li><a href="http://wakamsha.github.com/dev.cm/jhc-study/15/taginput6.html" target="_blank">View demo (Tag input #6)</a></li>
</ul>

<h2 id="toc-7-">実装7 - コールバック用のトリガーを定義</h2>
<p>よりプラグインとしての利便性を高めるために、コールバック用のトリガーを定義してみます。</p>
<p class="code-summary bold">taginput.js</p>

;(function($) {
	// プラグインの定義
	$.widget('ui.taginput', {
		options: {
			tagLimit: 0,
			allowSpaces: false,
			// event callbacks
			tagLimitExceeded: null
		},
		・・・・・・
		generateTag: function(value) {
			・・・・・・
			// タグの上限数チェック
			var limit = self.options.tagLimit;
			if (limit && self._tags().length >= limit) {
				this._trigger('tagLimitExceeded', event, {
					label: value
				});
				self.tagInput.val('');
				return false;
			}
			・・・・・・
		},

上記の例では、タグの上限数を超えたタイミングでイベントを発火させています。jQuery UI Widget ではこのような記述方式でイベントの発火を簡単に定義することが出来ます。

taginput.html (※JS部分のみ)

$(function() {
	$('#tags').taginput({
		tagLimit: 5,
		allowSpaces: true,
		tagLimitExceeded: function(e, obj) {
			console.log(e.type, 'Limit over!');	// => 'taginputtaglimiteceeded'
		},
	});
});

タグの上限数オーバー時のイベントコールバックでログ出力しています。

実装8 - スタイルシートでデザイン & オプションでplaceholder、readonly を設定

最後にスタイルシートでデザインをそれっぽいものに仕上げます。

.taginput {
  overflow: auto;
  padding: 2px 4px;
}
.taginput li {
  float: left;
}
.taginput .taginput-tag {
  margin: 2px 4px 2px 0;
  padding: 2px 32px 2px 8px;
  position: relative;
}
.taginput .taginput-tag .taginput-close {
  background-color: #666666;
  border-radius: 50%;
  color: #fdfdfd;
  font-size: 12px;
  height: 16px;
  right: 8px;
  margin-top: -8px;
  position: absolute;
  text-align: center;
  top: 50%;
  width: 16px;
  transition: 0.1s;
}
.taginput .taginput-tag .taginput-close:hover {
  background-color: #333;
}
.taginput .taginput-label {
  color: #01b0f0;
  font-size: 86%;
  position: relative;
  top: -2px;
}
.taginput .taginput-input input[type="text"] {
  border: none;
  font-size: 14px;
  outline: none;
  padding: 5px;
}

当記事執筆時点(※2013年2月)では Transitions プロパティに対してベンダープレフィックスをつけて指定する必要があります。当記事では可読性を考慮して省略しております。

ついでに HTML5 の機能である placeholderreadonly をオプションで設定できるようにしておきます。

;(function($) {
	// プラグインの定義
	$.widget('ui.taginput', {
		options: {
			tagLimit: 0,
			allowSpaces: false,
			readonly: false,
			placeholder: null,
			// event callbacks
			tagAdding: null,
			tagAdded: null,
			tagRemoving: null,
			tagRemoved: null,
			tagLimitExceeded: null
		},
		// 初期化処理として一番最初に呼び出される
		_create: function() {
			var self = this,
				element = self.element;
			// タグリスト要素を生成し、指定元の要素を隠す
			self.tagList = $('<ul/>').insertAfter(element);
			element.css('display', 'none');
			// タグ入力フォームを生成
			self.tagInput = $('<input type="text" class=""ui-widget-content" />');
			if (self.options.readonly) {
				self.tagInput.attr('readonly', 'readonly');
			}
			if (self.options.placeholder) {
				self.tagInput.attr('placeholder', self.options.placeholder);
			}
			・・・・・・

taginput.html (※JS部分のみ)

$(function() {
	$(function() {
	$('#tags').taginput({
		// tagLimit: 3,
		allowSpaces: true,
		tagLimitExceeded: function(e, obj) {
			console.log(e.type, 'Limit over!');	// => 'taginputtaglimiteceeded'
		},
		placeholder: '入力してください',
		readonly: false
	});
});
});

sampleimg-taginput_complete

おわりに

割りと地味な機能のプラグインですが、それでもここまでの機能を盛り込むとなると結構なコード量になってきます。とはいえ jQuery UI Widget の構造に従って作ってみるとそれなりに見通しの良いコードが出来上がったのではないでしょうか。

この他にあると便利そうな機能として入力補完といったものが挙げられますが、コレに関しても HTML5 のautocomplete をうまく利用すれば、割りと簡単に実装ができそうです。もし機会があれば補足としていずれご紹介するかもしれません。

参考サイト