🗓️

シンプルなカレンダーのWeb ComponentをSvelteで作る

2024/03/27に公開

ここ一年ほどSvelte/SvelteKitが個人的にお気に入りで趣味でなにか作る時によく使っているのですが、Custom Elements APIをまだ使ったことがないなと気づいたので、なにか作ってみることにしました。

Custom Elements API

Custom ElementsとはいわゆるWeb Componentsのことで、Svelteには作成したコンポーネントをWeb Componentsへコンパイルする機能が備わっています。

https://svelte.jp/docs/custom-elements-api

Web Componentsとはざっくりと言えば、Web標準の技術で実装する再利用可能なカスタムメイドのHTML要素みたいなものです。ReactやVueなどの特定のフレームワークやライブラリに依存するものではないので、様々なプロジェクトで再利用することができます。

https://developer.mozilla.org/ja/docs/Web/API/Web_components

今回は日付が確認できるシンプルなカレンダーを作ってみたいと思います。

完成したものがこちら

calendar photo

↓動きはこんな感じです

clandar gif

npmで公開しているので、unpkg経由で読み込めば実際に使えます。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Web Components Example</title>
  </head>
  <body>
    <minimal-calendar></minimal-calendar>
    <script src="https://unpkg.com/minimal-calendar-component@0.0.3/dist/minimal-calendar-component.umd.js"></script>
  </body>
</html>

npmに公開する手順についてはこちらの記事を参考にさせていただきました。

https://zenn.dev/eringiv3/articles/225e9bc2c92ff1

Vite+Svelteプロジェクトの準備

今回はWeb Componentを作るだけなので、SvelteKitではなくVite+Svelteのプロジェクトで開発します。
create vite@latestコマンドを実行すると対話形式でいろいろ聞かれるので、プロジェクト名、使用するフレームワーク(Svelte)、使用するオプション(TypeScript)などをそれぞれ指定します。

$ npm create vite@latest

✅ Project name: calendar-component
✅ Select a framework: Svelte
✅ Select a variant: TypeScript

とりあえず、作成されたプロジェクトにあるsrc/App.sveltesrc/app.cssは今回使わないので消してしまいましょう。

Web Componentsを開発するための準備

まず最初にプロジェクトのvite.config.tscompilerOptionsを追加します。

vite.config.ts
  import { svelte } from '@sveltejs/vite-plugin-svelte';
  import { defineConfig } from 'vite';

  // https://vitejs.dev/config/
  export default defineConfig({
+   plugins: [
+     svelte({
+       compilerOptions: {
+         customElement: true,
+       },
+     }),
+   ],
-   plugins: [svelte()],
  });

その次に/src/lib/ディレクトリ直下にCalendar.svelteというファイルを作成しておきます。

src/lib/Calendar.svelte
  <svelte:options customElement="minimal-calendar" />

  <h1>Calendar</h1>

この<svelte:options>customElementで定義している値がWeb Componentの名前になります。

そしてsrc/main.tsを以下のように書き換えます。

src/main.ts
+ export * from './lib/Calendar.svelte';
- import './app.css'
- import App from './App.svelte'
-
- const app = new App({
-   target: document.getElementById('app')!,
- })
-
- export default app

最後にindex.htmlを以下のように書き換えます。

index.html
  <!doctype html>
  <html lang="en">
    <head>
      <meta charset="UTF-8" />
      <link rel="icon" type="image/svg+xml" href="/vite.svg" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <title>Vite + Svelte + TS</title>
    </head>
    <body>
+     <minimal-calendar></minimal-calendar>
-     <div id="app"></div>
      <script type="module" src="/src/main.ts"></script>
    </body>
  </html>

これでnpm run dev -- --openを実行すると、ブラウザが立ち上がってCalendarの文字が表示されるはずです。

これで開発の準備は完了です。

カレンダーの考え方

  • 縦は7列。(曜日)
  • 1日がどの曜日からスタートするかは年月によって異なる。
  • 31日まである月の1日が土曜日だった場合を考慮すると、横は6行必要。
  • 今日の日付だけスタイルを変えて確認しやすいようにする。
  • デフォルトでは今日が含まれる年月を表示する。
  • カーソルボタンで翌月や来月へ移動できる。

calendar memo

今月の日数を算出する

カレンダーに表示させる日付の算出方法を考えてみます。
まずは基本として、今日の日付はnew Date()でわかりますね。

次に今月の日数ですが、これは今月の最終日がわかれば算出できます。今月の最終日はnew Date()に現在の年の値、来月の値日の値として0を渡してやると算出することができます。例として、今が2024年3月なら、new Date(2024,4,0)とすると、2024年03月31日という結果が得られるわけです。

そして今月の最終日が分かれば、今月のすべての日付のDateの配列を作成することができます。

