From bddb318b895360efc065ea3795508923fc235f6e Mon Sep 17 00:00:00 2001
From: RumenStoev <49361738+RumenStoev@users.noreply.github.com>
Date: Sat, 20 Sep 2025 16:45:42 +0300
Subject: [PATCH] feat: add navigation panel react component
---
components/navigation-panel/FooterAction.jsx | 21 ++
.../navigation-panel/NavigationItem.jsx | 46 ++++
.../navigation-panel/NavigationPanel.jsx | 180 ++++++++++++
.../NavigationPanel.module.css | 260 ++++++++++++++++++
.../navigation-panel/NavigationSection.jsx | 32 +++
components/navigation-panel/README.md | 78 ++++++
components/navigation-panel/icons.jsx | 128 +++++++++
components/navigation-panel/index.js | 2 +
8 files changed, 747 insertions(+)
create mode 100644 components/navigation-panel/FooterAction.jsx
create mode 100644 components/navigation-panel/NavigationItem.jsx
create mode 100644 components/navigation-panel/NavigationPanel.jsx
create mode 100644 components/navigation-panel/NavigationPanel.module.css
create mode 100644 components/navigation-panel/NavigationSection.jsx
create mode 100644 components/navigation-panel/README.md
create mode 100644 components/navigation-panel/icons.jsx
create mode 100644 components/navigation-panel/index.js
diff --git a/components/navigation-panel/FooterAction.jsx b/components/navigation-panel/FooterAction.jsx
new file mode 100644
index 0000000..6965698
--- /dev/null
+++ b/components/navigation-panel/FooterAction.jsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import styles from './NavigationPanel.module.css';
+
+const FooterAction = React.memo(function FooterAction({ action }) {
+ const { id, label, icon: Icon, href = `#${id}`, onClick } = action;
+
+ return (
+
+
+
+ {Icon ? : null}
+
+ {label}
+
+
+ );
+});
+
+FooterAction.displayName = 'FooterAction';
+
+export default FooterAction;
diff --git a/components/navigation-panel/NavigationItem.jsx b/components/navigation-panel/NavigationItem.jsx
new file mode 100644
index 0000000..24e543e
--- /dev/null
+++ b/components/navigation-panel/NavigationItem.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import styles from './NavigationPanel.module.css';
+
+const NavigationItem = React.memo(function NavigationItem({ item, isActive, onSelect }) {
+ const { id, label, icon: Icon, href = `#${id}`, badge, isDisabled } = item;
+
+ const className = [styles.link, isActive ? styles.linkActive : '', isDisabled ? styles.linkDisabled : '']
+ .filter(Boolean)
+ .join(' ');
+
+ const handleClick = (event) => {
+ if (isDisabled) {
+ event.preventDefault();
+ return;
+ }
+
+ onSelect?.(item, event);
+ };
+
+ return (
+
+
+
+ {Icon ? : null}
+
+ {label}
+ {typeof badge === 'number' ? (
+
+ {badge}
+
+ ) : null}
+
+
+ );
+});
+
+NavigationItem.displayName = 'NavigationItem';
+
+export default NavigationItem;
diff --git a/components/navigation-panel/NavigationPanel.jsx b/components/navigation-panel/NavigationPanel.jsx
new file mode 100644
index 0000000..2a56df5
--- /dev/null
+++ b/components/navigation-panel/NavigationPanel.jsx
@@ -0,0 +1,180 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import NavigationSection from './NavigationSection';
+import FooterAction from './FooterAction';
+import styles from './NavigationPanel.module.css';
+import {
+ DashboardIcon,
+ ReportsIcon,
+ ArchiveIcon,
+ SocialIcon,
+ UsersIcon,
+ DocumentsIcon,
+ FavoritesIcon,
+ SettingsIcon,
+ SupportIcon,
+ PowerIcon,
+ PlusIcon,
+} from './icons';
+
+/**
+ * @typedef {Object} NavigationItem
+ * @property {string} id
+ * @property {string} label
+ * @property {React.ElementType} icon
+ * @property {string=} href
+ * @property {number=} badge
+ * @property {boolean=} isDisabled
+ */
+
+/**
+ * @typedef {Object} NavigationSectionDefinition
+ * @property {string} id
+ * @property {string=} title
+ * @property {NavigationItem[]} items
+ */
+
+const DEFAULT_SECTIONS = Object.freeze([
+ {
+ id: 'primary',
+ title: 'Navigation',
+ items: [
+ { id: 'dashboard', label: 'Dashboard', icon: DashboardIcon, href: '#dashboard', badge: 3 },
+ { id: 'reports', label: 'Reports', icon: ReportsIcon, href: '#reports', badge: 12 },
+ { id: 'archive', label: 'Archive', icon: ArchiveIcon, href: '#archive' },
+ { id: 'social', label: 'Social', icon: SocialIcon, href: '#social' },
+ { id: 'users', label: 'Users', icon: UsersIcon, href: '#users', badge: 4 },
+ { id: 'documents', label: 'Documents', icon: DocumentsIcon, href: '#documents' },
+ { id: 'favorites', label: 'Favorites', icon: FavoritesIcon, href: '#favorites', badge: 8 },
+ ],
+ },
+]);
+
+const DEFAULT_FOOTER_ACTIONS = Object.freeze([
+ { id: 'settings', label: 'Settings', icon: SettingsIcon, href: '#settings' },
+ { id: 'support', label: 'Support', icon: SupportIcon, href: '#support' },
+ { id: 'logout', label: 'Logout', icon: PowerIcon, href: '#logout' },
+]);
+
+const findFirstItemId = (sections) => {
+ for (const section of sections) {
+ if (section?.items?.length) {
+ return section.items[0].id;
+ }
+ }
+ return null;
+};
+
+const composeClassName = (...values) => values.filter(Boolean).join(' ');
+
+function NavigationPanel({
+ sections,
+ footerActions = DEFAULT_FOOTER_ACTIONS,
+ initialActiveItemId,
+ activeItemId,
+ onActiveItemChange,
+ className,
+ brand = { initials: 'NP', name: 'Navigation Panel' },
+ onCreate,
+}) {
+ const normalizedSections = useMemo(
+ () => (sections && sections.length ? sections : DEFAULT_SECTIONS),
+ [sections],
+ );
+
+ const resolvedInitialActiveId = useMemo(() => {
+ if (activeItemId) {
+ return activeItemId;
+ }
+
+ if (initialActiveItemId) {
+ return initialActiveItemId;
+ }
+
+ return findFirstItemId(normalizedSections);
+ }, [activeItemId, initialActiveItemId, normalizedSections]);
+
+ const [uncontrolledActiveId, setUncontrolledActiveId] = useState(resolvedInitialActiveId);
+ const isControlled = activeItemId !== undefined;
+ const currentActiveId = isControlled ? activeItemId : uncontrolledActiveId;
+
+ useEffect(() => {
+ if (isControlled) {
+ return;
+ }
+
+ const hasActiveItem = normalizedSections.some((section) =>
+ section?.items?.some((navItem) => navItem.id === uncontrolledActiveId),
+ );
+
+ if (!hasActiveItem) {
+ setUncontrolledActiveId(findFirstItemId(normalizedSections));
+ }
+ }, [isControlled, normalizedSections, uncontrolledActiveId]);
+
+ useEffect(() => {
+ if (!isControlled && initialActiveItemId && initialActiveItemId !== uncontrolledActiveId) {
+ setUncontrolledActiveId(initialActiveItemId);
+ }
+ }, [initialActiveItemId, isControlled, uncontrolledActiveId]);
+
+ const handleItemSelect = useCallback(
+ (item, event) => {
+ if (item.isDisabled) {
+ event?.preventDefault();
+ return;
+ }
+
+ if (!isControlled) {
+ setUncontrolledActiveId(item.id);
+ }
+
+ onActiveItemChange?.(item, event);
+ },
+ [isControlled, onActiveItemChange],
+ );
+
+ const panelClassName = useMemo(
+ () => composeClassName(styles.panel, className),
+ [className],
+ );
+
+ return (
+
+
+
+
+ {brand.initials}
+
+ {brand.name}
+
+
+
+
+
+
+
+ {normalizedSections.map((section) => (
+
+ ))}
+
+
+ {footerActions?.length ? (
+
+
+ {footerActions.map((action) => (
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
+
+export default NavigationPanel;
+export { NavigationPanel, DEFAULT_SECTIONS as DEFAULT_NAVIGATION_SECTIONS, DEFAULT_FOOTER_ACTIONS };
diff --git a/components/navigation-panel/NavigationPanel.module.css b/components/navigation-panel/NavigationPanel.module.css
new file mode 100644
index 0000000..1a130a0
--- /dev/null
+++ b/components/navigation-panel/NavigationPanel.module.css
@@ -0,0 +1,260 @@
+.panel {
+ width: 260px;
+ background: radial-gradient(120% 120% at 10% 0%, rgba(79, 102, 191, 0.28) 0%, rgba(23, 27, 41, 0.95) 60%, #151927 100%);
+ border-radius: 28px;
+ padding: 28px 22px;
+ display: flex;
+ flex-direction: column;
+ gap: 28px;
+ color: #e8ecff;
+ color-scheme: dark;
+ font-family: 'Inter', 'Segoe UI', sans-serif;
+ box-shadow: 0 24px 48px rgba(10, 14, 35, 0.55);
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.brandSymbol {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 42px;
+ height: 42px;
+ border-radius: 14px;
+ background: linear-gradient(145deg, #736efe 0%, #62e4ff 100%);
+ color: #0c1220;
+ font-size: 0.95rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.brandName {
+ font-size: 0.95rem;
+ font-weight: 600;
+ color: #f6f7ff;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+.addButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px;
+ height: 44px;
+ border-radius: 16px;
+ border: none;
+ cursor: pointer;
+ background: rgba(120, 133, 187, 0.24);
+ color: #9ec5ff;
+ transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
+}
+
+.addButton:hover {
+ background: rgba(146, 163, 228, 0.42);
+ color: #ffffff;
+ transform: translateY(-1px);
+}
+
+.addButton:focus-visible {
+ outline: 2px solid rgba(146, 163, 228, 0.9);
+ outline-offset: 2px;
+}
+
+.addIcon {
+ width: 20px;
+ height: 20px;
+}
+
+.sections {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ flex: 1;
+}
+
+.section {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.sectionTitle {
+ margin: 0;
+ color: rgba(200, 207, 242, 0.55);
+ font-size: 0.7rem;
+ font-weight: 600;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+}
+
+.list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.listItem {
+ list-style: none;
+}
+
+.link {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 12px 16px;
+ border-radius: 16px;
+ text-decoration: none;
+ color: rgba(214, 221, 255, 0.8);
+ background: transparent;
+ transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.link:hover {
+ background: rgba(255, 255, 255, 0.06);
+ color: #ffffff;
+ transform: translateX(2px);
+}
+
+.link:focus-visible {
+ outline: 2px solid rgba(146, 163, 228, 0.8);
+ outline-offset: 2px;
+}
+
+.linkActive {
+ background: linear-gradient(90deg, #736efe 0%, #5efce8 100%);
+ color: #0e1323;
+ box-shadow: 0 18px 24px rgba(87, 132, 255, 0.35);
+ transform: translateX(4px);
+}
+
+.linkDisabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.iconWrapper {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ height: 34px;
+ border-radius: 12px;
+ background: rgba(123, 139, 188, 0.18);
+ color: #9bb5ff;
+ transition: background 0.2s ease, color 0.2s ease;
+ flex-shrink: 0;
+}
+
+.linkActive .iconWrapper {
+ background: rgba(12, 19, 35, 0.12);
+ color: #0e1323;
+}
+
+.link:hover .iconWrapper {
+ color: #ffffff;
+}
+
+.icon {
+ width: 18px;
+ height: 18px;
+}
+
+.label {
+ flex: 1;
+ font-size: 0.92rem;
+ font-weight: 500;
+ letter-spacing: 0.01em;
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 28px;
+ padding: 3px 8px;
+ border-radius: 999px;
+ font-size: 0.7rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ background: rgba(255, 255, 255, 0.18);
+ color: #ffffff;
+}
+
+.linkActive .badge {
+ background: rgba(14, 19, 35, 0.16);
+ color: #0e1323;
+}
+
+.footer {
+ margin-top: auto;
+ padding-top: 18px;
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.footerList {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.footerLink {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 14px;
+ border-radius: 14px;
+ text-decoration: none;
+ color: rgba(184, 196, 237, 0.78);
+ background: rgba(255, 255, 255, 0.04);
+ transition: background 0.2s ease, color 0.2s ease;
+}
+
+.footerLink:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: #ffffff;
+}
+
+.footerLink:focus-visible {
+ outline: 2px solid rgba(146, 163, 228, 0.8);
+ outline-offset: 2px;
+}
+
+.footerIcon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.06);
+ color: rgba(184, 196, 237, 0.75);
+ transition: background 0.2s ease, color 0.2s ease;
+}
+
+.footerLink:hover .footerIcon {
+ background: rgba(255, 255, 255, 0.18);
+ color: #ffffff;
+}
+
+.footerLabel {
+ font-size: 0.85rem;
+ font-weight: 500;
+}
diff --git a/components/navigation-panel/NavigationSection.jsx b/components/navigation-panel/NavigationSection.jsx
new file mode 100644
index 0000000..52a4a1b
--- /dev/null
+++ b/components/navigation-panel/NavigationSection.jsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import NavigationItem from './NavigationItem';
+import styles from './NavigationPanel.module.css';
+
+const NavigationSection = React.memo(function NavigationSection({ section, activeItemId, onSelect }) {
+ if (!section || !section.items?.length) {
+ return null;
+ }
+
+ const { title, items, id } = section;
+ const sectionLabel = title || id;
+
+ return (
+
+ {title ? {title} : null}
+
+ {items.map((item) => (
+
+ ))}
+
+
+ );
+});
+
+NavigationSection.displayName = 'NavigationSection';
+
+export default NavigationSection;
diff --git a/components/navigation-panel/README.md b/components/navigation-panel/README.md
new file mode 100644
index 0000000..c924297
--- /dev/null
+++ b/components/navigation-panel/README.md
@@ -0,0 +1,78 @@
+# NavigationPanel
+
+Ляв вертикален панел за навигация, реализиран като React компонент. Панелът пресъздава визията от предоставената навигация и е структуриран според добрите практики:
+
+- данните за секциите и елементите са конфигурируеми;
+- компонентът поддържа контролиран и неконтролиран режим за активния елемент;
+- достъпен е чрез семантичен `` контейнер и `aria` атрибути;
+- стиловете са инкапсулирани чрез CSS Module.
+
+## Използване
+
+```jsx
+import { NavigationPanel } from './components/navigation-panel';
+
+const sections = [
+ {
+ id: 'primary',
+ title: 'Navigation',
+ items: [
+ { id: 'dashboard', label: 'Dashboard', icon: DashboardIcon, href: '/dashboard' },
+ { id: 'reports', label: 'Reports', icon: ReportsIcon, href: '/reports', badge: 12 },
+ // ...
+ ],
+ },
+];
+
+function App() {
+ const [activeId, setActiveId] = useState('dashboard');
+
+ return (
+ setActiveId(item.id)}
+ onCreate={() => console.log('Create new item')}
+ brand={{ initials: 'NP', name: 'Navigation Panel' }}
+ />
+ );
+}
+```
+
+Ако не подадете `sections`, `NavigationPanel` използва вградените примерни данни, които съвпадат с навигацията от изображението.
+
+### Свойства
+
+| Prop | Тип | Описание |
+| ---- | --- | -------- |
+| `sections` | `NavigationSectionDefinition[]` | Навигационните секции и елементи. |
+| `footerActions` | `NavigationItem[]` | Допълнителни действия в долната част на панела. |
+| `activeItemId` | `string` | Контролиран идентификатор на активния елемент. |
+| `initialActiveItemId` | `string` | Начален активен елемент при неконтролиран режим. |
+| `onActiveItemChange` | `(item, event) => void` | Callback при смяна на активен елемент. |
+| `brand` | `{ initials: string, name: string }` | Данни за брандинга в горната част. |
+| `onCreate` | `() => void` | Обработчик за бутона с иконата „+“. |
+| `className` | `string` | Допълнителен CSS клас за контейнера. |
+
+### Структура на елементите
+
+```ts
+interface NavigationItem {
+ id: string;
+ label: string;
+ icon: React.ElementType;
+ href?: string;
+ badge?: number;
+ isDisabled?: boolean;
+}
+
+interface NavigationSectionDefinition {
+ id: string;
+ title?: string;
+ items: NavigationItem[];
+}
+```
+
+### Стилове
+
+Всички стилове се намират в `NavigationPanel.module.css` и са приложени единствено към компонента чрез CSS Modules. Палитрата е подбрана така, че да бъде максимално близка до дизайна от примера, а `color-scheme: dark` на кореновия контейнер подсказва на браузъра, че панелът е в тъмен режим.
diff --git a/components/navigation-panel/icons.jsx b/components/navigation-panel/icons.jsx
new file mode 100644
index 0000000..000347b
--- /dev/null
+++ b/components/navigation-panel/icons.jsx
@@ -0,0 +1,128 @@
+import React from 'react';
+
+const IconBase = ({ className, children }) => (
+
+ {children}
+
+);
+
+IconBase.displayName = 'IconBase';
+
+export const DashboardIcon = ({ className }) => (
+
+
+
+
+
+
+);
+
+DashboardIcon.displayName = 'DashboardIcon';
+
+export const ReportsIcon = ({ className }) => (
+
+
+
+
+
+);
+
+ReportsIcon.displayName = 'ReportsIcon';
+
+export const ArchiveIcon = ({ className }) => (
+
+
+
+
+
+);
+
+ArchiveIcon.displayName = 'ArchiveIcon';
+
+export const SocialIcon = ({ className }) => (
+
+
+
+
+);
+
+SocialIcon.displayName = 'SocialIcon';
+
+export const UsersIcon = ({ className }) => (
+
+
+
+
+
+);
+
+UsersIcon.displayName = 'UsersIcon';
+
+export const DocumentsIcon = ({ className }) => (
+
+
+
+
+
+
+
+);
+
+DocumentsIcon.displayName = 'DocumentsIcon';
+
+export const FavoritesIcon = ({ className }) => (
+
+
+
+);
+
+FavoritesIcon.displayName = 'FavoritesIcon';
+
+export const SettingsIcon = ({ className }) => (
+
+
+
+
+);
+
+SettingsIcon.displayName = 'SettingsIcon';
+
+export const SupportIcon = ({ className }) => (
+
+
+
+
+
+);
+
+SupportIcon.displayName = 'SupportIcon';
+
+export const PowerIcon = ({ className }) => (
+
+
+
+
+);
+
+PowerIcon.displayName = 'PowerIcon';
+
+export const PlusIcon = ({ className }) => (
+
+
+
+
+);
+
+PlusIcon.displayName = 'PlusIcon';
+
+export default IconBase;
diff --git a/components/navigation-panel/index.js b/components/navigation-panel/index.js
new file mode 100644
index 0000000..ac036d6
--- /dev/null
+++ b/components/navigation-panel/index.js
@@ -0,0 +1,2 @@
+export { default as NavigationPanel } from './NavigationPanel';
+export { DEFAULT_NAVIGATION_SECTIONS, DEFAULT_FOOTER_ACTIONS } from './NavigationPanel';