React使ってSPAを作るよ(10)の続きです。
今回は骨が折れました・・・長くなりそうです。
ReactDOMでユーザーの入力したタグをリストに反映する
モックのURL入力欄をクリックすると、タグリストが出てくるところまでは前回作りました。
このモックではjQueryを使って、タグのチェックボックスを増やす実装をしています。
//タグ追加 var html,newTagID; var newTag = 0; $('body').on('keydown', '#tag-input', function (e) { e.stopPropagation(); if (e.which === 13 && $(this).val() !== '') { $.each($('[data-tag]'),function(){ if(newTag < $(this).data('tag')){ newTag = $(this).data('tag'); } }); newTag++; newTagID = 'tag' + newTag; html = '<li data-tag="' + $(this).val() + '"><input type="checkbox" name="tag-check" id="' + newTagID + '" data-tag="' + newTag + '" checked="checked"><label for="' + newTagID + '">' + $(this).val() + '</label></li>'; $(this).prev().append(html); $(this).val(''); e.preventDefault(); } else if (e.which === 13) { e.preventDefault(); } });
キーを押したときに、それがEnterキー(which===13)で、かつ文字列が入力されている場合、という処理ですね。
既存タグのidの数字を見て、それに1を加えた新しいidをつけながらチェックボックスを増やしていっています。
…あまりスマートじゃないですね( ´Д`)=3
しかも、このままだとどんな値も入れられるので、html5の仕様に沿っていないidとかができちゃいます。
こういったことも解決しながら、これをReactでレンダリングするように変えていくのが今回のテーマ。
Reactでフォーム入力を反映するサンプルを探す
一番やりたいことに近いサンプルがありましたのでこのページを参考にしましょう。
ここに書いてあるbody.jsxをそのままコピったら、簡単に動きます。
そこから対象要素のidやモジュール、コンポーネントのクラス名などを自分の環境に合わせて修正していきます。
今回の場合、項目はタグだけなのでmail部分は削除したり、table表示する必要はないのでHTMLタグを消したりして構造を整えていきます。
//新規タグコンポーネント var NewTag = React.createClass({ propTypes:{ tagName: React.PropTypes.string.isRequired }, render:function(){ return ( <li> <input type="checkbox" name="tag-check" id={this.props.tagName} data-tag={this.props.tagName} defaultChecked="checked" /><label htmlFor={this.props.tagName}>{this.props.tagName}</label> </li> ); } });
tagNameの型の指定が追加されますね。
HTML5の仕様ではidは空白文字が入っていなければなんでも大丈夫そうなので、思い切ってタグの名前をそのままidにしてしまいました。
//新規タグリストコンポーネント var TagInputList = React.createClass({ propTypes:{ tagData:React.PropTypes.arrayOf(React.PropTypes.object).isRequired }, render:function(){ var TagNodes = this.props.tagData.map(function(tag, index){ return ( <NewTag tagName={tag.tagName} key={index}/> ); }); return ( <ul id="tag-input-list" className="check-list"> {TagNodes} </ul> ); } });
こちらもtagDataの配列を定義しています。tagDataは追加していくタグたちが入るので、これをいつもどおりmap関数で連続してliを生成していく感じですね。
問題はここからです。
サンプルのコンポーネントから階層を変更する
ここがとても苦労しました・・・
サンプルだと、
<div style={{width:"300px"}}> <UserForm addUser={this.handleAddUser}/> <hr/> <UserList userData={this.state.userData}/> </div>
というふうに入力用のinput要素が含まれるform要素と、リストが反映されるtable要素が同階層にあるんですね。
でも、モックでは実際にデータを送信するform要素の中に、追加したタグリストのul要素を入れたいんですよね。
単純にUserFormをURLFormに置き換えて・・・みたいなことができませんでした。
試行錯誤した結果、こうなりました。
//新規タグ入力フォームコンポーネント var TagForm = React.createClass({ propTypes:{ addTag:React.PropTypes.func.isRequired }, handleSubmit:function(){ var tagName = ReactDOM.findDOMNode(this.refs.tagName).value.trim(); tagName = tagName.replace(/\s+/g, ""); var already = document.getElementById(tagName); if (!tagName){ return; } else if(already){ already.checked = true; } else if(!already){ this.props.addTag(tagName); } ReactDOM.findDOMNode(this.refs.tagName).value = ""; }, handleEnterSubmit:function(e){ e.stopPropagation(); var tagName = ReactDOM.findDOMNode(this.refs.tagName).value.trim(); if (e.which === 13 && tagName !== '') { tagName = tagName.replace(/\s+/g, ""); var already = document.getElementById(tagName); if (!tagName){ return; } else if(already){ already.checked = true; } else if(!already){ this.props.addTag(tagName); } ReactDOM.findDOMNode(this.refs.tagName).value = ""; e.preventDefault(); } else if (e.which === 13) { e.preventDefault(); } }, render:function(){ return ( <div id="tag-input-area" className="box"> <TagInputList tagData={this.props.tagData}/> <input type="text" ref="tagName" id="tag-input" placeholder="タグ" form="" onKeyDown={this.handleEnterSubmit} /> <button onClick={this.handleSubmit} form="">追加</button> <TagCheckArea /> </div> ); } }); //URLフォームコンポーネント var URLForm = React.createClass({ getInitialState:function(){ return {tagData:[]}; }, handleAddTag:function(tagName){ var data = this.state.tagData; data.push({tagName: tagName}); this.setState({tagData: data}); }, render:function(){ return( <form id="form-url-input" className="wrap"> <input type="url" placeholder="URL" id="url-input" /><input type="submit" /> <TagForm addTag={this.handleAddTag} tagData={this.state.tagData} /> </form> ); } });
解説&自分用メモ
ひとつずつ見ていきます。
まず、タグ入力用の領域をURLFormから分離しました。
<div id="tag-input-area" className="box"> <TagInputList tagData={this.props.tagData}/> <input type="text" ref="tagName" id="tag-input" placeholder="タグ" onKeyDown={this.handleEnterSubmit} /> <button onClick={this.handleSubmit}>追加</button> <TagCheckArea /> </div>
ここで作った#tag-input-areaを、最終的にURLFormの中に入れることに。
私が最後まで躓いたのはこの中の
<TagInputList tagData={this.props.tagData}/>
ここ!
サンプルでは
<UserList userData={this.state.userData}/>
となっているところです。
userDataをtagDataに置き換えているだけなのに何がいけないんだろう?と悩みました。
これ実は、子コンポーネントにしたことによって、値がstateではなく、propsで指定しないといけなくなってたんですね。
ポポポポポ( ゚д゚)゚д゚)゚д゚)゚д゚)゚д゚)ポカーン…
まだstateとpropsの仕組みや動きが理解しきれていないのでこういうことになるんですね・・・
基本は大事(;´Д`)
URLForm側は、
<form id="form-url-input" className="wrap"> <input type="url" placeholder="URL" id="url-input" /><input type="submit" /> <TagForm addTag={this.handleAddTag} tagData={this.state.tagData} /> </form>
URL入力用のinput要素と、タグ入力用の領域#tag-input-areaを兄弟要素にしたform要素をコンポーネントにします。
TagFormにデータを渡してあげないといけないので、addTagとtagDataを入れておくのですが、
getInitialState:function(){ return {tagData:[]}; },
というように初期値を入れておかないとデータが渡せないよ~ってなります。
そして
handleAddTag:function(tagName){ var data = this.state.tagData; data.push({tagName: tagName}); this.setState({tagData: data}); },
これはほぼサンプルのままなので問題ないですね。
こいつを
module.exports = URLForm;
とすることで、ヘッダで呼び出すことになります。(これは前回までと同じ)
Enterキーで動くようにしたい
TagFormコンポーネントの中で、「追加」ボタンを押したときの処理が書かれています。
サンプルでは「追加」ボタンを押さないと反映されないので、モックのように入力してEnterを ッターン! したら動くようにしたいです。
ここでひとつ、モックと変えたポイントがあります。
もともと追加ボタンなんかないほうがスッキリするしいいだろうと思っていました。
ところが、スマホでの操作なども考えると…
期待通りに動かないこともあるかもしれないし、ボタンが存在したほうが安心かな、と思ったので、サンプルのとおり追加ボタンは残しました。
input → onKeyDown={this.handleEnterSubmit}
button → onClick={this.handleSubmit}
として、それぞれ動くようにしています。
追加ボタンのhandleSubmitは単純で、
handleSubmit:function(){ var tagName = ReactDOM.findDOMNode(this.refs.tagName).value.trim(); tagName = tagName.replace(/\s+/g, ""); var already = document.getElementById(tagName); if (!tagName){ return; } else if(already){ already.checked = true; } else if(!already){ this.props.addTag(tagName); } ReactDOM.findDOMNode(this.refs.tagName).value = ""; },
onClickイベントが発生したら、入力されたタグの文字列から空白文字を削除して、すでに存在するタグがないか判定してからaddTagに進みます。
…我ながら、変数名alreadyって…(;´Д`)
で!
肝心の、Enterキーを押したときの処理ですが…
handleEnterSubmit:function(e){ e.stopPropagation(); var tagName = ReactDOM.findDOMNode(this.refs.tagName).value.trim(); if (e.which === 13 && tagName !== '') { tagName = tagName.replace(/\s+/g, ""); var already = document.getElementById(tagName); if (!tagName){ return; } else if(already){ already.checked = true; } else if(!already){ this.props.addTag(tagName); } ReactDOM.findDOMNode(this.refs.tagName).value = ""; e.preventDefault(); } else if (e.which === 13) { e.preventDefault(); } },
中身はほぼ同じですね。
最初にjQueryで書いてた処理から、キーの判定をするif文を持ってきて、挟むだけ!
オマケ
はじめに追加ボタンだけでinput要素にデフォルトの動作をさせようとしたら、困ったことになりました。
それは、タグを入力するinput要素がform要素の中にいるから…
Enterを押したらURL送信のsubmitが動いてページがリフレッシュされちゃう!
ってことで、この入力欄と追加ボタンは、formと関係ないよ、こいつの中身は送信しないよ、っていうふうにしてあげないといけません。
私がどうしたかっていうと、
form=””
を追記しました。
HTML5ではform属性を指定することで、form要素の外にある部品を、さも、そのformの中身であるかのように紐付けることができます。
そこを逆手にとって、form=””と書くことで架空のformの部品と認識させたというわけです。
まあ、結局今回はEnterキーを押したときの処理を実装したので、form=””なんて付けなくても影響はありませんでしたけどね( ´∀`)bグッ!