src/lib/Calendar.svelte
  <svelte:options customElement="minimal-calendar" />
+ <script lang="ts">
+   const today = new Date(); // 現在の日時 。例 2024-03-27
+
+      let current = today;
+   $: year = current.getFullyear(); // 2024
+   $: month = current.getMonth(); // 2 (月の値は0が始点なので3月は2となる)
+   $: end = new Date(year, month + 1, 0); // 2024-03-31 (2024年3月の最終日)
+
+   $: dates = [
+     ...Array(end.getDate())].map((_, i) => new Date(year, month, i + 1))
+   ]
+ </script>
- <h1>Calendar</h1>

$:で宣言されている変数はSvelteではリアクティブな値として扱われ、参照している値が更新されるたびに再計算されます。(これは後ほど前月や来月へ表示を切り替える機能で利用します)

display: gridで日付をカレンダーっぽく並べる

作成したDateオブジェクトの配列をカレンダーっぽく表示させたいと思います。

カレンダーのような方眼紙っぽいレイアウトを実現する手段として<table>要素を使う方法もありますが、cssのdisplay: gridを使えば子要素を指定した数で折り返して表示させることができます。

src/lib/Calendar.svelte
  <script lang="ts">
    const today = new Date();

        let current = today;
    $: year = current.getFullyear(); 
    $: month = current.getMonth(); 
    $: end = new Date(year, month + 1, 0); 

    $: dates = [
      ...Array(end.getDate())].map((_, i) => new Date(year, month, i + 1))
    ]
  </script>
