2
2

vanillaのTypeScriptでToDoアプリを作成

Last updated at Posted at 2023-11-05

はじめに

タイトル通り、vanillaのTypeScriptでシンプルなToDoアプリを作成しました。

昨今、フロントエンドで開発するには基本VueやReact、AngularといったUIフレームワークを用いて開発するのが主流かと思います。
ですので敢えて、そういったUIフレームワークを使わず、TypeScriptとHTMLだけでToDoアプリを作成することで、改めてUIフレームワークのありがたみを知ろうというのが今回実装したきっかけになります。
そしてせっかく作成したのだから見てほしい! と、本記事を執筆しました。

成果物

環境構築

parcelを用います。
parcelはtsconfigとか飛ばして動かせるのでtypescript要らないかもです。

npm init -y
npm install parcel typescript
npx tsc --init

ディレクトリ構成

.
├── package.json
├── src
│   ├── category.ts
│   ├── index.html
│   ├── main.ts
│   ├── parent-category.ts
│   ├── style.css
│   ├── task-form.ts
│   └── todo-item.ts
└── tsconfig.json

実行

npx parcel src/index.html --open

HTML・CSS

説明すべきところがあまりなかったので解説かなり薄いです。

HTML

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>ToDoList</title>
    <link rel="stylesheet" href="style.css">
    <script type="module" src="./todo-item.ts"></script>
    <script type="module" src="./category.ts"></script>
    <script type="module" src="./task-form.ts"></script>
    <script type="module" src="./parent-category.ts"></script>
    <script type="module" src="./main.ts" defer></script>
</head>
<body>
    <header>
        <h1>ToDoリスト</h1>
    </header>
    <main>
        <div class="task-form">
            <div>
                <label for="task-input">タスク</label>
                <input type="text" id="task-input"/>
            </div>
            <button id="add-button">追加</button>
        </div>

        <div class="task-categories">
            <div class="category-box">
                <h2>未着手</h2>
                <div
                    id="not-started-yet"
                    class="item-box"
                >
                </div>
            </div>

            <div class="category-box">
                <h2>進行中</h2>
                <div
                    id="wip"
                    class="item-box"
                >
                </div>
            </div>

            <div class="category-box">
                <h2>完了</h2>
                <div
                    id="finish"
                    class="item-box"
                >
                </div>
            </div>
        </div>
        <dialog id="item-operate-dialog">
            <form class="dialog-form">
                どうする?
                <div class="dialog-operate">
                    <div>
                        <label for="select-new-state">状態:</label>
                        <select id="select-new-state">
                            <option value="not-started-yet">未着手</option>
                            <option value="wip">進行中</option>
                            <option value="finish">完了</option>
                        </select>
                    </div>
                    <div class="dialog-buttons">
                        <button
                                value="cancel"
                                formmethod="dialog"
                        >キャンセル</button>
                        <button id="change-button">変更</button>
                        <button id="remove-button">削除</button>
                    </div>
                </div>
            </form>
        </dialog>
    </main>
</body>
</html>

type="module"

<script type="module" src="./**.ts"></script>

import/exportを使ったらエラーになったのでこういう形でmoduleで読み込んでいます。

CSS

style.css
main {
    margin: 24px;
    padding: 0;
}

.task-form {
    display: flex;
    gap: 8px;
}

.task-categories {
    display: flex;
    justify-content: space-around;
}

.category-box {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
}

.item-box {
    width: 25vw;
    height: 560px;
    border: 1px solid black;
}

.dialog-form {
    min-width: 300px;
    display: flex;
    flex-direction: column;
    gap: 16px;
}

.dialog-operate {
    display: flex;
    flex-direction: column;
    gap: 16px;
}

.dialog-buttons {
    display: flex;
    justify-content: space-around;
}

最低限見た目を整えましたが、基本はブラウザのデフォルトスタイルまんまです。
見た目を整えるのにはflexboxを多用しています。

TypeScirpt

依存関係順に紹介します。
DOMの取得周りは存在するもの前提で書いています。

task-form.ts

タスク入力フォーム回りをまとめたクラスです。

/**
 * タスク追加ボタン押下時に発火する関数の型
 * @param inputValue 入力値
 */
