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 ( +
  • + + + {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 ( +
  • + + + {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 ( + + ); +} + +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} + +
    + ); +}); + +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 компонент. Панелът пресъздава визията от предоставената навигация и е структуриран според добрите практики: + +- данните за секциите и елементите са конфигурируеми; +- компонентът поддържа контролиран и неконтролиран режим за активния елемент; +- достъпен е чрез семантичен `