72
11

Vue.js 3.3.11 + Vuetify 3.4.6 で使い回し可能なカスタムフォーム(日付フォーマット)を作成するまでの軌跡 ~ v-model の双方向バインディング完全に理解した~【備忘録】

Last updated at Posted at 2023-12-11

本記事について

Vue3Vuetify3 を半年くらい触って理解した内容を備忘録として残しておこっか、という記事になります。
(なお、筆者は以前のバージョンである Vue2 等は一切触ったことがないため、過去バージョンとの使用感の比較等は行えません。許してつかあさい。)

Vue3 / Vuetify3

Vue.js は 2020年9月18日に v3.0.0 が正式にリリースされ早くも3年が経過しているらしいのですが、Composition API でのリアクティブな変数と双方向バインディングを使ったカスタムコンポーネントの作り方の解説はままあれど、フロント全体で使用可能な、きれいに部品化されたカスタムコンポーネントの具体例をあまり見かけませんでした。

Vue を利用した本格的な開発は未経験のため不明瞭ですが、Vue での開発は本記事でも取り扱う Vuetify のような UI フレームワークに含まれるコンポーネントのみを使用してシンプルに仕上げるのが理想なのでしょうか?

とはいえ、要件によっては UI フレームワークで用意されたものから痒い所に手が届くようにカスタムする場面は発生すると思います。

そんなわけでこの度はタイトルに記載の通り、Vue.js 3.3.11 + Vuetify 3.4.6 を使って、自動で日付フォーマットされるカスタムコンポーネントを作成するまで、自分が学習した内容を順に振り返ろうと思います。そのため、初心者向けの記事となりますのでご了承ください。

とりあえず完成品が見たい、という方は途中は読み飛ばしていただき、 Vuetify をカスタムして部品コンポーネントを作りたい をご確認ください。

なお、環境は Vuetify を軽く触るのに非常に便利な Vuetify Play を使って作成しております。
Vue, Vuetify のバージョンをさっと変えられるので、挙動確認にはうってつけです。

コンポーネントってなんぞや

Vue 公式ドキュメントにあるコンポーネントの基礎でわかりやすい説明があります。
通常の HTML や JSP 等では、ページの要素をすべて1つのソースに書くのが基本だと思いますが、Vue ではページの要素を複数のソースに分割し、再利用することができます。

簡単な具体例

コンポーネント化前

App.vue
<template>
  <v-app>
    <!-- 1 -->
    <v-container class="ma-2 pa-2 bg-cyan-lighten-5">
      <v-text-field v-model="msg" />
    </v-container>
    <!-- 2 -->
    <v-container class="ma-2 pa-2 bg-cyan-lighten-5">
      <v-text-field v-model="msg" />
    </v-container>
    <!-- 3 -->
    <v-container class="ma-2 pa-2 bg-cyan-lighten-5">
      <v-text-field v-model="msg" />
    </v-container>
  </v-app>
</template>

<script setup>
  import { ref } from 'vue'

  const msg = ref('Hello World!')
</script>

画面

image01.png

上記コードと画面はこちら (Vuetify Play) で見ることができます。
上記 App.vue では同じ記述の v-container が3回記述されてますが、この v-container をひとまとまりとしたコンポーネント Comp.vue として分割し、それを App.vue で呼び出すとこのようになります。

コンポーネント化後

Comp.vue
<template>
  <v-container class="ma-2 pa-2 bg-cyan-lighten-5">
    <v-text-field v-model="msg" />
  </v-container>
</template>

<script setup>
  import { ref } from 'vue'

  const msg = ref('Hello World!')
</script>
App.vue
<template>
  <v-app>
    <!-- 1 -->
    <Comp />
    <!-- 2 -->
    <Comp />
    <!-- 3 -->
    <Comp />
  </v-app>
</template>

<script setup>
  import Comp from './Comp.vue'
</script>

上記コードと画面はこちら (Vuetify Play)