type RegisterAddTaskButtonClickEventCallbackType = (inputValue: string) => void;

/** タスク登録フォーム */
export class TaskForm {
  /** タスク名入力フォーム */
  private taskInput: HTMLInputElement;
  /** タスク追加ボタン */
  private addTaskButton: HTMLButtonElement;

  constructor() {
    this.taskInput = document.querySelector('#task-input')!
    this.addTaskButton = document.querySelector('#add-button')!
  }

  /** タスク追加ボタン押下イベントで発火する関数を登録 */
  registerAddTaskButtonClickEventFunc(f: RegisterAddTaskButtonClickEventCallbackType): void {
    this.addTaskButton.addEventListener('click', () => {
      f(this.taskInput.value);
    })
  }
}

JSdoc

/**
 * 説明
 * @param param パラメータ
 * @return 返却値
 */

import、export

export class ClassName

class

class MyClass {
    constructor() {}
}
private prop: Type;

document.querySelector

element = document.querySelector('%query_selector%')!;

Element型を返します。
Element型を HTMLInputElementHTMLButtonElement に何故代入できるのか? それは、それらの型がElement型の拡張だからです。

仕様書

からも、HTMLElementを継承していることがわかり、HTMLElementがElementの継承であることも、

親である Element からプロパティを継承しています。
https://developer.mozilla.org/ja/docs/Web/API/HTMLElement

から分かります。

addEventListener

特定のイベントが起きた時に第二引数の関数を実行します。
特定のイベントは第一引数に指定します。

関数を引数に受け取る

registerAddTaskButtonClickEventFunc(f: RegisterAddTaskButtonClickEventCallbackType): void {
    this.addTaskButton.addEventListener('click', () => {
        f(this.taskInput.value);
    })
}

関数を引数に受け取り実行できます。
せっかくTypeScriptを使っているのですから、関数の受け取る引数及び返り値の型も縛りたいものです。

ここではボタン押下時に発火する関数を引数として受け取り、そこに入力された値を流し込みたいため、

type RegisterAddTaskButtonClickEventCallbackType = (inputValue: string) => void;

と定義されます。

todo-item.ts

それぞれのタスクについて処理をまとめたクラスです。

/**
 * アイテムクリック時イベントで発火する関数の型
 * @param event クリック時イベント
 * @param todoItem this
 */
type ClickEvent = (event: MouseEvent, todoItem: TodoItem) => void;
/** カテゴリの種別、およびそれぞれのID */
export type BoxIdLiteral = 'not-started-yet' | 'wip' | 'finish';

/** タスク */
export class TodoItem {
  /** タスクを一意に示すID */
  uuid: string;
  /** 現在所属するグループ */
  currentGroup: BoxIdLiteral = 'not-started-yet';
  /** Node */
  elm: HTMLDivElement | null = null;
  constructor(
    taskName: string,
    clickEvent: ClickEvent
  ) {
    this.uuid = crypto.randomUUID();
    const div = document.createElement('div');
    div.textContent = taskName;
    div.addEventListener(
      'click',
      (event) => clickEvent(event, this)
    );
    this.elm = div;
  }

  /** 自身を削除 */
  remove(): void {
    if (this.elm) {
      this.elm.remove();
      this.elm = null;
    } else {
      throw Error('[TodoItem.remove] this.elm = null');
    }
  }
}

リテラル型

type BoxIdLiteral = 'not-started-yet' | 'wip' | 'finish';

これと似た使い方をするのにenumsを用いることもあります。

uuid

どうせ被んないだろという楽観的考えのもと、重複判定を行なっておりません。
Crypto.randomUUIDで生成できます。
CryptoWeb APIなのでJavaScriptおよびTypeScriptの機能ではありません。

重複判定を行うとしたら、現在持つUUIDをSetあたりを使って管理するのが良さそうですね。

document.createElement

HTML要素を生成します。
第一引数で与えた文字列に応じた型で生成してくれます。

const div = document.createElement('div');

例えば上記の例なら、HTMLDivElementで生成してくれます。

なぜこのように柔軟になっているのでしょうか?
型定義を覗いてみます。

