🎃

パンくずを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つだけで、

  1. Breadcrumbは表示専用にする
  2. 静的部分はnav定義から解決する
  3. 動的部分はページ側で足す

です。

① 表示は表示だけにする

<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