皆さんは社内のいろんなプロジェクトで使うコンポーネントライブラリを作りますか?
私はそこらのライブラリではマークアップにミスがあったりアクセシビリティが気になったりJavaScriptなしで動くものに余計なコードが書かれていたりするのが気に入らず、ほぼ毎回一から作っています。
いいかげん公開したいですが、プライベートが管理で取られそうなのが嫌でずっと足踏みしています笑
そんな私が現時点でSvelteでコンポーネントライブラリを作る上で「こう作っておくといい」と思っていることをお伝えします。
HTMLタグを固定できるように作ろう
マークアップに詳しい人は実際あんまりいません。
時と場合によっては
「なんにもわからねぇ、俺は雰囲気でHTMLを書いている。俺のdivはクリック可能だぜ!」
みたいなこともありうるので、例えばDisclosureならdetailsを使ったコンポーネントを作っておきましょう。
コツ
よく言われていることですが、stateはコンポーネントの外に持ちましょう。
また、既存のコードからコンポーネント化する場合は意外とやってしまいがちな気がしますが、
$appなどSvelteKitに依存した値は使用しないようにしましょう。
あとuse:とかbind:とかのディレクティブはあまり使わないようにした方がテストの時などにシンプルにかけて助かります。
Svelte5のRemote Functionsのformではbind:を使用しなくてもform経由で現在の入力値を得ることができるので、bind:の使用箇所は今後減るかもしれないと思っています。
スタイル
コンポーネントライブラリ側では極力持ってはいけません。(ただしレイアウトは持っていてもよい)
ヘッドレスなものに毛が生えた程度のもので留めるのをお勧めします。
コンポーネントを作ればその見た目をほとんどの箇所で使いまわせると思い込んでいる人も多いですが、思い返してみれば全然そんなことはありません。
作るものによるとは思いますが、たかだか数パターンの見た目しか持たないコンポーネントでアプリケーション全体を作ってもいい体験のものが作れることは少ないと思います。
特にDisclosureなんかは開閉できる機能は同じでも見た目は全然違うなんてことはザラです。
個人的にはライブラリ側に関しては、機能を持っている、スタイルは自由 みたいなヘッドレスなコンポーネントを作るくらいでちょうどいいと感じています。
アプリケーションの規模によってはスタイルを持つコンポーネントは各アプリケーション側に置くので問題ないケースも多いと思います。
とはいえボタンならアイコンが入れられて…みたいにある程度抽象的にパターン化できるものもあるので、レイアウトくらいは持っておいてもいいと思います。
スタイルに関してはCSS変数で上書きできる口を用意するやり方もありっちゃありですが、
CSS変数の管理をSvelteの<style>でやるのは大変なのであまりお勧めしません。
CSSが強くて管理できる人がいるならアリだと思います。
Snippetを渡そう
{ title: string }みたいなpropsがきたら要注意です。
改行を入れたいと言われ渋々@htmlを使うことになったり、
コンポーネントを表示したいと言われ後からSnippetに差し替えたりすることになります。
親コンポーネントの中で子コンポーネントを表示していてそのためのpropsを…みたいなのも危険信号です。
propsを渡し終えた子コンポーネントをSnippetとして渡しましょう。
builderを作ろう
上にも書いた通り、おんなじ機能だけど見た目はちょっと違う みたいなコンポーネントは想像よりもずっと多くなります。
その前提で機能だけを抽出したbuilderをつくっておくのが良いでしょう。
Svelteでbuilderを作る上でおすすめするのは以下のような形です。
export function useXXX(unique_id: string) {
let xxx_state1 = $state(1)
let xxx_state2 = $state('hoge')
return {
'XXXElement1': {
props: {}, // clickなども含む
tag: 'HTMLタグ名',
},
'XXXState': {
get xxxState1: xxx_state1;
get xxxState2: xxx_state2;
},
actions: {
action1: () => {}
}
};
}
<Element as={XXXElement1.tag} {...XXXElement1.props}>
<button type="button" onclick={() => actions.action1()}>{XXXState.xxxState1}</button>
みたいな感じで使用します。
propsに関しても引数を受け取る形で作成できます。
内部でstateを持っていてそれによって属性が変わる場合は以下のようにgetをつければリアクティブな値を返せます。
get checked() {
return is_checked_state;
}
また、要素への参照についても以下のようにState管理できます。
let element_state = $state<HTMLElement>();
…
{
[createAttachmentKey()]: (element: HTMLElement) => {
element_state = element;
}
}
tagのようにHTMLタグをわざわざ返していますが、これはマークアップに詳しくない人のための保険に加えて、
detailsのように後からHTML標準で実装できるようになった場合の差し替えコストを想定しています。
また、私はJavaScriptが無効でも同じような操作ができるように作るようにしているので、JavaScriptの有効無効でHTMLタグを出し分けていることもあり、その用途でも指定しています。
これに関しては正直そこまでやる必要があるのかどうか怪しいため、好みかなと思います。
builderを使用したコンポーネントを作ろう
上に書いたbuilderですが、多用するとscriptタグの中が長くなるので基本的には見た目単位でコンポーネントを切ることにはなります。
ただ、ロジックとマークアップは統一することができています。
Breadcrumbだと以下のようになります。
export function useBreadcrumb(unique_id: string) {
return {
breadcrumb: {
props: {
'aria-labelledby': unique_id
},
tag: 'nav' as const
},
breadcrumbLabel: {
props: {
id: unique_id,
'data-accessibility-hidden-with-labelledby': ''
}
},
breadcrumbList: {
tag: 'ol' as const
},
breadcrumbItem: {
tag: 'li' as const
},
breadcrumbLink: {
props: (current: boolean = false) => ({
'aria-current': current ? ('page' as const) : undefined
}),
tag: 'a' as const
}
};
}
<script lang="ts">
import type { Snippet } from 'svelte';
import { useBreadcrumb } from '../builders/useBreadcrumb.svelte';
type Breadcrumb = ReturnType<typeof useBreadcrumb>;
let {
label,
children
}: {
label: string;
children: Snippet<
[
Breadcrumb['breadcrumb'],
Breadcrumb['breadcrumbList'],
Breadcrumb['breadcrumbItem'],
Breadcrumb['breadcrumbLink']
]
>;
} = $props();
const uniqueId = $props.id();
const { breadcrumb, breadcrumbLabel, breadcrumbList, breadcrumbItem, breadcrumbLink } =
useBreadcrumb(uniqueId);
</script>
<h2 {...breadcrumbLabel.props}>{label}</h2>
{@render children(breadcrumb, breadcrumbList, breadcrumbItem, breadcrumbLink)}
childrenSnippetにuseXXXの返り値を返しておくと、Snippetの呼び出し時値を受け取ることができるので便利です。
最近のSvelte周りのコンポーネントライブラリはこうなっていることが多くなってきたように思います。
このように作っておくと、例えば以下のように呼び出すことができます。
<Breadcrumb label="メインコンテンツ2のパンくず">
{#snippet children(breadcrumb, breadcrumbList, breadcrumbItem, breadcrumbLink)}
<Element {...breadcrumb.props} as={breadcrumb.tag}>
<Element as={breadcrumbList.tag}>
<Element as={breadcrumbItem.tag}>
<Element {...breadcrumbLink.props()} as={breadcrumbLink.tag} href="/"
><span aria-hidden="true">🏠</span><span data-accessibility-visually-hidden=""
>TOP</span
></Element
>
</Element>
<Element as={breadcrumbItem.tag}>
<Element {...breadcrumbLink.props(true)} as={breadcrumbLink.tag} href="/manyMain"
>ManyMain</Element
>
</Element>
</Element>
</Element>
{/snippet}
</Breadcrumb>
この例ではネストが深くわかりにくくなってしまってはいますが、パンくずの場合はそんなにデザインのパターンがない場合がほとんどなので気にならないことが多いでしょう。
DialogなどTriggerとContent、Actionのようにパーツを分割できる場合は編集したい範囲でコンポーネント化してContext経由で値を受け取り、childrenSnippetに渡すように作ることもできます。
加えて、例えばタブのaria-orientation='horizontal'のように見た目への反映が必須なものは最低限のスタイルを書いています。
<style>
:global(:where([data-tabs-list])) {
display: var(--tabs--tab-list-display);
list-style: '';
}
:global(:where([data-tabs-list][aria-orientation='horizontal'])) {
flex-direction: row;
}
:global(:where([data-tabs-list][aria-orientation='vertical'])) {
flex-direction: column;
}
</style>
その他少し注意していること
- コンポーネントのpropsはできるだけ少なくしている
特に特定の組み合わせの場合こっちのpropsには指定が不要 みたいなのがある場合は型も分けています - useXXXなどから返すキーの名前にはXXXをなるべく含める
- スタイリングにclass属性を使用せず、data属性に対してスタイリングする
- なるべくHTML標準で書き、JavaScriptは体験の向上のために使用する
- モノレポで作る
まとめ
- builderを作ろう
- HTML標準で書き、HTMLタグを返そう
- Snippetをうまく使おう
でした!