import { useState, type CSSProperties } from "react"; import { Link } from "react-router-dom"; import { AppShell, Flex, PageContainer, Sidebar, SidebarHeader, SidebarItem, SidebarSection, Topbar, createSidebarLink, } from "@godxjp/ui/layout"; import type { SidebarItemData, SidebarRenderItemProp, SidebarSectionProp } from "@godxjp/ui/layout"; import { Avatar, AvatarFallback, Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@godxjp/ui/data-display"; import { Button, Text } from "@godxjp/ui/general"; import { LayoutDashboard, FileText, Receipt, Users, BookOpen, Building2, ShieldCheck, CreditCard, Plus, Boxes, Star, Settings, PanelLeftClose, PanelLeftOpen, Search, ChevronsUpDown, } from "lucide-react"; /** * Sidebar · data-driven vertical nav rail. * Focus: every public prop · sections, groups with children[], product chip, * brand header, linkComponent (router links) + SidebarItem asChild, the * deprecated renderItem escape hatch, children composition, footer, active * item, collapsed icon-only mode. * Composed only from real @godxjp/ui components inside an AppShell frame. */ const FULL_SECTIONS: SidebarSectionProp[] = [ { label: "会計", items: [ { id: "dashboard", label: "ダッシュボード", icon: LayoutDashboard }, { id: "ledger", label: "元帳", icon: BookOpen, children: [ { id: "journal", label: "仕訳入力", icon: FileText }, { id: "recurring", label: "定期仕訳", icon: Receipt }, { id: "coa", label: "勘定科目", icon: CreditCard }, ], }, { id: "invoices", label: "請求書", icon: Receipt, badge: 3 }, { id: "bills", label: "仕入請求書", icon: FileText }, ], }, { label: "マスタ", items: [ { id: "partners", label: "取引先", icon: Building2 }, { id: "items", label: "品目", icon: CreditCard }, ], }, { label: "管理", items: [ { id: "users", label: "ユーザー", icon: Users }, { id: "roles", label: "権限", icon: ShieldCheck, disabled: true }, ], }, ]; const BRAND_SECTIONS: SidebarSectionProp[] = [ { label: "アプリ", items: [ { id: "home", label: "ホーム", icon: LayoutDashboard }, { id: "projects", label: "プロジェクト", icon: Boxes }, { id: "settings", label: "設定", icon: Settings }, ], }, ]; const FAVOURITE_SECTIONS: SidebarSectionProp[] = [ { label: "ナビゲーション", items: [ { id: "overview", label: "概要", icon: LayoutDashboard }, { id: "reports", label: "レポート", icon: FileText }, { id: "members", label: "メンバー", icon: Users }, ], }, ]; const FAVOURITE_IDS = new Set(["overview", "members"]); /** * badgeTone · what a count MEANS, not what colour it is. `neutral`(既定)は「未読」、 * `destructive` は「自分宛て」(メンション・DM)。`badge` には数値や文字列だけを渡します · * ここに を入れると .sb-badge の中にもう一枚ピルが入り、二重の丸が出ます。 */ const MENTION_SECTIONS: SidebarSectionProp[] = [ { label: "チャンネル", items: [ { id: "general", label: "general", icon: Boxes, badge: 12 }, { id: "accounting", label: "経理", icon: Receipt, badge: 3, badgeTone: "destructive" }, { id: "design", label: "design", icon: Star, badge: "9+", badgeTone: "neutral" }, { id: "random", label: "random", icon: LayoutDashboard }, ], }, ]; /** * nav row and nav icon read SEPARATE colour tokens. A service normally sets these once in * its theme.css (`:root` or a scoped `[data-tenant] .app-sidebar`); here they are scoped to one * demo frame so the default rail can sit next to the themed one. Icons only — geometry (16px icon, * 32px row, 10px gap) is untouched. */ const CANONICAL_NAV_TOKENS = { "--sidebar-nav-icon-foreground": "hsl(var(--foreground))", "--sidebar-nav-icon-active-foreground": "hsl(var(--primary))", } as CSSProperties; /** * THE router-link contract. `createSidebarLink` adapts any router `Link` — React Router / * TanStack use `to`, Inertia and Next.js use `href` (the default, and * `inertiaSidebarLink(Link)` from `@godxjp/ui/inertia` is the same thing pre-bound). The consumer * writes NO row markup: the Sidebar composes the icon slot, the label, the badge, the active state * and the collapsed rail, and hands them to the link as `children`. */ const RouterLink = createSidebarLink(Link, "to"); /** Router-driven rows — each carries an `href`, which is what the link adapter navigates to. */ const ROUTED_SECTIONS: SidebarSectionProp[] = [ { label: "ナビゲーション", items: [ { id: "overview", label: "概要", icon: LayoutDashboard, href: "/overview" }, { id: "reports", label: "レポート", icon: FileText, href: "/reports", badge: 5, }, { id: "ledger", label: "元帳", icon: BookOpen, children: [{ id: "journal-2", label: "仕訳入力", icon: Receipt, href: "/journal" }], }, { id: "members", label: "メンバー", icon: Users, href: "/members", disabled: true }, ], }, ]; /** * renderItem · DEPRECATED — kept working for existing consumers. It leaves the row CONTENT * to the caller, which is how a `{item.label}` silently dropped every icon in * production. `rowProps` now carries the library-composed `children`, so spreading it (or rendering * `rowProps.children`) restores the canonical row; the star is a decorative, non-interactive affix. * Prefer `linkComponent` (above) or `SidebarItem asChild` for new code. */ function renderFavouriteRow( item: SidebarItemData, rowProps: SidebarRenderItemProp, onSelect: (id: string) => void, ) { return ( { event.preventDefault(); onSelect(item.id); }} > {/* The icon + label come from the LIBRARY, not from hand-written .sb-icon / .sb-label spans. */} {rowProps.children} {FAVOURITE_IDS.has(item.id) ? ( ) : null} ); } const COMPOSED_ITEMS: SidebarItemData[] = [ { id: "starred-1", label: "月次決算", icon: Star, href: "/close" }, { id: "starred-2", label: "売掛金一覧", icon: Receipt, href: "/receivables" }, { id: "starred-3", label: "取引先マスタ", icon: Building2, href: "/partners" }, ]; export default function Demo() { const [activeId, setActiveId] = useState("journal"); const [collapsed, setCollapsed] = useState(false); const [brandActiveId, setBrandActiveId] = useState("projects"); const [renderActiveId, setRenderActiveId] = useState("overview"); const [routedActiveId, setRoutedActiveId] = useState("reports"); const [composedActiveId, setComposedActiveId] = useState("starred-1"); const [tokenActiveId, setTokenActiveId] = useState("overview"); const [mentionActiveId, setMentionActiveId] = useState("general"); const sidebar = ( undefined} /* * footer · brand と同じ「関数を渡すと EFFECTIVE な collapsed が来る」形。自前の collapsed * を読むと、AppShell が同じ Sidebar をドロワーにも渡す(ドロワーは展開表示に戻す)ため * 全幅のドロワーの中でアイコンだけの足元になります。 */ footer={(railCollapsed) => railCollapsed ? ( ) : ( 山田 太郎 オンライン ) } /> ); const topbar = ( C } center={ } /> ); return ( setCollapsed((c) => !c)}> {collapsed ? "サイドバーを展開" : "サイドバーを折りたたむ"} } > 現在の状態 左のレールに渡している sections prop の現在のアクティブ項目 {activeId} {/* brand prop · replaces the product chip with a fully custom header (SidebarHeader). */} brand プロップ product チップの代わりに SidebarHeader で完全に自作したヘッダーを差し込みます。 brand と product は排他です。 Acme Suite v7.0 Enterprise } /> {/* linkComponent prop · THE router-link contract — library composes the row. */} linkComponent プロップ(ルーターリンク) フレームワークのルーター Link を渡すだけ。行の中身(アイコン・ラベル・バッジ・ アクティブ状態・折りたたみレール)は ライブラリ側が組み立てて children として渡すので、 利用側が行マークアップを再実装することはありません。React Router / TanStack は createSidebarLink(Link, "to")、Inertia / Next.js は createSidebarLink(Link)(または @godxjp/ui/inertia の inertiaSidebarLink(Link))を使います。 左は展開、右は折りたたみレールです。 どちらも同じ Link のままアイコンが残ります。 {/* renderItem prop · DEPRECATED escape hatch — kept for back-compat. */} renderItem プロップ(非推奨) 行の中身まで利用側が書く旧エスケープハッチ。これが原因で <Link>{"{item.label}"}</Link> がアイコンを全て落とす回帰が発生しました。 現在は rowProps.children にライブラリ製の行内容が入るので、それを描画すれば 正規の行が復元されます(ここでは その右にお気に入りスターを添えています)。 新規コードでは linkComponent か SidebarItem asChild を使ってください。 renderFavouriteRow(item, rowProps, setRenderActiveId) } aria-label="renderItem プロップ例のナビゲーション" /> {/* children prop · full nav override: compose SidebarSection / SidebarItem directly. */} children プロップ(ナビ全体の差し替え) sections を使わず SidebarSection / SidebarItem を直接組み立てて、 ナビゲーション全体を 自前で構成します。右は同じ構成に SidebarItem の asChild を足したものです。子として ルーターの Link を「要素だけ」渡すと、アイコン・ラベル・バッジは ライブラリが差し込みます(。 children は書きません)。 {COMPOSED_ITEMS.map((item) => ( ))} {COMPOSED_ITEMS.map((item) => ( ))} {/* badgeTone · 未読(中立)と「自分宛て」(強調)を一本の軸で分ける。 */} badgeTone プロップ(未読と自分宛ての区別) 行のカウントが「未読」なのか「自分宛て(メンション・DM)」なのかを badgeTone で表します。既定の neutral は従来のピルとバイト単位で同一で、 属性すら出しません。destructive のときだけ data-tone が付き、 --sidebar-badge-destructive-background / -foreground を読みます。 badge には数値や文字列だけを渡してください · そこに <Badge> を入れると .sb-badge の中にもう一枚ピルが入って丸が二重になります(実測 37.11×19.14 の 中に 25.11×19.14)。 色は 2 トークンだけが動き、ピルの高さも角丸も左右余白も 共通なので、未読行とメンション行は同じ列に揃ったままです。 {/* Nav colour tokens · icon and label are themed SEPARATELY. */} ナビの配色トークン(アイコンとラベルを別々に) 行(ラベル)は --sidebar-nav-item-foreground、アイコンは --sidebar-nav-icon-foreground を読みます。 既定値は従来どおり(どちらも --muted-foreground)。 テーマ側でアイコンだけ濃くすれば、muted テキスト全体の色を変えずに キャンバス基準の 16px アイコンに揃えられます(ホバー/アクティブ/無効の各状態にも 対応トークンあり)。 {/* trailingIcon · 行の末尾に置く 16px の「開くよ」グリフ。badge(件数ピル)とは別枠。 */} trailingIcon プロップ(末尾のグリフ) ワークスペース切替のような「押すと何か開く」行の末尾に置く 16px のグリフです。件数ピルの badge とは別の枠で、背景も角丸も描きません · badge に ⌃⌄ を入れると .sb-badge の灰色ピル(実測 36×24、中の SVG は 24×24)に なってしまうため、サイズを DS 側で固定した専用の枠にしています。 icon と同じくコンポーネントを渡す形なので、SVG の寸法は 16px に固定されます。 折りたたみレールでは badge と同じように隠れます。 } /> {/* Feature notes */} 主な機能 {[ "product chip · name / role(エンティティ名)/ color", "collapsed=true でアイコンのみのレール表示に切替", "children[] を持つ item は自動で折りたたみグループになる", "activeId が子孫にマッチすると親グループが自動展開", "badge prop で件数バッジをアイテムに付与可能(中身は数値・文字列のみ)", "badgeTone で未読(neutral)と自分宛て(destructive)を色だけで区別", "disabled=true で項目を非活性化(クリック不可)", "footer prop でスクロール外にユーザー情報を固定", "footer は brand と同じく関数も受け取る · 実効の collapsed が渡る", "trailingIcon で行の末尾に 16px のグリフ(切替の ⌃⌄ など)を置ける", "linkComponent · ルーター Link は「要素だけ」渡す。行の中身はライブラリが組み立てる", "linkComponent は葉・サブメニュー・折りたたみレール・フライアウトの全てに適用される", "グループのトリガーは aria-expanded を持つ開閉ボタンのままなので linkComponent は適用されない", ].map((note) => ( {note} ))} ); }