スケルトンコンポーネントの作り方 part1

:::note info
この記事は2部構成です。
[part2はこちら](https://qiita.com/shamokit/items/6d03ed694774421c816c)
:::


## スケルトンコンポーネントとは
[こういうやつ](https://mui.com/material-ui/react-skeleton/)です。

## 満たしたい要件
- 何かしらの遅延して読み込むコンポーネントが表示されるまでの間、決まった高さをスケルトンで確保したい
    - レイアウトシフトを起こさないようにしたい
- 通常のコンポーネントとスケルトン用のコンポーネントを二つに分けたくない
    - クラス名を重複して管理したくない　など
- スケルトンの内部でさらに別のスケルトンを表示する場合、同期されないようにしたい（スケルトンごとにグルーピングしたい）
- スケルトンの色を指定できる
    - ついでに角丸も

## 前準備1
早速作っていきます。
まず必要なスケルトンをざっくりを二つに分けます。

- 画像などのいわゆるテキスト以外を表示するスケルトン
- テキストを表示するスケルトン

の二つです。
それぞれ見ていきましょう。

:::note warn
これから以降使用されるクラス名はTailwind CSSのクラス名です。
:::

### 画像などのいわゆるテキスト以外を表示するスケルトン
要件に「レイアウトシフトを起こさないようにしたい」があるので、まず画像を表示するエリアに必要なwidthとheightを指定できるようにします。

表示するエリアの確保のため、svgを配置します。

ということでPathSkeleton.svelteを以下で作成します。

```svelte
<script lang="ts">
	let {
		width,
		height,
		borderRadius = '4px',
		backgroundColor = 'lightgray'
	}: Partial<{
		borderRadius: string;
		backgroundColor: string;
	}> & {
		width: number;
		height: number;
	} = $props();
</script>

<svg
	class="h-auto max-w-full"
	style:background-color={backgroundColor}
	style:border-radius={borderRadius}
	style:width="{width}px"
	viewBox="0 0 {width} {height}"
></svg>
```

### テキストを表示するスケルトン
こちらに関しても要件に「レイアウトシフトを起こさないようにしたい」がありますが、いちいち指定するフォントサイズによって高さを書いたりするのは非常に面倒です。

フォントサイズやline-heightを考慮して自動で高さが決まるようなスケルトンを作りましょう。

あるウィンドウサイズにおいてテキストが何行になるのかは予想できません。
使用する場合はあらかじめCSSで高さを制限するなどしましょう。

この前提の元、テキストが最大何行表示されるのかがわかっていることとします。

このスケルトンはLineSkeleton.svelteという名前で作成します。

```svelte
<script lang="ts">
	let {
		width = '100%',
		borderRadius = '4px',
		backgroundColor = 'lightgray',
		line = 1
	}: Partial<{
		width: number | string;
		borderRadius: string;
		backgroundColor: string;
		line: number;
	}> = $props();

	const range = (n: number): number[] => {
		const l = [];
		for (let i = 1; i <= n; i++) {
			l.push(i);
		}
		return l;
	};
</script>

{#each range(line) as _ (_)}
	<span
		aria-hidden="true"
		class="block scale-y-75"
		style:background-color={backgroundColor}
		style:border-radius={borderRadius}
		style:width>&nbsp;</span
	>
{/each}
```

テキストのスケルトンにはフォントサイズやline-heightを反映したテキストをおくために`&nbsp;`を置きます。
また、これがスクリーンリーダーに読み上げられても困るので`aria-hidden="true"`をつけます。

あとは指定したlineの数だけこのspanを配置しているだけです。
このコンポーネントは横幅を設定できた方が実用的なので`width`を当てられるようにしています。

また、`scale-y-75`はn行になった時いい感じに余白が開くようにつけているものなのでいい感じに調整してください。

## 前準備2
前準備はこれで終わりです。

何かしらのローディング状態を受け取って、

- ローディング中ならPathSkeletonやLineSkeletonを表示
- ローディングが終わったらテキストや画像を表示

するコンポーネントを作成しましょう。

:::note warn
前準備1で作成したコンポーネントは直接使用しないので、今から作成するImageSkeleton.svelteとTextSkeleton.svelteに直接書いてもいいかもしれません。
:::

あまり説明することもないのでソースを貼ります。


TextSkeleton.svelte
```svelte
<script lang="ts">
	import type { ComponentProps } from 'svelte';
	import type { HTMLImgAttributes } from 'svelte/elements';
	import PathSkeleton from './PathSkeleton.svelte';
	import { getLoadingContext } from './createLoading.svelte';

	type ImageProps = Omit<HTMLImgAttributes, 'src' | 'alt'> &
		Required<{
			src: string;
			alt: string;
		}>;
	type PathSkeletonProps = ComponentProps<PathSkeleton>;

	let { key, src, alt, width, height, ...rest }: { key: symbol } & ImageProps & PathSkeletonProps =
		$props();
	let loading = getLoadingContext(key);
</script>

{#if loading.state}
	<PathSkeleton {width} {height} {...rest} />
{:else}
	<img {src} {alt} {width} {height} />
{/if}
```

ImageSkeleton.svelte
```svelte
<script lang="ts">
	import type { ComponentProps } from 'svelte';
	import type { HTMLImgAttributes } from 'svelte/elements';
	import PathSkeleton from './PathSkeleton.svelte';
	import { getLoadingContext } from './createLoading.svelte';

	type ImageProps = Omit<HTMLImgAttributes, 'src' | 'alt'> &
		Required<{
			src: string;
			alt: string;
		}>;
	type PathSkeletonProps = ComponentProps<PathSkeleton>;

	let { key, src, alt, width, height, ...rest }: { key: symbol } & ImageProps & PathSkeletonProps =
		$props();
	let loading = getLoadingContext(key);
</script>

{#if loading.state}
	<PathSkeleton {width} {height} {...rest} />
{:else}
	<img {src} {alt} {width} {height} />
{/if}
```

落ち着いて読めばそんなに難しい実装はありませんが、`getLoadingContext`のローディング状態を作るstateやContextをまだ作成していません。

この次の記事で作成しますが、型を見るとある程度予想がつきます。

キーワードは

- runes
- context
- symbol

です。

最終的には

```svelte
<script lang="ts">
	import TextSkeleton from './TextSkeleton.svelte';
	import ImageSkeleton from './ImageSkeleton.svelte';
	import Skeleton from './Skeleton.svelte';

	const key = Symbol();
	const otherKey = Symbol();
</script>
<Skeleton {key} time={3000}>
	<ImageSkeleton
		{key}
		src="https://shamokit-ogimage.shamokit.workers.dev"
		alt="しゃもきっとブログ"
		width={1200}
		height={630}
	/>
	<h2 class="text-lg leading-relaxed">
		<TextSkeleton {key} width="30%" line={2}
			>遅延して表示<br />遅延して表示
			<Skeleton key={otherKey} time={3000}>
				<TextSkeleton key={otherKey} width="30%" line={1}>さらに遅延して表示</TextSkeleton>
			</Skeleton>
		</TextSkeleton>
	</h2>
</Skeleton>
```

のようにして3秒後に画像＆「遅延して表示<br />遅延して表示」を表示し、さらに3秒後に「さらに遅延して表示」を表示します。

今回例なのでSkeletonには秒数を渡せるように作っていますが、実際にはPromiseを渡すようにするといいでしょう。

この記事はこれで終わります。
[part2はこちら](https://qiita.com/shamokit/items/6d03ed694774421c816c)