feat: навигация на ui-kit SIdebar + мобильный drawer
Tests / frontend-test (push) Successful in 1m9s

This commit is contained in:
Тимур Городецкий
2026-08-14 11:35:49 +05:00
parent c98a665611
commit 64527ebd5b
4 changed files with 34 additions and 255 deletions
@@ -81,15 +81,22 @@
display: none; display: none;
} }
// На узких экранах рейл уходит наверх. // Мобильная кнопка-бургер (открывает выезжающий Sidebar). На десктопе скрыта.
@media (max-width: 900px) { .mobileNav {
display: none;
margin-bottom: $sp-4;
}
// < 768px: рейл превращается в выезжающий drawer (см. @tnsu/ui-kit Sidebar). Он
// position:fixed и выходит из потока — колонка .side схлопывается сама, main
// занимает всю ширину. Показываем бургер в шапке контента.
@media (max-width: 768px) {
.layout { .layout {
flex-direction: column;
min-height: 0; min-height: 0;
} }
.side { .mobileNav {
width: 100%; display: block;
} }
} }
@@ -2,7 +2,8 @@ import * as React from 'react';
import styles from './RequestPortal.module.scss'; import styles from './RequestPortal.module.scss';
import '@tnsu/ui-kit/styles.css'; import '@tnsu/ui-kit/styles.css';
import type { IRequestPortalProps } from './IRequestPortalProps'; import type { IRequestPortalProps } from './IRequestPortalProps';
import { IconSprite } from '@tnsu/ui-kit'; import { IconSprite, Sidebar, SidebarToggle } from '@tnsu/ui-kit';
import type { ISidebarItem } from '@tnsu/ui-kit';
import { ConfirmHost } from '../shared/ui'; import { ConfirmHost } from '../shared/ui';
import { import {
navigate, navigate,
@@ -20,7 +21,6 @@ import {
} from '../shared/routing'; } from '../shared/routing';
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents'; import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges'; import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
import { SideNav } from './SideNav/SideNav';
import { Drawer } from './Drawer/Drawer'; import { Drawer } from './Drawer/Drawer';
import { CategoryTabs } from './CategoryTabs/CategoryTabs'; import { CategoryTabs } from './CategoryTabs/CategoryTabs';
import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../data/categories'; import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../data/categories';
@@ -59,6 +59,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
const route = useRoute(); const route = useRoute();
const [navCollapsed, setNavCollapsed] = React.useState<boolean>(false); const [navCollapsed, setNavCollapsed] = React.useState<boolean>(false);
const [mobileNavOpen, setMobileNavOpen] = React.useState<boolean>(false);
const [navCounts, setNavCounts] = React.useState<INavCounts | undefined>(undefined); const [navCounts, setNavCounts] = React.useState<INavCounts | undefined>(undefined);
const [activeCategoryId, setActiveCategoryId] = React.useState<string>(() => { const [activeCategoryId, setActiveCategoryId] = React.useState<string>(() => {
@@ -250,26 +251,38 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
}).catch(() => undefined); }).catch(() => undefined);
}; };
// Разделы бокового меню для пакетного Sidebar (счётчики — из navCounts).
const navItems: ISidebarItem[] = [
{ id: 'new', title: 'Новая заявка', icon: 'plus' },
{ id: 'approvals', title: 'На согласовании', icon: 'user-check', count: navCounts?.approvals },
{ id: 'execution', title: 'На исполнении', icon: 'helmet', count: navCounts?.execution },
{ id: 'rework', title: 'Доработка', icon: 'edit', count: navCounts?.rework },
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' },
{ id: 'search', title: 'Поиск', icon: 'search' },
];
return ( return (
<section className={rootClasses}> <section className={rootClasses}>
<IconSprite /> <IconSprite />
<ConfirmHost /> <ConfirmHost />
<div className={styles.layout}> <div className={styles.layout}>
<div className={styles.side}> <div className={styles.side}>
<SideNav <Sidebar
items={navItems}
activeId={route.nav} activeId={route.nav}
onChange={goto} onChange={(id) => goto(id as NavSection)}
counts={{ brand="Портал заявок"
approvals: navCounts?.approvals,
execution: navCounts?.execution,
rework: navCounts?.rework,
}}
collapsed={navCollapsed} collapsed={navCollapsed}
onToggleCollapsed={() => setNavCollapsed((v) => !v)} onToggleCollapsed={() => setNavCollapsed((v) => !v)}
mobileOpen={mobileNavOpen}
onMobileClose={() => setMobileNavOpen(false)}
/> />
</div> </div>
<main className={styles.main}> <main className={styles.main}>
<div className={styles.mobileNav}>
<SidebarToggle onClick={() => setMobileNavOpen(true)} />
</div>
{/* Раздел «Новая заявка» держим СМОНТИРОВАННЫМ и просто прячем стилем, {/* Раздел «Новая заявка» держим СМОНТИРОВАННЫМ и просто прячем стилем,
когда открыта другая вкладка. Иначе React размонтировал бы форму, и когда открыта другая вкладка. Иначе React размонтировал бы форму, и
всё введённое пропадало бы при переключении на «Мои заявки» и всё введённое пропадало бы при переключении на «Мои заявки» и
@@ -1,154 +0,0 @@
@import '../../../../styles/design-tokens';
// Тёмный боковой рейл портала на всю высоту (фирменный navy-градиент, золотой
// маркер активного пункта). Не «плавает»: примыкает к контенту, тянется по высоте.
.nav {
display: flex;
flex-direction: column;
width: 248px;
padding: $sp-4 0 $sp-3;
background: $grad-header;
box-shadow: inset -1px 0 0 rgba(255, 255, 255, .06);
transition: width .2s;
}
.navCollapsed {
width: 64px;
}
// ---- Шапка рейла: бренд + сворачивание ----
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
padding: 0 $sp-4 $sp-4;
border-bottom: 1px solid rgba(255, 255, 255, .08);
}
.navCollapsed .head {
flex-direction: column;
gap: $sp-3;
padding: 0 0 $sp-3;
}
.brand {
display: flex;
align-items: center;
gap: $sp-2;
min-width: 0;
}
.brandText {
font-family: $font-serif;
font-size: 15px;
font-weight: 600;
color: $white;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toggle {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: 1px solid rgba(255, 255, 255, .18);
border-radius: $r-sm;
background: transparent;
color: rgba(255, 255, 255, .6);
cursor: pointer;
transition: .16s;
&:hover {
border-color: $gold-400;
color: $gold-300;
}
}
// ---- Пункты разделов ----
.items {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: $sp-4;
}
.navCollapsed .items {
margin-top: $sp-3;
}
.item {
position: relative;
display: flex;
align-items: center;
gap: $sp-3;
width: 100%;
padding: 12px 18px;
border: 0;
border-left: 3px solid transparent;
background: transparent;
color: rgba(255, 255, 255, .62);
font-family: $font-sans;
font-size: 13.5px;
font-weight: 500;
text-align: left;
white-space: nowrap;
cursor: pointer;
transition: color .18s, background .18s, border-color .18s;
&:hover {
color: rgba(255, 255, 255, .92);
background: rgba(255, 255, 255, .04);
}
}
.navCollapsed .item {
justify-content: center;
padding: 12px 0;
}
.itemActive {
color: $white;
background: rgba(255, 255, 255, .07);
border-left-color: $gold-500;
.itemIcon {
color: $gold-400;
}
}
.itemIcon {
flex-shrink: 0;
display: inline-flex;
color: inherit;
}
.itemTitle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
flex-shrink: 0;
min-width: 18px;
padding: 1px 6px;
border-radius: $r-full;
background: $gold-500;
color: $navy-950;
font-size: $fs-overline;
font-weight: 700;
text-align: center;
}
.navCollapsed .badge {
position: absolute;
top: 6px;
right: 8px;
}
@@ -1,87 +0,0 @@
import * as React from 'react';
import styles from './SideNav.module.scss';
import { Icon, IconName } from '@tnsu/ui-kit';
import { NavSection } from '../../shared/routing';
interface INavItem {
id: NavSection;
title: string;
icon: IconName;
}
// Порядок неслучаен: сверху действие (создать), затем три очереди — то, что
// ждёт пользователя прямо сейчас, — и только потом архив и поиск.
const ITEMS: INavItem[] = [
{ id: 'new', title: 'Новая заявка', icon: 'plus' },
{ id: 'approvals', title: 'На согласовании', icon: 'user-check' },
{ id: 'execution', title: 'На исполнении', icon: 'helmet' },
{ id: 'rework', title: 'Доработка', icon: 'edit' },
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' },
{ id: 'search', title: 'Поиск', icon: 'search' },
];
interface ISideNavProps {
activeId: NavSection;
onChange: (id: NavSection) => void;
/** Счётчики очередей: сколько заявок ждёт пользователя в каждом разделе. */
counts?: Partial<Record<NavSection, number>>;
collapsed: boolean;
onToggleCollapsed: () => void;
}
// Тёмный боковой рейл портала на всю высоту: бренд сверху, разделы снизу.
// Сворачивается до узкой полосы с одними иконками.
export const SideNav: React.FC<ISideNavProps> = ({
activeId,
onChange,
counts,
collapsed,
onToggleCollapsed,
}) => {
const navClasses = [styles.nav, collapsed ? styles.navCollapsed : ''].filter(Boolean).join(' ');
return (
<nav className={navClasses} aria-label="Разделы портала">
<div className={styles.head}>
<div className={styles.brand}>
{!collapsed && <span className={styles.brandText}>Портал заявок</span>}
</div>
<button
type="button"
className={styles.toggle}
onClick={onToggleCollapsed}
aria-label={collapsed ? 'Развернуть меню' : 'Свернуть меню'}
title={collapsed ? 'Развернуть меню' : 'Свернуть меню'}
>
<Icon name={collapsed ? 'chevron-right' : 'chevron-left'} size="sm" />
</button>
</div>
<div className={styles.items}>
{ITEMS.map((item) => {
const isActive = item.id === activeId;
const itemClasses = [styles.item, isActive ? styles.itemActive : ''].filter(Boolean).join(' ');
const count = counts?.[item.id];
const showCount = !!count;
return (
<button
key={item.id}
type="button"
className={itemClasses}
aria-current={isActive ? 'page' : undefined}
onClick={() => onChange(item.id)}
title={collapsed ? item.title : undefined}
>
<span className={styles.itemIcon}>
<Icon name={item.icon} size="sm" />
</span>
{!collapsed && <span className={styles.itemTitle}>{item.title}</span>}
{showCount && <span className={styles.badge}>{count}</span>}
</button>
);
})}
</div>
</nav>
);
};