createElement<K extends keyof HTMLElementTagNameMap>(tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K];
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    "a": HTMLAnchorElement;
    "abbr": HTMLElement;
    "address": HTMLElement;
    "area": HTMLAreaElement;
    "article": HTMLElement;
    "aside": HTMLElement;
    "audio": HTMLAudioElement;
    "b": HTMLElement;
    "base": HTMLBaseElement;
    "bdi": HTMLElement;
    "bdo": HTMLElement;
    "blockquote": HTMLQuoteElement;
    "body": HTMLBodyElement;
    "br": HTMLBRElement;
    "button": HTMLButtonElement;
    "canvas": HTMLCanvasElement;
    "caption": HTMLTableCaptionElement;
    "cite": HTMLElement;
    "code": HTMLElement;
    "col": HTMLTableColElement;
    "colgroup": HTMLTableColElement;
    "data": HTMLDataElement;
    "datalist": HTMLDataListElement;
    "dd": HTMLElement;
    "del": HTMLModElement;
    "details": HTMLDetailsElement;
    "dfn": HTMLElement;
    "dialog": HTMLDialogElement;
    "div": HTMLDivElement;
    "dl": HTMLDListElement;
    "dt": HTMLElement;
    "em": HTMLElement;
    "embed": HTMLEmbedElement;
    "fieldset": HTMLFieldSetElement;
    "figcaption": HTMLElement;
    "figure": HTMLElement;
    "footer": HTMLElement;
    "form": HTMLFormElement;
    "h1": HTMLHeadingElement;
    "h2": HTMLHeadingElement;
    "h3": HTMLHeadingElement;
    "h4": HTMLHeadingElement;
    "h5": HTMLHeadingElement;
    "h6": HTMLHeadingElement;
    "head": HTMLHeadElement;
    "header": HTMLElement;
    "hgroup": HTMLElement;
    "hr": HTMLHRElement;
    "html": HTMLHtmlElement;
    "i": HTMLElement;
    "iframe": HTMLIFrameElement;
    "img": HTMLImageElement;
    "input": HTMLInputElement;
    "ins": HTMLModElement;
    "kbd": HTMLElement;
    "label": HTMLLabelElement;
    "legend": HTMLLegendElement;
    "li": HTMLLIElement;
    "link": HTMLLinkElement;
    "main": HTMLElement;
    "map": HTMLMapElement;
    "mark": HTMLElement;
    "menu": HTMLMenuElement;
    "meta": HTMLMetaElement;
    "meter": HTMLMeterElement;
    "nav": HTMLElement;
    "noscript": HTMLElement;
    "object": HTMLObjectElement;
    "ol": HTMLOListElement;
    "optgroup": HTMLOptGroupElement;
    "option": HTMLOptionElement;
    "output": HTMLOutputElement;
    "p": HTMLParagraphElement;
    "picture": HTMLPictureElement;
    "pre": HTMLPreElement;
    "progress": HTMLProgressElement;
    "q": HTMLQuoteElement;
    "rp": HTMLElement;
    "rt": HTMLElement;
    "ruby": HTMLElement;
    "s": HTMLElement;
    "samp": HTMLElement;
    "script": HTMLScriptElement;
    "search": HTMLElement;
    "section": HTMLElement;
    "select": HTMLSelectElement;
    "slot": HTMLSlotElement;
    "small": HTMLElement;
    "source": HTMLSourceElement;
    "span": HTMLSpanElement;
    "strong": HTMLElement;
    "style": HTMLStyleElement;
    "sub": HTMLElement;
    "summary": HTMLElement;
    "sup": HTMLElement;
    "table": HTMLTableElement;
    "tbody": HTMLTableSectionElement;
    "td": HTMLTableCellElement;
    "template": HTMLTemplateElement;
    "textarea": HTMLTextAreaElement;
    "tfoot": HTMLTableSectionElement;
    "th": HTMLTableCellElement;
    "thead": HTMLTableSectionElement;
    "time": HTMLTimeElement;
    "title": HTMLTitleElement;
    "tr": HTMLTableRowElement;
    "track": HTMLTrackElement;
    "u": HTMLElement;
    "ul": HTMLUListElement;
    "var": HTMLElement;
    "video": HTMLVideoElement;
    "wbr": HTMLElement;
}

