PWA で作った台本ビューア を Svelte で作り直すことにしたので、やったことをメモしていきます。
- 前回 : <第9回> 台本一覧を D&D で並べ替え
- 次回 : <第11回> Svelte 5 対応
- シリーズ記事一覧
<第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 は こちら。
今回の概要
- Svelte, TypeScript 周りの更新
- 型チェック対応
- a11y 対応
- Dexie の更新
今回使うソースコードは GitHub のこちらのコミット にあります。
Svelte, TypeScript 周りの更新
間が空いてしまった時の更新
プラグインも含め、Svelte, TypeScript 周りのライブラリは、更新する時は一度にするのが良いようです。
一部のライブラリだけ更新すると、あるライブラリが依存するライブラリの廃止された機能を使っていてエラーになっても、原因がそれと分からなかったりします。
型チェック対応
Svelte, TypeScirpt 周りを更新したことによって、.svelte ファイルでの型チェックがちゃんと行われるようになったようです。
VSCode の拡張機能「Svelte for VS Code」が更新されているのも関係あると思います。
ほとんどは 型ガード と 型アサーション で対応できました。
それ以外だと、ストアに型を指定する必要があったので、それについて説明します。
ストアに型を指定する
ここでいう「ストア」は、Svelte のストア のことです。
第3回 の「更新用関数を保持するストア」で、関数を保持するストアを作りました。
この時、特に「関数を保持する」という指定をせず初期値を null
にしていました。
import { writable } from 'svelte/store'
export const appUpdateFunc = writable(null)
そのため、ちゃんと型チェックがされるようになると、このストアの中身を関数として実行するところでエラーになりました。
<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) でないとエラーになります。
<script lang="ts">
/* 中略 */
function update() {
if ($appUpdateFunc != null) { // 型ガード
isLoading = true
$appUpdateFunc(true) // ここでエラー
}
}
/* 中略 */
</script>
This expression is not callable.
Type 'never' has no call signatures.
以下のように、ストアに ジェネリクス を含む型を指定すれば、「null でなければ関数である」ということが明確になり、エラーが解消します。
export const appUpdateFunc: Writable<Function | null> = writable(null)
ライブラリの型定義ファイル
本プロジェクトでは body-scroll-lock
というライブラリを使っています。
これは 第7回で導入 したものです。
これの型定義ファイル をインストールしていなかったために型チェックでエラーが出るようになりました。
対応としては、package.json で型定義ファイルを読込むようにしました。
{
...,
"devDependencies": {
"@types/body-scroll-lock": "^3.1.2",
...
},
...
}
a11y 対応
a11y (アクセシビリティ) についての警告も出るようになりました。
この警告については Svelte の公式サイトに詳しく書かれています (こちら)。
ここでは私が対応した警告について説明します。
対応した警告
主に以下の3種類の警告が出たので、これらに対応しました。
-
A11y: Non-interactive element <img> should not be assigned mouse or keyboard event listeners.
-
A11y: visible, non-interactive elements with an on:click event must be accompanied by a keyboard event handler.
-
A11y: <div> with click handler must have an ARIA role
1番は、本来インタラクティブでない要素 (img
など) でマウスイベントやキーボードイベントを受け付けるな、と言っています。
2番は、本来インタラクティブでない要素に on:click
イベントを付けたなら、キーボードにも反応すべき、という事を言っています。
1番と矛盾するように思うかも知れませんが、あくまで警告なので すべてを禁止する訳ではなく、「少しでもマシな対応をして」という事かと思います。
3番は、なぜ <div>
にクリックハンドラがあるのか、意味を示せという事です。
ARIA で規定されている role を示すことで分かってもらえるようです。
対応その1 - ボタンにする
on:click
をしたい要素は、出来るならボタン (button
要素) にしてしまいます。
CSS でブロック要素っぽくしたり境界を調整したりすれば、見た目は何とかなります。
対応その2 - キーイベントを受け付ける
クリックに反応する要素をボタンにできない (したくない) 場合もあります。
たとえば、以下の <div>
は その中にボタンを持っているので、この <div>
をボタンにする事はできません。
できるかも知れませんが、一応 HTML のルールでは button
の中に button
を持たせることは非推奨となっています。
先ほどの「クリックできるならキーボードにも反応すべき」という警告をクリアするために、Enter キーが押されたらクリック時と同じハンドラを呼ぶようにします。
<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" で良いと思います。
ここで tabindex
をつけないと、さらに「キーボードに反応するなら Tab キーでフォーカスできるべき」という警告が出ます。
この <div>
は {#each}
で複数 生成されますが、すべて tabindex="0"
にしておけば、ブラウザがいい感じに順番を決めてくれます。
対応その3 - 警告を無効化する
ここまでのやり方で対応できないケースもあるかと思います。
そんな時は a11y の警告が出ないようにすることもできます。
たとえばダイアログの表示中、オーバーレイの部分をクリック (タッチ) したらダイアログを閉じるようにしたいです。
でも Tab キーでオーバーレイにフォーカスしたり、オーバーレイがキーボードに反応したりするのは変なので、こういう場合は警告を無効化します。
無効化の仕方は公式サイトの Accessibility warnings というページに説明があります。
以下、VSCode 上での具体的なやり方を説明します。
上記のオーバーレイにはこんな警告が出ています。
警告メッセージの後に警告の種類を表すコードがあるので、先ほどの Accessibility warnings というページでこれを探せば無効化の仕方が分かります。
VSCode 上では、このメッセージを右クリックして、コンテクスト・メニューから無効化用のコメントを入れることもできます。
警告の種類を選択すると、対象の要素の上に無効化用のコメントが入ります。
もうひとつの警告も無効化すると、コードは以下のようになります。
複数の無効化用のコメントを、対象の要素の上に積み上げて適用します。
<!-- 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 キーを押せばこのボタンを押せてしまいます。
オーバーレイの下には他にもフォーカス可能な要素があるので、これらをまとめて触れないようにすべきです。
それには、それらの親となる要素に HTML の inert
属性 を付けます。
あるコンポーネント (ここでは Viewer.svelte) をキーに反応しないようにするには、そのコンポーネントのルート要素に inert
属性を付けて、モーダルな時は true
になるようにします。
<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回の「スクロールを固定する」参照
<Viewer
bind:this="{viewer}"
bind:psc
bind:top="{viewerTop}"
bind:inert="{isModal}"
/>
Dexie の更新
Dexie は、第8回 で導入した IndexedDB のためのライブラリです。
4.x からは、テーブルの id
列 (プライマリキー) をオプショナルにしなくて良くなりました。
/* 今まで */
export interface ScriptIndex {
id?: number // オプショナル
name: string
scriptId: number
}
/* 4.x 以降 */
export interface ScriptIndex {
id: number // 必須で良い
name: string
scriptId: number
}
id
列をオプショナルにしていた理由
これまでは DB に挿入するオブジェクトも DB から取り出すオブジェクトも同じ型を使っていました。挿入するオブジェクトはまだプライマリキーが割り当てられていないので、id
が undefined
という状態を許す必要があったのです。
id
列がオプショナルだと、あちこちで「それは数値型には代入できない」と言われます。
今回、型チェックがちゃんと行われるようになったのは良いのですが、scIndex.id
を参照するところで毎回 型ガードや型アサーションをしたくありません。
なので この Dexie の改善はとても嬉しいです。
id
を必須にしても大丈夫なからくりは、Dexie に新しく導入された EntityTable
型にあります。
結論からいうと以下のようにスキーマ定義することで、id
のないオブジェクトを DB に挿入できるようになります。
なぜこれで良いのかの詳しい解説については、リリースノートの Improved Typing をご一読ください。
/* 今まで */
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
を使う必要がなくなりました。
/* 今まで */
await this.scriptIndex.delete(scIndex.id as IndexableType)
/* 4.x 以降 */
await this.scriptIndex.delete(scIndex.id)