kaibukuroのブログ

HTML/CSS/JavaScript/PHPなどのアウトプット

JavaScript(TypeScript)でToDo Webアプリ作成

初めに

cookie, localStorage, sessionStorageなど、セッション中のデータ保存できる仕組みがあります。

私はJavaScriptでこれらを使った何かしらの実装をしたことが無かったので、今回はlocalStorageを使った簡単なToDo Webアプリを作成してみました。

cookie, localStorage, sessionStorageの3つの仕組みの違いはこちらの解説が分かりやすかったです。↓

qiita.com


今回扱うことにしたlocalStorageの特長としては、「ブラウザに保存される」・「保存容量は10MBまで」・「保存期限が無い」などです。
「保存期限が無い」については、セキュリティ問題があるようなので、公開するサイトでの利用などでは注意が必要です。
https://techracho.bpsinc.jp/hachi8833/2019_10_09/80851

成果物:

作ったコード
https://github.com/kaiyoshida57/js_app_session

ページ
http://kaibukuro.starfree.jp/practice-js/js_app_session/

機能 →

  • 上部の枠でタイトルや日付を入力した上で新規追加し、ブラウザに保存されます。
  • 削除とステータスの変更が可能。
  • Todo名をダブルクリックで編集が可能。

解説:

src/index.html

<div class="wrapper">
    <header class="header" id="header">
        <div class="header__inner"><a class="header_logo" href="/" aria-label="Site logo">
                <h1 class="header__title">Todo</h1>
            </a>
        </div>
    </header>

    <main>
        <p class="introduction">Todoを編集・保存できます。</p>
        <div class="control">
            <p class="control__heading">
                新規作成
            </p>
            <div class="control__form">
                <label for="control__formInput01">
                    <span class="control__text">Todo:</span>
                    <input type="text" value="" id="control__formInput01" placeholder="Todo" class="control__formInput01">
                </label>
                <label for="control__formInput02">
                    <span class="control__text">期限:</span>
                    <input type="datetime-local" value="2022-11-11T10:00" id="control__formInput02" class="control__formInput02">
                </label>
                <button type="button" class="control__addButton">
                    &plus; 新規作成する
                </button>
            </div>
        </div>

        <table id="todoTable" class="table">
            <thead>
                <tr>
                    <th class="table__title01">Todo</th>
                    <th class="table__title02">期日</th>
                    <th class="table__title03">状態</th>
                    <th class="table__title05">&nbsp;</th>
                </tr>
            </thead>

            <tbody class="table__tbody">
            </tbody>

        </table>
    </main>
</div>
  • HTMLのコードで処理を解説すると、上の緑の入力バーにて「todo名」と「期限」を入力した上で「新規追加する」ボタン押下により、空のtbodyタグの中に、trタグ以下が一式追加されます。(ステータスは初期状態は必ず「未完」になります。)

src/js/_modules/todo.ts

コードにコメント入れてるので、要点のみ解説します。
はてブのコードだと行数が参照されてないので、以下Github と照らし合わせて参照をお願いします。
https://github.com/kaiyoshida57/js_app_session/blob/main/src/js/_modules/todo.ts

/**
 * Todo操作処理
 **/



