DELOGs
[管理画面フォーマット制作編 #4] サイドバーのメニューと参照中ページの同期

管理画面フォーマット制作編 #4
サイドバーのメニューと参照中ページの同期

Next.js App Router + shadcn/ui のサイドバーで「いま見ているページ」を正しくハイライト

初回公開日

最終更新日

0. はじめに

前回までの記事で管理画面レイアウト・ユーザ管理を仕上げました。
まだ、メニューと現在参照中のページを同期ができていません。 本記事では、メニュー定義を1か所に集約し、現在のURLから“最長一致”で1つだけ現在地を決めるというシンプルな仕組みで、この悩みを解決します。ページ本体はSSRのまま据え置き、サイドバー側でだけ判定します。

何を解決するか(要点を一望)

典型的なつまずき解き方のコア画面での結果
ハイライトがズレるusePathname() のURLとメニュー定義を照合し、最長一致で1件だけ選ぶ“そのページを代表するリンク”にだけ aria-current="page" が付く
動的ルートが扱いにくい子に該当が無ければ親(例:/users)にフォールバック/users/U00000001 では「ユーザ管理」が強調される
親メニューの開閉がバラつく現在地の祖先を自動展開迷子になりにくいサイドバーになる
具体的な仕様は下記の通りです:
txt
1【ブラウザの現在URL】 2 └─ usePathname() で取得 → 例: "/users/U00000001" 345【メニュー定義(単一出所のツリー)MENU: MenuNode[]】 6 ┌──────────────┐ 7 │ 設定 (/settings) │ 8 │ └─ ユーザ管理 (/users) ──┐ 9 │ ├─ 一覧 (/users) │ (match: "exact") 10 │ └─ 新規 (/users/new) │ (match: "exact") 11 └──────────────┘ │ 12 │ │(※ 動的URL /users/[id] は 13 │ │ 子に列挙せず“親 /users”が受ける) 1415【現在地判定フック useActive(MENU)】 16 1) ツリーをフラット化(親子関係も保持) 17 2) ノードごとに pathname と照合して “スコア” 計算 18 - match: "exact" → 完全一致なら 1000 + href長 19 - match: "prefix" → 前方一致なら href長 20 - match: "regex" → 正規表現の一致長(必要時のみ) 21 3) 最大スコアのノード = active 22 4) active の祖先ノードもたどって配列に 232425【NavMain 描画】 26 - active のリンクに aria-current="page" 27 - active または祖先ノードのグループを展開(open) 28 - data-active="true" などで太字/ハイライト 293031【結果の見え方(例)】 32 URL: /users/U00000001 33 → 子に exact が無いので親 "/users" が最長一致で勝ち 34 → サイドバーでは「ユーザ管理」が太字(aria-current="page") 35 → 「設定」グループも自動で開く
下図のようにメニューと表示中のページが一致する状態になることを目指します。
メニューと同期のとれたユーザ管理画面

前提(ここだけ目を通せばOK)

本記事は、下記の続きとなります。
すでに Next.js App Router + shadcn/ui のレイアウトユーザ管理ページ群/users/users/new/users/[displayId])がある前提です。参考として、ログイン後レイアウトとユーザ管理UIの記事が整っていれば十分です。なお、ページ本体(page.tsx)は変更しません。サイドバー側の最小追加で同期させます。

この先で触るファイル(最小セット)

役割ファイル名何を入れるか
単一出所のメニューmenu.schema.tsタイトル・URL・match"exact" / "prefix" / RegExp)・子ノード
現在地の判定use-active.tsusePathname() → スコア方式で最長一致 → 現在地と祖先を返す
リンクの薄いラッパnav-link.tsxaria-current="page" とクラス切替を標準化
既存サイドバーapp-sidebar.tsx<nav aria-label="メインメニュー"> など a11y 属性を付与して組み込む

a11y(アクセシビリティ)はここだけ押さえる

ルール付ける場所何が伝わるか
<nav aria-label="メインメニュー">サイドバーの外枠ランドマークとして「ここがメインメニュー」と分かる
aria-current="page"“今いるページ”のリンク1つだけスクリーンリーダーで「現在のページ」と読まれる
aria-expanded / aria-controls親メニューの開閉ボタン/サブメニュー開いている/閉じている状態が伝わる

技術スタック

Tool / LibVersionPurpose
React19.xUIの土台。コンポーネント/フックで状態と表示を組み立てる
Next.js15.xフルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理
TypeScript5.x型安全・補完・リファクタリング
shadcn/uilatestRadixベースのUIキット
Tailwind CSS4.xユーティリティファーストCSSで素早くスタイリング

