【Vue3でwebアプリをつくろう1】英語学習アプリを作る(Vueの基本的な機能)

この記事はプログラミングに興味を持つ学生を対象とした学習用コンテンツです。今回はVue3で英語学習アプリを作ってみます。以前Vue2で作ったものをVue3で書き直したですが,今回はVueの仕組みを学ぶために,なるべくVueの機能を使っていきます。

アプリは問題集などでおなじみの語句選択問題です。具体的なアプリの事例を通じてVueの仕組みを学んでいきましょう。

VueとReactどっちがいい?

試しに同じようなものをReactで作ってみたのですが,今回のようにゲーム的なものであればVueの方が作りやすいと思います。これらのフレームワークは,webサイトの構造がある程度複雑になってきたときに情報の流れを整理するためのツールとして役立ちます。

アプリの概要

今回作るアプリは問題文を表示して適切な選択肢を選ぶと正解不正解を判定して次の問題に進むというものです。

今回使っているVueの機能をおおまかに説明すると

  • 算出プロパティを使って,問題文の番号を変更するだけで,画面全体の表示を更新する。
  • メソッドでボタンクリック時の処理を行う。
  • v-ifで要素の表示・非表示を切り替える。
  • トランジションを使って,要素のフェードイン・フェードアウト(問題文がふわっと表示されたり消えたりする)を行う。
  • 子コンポーネントを作って,親コンポーネントからデータを送る。

実際のコードを見ながら,Vueの基本的な機能を学んでいきましょう。

インスタンスとマウント createApp() .mount()

//Vueのインスタンス
const app = Vue.createApp(RootComponent);
//アプリケーションのマウント
app.mount('#root');

まず,Vue.createAppでVueのインスタンスを行います。ヘッダーでVueを読み込んだだけではVueは動かないので,このインスタンスという作業が必要になります。このとき,RootComponentというオブジェクトを代入します。これについては後述します。そして,app.mount(elem)でマウントします。この動作によって,htmlの中に<div id="root"></div>と書いた部分にRootComponentで設定した内容が画面に表示されます。

問題文のデータ

const texts_data = [
  {
    "expository": {
      "item": "不定詞の否定",
      "en": "not to ~",
      "ja": "~しないために",
      "detail": "不定詞の否定の形は not to 動詞原形 となります。間違えて to not ~ としやすいので注意しましょう。" },
    "ja": "私の父は私にあまりに働きすぎないように言った。",
    "select": {
      "sentence": "My father told me # work too hard.",
      "opt": ["not to","don't","not","without"],
      "answer": "not to" }},
  {
    "expository": {
      "item": "expect ~ to ・・・",
      "en": "expect ~ to ・・・",
      "ja": "~が・・・すると予想,期待する。",
      "detail": "" },
    "ja": "私は彼の新しい家を気に入ったが,それがあれほど小さいとは予想していなかった。",
    "select": {
      "sentence": "I liked his new house, but I hadn't expected it # so small.",
      "opt": ["be","of being","to be","to being"],
      "answer": "to be" }}
]

問題文のデータです。今回は問題文を2つ用意しました。expositoryは解説文でいくつかの要素を持っています。解説文は不正解の場合にだけ表示することにします。

jaは日本文です。

selectもいくつかの要素を持ちます。sentenceは問題文で,画面に表示するときには#の部分が( )に変換されます。optは選択肢です。今回は4つの選択肢を用意していますが,必ずしも4つである必要はなく,Vueが自動的に要素の数だけ選択肢のボタンを表示してくれます。answerは正解で,選択肢のボタンで選んだ文字列とanswerの文字列が一致するかどうかで,正解・不正解の判定を行います。

ルートコンポーネント RootComponent

RootComponentの中身を見ていきましょう。まず,全体の構造を把握します。

const RootComponent = {
  data() { return {
    データ
  }},
  template: テンプレート,
  computed: 算出プロパティ,
  methods: メソッド
}

ルートコンポーネントの中に,データ,テンプレート,算出プロパティ,メソッドの 4 つの要素があります。

データ data()

