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:
@@ -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}`);
|
||||
|
||||
|
||||
+17
-13
@@ -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,19 +22,21 @@ export function AppShell() {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="flex items-center gap-4 border-b px-4 py-2">
|
||||
<div className="flex-1" />
|
||||
<ThemeSwitch />
|
||||
<LangSwitch />
|
||||
<Button variant="ghost" size="sm" onClick={onSignOut}>
|
||||
{t("auth.signOut")}
|
||||
</Button>
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<BreadcrumbProvider>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="flex items-center gap-4 border-b px-4 py-2">
|
||||
<Breadcrumb />
|
||||
<ThemeSwitch />
|
||||
<LangSwitch />
|
||||
<Button variant="ghost" size="sm" onClick={onSignOut}>
|
||||
{t("auth.signOut")}
|
||||
</Button>
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</BreadcrumbProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user