1. メニュー定義を“単一出所”に(menu.schema.ts

「どのリンクを太字にするか」を1か所の定義から決められるようにします。
ここで作るのは――
  • 型(MenuNode:タイトル・URL・match 方式("exact" | "prefix" | "regex")・子ノード(ツリー)
  • 実データ(MENU:ダッシュボード、設定 > ユーザ管理(一覧・新規)の最小セット
“単一出所”にすると、ページ側(/users, /users/new…)は無改造のまま、サイドバーだけで同期が完結します。
src/lib/sidebar/menu.schema.tsを下記の内容で作成します:
tsx
1/** 2 * src/lib/sidebar/menu.schema.ts 3 * 「型」と「メニュー定義」を1ファイルに集約(単一出所) 4 */ 5import type { LucideIcon } from "lucide-react"; 6import { SquareTerminal, BookOpen, Settings2 } from "lucide-react"; 7 8/** どのルールでURL一致を判定するか */ 9export type MatchMode = "exact" | "prefix" | "regex"; 10 11/** 1つのメニュー要素(ツリーの節) */ 12export type MenuNode = { 13 /** 安定したキー(href が変わっても差分検知しやすくするなら独立ID推奨) */ 14 id: string; 15 /** ラベル(サイドバーに表示) */ 16 title: string; 17 /** 対応するURL(絶対パス。トレーリングスラッシュは付けない) */ 18 href: string; 19 /** アイコン(任意) */ 20 icon?: LucideIcon; 21 /** 22 * URLの一致方法: 23 * - "exact": 完全一致(例 /users と /users だけ一致) 24 * - "prefix": 前方一致(例 /users 配下すべて) 25 * - "regex": 正規表現で高度な一致(必要時のみ) 26 * 27 * 指定がなければ "prefix" を既定とするのが実務的です。 28 */ 29 match?: MatchMode; 30 /** match === "regex" のときに使うパターン(未使用なら省略) */ 31 pattern?: RegExp; 32 /** 子ノード(グループやサブメニュー) */ 33 children?: MenuNode[]; 34}; 35 36/** ツリー全体 */ 37export type MenuTree = MenuNode[]; 38 39/** 40 * 最小のメニュー定義 41 * - ダッシュボード(/dashboard) 42 * - 設定(/settings)配下に「ユーザ管理(/users)」グループ 43 * - 一覧(/users)… exact 44 * - 新規(/users/new)… exact 45 * 46 * 動的URL(/users/[displayId])は子に列挙しません。 47 * 親(/users)が "prefix" で受け止めます。 48 */ 49export const MENU: MenuTree = [ 50 { 51 id: "dashboard", 52 title: "ダッシュボード", 53 href: "", 54 icon: SquareTerminal, 55 match: "prefix", // /dashboard 配下すべてを受け持つ 56 children: [ 57 { 58 id: "dashboard-overview", 59 title: "全体進捗", 60 href: "/dashboard", 61 match: "exact", 62 }, 63 { 64 id: "dashboard-my-project", 65 title: "Myプロジェクト", 66 href: "#", 67 match: "prefix", 68 }, 69 { 70 id: "dashboard-my-task", 71 title: "Myタスク", 72 href: "#", 73 match: "prefix", 74 }, 75 ], 76 }, 77 { 78 id: "docs", 79 title: "ドキュメント", 80 href: "", 81 icon: BookOpen, 82 match: "prefix", 83 children: [ 84 { 85 id: "docs-tutorial", 86 title: "チュートリアル", 87 href: "#", 88 match: "prefix", 89 }, 90 { 91 id: "docs-changelog", 92 title: "更新履歴", 93 href: "#", 94 match: "prefix", 95 }, 96 ], 97 }, 98 { 99 id: "settings", 100 title: "設定", 101 href: "", 102 icon: Settings2, 103 match: "prefix", 104 children: [ 105 { 106 id: "settings-projects", 107 title: "プロジェクト管理", 108 href: "#", 109 match: "prefix", 110 }, 111 { 112 id: "settings-masters", 113 title: "マスタ管理", 114 href: "#", 115 match: "prefix", 116 }, 117 { 118 id: "settings-users", 119 title: "ユーザ管理", 120 href: "/users", 121 match: "prefix", // /users 配下を親で受ける(動的URL対応) 122 children: [ 123 { id: "users-list", title: "一覧", href: "/users", match: "exact" }, 124 { 125 id: "users-new", 126 title: "新規登録", 127 href: "/users/new", 128 match: "exact", 129 }, 130 // /users/[displayId] は列挙しない → 親 "/users" が最長一致で勝つ 131 ], 132 }, 133 ], 134 }, 135];

どうして“単一出所”が効くのか

サイドバーの強調表示(ハイライト)は、URLとメニュー定義の一致判定だけで決められます。
この章で用意した MENU を“真実の1か所”にしておけば、今後メニューを増減してもサイドバーだけで同期が保てます。ページファイルを触らずに済むので、運用が格段に楽になります。

プロパティ一覧(迷ったらこの表)

項目必須意味・コツ
id✔︎"users-new"安定キー。href変更の影響を避けたいなら独立ID
title✔︎"新規"表示ラベル
href✔︎"/users/new"絶対パス、末尾 / は付けない(/users/ ←非推奨)
iconSquareTerminallucide-react のアイコンを指定可
match"exact"未指定は "prefix" を既定にすると運用が簡単
pattern/^\/topics\/\d{4}-\d{2}$/match: "regex" のときだけ使用(なるべく後回しに)
children[...]グループ配下のサブメニュー

どの match を選べばいい?

目的向いている match補足
そのURLだけをピンポイントで強調exact/users/users/new子の葉に使うと分かりやすい
配下を丸ごと親で受けるprefix/users/users/** を受ける動的ルートの受け皿にも最適
複雑パターンが必要regex/^\/topics\/\d{4}-\d{2}$/まずは不要。要件が出たら追加でOK

つまずきポイント(対処もセットで)

つまずき症状確認/対処
末尾スラッシュの揺れ/users/users/ で判定が不安定ルール:末尾 / は付けない。定義とURLを統一
#? を考慮しすぎるハイライトが点いたり消えたりパスだけを見る(usePathname() で十分)
子に動的URLを並べようとするメニュー定義が膨らむ/維持が大変子に列挙しない。"prefix" + “最長一致” に任せる
regex を先に使いたくなる可読性・パフォーマンスが落ちがちまずは exact/prefix で9割カバー可能

ここまでの“できたこと”

  • メニューの定義を1か所に集約(単一出所)
  • 動的ルートは親 "prefix" で受ける前提を明確化
  • 以降の章で、各メニューと現在URLを突き合わせて “最長一致で1件だけ” を選ぶ土台が整いました
次章では、この MENU を使って 現在地を決定するフック(use-active.ts を用意します。

2. 現在地の判定フック(use-active.ts

この章では、現在のURL(usePathname())と“単一出所のメニューMENU”を突き合わせて、ハイライト対象を1つに決めるフックを作ります。返り値は「現在地ノード」「祖先ノード集合」と、UIで使いやすい小さな関数群(isActive / isAncestor / ariaCurrentFor / openByDefault)です。
まず“入出力”と“判定ルール”を表で押さえてください。

フックの入出力(使う側のイメージ)

項目役割
useActive(menu)(menu: MenuTree) => ActiveStateメニューを渡すと“現在地と祖先”を返す
ActiveState.activeMenuNode | null現在地ノード(なければ null)
ActiveState.ancestorsSet<string>祖先ノードの id 集合(親→祖父…)
isActive(id)(id: string) => booleanその id が現在地か
isAncestor(id)(id: string) => booleanその id が現在地の祖先か
ariaCurrentFor(id)(id: string) => "page" | undefined“そのリンクに aria-current="page" を付けるべきか”
openByDefault(id)(id: string) => booleanグループ(親メニュー)をデフォルト展開するか(現在地 or 祖先なら true

判定ルール(“最長一致”を点数化)

match一致条件スコアの付け方
"exact"pathname === href1000 + href.length(最優先)/users/new/users/new
"prefix"pathname === href または pathnamehref + "/" から始まるhref.length(長いほど強い)/users/abc に対して /users
"regex"pattern.test(pathname)一致テキスト長(必要時のみ)^/topics/\d{4}-\d{2}$
“exact が勝つ。そうでなければ、より長く一致した方が勝つ。”という素朴な決め方です。
regex はまず不要です。exact / prefix だけで9割以上カバーできます。

フックの作成(use-active.ts

src/lib/sidebar/use-active.tsを下記の内容にて作成します:
ts
1// src/lib/sidebar/use-active.ts 2"use client"; 3 4import { useMemo, useCallback } from "react"; 5import { usePathname } from "next/navigation"; 6import type { MenuTree, MenuNode } from "./menu.schema"; 7 8/* ========== 公開インターフェース ========== */ 9 10export type ActiveState = { 11 active: MenuNode | null; 12 ancestors: Set<string>; 13 isActive: (id: string) => boolean; 14 isAncestor: (id: string) => boolean; 15 ariaCurrentFor: (id: string) => "page" | undefined; 16 openByDefault: (id: string) => boolean; 17}; 18 19type FlatNode = { 20 id: string; 21 href: string; 22 match: "exact" | "prefix" | RegExp; // "regex" は RegExp に正規化して持つ 23 parentId: string | null; 24 depth: number; 25}; 26 27// "regex" 指定を実際の RegExp に正規化 28function toFlatMatch(n: MenuNode): FlatNode["match"] { 29 if (!n.match || n.match === "exact" || n.match === "prefix") { 30 return n.match ?? "exact"; 31 } 32 // n.match === "regex" の場合 33 // パターン未指定なら絶対にマッチしないダミーを返す(. ^ はどれにも一致しない) 34 return n.pattern ?? /.^/; 35} 36 37function flatten( 38 tree: MenuTree, 39 parentId: string | null, 40 depth: number, 41 out: FlatNode[], 42) { 43 for (const n of tree) { 44 out.push({ 45 id: n.id, 46 href: n.href, 47 match: toFlatMatch(n), // ← ここで型を合わせる 48 parentId, 49 depth, 50 }); 51 if (n.children?.length) flatten(n.children, n.id, depth + 1, out); 52 } 53} 54 55export function useActive(menu: MenuTree) { 56 const pathname = usePathname(); 57 58 // 1) フラット化 59 const flat = useMemo(() => { 60 const buf: FlatNode[] = []; 61 flatten(menu, null, 0, buf); 62 return buf; 63 }, [menu]); 64 65 // ★ 子参照に使う簡易インデックス(id -> node) 66 const nodeById = useMemo(() => { 67 const map = new Map<string, MenuNode>(); 68 const walk = (list: MenuNode[]) => { 69 for (const n of list) { 70 map.set(n.id, n); 71 if (n.children?.length) walk(n.children); 72 } 73 }; 74 walk(menu); 75 return map; 76 }, [menu]); 77 78 // 2) スコア計算 79 const scoreFor = useCallback( 80 /* 既存のまま */ (n: FlatNode): number => { 81 const href = n.href.replace(/\/+$/, ""); 82 const current = pathname.replace(/\/+$/, ""); 83 if (n.match === "exact") { 84 return current === href ? 1_000_000 + href.length : 0; 85 } 86 if (n.match === "prefix") { 87 const pref = href === "" ? "/" : href + "/"; 88 return current.startsWith(pref) ? href.length : 0; 89 } 90 if (n.match instanceof RegExp) { 91 const m = current.match(n.match); 92 return m ? 500_000 + (m[0]?.length ?? 0) : 0; 93 } 94 return 0; 95 }, 96 [pathname], 97 ); 98 99 // 3) ベストを選ぶ 100 const active = useMemo(() => { 101 const scored = flat 102 .map((n) => ({ ...n, score: scoreFor(n) })) 103 .filter((n) => n.score > 0) 104 .sort( 105 (a, b) => 106 b.score - a.score || 107 b.depth - a.depth || 108 b.href.length - a.href.length, 109 ); 110 return scored[0] ?? null; 111 }, [flat, scoreFor]); 112 113 // 3.5) ★ 代表リンク(/users の exact 子)に振り替えるための pageId を確定 114 const pageId = useMemo(() => { 115 if (!active) return undefined; 116 const node = nodeById.get(active.id); 117 if (!node) return active.id; 118 119 // 親が prefix で、自分と同じ href を持つ exact の子がいれば、それを代表にする 120 const exactChild = node.children?.find( 121 (c) => 122 (c.match ?? "exact") === "exact" && 123 c.href.replace(/\/+$/, "") === node.href.replace(/\/+$/, ""), 124 ); 125 return exactChild?.id ?? active.id; 126 }, [active, nodeById]); 127 128 // 祖先判定 129 const parentById = useMemo(() => { 130 const map = new Map<string, string | null>(); 131 for (const n of flat) map.set(n.id, n.parentId); 132 return map; 133 }, [flat]); 134 135 const isActive = useCallback((id: string) => active?.id === id, [active]); 136 const isAncestor = useCallback( 137 (id: string) => { 138 let p = active?.parentId ?? null; 139 while (p) { 140 if (p === id) return true; 141 p = parentById.get(p) ?? null; 142 } 143 return false; 144 }, 145 [active, parentById], 146 ); 147 148 // ★ aria-current は代表リンク(pageId)に付与 149 const ariaCurrentFor = useCallback<(id: string) => "page" | undefined>( 150 (id) => (pageId && pageId === id ? "page" : undefined), 151 [pageId], 152 ); 153 154 const openByDefault = useCallback( 155 (id: string) => isAncestor(id) || isActive(id), 156 [isAncestor, isActive], 157 ); 158 159 return { isActive, isAncestor, ariaCurrentFor, openByDefault }; 160}

使い方の“型合わせ”とコツ

  • NavMain 側ではuseActive(MENU) を呼び、
    aria-current とハイライト用クラス(例:data-active="true")を “たった1つのリンク” にだけ付けます。
    親のグループ(Collapsible)には openByDefault(id) を使うと、現在地の祖先は自動で展開されます。
  • prefix の“境界”が大事/users/users/new は前方一致ですが、/user(単数)とは一致しません。
    下表のように href + "/" から始まるか?」 を見ているため、誤爆を防げます。
hrefpathname一致?理由
/users/users完全一致
/users/users/newhref + "/" から始まる
/users/user×セグメント境界が違う
//anything◯(弱)ルートは最弱スコア(常に負けやすい)
  • “ tie のときは長い方が勝つ ” ので、/users/new/users より強くなります。
    exact はさらに +1000 のボーナスがあるため、同じ長さなら必ず exact が勝ちます。

ありがちエラーと直し方(早見表)

症状主因直し方
ハイライトが2か所に付くUI側で isActive 条件が重複代表リンク1つにだけ aria-current="page" を付ける”ルールを徹底
/users/(末尾 /)で反応が違う末尾スラッシュの揺れnormalizePath() を必ず通す(定義側も末尾 / を付けない)
/user でも「ユーザ管理」が光る素朴な startsWith 判定セグメント境界を見て href + "/" で判定する実装に修正
regex が効かないpattern 未指定match: "regex" のときは pattern を必ず与える(不要なら regex は使わない)

ここまでの到達点

  • MENU(単一出所)と現在URLから、“最長一致でただ1つ” の現在地を計算できるようになりました。
  • UI は isActive / isAncestor / ariaCurrentFor / openByDefault を使うだけ。
  • 次章では、このフックを NavMain に組み込み、aria-current="page" と親の自動展開を反映させます。

3. リンクの軽量ラッパー(nav-link.tsx

<Link> をそのまま使うと、ページごとに「aria-current の付け方」「太字クラスの当て方」「target="_blank" の安全対策」などを毎回書くことになり、ムラが出がちです。
ここでは “軽量ラッパー” を1つ用意し、判定は前章の useActive()、表現は本ラッパーという役割分担にします。

役割の切り分け

レイヤー何をする何をしない
useActive()現在地/祖先の判定、ariaCurrentFor(id) の提供見た目のクラスは決めない
NavLink(本章)aria-current="page" の受け取り→リンクへ付与。data-active="true" を出力。外部リンクなら rel を自動補完どのリンクが現在地かを決めない
NavMainuseActive() から active/ariaCurrent を取得して NavLink に渡す。SidebarMenuButton のクラスに data-[active=true]:… を書くURL判定ロジックは持たない

軽量ラッパーの作成 (nav-link.tsx)

src/components/sidebar/nav-link.tsxを下記内容にて作成します:
tsx
1/* src/components/sidebar/nav-link.tsx 2 - Next.js の <Link> を“軽量に”包む 3 - 受け取った active / ariaCurrent を素直に反映 4 - 外部リンクや target="_blank" の安全対策もここで吸収 5*/ 6"use client"; 7 8import * as React from "react"; 9import Link, { type LinkProps } from "next/link"; 10 11type AriaCurrent = "page" | undefined; 12 13/** 使う側(NavMain)からは “結果” を渡してもらうだけ */ 14export type NavLinkProps = LinkProps & 15 Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & { 16 /** そのリンクが現在地なら true(useActive() 由来) */ 17 active?: boolean; 18 /** そのリンクに付与すべき aria-current(通常は "page" か undefined) */ 19 ariaCurrent?: AriaCurrent; 20 }; 21 22// かんたんなクラス結合 23function cx(...v: Array<string | undefined>) { 24 return v.filter(Boolean).join(" "); 25} 26 27/** “軽量ラッパー”本体:見た目ロジックは持たず、渡された結果を素直に出す */ 28export const NavLink = React.forwardRef<HTMLAnchorElement, NavLinkProps>( 29 function NavLink( 30 { active, ariaCurrent, className, href, target, rel, ...rest }, 31 ref, 32 ) { 33 // target="_blank" の安全対策(親から rel が来ていなければ付与) 34 const safeRel = 35 target === "_blank" ? (rel ? rel : "noopener noreferrer") : rel; 36 37 return ( 38 <Link 39 ref={ref} 40 href={href} 41 aria-current={ariaCurrent} 42 data-active={active ? "true" : undefined} 43 target={target} 44 rel={safeRel} 45 className={cx( 46 // 基本形(SidebarMenuButton/SubButton の asChild でも単体でも崩れない) 47 "flex w-full items-center gap-2 rounded-md px-2 py-1.5 transition-colors", 48 // data-active でも aria-current でも強調が効くように両方用意 49 "data-[active=true]:bg-muted data-[active=true]:font-semibold", 50 "aria-[current=page]:bg-muted aria-[current=page]:font-semibold", 51 className, // ← 親(asChild)から渡ってくるクラスを必ず後ろでマージ 52 )} 53 {...rest} 54 /> 55 ); 56 }, 57); 58 59export default NavLink;

ここがポイント

  • 見た目の規約を1か所に統一
    太字・ハイライトは data-[active=true]aria-[current=page] の両対応にしてあるので、親側の書き方が多少違っても強調が揃います。
  • 安全な外部リンク
    target="_blank" の場合は rel="noopener noreferrer" を自動補完。記事外でも安心して使い回せます。
次章では、この NavLink を使って nav-main.tsx を実装し、
useActive() の結果 → NavLink の属性 → サイドバーの開閉/強調 という流れを完成させます。

4. NavMain を実装(src/components/sidebar/nav-main.tsx

ここで前回までの記事で作成したsrc/components/sidebar/nav-main.tsxを入れ替えます。やることは次の3つだけです。
  • MENU(単一出所)を前提に、MenuTree 型の items を受け取る
  • useActive(items) の結果から、aria-current="page" と祖先の自動展開を反映
  • クリック時の操作性:折り畳み状態で親をクリックしたら展開だけ(すぐ遷移しない)
既存の UI パーツ(Collapsible / SidebarMenuButton など)はそのまま活かします。
また、**サブ階層の“親”**にも data-active を付けられるようにし、Chevron の回転aria-expanded を参照するように揃えています。

NavMainの修正

tsx
1/* 修正版 “全量”:src/components/sidebar/nav-main.tsx */ 2"use client"; 3 4import { ChevronRight } from "lucide-react"; 5import { 6 Collapsible, 7 CollapsibleContent, 8 CollapsibleTrigger, 9} from "@/components/ui/collapsible"; 10import { 11 SidebarGroup, 12 SidebarGroupLabel, 13 SidebarMenu, 14 SidebarMenuButton, 15 SidebarMenuItem, 16 SidebarMenuSub, 17 SidebarMenuSubButton, 18 SidebarMenuSubItem, 19 useSidebar, 20} from "@/components/ui/sidebar"; 21 22import { NavLink } from "@/components/sidebar/nav-link"; 23import { useActive } from "@/lib/sidebar/use-active"; 24import type { MenuTree, MenuNode } from "@/lib/sidebar/menu.schema"; 25 26type Props = { items: MenuTree }; 27 28export function NavMain({ items }: Props) { 29 const { isActive, isAncestor, ariaCurrentFor, openByDefault } = 30 useActive(items); 31 const { state, isMobile, setOpen } = useSidebar(); 32 33 // サイドバーが“collapsed(アイコンのみ)”のときは、親クリックでまずサイドバーを展開 34 const handleTopParentClick = (e: React.MouseEvent) => { 35 if (state === "collapsed" && !isMobile) { 36 setOpen(true); 37 e.preventDefault(); 38 } 39 }; 40 41 // ───────────────────────────────────────────── 42 // レベル0(トップ階層)を描画 43 // ───────────────────────────────────────────── 44 return ( 45 <SidebarGroup> 46 <SidebarGroupLabel>メニュー</SidebarGroupLabel> 47 <SidebarMenu> 48 {items.map((node) => ( 49 <TopNode 50 key={node.id} 51 node={node} 52 isActive={isActive} 53 isAncestor={isAncestor} 54 ariaCurrentFor={ariaCurrentFor} 55 openByDefault={openByDefault} 56 onTopParentClick={handleTopParentClick} 57 /> 58 ))} 59 </SidebarMenu> 60 </SidebarGroup> 61 ); 62} 63 64/* ========== トップ階層用:親は SidebarMenuItem、子は SidebarMenuSub で受ける ========== */ 65function TopNode({ 66 node, 67 isActive, 68 isAncestor, 69 ariaCurrentFor, 70 openByDefault, 71 onTopParentClick, 72}: { 73 node: MenuNode; 74 isActive: (id: string) => boolean; 75 isAncestor: (id: string) => boolean; 76 ariaCurrentFor: (id: string) => "page" | undefined; 77 openByDefault: (id: string) => boolean; 78 onTopParentClick: (e: React.MouseEvent) => void; 79}) { 80 const hasChildren = !!node.children?.length; 81 82 // 子なし(トップの葉) → そのままリンク 83 if (!hasChildren) { 84 return ( 85 <SidebarMenuItem> 86 <SidebarMenuButton 87 asChild 88 tooltip={node.title} 89 className="data-[active=true]:bg-muted data-[active=true]:font-semibold" 90 > 91 <NavLink 92 href={node.href} 93 active={isActive(node.id)} 94 ariaCurrent={ariaCurrentFor(node.id)} 95 > 96 {node.icon && <node.icon className="size-4" />} 97 <span>{node.title}</span> 98 </NavLink> 99 </SidebarMenuButton> 100 </SidebarMenuItem> 101 ); 102 } 103 104 // 子あり(トップの親) → Collapsible。親は“開閉のみ” 105 return ( 106 <Collapsible 107 asChild 108 defaultOpen={openByDefault(node.id)} 109 className="group/collapsible" 110 > 111 <SidebarMenuItem> 112 <CollapsibleTrigger asChild> 113 <SidebarMenuButton 114 tooltip={node.title} 115 onClick={onTopParentClick} 116 data-active={ 117 isActive(node.id) || isAncestor(node.id) ? "true" : undefined 118 } 119 className="data-[active=true]:bg-muted data-[active=true]:font-semibold" 120 > 121 {node.icon && <node.icon className="size-4" />} 122 <span>{node.title}</span> 123 <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" /> 124 </SidebarMenuButton> 125 </CollapsibleTrigger> 126 127 <CollapsibleContent> 128 <SidebarMenuSub> 129 {node.children?.map((child) => ( 130 <SubNode 131 key={child.id} 132 node={child} 133 isActive={isActive} 134 isAncestor={isAncestor} 135 ariaCurrentFor={ariaCurrentFor} 136 openByDefault={openByDefault} 137 /> 138 ))} 139 </SidebarMenuSub> 140 </CollapsibleContent> 141 </SidebarMenuItem> 142 </Collapsible> 143 ); 144} 145 146/* ========== サブ階層用:子を持つなら“さらに”Collapsible、持たなければリンク ========== */ 147function SubNode({ 148 node, 149 isActive, 150 ariaCurrentFor, 151 openByDefault, 152}: { 153 node: MenuNode; 154 isActive: (id: string) => boolean; 155 isAncestor: (id: string) => boolean; 156 ariaCurrentFor: (id: string) => "page" | undefined; 157 openByDefault: (id: string) => boolean; 158}) { 159 const hasChildren = !!node.children?.length; 160 161 // 子なし(葉) → リンク 162 if (!hasChildren) { 163 return ( 164 <SidebarMenuSubItem> 165 <SidebarMenuSubButton 166 asChild 167 className="data-[active=true]:bg-muted data-[active=true]:font-semibold" 168 > 169 <NavLink 170 href={node.href} 171 active={isActive(node.id)} 172 ariaCurrent={ariaCurrentFor(node.id)} 173 > 174 <span>{node.title}</span> 175 </NavLink> 176 </SidebarMenuSubButton> 177 </SidebarMenuSubItem> 178 ); 179 } 180 181 // 子あり(サブ階層の親) → サブ内 Collapsible。親は開閉のみ(リンクにしない) 182 return ( 183 <SidebarMenuSubItem> 184 <Collapsible defaultOpen={openByDefault(node.id)}> 185 <CollapsibleTrigger asChild> 186 <SidebarMenuSubButton 187 asChild 188 className="data-[active=true]:bg-muted data-[active=true]:font-semibold" 189 > 190 {/* ← 最終的なトリガー要素は <button> */} 191 <button 192 type="button" 193 data-active={openByDefault(node.id) ? "true" : undefined} 194 className="group flex w-full items-center justify-between" 195 > 196 <span>{node.title}</span> 197 {/* ← トリガーの aria-expanded を見て回転 */} 198 <ChevronRight className="ml-1 size-4 transition-transform duration-200 group-aria-[expanded=true]:rotate-90" /> 199 </button> 200 </SidebarMenuSubButton> 201 </CollapsibleTrigger> 202 203 <CollapsibleContent> 204 <SidebarMenuSub className="ml-2"> 205 {node.children?.map((gchild) => ( 206 <SidebarMenuSubItem key={gchild.id}> 207 <SidebarMenuSubButton 208 asChild 209 className="data-[active=true]:bg-muted data-[active=true]:font-semibold" 210 > 211 <NavLink 212 href={gchild.href} 213 active={isActive(gchild.id)} 214 ariaCurrent={ariaCurrentFor(gchild.id)} 215 > 216 <span>{gchild.title}</span> 217 </NavLink> 218 </SidebarMenuSubButton> 219 </SidebarMenuSubItem> 220 ))} 221 </SidebarMenuSub> 222 </CollapsibleContent> 223 </Collapsible> 224 </SidebarMenuSubItem> 225 ); 226}

置き換えのコツ(最短チェック)

箇所期待する状態確認ポイント
type Props = { items: MenuTree }NavItem 参照が残っていない
判定useActive(items) を呼ぶisActive / ariaCurrentFor / openByDefault を使用
Linkすべて <NavLink>SidebarMenu(Button/SubButton)data-[active=true]:… を記述
折り畳み時の挙動親クリックでまず展開handleTopParentClicke.preventDefault() が効いている
サブ親の強調サブ親にも data-active「ユーザ管理」など開閉専用の親も太字に

動作確認の目安

  • /users → 「設定」「ユーザ管理」「一覧」が順に強調。aria-current="page" は「一覧」のみ
  • /users/new → 「設定」「ユーザ管理」「新規登録」
  • /users/U00000001 → 子に exact が無いので「ユーザ管理」が強調(最長一致)。さらに「一覧」を代表リンクとして aria-current="page" を付与したい場合は、useActive の拡張(表示側ルール)で「詳細は一覧の代表扱い」を選ぶ設計も可能
  • サイドバーをアイコンのみ(collapsed)にして親をクリック → まず展開、その後子を選べる
ここまでで、「判定は1か所(useActive)・表現は統一(NavLink)・親は開閉のみ」が揃いました。次章で app-sidebar.tsxMENU を渡して締めます。

5. Sidebar 組み込み(app-sidebar.tsx

最後に、サイドバーへ MENU を“正式採用” します。ポイントは2つだけです。
  1. NavMainitems={MENU} を渡す(mockNavMain は廃止)
  2. a11y:<nav aria-label="メインメニュー"> を付ける

app-sidebar.tsxの修正

下記のように src/components/sidebar/app-sidebar.tsx を修正します。
tsx
1// src/components/sidebar/app-sidebar.tsx 2"use client"; 3 4import * as React from "react"; 5 6import { ModeToggle } from "@/components/sidebar/mode-toggle"; 7import { NavMain } from "@/components/sidebar/nav-main"; 8import { NavUser } from "@/components/sidebar/nav-user"; 9import { NavTeam } from "@/components/sidebar/nav-team"; 10 11import { 12 Sidebar, 13 SidebarContent, 14 SidebarFooter, 15 SidebarHeader, 16 SidebarRail, 17} from "@/components/ui/sidebar"; 18 19import { mockTeam } from "@/lib/sidebar/mock-team"; 20import { mockUser } from "@/lib/sidebar/mock-user"; 21// ★ 単一出所に統一:ここからメニューを取る 22import { MENU } from "@/lib/sidebar/menu.schema"; 23 24export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { 25 return ( 26 <Sidebar collapsible="icon" {...props}> 27 <SidebarHeader> 28 <NavTeam team={mockTeam} /> 29 </SidebarHeader> 30 31 <SidebarContent> 32 {/* a11y ランドマーク:メインメニュー */} 33 <nav aria-label="メインメニュー"> 34 <NavMain items={MENU} /> 35 </nav> 36 </SidebarContent> 37 38 <SidebarFooter> 39 <ModeToggle className="ml-auto" /> 40 <NavUser user={mockUser} /> 41 </SidebarFooter> 42 43 <SidebarRail /> 44 </Sidebar> 45 ); 46}

置き換え後に見るべきところ

  • ページ本体はSSRのままusePathname() はサイドバー内=クライアントでのみ使用)
  • ランドマーク <nav> によってスクリーンリーダーで「ここがメインメニュー」と把握できる
  • 以後、新規タブやパンくず自動生成なども**MENU から派生**させやすい
既存の NavTeam / NavUser / ModeToggle / SidebarRail などは無改造でそのまま使えます。

動作確認

ここまで来たら、一旦動作確認が可能です。
npm run devでテスト実行して、/usersにアクセスしてみてください。下図のようにメニューとページの同期がとれた状態になったことが確認できます。
メニューと同期のとれたユーザ管理画面
アクセス先期待する見え方
/users「設定 ▸ ユーザ管理 ▸ 一覧」にハイライト(aria-current="page"
/users/new「設定 ▸ ユーザ管理 ▸ 新規」にハイライト
/users/U00000001子に exact が無いので「ユーザ管理」だけが強調(最長一致)
サイドバー collapsed親(例「設定」)をクリック → 展開 → 子を選んで遷移
補足:ライトモードでアクティブなメニューが少しわかりづらい点
これは好みになりますが、Shadcn/uiのデフォルトのCSSだと、ライトモードのときに、アクティブなメニューが判別づらいと感じました。
こうしたときはsrc/app/globals.cssroot:ブロックを調整します。--muted: oklch(0.97 0 0);がアクティブ時のCSSとして利用されています。
私はこれを--muted: oklch(0.95 0 0);にして、少し判別しやすく調整しました。
これで、ページ本体は SSR のままサイドバーだけで現在地同期が完了です。以降、メニュー追加・順序変更は menu.schema.ts のみを編集すれば、強調・展開・aria-current が自動で追随します。

6. a11y チェック

“どのページにいるか”を目と耳の両方に伝えるのが a11y。ここでは、本稿の実装で押さえるべきポイントを 最小限のコード+確認表 にまとめます。すでに実装済み(NavLink / NavMain / app-sidebar)なので、最後にセルフチェックするだけで OK です。

ランドマーク:サイドバーを <nav> で囲う

スクリーンリーダーに「ここがメインメニュー」と伝えるため、NavMain<nav aria-label="メインメニュー"> で包みます(実装済み)。
▼該当箇所:
tsx
1// src/components/sidebar/app-sidebar.tsx(抜粋) 2<SidebarContent> 3 <nav aria-label="メインメニュー"> 4 <NavMain items={MENU} /> 5 </nav> 6</SidebarContent>
補足:aria-label は “メニュー” の意味が分かる短い日本語であれば OK です。

「いまのページ」を 1 件だけ:aria-current="page"

リンクの代表 1 件だけaria-current="page" を付けます。判定は useActive()、付与は NavLink が担当(実装済み)。
▼該当箇所:
tsx
1// src/components/sidebar/nav-link.tsx(抜粋) 2<Link 3 href={href} 4 aria-current={ariaCurrent} // ← "page" か undefined をそのまま渡す 5 data-active={active ? "true" : undefined} 6/>
ポイント:aria-current を付けるのは 本当に “そのページを代表するリンク” だけ。親の開閉ボタンには付けません。

開閉 UI:ボタンは aria-expanded が付く(Radix/shadcn が自動付与)

親メニューは リンクではなくボタンCollapsibleTrigger を使うと、開閉に応じて aria-expanded が自動で更新されます(手動で付ける必要はありません)。
▼該当箇所:
tsx
1// src/components/sidebar/nav-main.tsx(抜粋) 2<Collapsible> 3 <CollapsibleTrigger asChild> 4 <SidebarMenuButton 5 // 開いているときは aria-expanded="true"(Radix が管理) 6 // “親は開閉のみ、遷移しない” のがポイント 7 > 8 <span>{node.title}</span> 9 </SidebarMenuButton> 10 </CollapsibleTrigger> 11 <CollapsibleContent></CollapsibleContent> 12</Collapsible>
補足:親に URL を持たせて遷移させると “開閉 vs 遷移” が競合して混乱しがち。親は開閉だけにしておくのが安全です。

色だけに頼らない強調(視覚的な配慮)

NavLinkaria-currentdata-active両方で強調クラスを当てています。太字(font-weight)や背景色を併用することで、色覚に依存しない差異を作れます(実装済み)。
tsx
1// src/components/sidebar/nav-link.tsx(抜粋) 2className={cx( 3 "flex w-full items-center gap-2 rounded-md px-2 py-1.5 transition-colors", 4 "data-[active=true]:bg-muted data-[active=true]:font-semibold", 5 "aria-[current=page]:bg-muted aria-[current=page]:font-semibold", 6)}
TIPS:font-semibold(太字)を併用しておくと、モノクロ環境でも “現在地” が判別しやすくなります。

キーボード操作の目安

操作期待される挙動
Tab / Shift + Tabフォーカスがメニュー内のボタン/リンク間を順に移動
Enter or Space(親)親メニューの開閉(aria-expanded が更新)
Enter(子リンク)該当ページへ遷移
Esc(ツールチップ等が出ていれば)クローズ
補足:shadcn/ui(Radix)コンポーネントは上記の基本操作をカバー済みです。独自のキーバインドを追加する必要はありません。

セルフチェック(すぐ終わるテスト)

見るところどうなっていれば OK?確認方法
ランドマーク<nav aria-label="メインメニュー"> があるDevTools Elements で確認
現在地リンクaria-current="page"1 件だけ付く/users/users/new をそれぞれ開く
親の開閉折り畳み時に aria-expandedfalse → true に変わる親のボタンを Enter/クリック
視覚強調現在地リンクが太字+背景色で強調される目視/強調クラスの有無を確認
誤判定防止/user では「ユーザ管理」が光らないパス境界の判定(/users と区別)を確認
  • ランドマーク(nav)+ aria-current(代表 1 件)+ aria-expanded(親の開閉) の三点セットだけで、今回のサイドバーは a11y 的に必要十分。
  • 以降は MENU 定義を編集するだけで、強調・展開・aria-current が自動で追随します。

7.補足:layout.tsxpage.tsx

前回までの記事で、src/app/(protected)/layout.tsxsrc/app/(protected)/配下の各ページについて、下記のようにしていました。
tsx : layout.tsx
1// src/app/(protected)/layout.tsx(抜粋) 2・・・省略・・・ 3 return ( 4 <SidebarProvider> 5 {/* サイドバー/ヘッダ/パンくずは“各 page.tsx”で自由に */} 6 {children} 7 <Toaster richColors closeButton /> 8 </SidebarProvider> 9 ); 10・・・省略・・・
tsx : pages.tsx
1// src/app/(protected)/dashboard/page.tsx(抜粋) 2・・・省略・・・ 3 return ( 4 <> 5 <AppSidebar /> 6 <SidebarInset> 7 ・・・省略・・・ 8 </SidebarInset> 9 </> 10 ); 11・・・省略・・・
これは当初<AppSidebar />に引数を渡すような仕様を想定していたためでした。しかし、今回、URLのパスからアクティブなメニューを判定する仕様にしました。これによって、page.tsx側で<AppSidebar />を持つ必要がなくなりました。
したがって、下記のように変更しました。
tsx : layout.tsx
1// src/app/(protected)/layout.tsx(抜粋) 2・・・省略・・・ 3 return ( 4 <SidebarProvider> 5 <AppSidebar /> 6 <SidebarInset className="min-w-0"> 7 {/* サイドバー/ヘッダ/パンくずは“各 page.tsx”で自由に */} 8 {children} 9 <Toaster richColors closeButton /> 10 </SidebarInset> 11 </SidebarProvider> 12 ); 13・・・省略・・・
tsx : pages.tsx
1// src/app/(protected)/dashboard/page.tsx(抜粋)各ページ同様 2・・・省略・・・ 3 return ( 4 <> 5 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"> 6 ・・・省略・・・ 7 </header> 8 <div className="flex flex-1 flex-col gap-4 p-4 pt-0"> 9 ・・・省略・・・ 10 </div> 11 </> 12 ); 13・・・省略・・・
<AppSidebar /><SidebarInset>layout.tsx側に移動させました。<SidebarInset>については データテーブルの横幅に影響がでないように<SidebarInset className="min-w-0">を共通のものとしています。これで、page.tsxは本当に必要な内容だけを記述すればよくなりました。

8. まとめと次回予告

ここまでで「サイドバーのメニューと参照中ページの同期」を実装しました。ページ本体(SSR)は無改造のまま、サイドバー(Client)だけで現在地を判定・表示します。

今日やったこと(要点だけ一望)

テーマ中身得られた効果
単一出所のメニューMENU: MenuTreesrc/lib/sidebar/menu.schema.ts に集約追加・順序変更が1ファイルで完結
現在地の判定useActive(menu)usePathname() × 最長一致(exact>prefix>regex)ハイライトが常に1か所に定まる
軽量ラッパーNavLinkaria-current="page"data-active受けて出すだけa11yと見た目の規約を再利用
本体実装NavMain:親は開閉ボタン、子はリンク/Chevron回転は aria-expanded操作性(折り畳み時の誤遷移防止)と一貫表現
組み込みapp-sidebar.tsxitems={MENU}<nav aria-label="メインメニュー">ランドマークと現在地が揃って伝わる
以後は menu.schema.ts を編集するだけで、強調・展開・aria-current が自動追随します。

次回予告:プロフィール系の2画面を作る

サイドバー下部のユーザメニューから遷移する 「ユーザー情報確認」「パスワード変更」 を追加する予定です。

参考文献

本稿で扱った「現在地の判定」「サイドバーの開閉」「a11y属性(aria-current/aria-expanded)」の根拠や、実装に使った主要ライブラリの一次情報です。

Next.js

shadcn/ui / Radix UI

アクセシビリティ(WAI-ARIA / MDN)

Tailwind CSS

React / TypeScript(補助)

Githubリポジトリ

この記事で作成した内容は下記のGithubリポジトリにアップしています。ご参考にどうぞ。
この記事の執筆・編集担当
DE

松本 孝太郎

DELOGs編集部/中年新米プログラマー

ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。