Svelte で PWA (台本ビューア) <第10回>

[PWA で作った台本ビューア](https://github.com/satamame/pscv/tree/0.0.2) を [Svelte](https://svelte.jp) で作り直すことにしたので、やったことをメモしていきます。

- 前回 : [<第9回> 台本一覧を D&D で並べ替え](https://qiita.com/satamame/items/167d71f9666ffe8a329c)
- 次回 : [<第11回> Svelte 5 対応](https://qiita.com/satamame/items/430d53bff0dc259d613a)
- [シリーズ記事一覧](https://qiita.com/satamame/items/3e0683a1fc6ecd6b66ee#%E3%82%B7%E3%83%AA%E3%83%BC%E3%82%BA%E8%A8%98%E4%BA%8B%E4%B8%80%E8%A6%A7)

# <第10回> Svelte, TS, Dexie の更新

1年近く放置していたので、今回はライブラリの更新でいろいろ対応した話を書きます。  
更新した主なライブラリは以下のとおりです。

- Svelte : 3.49.0 -> 4.2.12
- TypeScript : 4.6.4 -> 5.4.3
- Dexie : 3.2.3 -> 4.0.1

他にもプラグイン等を更新しています。執筆時点での package.json は [こちら](https://github.com/satamame/pscv/blob/qiita.10/package.json)。

**今回の概要**

- Svelte, TypeScript 周りの更新
    - 型チェック対応
    - a11y 対応
- Dexie の更新

今回使うソースコードは [GitHub のこちらのコミット](https://github.com/satamame/pscv/tree/qiita.10) にあります。

# Svelte, TypeScript 周りの更新

## 間が空いてしまった時の更新

プラグインも含め、Svelte, TypeScript 周りのライブラリは、更新する時は一度にするのが良いようです。

一部のライブラリだけ更新すると、あるライブラリが依存するライブラリの廃止された機能を使っていてエラーになっても、原因がそれと分からなかったりします。

## 型チェック対応

Svelte, TypeScirpt 周りを更新したことによって、.svelte ファイルでの型チェックがちゃんと行われるようになったようです。  
VSCode の拡張機能「Svelte for VS Code」が更新されているのも関係あると思います。

ほとんどは [型ガード](https://typescript-jp.gitbook.io/deep-dive/type-system/typeguard) と [型アサーション](https://typescript-jp.gitbook.io/deep-dive/type-system/type-assertion) で対応できました。  
それ以外だと、ストアに型を指定する必要があったので、それについて説明します。

### ストアに型を指定する

ここでいう「ストア」は、[Svelte のストア](https://svelte.jp/docs/svelte-store) のことです。

[第3回](https://qiita.com/satamame/items/36c6761d363ca3894824) の[「更新用関数を保持するストア」](https://qiita.com/satamame/items/36c6761d363ca3894824#%E6%9B%B4%E6%96%B0%E7%94%A8%E9%96%A2%E6%95%B0%E3%82%92%E4%BF%9D%E6%8C%81%E3%81%99%E3%82%8B%E3%82%B9%E3%83%88%E3%82%A2)で、**関数**を保持するストアを作りました。  
この時、特に「関数を保持する」という指定をせず初期値を `null` にしていました。

```typescript:src/lib/store.ts (再掲)
import { writable } from 'svelte/store'

export const appUpdateFunc = writable(null)
```

そのため、ちゃんと型チェックがされるようになると、このストアの中身を関数として実行するところでエラーになりました。

```svelte:src/components/About.svelte (抜粋)
<script lang="ts">
  import { appUpdateFunc } from '../lib/store'
  /* 中略 */
  function update() {
    isLoading = true
    $appUpdateFunc(true)  // ここでエラー
  }
  /* 中略 */
</script>
```
```
Cannot invoke an object which is possibly 'null'.
```

実際の動きとしては、`appUpdateFunc` に関数が保持されている時だけ `update()` が呼ばれるのですが、静的解析により `appUpdateFunc` の内容が `null` である可能性を指摘されます。

初期値が `null` なのは仕方ないので、とりあえず型ガードは必要です。  
ただ、型ガードをしても (`null` でないことが保証されても) 呼び出し可能 (callable) でないとエラーになります。

```svelte:src/components/About.svelte
<script lang="ts">
  /* 中略 */
  function update() {
    if ($appUpdateFunc != null) { // 型ガード
      isLoading = true
      $appUpdateFunc(true)  // ここでエラー
    }
  }
  /* 中略 */
</script>
```
```
This expression is not callable.
  Type 'never' has no call signatures.
```

以下のように、ストアに [ジェネリクス](https://typescript-jp.gitbook.io/deep-dive/type-system/generics) を含む型を指定すれば、「null でなければ関数である」ということが明確になり、エラーが解消します。

```typescript:src/lib/store.ts (抜粋)
export const appUpdateFunc: Writable<Function | null> = writable(null)
```

### ライブラリの型定義ファイル

本プロジェクトでは [`body-scroll-lock`](https://www.npmjs.com/package/body-scroll-lock) というライブラリを使っています。  
これは [第7回で導入](https://qiita.com/satamame/items/27439b805e3a0cccdcc7#body-scroll-lock-%E3%81%AE%E5%B0%8E%E5%85%A5) したものです。

[これの型定義ファイル](https://www.npmjs.com/package/@types/body-scroll-lock) をインストールしていなかったために型チェックでエラーが出るようになりました。  
対応としては、package.json で型定義ファイルを読込むようにしました。

```json:package.json (抜粋)
{
  ...,
  "devDependencies": {
    "@types/body-scroll-lock": "^3.1.2",
    ...
  },
  ...
}
```

## a11y 対応

a11y (アクセシビリティ) についての警告も出るようになりました。  
この警告については Svelte の公式サイトに詳しく書かれています ([こちら](https://svelte.jp/docs/accessibility-warnings))。  
ここでは私が対応した警告について説明します。

### 対応した警告

主に以下の3種類の警告が出たので、これらに対応しました。

1. > A11y: Non-interactive element \<img> should not be assigned mouse or keyboard event listeners.
1. > A11y: visible, non-interactive elements with an on:click event must be accompanied by a keyboard event handler.
1. > A11y: \<div> with click handler must have an ARIA role

1番は、本来インタラクティブでない要素 (`img` など) でマウスイベントやキーボードイベントを受け付けるな、と言っています。

2番は、本来インタラクティブでない要素に `on:click` イベントを付けたなら、キーボードにも反応すべき、という事を言っています。  
1番と矛盾するように思うかも知れませんが、あくまで警告なので すべてを禁止する訳ではなく、「少しでもマシな対応をして」という事かと思います。

3番は、なぜ `<div>` にクリックハンドラがあるのか、意味を示せという事です。  
ARIA で規定されている [role](https://developer.mozilla.org/ja/docs/Web/Accessibility/ARIA/Roles) を示すことで分かってもらえるようです。

### 対応その1 - ボタンにする

`on:click` をしたい要素は、出来るならボタン (`button` 要素) にしてしまいます。  
CSS でブロック要素っぽくしたり境界を調整したりすれば、見た目は何とかなります。

### 対応その2 - キーイベントを受け付ける

クリックに反応する要素をボタンにできない (したくない) 場合もあります。  
たとえば、以下の `<div>` は その中にボタンを持っているので、この `<div>` をボタンにする事はできません。  
できるかも知れませんが、一応 HTML のルールでは `button` の中に `button` を持たせることは非推奨となっています。

![10_button_inside_div.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/b6bef2d1-6bc6-3439-9a4d-30b1900b365f.png)

先ほどの「クリックできるならキーボードにも反応すべき」という警告をクリアするために、Enter キーが押されたらクリック時と同じハンドラを呼ぶようにします。

```svelte:src/components/DataCell.svelte (抜粋)
<div
  id="{cellId}"
  class="cell bottom-line"
  on:click="{showPSc}"
  on:keydown="{e => e.key == 'Enter' && showPSc()}"
  role="button"
  tabindex="0"
>
  <!-- 中略 -->
</div>
```

ついでに `role` 属性と `tabindex` 属性も追加しました。

`role` 属性は、先ほど「対応した警告」で見た3つ目の警告に対応するためです。  
値は、クリックや Enter キーに反応するなら ["button"](https://developer.mozilla.org/ja/docs/Web/Accessibility/ARIA/Roles/button_role) で良いと思います。

ここで `tabindex` をつけないと、さらに「キーボードに反応するなら Tab キーでフォーカスできるべき」という警告が出ます。  
この `<div>` は [`{#each}`](https://svelte.jp/docs/logic-blocks#each) で複数 生成されますが、すべて `tabindex="0"` にしておけば、ブラウザがいい感じに順番を決めてくれます。

### 対応その3 - 警告を無効化する

ここまでのやり方で対応できないケースもあるかと思います。  
そんな時は a11y の警告が出ないようにすることもできます。

たとえばダイアログの表示中、オーバーレイの部分をクリック (タッチ) したらダイアログを閉じるようにしたいです。  
でも Tab キーでオーバーレイにフォーカスしたり、オーバーレイがキーボードに反応したりするのは変なので、こういう場合は警告を無効化します。

![10_overlay_clickable_no_focus.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/e189de8a-d4de-e174-3747-b7dac7e0a89b.png)

無効化の仕方は公式サイトの [Accessibility warnings](https://svelte.dev/docs/accessibility-warnings) というページに説明があります。  
以下、VSCode 上での具体的なやり方を説明します。

上記のオーバーレイにはこんな警告が出ています。

![10_overlay_ignore_a11y_warnings_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/0200e611-bc47-3054-9697-5dc711e3353d.png)

警告メッセージの後に警告の種類を表すコードがあるので、先ほどの [Accessibility warnings](https://svelte.dev/docs/accessibility-warnings) というページでこれを探せば無効化の仕方が分かります。  
VSCode 上では、このメッセージを右クリックして、コンテクスト・メニューから無効化用のコメントを入れることもできます。

![10_overlay_ignore_a11y_warnings_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/b1f2be17-bb06-20e7-c14f-a0ee7bb909c4.png)

警告の種類を選択すると、対象の要素の上に無効化用のコメントが入ります。

![10_overlay_ignore_a11y_warnings_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/4511c58d-da6e-5887-ba6b-749b4e61cecb.png)

もうひとつの警告も無効化すると、コードは以下のようになります。  
複数の無効化用のコメントを、対象の要素の上に積み上げて適用します。

```svelte:src/components/UI/Overlay.svelte (抜粋)
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div
  style:background-color="{color}"
  style:opacity
  on:click
  role="dialog"
></div>
```

## Tab キーでのフォーカスを抑制する

クリック可能な画像を `button` にしたり、`tabindex` を追加したりしたことによって、今までは触れなかったボタンが触れるようになりました。  
下図は、オーバーレイの下の今まで触れなかったボタンが Tab キーによってフォーカスされているところです。  
Enter キーを押せばこのボタンを押せてしまいます。

![10_tab_focus_under_overlay.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/9b8365a2-5202-22e6-1fea-7e54f52946db.png)

オーバーレイの下には他にもフォーカス可能な要素があるので、これらをまとめて触れないようにすべきです。  
それには、それらの親となる要素に HTML の [`inert` 属性](https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/inert) を付けます。

あるコンポーネント (ここでは Viewer.svelte) をキーに反応しないようにするには、そのコンポーネントのルート要素に `inert` 属性を付けて、モーダルな時は `true` になるようにします。

```svelte:src/components/Viewer.svelte (抜粋)
<script lang="ts">
  export let inert: boolean
  /* 中略 */
</script>

<div bind:this="{container}" class="container" inert="{inert}">
  <!-- 中略 -->
</div>
```

`inert` をコンポーネントのプロパティにしているのは、モーダルかどうかが親 (App.svelte) によって決まるからです。  
App.svelte には元々 `isModal` という変数があったので (*1)、これを使って子コンポーネントにモーダル状態を連携すればいいです。

> (*1) [第1回の「スクロールを固定する」参照](https://qiita.com/satamame/items/3e0683a1fc6ecd6b66ee#%E3%82%B9%E3%82%AF%E3%83%AD%E3%83%BC%E3%83%AB%E3%82%92%E5%9B%BA%E5%AE%9A%E3%81%99%E3%82%8B)

```svelte:src/App.svelte (抜粋)
<Viewer
  bind:this="{viewer}"
  bind:psc
  bind:top="{viewerTop}"
  bind:inert="{isModal}"
/>
```

# Dexie の更新

Dexie は、[第8回](https://qiita.com/satamame/items/2e9794d6b084b86b2044) で導入した [IndexedDB](https://developer.mozilla.org/ja/docs/Web/API/IndexedDB_API) のためのライブラリです。  
4.x からは、テーブルの `id` 列 (プライマリキー) をオプショナルにしなくて良くなりました。

```typescript:src/lib/db.ts (変更前・変更後の抜粋)
/* 今まで */
export interface ScriptIndex {
  id?: number       // オプショナル
  name: string
  scriptId: number
}

/* 4.x 以降 */
export interface ScriptIndex {
  id: number        // 必須で良い
  name: string
  scriptId: number
}
```

:::note info
`id` 列をオプショナルにしていた理由
これまでは DB に挿入するオブジェクトも DB から取り出すオブジェクトも同じ型を使っていました。挿入するオブジェクトはまだプライマリキーが割り当てられていないので、`id` が `undefined` という状態を許す必要があったのです。
:::

`id` 列がオプショナルだと、あちこちで「それは数値型には代入できない」と言われます。  
今回、型チェックがちゃんと行われるようになったのは良いのですが、`scIndex.id` を参照するところで毎回 型ガードや型アサーションをしたくありません。  
なので この Dexie の改善はとても嬉しいです。

`id` を必須にしても大丈夫なからくりは、Dexie に新しく導入された `EntityTable` 型にあります。  
結論からいうと以下のようにスキーマ定義することで、`id` のないオブジェクトを DB に挿入できるようになります。  
なぜこれで良いのかの詳しい解説については、リリースノートの [Improved Typing](https://github.com/dexie/Dexie.js/releases/tag/v4.0.1#:~:text=data%20between%20users.-,Improved%20Typing,-Dexie%204%20has) をご一読ください。

```typescript:src/lib/db.ts (変更前・変更後の抜粋)
/* 今まで */
import type { IndexableType, Table } from 'dexie'
class PscvDB extends Dexie {
  scriptIndex!: Table<ScriptIndex>
  /* 中略 */
}

/* 4.x 以降 */
import type { EntityTable } from 'dexie'
class PscvDB extends Dexie {
  scriptIndex!: EntityTable<ScriptIndex, 'id'>
  /* 中略 */
}
```

簡単にいうと、`Table` の代わりに `EntityTable` を使ってジェネリクスの2番目の型引数に 'id' と書いておけば、`id` 列をオプショナルにしなくても `id` 列のないオブジェクトを挿入できるようになります。

また、プライマリキーを引数に取る関数 (`delete` 等) でも `IndexableType` を使う必要がなくなりました。

```typescript:src/lib/db.ts (変更前・変更後の抜粋)
/* 今まで */
await this.scriptIndex.delete(scIndex.id as IndexableType)

/* 4.x 以降 */
await this.scriptIndex.delete(scIndex.id)
```