feat(ui): Implement adaptive navigation system (Stage 1)

- Added useDisplay hook for breakpoint-based layout
- Added useScrollDirection hook for hide-on-scroll logic
- Implemented three-tier navigation:
  - Navigation Bar (Compact < 600px) with auto-hide
  - Navigation Rail (Medium 600-1240px)
  - Expanded Navigation Rail (Expanded > 1240px)
- Added support for sub-menus in expanded rail
- Added notification badges support
- Integrated ThemeProvider into App

Refs #3
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-04-11 01:10:55 +03:00
parent 67563664ce
commit db0b93b007
6 changed files with 360 additions and 77 deletions

View File

@@ -1,11 +1,18 @@
import React from 'react'; import React from 'react';
import { Navigation } from './Navigation'; import { Navigation } from './Navigation';
import { useStore } from '../store/useStore';
import { useDisplay } from '../hooks/useDisplay';
export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isRailExpanded } = useStore();
const { navMode } = useDisplay();
const isExpanded = navMode === 'rail-expanded' && isRailExpanded;
return ( return (
<div className="app-container"> <div className="app-container">
<Navigation /> <Navigation />
<main className="main-content"> <main className={`main-content ${isExpanded ? 'with-expanded-rail' : ''}`}>
{children} {children}
</main> </main>
</div> </div>

View File

@@ -1,37 +1,84 @@
import React from 'react'; import React, { useState } from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { User, Calendar, GraduationCap, MessageSquare, Settings } from 'lucide-react'; import {
Calendar,
GraduationCap,
FileText,
MessageSquare,
User,
ChevronDown,
ChevronRight,
Menu
} from 'lucide-react';
import { useStore } from '../store/useStore';
import { useDisplay } from '../hooks/useDisplay';
import { useScrollDirection } from '../hooks/useScrollDirection';
const navItems = [ interface NavItem {
{ path: '/profile', label: 'Profile', icon: User }, path: string;
{ path: '/schedule', label: 'Schedule', icon: Calendar }, label: string;
{ path: '/grades', label: 'Grades', icon: GraduationCap }, icon: any;
{ path: '/messages', label: 'Messages', icon: MessageSquare }, iconFilled: any;
{ path: '/settings', label: 'Settings', icon: Settings }, badgeKey?: 'messages';
children?: { path: string; label: string }[];
}
const navItems: NavItem[] = [
{ path: '/profile', label: 'Профиль', icon: User, iconFilled: User },
{
path: '/schedule',
label: 'Расписание',
icon: Calendar,
iconFilled: Calendar,
children: [
{ path: '/schedule/week', label: 'Текущая неделя' },
{ path: '/schedule/session', label: 'Сессия' },
]
},
{
path: '/grades',
label: 'Успеваемость',
icon: GraduationCap,
iconFilled: GraduationCap,
children: [
{ path: '/grades/list', label: 'Оценки' },
{ path: '/grades/statements', label: 'Ведомости' },
]
},
{
path: '/docs',
label: 'Документы',
icon: FileText,
iconFilled: FileText,
children: [
{ path: '/docs/certs', label: 'Справки' },
{ path: '/docs/apps', label: 'Заявления' },
]
},
{ path: '/messages', label: 'Сообщения', icon: MessageSquare, iconFilled: MessageSquare, badgeKey: 'messages' },
]; ];
export const Navigation: React.FC = () => { export const Navigation: React.FC = () => {
return ( const { navMode } = useDisplay();
<> const { isRailExpanded, toggleRail, badges } = useStore();
<nav className="navigation-rail"> const scrollDir = useScrollDirection();
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '12px' }}> const [expandedItems, setExpandedItems] = useState<string[]>([]);
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
>
<div className="nav-item-icon-wrapper">
<item.icon size={24} />
</div>
<span className="nav-item-label">{item.label}</span>
</NavLink>
))}
</div>
</nav>
<nav className="navigation-bar"> const toggleSubmenu = (label: string) => {
{navItems.map((item) => ( setExpandedItems(prev =>
prev.includes(label) ? prev.filter(i => i !== label) : [...prev, label]
);
};
const renderBadge = (count?: number) => {
if (!count) return null;
return <span className="m3-badge">{count > 99 ? '99+' : count}</span>;
};
if (navMode === 'bar') {
return (
<nav className={`navigation-bar ${scrollDir === 'down' ? 'hidden' : ''}`}>
{navItems.slice(0, 5).map((item) => (
<NavLink <NavLink
key={item.path} key={item.path}
to={item.path} to={item.path}
@@ -39,11 +86,71 @@ export const Navigation: React.FC = () => {
> >
<div className="nav-item-icon-wrapper"> <div className="nav-item-icon-wrapper">
<item.icon size={24} /> <item.icon size={24} />
{item.badgeKey && renderBadge(badges[item.badgeKey])}
</div> </div>
<span className="nav-item-label">{item.label}</span> <span className="nav-item-label">{item.label}</span>
</NavLink> </NavLink>
))} ))}
</nav> </nav>
</> );
}
const isExpanded = navMode === 'rail-expanded' && isRailExpanded;
return (
<nav className={`navigation-rail ${isExpanded ? 'expanded' : ''}`}>
<div className="rail-header">
<button className="icon-button" onClick={toggleRail}>
<Menu size={24} />
</button>
{isExpanded && <span className="rail-title">Bonch</span>}
</div>
<div className="rail-items">
{navItems.map((item) => {
const hasChildren = item.children && item.children.length > 0;
const isItemExpanded = expandedItems.includes(item.label);
return (
<div key={item.label} className="nav-group">
<NavLink
to={item.path}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
onClick={() => {
if (hasChildren && isExpanded) {
toggleSubmenu(item.label);
}
}}
>
<div className="nav-item-icon-wrapper">
<item.icon size={24} />
{item.badgeKey && renderBadge(badges[item.badgeKey])}
</div>
{isExpanded && <span className="nav-item-label">{item.label}</span>}
{isExpanded && hasChildren && (
<div className="submenu-arrow">
{isItemExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</div>
)}
</NavLink>
{isExpanded && isItemExpanded && hasChildren && (
<div className="submenu">
{item.children!.map(child => (
<NavLink
key={child.path}
to={child.path}
className={({ isActive }) => `submenu-item ${isActive ? 'active' : ''}`}
>
{child.label}
</NavLink>
))}
</div>
)}
</div>
);
})}
</div>
</nav>
); );
}; };

