feat: remove top header and rework mobile bottom navigation (#109) #110

Merged
codex-bot merged 1 commits from feat/109-mobile-nav-no-header into develop 2026-10-25 18:25:11 +03:00
6 changed files with 185 additions and 63 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#ffffff" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>WealthLedger</title>
</head>
<body>
+3 -2
View File
@@ -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();
});
});
+84 -18
View File
@@ -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)
);
}
}
+47
View File
@@ -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(
<MantineProvider>
<MemoryRouter initialEntries={[path]}>
<AppShell />
</MemoryRouter>
</MantineProvider>,
);
}
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");
});
});
+49 -42
View File
@@ -17,6 +17,12 @@ type AppShellProps = {
contentAboveOutlet?: ReactNode;
};
function getBottomNavItemClass({ isActive }: { isActive: boolean }) {
return isActive
? `${classes.bottomNavItem} ${classes.active}`
: classes.bottomNavItem;
}
const AppShell: FC<AppShellProps> = (props) => {
const { contentAboveOutlet } = props;
const { t } = useTranslation();
@@ -24,31 +30,31 @@ const AppShell: FC<AppShellProps> = (props) => {
const items = [
{
icon: <PortfolioIcon size={20} />,
Icon: PortfolioIcon,
iconClassName: classes.portfolioIcon,
label: t("navigation.accounts"),
to: "/accounts",
},
{
icon: <CardsIcon size={20} />,
Icon: CardsIcon,
iconClassName: classes.cardsIcon,
label: t("navigation.cards"),
to: "/cards",
},
{
icon: <StocksIcon size={20} />,
Icon: StocksIcon,
iconClassName: classes.stocksIcon,
label: t("navigation.stocks"),
to: "/stocks",
},
{
icon: <BondsIcon size={20} />,
Icon: BondsIcon,
iconClassName: classes.bondsIcon,
label: t("navigation.bonds"),
to: "/bonds",
},
{
icon: <SettingsIcon size={20} />,
Icon: SettingsIcon,
iconClassName: classes.settingsIcon,
label: t("navigation.settings"),
to: "/settings",
@@ -57,32 +63,32 @@ const AppShell: FC<AppShellProps> = (props) => {
return (
<MantineAppShell
header={{ height: 60 }}
className={classes.root}
navbar={{ breakpoint: "sm", collapsed: { mobile: true }, width: 220 }}
padding="md"
>
<MantineAppShell.Header className={classes.header}>
<Text fw={700}>{t("appName")}</Text>
</MantineAppShell.Header>
<MantineAppShell.Navbar aria-label={t("navigation.primary")}>
<div className={classes.brand}>
<Text fw={700}>{t("appName")}</Text>
</div>
<MantineAppShell.Navbar p="sm">
{items.map(item => (
<NavLink
active={pathname.startsWith(item.to)}
className={classes.navLink}
component={RouterNavLink}
key={item.to}
label={item.label}
leftSection={(
<span
className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
>
{item.icon}
</span>
)}
to={item.to}
/>
))}
<div className={classes.navList}>
{items.map(item => (
<NavLink
active={pathname.startsWith(item.to)}
className={classes.navLink}
component={RouterNavLink}
key={item.to}
label={item.label}
leftSection={(
<span className={`${classes.navIcon} ${item.iconClassName}`}>
<item.Icon size={20} />
</span>
)}
to={item.to}
/>
))}
</div>
</MantineAppShell.Navbar>
<MantineAppShell.Main className={classes.main}>
@@ -93,22 +99,23 @@ const AppShell: FC<AppShellProps> = (props) => {
</div>
</MantineAppShell.Main>
<nav aria-label={t("appName")} className={classes.bottomNav}>
{items.map((item) => {
const appearanceClass = pathname.startsWith(item.to)
? classes.active
: undefined;
<nav
aria-label={t("navigation.primary")}
className={classes.bottomNav}
>
{items.map(item => (
<RouterNavLink
className={getBottomNavItemClass}
key={item.to}
to={item.to}
>
<span className={classes.iconPill}>
<item.Icon size={24} />
</span>
return (
<RouterNavLink
className={appearanceClass}
key={item.to}
to={item.to}
>
{item.label}
</RouterNavLink>
);
})}
<span className={classes.bottomNavLabel}>{item.label}</span>
</RouterNavLink>
))}
</nav>
</MantineAppShell>
);
+1
View File
@@ -450,6 +450,7 @@ const ru = {
accounts: "Счета",
bonds: "Облигации",
cards: "Карты",
primary: "Основная навигация",
settings: "Настройки",
stocks: "Акции",
},