こんにちは、フロントエンドエンジニアのいなばです。
去年はひたすらAngularJSをやっていましたが、今年はAngular2への乗り換えのためにいろいろと苦労しています。Angular2づくしの1年になりそうです。そして、去年の暮れからbabel教からTypeScript教へと改宗ました。
まだジェネリクスなどをあまり理解していない部分もありますが、TypeScriptの練習がてら、よくあるお問い合わせフォームのバリデーションライブラリを実装してみました。
リポジトリはこちら。
https://github.com/i78s/ValidateJS
対応ブラウザはモダンブラウザ(IEは10以上)、モバイルも対応しているつもりです。(なにか不具合があれば、issueを立ててもらえるとありがたいです)
jQueryのバリデーションプラグインなどではデザインによっては仕様が合わず、使いずらいことが多かったので、エラーの表示周りをHTML構造に依存させないようにしています。
また、既存のバリデーションライブラリでは、古いブラウザの対応のために設定が複雑になりがち。しかし、IE10以下はそろそろ切ってもよさそうな雰囲気を踏まえて、HTMLタグの属性で済むことはHTMLに任せることで実装をシンプルにしています。
デモ&ドキュメントはこちらです。npmにも公開したので、webpackやBrowserifyなどで適宜requireして使ってください。
インストール
必要であればsudoをつけて実行してください。
$ npm install i78s.validatejs
使い方
webpackやBrowserifyなどで適宜requireして使ってください。コンストラクタの第一引数はform要素、もしくは formについているid名を受け取ります。
第二引数は後述するオプションをオブジェクトで受け取ります。import Validate from '../../lib/Validate'; let $form = document.getElementById('form'); let validate = new Validate($form, option);
オプション
オプションには3つのcallback関数を指定することができます。
option = { customValidate?: { [key: string]: (element: HTMLInputElement | HTMLSelectElement, form: HTMLFormElement) => {}; } onCheckHandler?(element: HTMLInputElement | HTMLSelectElement, validity: ValidityState): void; onSubmitHandler?(): void; }
onCheckHandler
formのinput / changeイベントが発火した際に呼ばれるcallbackを指定できます。同梱されているValidateMessagesと組み合わせることで、HTML構造に依存しないエラー文言の制御が可能です。
import Validate from '../../lib/Validate'; import ValidateMessages from '../../lib/ValidateMessages'; let validateMessages = new ValidateMessages('.js-validate-messages'); let validate = new Validate('form', { onCheckHandler: function(element, validity) { let parent = element.parentNode; validateMessages.update(element.name, validity); validateMessages.toggleClass(parent, 'has-success', validity.valid); validateMessages.toggleClass(parent, 'has-error', !validity.valid); } });
onSubmitHandler
formのsubmitイベントが発火した際に呼ばれるcallbackを指定できます。onSubmitHandlerが未指定の場合は、form.submitが呼ばれます。
import Validate from '../../lib/Validate'; let validate = new Validate('form', { onSubmitHandler: function() { // 例 // this.disabled(true); // フォームの値を取得してajaxを投げる // 通信完了時に this.disabled(false); で送信ボタンのdisabledを解除 } });
customValidate
customValidateのkeyと同じname属性を持つinput / select要素が変更された際に、カスタムバリデーションを設定することができます。
import Validate from '../../lib/Validate'; let validate = new Validate('form', { customValidate: { password: function(element, form) { this.trigger('change', form['passwordConfirm']); }, passwordConfirm: function(element, form) { if (element.value !== form['password'].value) { element.setCustomValidity('パスワードが一致しません'); return; } element.setCustomValidity(''); } } });
デモ
デモはこちらです。
実装
ここには貼りませんが今回はユニットテストも書いてみました。既存のライブラリよりは大分使いやすいのではないか? と思っているのですが、それ微妙じゃない? みたいなツッコミも、こっそり教えてもらえると嬉しいです。
formの制御をするクラス
src/Validate.ts
interface ValidateOption { customValidate?: { [key: string]: (element: HTMLInputElement | HTMLSelectElement, form: HTMLFormElement) => {}; } onCheckHandler?(element: HTMLInputElement | HTMLSelectElement, validity: ValidityState): void; onSubmitHandler?(): void; } export default class Validate { static noop(): void { } private form: HTMLFormElement; private submitBtn: HTMLButtonElement; option: ValidateOption; private _submitHandler: (e: Event) => void; private _changeHandler: (e: Event) => void; private _inputHandler: (e: Event) => void; constructor(element: HTMLFormElement, option: ValidateOption); constructor(element: string, option: ValidateOption); constructor( element: any, option: ValidateOption = {} ) { this.form = element; if (typeof element === "string") { this.form = <HTMLFormElement>document.getElementById(element); } this.submitBtn = <HTMLButtonElement>this.form.querySelector('button'); this.option = this.extend({ customValidate: {}, onCheckHandler: Validate.noop, onSubmitHandler: Validate.noop }, option); this._changeHandler = (e: Event) => { this.update(e); }; this._inputHandler = (e: Event) => { this.update(e); }; this._submitHandler = (e: Event) => { if (!this.isValid()) return; e.preventDefault(); this.submit(); }; this.init(); } /** * formの監視を開始する */ init() { this.form.addEventListener('change', this._changeHandler); this.form.addEventListener('input', this._inputHandler); this.form.addEventListener('submit', this._submitHandler); } /** * formのバリデーションが通っているかを返す * @returns {boolean} */ isValid(): boolean { return this.form.checkValidity(); } /** * formの変更を元に画面を更新する * @param e */ update(e: Event) { let target: any = e.target; target.classList.add('is-dirty'); let customValidate = this.option.customValidate[target.name]; if (customValidate) { customValidate.apply(this, [target, this.form]); } this.option.onCheckHandler.apply(this, [target, target.validity]); if (this.form.checkValidity()) { this.disabled(false); return; } this.disabled(true); } /** * changeイベントを強制発火させる * @param element */ trigger(event: string, element: HTMLInputElement | HTMLSelectElement) { let e = document.createEvent('HTMLEvents'); e.initEvent(event, true, true); element.dispatchEvent(e); } /** * 送信ボタンのdisabledを切り替える * @param bool */ disabled(bool: boolean) { if (bool) { this.submitBtn.setAttribute('disabled', 'disabled'); return; } this.submitBtn.removeAttribute('disabled'); } /** * formを送信する * オプションでonSubmitHandlerが設定されていなければform.submitを発火する */ submit() { if (!this.isValid()) { return; } if (this.option.onSubmitHandler !== Validate.noop) { this.option.onSubmitHandler.apply(this, []); return; } this.form.submit(); } destroy() { this.form.removeEventListener('change', this._changeHandler); this.form.removeEventListener('input', this._inputHandler); this.form.removeEventListener('submit', this._submitHandler); } extend(obj: any = {}, ...src: any[]): any { if (arguments.length < 2) { return obj; } for (var i = 1; i < arguments.length; i++) { for (var key in arguments[i]) { if (arguments[i][key] !== null && typeof (arguments[i][key]) === "object") { obj[key] = this.extend(obj[key], arguments[i][key]); } else { obj[key] = arguments[i][key]; } } } return obj; } }
エラー表示の制御をするクラス
src/ValidateMessages.ts
/** * ValidityStateオブジェクトを受け取り * formのエラー表示をする */ export default class ValidateMessages { private elements: NodeList; private error: { key?: string; element?: HTMLElement; }; private messages: { [key: string]: HTMLElement; }; constructor(selector: string) { this.elements = document.querySelectorAll(selector); this.error = {}; this.messages = {}; this.init(); } init() { Array.prototype.forEach.call(this.elements, (el: HTMLElement) => { let key = el.getAttribute('data-messages'); this.messages[key] = el; }); } update(key: string, validity: ValidityState) { let target = this.messages[key]; if (!target) { return; } let messages: NodeList = target.children; let len = messages.length; let isValid: boolean = validity.valid; this.error = {}; while (len--) { let message = <HTMLElement>messages[len]; this.toggleClass(message, 'is-show', false); if (isValid) continue; let key = message.getAttribute('data-message'); if (!validity[key]) continue; this.error = { key: key, element: message }; } if (this.error.key) { this.toggleClass(this.error.element, 'is-show', true); } } toggleClass(element: HTMLElement, className: string, force?: boolean) { if (typeof force === 'undefined') { element.classList.toggle(className); return; } let method = force ? 'add' : 'remove'; element.classList[method](className); } }
終わりに
AngularJSなどを入れてしまえばフォームのバリデーションは楽に実装できてしまうのですが、フォームのバリデーションのためだけにAngularJSを入れるのはちょっと……というときに、ぜひ導入を検討していただけると幸いです。
(そしてGithubスターをください)LIGはWebサイト制作を支援しています。ご興味のある方は事業ぺージをぜひご覧ください。