🪶

Svelte5で可変複数個のSnippetを受取るTabsコンポーネント作成メモ

2024/07/25に公開


Svelte5で学習用Webアプリを作成している中でTabsコンポーネントが欲しくなったので作成しました。基礎ライブラリとしての各コンポーネントはできるだけ他のコンポーネントファイルに依存してほしくない事と、HTMLライクで直感的な構造記述の両立が悩ましかったです。まだ妥協できるラインのものができたので、スタイリング等を除いた基本機能部分を備忘メモとして残しておきます。

作成したコンポーネント

REPL

https://svelte-5-preview.vercel.app/#H4sIAAAAAAAACq1UTW_bMAz9K4Q2DDFg2Gt7S21jO2_YinXDDlEOssMkWhTJkOg2mev_PshfcZt2H8Bulvge-UQ-umZrqdCx-aJmWuyRzdn7smQho2PpD-4OFSELmTOVLfxN4gorS8q45iT3pbEEX0XuYG3NHjiLYn-KOh5n11wn8YmhEx9tufUrp2VZIgGJ_KPIUV3MgsZHOJHIL1pM3GOa5xmXU8bl3zCupoyrM8ZTzo3QU11Jma2NSeIy-0OtlndSl1Qq6744JUpmubBJrOQ_3iVxn-ZPhU-PTLZXWS5-JvH2akiytcNXXhEZDX7SKWfdibMO353OqiVxPz8Wsr1ZybXEFZuTrbAJRwN5yF84CAqjCQ-Ucp-qUsgZKKE3KWfkOJsazKeCGm77dzaD2SYuA-ixNWyQeuQHPLrvkrafxB5PrCj-RlJJkvjIpwBSE9q1KBBurCkd1P4SYLHD4xwcWak3yzlUeqfNvYaHQU_oYc0jow9PfPweAIVeYBRFpbBi76CZ96VSeF36j1nQKimMdgR93320xwsHX7AwdpV0csJBQ3YNcQxuayq1gjuh5EoQnjIp733fDUhfaM9sKBYCZ8O2cBZEZG6NJVw9UlZ6m_1TvtaYz-TzHREFyTv0PXAkCGdvfcgH15UuSBoN7l5Ssf0q8pnUKzzMQVf7HG0wjGjM0IavzwfCdf0KRbGddEI42OExBNl4-AvbAEYXSha7tJ4FkGZTIUGTdcXrdxb1Cu04L2-YZbuDAJNVqmOvoP3NnFPGji66tyx9gt9t2ejh_7JqAHho12ds-QtjbV06B6GPi2UIXsuwGsHwsVgOY7FIldXwOf-BBUU7PLqOH3RhgGgtFaGdHXxvD5EjYamtNfOZRxjAmzcgXa-ny7E4LEM4jJBgGPrENScGCbtBGnf3THhujEKhn-j2bTVr6MiQpilwNiTnzGvqQpFCvaFti7jw1329yFfpeZzBwwM8vW9feWbX87Evm18G8bfCqQcAAA==

コード全体

code
App.svelte
<script>
  import Tabs from "./Tabs.svelte";
</script>

<Tabs>
  {#snippet tabLabel1()}
    tab1
  {/snippet}
  {#snippet tabLabel2()}
    tab2
  {/snippet}
  {#snippet tabLabel3()}
    tab3
  {/snippet}

  {#snippet tabPanel1()}
    <p>foo</p>
  {/snippet}
  {#snippet tabPanel2()}
    <ul>
      <li>bar</li>
      <li>bar</li>
      <li>bar</li>
    </ul>
  {/snippet}
  {#snippet tabPanel3()}
    <h3>baz</h3>
    <hr>
    <button type="button">baz</button>
  {/snippet}
</Tabs>
Tabs.svelte
<script context="module" lang="ts">
  import type { Snippet } from "svelte";
  import { getSnippetKeysWithName } from "./Utilities.svelte";
  interface Props {
    [key: string]: unknown | Snippet,
  }
</script>
<script lang="ts">
  let { ...params }: Props = $props();
  const snippets = params as Record<string, Snippet>; // should validate
  const labelKeys = getSnippetKeysWithName(snippets, "tabLabel").toSorted();
  const panelKeys = getSnippetKeysWithName(snippets, "tabPanel").toSorted();
  let active = $state(0);

  function switchTab(index: number) {
    active = index;
  }
</script>

{#each labelKeys as key, i}
  <button type="button" onclick={() => switchTab(i)}>
    {@render snippets[key]()}
  </button>
{/each}

{@render snippets[panelKeys[active]]()}
Utilities.svelte
<script context="module" lang="ts">
  export function getSnippetKeysWithName(props: any[], name: string): string[] {
    return Object.keys(props)
      .filter(x => x.startsWith(name)
        && isSnippet(props[x], x)
      );
  }
  function isSnippet(target: unknown, name: string): boolean {
    return typeof target === "function" && target.length === 1 && (target.name === "" || target.name === name);
  }
</script>

簡単な解説

可変個Snippet受け渡し

コンポーネントの$propsの型をinterfaceで定義し、残余引数を用いて可変個を表現している。以下案も検討したが直感的な構造記述ができない等のため採用しなかった。

  1. Snippetを配列で受け取る
    • 呼び出し側コンポーネントの任意場所で唐突な{#snippet}定義が必要
    • {#snippet}で定義した関数を<script>タグ内で配列に加工する必要がある
    • 上記2つが必要かつ、呼び出し側で関連性のあるコードが散らばるため不採用
  2. 各タブコンテンツを表す{#if}ブロックを含むSnippetをchildrenで一括で受け取る
    • タブの表示切り替えを制御する変数を呼び出し側コンポーネントも持つ必要がある
    • コンポーネントとしての独立性が低くなってしまう事と気軽さ、使いやすさの面で不採用

Snippet値の判定

残余引数の値にSnippetが格納されているかどうかの厳密な判定方法が現時点でわからなかったため緩い。ここで定義されているSnippetReturnあたりが使えるのかもしれない。

Snippetの種類判別

カスタムタグ名の代わりとして、Snippet関数名が種別毎の特定文字列で始まっているかどうかで判別している。上記例ではtabLabeltabPanelが特定文字列。

ラベルとパネルの同期

Snippet種別毎の配列のインデックスでラベルとパネルを同期している。呼び出し側が制御可能かつ結果を予測可能とするためSnippet関数名でソートしている。

所感

何回も何回も何回も"{#snippet}"と書きたくないので何とかしたいです。

参考文献

  • なし

Discussion