export function todo(): void {

	//datetime-localの初期value値に現在時間追加
	const timeInput =  document.querySelector('.control__formInput02') as HTMLInputElement;
	const now = new Date();
	now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
  timeInput.value = now.toISOString().slice(0, -8);

	// todo入力情報を入れる配列
	let listItems = Array();

	// tableへ挿入関数
	const insertingTable = (todo: string, time: string, status: string) => {
		// trにclass付与
		let trClass = '';
		if (status === '1') {
			trClass = 'is-uncomplete';
		} else if (status === '2') {
			trClass = 'is-process';
		} else if (status === '3') {
			trClass = 'is-complete';
		}
		// selectにselected属性付与
		let statusToSelected1 = status === '1' ? 'selected="selected"' : '';
		let statusToSelected2 = status === '2' ? 'selected="selected"' : '';
		let statusToSelected3 = status === '3' ? 'selected="selected"' : '';

		const insertHtml = `
		<tr class="table__row ${trClass}" data-todoname="${todo}">
		<td class="table__todoName">${todo}</td>
		<td class="table__todoTime">${time}</td>
		<td>
		<select name="" id="" class="table__select">
		<option value="1" class="table__selectItem" ${statusToSelected1}>未完</option>
		<option value="2" class="table__selectItem" ${statusToSelected2}>処理中</option>
		<option value="3" class="table__selectItem" ${statusToSelected3}>完了</option>
		</select>
		</td>
		<td>
		<button type="button" class="table__delButton">削除</button>
		</td>
		</tr>
		`;
		const targetTbody = document.querySelector('.table__tbody') as HTMLElement;
		if (targetTbody) {
			targetTbody.insertAdjacentHTML('afterbegin', insertHtml);
		}
	}


	// -- 新規追加 --
	const addBtn = document.querySelector('.control__addButton') as HTMLButtonElement;
	addBtn.addEventListener('click', () => {

		//入れる要素
		const todoInput =  document.querySelector('.control__formInput01');
		const todoInputVal =  (todoInput as HTMLInputElement).value;
		const timeInputVal = (timeInput as HTMLInputElement).value;

		// 2026-08-28T23:00 を2026/08/28 23:00の形式に変更
		const replacedTimeInputVal01 = timeInputVal.replace(/-/g, '/');
		const replacedTimeInputVal02 = replacedTimeInputVal01.replace(/T/g, ' ');

		// 空なら終了
		if(todoInputVal === '') {
			window.alert('todo名を入力してください。');
			return false;
		}

		// -- localStorageの準備 --
		// まずはlocalStorageからデータ取得
		const storageJson = localStorage.mykey;
		if(storageJson === undefined) {
			return false;
		}
		// 呼び出し時はオブジェクト形式に戻す
		listItems = JSON.parse(storageJson);

		//   入力情報をオブジェクトの配列に入れる
		const item = {
			todoVal: todoInputVal,
			todoTime: replacedTimeInputVal02,
			status: 1, //状態は1=未完が初期値
			isDeleted: false
		};
		listItems.push(item);

		//オブジェクトを復元できるように文字列(JSON)形式にする
		const listItemsString = JSON.stringify(listItems);

		// localStorageに保存、構文→localStorage.setItem(キー, データ)
		localStorage.setItem('mykey', listItemsString);

		insertingTable(todoInputVal, replacedTimeInputVal02, "1");
	});

	//ページ読込時に、localStorageからリスト呼び出し
	document.addEventListener('DOMContentLoaded', () => {
		const storageJson = localStorage.mykey;
		const listItemsString = JSON.stringify(listItems);
		// 初回読み込み時データ無い場合動かないのでここで追加
		if(!storageJson) {
			localStorage.setItem('mykey', listItemsString);
		}
		if(storageJson === undefined) {
			return false;
		}
		//呼び出し時は逆にオブジェクト形式に戻す
		listItems = JSON.parse(storageJson);
		listItems.forEach((item: { todoVal: string; todoTime: string; status: string; }) => {
			insertingTable(item.todoVal, item.todoTime, item.status);
		})
	});


	// 状態の変更 (追加要素への処理時のため、親要素にイベント設定)
	const tbody = document.querySelector('.table__tbody') as HTMLElement;
	tbody.addEventListener('change', (e) => {
		if ((e.target as HTMLElement).classList.contains('table__select')) {

			const selectValue = (e.target as HTMLSelectElement).value;
			const parent = (e.target as HTMLElement).closest('.table__row') as HTMLElement;
			//まず既存classは削除
			if (
				parent.classList.contains('is-uncomplete') ||
				parent.classList.contains('is-process') ||
				parent.classList.contains('is-complete')
			) {
				(parent as HTMLElement).classList.remove('is-uncomplete', 'is-process', 'is-complete');
			}

			if ((selectValue as string) === '1') {
				(parent as HTMLElement).classList.add('is-uncomplete');
			}
			if ((selectValue as string) === '2') {
				(parent as HTMLElement).classList.add('is-process');
			}
			if ((selectValue as string) === '3') {
				(parent as HTMLElement).classList.add('is-complete');
			}

			// localStorageへも変更を保存
			// tr data値
			const trDataVal = (parent as HTMLElement).dataset['todoname'] as string;
			// todoごと(特定のkeyのみ)更新するにはデータ取り出してから更新
			let storageJson = localStorage.mykey;
			//呼び出し時はオブジェクト形式に戻す
			let storageJsonObj = JSON.parse(storageJson);
			if (storageJsonObj) {
				//変更対象の要素のtodoVal値と一致させる
				//メモ:find()は、提供されたテスト関数を満たす配列内の要素を返す
				const changeValue = storageJsonObj.find(
					(item: { todoVal: string; }) => item.todoVal === trDataVal
				);
				//一致した削除対象のオブジェクトのstatusプロパティにvalue値(整数)を入れる
				changeValue.status = selectValue;


			}
			// JSONに変換し直してローカルストレージに再設定
			localStorage.setItem('mykey', JSON.stringify(storageJsonObj));
		}
	})

	// 削除する (追加要素への処理時のため、親要素にイベント設定)
	tbody.addEventListener('click', (e) => {
		if ((e.target as HTMLElement).classList.contains('table__delButton')) {
			const parent = (e.target as HTMLElement).closest('tr') as HTMLElement;
			const todoName = parent.querySelector('td:first-of-type') as HTMLElement;
			const todoNameValue = todoName.textContent;
			const confirmDel = window.confirm(`「${todoNameValue}」を削除しますか?`);
			//tr data値
			const trDataVal = (parent as HTMLElement).dataset['todoname'] as string;
			if (confirmDel) {
				(parent as HTMLElement).remove();
				// todoごと(特定のkeyのみ)削除するにはデータ取り出してから削除
				let storageJson = localStorage.mykey;
				//呼び出し時はオブジェクト形式に戻す
				let storageJsonObj = JSON.parse(storageJson);
				if (storageJsonObj) {

					//削除対象の要素のtodoVal値と一致させる
					//メモ:find()は、提供されたテスト関数を満たす配列内の要素を返す
					const delValue = storageJsonObj.find(
						(item: { todoVal: string; }) => item.todoVal === trDataVal
					);
					//一致した削除対象のオブジェクトのisDeletedプロパティにtrueを入れる
					delValue.isDeleted = true;

					//オブジェクトをfilter()で削除
					//メモ:filter()はオブジェクトのうちfalseのものを配列で返す
					const newStorageJsonObj = storageJsonObj.filter(
						(item: { isDeleted: boolean; }) => item.isDeleted === false
					);
					storageJsonObj = newStorageJsonObj;

					// JSONに変換し直してローカルストレージに再設定
					localStorage.setItem('mykey', JSON.stringify(storageJsonObj));

				}
			} else {
				return false;
			}
		}
	});

	// 全て削除する
	const removeAllBtn = document.querySelector('.control__delButton') as HTMLButtonElement;
	removeAllBtn.addEventListener('click', (e) => {
		const confirmDel = window.confirm(`全て削除しますか?`);
		if (confirmDel) {
			//ストレージからの削除
			localStorage.clear();
			// 空になった後も新規追加はさせたいので、mykeyは残す
			listItems = Array();
			let storageJson = localStorage.mykey;
			const listItemsString = JSON.stringify(listItems);
			if(!storageJson) {
				localStorage.setItem('mykey', listItemsString);
			}
			//HTMLからの削除
			const main = (e.target as HTMLElement).closest('main') as HTMLElement;
			const todoList = main.querySelectorAll('.table__row') as NodeList;
			todoList.forEach((todo) => {
				(todo as HTMLElement).remove();
			});
		} else {
			return false;
		}
	});

	//todo名の、入力による変更
	// クリックで編集可能の属性付与 (追加要素への処理時のため、親要素にイベント設定)
	tbody.addEventListener('click', (e) => {
		if ((e.target as HTMLElement).classList.contains('table__todoName')) {
			(e.target as HTMLElement).setAttribute('contenteditable', 'true');
		}
	});

	// value変更されたら、focusoutイベントで保存させる
	// **contenteditable属性ではchangeが効かないためfocusout使う
	tbody.addEventListener('focusout', (e) => {
		if ((e.target as HTMLElement).classList.contains('table__todoName')) {

			const changedTodoName = (e.target as HTMLElement).textContent as string;
			const parent = (e.target as HTMLElement).closest('tr') as HTMLElement;
			const trDataVal = (parent as HTMLElement).dataset['todoname'] as string;

			// まずtrのdataも更新する(状態変更・削除処理の時に失敗するため)
			(parent as HTMLElement).dataset['todoname'] = changedTodoName;

			// todoごと(特定のkeyのみ)更新するにはデータ取り出してから更新
			let storageJson = localStorage.mykey;
			//呼び出し時はオブジェクト形式に戻す
			let storageJsonObj = JSON.parse(storageJson);
			if (storageJsonObj) {
				//変更対象の要素のtodoVal値と一致させる
				const changeValue = storageJsonObj.find(
					(item: { todoVal: string; }) => item.todoVal === trDataVal
				);
				//一致した削除対象のオブジェクトのtodoValプロパティにvalue値(文字列)を入れる
				changeValue.todoVal = changedTodoName;
			}
			// JSONに変換し直してローカルストレージに再設定
			localStorage.setItem('mykey', JSON.stringify(storageJsonObj));
		}
	});
}


