feat: remove top header and rework mobile bottom navigation (#109)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Failing after 7m7s

This commit was merged in pull request #110.
This commit is contained in:
2026-10-25 18:25:10 +03:00
parent df86b8450f
commit 1a92f023c1
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="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#ffffff" /> <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> <title>WealthLedger</title>
</head> </head>
<body> <body>
+3 -2
View File
@@ -232,7 +232,8 @@ describe("AppRoutes", () => {
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByRole("navigation", { name: "WealthLedger" }), screen.getAllByRole("navigation", { name: "Основная навигация" }),
).toBeInTheDocument(); ).toHaveLength(2);
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
}); });
}); });
+84 -18
View File
@@ -1,12 +1,22 @@
.root {
--bottom-nav-height: 56px;
}
.content { .content {
max-width: 1120px; max-width: 1120px;
margin-inline: auto; margin-inline: auto;
} }
.header { .brand {
align-items: center; align-items: center;
border-bottom: 1px solid var(--mantine-color-gray-3);
display: flex; display: flex;
padding: 0 var(--mantine-spacing-md); min-height: 60px;
padding-inline: var(--mantine-spacing-sm);
}
.navList {
padding: var(--mantine-spacing-sm);
} }
.bottomNav { .bottomNav {
@@ -52,29 +62,85 @@
.bottomNav { .bottomNav {
background: var(--mantine-color-white); background: var(--mantine-color-white);
border-top: 1px solid var(--mantine-color-gray-3); border-top: 1px solid var(--mantine-color-gray-3);
bottom: 0; display: grid;
display: flex; grid-template-columns: repeat(5, minmax(0, 1fr));
justify-content: space-around; inset: auto 0 0;
left: 0; min-height: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
padding: var(--mantine-spacing-xs) 2px; padding-bottom: env(safe-area-inset-bottom, 0px);
gap: 2px; padding-inline:
font-size: 11px; max(4px, env(safe-area-inset-left, 0px))
& a { max(4px, env(safe-area-inset-right, 0px));
min-width: 0;
padding: 8px 2px;
text-align: center;
}
position: fixed; position: fixed;
right: 0; z-index: var(--mantine-z-index-app);
z-index: 200; }
.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 { .active {
color: var(--mantine-primary-color-filled); color: var(--mantine-primary-color-filled);
font-weight: 600;
& .iconPill {
background-color: var(--mantine-primary-color-light);
}
} }
.main { .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; contentAboveOutlet?: ReactNode;
}; };
function getBottomNavItemClass({ isActive }: { isActive: boolean }) {
return isActive
? `${classes.bottomNavItem} ${classes.active}`
: classes.bottomNavItem;
}
const AppShell: FC<AppShellProps> = (props) => { const AppShell: FC<AppShellProps> = (props) => {
const { contentAboveOutlet } = props; const { contentAboveOutlet } = props;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -24,31 +30,31 @@ const AppShell: FC<AppShellProps> = (props) => {
const items = [ const items = [
{ {
icon: <PortfolioIcon size={20} />, Icon: PortfolioIcon,
iconClassName: classes.portfolioIcon, iconClassName: classes.portfolioIcon,
label: t("navigation.accounts"), label: t("navigation.accounts"),
to: "/accounts", to: "/accounts",
}, },
{ {
icon: <CardsIcon size={20} />, Icon: CardsIcon,
iconClassName: classes.cardsIcon, iconClassName: classes.cardsIcon,
label: t("navigation.cards"), label: t("navigation.cards"),
to: "/cards", to: "/cards",
}, },
{ {
icon: <StocksIcon size={20} />, Icon: StocksIcon,
iconClassName: classes.stocksIcon, iconClassName: classes.stocksIcon,
label: t("navigation.stocks"), label: t("navigation.stocks"),
to: "/stocks", to: "/stocks",
}, },
{ {
icon: <BondsIcon size={20} />, Icon: BondsIcon,
iconClassName: classes.bondsIcon, iconClassName: classes.bondsIcon,
label: t("navigation.bonds"), label: t("navigation.bonds"),
to: "/bonds", to: "/bonds",
}, },
{ {
icon: <SettingsIcon size={20} />, Icon: SettingsIcon,
iconClassName: classes.settingsIcon, iconClassName: classes.settingsIcon,
label: t("navigation.settings"), label: t("navigation.settings"),
to: "/settings", to: "/settings",
@@ -57,32 +63,32 @@ const AppShell: FC<AppShellProps> = (props) => {
return ( return (
<MantineAppShell <MantineAppShell
header={{ height: 60 }} className={classes.root}
navbar={{ breakpoint: "sm", collapsed: { mobile: true }, width: 220 }} navbar={{ breakpoint: "sm", collapsed: { mobile: true }, width: 220 }}
padding="md" padding="md"
> >
<MantineAppShell.Header className={classes.header}> <MantineAppShell.Navbar aria-label={t("navigation.primary")}>
<Text fw={700}>{t("appName")}</Text> <div className={classes.brand}>
</MantineAppShell.Header> <Text fw={700}>{t("appName")}</Text>
</div>
<MantineAppShell.Navbar p="sm"> <div className={classes.navList}>
{items.map(item => ( {items.map(item => (
<NavLink <NavLink
active={pathname.startsWith(item.to)} active={pathname.startsWith(item.to)}
className={classes.navLink} className={classes.navLink}
component={RouterNavLink} component={RouterNavLink}
key={item.to} key={item.to}
label={item.label} label={item.label}
leftSection={( leftSection={(
<span <span className={`${classes.navIcon} ${item.iconClassName}`}>
className={`${classes.navIcon} ${item.iconClassName ?? ""}`} <item.Icon size={20} />
> </span>
{item.icon} )}
</span> to={item.to}
)} />
to={item.to} ))}
/> </div>
))}
</MantineAppShell.Navbar> </MantineAppShell.Navbar>
<MantineAppShell.Main className={classes.main}> <MantineAppShell.Main className={classes.main}>
@@ -93,22 +99,23 @@ const AppShell: FC<AppShellProps> = (props) => {
</div> </div>
</MantineAppShell.Main> </MantineAppShell.Main>
<nav aria-label={t("appName")} className={classes.bottomNav}> <nav
{items.map((item) => { aria-label={t("navigation.primary")}
const appearanceClass = pathname.startsWith(item.to) className={classes.bottomNav}
? classes.active >
: undefined; {items.map(item => (
<RouterNavLink
className={getBottomNavItemClass}
key={item.to}
to={item.to}
>
<span className={classes.iconPill}>
<item.Icon size={24} />
</span>
return ( <span className={classes.bottomNavLabel}>{item.label}</span>
<RouterNavLink </RouterNavLink>
className={appearanceClass} ))}
key={item.to}
to={item.to}
>
{item.label}
</RouterNavLink>
);
})}
</nav> </nav>
</MantineAppShell> </MantineAppShell>
); );
+1
View File
@@ -450,6 +450,7 @@ const ru = {
accounts: "Счета", accounts: "Счета",
bonds: "Облигации", bonds: "Облигации",
cards: "Карты", cards: "Карты",
primary: "Основная навигация",
settings: "Настройки", settings: "Настройки",
stocks: "Акции", stocks: "Акции",
}, },