Fresh(Deno) でイイ感じに使えるモーダルを作りたい、それと islands は孤島でないといけない話

Deno Advent Calendar 2022 14日目です。

タイトルの通り、Fresh でイイ感じのモーダルを使いたかったので、トライしました。

これの途上で引っかかった islands のとある仕様について書き残しておきます。

参考

モーダル実装

方針として、dialog 要素を使う方向で実装します。

モーダル本体の実装は、次の通りです。

components/Modal.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import { JSX } from "preact";
import { useEffect, useRef } from "preact/hooks";

interface Props {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
isNoBackdropClose?: boolean;
beforeOpen?: () => void | Promise<void>;
afterOpen?: () => void | Promise<void>;
beforeClose?: () => void | Promise<void>;
afterClose?: () => void | Promise<void>;
}

export default function Modal(props: Props & JSX.HTMLAttributes) {
const didMountRef = useRef(false);
const ref = useRef<HTMLDialogElement | null>(null);

const doOpen = async () => {
if (props.beforeOpen) await props.beforeOpen();
ref.current?.showModal();
if (props.afterOpen) await props.afterOpen();
};
const doClose = async () => {
if (props.beforeClose) await props.beforeClose();
ref.current?.close();
props.setIsOpen(false);
if (props.afterClose) await props.afterClose();
};

useEffect(() => {
if (!didMountRef.current) {
didMountRef.current = true;
return;
}
if (props.isOpen) {
doOpen();
return;
}
doClose();
}, [props.isOpen]);

return (
<>
<dialog
ref={ref}
class={`p-0 bg-transparent ${
props.class?.split(" ").filter((p) => p.match(/^[h,w]/)).join(" ")
}`}
onClick={() => {
!props.isNoBackdropClose && doClose();
}}
>
<div
class={`w-full h-full bg-white ${
props.class?.split(" ").filter((p) => !p.match(/^[h,w]-/)).join(" ")
}`}
onClick={(e) => e.stopPropagation()}
>
{props.children}
</div>
</dialog>
</>
);
}

そして、このコンポーネントを呼び出す islands はこちら。

islands/ModalMounter.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import { JSX } from "preact";
import { useState } from "preact/hooks";
import Modal from "../components/Modal.tsx";

export default function ModalMounter(
props: JSX.HTMLAttributes<HTMLElement>,
) {
const [isOpen, setIsOpen] = useState<boolean>(false);

// イベントの補足のために用意したが、無くても良い
const beforeOpen = () => console.log("beforeOpen");
const afterOpen = () => console.log("afterOpen");
const beforeClose = () => console.log("beforeClose");
const afterClose = () => console.log("afterClose");

return (
<>
<button
class="px-3 py-2 bg-white rounded border(gray-500 2) hover:bg-gray-200 disabled:(opacity-50 cursor-not-allowed)"
onClick={() => setIsOpen(true)}
>
open
</button>

<Modal
isOpen={isOpen}
setIsOpen={setIsOpen}
isNoBackdropClose={false}
class="p-2 border-2 border-gray-100 rounded"
beforeOpen={beforeOpen}
afterOpen={afterOpen}
beforeClose={beforeClose}
afterClose={afterClose}
>
<div class="flex flex-col">
<div class="mb-4">
<h2 class="text-2xl">Fresh</h2>
<p>1. Just-in-time rendering on the edge.</p>
<p>2. Island based client hydration for maximum interactivity.</p>
<p>
3. Zero runtime overhead: no JS is shipped to the client by
default.
</p>
<p>4. No build step.</p>
<p>5. No configuration necessary.</p>
<p>6. TypeScript support out of the box.</p>
</div>
<div class="">
<button
class="w-full px-3 py-2 bg-white rounded border(gray-500 2) hover:bg-gray-200 disabled:(opacity-50 cursor-not-allowed)"
onClick={() => {
setIsOpen(false);
}}
>
close
</button>
</div>
</div>
</Modal>
</>
);
}

ここまでできたら、この islands を読み込み。