もともと App.vue にあった v-container と <script setup> 内の記述を Comp.vue に分割して、App.vue には Comp.vue をインポートし <Comp /> で使用しただけとなります。
オブジェクト指向っぽい感覚としては複数回使用する内部処理を別関数として定義して呼び出した、という感じですね。

 
これでコンポーネント化する前と 同じ画面 を再現できました。

....... ん?同じ画面? 何かが違う。。。

挙動が違うじゃん

before コンポーネント化

Animation.gif

after コンポーネント化

Animation2.gif

両画面を触ってみるとわかるのですが、フォーム内の値を変えたときに挙動が異なります。
befer は3フォームすべてが連動していますが、 after はすべて独立しています。

理由は単純で、

before コンポーネント化
→3フォームとも同じ App.vue 内の変数 msg を参照・書き換えしている。
after コンポーネント化
→3フォームはそれぞれ Comp.vue 内に定義した変数 msg を参照・書き換えしている。

ということです。同じ変数か 独立変数か の違いですね。

after と同じ挙動(独立パターン)を before コンポーネント化 で再現するとしたら、 App.vue 内の変数 msg と別名でもう2つ変数を用意すればOKですね。

では一方で、もし before と同じ挙動(連動パターン)を after コンポーネント化でもやりたいとしたらどうすればいいのでしょうか。
第一感としては、さっきと逆に App.vue で定義した msg を Comp.vue を使用している3か所すべてで参照渡しすればよさそうです。
じゃあ、呼び出したコンポーネント(子コンポーネント)に値を渡すにはどうすれば・・・?

Props/Emits と v-bind/v-on :コンポーネント親子間での値のやり取り

いきなり見出しによくわからん単語がならんでおりますが、とにかく親から子へ値をどうやって渡したらいいか調べてみましょう。

親から子への値渡しは Props

Vue3 公式ドキュメントにある プロパティ に子コンポーネントへの値渡しについて記載があります。
簡単な実装で確認してみましょう。

値渡しの簡単な例

SimpleComp.vue
<template>
  <v-container class="ma-2 pa-2 bg-cyan-lighten-5"> {{ hoge }} </v-container>
</template>

<script setup>
  const props = defineProps(['hoge'])
</script>
App.vue
<template>
  <v-app>
    <!-- 1 -->
    <SimpleComp :hoge="msg1" />
    <!-- 2 -->
    <SimpleComp :hoge="msg2" />
    <div class="ma-2 pa-2">msg1: {{ msg1 }}</div>
    <div class="ma-2 pa-2">msg2: {{ msg2 }}</div>
  </v-app>
</template>

<script setup>
  import { ref } from 'vue'
  import SimpleComp from './SimpleComp.vue'

  const msg1 = ref('親側で定義した変数1')
  const msg2 = ref('親側で定義した変数2')
</script>

画面

image02.png

コードはこちら (Vuetify Play)

背景色が水色の箇所は SimpleComp.vue の v-container になります。

SimpleComp.vue における defineProps'hoge' という Props を定義することで <SimpleComp /> を使用する際に :hoge="<親側で定義した変数名>" と書いて値を渡すことができます。

実際に親側で定義した msg1, msg2 がちゃんと SimpleComp.vue に渡されて v-container 内に表示されてますね。

じゃあ、同じ感じでさっきの Comp.vue でもやってみましょう。
これにて解決です。やったぜ。

が・・・・・、駄目っ・・・・・!

Comp.vue
<template>
  <v-container class="ma-2 pa-2 bg-cyan-lighten-5">
    <v-text-field v-model="hoge" />
  </v-container>
</template>

<script setup>
  const props = defineProps(['hoge'])
</script>
App.vue
<template>
  <v-app>
    <!-- 1 -->
    <Comp :hoge="msg" />
    <!-- 2 -->
    <Comp :hoge="msg" />
    <!-- 3 -->
    <Comp :hoge="msg" />
  </v-app>
</template>

<script setup>
  import Comp from './Comp.vue'
  import { ref } from 'vue'

  const msg = ref('Hello World!')
</script>

画面

image03-2.png

コードはこちら (Vuetify Play)

・・・・・?(;゚Д゚)

うまくいかない!
以下のエラーが発生してしまいました。