9行目、
input type="datetime-local"は「2018-06-12 19:30」のような形式で入力できる要素です。
( https://developer.mozilla.org/ja/docs/Web/HTML/Element/input/datetime-local
https://qiita.com/terufumi1122/items/76bafb9eed7cfc77b798
)
この要素の初期表示に関しては、空欄より、現在時間が入ってた方が使いやすそうと思い、value値に動的に追加しています。

参考にさせていただきました:

qiita.com


18行目、
insertingTable という関数では、前述のtr以下一式を追加する処理がまとまっています。
引数には、(todo, time, status)が設定されていて、(todo名、期限、状態) を入れるように割り当てられています。

中身の処理ですが、

21行から、trClassという変数に、第三引数の「status(状態)」の数字1 or 2 or 3により、trに入れるべきclassを格納します。 
これは、trの背景色を、未完、処理中、完了など、「状態」の種類ごとに違う色を適用するためです。
この変数trClassは、後のテンプレート文字列内で使います。

さらに29行からは、
statusToSelected1, statusToSelected2, statusToSelected3 という変数を定義し、これもstatusの数字により判定し、それぞれの合う数字(1~3)があれば、その数字を変数に入るようにしています。
これは、optionタグのselected属性へ、選択状態を適用する為です。
これも後のテンプレート文字列内で使います。

