パンくずをURLから作るのをやめた話
最近、SvelteKitでwebアプリを作っていて、
パンくずをどう実装するか考えていた。
よくあるのはこれ。
pathname.split('/').filter(Boolean)
とりあえず動くのだが、なんかなぁ〜〜という感じ。
- URLと表示名は一致しない
-
[id]がある - ナビ定義は別にある
だったら、URLを分解するんじゃなくて
ナビ定義を正にすればよくない?
ってなりました。
この記事は
https://zenn.dev/shamokit/articles/71d40ad83d4a46
を参考にしつつ、自分なりに整理したメモです。
想定構造
/app/
├── hoge/
│ ├── fuga/
│ └── piyo/
└── settings/
表示名はURLと一致しない。
/app/hoge/ → Hoge一覧
/app/hoge/fuga/ → Hoge一覧 / Fuga詳細
/app/hoge/fuga/123 → Hoge一覧 / Fuga詳細 / ○○
方針
やったことは3つだけで、
- Breadcrumbは表示専用にする
- 静的部分はnav定義から解決する
- 動的部分はページ側で足す
です。
① 表示は表示だけにする
<script lang="ts">
type Props = {
items?: { label: string; href: string }[]
}
let { items = [] }: Props = $props()
</script>
<nav aria-label="Breadcrumb">
<ol class="flex items-center gap-1">
{#each items as item, i (`${i}-${item.href}`)}
<li class="flex items-center">
{#if i !== items.length - 1}
<a href={item.href}>{item.label}</a>
<span aria-hidden="true" class="mx-1">/</span>
{:else}
<span aria-current="page">{item.label}</span>
{/if}
</li>
{/each}
</ol>
</nav>
Breadcrumbは配列を受け取るだけ。
ロジックは持たせない。
あとで直すのは面倒なので、最低限のARIAは入れておく。
② 静的部分はnav定義から辿る
ナビ定義はこんな感じ。
export const navData = [
{
title: 'Hoge一覧',
href: '/app/hoge/',
exactPath: '/app/hoge/',
children: [
{
title: 'Fuga詳細',
href: '/app/hoge/fuga/',
exactPath: '/app/hoge/fuga/'
}
]
}
]
そして、 pathname から解決する。
export function resolveBreadcrumbs(pathname: string) {
const items: { label: string; href: string }[] = []
for (const item of navData) {
if (item.exactPath && pathname.startsWith(item.exactPath)) {
items.push({ label: item.title, href: item.href })
if (item.children) {
for (const child of item.children) {
if (child.exactPath && pathname.startsWith(child.exactPath)) {
items.push({ label: child.title, href: child.href })
}
}
}
}
}
return items
}
URLを分解しないで、nav定義を正として辿る。
これでメニューとパンくずがズレない。
※ 今回は簡略化のため2階層までの例にしています。
ネストが深くなる場合は、再帰的に解決する形にした方がよさそうです。
③ 動的ルートはページで足す
/app/hoge/fuga/[id] みたいなページ。
<Breadcrumb items={[...resolveBreadcrumbs(page.url.pathname), { label: data.fuga.name, href: page.url.pathname }]} />
静的は共通ロジック、動的はページで補完。
責務が分かれていて、良さげ。
補足
navDataと resolveBreadcrumbs は同じファイルに置いていて、
各ページから直接インポートして使ってます。
<!-- +page.svelte -->
<script lang="ts">
import { page } from '$app/state'
import Breadcrumb from '$lib/Breadcrumb.svelte'
import { resolveBreadcrumbs } from '$lib/navigation'
let items = $derived.by(() => {
return resolveBreadcrumbs(page.url.pathname)
})
</script>
<Breadcrumb {items} />
$derived.by でリアクティブにしておけば、ページ遷移時に自動で更新される。
サイドナビも同じnavDataを参照しているので、
メニューの構造とパンくずの表示が常に一致する。
感想
やってみて思ったこと。
- URLベースよりnavベースの方が素直
- 表示とロジックを分けると迷わない
- 動的ルートも扱いやすい
そして、SvelteKitは楽。
$state や $derived が標準で使えるのもありがたいし、
parent() で親データを使い回せるのも便利。
派手な実装ではないけど、
こういう整理があとで効く。
SvelteKitはいいぞぉ。
Discussion