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 { ObjectsTable } from "./objects-table";
|
||||||
import { useMediaQuery } from "../lib/use-media-query";
|
import { useMediaQuery } from "../lib/use-media-query";
|
||||||
import { useDocumentTitle } from "../lib/use-document-title";
|
import { useDocumentTitle } from "../lib/use-document-title";
|
||||||
|
import { useBreadcrumb } from "../shell/use-breadcrumb";
|
||||||
import { PageTitle } from "@/components/ui/page-title";
|
import { PageTitle } from "@/components/ui/page-title";
|
||||||
|
|
||||||
const ObjectDetailDrawer = lazy(() =>
|
const ObjectDetailDrawer = lazy(() =>
|
||||||
@@ -26,6 +27,7 @@ export function ObjectsPage() {
|
|||||||
const isWide = useMediaQuery("(min-width: 1024px)");
|
const isWide = useMediaQuery("(min-width: 1024px)");
|
||||||
|
|
||||||
useDocumentTitle(t("nav.objects"));
|
useDocumentTitle(t("nav.objects"));
|
||||||
|
useBreadcrumb([{ label: t("nav.objects") }]);
|
||||||
|
|
||||||
const closeDetail = () => navigate(`/objects?${searchParams}`);
|
const closeDetail = () => navigate(`/objects?${searchParams}`);
|
||||||
|
|
||||||
|
|||||||
+17
-13
@@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { LangSwitch } from "./lang-switch";
|
import { LangSwitch } from "./lang-switch";
|
||||||
import { ThemeSwitch } from "./theme-switch";
|
import { ThemeSwitch } from "./theme-switch";
|
||||||
import { Sidebar } from "./sidebar";
|
import { Sidebar } from "./sidebar";
|
||||||
|
import { BreadcrumbProvider } from "./breadcrumb-provider";
|
||||||
|
import { Breadcrumb } from "./breadcrumb";
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -20,19 +22,21 @@ export function AppShell() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="flex flex-1 flex-col">
|
<BreadcrumbProvider>
|
||||||
<header className="flex items-center gap-4 border-b px-4 py-2">
|
<div className="flex flex-1 flex-col">
|
||||||
<div className="flex-1" />
|
<header className="flex items-center gap-4 border-b px-4 py-2">
|
||||||
<ThemeSwitch />
|
<Breadcrumb />
|
||||||
<LangSwitch />
|
<ThemeSwitch />
|
||||||
<Button variant="ghost" size="sm" onClick={onSignOut}>
|
<LangSwitch />
|
||||||
{t("auth.signOut")}
|
<Button variant="ghost" size="sm" onClick={onSignOut}>
|
||||||
</Button>
|
{t("auth.signOut")}
|
||||||
</header>
|
</Button>
|
||||||
<main className="flex-1 overflow-hidden">
|
</header>
|
||||||
<Outlet />
|
<main className="flex-1 overflow-hidden">
|
||||||
</main>
|
<Outlet />
|
||||||
</div>
|
</main>
|
||||||
|
</div>
|
||||||
|
</BreadcrumbProvider>
|
||||||
</div>
|
</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