34行からは、
タグ中身一式をテンプレート文字列で変数 insertHtml に入れています。 前述の各変数を部分的に入れることで、この関数を使う場面によって、正しい属性やclassが入ったタグが挿入されるようにします。 50行から、 ここで上記の変数insertHtmlを、insertAdjacentHTML()で、tbody内の最初の行に挿入させます。


次は、todoの新規追加(保存含む)のイベント処理です

57行からは、新規追加するのaddButtonとして取得し、クリックイベントを設定しています。

61行からは、内容は、ボタン左の入力枠、todo名、期限、に入力されたテキストを変数に入れ、
66行からは、コメントにもあるような処理をしています↓
「// 2026-08-28T23:00 を2026/08/28 23:00の形式に変更」
これは、datetime-localのvalueのままだと、余計な文字列が入ってるので、replace()で引数に正規表現を用いて、
スラッシュ / と、Tを消すように、変換しています。

70行からは、todo名の入力が無しだった場合は、画面ではアラートを出し、イベント処理は終了するようにしています。

76行からは、localStorageに入れるためのオブジェクトを4つのプロパティに分けて作成しています。

  • キーと値ですが次のようなセットにします。
const item = {
  //キー: 値
  todoVal (todo名): todoInputVal,
  todoTime (期限): replacedTimeInputVal02,
  status (状態): 1,
  isDeleted(削除されたかどうか): false
}
  • 値に関しては、最初の2つまでは、60行目で定義した変数を入れますが、statusには、追加の場合は初期状態が未完にしたいので1を、isDeletedには、ここではfalseを明示的に入れます。


作ったオブジェクトは、16行で作った空の配列にpush()で入れます。