data() { return { 
	index: 0,                 //問題番号
	correct_incorrect: '',    //正解不正解の判定
	texts: texts_data,        //問題文
	show: {                   //表示・非表示の制御
		question: true,         //問題文
		expository: false,      //解説文
		options: true,          //選択肢のボタン
		next: false             //「次へ」ボタン
	}
}},

データの部分です。indexは問題番号を表し,この値に応じて画面に問題文が表示される仕組みです。

ここがVueを使うメリットとも言えるでしょう。後述しますが,一つの問題が終わったあとindex++というコードを書くだけで,次の問題文を表示することができます。indexの値が変更されるとVueがそれを自動的に検知して画面の表示全体に波及させるのです。

textsには上で用意した問題データを格納します。

showは要素の表示・非表示を制御するもので,trueなら画面上に表示され,falseなら表示されません。初めの段階では,問題文と選択肢のボタンだけ表示し,あとで選択肢のボタンを押したときに解説文と「次へ」のボタンを表示することにします。

テンプレート template:

template: `
	<transition name="fade" @after-leave="afterLeave">
		<div class="frame-round-white" v-if="show.question">
			<p class="instruction">適切な英文になるように,( )に当てはまる語句を1つクリックしなさい。</p>
			<p class="ja-st">{{ ja }}</p>
			<p class="en-st" v-html="en()"></p>
		</div>
	</transition>
	<div id="btn-options" v-if="show.options">
		<button class="btn-option"
			v-for="opt in opts"
			@click="judgement(opt)">
			{{ opt }}
		</button>
	</div>
	<div class="btn-wrapper">
		<button id="btn_next" class="btn-option" 
				v-if="show.next" @click="GoNext">
				次へ
		</button>
	</div>
	<transition name="fade">
 	  <expository :data="expositoryObj" v-if="show.expository"></expository>
  </transition>`,

テンプレートです。マウントしたときに実際に表示されるhtmlの中身をここに書きます。テンプレートはバッククォート ``shift@) で囲みます。

基本的にはhtmlのコードそのものなのですが,いくつかVue独自の部分があるのが分かります。

トランジション <transition>

<transition> ~ </transition>で囲まれた部分がトランジションです。トランジションは要素が非表示→表示または表示→非表示に切り替わるときにアニメーションを実行します。実際のアニメーションの設定はスタイルシートの側で行います。

.fade-enter-active,
.fade-leave-active {
	transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
	opacity: 0;
}

transitionタグの中にname="fade"を設定しています。タグの中のdiv要素が非表示→表示に切り替わるときに.fade-enter-activeが適用され,表示→非表示に切り替わるときに.fade-leave-activeが適用されます。書き方はnameで設定した文字列-enter-activeのようになります。

ここでは,transition: opacity 0.3s easeとして要素が0.3秒間でフェードイン・フェードアウトするようにしています。実際にはここにいろんな指示を書いて,要素をスライドさせたり色を変えたりすることもできます。

また,enter-fromは非表示→表示のときのスタート地点を表し,最初はopacity: 0として画面に表示されない状態からスタートするようにします。反対にleave-toは表示→非表示のときのゴール地点で,同様に画面に表示されない状態で終えるようにします。

問題文の表示 v-if v-html

<div class="frame-round-white" v-if="show.question">
	<p class="instruction">適切な英文になるように,( )に当てはまる語句を1つクリックしなさい。</p>
	<p class="ja-st">{{ jaSentence }}</p>
	<p class="en-st" v-html="enSentence()"></p>
</div>

1つのトランジションで操作できるブロック要素は1つだけなので,<div></div>で囲います。また,ここに条件付きレンダリングv-ifを書き入れます。show.questionは先にdata()の中で定義した変数で,中身がtrueのときは<div></div>の範囲を画面に表示し,falseのときは非表示にします。

<p class="ja-st">{{ jaSentence }}</p> が日本文を表示する部分です。このように書くと,算出プロパティjaSentence()が呼び出され,返ってきた内容が書き込まれます。

