Date型を直接取り扱えるInput要素をVue3で作る。

よく作る割に忘れてしまいがちなので記事としてまとめる。

# 環境
- Nodejs18以降
- Vue@3.4.21以降
- Date-fns@3.5.0以降

# コード
とりあえず全体のコード。invalid Dateになる場合はnullを返すようにしている。

```html:DatetimeInput.vue
<template>
<!-- Date型を受け付けるinputのカスタムコンポーネント -->
    <input v-model="value" type="datetime-local" />
</template>

<script setup lang="ts">
import {  computed } from 'vue'
import { parse,format, isValid } from 'date-fns'
const formatString = "yyyy-MM-dd'T'HH:mm"

interface Props {
  modelValue: Date|null
}
const props = withDefaults(defineProps<Props>(), {
  modelValue: null
})

interface Emits{
    (e:"update:modelValue",value:Date|null):void
    (e:"error"):void
}
const emits = defineEmits<Emits>()

const value =computed({
    get:():string=>{
        if(!props.modelValue || !isValid(props.modelValue)){
            emits('error')    
            return ""
        }
            return format(props.modelValue,formatString)||""
    },
    set(v:string){
        const d = parse(v,formatString,new Date())
        if(!isValid(d)){
            emits('error')   
            emits('update:modelValue',null)
            return
        }    
        emits('update:modelValue',d)
    }
})
</script>
```
以下説明
# 説明
## template
type属性に"datetime-local"を指定したinput要素だけ。ただしmodelValueではなくvalue(後述)をバインディングしている。

```html:DatetimeInput.vue
<template>
    <input v-model="value" type="datetime-local" />
</template>
```
### 互換性
MDNのページではtype="datetime-local"を指定した場合の互換性についての注意書きがある。safariでは日付の入力欄しか出ないようなので注意。

> datetime-local は限られたブラウザーしか対応しておらず、入力欄の動作が様々であるため、現在はこれを表示するためにフレームワークやライブラリーを使用するか、独自のカスタム入力欄をした方が良いかもしれません。また、 date と time の入力欄を別々に使用すると、 datetime-local よりも対応が広くなります。

[\<input type="datetime-local"\> - HTML: ハイパーテキストマークアップ言語 | MDN](https://developer.mozilla.org/ja/docs/Web/HTML/Element/input/datetime-local)

## script
### Vue3の双方向バインディング
Vue3になって破壊的変更が入った。v-modelで指定されるpropの初期プロパティ名はmodelValueになり、イベント名も"update:modelValue"に変更されている。
[v-model | Vue 3 移行ガイド](https://v3-migration.vuejs.org/ja/breaking-changes/v-model)

```typescript:DatetimeInput.vue
interface Props {
  modelValue: Date|null
}
const props = withDefaults(defineProps<Props>(), {
  modelValue: null
})

interface Emits{
    (e:"update:modelValue",value:Date|null):void
    (e:"error"):void // なくてもいい。
}
const emits = defineEmits<Emits>()
```

### 算出プロパティを使ってemit
双方向バインディングの書き方は他にも色々あると思うが、個人的にcomputedを使うのが好みなのでこの書き方にしている。算出プロパティは通常読み取り専用だが、computed()関数の引数をgetter関数とsetter関数を含むオブジェクトにすると代入された際の処理を追加できる。

> 算出プロパティは、デフォルトでは getter 関数のみです。算出プロパティに新しい値を代入しようとすると、ランタイム警告が表示されます。まれに「書き込み可能な」算出プロパティが必要な場合があります。その場合は getter 関数と setter 関数の両方を提供することで、それを作成することができます:

[算出プロパティ | Vue.js](https://ja.vuejs.org/guide/essentials/computed.html#writable-computed)

これでinput要素とバインディングするための変数valueを作る。この変数valueはinput要素から見ると常に文字列を返すが、新しい値が代入されると親コンポーネントに対してDateかnullをemitする。

```typescript:DatetimeInput.vue
import {  computed } from 'vue'
import { parse,format, isValid } from 'date-fns'

// これをinput要素と双方向バインディングさせる。
const value =computed({
    get:():string=>{
        if(!props.modelValue || !isValid(props.modelValue)){
            emits('error',"get" )  
            // inputで扱えるように文字列を返す。
            return ""
        }
            // inputで扱えるように文字列を返す。
            return format(props.modelValue,formatString)||""
    },
    // input要素からは文字列が代入される。
    set(v:string){
        // Date型に変換
        const d = parse(v,formatString,new Date())
        // invalid Dateならnullをemitしてreturn
        if(!isValid(d)){
            emits('error',"set" )   
            emits('update:modelValue',null)
            return
        }    
        // 問題なければemit
        emits('update:modelValue',d)
    }
})
```

「 __まれに__「書き込み可能な」算出プロパティが必要な場合があります。」とあるのでこういう書き方は一般的でないかもしれない。

### date-fnsでエスケープしたい。
type="datetime-local"を指定したinput要素の値は"YYYY-MM-DDThh:mm"の形式になるが、date-fnsのparse()関数でTを指定するとタイムスタンプになってしまう。公式ドキュメント曰く「シングルクォーテーションで囲むと変換しない」との事。
> The characters in the format string wrapped between two single quotes characters (') are escaped. Two single quotes in a row, whether inside or outside a quoted sequence, represent a 'real' single quote.

[date-fns - modern JavaScript date utility library](https://date-fns.org/v3.6.0/docs/parse)

```typescript:DatetimeInput.vue
// 大文字のまま使うとエラーメッセージで丁寧に小文字にするよう表示される。
const formatString = "yyyy-MM-dd'T'HH:mm"
```
# 結果
こういう感じでコンポーネントを使ってみる。
```html:ebi.vue
<template>
 <v-container>
  <v-row >
    <v-col>
      <DatetimeInput  v-model="d" />
      <p>{{ d}}</p>
     </v-col>
    </v-row>
  </v-container>
  </template>

<script setup lang="ts">
import { ref } from 'vue'
import DatetimeInput from '../components/DatetimeInput.vue'

const d = ref<Date|null>(null)

</script>
```

Devtool上でDate型となっていれば正解。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/425022/a73c88d1-65c9-79fd-8285-50e1b2928ae7.png)
Date型に変換できない場合はnullを返す。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/425022/81cbf96c-290c-0fb5-0088-da99d5e3f253.png)