feat(web): page-driven breadcrumb context + header render + objects wiring (#54)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 19:11:31 +02:00
parent dbaf22500e
commit 18cb35beff
7 changed files with 116 additions and 13 deletions
+2
View File
@@ -6,6 +6,7 @@ import { X } from "lucide-react";
import { ObjectsTable } from "./objects-table";
import { useMediaQuery } from "../lib/use-media-query";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { PageTitle } from "@/components/ui/page-title";
const ObjectDetailDrawer = lazy(() =>
@@ -26,6 +27,7 @@ export function ObjectsPage() {
const isWide = useMediaQuery("(min-width: 1024px)");
useDocumentTitle(t("nav.objects"));
useBreadcrumb([{ label: t("nav.objects") }]);
const closeDetail = () => navigate(`/objects?${searchParams}`);
+5 -1
View File
@@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button";
import { LangSwitch } from "./lang-switch";
import { ThemeSwitch } from "./theme-switch";
import { Sidebar } from "./sidebar";
import { BreadcrumbProvider } from "./breadcrumb-provider";
import { Breadcrumb } from "./breadcrumb";
export function AppShell() {
const { t } = useTranslation();
@@ -20,9 +22,10 @@ export function AppShell() {
return (
<div className="flex min-h-screen">
<Sidebar />
<BreadcrumbProvider>
<div className="flex flex-1 flex-col">
<header className="flex items-center gap-4 border-b px-4 py-2">
<div className="flex-1" />
<Breadcrumb />
<ThemeSwitch />
<LangSwitch />
<Button variant="ghost" size="sm" onClick={onSignOut}>
@@ -33,6 +36,7 @@ export function AppShell() {
<Outlet />
</main>
</div>
</BreadcrumbProvider>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { createContext, useContext } from "react";
export type BreadcrumbItem = { label: string; to?: string };
type BreadcrumbContextValue = {
trail: BreadcrumbItem[];
setTrail: (trail: BreadcrumbItem[]) => void;
};
export const BreadcrumbContext = createContext<BreadcrumbContextValue>({
trail: [],
setTrail: () => {},
});
export function useBreadcrumbTrail(): BreadcrumbItem[] {
return useContext(BreadcrumbContext).trail;
}
+12
View File
@@ -0,0 +1,12 @@
import { useState, type ReactNode } from "react";
import { BreadcrumbContext, type BreadcrumbItem } from "./breadcrumb-context";
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
const [trail, setTrail] = useState<BreadcrumbItem[]>([]);
return (
<BreadcrumbContext.Provider value={{ trail, setTrail }}>
{children}
</BreadcrumbContext.Provider>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { expect, test } from "vitest";
import { screen } from "@testing-library/react";
import { renderApp } from "../test/render";
import { BreadcrumbProvider } from "./breadcrumb-provider";
import { Breadcrumb } from "./breadcrumb";
import { useBreadcrumb } from "./use-breadcrumb";
function Setter() {
useBreadcrumb([
{ label: "Objects", to: "/objects" },
{ label: "LM-0042" },
]);
return null;
}
test("renders the trail with a link on non-leaf crumbs", async () => {
renderApp(
<BreadcrumbProvider>
<Breadcrumb />
<Setter />
</BreadcrumbProvider>,
);
const link = await screen.findByRole("link", { name: "Objects" });
expect(link).toHaveAttribute("href", "/objects");
expect(screen.getByText("LM-0042")).toBeInTheDocument();
});
+30
View File
@@ -0,0 +1,30 @@
import { Fragment } from "react";
import { Link } from "react-router-dom";
import { useBreadcrumbTrail } from "./breadcrumb-context";
export function Breadcrumb() {
const trail = useBreadcrumbTrail();
if (trail.length === 0) return <div className="min-w-0 flex-1" />;
return (
<nav aria-label="Breadcrumb" className="flex min-w-0 flex-1 items-center gap-1 text-sm">
{trail.map((item, i) => {
const last = i === trail.length - 1;
return (
<Fragment key={`${item.label}-${i}`}>
{i > 0 && <span className="text-muted-foreground">/</span>}
{item.to && !last ? (
<Link to={item.to} className="truncate text-muted-foreground hover:text-foreground">
{item.label}
</Link>
) : (
<span className={last ? "truncate text-foreground" : "truncate text-muted-foreground"}>
{item.label}
</span>
)}
</Fragment>
);
})}
</nav>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { useContext, useEffect } from "react";
import { BreadcrumbContext, type BreadcrumbItem } from "./breadcrumb-context";
export function useBreadcrumb(trail: BreadcrumbItem[]): void {
const { setTrail } = useContext(BreadcrumbContext);
const key = trail.map((i) => `${i.label} ${i.to ?? ""}`).join("");
useEffect(() => {
setTrail(trail);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, setTrail]);
}