🛀

Svelte5でdetailsタグのAccordionコンポーネント作成メモ

2024/07/22に公開


Svelte5で学習用Webアプリを作成している中でAccordionコンポーネントが欲しくなったので作成しました。閉じる時にtransitionが機能しないように見えたり、他コンポーネントとの連携用$effectの使い方に若干迷ったりしたのでスタイリング部分以外を備忘用に残しておきます。

作成したコンポーネント

REPL

https://svelte-5-preview.vercel.app/#H4sIAAAAAAAACq1WbW_bNhD-K1e1QKVAs_thn2QrXYEGW4ENHZDuUxQUtHSyiVCkRh69eIL--8AXyYqTbgM2f5Ap8l6eu3vuqCFpuUCTFHdDIlmHSZF86PskT-jUuxdzREGY5IlRVtduZ2tqzXuCWknCRyqrpFONFVglIJjcl1VCpkquK1kR73qlCT7UtdINVxJarTqoktV63loFB1WyqeR2HUw7Xbmd1wACCQyXe4G3xMgaKOGNIUaYtkwYzDaT0F4r21_K3HmhHJZ_99mlv-G1kbzvMQYmKc1GZ_ZnpbED3hvbQaOE0mA4AeuQQPijeRFkwjpIIgPJdwcw2KzgF7aXzG0hwQMzDVgCFJw0HFErwEfUNYeGM29y4RAdIoOEZDUYcmHaHeq9RrmCj94RE_x3e2IdHFEAs4Sd8wnEHnjHiMHRit66ZAR9Y03Ne07ekTzyxkqCTgk0xNFtBlze-wp-I4eaa241gmEN703N5T6CW8GNZhTsBtBSSdt1JzAufBUTwuraGtatKjmsY6JHX-YzOQTboSir5NZXGggNVQnsuGwKbj73KMthSYLRU2P4QaNsUD-p2vbMr8Clnb6u5Pbgn37t6o2sPjxhDDPwNQfuy_4Crh-dqAvcIYOBj5foFrbu-P0YDv3mk7MA_BvQAZ6CH9YO5ljJJHd9xluOTVKQtjjm546dFP6XvgVYX11dwc2jb96rq6u128Pw6szDr1r1rsGGEEh94KLRKAu4DZXNw75PXAGGNJf7uBdy9b6AnVICmcwBYL0G3oLg8gGM6jAH02PN2xMwrdnpjt-DaoEO6E-DGZ_Os5W7-_yfzFzaGDeOBTHWz3RAbeZYXb8RNFYzcgQo4ft37zbewbynWlA9yrwWyiCQZtJwd7AwGqffZDS--gQOU6ZgnGbiYg7OsgMYwRuchN4GmfXZ29tnU_PVd__ytxyyzwjgZukw1zUPlcxj8dxkddRmOzEN4DwU5MlJBmMxM-VN7xZptkz6J8mJM8H_xDlHzq9L61zY8xQPvhej3jLdLOU8kqX925OsZ8tvsG2xplWvMU0zKK8n8ga7obqk-X6PeqJK1AlypPZ7gR-RGBchEIAx-yaJKmqtrD1Veo1HlLT9AvhIKBsDNwI7lHSdOpn3BaR04KaALzngkQq4ceIe41HxJnNIKwLQ7gKQMNl9WSnKgmsFbx5elSVY2WDLJTYLAXB6qwjuI7bMCgoFisdOe1UzIbwn7-Z8PIaF7yI_yYkZl-85aiMQ-7QzWBcgbbdDfRmIxD8cPzpuMNVolDiii9kgfeEdKkvTbg7OTJadfc1efPt9EMLP5jQrfMbm0raQBl4-z0EI0Z-uWqVvWH1I068592n3227uRFaFavug42MGcEGLFwA4nmYwxKg3QR8Cf6EEN8o3Z_FI8zNEFTpuITYCCoNniZDpaTJlKzqgjByPys-D-FutCdpCbVzw_CcmG4HgeXOemXNGbN8wwk8-jsuEuMalzs2JV7Hv4g3ytIybZWu64Lt-QvGfpl0TyuTTUg7uGe7irbFdx_QJlKwFrx_KIbZFugwmmy9uPw7jZR1Vw8fIa95G1DHL24YfF7dD4ed5OQzzRVKcr5Rxsr_8NIgjOH4bOIcNPwZna976hMSorl_6Rrgf_wKHm6xT4QsAAA==

コード全体

code
App.svelte
<script context="module" lang="ts">
  import Accordion from "./Accordion.svelte";
</script>

<script>
  let singleStatus = $state(false);
  let groupStatus = $state([false, false, false]);
</script>