なるほど。対応する型をマッピングしていました。

textContentinnerHTML

「textContent innerHTML」みたいに検索をかければ山ほど違いに関する記述が出てくるため検索してください。
textContentを用いている理由はXSSを防ぐためです。

自身を削除

remove(): void {
  if (this.elm) {
    this.elm.remove();
    this.elm = null;
  } else {
    throw Error('[TodoItem.remove] this.elm = null');
  }
}

削除時にElement.removeでエレメントを消してあげます。
そして明示的に this.elm をnullにします。
今回は処理の流れ的に this.elm = null でremoveが叩かれることはありえないためエラーを発生させています。

category.ts

タスクの状態(未着手か、進行中か、完了か)のそれぞれのカテゴリについてのクラスです。

import { TodoItem, BoxIdLiteral } from './todo-item';

/** カテゴリ */
export class Category {
  /** 所属するタスク一覧 */
  children: TodoItem[] = [];
  /** Node */
  private categoryBox: HTMLDivElement;

  /**
   * @param boxId カテゴリ種別
   */
  constructor(private readonly boxId: BoxIdLiteral) {
    this.categoryBox = document.querySelector(`#${boxId}`)!;
  }

  /**
   * タスクの追加
   * @param item タスク
   */
  appendTodo(item: TodoItem): void {
    if (item.elm) {
      item.currentGroup = this.boxId;
      this.children.push(item);
      this.refreshCategoryBox();
    } else {
      throw Error('[Category.appendTodo] item.elm = null');
    }
  }

  /**
   * タスクをカテゴリから削除
   * @param itemUuid タスクのUUID
   */
  deleteTodo(itemUuid: string): void {
    this.children = this.children.filter((child) => child.uuid !== itemUuid);
    this.refreshCategoryBox();
  }

  /** 画面表示のリフレッシュ */
  private refreshCategoryBox(): void {
    while (this.categoryBox.firstChild) {
      this.categoryBox.removeChild(this.categoryBox.firstChild);
    }
    this.children.forEach((child) => {
      if (child.elm) {
        this.categoryBox.appendChild(child.elm);
      }
    })
  }
}

モジュール

import { TodoItem, BoxIdLiteral } from './todo-item';

インスタンス

children: TodoItem[] = [];

class TodoItem のインスタンスの配列型です。

new TodoItem(/** param */)

でインスタンスを作成して、それを格納します。

classやインスタンスが何かは下記を読んだりご自身で調べるなどしてください。

また、余談ですがどのクラスのインスタンスかを確認するにはObject.prototype.constructorで確認できます。

>> class A {
    constructor(a) {
        this.a = a;
    }
}
>> const ac = new A('a');
>> ac.constructor
class A {
    constructor(a) {
        this.a = a;
    }
}

そして、そのインスタンスがあるクラスのインスタンスかを判別するにはinstanceofを用います。

>> ac instanceof A
true

さらに余談ですが、JavaScriptのArrayはObjectです。

>> typeof ['a', 'b', 'b']
'object'

なので配列かを確認したい場合はinstanceofやconstructorをみるか、isArrayを用います。

>> ['a', 'b', 'c'].constructor
ƒ Array() { [native code] }
>> ['a', 'b', 'c'] instanceof Array
true
>> Array.isArray(['a', 'b', 'c'])
true
>> Array.isArray({a: 'aaa', b: 'bbb'})
false

配列はオブジェクトというのは文字だけで見ると違和感を覚えます(Objectがルートクラスな言語も多いのでやっぱ違和感覚えないかもです)が、挙動を見るとキーが連番の数字のオブジェクトだと納得できます。
振る舞いを見てみましょう。

>> {...['a', 'b', 'c']}
{0: 'a', 1: 'b', 2: 'c'}
>> const a = ['a', 'b', 'c'];
>> delete a[1]
true
>> a
['a', empty, 'c']
>> a[1]
undefined
>> {...a}
{0: 'a', 2: 'c'}

constructor shorthand

constructor(private readonly boxId: BoxIdLiteral) {}