+
+ <div class="calendar">
+   <div class="calendar-body">
+     <div class="table">
+       {#each ['S','M','T','W','T','F','S'] as weekday}        
+         <div class="column">
+           {weekday}
+         </div>
+       {/each}
+     </div>
+     <div class="table">
+       {#each dates as date}
+         <div class="column">
+           {date.getDate()}
+         </div>
+       {/each}
+     </div>
+   </div>
+ </div>
+
+ <style>
+   .calendar {
+     color: rgb(66,66,66);
+     max-width: 400px;
+   }
+   .table { 
+     display: grid;
+     grid-template-columns: repeat(7, 1fr);
+   }
+   .column { 
+     display: flex;
+     align-items: center;
+     justify-content: center;
+     height: auto;
+     aspect-ratio: 1 / 1;
+   }
+ </style>

Svelteではこんな感じで、{#each ...}というブロックを使って配列をイテレートして処理することができます。

日付を正しい曜日からスタートさせる

日付を横並びに7個ずつ表示させることはできましたが、今のままだとどの月も1日が日曜日から始まってしまいます。例えば2024年3月なら1日は金曜日に表示させなければなりません。

calendar error

この場合、1日の前に空欄のマスを5つ挿入できれば良いということになります。

この空欄の数はDategetDay()メソッドで求めることができます。getDate()が日の値を返すのに対し、getDay()はその日の曜日の値を返します。

その曜日の値は0から6までの数値で表され、日曜日は0、月曜日は1という具合に連番になっており、最後の土曜日は6となります。この数がカレンダーを表す際に、1日の前に挿入するべき空欄の数としてそのまま使えるのです。

src/lib/Calendar.svelte
  <script lang="ts">
    const today = new Date();

    let current = today;
    $: year = current.getFullyear();
    $: month = current.getMonth();
+   $: start = new Date(year, month, 1);
    $: end = new Date(year, month + 1, 0)

    const dates = [
+     ...[...Array(start.getDay())].map(() => null),
+     ...[...Array(end.getDate())].map((_, i) => new Date(year, month, i + 1))
-     ...Array(end.getDate())].map((_, i) => new Date(year, month, i + 1))
    ]
  </script>

  <div class="calendar">
    <div class="calendar-body">
      <div class="table">
        {#each ['S','M','T','W','T','F','S'] as weekday}        
          <div class="column">
            {weekday}
          </div>
        {/each}
      </div>
      <div class="table">
        {#each dates as date}
          <div class="column">
+           {date?.getDate() ?? ''}
-           {date.getDate()}
          </div>
        {/each}
      </div>
    </div>
  </div>

  <!-- 以下略 -->

これで正しい曜日から日付をスタートさせることができました。

前月や来月への切り替え

表示する月を前月や来月へ切り替えるには、日付の配列を算出する際に基準となっているcurrentの値を更新してやれば良いです。Svelteでは変数への代入をトリガーにリアクティブな値の再計算が行われます。

src/lib/Calendar.svelte
  <script lang="ts">
    const today = new Date();

    let current = today;
    $: year = current.getFullyear();
    $: month = current.getMonth();
    $: start = new Date(year, month, 1);
    $: end = new Date(year, month + 1, 0)

    const dates = [
      ...[...Array(start.getDay())].map(() => null),
      ...[...Array(end.getDate())].map((_, i) => new Date(year, month, i + 1))
    ]

+   function prev() {
+     current = new Date(year, month - 1, 1);
+   }

+   function next() {
+     current = new Date(year, month + 1, 1);
+   }
  </script>

   <div class="calendar">
+   <div class="calendar-header">
+     <button on:click={prev}>
+       {`◀︎`}
+     </button>
+          <button on:click={() => current = today}>
+       {`${year} - ${`00${month + 1}`.slice(-2)}`}
+       </button>
+     <button on:click={next}>
+       {`▶︎`}
+     </button>
+   </div>
    <div class="calendar-body">
      <div class="table">
        {#each ['日','月','火','水','木','金','土'] as weekday}        
          <div class="column">
            {weekday}
          </div>
        {/each}
      </div>
      <div class="table">
        {#each dates as date}
          <div class="column">
            {date?.getDate() ?? ''}
          </div>
        {/each}
      </div>
    </div>
  </div>
  <!-- 以下略 -->

ついでに現在の年月の表示をリセットボタン(押すと今月へ戻る)にしてみました。

カレンダーとしてスタイルを整える

仕上げとしてカレンダーっぽいスタイルを追加して見た目を整えていきます。

今日の日付だけスタイルを変える

src/lib/Calendar.svelte(日付の部分)
  <div class="table">
    {#each dates as date}
+     <div 
+       class="column"
+       class:today={date && date.toDateString() === today.toDateString()}
+     >
-     <div class="column">
        {date?.getDate() ?? ''}
      </div>
    {/each}
  </div>
src/lib/Calendar.svelte(todayのスタイル)
+ .today {
+   color: #fff;
+   background-color: rgb(66,66,66);
+ }

class:today={...}の部分はSvelteのClass directiveという機能で、html要素にclass:クラス名と記述すると指定した条件がtrueの時だけそのクラスが付与されます。これを利用してtodayの値とdateの値を比較し、一致する場合のみスタイルが適用されるようにしています。Dateオブジェクトのままだと正しく比較されないので、一旦.toDateString()で日付文字列に変換しています。

土・日の列のみ色を変える

カレンダーの左端(日曜日の列)と右端(土曜日の列)だけ文字の色を変えたいと思います。
これはSvelteのClass directiveやTypeScirptなどを使わなくても、cssの標準機能のみで実現できます。

src/lib/Calendar.svelte(土・日のスタイル)
+ .column:nth-of-type(7n) {
+   color: rgb(126, 139, 168);
+ }
+ .column:nth-of-type(7n+1) {
+   color: rgb(168, 126, 139);
+ }

nth-of-type(7n)は連続して並んでいる要素の7の倍数番目の要素のみに適用されます。要するにカレンダーの表でいうと一番右端の列になります。
nth-of-type(7n+1)も7の倍数番目の要素にのみ適用されますが、その基点となる要素として一番目の要素を指定しています。つまり一番左端の列に適用されます。

今回作成したリポジトリ

こちらに公開しています。
https://github.com/THiragi/calendar-component

Svelte/SvelteKitの好きなところ

本来この記事はSvelteの良さを伝える目的で書き始めたのですが、なんだかSvelte独自の機能よりもDateの計算方法やcssの話の方が多かったような気がするので、最後にSvelteについて個人的に好きなところを挙げてみようと思います。

  • htmlをベースにした新しいDSLという思想。
    • htmlやcssの基本的な記法や表現力がほぼそのまま活かせる。
  • シンプルですっきりとした書き心地。
  • storeやアニメーションなど、リッチなUIを作るために必要な機能が最初から揃っている。
    • ちょっとしたUIも「とりあえず自分で作ってみよう」、という気分にさせてくれる。
  • 使ってないcssクラスや適切ではないhtmlの使い方などをチェックしてくれる。
  • ファイルベースルーティング。(SvelteKit)
  • ページごとにSPA、SSG、SSRが切り替えられる。(SvelteKit)
  • Server-only modules。(SvelteKit)
    • .serverと名付けられたファイルや$lib/serverに置かれたファイルに記述されたモジュールはクライアントサイドからはインポートできない仕組みになっている。
    • 高機能化が進むにつれフロントエンドとバックエンドの垣根がどんどん曖昧になってきていますが、個人的にはやはりどのコードがどこで実行されるのか、ファイル単位で明示的に分けられている方が好ましいです。

Svelteの思想について知るには、個人的にこちらの記事がおすすめです。

https://zenn.dev/ryoppippi/articles/8addfe62eb4d3e

Svelte/SvelteKitを使う人がもっと増えたら嬉しいです。

Discussion