<p class="en_st v-html="enSentence()"></p> は英文を表示します。同様に算出プロパティenSentence()を呼び出すのですが日本分の場合と書き方が異なります。表示されるものがただの文字列であれば{{}}で囲みますが,文字列に<span>などのHTMLタグが含まれる場合にはv-htmlを用います。

書き方を変えるのはセキュリティ上の理由で,v-htmlを使う場所はなるべく最小限に留めるというのが正しい使い方です。

算出プロパティ computed:

算出プロパティを使ったデータの受け渡しを見ていきましょう。

<p class="ja-st">{{ jaSentence }}</p>

=> <p class="ja-st">私の父は私にあまりに働きすぎないように言った。</p>

まずテンプレートで{{ jaSentence }}とすると,算出プロパティjaSentence()が呼び出されます。

computed: {
  jaSentence() { return this.texts[this.index].ja }
}

//index=0なら
//texts[index].ja="私の父は私にあまりに働きすぎないように言った。"
//index=1なら
//texts[index].ja="私は彼の新しい家を気に入ったが,それがあれほど
//小さいとは予想していなかった。"

computed:が算出プロパティです。data()で定義した変数やオブジェクトを参照し,returnで返しています。また,参照するときにはthis.を付けます(以下,解説文の中ではthis.は省略しますがコード上ではちゃんとthis.を付けましょう)。

算出プロパティを使う意味

算出プロパティを使う理由は何でしょうか。実は算出プロパティを使わなくても

<p class="ja-st">{{ texts[index].ja }}</p>

=> <p class="ja-st">私の父は私にあまりに働きすぎないように言った。</p>

となり,表示されるものは同じです。

ただし,この書き方ではあとからindexの値を変えても画面上の表示には反映されません。

しかし今回やりたいのは,

index=0なら
“私の父は私にあまりに働きすぎないように言った。”

index=1なら
“私は彼の新しい家を気に入ったが,それがあれほど小さいとは予想していなかった。”

というデータを表示させることです。

そこで算出プロパティを設定すると,indexの中身が書き換えられたときにVueがそれを検知して,画面上の表示も自動的に更新されるようになります。

選択肢ボタンの表示 v-for

<div id="btn-options" v-if="show.options">
	<button class="btn-option"
		v-for="opt in opts"
		@click="judgement(opt)">
		{{ opt }}
	</button>
</div>

選択肢のボタンを表示します。ここもv-ifを設定して,状況に応じてボタンを画面上に表示したり非表示にしたりします。

v-for="opt in opts"は繰り返し処理を行うfor文のことです。

optsは算出プロパティで

computed: {
		opts() { return this.texts[this.index].select.opt }
}

//index=0なら
//texts[index].select.opt = ["not to","don't","not","without"]
//index=1なら
//texts[index].select.opt = ["be","of being","to be","to being"]

としています。上でやったように算出プロパティを使うことで,indexの値に応じた選択肢の文字列が返ってきます。

v-for="opt in opts"とするとoptsの中に入っている文字列が一つずつoptに放り込まれ,画面に表示されます。

<button v-for="opt in opts">{{ opt }}</button>

は

<button>not to</button>
<button>don't</button>
<button>not</button>
<button>without</button>

と同じ

今回はボタンを4つ表示しましたが,配列の要素数に応じて表示されるボタンの数も変わります。

クリック時の処理 @click methods:

<button>にはv-forと同時に@click="judgement(opt)"が設定されています。@clickはボタンがクリックされたときに処理を行います。ここではメソッドjudgement()を呼び出します。

メソッドの中身を見ていきましょう。

methods: {
	//正解・不正解の判定
	judgement(opt) {
		if(opt == this.texts[this.index].select.answer) {
			this.correct_incorrect = 'correct';
		} else {
			this.correct_incorrect = 'incorrect';
			this.show.expository = true; //解説文の表示
		}
		this.show.options = false; //選択肢ボタンを消す
		this.show.next = true; //「次へ」のボタンを表示
	}
}

//index=0なら
//texts[index].select.answer = "not to"

ここで正解・不正解の判定を行います。