画面表示のリフレッシュ

カテゴリ内の要素を一旦全部消して、最新の状態で作り直しています。

private refreshCategoryBox(): void {
  while (this.categoryBox.firstChild) {
    this.categoryBox.removeChild(this.categoryBox.firstChild);
  }
  this.children.forEach((child) => {
    if (child.elm) {
      this.categoryBox.appendChild(child.elm);
    }
  });
}

removeChildで対象子要素の削除をwhilefirstChildが取れる限りぶん回して要素内の子要素を全て削除しています。

ちなみに、このfirstChildはCSSにも擬似クラスとして存在します。

そして、 this.children.forEach で現在の子要素を取得し、appendChildで詰め直しています。
child.elm はnullの可能性があるため、型ガードを挟んでいます。

要素の洗い替えは重たい処理のため、該当箇所の追加・削除のみにすべきですが、面倒だったので全て洗い替えしています。

parent-category.ts

カテゴリ全体を統率する親カテゴリのクラスです。

import { Category } from './category';
import { BoxIdLiteral, TodoItem } from './todo-item';

/** カテゴリ全体を統括 */
export class ParentCategory {
  /** ダイアログ */
  private dialog: HTMLDialogElement;
  /** [ダイアログ]セレクトボックス */
  private selectInput: HTMLSelectElement;
  /** [ダイアログ]変更ボタン */
  private changeButton: HTMLButtonElement;
  /** [ダイアログ]削除ボタン */
  private removeButton: HTMLButtonElement;
  /** 未着手カテゴリ */
  private notStartedYetCategory: Category;
  /** 進行中カテゴリ */
  private wipCategory: Category;
  /** 完了カテゴリ */
  private finishCategory: Category;

  /** 現在選択中タスク */
  private currentSelectItem: TodoItem | null = null;

  constructor() {
    this.dialog = document.querySelector('#item-operate-dialog')!;
    this.selectInput = document.querySelector('#select-new-state')!;
    this.changeButton = document.querySelector('#change-button')!;
    this.removeButton = document.querySelector('#remove-button')!;
    this.notStartedYetCategory = new Category('not-started-yet');
    this.wipCategory = new Category('wip');
    this.finishCategory = new Category('finish');

    this.dialog.addEventListener('close', () => {
      if (this.currentSelectItem === null) {
        throw Error('[ParentCategory.constructor] this.currentSelectItem = null');
      }

      switch (this.dialog.returnValue) {
        case 'not-started-yet':
        case 'wip':
        case 'finish':
          this.moveItem(this.dialog.returnValue);
          break;
        case 'remove':
          this.deleteItem();
          break;
        default:
          return;
      }

      this.currentSelectItem = null;
    });

    this.changeButton.addEventListener('click', (event) => {
      event.preventDefault();
      this.dialog.close(this.selectInput.value);
    });

    this.removeButton.addEventListener('click', (event) => {
      event.preventDefault();
      this.dialog.close('remove');
    });
  }

  /**
   * 新アイテム追加
   * @param taskName タスク名
   * @note 新アイテムの追加は仕様上未着手にしか入らない
   */
  appendNewTask(taskName: string): void {
    const todoItem = new TodoItem(
      taskName,
      (_event, todoItem) => {
        this.currentSelectItem = todoItem;
        this.selectInput.value = todoItem.currentGroup;
        this.dialog.showModal();
      }
    );
    this.notStartedYetCategory.appendTodo(todoItem);
  }

  /**
   * アイテムの移動
   * @param category 移動先カテゴリ
   */
  private moveItem(category: BoxIdLiteral): void {
    if (this.currentSelectItem === null) {
      throw Error('[ParentCategory.moveItem] this.currentSelectItem = null');
    }

    this.removeItemInCategory();

    switch (category) {
      case 'not-started-yet':
        this.notStartedYetCategory.appendTodo(this.currentSelectItem);
        break;
      case 'wip':
        this.wipCategory.appendTodo(this.currentSelectItem);
        break;
      case 'finish':
        this.finishCategory.appendTodo(this.currentSelectItem);
        break;
    }

    this.currentSelectItem = null;
  }