SyntaxError: v-model cannot be used on a prop, because local prop bindings are not writable.
Use a v-bind binding combined with a v-on listener that emits update:x event instead.

「 v-model は prop 使えないよ。だってローカル内の prop は書き込み禁止だもん。」(意訳)ですって??
で、
「update:なんちゃら って emits のイベントを監視している v-on と組み合わせた v-bind を代わりに使ってくれや。」(意訳)だって??
お前は何を言っているんだ???

 
そうです。ここで今までしれっと使っていた v-model について、その真の姿を知るときが来たようです。

てか v-model ってなんなん?

ぬらりひょんの如く気付けば懐に入り込んでいた v-model くん。
コンポーネントの v-model にその正体が書かれています。

以下、引用

最初に、ネイティブ要素で v-model がどのように使われるかを再確認してみましょう:

template1
<input v-model="searchText" />

テンプレートコンパイラーはその内部で、 v-model を冗長な同じ内容に展開してくれます。つまり、上のコードは以下と同じことをするわけです:

template2
<input
  :value="searchText"
  @input="searchText = $event.target.value"
/>

コンポーネントで使用する場合はその代わり、v-model は以下のように展開されます:

template3
<CustomInput
  :model-value="searchText"
  @update:model-value="newValue => searchText = newValue"
/>

ふーん、 @update:model-value かぁ・・・

((( 脳裏をよぎるさっきのエラー )))

update:なんちゃら って emits のイベントを監視している v-on と組み合わせた v-bind を代わりに使ってくれや。

・・・!
「update:なんちゃら」くん!!!?

そうです。先ほどのエラー2行目はこれを指しています。
説明を割愛していましたが、そもそも v-text-field は Vuetify で用意されたコンポーネントです。
そのため、 v-model を使用することは上記 template3 の展開がされていることを想定しなければなりません。
それを意識せずに使用したために何が起こったかというと、
親(App.vue)→ 子(Comp.vue) → 孫(v-text-field) という流れにおける「 子 → 孫 」箇所でエラーになったというわけです。

エラーに書いてある v-bindv-on も実は template3 に潜んでいます。
template3 ではさらに省略記号が使われており、 以下と同じものになります。

template3-2
<CustomInput
  v-bind:model-value="searchText"
  v-on:update:model-value="newValue => searchText = newValue"
/>

v-bind