19
src/hooks/useDisplay.ts Normal file
View File

@@ -0,0 +1,19 @@
import { useState, useEffect, useMemo } from 'react';
export function useDisplay() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const navMode = useMemo(() => {
if (width < 600) return 'bar';
if (width < 1240) return 'rail';
return 'rail-expanded';
}, [width]);
return { width, navMode };
}

View File

@@ -0,0 +1,23 @@
import { useState, useEffect } from 'react';
export function useScrollDirection() {
const [scrollDir, setScrollDirection] = useState<'up' | 'down'>('up');
useEffect(() => {
let lastScrollY = window.pageYOffset;
const updateScrollDirection = () => {
const scrollY = window.pageYOffset;
const direction = scrollY > lastScrollY ? 'down' : 'up';
if (direction !== scrollDir && (scrollY - lastScrollY > 10 || scrollY - lastScrollY < -10)) {
setScrollDirection(direction);
}
lastScrollY = scrollY > 0 ? scrollY : 0;
};
window.addEventListener('scroll', updateScrollDirection);
return () => window.removeEventListener('scroll', updateScrollDirection);
}, [scrollDir]);
return scrollDir;
}

View File

@@ -7,11 +7,18 @@ interface AppState {
isMockMode: boolean; isMockMode: boolean;
theme: 'system' | 'light' | 'dark'; theme: 'system' | 'light' | 'dark';
seedColor: string; seedColor: string;
isRailExpanded: boolean;
badges: {
messages: number;
};
setApiDomain: (domain: string) => void; setApiDomain: (domain: string) => void;
setApiKey: (key: string) => void; setApiKey: (key: string) => void;
setMockMode: (isMock: boolean) => void; setMockMode: (isMock: boolean) => void;
setTheme: (theme: 'system' | 'light' | 'dark') => void; setTheme: (theme: 'system' | 'light' | 'dark') => void;
setSeedColor: (color: string) => void; setSeedColor: (color: string) => void;
toggleRail: () => void;
setRailExpanded: (expanded: boolean) => void;
setBadge: (key: keyof AppState['badges'], count: number) => void;
reset: () => void; reset: () => void;
} }
@@ -22,12 +29,21 @@ export const useStore = create<AppState>()(
apiKey: '', apiKey: '',
isMockMode: import.meta.env.VITE_MOCK_MODE === 'true', isMockMode: import.meta.env.VITE_MOCK_MODE === 'true',
theme: 'system', theme: 'system',
seedColor: '#0061a4', // Default M3 Blue seedColor: '#0061a4',
isRailExpanded: true,
badges: {
messages: 3, // Mock value
},
setApiDomain: (apiDomain) => set({ apiDomain }), setApiDomain: (apiDomain) => set({ apiDomain }),
setApiKey: (apiKey) => set({ apiKey }), setApiKey: (apiKey) => set({ apiKey }),
setMockMode: (isMockMode) => set({ isMockMode }), setMockMode: (isMockMode) => set({ isMockMode }),
setTheme: (theme) => set({ theme }), setTheme: (theme) => set({ theme }),
setSeedColor: (seedColor) => set({ seedColor }), setSeedColor: (seedColor) => set({ seedColor }),
toggleRail: () => set((state) => ({ isRailExpanded: !state.isRailExpanded })),
setRailExpanded: (isRailExpanded) => set({ isRailExpanded }),
setBadge: (key, count) => set((state) => ({
badges: { ...state.badges, [key]: count }
})),
reset: () => set({ apiKey: '' }), reset: () => set({ apiKey: '' }),
}), }),
{ {

View File

@@ -14,8 +14,9 @@
--md-sys-color-outline: #73777f; --md-sys-color-outline: #73777f;
--md-sys-color-error: #ba1a1a; --md-sys-color-error: #ba1a1a;
--nav-width: 80px; --nav-rail-width: 80px;
--bottom-nav-height: 80px; --nav-rail-expanded-width: 280px;
--nav-bar-height: 80px;
} }
:root.dark { :root.dark {
@@ -49,7 +50,41 @@ body {
transition: background-color 0.3s, color 0.3s; transition: background-color 0.3s, color 0.3s;
} }
/* Material 3 Components */ /* M3 Components */
.m3-badge {
position: absolute;
top: -4px;
right: -4px;
background-color: var(--md-sys-color-error);
color: white;
font-size: 10px;
font-weight: 500;
min-width: 16px;
height: 16px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
.icon-button {
width: 48px;
height: 48px;
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--md-sys-color-on-surface-variant);
transition: background-color 0.2s;
}
.icon-button:hover {
background-color: var(--md-sys-color-surface-variant);
}
.m3-button { .m3-button {
display: inline-flex; display: inline-flex;
@@ -65,6 +100,7 @@ body {
transition: box-shadow 0.2s, background-color 0.2s; transition: box-shadow 0.2s, background-color 0.2s;
background-color: transparent; background-color: transparent;
color: var(--md-sys-color-primary); color: var(--md-sys-color-primary);
text-decoration: none;
} }
.m3-button-filled { .m3-button-filled {
@@ -97,12 +133,7 @@ body {
margin-bottom: 16px; margin-bottom: 16px;
} }
.m3-text-field:focus { /* Layout */
outline: none;
border-bottom: 2px solid var(--md-sys-color-primary);
}
/* Layout and Navigation */
.app-container { .app-container {
display: flex; display: flex;
@@ -111,59 +142,107 @@ body {
.main-content { .main-content {
flex: 1; flex: 1;
padding-bottom: var(--bottom-nav-height); padding: 16px;
} }
@media (min-width: 600px) { /* Navigation Bar (Mobile) */
.main-content {
padding-bottom: 0;
margin-left: var(--nav-width);
}
}
.navigation-rail {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: var(--nav-width);
background-color: var(--md-sys-color-surface);
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0;
border-right: 1px solid var(--md-sys-color-outline);
z-index: 100;
}
.navigation-bar { .navigation-bar {
position: fixed; position: fixed;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
height: var(--bottom-nav-height); height: var(--nav-bar-height);
background-color: var(--md-sys-color-surface-variant); background-color: var(--md-sys-color-surface-variant);
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
align-items: center; align-items: center;
padding: 0 8px; padding: 0 8px;
z-index: 100; z-index: 100;
transition: transform 0.2s ease-in-out;
} }
.navigation-bar.hidden {
transform: translateY(100%);
}
@media (min-width: 600px) {
.navigation-bar { display: none; }
.main-content { margin-left: var(--nav-rail-width); }
}
@media (min-width: 1240px) {
.main-content.with-expanded-rail { margin-left: var(--nav-rail-expanded-width); }
}
@media (max-width: 599px) {
.main-content { padding-bottom: calc(var(--nav-bar-height) + 16px); }
}
/* Navigation Rail (Tablet/Desktop) */
.navigation-rail {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: var(--nav-rail-width);
background-color: var(--md-sys-color-surface);
display: flex;
flex-direction: column;
padding: 16px 0;
border-right: 1px solid var(--md-sys-color-outline);
z-index: 100;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow-x: hidden;
}
.navigation-rail.expanded {
width: var(--nav-rail-expanded-width);
}
.rail-header {
display: flex;
align-items: center;
padding: 0 16px;
height: 56px;
gap: 12px;
margin-bottom: 8px;
}
.rail-title {
font-size: 20px;
font-weight: 500;
white-space: nowrap;
}
.rail-items {
display: flex;
flex-direction: column;
gap: 4px;
}
/* Nav Items Shared */
.nav-item { .nav-item {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
text-decoration: none; text-decoration: none;
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
gap: 4px; height: 56px;
flex: 1; padding: 0 12px;
height: 100%; position: relative;
border-radius: 16px;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.navigation-bar .nav-item {
flex-direction: column;
justify-content: center;
flex: 1;
gap: 4px;
padding: 0;
}
.nav-item-icon-wrapper { .nav-item-icon-wrapper {
width: 64px; width: 64px;
height: 32px; height: 32px;
@@ -172,6 +251,7 @@ body {
justify-content: center; justify-content: center;
border-radius: 16px; border-radius: 16px;
transition: background-color 0.2s; transition: background-color 0.2s;
position: relative;
} }
.nav-item.active .nav-item-icon-wrapper { .nav-item.active .nav-item-icon-wrapper {
@@ -182,16 +262,47 @@ body {
.nav-item-label { .nav-item-label {
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
white-space: nowrap;
} }
@media (min-width: 600px) { .navigation-rail.expanded .nav-item {
.navigation-bar { height: 56px;
display: none; border-radius: 28px;
} margin: 0 12px;
} }
@media (max-width: 599px) { .navigation-rail.expanded .nav-item-label {
.navigation-rail { font-size: 14px;
display: none; margin-left: 12px;
} flex: 1;
}
/* Submenu */
.submenu {
display: flex;
flex-direction: column;
padding-left: 56px;
margin-bottom: 8px;
}
.submenu-item {
height: 48px;
display: flex;
align-items: center;
text-decoration: none;
color: var(--md-sys-color-on-surface-variant);
font-size: 14px;
border-radius: 24px;
margin: 0 12px;
padding: 0 16px;
}
.submenu-item.active {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
.submenu-arrow {
margin-right: 8px;
} }