例えば「not to」のボタンを押した場合,これは正解となります。

optには"not to"が代入されていて,if文でanswerと一致するかどうかの判定を行います。正解ならば一致するのでcorrect_incorrect"correct"を代入します。

もし,「don’t」のボタンを押した場合は,不正解となり,correct_incorrect"incorrect"を代入します。

また,this.show.expository = true;として解説文を表示します。これについてはv-ifの項目を参考にしてください。

正解・不正解の表示

correct_incorrectの値を設定したところで,これを英文の表示に反映させます。

<p class="en-st" v-html="enSentence()"></p>

テンプレートの中で英文の表示部分は上のようになっていました。メソッドenSentence()を呼び出しています。

methods: {
	enSentence() {
		let replacement;
		if(this.correct_incorrect == 'correct') {
			replacement = `
				<span class="answer-correct">
					${this.texts[this.index].select.answer}
				</span>`;
		} else if(this.correct_incorrect == 'incorrect') {
			replacement = `
				<span class="answer-incorrect">
					${this.texts[this.index].select.answer}
				</span>`;
		} else {
			replacement = '(    )';
		}
		return this.texts[this.index].select.sentence.replace('#', replacement);
	}

関数の中ではif文を使って,正解,不正解,判定前の振り分けを行います。

上で判定処理を行ったとき,正解であればcorrect_incorrect"correct"が代入されています。この場合,データ文字列の#の部分を.replace()を用いて正解の文字列に置き換えます。<p>にスタイルシートclass="answer-correct"を指定することで文字を緑色で表しています。

不正解の場合はcorrect_incorrect"incorrect"が代入されています。このときも#を正解の文字列に置き換えますが,スタイルシートをclass="answer-incorrect"に変えることで,今度は赤色で表示しています。

この辺りは,どのような表示が見やすいかを工夫してみると良いでしょう。

また,correct_incorrectは初期状態では空です。この場合は,#をカッコに置き換えて表示します。これが問題文として初めに表示されるものです。

流れを整理すると

  • メソッドjudgement()内でcorrect_incorrectの値を変える。
  • Vueが変更を感知し,メソッドenSentence()を再計算する。
  • 再計算された内容がテンプレートのv-html="enSentence()"の部分に反映され,画面上の表示が書き換えられる。

となります。

「次へ」のボタン @after-leave

//templateの中身
<div class="btn-wrapper">
	<button id="btn_next" class="btn-option" 
			v-if="show.next" @click="goNext">
			次へ
	</button>
</div>

最初の状態ではshow.next=falseとなっているのでこの部分は表示されません。正解・不正解の処理の際にshow.next=trueとすることで,初めて表示されます。

ここでは,@click="goNext"を設定し,ボタンがクリックされたらメソッドgoNext()を呼び出すようにしています。

goNext()の中身を見ていきます。

methods:
  goNext() {
 	  this.show.question = false; //問題文を消す
  },

「次へ」のボタンが押されると問題文を消去します。このとき,テンプレートでトランジションが設定されていたことを思い出してください。

<transition name="fade" @after-leave="afterLeave">
	<div class="frame-round-white" v-if="show.question">

v-ifの項目で解説した通り,v-if="show.question"としてshow.questionfalseにすると,<div>~</div>の部分は画面に表示されなくなります。このとき,<transition>によってフェードアウトしながら消えていきます。

さらに,トランジションには@after-leave="afterLeave"が設定されています。こうするとフェードアウトが完了したときにメソッドafterLeave()が呼び出されます。

methods: {
		afterLeave() {
			if(this.index >= this.texts.length - 1) { //次の問題がなければ終了
				this.show.next = false; //「次に」ボタンを消す
				this.show.expository = false; //解説文を消す
				console.log('終了');
			} else {
				this.show.next = false; //「次に」ボタンを消す
				this.show.expository = false; //解説文を消す
				this.correct_incorrect = ''; //正解不正解の判定をリセット
				this.index++;	//次の問題に進む
				this.show.question = true; //問題文を表示
				this.show.options = true; //選択肢を表示
			}
		}
}

ここでは,indexの値が問題の最後に達しているかどうかによって処理を分けています。

次に表示する問題が存在している場合にはindex++として問題番号を一つ増やし,次の問題を表示します。

値を増やすだけの単純な命令ですが,これで問題文,選択肢,解説文がすべて入れ替わります。

コンポーネントの設置 component()

<expository :data="expositoryObj" v-if="show.expository"></expository>`,

テンプレートの最後の行に解説文を表示するタグが書かれています。

<expository>は独自に設定されたコンポーネント(構成要素)で,解説文を表示します。

解説文の中にはさらに色々な要素があるのですが,これらを一つのパーツとしてまとめることで,情報をより構造化したものとして扱うことができます。

:data="expositoryObj"で解説文のデータを渡します。expositoryObjは算出プロパティで設定された関数で,indexの値に応じて解説文のデータをオブジェクトとして返します。

computed: {
		expositoryObj() { return this.texts[this.index].expository }
}

//index=0なら
//texts[index]expository = {
//"item": "不定詞の否定",
//"en": "not to ~",
//"ja": "~しないために",
//"detail": "不定詞の否定の形は not to 動詞原形 となります。
//           間違えて to not ~ としやすいので注意しましょう。" }

コンポーネントを作って,データを受け取りましょう。

//Vueのインスタンス
const app = Vue.createApp(RootComponent);
//解説文コンポーネント
app.component('expository', {
	props: ['data'],
	template: `
			<div class="expository">
				<p class="expository-item">
					<span class="material-icons">hdr_strong</span> //アイコン
					<span v-html="this.data.item" />
				</p>
				<p class="expository-phrase-wrapper">
					<span class="expository-en" v-html="this.data.en" />
					<span class="expository-ja" v-html="this.data.ja" />
				</p>
				<p class="expository-detail" v-html="this.data.detail" />
			</div>`
})
//アプリケーションのマウント
const elem = document.querySelector('#root');
app.mount(elem);

コンポーネントの中身です。上のようにコンポーネントはインスタンスとマウントの間に書きます。

コンポーネントは,app.component('コンポーネント名', {で始めます。

props: ['data']はデータを受け取る部分です。テンプレートの中で:dataで送った情報をprops:で受け取る仕組みです。

テンプレートでは受け取ったデータをもとに解説文を記述していきます。

また,ここでは,アイコンの表示にgoogle Fontsのマテリアルアイコンを使用しています。

まとめ

ここではVue3を使って英語学習アプリを作成しました。算出プロパティを用いることで問題文が次々と移り変わるプログラムを書くことができました。また,トランジションを用いて画面にエフェクトをほどこし,それに応じた非同期処理を行うことができました。

最後にコード全体を示します。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>fasgram 英文法トレーニングwebアプリ</title>
<!--Vue-->
<script src="https://unpkg.com/vue@next"></script>
</head>
<body>
<div id="header" class="size-4">fasgram 英文法トレーニングwebアプリ動作サンプル</div>
<div id="container">
	<div id="root"></div>
</div>
<script>
//問題文データ
const texts = [
  {
    "category": "不定詞",
    "expository": {
      "item": "不定詞の否定",
      "en": "not to ~",
      "ja": "~しないために",
      "detail": "不定詞の否定の形は not to 動詞原形 となります。間違えて to not ~ としやすいので注意しましょう。" },
    "en": "My father told me not to work too hard.",
    "ja": "私の父は私にあまりに働きすぎないように言った。",
    "select": {
      "sentence": "My father told me # work too hard.",
      "opt": ["not to","don't","not","without"],
      "answer": "not to" }},
  {
    "category": "不定詞",
    "expository": {
      "item": "expect ~ to ・・・",
      "en": "expect ~ to ・・・",
      "ja": "~が・・・すると予想,期待する。",
      "detail": "" },
    "en": "I liked his new house, but I hadn't expected it to be so small.",
    "ja": "私は彼の新しい家を気に入ったが,それがあれほど小さいとは予想していなかった。",
    "select": {
      "sentence": "I liked his new house, but I hadn't expected it # so small.",
      "opt": ["be","of being","to be","to being"],
      "answer": "to be" }}
]
//選択肢のボタンが自動的にシャッフルされ,ボタンが表示される順番が変わる
//必要がなければ削除してよい
for(let i = 0; i < texts.length; i++) {
  let opts = texts[i].select.opt;
  for(let j = 0; j < 100; j++) {
    let a = Math.floor(Math.random() * opts.length);
    let b = Math.floor(Math.random() * opts.length);
    [opts[a], opts[b]] = [opts[b], opts[a]];
  }
  texts[i].select.opt = opts;
}
//ルートコンポーネント
const RootComponent = {
	//データを設置
	data() { return { 
		index: 0, //問題番号
		correct_incorrect: '',		//正解不正解の判定
		texts: texts,							//問題文
		show: {										//表示・非表示の制御
			question: true,					//問題文
			expository: false,			//解説文
			options: true,					//選択肢のボタン
			next: false							//「次へ」ボタン
		}
	}},
	//テンプレート
	template: `
		<transition name="fade" @after-leave="afterLeave">
			<div class="frame-round-white" v-if="show.question">
				<p class="instruction">適切な英文になるように,( )に当てはまる語句を1つクリックしなさい。</p>
				<p class="ja-st">{{ jaSentence }}</p>
				<p class="en-st" v-html="enSentence()"></p>
			</div>
		</transition>
		<div id="btn-options" v-if="show.options">
			<button class="btn-option"
				v-for="opt in opts"
				@click="judgement(opt)">
				{{ opt }}
			</button>
		</div>
		<div class="btn-wrapper">
			<button id="btn_next" class="btn-option" 
					v-if="show.next" @click="goNext">
					次へ
			</button>
		</div>
 	  <transition name="fade">
 	    <expository :data="expositoryObj" v-if="show.expository"></expository>
    </transition>`,
  //算出プロパティ
	computed: {
		jaSentence() { return this.texts[this.index].ja },
		opts() { return this.texts[this.index].select.opt },
		expositoryObj() { return this.texts[this.index].expository }
	},
	//メソッドの定義
	methods: {
    //英文の表示
		enSentence() {
			let replacement;
			if(this.correct_incorrect == 'correct') {
				replacement = `
					<span class="answer-correct">
						${this.texts[this.index].select.answer}
					</span>`;
			} else if(this.correct_incorrect == 'incorrect') {
				replacement = `
					<span class="answer-incorrect">
						${this.texts[this.index].select.answer}
					</span>`;
			} else {
				replacement = '(    )';
			}
			return this.texts[this.index].select.sentence.replace('#', replacement);
		},
		//正解・不正解の判定
		judgement(opt) {
			this.show.options = false;
			this.show.next = true;
			if(opt == this.texts[this.index].select.answer) {
				this.correct_incorrect = 'correct';
			} else {
				this.correct_incorrect = 'incorrect';
				this.show.expository = true;
			}
		},
		//次の問題に進む
		goNext() {
			this.show.question = false; //問題文を消す
		},
		afterLeave() {
			if(this.index >= this.texts.length - 1) { //次の問題がなければ終了
				this.show.next = false; //「次に」ボタンを消す
				this.show.expository = false; //解説文を消す
				console.log('終了');
			} else {
				this.show.next = false; //「次に」ボタンを消す
				this.show.expository = false; //解説文を消す
				this.correct_incorrect = ''; //正解不正解の判定をリセット
				this.index++;	//次の問題に進む
				this.show.question = true; //問題文を表示
				this.show.options = true; //選択肢を表示
			}
		}
	}
}
//Vueのインスタンス
const app = Vue.createApp(RootComponent);
//解説文コンポーネント
app.component('expository', {
	props: ['data'],
	template: `
			<div class="expository">
				<p class="expository-item">
					<span class="material-icons">hdr_strong</span>
					<span v-html="this.data.item" />
				</p>
				<p class="expository-phrase-wrapper">
					<span class="expository-en" v-html="this.data.en" />
					<span class="expository-ja" v-html="this.data.ja" />
				</p>
				<p class="expository-detail" v-html="this.data.detail" />
			</div>`
})
//アプリケーションのマウント
app.mount('#root');
</script>
<style>
	@charset "UTF-8";
	@import url("https://fonts.googleapis.com/css2?family=Noto+Serif&display=swap");
	@import url("https://fonts.googleapis.com/css2?family=Kosugi+Maru&display=swap");
	@import url("https://fonts.googleapis.com/css2?family=Material+Icons&display=swap");
	body { font-family: 'Noto Serif', 'Kosugi Maru', serif;
		font-size: 18px; background-color: #fafafa; margin: 0px; padding: 0px; }
	p { margin: 0.5rem; }
	span { display: inline-block; }
	#header { padding: 4px; background-color: #51ab9f; color: #fff;
		-webkit-box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.16);
		box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.16); }
	#container { max-width: 680px; margin-right: auto; margin-left: auto;
		padding: 10px; }
	.size-4 {font-size: 0.75rem;}
	.instruction { font-size: 0.75rem; padding-bottom: 1rem; }
	.ja-st { font-size: 1rem; font-weight: 400; }
	.en-st { padding-left: 8px; font-size: 1.25rem; font-weight: 400; }
	.frame-round-white { margin: 5px 2px; padding: 1rem 1rem;
		border: 1px solid #ccc; border-radius: 0.5em; background: #fff;
		-webkit-box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.16);
						box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.16);
		outline: none; }
	.btn-wrapper {
		display: -webkit-box; display: -ms-flexbox; display: flex;
		-webkit-box-pack: center; -ms-flex-pack: center; justify-content: center;
		-webkit-box-align: center; -ms-flex-align: center; align-items: center;
		-ms-flex-wrap: wrap; flex-wrap: wrap; width: auto;
		margin-top: 2rem; }
	.btn-option {
		font-family: 'Noto Serif', 'Kosugi Maru', serif;
		font-size: 1.25rem; font-weight: 400; margin: 5px 2px;
		padding: 0.75rem 1.25rem; border: 1px solid #ccc;
		border-radius: 0.5rem; background: #fff;
		-webkit-box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.16);
						box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.16);
		outline: none; }
	.expository {
		margin-top: 16px; padding: 8px; background: #fff;
		-webkit-box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.16);
						box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.16);
		border: 1px solid #ccc; border-radius: 8px; }
	.expository-item {
		display: -webkit-box; display: -ms-flexbox; display: flex;
		-webkit-box-align: center; -ms-flex-align: center; align-items: center;
		font-size: 1rem; border-bottom: 2px solid #ccc; padding: 4px; }
	.expository-phrase-wrapper {
		display: -webkit-box; display: -ms-flexbox; display: flex;
		-webkit-box-align: center; -ms-flex-align: center; align-items: center; }
	.expository-en {
		color: #51ab9f; font-size: 1.5rem; font-weight: 700; }
	.expository-ja {
		font-size: 1rem; font-weight: 700; padding-left: 1rem; }
	.expository-detail { font-size: 1rem; }
	.material-icons { margin-right: 4px; color: #51ab9f; }
	.answer-correct { font-family: 'Kosugi Maru', serif; font-weight: 700;
		color: #51ab9f; border-bottom: 3px solid #51ab9f; }
	.answer-incorrect { position: relative; font-family: 'Kosugi Maru', serif;
		font-weight: 700; color: #c6292c; }
	.answer-incorrect:before {
		position: relative; font-weight: 900; content: '正解は';
		background: #51ab9f; color: #fff;
		border-radius: 5px; margin-right: 5px;
		padding: 3px 7px 1px; font-size: 0.7em; line-height: 1; }
	.fade-enter-active,
	.fade-leave-active {
		-webkit-transition: opacity 0.3s ease;
		transition: opacity 0.3s ease;
	}
	.fade-enter-from,
	.fade-leave-to {
		opacity: 0;
	}
</style>
</body>
</html>