実はすでに先ほどの SimpleComp.vue の説明の際にしれっと :hoge="msg" という形で使っておりました。
props の詳細に v-bind について説明 があります。というか :v-bind: の省略形って説明、ここでしれっと出たくらいしか見た記憶がありません。(一応、 チュートリアル#3 にもありますが)
こちらに説明のある通り、 v-bind は変数の値を動的に渡す ために使用されます。

v-on

v-on の説明にある通り、 v-on は イベントの監視を行う ものになります。
v-on:update:model-value="newValue => searchText = newValue" という記載は、コンポーネント内で update:model-value というイベントが発生した際、newValue => searchText = newValue という処理(この記述はラムダ式。関数呼び出しも可能。)が実行される、ということになります。

 
ここで疑問が浮かぶわけです。
じゃあ「コンポーネント内で update:model-value というイベント」はどうやって発生させているの?
それがさっきのエラー文内の残された登場人物、emits くんです。

Emits

Emits は任意の名前のイベントを発生させる、ご機嫌なやつです。
単にイベント発生だけでなく、値を渡す ことができます。
すなわち、先ほどの v-on:update:model-value="newValue => searchText = newValue" という記載は、
update:model-value の emits の引数 newValue の値を searchText に代入している、という処理になります。

とりあえず props で値だけ渡してみよう

ラーのかがみで v-model のモシャスが解けたため、とりあえず v-on やら emits やらは一旦置き、さきほどのエラーを解決しましょう。

Comp.vue を直すゾイ

Comp.vue
<template>
  <v-container class="ma-2 pa-2 bg-cyan-lighten-5">
-    <v-text-field v-model="hoge" />
+    <v-text-field :model-value="hoge" />
  </v-container>
</template>

<script setup>
  const props = defineProps(['hoge'])
</script>

エラーは解消して値は渡せたが・・・。

Animation3.gif

コードはこちら (Vuetify Play)

しってた。

まあ当然です。
親(App.vue)→ 子(Comp.vue) → 孫(v-text-field)への値渡しはできましたが、値が更新されたことを逆順で伝えなければいけません。
考えなければいけないのは4つです。

孫(v-text-field) → 子(Comp.vue)
1.孫(v-text-field) の emits
2.孫(v-text-field) の emits を受け取る、子(Comp.vue) の v-on
子(Comp.vue)→ 親(App.vue)
3.子(Comp.vue) の emits
4.子(Comp.vue) の emits を受け取る、親(App.vue) の v-on

上記1.に関しては Vuetify 側で用意してくれてます。v-text-field の API の Events を見てみます。

update:modelValue [string]
Event that is emitted when the component’s model changes.

「コンポーネントのモデル( modelValue のこと)が変わったらイベントを emits するよ」(意訳)って書いてますね。
コンポーネントの v-model に書かれていた通りの実装にもちろんなってます。いえい。

emits で値を返そう

まずは上記2.だけ対応してみましょう。

孫と子の不仲を解消

Comp.vue
<template>
  <v-container class="ma-2 pa-2 bg-cyan-lighten-5">
    <v-text-field
+     class="ma-2 pa-2 bg-purple-lighten-5"
-     :model-value="hoge"
+     :model-value="local_hoge"
+     @update:model-value="newValue => local_hoge = newValue"
    />
+   <div>local_hoge: {{ local_hoge }}</div>
  </v-container>
</template>

<script setup>
+ import { ref } from 'vue'
  const props = defineProps(['hoge'])
+ const local_hoge = ref(props.hoge)
</script>
App.vue
<template>
  <v-app>
    <!-- 1 -->
    <Comp :hoge="msg" />
    <!-- 2 -->
    <Comp :hoge="msg" />
    <!-- 3 -->
    <Comp :hoge="msg" />
+   <div>msg: {{ msg }}</div>
  </v-app>
</template>

<script setup>
  import Comp from './Comp.vue'
  import { ref } from 'vue'

  const msg = ref('Hello World!')
</script>

動作確認

Animation4.gif

コードはこちら (Vuetify Play)

props の hoge 先ほどのエラーに書かれていた通り書き込み不可のため、値の変化を確認するために Comp.vue 内に ローカル変数 local_hoge を用意しました。ついでに色分けもしてます。
孫(紫色)で値を変更すると即座に子(水色)の local_hoge が変化していることがわかりますね。
しかし、もちろん親(白色)の msg までは届きません。頑固おやじのままです。

親子孫三世代で値のやり取りを通す

Comp.vue
<template>
  <v-container class="ma-2 pa-2 bg-cyan-lighten-5">
    <v-text-field
      class="ma-2 pa-2 bg-purple-lighten-5"
     :model-value="hoge"
-     @update:model-value="newValue => local_hoge = newValue"
+     @update:model-value="newValue => $emit('update:hoge', newValue)"
    />
    <div>hoge: {{ hoge }}</div>
  </v-container>
</template>

<script setup>
  const props = defineProps(['hoge'])
</script>
App.vue
<template>
  <v-app>
    <!-- 1 -->
-   <Comp :hoge="msg" />
+   <Comp :hoge="msg" @update:hoge="newValue => msg = newValue" />
    <!-- 2 -->
-   <Comp :hoge="msg" />
+   <Comp :hoge="msg" @update:hoge="newValue => msg = newValue" />
    <!-- 3 -->
-   <Comp :hoge="msg" />
+   <Comp :hoge="msg" @update:hoge="newValue => msg = newValue" />
    <div>msg: {{ msg }}</div>
  </v-app>
</template>

<script setup>
  import Comp from './Comp.vue'
  import { ref } from 'vue'

  const msg = ref('Hello World!')
</script>

やったぜ。

Animation5.gif

コードはこちら (Vuetify Play)

ようやく想定した動きができるようになりました。
しかし、ここまで理解出来たらもう少しスマートにしたくなりますね。
そうです。 v-model を使えるようにしましょう。

最終形

Comp.vue
<template>
  <v-container class="ma-2 pa-2 bg-cyan-lighten-5">
    <v-text-field
      :model-value="modelValue"
      @update:model-value="newValue => $emit('update:modelValue', newValue)"
    />
  </v-container>
</template>

<script setup>
  const props = defineProps(['modelValue'])
</script>
App.vue
<template>
  <v-app>
    <!-- 1 -->
    <Comp v-model="msg" />
    <!-- 2 -->
    <Comp v-model="msg" />
    <!-- 3 -->
    <Comp v-model="msg" />
  </v-app>
</template>

<script setup>
  import Comp from './Comp.vue'
  import { ref } from 'vue'

  const msg = ref('Hello World!')
</script>

コードはこちら (Vuetify Play)

長く苦しい戦いだった。
ようやくこれで before コンポーネント化 と同じ動きになりました。

v-model による双方向バインディング

これまで説明してきたものは、 v-model を使った 双方向バインディング と呼ばれる動きになります。
v-model により双方向バインディングされたリアクティブな値は、親での変更を子に反映させ、逆に子での変更を親に反映させます

うまく使えば非常に便利な機能ではありますが、親、子、孫、ひ孫・・・みたいに何層にもわたってコンポーネント化されたページのすべての層間を理解不足のまま双方向バインディングしてしまうと、どこのコンポーネント内で値が変更されたのかが非常に追い辛くなる恐れもあります。

親から子の変更のみを反映させる状態は 単方向バインディング というのですが、コンポーネントの用途(および、渡したい値の内容)によってはそちらで実装したほうが良い場合も大いにあるわけです。
実は 先ほど props の値渡しだけ実装していた内容 がまさに単方向バインディングになります。

Vuetify をカスタムして部品コンポーネントを作りたい

ようやく表題のカスタムフォーム作成に入ります。

Vuetify に実装されているコンポーネントはシンプルかつ使いやすいものが多いため、大体の機能はそちらをそのまま使いたい。
しかし、「 Vuetify に用意されてないから、その要件は無理でーす:stuck_out_tongue_winking_eye:」なんて問屋が卸しません。(もちろん全部やるわけではないけど・・・)

ということで、 Vuetify をカスタマイズしたようなコンポーネントをいっちょ作ってみよう。

表示形式 yyyy/mm/dd 、内部値 (String) yyyymmdd と扱える入力フォームを作成する。

表示形式のみ特定のフォーマットにし、内部値は別形式で取り持つような Vuetify コンポーネントが意外と見つからなかったため、今回は日付フォーマット形式で表示してくれるコンポーネントを作成したいと思います。

なお、作成にあたり以下の記事を参考にいたしました。

今回使用した Vue.js 3.3.11 + Vuetify 3.4.6 ではそのまま実装することができませんでしたが、とても参考になりました。
また、追加機能として、全角数字を入力してフォーカスアウトした際に自動で半角数字に置き換える機能も追加してみようと思います。(いらん機能だと思うけど)

まずは完成品の前に、うまくイケてない例をお見せいたします。

日付フォーマットのカスタムフォーム(イケてない例)

CustomVTextFieldDate.vue
<template>
  <template v-if="isEdit">
    <v-text-field
      class="bg-purple-lighten-5"
      :autofocus="isEdit"
      :label="label+'_edit'"
      :model-value="editValue"
      @update:model-value="editValue = $event"
      :rules="[rules.required, rules.dateformat,]"
      @blur="format();"
      :maxlength="maxlength"
    />
  </template>
  <template v-else>
    <v-text-field
      class="bg-purple-lighten-5"
      :label="label+'_display'"
      :model-value="dispValue"
      @focus="isEdit=true;"
    />
  </template>
  <div class="bg-blue-lighten-5">props : {{ $props }}</div>
  <div class="bg-blue-lighten-5">isEdit : {{ isEdit }}</div>
  <div class="bg-blue-lighten-5">editValue : {{ editValue }}</div>
  <div class="bg-blue-lighten-5">dispValue : {{ dispValue }}</div>
</template>

<script setup>
  import { ref, unref } from 'vue'

  const props = defineProps(['modelValue', 'label'])
  const emits = defineEmits(['update:modelValue'])
  const rules = {
    required: value => !!value || value === 0 || '必須項目です',
    dateformat: value => {
      return dateRule.test(value) || pattern + '形式で入力してください'
    },
  }
  const pattern = 'yyyymmdd'
  const dateRule = /^[0-9]{4}(0[1-9]|1[0-2])(0[1-9]|[12][0-9]|3[01])$/
  const maxlength = 8
  const toDateFormat = v => {
    if (!dateRule.test(v)) {
      return v
    }
    return v.substring(0, 4) + '/' + v.substring(4, 6) + '/' + v.substring(6, 8)
  }

  const isEdit = ref(false)
  const editValue = ref(unref(props.modelValue))
  const dispValue = ref(unref(props.modelValue))

  function toHalfNumString(str) {
    if (typeof str === 'string' || str instanceof String) {
      // 全角英数字を半角に変換
      str = str.replace(/[0-9]/g, function (s) {
        return String.fromCharCode(s.charCodeAt(0) - 0xfee0)
      })
    }
    return str
  }

  function format() {
    editValue.value = toHalfNumString(editValue.value)
    dispValue.value = toDateFormat(editValue.value)
    emits('update:modelValue', editValue.value)
    if (dateRule.test(editValue.value)) isEdit.value = false
  }

  // init
  format()
</script>
App.vue
<template>
  <v-app>
    <v-container>
      <custom-v-text-field-date v-model="date" label="日付項目" />
      <div>date : {{ date }}</div>
    </v-container>
  </v-app>
</template>

<script setup>
  import { ref } from 'vue'
  import CustomVTextFieldDate from './CustomVTextFieldDate.vue'
  const date = ref('20231223')
</script>

動作確認

Animation6.gif

コードはこちら (Vuetify Play)

おおまかな仕組みとしては、表示用の v-text-field と入力用の v-text-field をフォーカスが当たった時に切り替えている( @focus="isEdit=true;" )だけです。
表示用では dispValue を、入力用では editValue をコンポーネント内部で持っており、フォーカスが外れたタイミングで全角数字を半角に変換し、親に emit しています。( @blur="format();"

これまでの例と書き方が違うところは、 v-text-field からの emit を受け取る @update:model-value="editValue = $event" と、
呼び出し元に emit する emits('update:modelValue', editValue.value) の2点ですね。
あくまで書き方が異なるだけで、 emit しているということに変わりはありません。
また、 props の一つである label は書き換えられたら困るため emit していません。(単方向バインディング)

どこがイケてない?

Animation7.gif

コードはこちら (Vuetify Play)

ご覧の通り、親から値を書き換えたときにコンポーネントに反映がされません。それどころか部品側にフォーカスイン・アウトすると残ってた editValue の値に戻されてしまいます。
これはいけません。親側の処理で対象の v-model が変更された際、部品側にも反映されないと困ります。

解決策としては、部品内部の editValue を廃止して、

CustomVTextFieldDate.vue(抜粋)
.
.
.
<template>
  <template v-if="isEdit">
    <v-text-field
      class="bg-purple-lighten-5"
      :autofocus="isEdit"
      :label="label+'_edit'"
-     :model-value="editValue"
-     @update:model-value="editValue = $event"
+     :model-value="modelValue"
+     @update:model-value="$emit('update:model-value', $event)"
      :rules="[rules.required, rules.dateformat,]"
      @blur="format();"
      :maxlength="maxlength"
    />
  </template>
.
.
.  

とすれば、 v-model を直接的に双方向バインディングしているため親の変更は反映されます。
しかし、全角数字を半角にする処理もあるため、できればフォーカスアウトした際に親側に反映してほしいところ。

子から親への更新タイミングは今のままで、親から値が変わったことを子が検知する。。。
うーん。。。

あ。そうだ、あれだ。

watch を使って部品側から props を監視する

watch は値の監視を行うことができ、
値が変更された際に任意の処理を実行することができます。
これを使えば、親側で props.modelValue が変更された際、コンポーネント内の editValue 等に代入することができます。やったね。

watch を使った解決(完成版)

CustomVTextFieldDate.vue
<template>
  <template v-if="isEdit">
    <v-text-field
      class="bg-purple-lighten-5"
      :autofocus="isEdit"
      :label="label+'_edit'"
      :model-value="editValue"
      @update:model-value="editValue = $event"
      :rules="[rules.required, rules.dateformat,]"
      @blur="format();"
      :maxlength="maxlength"
    />
  </template>
  <template v-else>
    <v-text-field
      class="bg-purple-lighten-5"
      :label="label+'_display'"
      :model-value="dispValue"
      @focus="isEdit=true;"
+     :rules="[rules.required, rules.dateformat_disp,]"
    />
  </template>
  <div class="bg-blue-lighten-5">props : {{ $props }}</div>
  <div class="bg-blue-lighten-5">isEdit : {{ isEdit }}</div>
  <div class="bg-blue-lighten-5">editValue : {{ editValue }}</div>
  <div class="bg-blue-lighten-5">dispValue : {{ dispValue }}</div>
</template>

<script setup>
- import { ref, unref } from 'vue'
+ import { ref, unref, watch } from 'vue'

  const props = defineProps(['modelValue', 'label'])
  const emits = defineEmits(['update:modelValue'])
  const rules = {
    required: value => !!value || value === 0 || '必須項目です',
    dateformat: value => {
      return dateRule.test(value) || pattern + '形式で入力してください'
    },
+   dateformat_disp: value => {
+     return dateRule_disp.test(value) || pattern + '形式で入力してください'
+   },
  }
  const pattern = 'yyyymmdd'
  const dateRule = /^[0-9]{4}(0[1-9]|1[0-2])(0[1-9]|[12][0-9]|3[01])$/
+ const dateRule_disp = /^[0-9]{4}\/(0[1-9]|1[0-2])\/(0[1-9]|[12][0-9]|3[01])$/
  const maxlength = 8
  const toDateFormat = v => {
    if (!dateRule.test(v)) {
      return v
    }
    return v.substring(0, 4) + '/' + v.substring(4, 6) + '/' + v.substring(6, 8)
  }

  const isEdit = ref(false)
  const editValue = ref(unref(props.modelValue))
  const dispValue = ref(unref(props.modelValue))

  function toHalfNumString(str) {
    if (typeof str === 'string' || str instanceof String) {
      // 全角英数字を半角に変換
      str = str.replace(/[0-9]/g, function (s) {
        return String.fromCharCode(s.charCodeAt(0) - 0xfee0)
      })
    }
    return str
  }

  function format() {
    editValue.value = toHalfNumString(editValue.value)
    dispValue.value = toDateFormat(editValue.value)
    emits('update:modelValue', editValue.value)
    if (dateRule.test(editValue.value)) isEdit.value = false
  }

  // init
  format()

+ // watch
+ watch(
+   () => props.modelValue,
+   () => {
+     editValue.value = unref(props.modelValue)
+     dispValue.value = unref('')
+     format()
+   }
+ )
</script>

動作確認

Animation.gif

コードはこちら (Vuetify Play)

成し遂げたぜ。
watch 以外にも dateformat_disp なるものが追加されていますが、これは親側から値が書き換わった際、 disp 状態のまま入力チェックを行うために追加しています。

これで行いたかった動作ができる部品コンポーネントができました:clap:

おしまい

本記事でまとめた内容は Vuetify に限らず、Vue3 対応の他の UI コンポーネントでも同じ考え方でカスタムすることができると思います。
特に、v-model の双方向バインディングについては概念はわかっていても実装上の挙動を理解できていなかったので、今回まとめたおかげでだいぶ整理ができました。
このような記事を書いたことは初めてでしたが、やはりアウトプットは大切だなぁとしみじみ実感いたしました。このような機会をいただけて感謝感謝です。

72
11
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
72
11