feat: add breadcrumbs

This commit is contained in:
2026-08-14 14:26:25 +03:00
parent 4f3ee7ddb2
commit 719a6ed8b7
8 changed files with 228 additions and 9 deletions
+89
View File
@@ -0,0 +1,89 @@
import { cleanup, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AppBreadcrumbs } from "./AppBreadcrumbs";
const { useBrokerageAccounts } = vi.hoisted(() => ({
useBrokerageAccounts: vi.fn(),
}));
vi.mock("@/entities/brokerage-account", () => ({ useBrokerageAccounts }));
vi.mock("@/features/auth", () => ({
useAuth: () => ({ accessToken: "token" }),
}));
function renderBreadcrumbs(
path: string,
accounts?: { id: string; name: string }[],
) {
useBrokerageAccounts.mockReturnValue({ data: accounts });
return render(
<MemoryRouter initialEntries={[path]}>
<AppBreadcrumbs />
</MemoryRouter>,
);
}
describe("AppBreadcrumbs", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("shows the current accounts page without a link", () => {
renderBreadcrumbs("/accounts");
expect(
screen.getByRole("navigation", { name: "Хлебные крошки" }),
).toBeInTheDocument();
expect(screen.getByText("Брокерские счета")).toHaveAttribute(
"aria-current",
"page",
);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
it("shows settings as the current page without a link", () => {
renderBreadcrumbs("/settings");
expect(screen.getByText("Настройки")).toHaveAttribute(
"aria-current",
"page",
);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
it("links to the account hierarchy from operations", () => {
renderBreadcrumbs("/accounts/account-1/operations", [
{ id: "account-1", name: "ИИС" },
]);
expect(
screen.getByRole("link", { name: "Брокерские счета" }),
).toHaveAttribute("href", "/accounts");
expect(screen.getByRole("link", { name: "ИИС" })).toHaveAttribute(
"href",
"/accounts/account-1",
);
expect(screen.getByText("Операции")).toHaveAttribute(
"aria-current",
"page",
);
expect(screen.getByRole("navigation")).toHaveAttribute("data-depth", "3");
});
it("uses the account fallback without exposing the identifier", () => {
renderBreadcrumbs("/accounts/account-1");
expect(screen.getByText("Счет")).toHaveAttribute("aria-current", "page");
expect(screen.queryByText("account-1")).not.toBeInTheDocument();
});
it.each(["/login", "/unknown"])("does not render on %s", (path) => {
renderBreadcrumbs(path);
expect(
screen.queryByRole("navigation", { name: "Хлебные крошки" }),
).not.toBeInTheDocument();
});
});
+100
View File
@@ -0,0 +1,100 @@
import { useTranslation } from "react-i18next";
import { Link, useMatch } from "react-router";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { useAuth } from "@/features/auth";
type Crumb = { label: string; to?: string };
function BreadcrumbTrail({ items }: { items: Crumb[] }) {
const { t } = useTranslation();
return (
<nav
aria-label={t("breadcrumbs.label")}
className="breadcrumbs"
data-depth={items.length}
>
{items.map((item, index) => (
<span className="breadcrumbs-entry" key={item.to ?? item.label}>
{index ? (
<span
aria-hidden="true"
className={`breadcrumbs-separator-${index}`}
>
→
</span>
) : null}
<span className={`breadcrumbs-item-${index}`}>
{item.to ? (
<Link to={item.to}>{item.label}</Link>
) : (
<span aria-current="page">{item.label}</span>
)}
</span>
</span>
))}
</nav>
);
}
function AccountBreadcrumbs({
accountId,
operations,
}: {
accountId: string;
operations: boolean;
}) {
const { accessToken } = useAuth();
const { data: accounts } = useBrokerageAccounts(accessToken);
const { t } = useTranslation();
const accountName =
accounts?.find((account) => account.id === accountId)?.name ??
t("breadcrumbs.accountFallback");
const accountPath = `/accounts/${accountId}`;
return (
<BreadcrumbTrail
items={
operations
? [
{ label: t("breadcrumbs.accounts"), to: "/accounts" },
{ label: accountName, to: accountPath },
{ label: t("breadcrumbs.operations") },
]
: [
{ label: t("breadcrumbs.accounts"), to: "/accounts" },
{ label: accountName },
]
}
/>
);
}
export function AppBreadcrumbs() {
const { t } = useTranslation();
const accountOperations = useMatch({
path: "/accounts/:accountId/operations",
end: true,
});
const account = useMatch({ path: "/accounts/:accountId", end: true });
const accounts = useMatch({ path: "/accounts", end: true });
const settings = useMatch({ path: "/settings", end: true });
if (accountOperations)
return (
<AccountBreadcrumbs
accountId={accountOperations.params.accountId ?? ""}
operations
/>
);
if (account)
return (
<AccountBreadcrumbs
accountId={account.params.accountId ?? ""}
operations={false}
/>
);
if (accounts)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
if (settings)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} />;
return null;
}