routes/index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Head } from "$fresh/runtime.ts";
import ModalMounter from "../islands/ModalMounter.tsx";

export default function Home() {
return (
<>
<Head>
<title>Fresh App</title>
<link rel="stylesheet" href="/main.css"></link>
</Head>
<div class="mx-auto max-w-screen-md p-4">
<ModalMounter />
</div>
</>
);
}

あとは、tailwind でうまくスタイルが適用されなかったので、別途cssで展開したスタイル指定。背景をもう少し暗くする。

static/main.css
1
2
3
dialog::backdrop{ 
background: rgba(0, 0,0, 0.7);
}

動いているのは冒頭と同じく以下の動画の通りです。

各種タイミングで処理を呼び出せるようにしたのでかなり使えるはずです。

それとislands は孤島でないといけないという話

このモーダルを作っていた時、その途上で一時次のような実装をしていました。(ちゃんと動いてくれません。)

islands/Modal.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { useRef } from "preact/hooks";
import { Button } from "../components/Button.tsx";

export default function Modal(props) {
const ref = useRef<HTMLDialogElement | null>(null);

return (
<div>
<Button onClick={() => ref.current?.showModal()}>open</Button>
<dialog
ref={ref}
class={props.class}
onClick={(e) => {
ref.current?.close();
}}
>
<div
onClick={(e) => e.stopPropagation()}
>
{props.children}

<Button
onClick={() => {
ref.current?.close();
}}
>
close
</Button>
</div>
</dialog>
</div>
);
}

呼び出し側はこちら。

routes/index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Head } from "$fresh/runtime.ts";
import Modal from "../islands/Modal.tsx";

export default function Home() {
return (
<>
<Head>
<title>Fresh App</title>

<link rel="stylesheet" href="/main.css"></link>
</Head>
<div class="mx-auto max-w-screen-md">
<Modal>
<p>Opened Modal</p>
</Modal>
</div>
</>
);
}

どうなるでしょうか。

はい、props.children として <p>Opened Modal</p> が渡され、モーダルが開くと見えそうなものですが見えないのです。

ここで、Fresh のドキュメントを参照すると次のようなことが書いてあります。

参考: FRESH - 3. Concepts - 3.3. Interactive islands

Passing props to islands is supported, but only if the props are JSON serializable. This means that you can only pass primitive types, plain objects, and arrays. It is currently not possible to pass complex objects like Date, custom classes, or functions. This means that it is not possible to pass children to an island, as children are VNodes, which are not serializable.

    1. props を islands に渡すことはサポートしている
    1. ただし、props で渡せるのは、プリミティブ型、プレーン オブジェクト、および配列
    1. Date、Custom Class、関数などは複雑なオブジェクトは渡せない
    1. VNode、childrenは、アイランドに渡せない

と、このようなことが書いてあります。

そして、記述したからといって、エラーも特に出しません。
というわけで、islands は、何かの漂着物(複雑ではない、プリミティブ型、プレーンオブジェクト、配列)くらいが届く孤島。
孤島の間は 電話(preact/signals) で話せます。という状況の様子になっています。
islands は閉じタグを書かない何故なら開かれていない孤島だから。と覚えておく。

これを知っていないと、エラーも出さないのでそこそこの時間悩むことになります。(実際しばらく悩んだ)

実は今 islands に関わる結構重要なプルリクが出ている

注目しているのは、このプルリク。

GitHub - denoland/fresh - Pull requests - Filename islands

現在の islands の制約は、islands ディレクトリにファイルを置かなければならないというものです。
このプルリクでは、.island.{ts,tsx,js,tsx} は、islands として取り扱います。というふうにルールを変えるものです。

現在の islands ディレクトリの制約から解き放たれるので islands を配布できるようになることが想定出来ます。
例えば、twitter のウィジェットとか。

なかなか楽しそうです。


Fresh の islands でモーダルを動かすチャレンジの完成品。途中でぶち当たったもの、期待してるプルリクに触れました。
Fresh は「結構いける、かなりいける」というのを感じているので、引き続きいろいろとやっていきたいと考えているところです。

では。