  /** タスクの削除 */
  private deleteItem(): void {
    if (this.currentSelectItem === null) {
      throw Error('[ParentCategory.deleteItem] this.currentSelectItem = null');
    }

    this.currentSelectItem.remove();
    this.removeItemInCategory();
    this.currentSelectItem = null;
  }

  /** カテゴリからタスクを取り除く */
  private removeItemInCategory(): void {
    if (this.currentSelectItem === null) {
      throw Error('[ParentCategory.removeItemInCategory] this.currentSelectItem = null');
    }

    switch (this.currentSelectItem.currentGroup) {
      case 'not-started-yet':
        this.notStartedYetCategory.deleteTodo(this.currentSelectItem.uuid);
        break;
      case 'wip':
        this.wipCategory.deleteTodo(this.currentSelectItem.uuid);
        break;
      case 'finish':
        this.finishCategory.deleteTodo(this.currentSelectItem.uuid);
        break;
    }
  }
}

ダイアログ

this.dialog.addEventListener('close', () => {...});

this.changeButton.addEventListener('click', (event) => {
    event.preventDefault();
    this.dialog.close(this.selectInput.value);
});

this.removeButton.addEventListener('click', (event) => {
    event.preventDefault();
    this.dialog.close('remove');
});

ダイアログからほぼまるパクしてます。

削除もキャンセル同様

<button value="remove" formmethod="dialog">削除</button>

としても良かったかもしれません。

switch-case

case 'not-started-yet':
case 'wip':
case 'finish':
    this.moveItem(this.dialog.returnValue);
    break;

moveItem は引数に 'not-started-yet' | 'wip' | 'finish' を取ります。
caseはbreakしなければ続けて下の処理も行います。
これを利用して、

case 'not-started-yet':
case 'wip':
case 'finish':

とすることで、this.dialog.returnValueは 'not-started-yet' | 'wip' | 'finish' のいずれかとなり、下記のように似たコードを書かずに済みます。

case 'not-started-yet':
    this.moveItem('not-started-yet');
    break;
case 'wip':
    this.moveItem('wip');
    break;
case 'finish':
    this.moveItem('finish');
    break;

アイテムの移動

カテゴリから削除し、選択された移住先カテゴリに追加しています。

// カテゴリから削除
this.removeItemInCategory();

// 選択された移住先カテゴリに追加
switch (category) {
  case 'not-started-yet':
    this.notStartedYetCategory.appendTodo(this.currentSelectItem);
    break;
  case 'wip':
    this.wipCategory.appendTodo(this.currentSelectItem);
    break;
  case 'finish':
    this.finishCategory.appendTodo(this.currentSelectItem);
    break;
}

タスクの削除

カテゴリからの削除だけでなく、タスクの削除もしています。

this.currentSelectItem.remove();
this.removeItemInCategory();

main.ts

メイン処理。

import { TaskForm } from './task-form';
import { ParentCategory } from './parent-category';

function main(): void {
  const taskForm = new TaskForm();
  const parentCategory = new ParentCategory();
  taskForm.registerAddTaskButtonClickEventFunc((inputValue) => {
    parentCategory.appendNewTask(inputValue);
  });
}

window.addEventListener('load', main);

フォームと親カテゴリの実態を作って、結合してあげて終了です。
お疲れ様でした!

終わりに

実際にやってみて、
HTMLやCSSを書いているときはコンポーネントとして分割し、スコープを小さくしたい。
TypeScriptを書いているときはpropsやemitが欲しくなることが多々ありました。
また、イベント登録もテンプレート側に書いて、関数を紐づけるだけなのもUIフレームワークのいいところですね。(buttonエレメントにも onClick が生えていますが)
それとUIフレームワークだけでなく、大体UIフレームワークに紐づくUIライブラリも使いながら開発することのほうが多いので、自分で頑張ってCSSを書くのも久々の経験でした。
ほんと感謝ですね。

ただ、実際Vanilla TSで書いてみて、データの受け渡しやクラスの切り分け方は改めて勉強になりました。昨今のUIフレームワークへの解像度も上がったと思います。

ここまで読んで頂きありがとうございました。
終わります。

2
2
0

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
2
2