feat: remove top header and rework mobile bottom navigation (#109)
This commit is contained in:
+1
-1
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,15 +63,16 @@ 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")}>
|
||||||
|
<div className={classes.brand}>
|
||||||
<Text fw={700}>{t("appName")}</Text>
|
<Text fw={700}>{t("appName")}</Text>
|
||||||
</MantineAppShell.Header>
|
</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)}
|
||||||
@@ -74,15 +81,14 @@ const AppShell: FC<AppShellProps> = (props) => {
|
|||||||
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} />
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
</span>
|
</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 => (
|
||||||
|
|
||||||
return (
|
|
||||||
<RouterNavLink
|
<RouterNavLink
|
||||||
className={appearanceClass}
|
className={getBottomNavItemClass}
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
>
|
>
|
||||||
{item.label}
|
<span className={classes.iconPill}>
|
||||||
|
<item.Icon size={24} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className={classes.bottomNavLabel}>{item.label}</span>
|
||||||
</RouterNavLink>
|
</RouterNavLink>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</nav>
|
</nav>
|
||||||
</MantineAppShell>
|
</MantineAppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -450,6 +450,7 @@ const ru = {
|
|||||||
accounts: "Счета",
|
accounts: "Счета",
|
||||||
bonds: "Облигации",
|
bonds: "Облигации",
|
||||||
cards: "Карты",
|
cards: "Карты",
|
||||||
|
primary: "Основная навигация",
|
||||||
settings: "Настройки",
|
settings: "Настройки",
|
||||||
stocks: "Акции",
|
stocks: "Акции",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user