94行、保存する前に、オブジェクトを文字列(JSON)形式に変換します。
(ストレージの基本仕様として、ストレージへの保存時は、文字列に変換しなくてはいけません。
反対に、呼び出す時は、オブジェクトに復元させます。)


97行、

localStorage.setItem('任意のkey', '任意の文字列')
このような構文で、localStorageに保存します。




次は読み込み時の呼び出しです

103行からは、読み込み時は、localStorageから保存したデータを読み出すようにします。
呼び出す時は、オブジェクトに復元させます。
116行では、配列を、forEachにより、オブジェクト分だけ、insertTable() にそれぞれプロパティの値として引数に割り当てた上で実行させます。
これにより画面では、保存してたtodoリストがtable内に全て表示されます。





ここまではアプリに必要な「追加」・「保存」の機能はできました。
次はtodoごとにある「状態」の変更(保存含む)処理です。




122行から状態変更の処理です

まずは、DOM更新の処理なので詳しくは割愛しますが、
「状態」selectの変更時のイベントにより、class付け替えてリストの背景色変えているだけです。

148行からが、localStorageへの状態変更の保存処理です。
trタグのdata属性 data-todoname にtodo名が入るように追加しているので、この値を文字列として取得しておきます。
続いてlocalStorageから、データを全て取り出し、そこからfind()を使うと、上記文字列を一致したkey(今回はtodoVal(todo名))のオブジェクトが返ってきます。
その中のキーstatusの値に、selectValue(変えたselectのvalue値)を入れます。
あとは例のごとく文字列にしてからlocalStorageへ保存しなおします。

これで、状態の変更が上書きされる形で保存されました。



続いて、削除の処理です

172行からの削除処理は、confirm() で削除の確認するアクションを挟んでいますが、「状態更新」の時とあまり変わらない処理になっています。

188行から、削除対象のオブジェクトをfind()で検出したあと、削除キーisDeletedをtrueに変更します。

196行からが、「状態更新」とは少し違う処理が入っています。

データから、filter()を使い、isDeletedがfalseのものを配列で返ってくるようにします。
(削除対象じゃないオブジェクトだけを絞りこみます。)
そうしたら、例のごとく文字列にしてからlocalStorageに保存し直します。

これで、削除対象を除いたデータだけがストレージに残るので、削除処理はこれにて完了です。



そして、全削除の処理は次の通りです

まず、CSSの話ですが、全削除ボタン(.control__delButton)は、has()を使いtrタグがある時だけdisplay:blockで表示させています。

//mainから見て、trタグを持っている時だけ表示
main:has(.table__row) .control__delButton {
  display: block;
}

JSに戻ります。

216行から、こちらもconfirm()で確認した上で、
219行で、localStorage.clear() により、ストレージのデータを全て削除します。
(ただし、このアプリで統一して設定しているキー”mykey”ごと消えてしまうので下記の処理で対応します)。
221行、配列が入っている変数listItemsも空にします。

listItems = Array();

224行から、mykeyのストレージが存在しなければ、
空にしたlistItemsを代入して、mykeyは残し、空の配列だけになるように設定しています。

続いて、Todo名の入力による変更処理です

238行から、まずはリストのTodo欄をクリックさせた時に、
contenteditable属性 という、要素を入力可能にできる属性の付与により、編集可能にします。
developer.mozilla.org


そのあとは、focusoutイベントにより、新たに入力された値を取得し、
先述の「削除」や「状態更新」と同じように、ストレージからデータを取り出してTodo名を更新(上書き)すればOKです。


終わりに

  • LocalStorageへの保存/取得時は、JSON ⇄ オブジェクトなどの変換が必要だったりで、オブジェクトの操作は今まであまりやってこなかったので、ここらへんを新たに学べました。
  • また、Todoリストの項目(タイトル、日付など)をどうやったら効率よくデータ管理(保存、更新など)出来るコードになるかを考える必要があり、学びになりました。

参考にさせていただいた記事:

find()やfilter()を用いたオブジェクトの登録や、データ更新方法が、とても参考になりました。
https://univ-programmer.com/feweek4-4/

オブジェクトの追加・削除の参考
https://freelance-jak.com/technology/javascript/1617/