{#snippet content()}
  Lorem ipsum dolor sit amet lorem et lorem ipsum lorem dolor ea nibh sed. Magna ea et kasd ut elitr vero exerci diam et ipsum dolore consetetur stet gubergren. Dolor aliquyam vel autem sed takimata vulputate stet suscipit et invidunt molestie et elitr dolor. Ut ea iriure sadipscing dolore. Erat stet ipsum nonummy sea no lorem accusam.
{/snippet}

<Accordion label="Single test" bind:isOpen={singleStatus}>
  {@render content()}
</Accordion>

<br>
<hr>
<br>

{#each groupStatus as _, i}
  <Accordion label="Grouping test {i}" bind:isOpen={groupStatus[i]} bind:group={groupStatus}>
    {@render content()}
  </Accordion>
{/each}
Accordion.svelte
<script context="module" lang="ts">
  /*** Export ***/
  export type Props = {
    children: Snippet,
    label: string,
    isOpen?: boolean,   // if link some, specify array[i] of the some
    group?: boolean[],  // if link some, specify array of the some
  };

  /*** Others ***/
  const duration = 400;  // duration of open,close transition

  /*** import ***/
  import type { Snippet } from "svelte";
  import { slide } from 'svelte/transition';
</script>

<!---------------------------------------->

<script lang="ts">
  let { children, label, isOpen = $bindable(false), group = $bindable() }: Props = $props();

  /*** Initialize ***/
  let open: boolean = $state(isOpen);
  let guard: boolean = false;

  /*** Sync ***/
  $effect.pre(() => {
    isOpen;  // trigger of the $effect
    toggleDetails();
  });

  /*** Others ***/
  function prevent<T extends Element>(func?: (this: T, evt: Event) => void) {
    return function(this: T, evt: Event) {
      if (func !== undefined) {
        evt.preventDefault();
        func.call(this, evt);
      }
    };
  }
  async function sleep(msec: number) {
    return new Promise(resolve => setTimeout(resolve, msec));
  }
  function closeAllGroup(): void {
    if (group !== undefined) {
      group.forEach((_,i) => group[i] = false);
    }
  }
  function toggleDetails(): void {
    if (guard) { return; }
    guard = true;
    if (isOpen) {
      open = true;
    } else {
      sleep(duration).then(() => open = false);
    }
    sleep(duration).then(() => guard = false);
  }

  /*** Handle events ***/
  function updateIsOpen(): void {
    let tmp = !isOpen;
    closeAllGroup();
    isOpen = tmp;
  }
</script>

<!---------------------------------------->

<details open={open}>
  <summary onclick={prevent(updateIsOpen)}>
    {label}
  </summary>
  {#if isOpen}
    <div transition:slide={{ duration: duration }}>
      {@render children()}
    </div>
  {/if}
</details>

簡単な解説

開閉状態の制御

detailsタグはopen属性で開閉状態を指定可能。transitionを使用しない場合は$propsの値をそのままopen属性に渡せば事足りる。transitionを使用する場合は、open属性がfalseになった時点でコンテンツの描画が瞬間的に無くなりアニメーションしていないように見えてしまう。そのため、transitionのアニメーションが終わったタイミングでopen属性をfalseにする必要がある。すなわち$propsの閉じる状態の値とは別に、実際に閉じるタイミングを制御する変数を持つ必要がある。

開閉トリガー

detailsタグの開閉のトリガーは複数Accordion間の連携のために以下の2パターンある。

  • 自身のイベント
  • 親コンポーネントからの$props変更

このため、上記コードで開閉指示は$propsの受け取り変数isOpenの変更に統一し、2パターンどちらもisOpenの変更のみ行う。実際の描画制御はisOpenの変化を検知した後に起動する$effect.preで行う。$effect.pre内で変数の明示が無いと他の$state変数の変化でも起動してしまうので、isOpenの変化でのみ起動するように$effect.pre内で意味のない記述isOpen;を記述している。

開閉連携

上記コードではAccordion自身の開閉状態をisOpen変数、連携しているAccordion群の開閉状態をgroup配列変数で表現している。実質的には連携という連携はしておらず、イベントがトリガした際にAccordion群を全て閉じた後に本来の自身の開閉処理をしているだけ。

動作中のトリガ抑制

開閉を制御しているtoggleDetails関数が動作中に再度起動しないようにguard変数で起動状態を保持。

Svelte側の制御が関連する部分

上記コードのイベントハンドラupdateIsOpen関数ではgroupを変更した後にisOpenを変更している。groupを変更すると間接的に自身のisOpenを変更しているため、表面上は2回isOpenが変更され2回$effect.preが実行されるはずだが、実際は1回しか実行されないように見えうまくいっているように見える。

所感

実際は外部からのアイコンの描画有無,スタイリング制御等を盛り込んだのでもう少し複雑になってしまいましたが、スタイリングの記述が無いとシンプルでいいですね。

参考文献

Discussion