🕋

Svelte5でdialogタグのModalコンポーネント作成メモ

2024/09/13に公開


Modalコンポーネントが欲しくなったので作成しました。Escキー無効化に手間取ったり背景スクロール不可を手軽に実装できないか色々調べたりしたので一部スタイリング部分以外を備忘用に残しておきます。

作成したコンポーネント

REPL

https://svelte-5-preview.vercel.app/#H4sIAAAAAAAACqVV34vjNhD-V2bdfbDBce6gT46d69FbKLQHhe7b6QiKPU7EypKxZO8G4_-9jOQfyW73KO1TrNHM6JtvZr4MQSUkmiD9NgSK1xikweemCeLAXho6mB6lxSAOjO7agiyZKVrR2D1TzIq60a2Fr7rkEqpW18CCZOuOiY9kwY4pcpVoQTeoIId7Y7nFsOLSYETXtupUYYVWoFUhRfEURjDM3nf0u4ORqWy7Pq2yY2etVkA4cxb4EwtgmFKMe4rLtv5iz1TmUR6FKlO6cgVkpuFqX7ubUnCpT9nWmeg1F-AeY2r4CXlxhs9tyy_hzx-ipBJShh-ipOZNGL7EIsr3IgJuQHX16FI3-4G-s22zZ2rYUvzIVBAHtS5FJbAMUtt2OMYL8-7Bf8E91LrsJILk6pSzwBoWuGrwxbWDwuHPVjcGchjoglmqOIWj1hK5ir2tkNrwo8Q39rOQZUv-fynRNGgnO0qsUdkUfnv8-scXx9aDNzmHcer0NBSDxzGngE7ZlhdPMM6Dcj0ga5Cxull9km1nxc0sXQ3BzMZrGmjUlvG5p4ZTlWEUw1wx5EDUxzCXGsNU3G0EjOlC5H1DH6Ef2HusKixs0rQYhhHk--nBnWdqqnW5svp0kugpC6MdjD6L_7me_1vHuXmignDBl-fQqRIrobCkNWnRdq1fkNmXkCzBS98Sc9bPbsSmIpgdAaXBt57EE65eDqtHWmhlrONxQgn5yuonuOYCcnArvoMR0hXzbk3TtNijsr9Stts8NAVhGOX7YYxeBQPcBD-YgkTiGgP2KfyOl6PmbflAPhMoR2OfPOHFsciCB1PwBllANGKfTBm_YMU7aV2f3kOuVcFVgfK_VH8rY3ebDfjx3oiT0i0C__jxclD6oLQSymLLCyt6PEy9OSw2rQxsNrQHXrm8tNmzMPkwOY-zoObDVcvI-oSXUj-rfFhpHJ12urJGL47vYHMZDy7MHM68x8MTXqazR_R-rNIHUn9R_KAeZrNS9Cv06zHxyJgdfmlRldguGxxGXnW3peidentSPMnGXiS6yPQk9ZHL8GxrmZ65Cb3bN2rZ92haGgDdY1tJ_ZzCWZTltNf-L2hK9SMdJ836_zK-iAItQ_YI-GJRlQYmzd2H5PAphZBansJjDNjbFNaJ77Uol4q8TCxJ_zlo8gW3KuQKd6_kZnYAiksIGekjP3GX1CuGv6fwpOBSuqfcO-v16D_GK17nnXhL7Pfxb9MOIMemCAAA

コード全体

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

  let open = $state(false);
  function onclick() { open = !open; }
</script>

<button type="button" {onclick}>open</button>
<Modal bind:open>
  <span>modal dialog</span>
</Modal>


{#each Array(40).fill(0).map((x,i)=>i) as num}
  <p>{num}</p>
{/each}
Modal.svelte
<script module lang="ts">
  export type Props = {
    open: boolean,
    closable: boolean,
    children: Snippet,
    element: HTMLDialogElement,
  };

  import { type Snippet, untrack } from "svelte";
  import { stop } from "./util.svelte";
</script>
<script lang="ts">
  let { open = $bindable(), closable = true, children, element = $bindable() }: Props = $props();
  $effect.pre(() => { open;
    untrack(() => { toggleDialog(); });
  });

  function toggleDialog() {
    if (element === undefined) { return; }
    if (open) {
      element.showModal();
    } else {
      element.close();
    }
  }

  const closeDialog = closable ? () => { open = false; } : undefined;
  const preventClose = closable ? stop(()=>{}) : undefined;
  const preventEsc = !closable ? (ev: KeyboardEvent) => { if (ev.key === "Escape") { ev.preventDefault(); }} : undefined;
  const oncancel = closable ? () => { open = false; } : undefined;
</script>

<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<dialog bind:this={element} onclick={closeDialog} onkeydown={preventEsc} {oncancel}>
  <!-- svelte-ignore a11y_click_events_have_key_events -->
  <!-- svelte-ignore a11y_no_static_element_interactions -->
  <div onclick={preventClose}>
    {@render children()}
  </div>
</dialog>

<style>
  :global(html:has(dialog[open])) {
    overflow: hidden;
  }
</style>
util.svelte
<script module lang="ts">
  export function stop<T extends Element>(func?: (this: T, evt: Event) => void) {
    return function(this: T, evt: Event) {
      if (func !== undefined) {
        evt.stopPropagation();
        func.call(this, evt);
      }
    };
  }
</script>

簡単な解説

開閉制御

dialogタグをモーダルとして開くにはshowModal関数の実行が必要。そのため、$propsの受け取り変数openの状態変化に応じてshowModal又はcloseを呼び出すようにしている。トグル用関数をbindableにしようかとも思ったが、開閉状態を直接読み書きできたほうが利便性が高そうなため$effect.preで制御することにした。

追加的な閉じる制御

  • モーダルウインドウ外(backdrop)のクリックで閉じるためにdialogタグにonclickイベントを登録
  • モーダルウインドウ内のクリックで閉じないために中のdivタグにstopPropagationを含むonclickイベントを登録
  • 通常Escキーで閉じるが、標準の閉じる操作を制御するclosable変数のためにonkeydownイベントでpreventDefaultを用いて抑制可能に
    • oncancelイベントで抑制したかったが、そうするとChromium系はEscキー2回押しで強制的に閉じられるため妥協
  • 各関数をわざわざ変数化しているのはEventListenerへの不要な登録を避けるため

背景スクロールの停止

html:has(dialog[open])でモーダルウィンドウを開いている時にCSSを有効化できる。:globalで設定を全体化。iOSの場合はこの方法で抑制できないとのこと。

雑記

モードレスな領域(ウィンドウ)はポップオーバーAPIを使用するとJSレスで簡単に実現できるようになったみたいです。色々と進化していますね。

参考文献

Discussion