はじめに
Web components ベースのフロントエンドUIフレームワーク Lit の入門記事です。
前回は Lit コンポーネントの作り方について簡単に説明しました。
今回は Lit による ToDo コンポーネントを Vite を使って構築していきます(と言っても、内容は本家のチュートリアルのまんまです)。
Vite については以下を参照してください。
Vite によるプロジェクト作成
Vite でプロジェクト作成します。
プロジェクト名は todo-list とし、TypeScript を選択します。
$ npm create vite@latest ✔ Project name: … todo-list ✔ Select a framework: › Lit ✔ Select a variant: › TypeScript Scaffolding project in /...todo-list... Done. Now run: cd todo-list npm install npm run dev
作成されたひな形のままでも良いですが、todo-list.ts
として実装することにするので、プロジェクトルートの index.html
を以下のように変更します。
<!DOCTYPE html> <html> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>ToDo List</title> <script type="module" src="/src/todo-list.ts"></script> </head> <body> <todo-list></todo-list> </body> </html>
Vite の設定ファイルである vite.config.ts
を以下のように変更します。
import { defineConfig } from 'vite' // https://vitejs.dev/config/ export default defineConfig({ build: { lib: { entry: 'src/todo-list.ts', formats: ['es'] }, rollupOptions: { external: /^lit/ } } })
Vite の defineConfig ヘルパを使用しています。
build.lib によりライブラリモードの設定とし、エントリポイントとして todo-list.ts
を指定しています。
rollupOptions.external では、lit の依存関係をライブラリにバンドルしない設定になります。
todo-list コンポーネントの作成
todo-list コンポーネント src/todo-list.ts
を追加して以下のように編集します。
import {LitElement, html} from 'lit'; import {customElement} from 'lit/decorators.js'; @customElement('todo-list') export class ToDoList extends LitElement { override render() { return html` <h2>To Do</h2> <ul></ul> <input id="newitem" aria-label="New item"> <button @click=${this.addToDo}>Add</button> `; } addToDo() { } } declare global { interface HTMLElementTagNameMap { 'todo-list': ToDoList } }
ToDoList クラスを作成し、カスタム要素として todo-list
という名前を付けました。
render()
メソッドでは、テキスト入力と、ToDo追加用のボタンを html リテラルで定義しています。
ボタンクリック時のイベントリスナを addToDo()
とし、現在は空実装としています。
ここまでの内容で、プロジェクトを実行してみましょう。
$ cd todo-list $ npm install $ npm run dev
http://localhost:5173/
にアクセスすれば、以下のような画面が表示されます。
ToDo アイテムの追加
ToDo アイテムを 内部リアクティブステート として定義し、ボタンのクリックでアイテムが追加できるようにします。
最初に完了後のソースを示します。
import {LitElement, html} from 'lit'; import {customElement, state, query} from 'lit/decorators.js'; @customElement('todo-list') export class ToDoList extends LitElement { @state() private _listItems = [ { text: 'Start Lit tutorial', completed: true }, { text: 'Make to-do list', completed: false } ]; @query('#newitem') input!: HTMLInputElement; override render() { return html` <h2>To Do</h2> <ul> ${this._listItems.map((item) => html`<li>${item.text}</li>` )} </ul> <input id="newitem" aria-label="New item"> <button @click=${this.addToDo}>Add</button> `; } addToDo() { this._listItems = [...this._listItems, {text: this.input.value, completed: false}]; this.input.value = ''; } } declare global { interface HTMLElementTagNameMap { 'todo-list': ToDoList } }
内部リアクティブステートには @state
ディレクティブを使うので、インポートに追加します。
import {customElement, state} from 'lit/decorators.js';
続いてアイテムリストを配列として定義します。
@customElement('todo-list') export class ToDoList extends LitElement { @state() private _listItems = [ { text: 'Start Lit tutorial', completed: true }, { text: 'Make to-do list', completed: false } ]; ...
アイテムリストは内部リアクティブステートとして定義したため、属性として公開されず、コンポーネントの外部からアクセスすることはできません。
続いて、シャドウDOM内の要素を参照するために、@query
デコレータを使用します。
こちらもインポートに追加しておきます。
import {customElement, state, query} from 'lit/decorators.js';
@query
ディレクティブと、空実装としていた addToDo()
の中身を以下のように実装します。
@query('#newitem') input!: HTMLInputElement; ... addToDo() { this._listItems = [...this._listItems, {text: this.input.value, completed: false}]; this.input.value = ''; }
@query
デコレータにより、クラスプロパティが、レンダールートからノードを返すゲッターに変換されます。
入力用のテキストボックスに this.input
としてアクセス可能となります。
addToDo()
イベントリスナで、入力内容を既存のリストに追加した新しいリストを設定しています。
リストアイテムはリアクティブプロパティなので、これによりレンダリングがトリガされます。
最後に、html リテラルで、リストアイテムを表示するように変更します。
override render() { return html` <h2>To Do</h2> <ul> ${this._listItems.map((item) => html`<li>${item.text}</li>` )} </ul> ... `; }
以下のように、ToDo アイテムの追加が可能になりました。
ToDo アイテムのトグル
追加可能となった ToDo アイテムを、クリックにて完了/未完了をトグルできるようにしていきます。
最初に、完了後のソースを示します。
import {LitElement, html, css} from 'lit'; import {customElement, state, query} from 'lit/decorators.js'; type ToDoItem = { text: string, completed: boolean } @customElement('todo-list') export class ToDoList extends LitElement { static override styles = css` .completed { text-decoration-line: line-through; color: #777; } `; @state() private _listItems = [ { text: 'Start Lit tutorial', completed: true }, { text: 'Make to-do list', completed: false } ]; @query('#newitem') input!: HTMLInputElement; override render() { const items = this._listItems; const todos = html` <ul> ${items.map((item) => html` <li style='cursor: pointer' class=${item.completed ? 'completed' : ''} @click=${() => this.toggleCompleted(item)}> ${item.text} </li>` )} </ul> `; return html` <h2>To Do</h2> ${todos} <input id="newitem" aria-label="New item"> <button @click=${this.addToDo}>Add</button> `; } addToDo() { this._listItems = [...this._listItems, {text: this.input.value, completed: false}]; this.input.value = ''; } toggleCompleted(item: ToDoItem) { item.completed = !item.completed; this.requestUpdate(); } } declare global { interface HTMLElementTagNameMap { 'todo-list': ToDoList } }
最初に、アイテムクリック時のリスナメソッドを以下のように定義します。
toggleCompleted(item: ToDoItem) { item.completed = !item.completed; this.requestUpdate(); }
引数の ToDoItem
は import 文の下部に以下のように型定義します。
type ToDoItem = { text: string, completed: boolean }
render()
メソッドを以下のように変更します。
override render() { const items = this._listItems; const todos = html` <ul> ${items.map((item) => html` <li style='cursor: pointer' @click=${() => this.toggleCompleted(item)}> ${item.text} </li>` )} </ul> `; return html` <h2>To Do</h2> <!-- TODO: Update expression. --> ${todos} <input id="newitem" aria-label="New item"> <button @click=${this.addToDo}>Add</button> `;
html リテラルを分割して定義するように変更しています。
li
要素には、クリックに対するリスナーとして () => this.toggleCompleted(item)
のように登録しています。
クリックによるトグルで表示を変化させるため、スタイルを適用します。
css
をインポートします。
import {LitElement, html, css} from 'lit';
スタイルを以下のように定義します。
static override styles = css` .completed { text-decoration-line: line-through; color: #777; } `;
li
要素の class
属性に、状態に応じてスタイルを適用するようにします。
const todos = html` <ul> ${items.map((item) => html` <li style='cursor: pointer' class=${item.completed ? 'completed' : ''} @click=${() => this.toggleCompleted(item)}> ${item.text} </li>` )} </ul> `;
これにより、以下のようにクリックに応じて打消し線がトグルするようになりました。
ToDo アイテムの消込
完了したToDoアイテムは、表示から消しこめるように変更しましょう。
ソース全体は以下のようになります。
import {LitElement, html, css} from 'lit'; import {customElement, state, property, query} from 'lit/decorators.js'; type ToDoItem = { text: string, completed: boolean } @customElement('todo-list') export class ToDoList extends LitElement { static override styles = css` .completed { text-decoration-line: line-through; color: #777; } `; @state() private _listItems = [ { text: 'Start Lit tutorial', completed: true }, { text: 'Make to-do list', completed: false } ]; @property() hideCompleted = false; @query('#newitem') input!: HTMLInputElement; override render() { const items = this.hideCompleted ? this._listItems.filter((item) => !item.completed) : this._listItems; const todos = html` <ul> ${items.map((item) => html` <li style='cursor: pointer' class=${item.completed ? 'completed' : ''} @click=${() => this.toggleCompleted(item)}> ${item.text} </li>` )} </ul> `; const caughtUpMessage = html`<p>You're all caught up!</p>`; const todosOrMessage = items.length > 0 ? todos : caughtUpMessage; return html` <h2>To Do</h2> ${todosOrMessage} <input id="newitem" aria-label="New item"> <button @click=${this.addToDo}>Add</button> <br> <label> <input type="checkbox" @change=${this.setHideCompleted} ?checked=${this.hideCompleted}> Hide completed </label> `; } addToDo() { this._listItems = [...this._listItems, {text: this.input.value, completed: false}]; this.input.value = ''; } toggleCompleted(item: ToDoItem) { item.completed = !item.completed; this.requestUpdate(); } setHideCompleted(e: Event) { this.hideCompleted = (e.target as HTMLInputElement).checked; } } declare global { interface HTMLElementTagNameMap { 'todo-list': ToDoList } }
画面から消しこむかどうかはチェックボックスで指定でき、hideCompleted
というプロパティで扱うこととします。
最初に @property
ディレクティブをインポートに追加しておきます。
import {customElement, state, property, query} from 'lit/decorators.js';
クラスフィールドに hideCompleted
プロパティを定義します。
@property() hideCompleted = false;
このプロパティは、以下のメソッドでチェック状態を反映します。
setHideCompleted(e: Event) { this.hideCompleted = (e.target as HTMLInputElement).checked; }
render()
メソッドに以下の input
要素を追加し、イベントリスナと属性を追加します。
override render() { ... return html` ... <br> <label> <input type="checkbox" @change=${this.setHideCompleted} ?checked=${this.hideCompleted}> Hide completed </label> `;
render()
メソッドの先頭に記載していた items
を完了状態によりフィルタリングするようにします。
const items = this.hideCompleted ? this._listItems.filter((item) => !item.completed) : this._listItems;
これで消込処理は完了です。
最後に、ToDo アイテムが無くなった場合にメッセージを表示するようにします。
const caughtUpMessage = html`<p>You're all caught up!</p>`; const todosOrMessage = items.length > 0 ? todos : caughtUpMessage;
return html` <h2>To Do</h2> ${todosOrMessage} <input id="newitem" aria-label="New item"> <button @click=${this.addToDo}>Add</button> <br> <label> <input type="checkbox" @change=${this.setHideCompleted} ?checked=${this.hideCompleted}> Hide completed </label> `;
これにより、以下のような ToDo 操作が完了しました。
まとめ
Lit コンポーネントの構築をチュートリアル形式で紹介しました。
本家にはインタラクティブに操作できるチュートリアルがいくつもあるので、試してみてはいかがでしょうか。