From 1a92f023c1c79f646424f72710ab74de63386a11 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Sun, 25 Oct 2026 18:25:10 +0300 Subject: [PATCH] feat: remove top header and rework mobile bottom navigation (#109) --- index.html | 2 +- src/app/routes/AppRoutes/AppRoutes.test.tsx | 5 +- src/app/ui/AppShell/AppShell.module.css | 102 ++++++++++++++++---- src/app/ui/AppShell/AppShell.test.tsx | 47 +++++++++ src/app/ui/AppShell/AppShell.tsx | 91 +++++++++-------- src/shared/i18n/locales/ru.ts | 1 + 6 files changed, 185 insertions(+), 63 deletions(-) create mode 100644 src/app/ui/AppShell/AppShell.test.tsx diff --git a/index.html b/index.html index e33e6a4..42dc759 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ - + WealthLedger diff --git a/src/app/routes/AppRoutes/AppRoutes.test.tsx b/src/app/routes/AppRoutes/AppRoutes.test.tsx index ca97244..7707151 100644 --- a/src/app/routes/AppRoutes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes/AppRoutes.test.tsx @@ -232,7 +232,8 @@ describe("AppRoutes", () => { }), ).toBeInTheDocument(); expect( - screen.getByRole("navigation", { name: "WealthLedger" }), - ).toBeInTheDocument(); + screen.getAllByRole("navigation", { name: "Основная навигация" }), + ).toHaveLength(2); + expect(screen.queryByRole("banner")).not.toBeInTheDocument(); }); }); diff --git a/src/app/ui/AppShell/AppShell.module.css b/src/app/ui/AppShell/AppShell.module.css index 2baaffc..6d463bf 100644 --- a/src/app/ui/AppShell/AppShell.module.css +++ b/src/app/ui/AppShell/AppShell.module.css @@ -1,12 +1,22 @@ +.root { + --bottom-nav-height: 56px; +} + .content { max-width: 1120px; margin-inline: auto; } -.header { +.brand { align-items: center; + border-bottom: 1px solid var(--mantine-color-gray-3); display: flex; - padding: 0 var(--mantine-spacing-md); + min-height: 60px; + padding-inline: var(--mantine-spacing-sm); +} + +.navList { + padding: var(--mantine-spacing-sm); } .bottomNav { @@ -52,29 +62,85 @@ .bottomNav { background: var(--mantine-color-white); border-top: 1px solid var(--mantine-color-gray-3); - bottom: 0; - display: flex; - justify-content: space-around; - left: 0; - padding: var(--mantine-spacing-xs) 2px; - gap: 2px; - font-size: 11px; - & a { - min-width: 0; - padding: 8px 2px; - text-align: center; - } + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + inset: auto 0 0; + min-height: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px)); + padding-bottom: env(safe-area-inset-bottom, 0px); + padding-inline: + max(4px, env(safe-area-inset-left, 0px)) + max(4px, env(safe-area-inset-right, 0px)); position: fixed; - right: 0; - z-index: 200; + z-index: var(--mantine-z-index-app); + } + + .bottomNavItem { + align-items: center; + color: var(--mantine-color-gray-7); + display: flex; + flex-direction: column; + gap: 2px; + justify-content: center; + min-height: var(--bottom-nav-height); + min-width: 0; + text-decoration: none; + + &:active .iconPill { + background-color: var(--mantine-color-gray-1); + } + + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: -2px; + } + } + + @media (hover: hover) { + .bottomNavItem:hover .iconPill { + background-color: var(--mantine-color-gray-1); + } + } + + .iconPill { + align-items: center; + border-radius: 14px; + display: inline-flex; + height: 28px; + justify-content: center; + width: 48px; + } + + .bottomNavLabel { + font-size: 12px; + font-weight: 500; + line-height: 16px; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + @media (prefers-reduced-motion: no-preference) { + .bottomNavItem, + .iconPill { + transition: + background-color 150ms ease-in-out, + color 150ms ease-in-out; + } } .active { color: var(--mantine-primary-color-filled); - font-weight: 600; + + & .iconPill { + background-color: var(--mantine-primary-color-light); + } } .main { - padding-bottom: 5rem; + padding-bottom: calc( + var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px) + + var(--mantine-spacing-md) + ); } } diff --git a/src/app/ui/AppShell/AppShell.test.tsx b/src/app/ui/AppShell/AppShell.test.tsx new file mode 100644 index 0000000..307f2a0 --- /dev/null +++ b/src/app/ui/AppShell/AppShell.test.tsx @@ -0,0 +1,47 @@ +import { MantineProvider } from "@mantine/core"; +import { render, screen, within } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it } from "vitest"; + +import { AppShell } from "./AppShell"; + +function renderShell(path: string) { + return render( + + + + + , + ); +} + +describe("AppShell", () => { + it("has no header banner", () => { + renderShell("/cards"); + + expect(screen.queryByRole("banner")).not.toBeInTheDocument(); + }); + + it("marks only the current bottom navigation item", () => { + renderShell("/cards"); + + const bottomNav = screen + .getAllByRole("navigation", { name: "Основная навигация" }) + .at(-1); + + if (!bottomNav) { + throw new Error("Bottom navigation is not rendered"); + } + + const links = within(bottomNav).getAllByRole("link"); + + expect(links).toHaveLength(5); + expect(within(bottomNav).getByRole("link", { name: "Карты" })).toHaveAttribute( + "aria-current", + "page", + ); + expect( + within(bottomNav).getByRole("link", { name: "Счета" }), + ).not.toHaveAttribute("aria-current"); + }); +}); diff --git a/src/app/ui/AppShell/AppShell.tsx b/src/app/ui/AppShell/AppShell.tsx index b498395..aee1778 100644 --- a/src/app/ui/AppShell/AppShell.tsx +++ b/src/app/ui/AppShell/AppShell.tsx @@ -17,6 +17,12 @@ type AppShellProps = { contentAboveOutlet?: ReactNode; }; +function getBottomNavItemClass({ isActive }: { isActive: boolean }) { + return isActive + ? `${classes.bottomNavItem} ${classes.active}` + : classes.bottomNavItem; +} + const AppShell: FC = (props) => { const { contentAboveOutlet } = props; const { t } = useTranslation(); @@ -24,31 +30,31 @@ const AppShell: FC = (props) => { const items = [ { - icon: , + Icon: PortfolioIcon, iconClassName: classes.portfolioIcon, label: t("navigation.accounts"), to: "/accounts", }, { - icon: , + Icon: CardsIcon, iconClassName: classes.cardsIcon, label: t("navigation.cards"), to: "/cards", }, { - icon: , + Icon: StocksIcon, iconClassName: classes.stocksIcon, label: t("navigation.stocks"), to: "/stocks", }, { - icon: , + Icon: BondsIcon, iconClassName: classes.bondsIcon, label: t("navigation.bonds"), to: "/bonds", }, { - icon: , + Icon: SettingsIcon, iconClassName: classes.settingsIcon, label: t("navigation.settings"), to: "/settings", @@ -57,32 +63,32 @@ const AppShell: FC = (props) => { return ( - - {t("appName")} - + +
+ {t("appName")} +
- - {items.map(item => ( - - {item.icon} - - )} - to={item.to} - /> - ))} +
+ {items.map(item => ( + + + + )} + to={item.to} + /> + ))} +
@@ -93,22 +99,23 @@ const AppShell: FC = (props) => { -