UNPKG

svarog-ui-core

Version:

Core components of Svarog UI without themes

671 lines (573 loc) 19.4 kB
// src/components/Navigation/Navigation.js import { createComponent, createElement, validateProps, } from '../../utils/componentFactory.js'; import { createBaseComponent } from '../../utils/baseComponent.js'; import { withThemeAwareness } from '../../utils/composition.js'; import { debounce } from '../../utils/performance.js'; // CSS injection imports import { createStyleInjector } from '../../utils/styleInjection.js'; import { navigationStyles } from './Navigation.styles.js'; // Create style injector for Navigation component const injectNavigationStyles = createStyleInjector('Navigation'); /** * Creates a Navigation component * @param {Object} props - Navigation properties * @param {Array} props.items - Navigation items * @param {boolean} [props.responsive=true] - Whether navigation is responsive * @param {string|null} [props.activeId=null] - ID of the active navigation item * @param {string|null} [props.value=null] - Alias for activeId * @param {boolean} [props.horizontal=true] - Whether navigation is horizontal * @param {boolean} [props.expandable=true] - Whether submenus can be expanded * @param {Function} [props.onSelect=null] - Callback when item is selected * @param {Function} [props.onItemSelect=null] - Deprecated: Use onSelect instead * @param {string} [props.className=''] - Additional CSS class names * @param {string} [props.burgerPosition='left'] - Burger menu position ('left' or 'right') * @param {boolean} [props.submenuShadow=false] - Whether to add shadow to submenus * @returns {Object} Navigation component API */ const createNavigation = (props) => { // Inject styles on first render injectNavigationStyles(navigationStyles); // Validate required props validateProps(props, ['items'], 'Navigation'); // Migrate legacy props const migratedProps = migrateLegacyProps(props); // Set default props const initialProps = { items: [], responsive: true, activeId: null, value: null, horizontal: true, expandable: true, onSelect: null, className: '', burgerPosition: 'left', submenuShadow: false, ...migratedProps, }; // Resolve activeId/value priority (value overrides activeId if both are provided) initialProps.activeId = initialProps.value !== null ? initialProps.value : initialProps.activeId; // Internal state const state = { isOpen: false, activeId: initialProps.activeId, expandedId: null, ...initialProps, }; // Event handler references (for cleanup) let documentClickHandler = null; let documentKeyHandler = null; let windowResizeHandler = null; /** * Create navigation class names * @param {string} baseClass - Base class name * @param {Object} conditionalClasses - Object with class names as keys and conditions as values * @param {string} additionalClasses - Additional class names * @returns {string} Combined class names */ const createClassNames = ( baseClass, conditionalClasses = {}, additionalClasses = '' ) => { const classNames = [baseClass]; Object.entries(conditionalClasses).forEach(([className, condition]) => { if (condition) { classNames.push(className); } }); if (additionalClasses) { classNames.push(additionalClasses); } return classNames.join(' '); }; /** * Find item by ID in the navigation tree * @param {string} itemId - Item ID to find * @returns {Object|null} Found item or null */ const findItemById = (itemId) => { if (!itemId) return null; const find = (items) => { for (const item of items) { if (item.id === itemId) return item; if (item.items && item.items.length > 0) { const found = find(item.items); if (found) return found; } } return null; }; return find(state.items); }; /** * Create a navigation item * @param {Object} item - Item data * @returns {HTMLElement} Navigation item element */ const createNavigationItem = (item) => { if (!item || !item.id || !item.label) { console.warn('Navigation: Invalid item', item); return document.createDocumentFragment(); } const hasChildren = Array.isArray(item.items) && item.items.length > 0; const isActive = state.activeId === item.id; const isExpanded = state.expandedId === item.id; const navItem = createElement('li', { classes: createClassNames('nav__item', { 'nav__item--active': isActive, 'nav__item--expanded': isExpanded, 'nav__item--has-children': hasChildren, 'nav__item--disabled': !!item.disabled, }), attributes: { 'data-id': item.id }, }); const linkAttributes = { ...(item.href && !hasChildren ? { href: item.href } : {}), ...(item.disabled ? { 'aria-disabled': 'true' } : {}), ...(hasChildren ? { 'aria-expanded': isExpanded ? 'true' : 'false', 'aria-haspopup': 'true', } : {}), }; const linkElement = createElement( hasChildren && state.expandable ? 'button' : 'a', { classes: 'nav__link', attributes: linkAttributes, text: item.label, } ); navItem.appendChild(linkElement); // Create submenu if has children if (hasChildren) { // Create empty submenu container const submenu = createElement('ul', { classes: 'nav__submenu', attributes: { role: 'menu' }, }); // Only populate submenu if expanded or first render if (isExpanded) { // Use DocumentFragment for batch DOM operations const fragment = document.createDocumentFragment(); item.items.forEach((childItem) => { fragment.appendChild(createNavigationItem(childItem)); }); submenu.appendChild(fragment); } navItem.appendChild(submenu); } return navItem; }; /** * Toggle mobile menu state */ const toggleMobileMenu = (element) => { state.isOpen = !state.isOpen; element.classList.toggle('nav--open', state.isOpen); // Update aria-expanded on burger button const burger = element.querySelector('.nav__burger'); if (burger) { burger.setAttribute('aria-expanded', state.isOpen ? 'true' : 'false'); } return element; }; /** * Close mobile menu */ const closeMobileMenu = (element) => { if (!state.isOpen) return element; state.isOpen = false; element.classList.remove('nav--open'); // Update aria-expanded on burger button const burger = element.querySelector('.nav__burger'); if (burger) { burger.setAttribute('aria-expanded', 'false'); } return element; }; /** * Update expanded states in the navigation */ const updateExpandedStates = (element) => { const items = element.querySelectorAll('.nav__item--has-children'); items.forEach((item) => { const isExpanded = item.dataset.id === state.expandedId; item.classList.toggle('nav__item--expanded', isExpanded); // Update aria-expanded attribute on the link/button const link = item.querySelector('.nav__link'); if (link) { link.setAttribute('aria-expanded', isExpanded ? 'true' : 'false'); } // If this item is now expanded and has an empty submenu, populate it if (isExpanded) { const submenu = item.querySelector('.nav__submenu'); if (submenu && submenu.children.length === 0) { // Find the corresponding item data const itemData = findItemById(item.dataset.id); if (itemData && itemData.items) { // Use DocumentFragment for batch DOM operations const fragment = document.createDocumentFragment(); itemData.items.forEach((childItem) => { fragment.appendChild(createNavigationItem(childItem)); }); submenu.appendChild(fragment); } } } }); return element; }; /** * Update active states in the navigation */ const updateActiveStates = (element) => { const items = element.querySelectorAll('.nav__item'); items.forEach((item) => { item.classList.toggle( 'nav__item--active', item.dataset.id === state.activeId ); }); return element; }; /** * Handle navigation click events */ const handleClick = (e, element) => { // Handle burger menu click if (e.target.closest('.nav__burger')) { e.stopPropagation(); toggleMobileMenu(element); return; } // Handle nav link click const navLink = e.target.closest('.nav__link'); if (!navLink) return; const navItem = navLink.closest('.nav__item'); if (!navItem) return; // Skip if disabled if (navItem.classList.contains('nav__item--disabled')) { e.preventDefault(); return; } const itemId = navItem.dataset.id; const hasChildren = navItem.classList.contains('nav__item--has-children'); if (hasChildren && state.expandable) { e.preventDefault(); // Toggle submenu - if clicking the same item, close it. Otherwise open the new one. state.expandedId = state.expandedId === itemId ? null : itemId; updateExpandedStates(element); } else { // Close any open submenu when clicking non-parent items state.expandedId = null; updateExpandedStates(element); } // Set active item state.activeId = itemId; updateActiveStates(element); // Callback if (state.onSelect) { const item = findItemById(itemId); if (item) state.onSelect(item); } // Close mobile menu on item click if (state.isOpen && state.responsive && !hasChildren) { closeMobileMenu(element); } }; /** * Set up event listeners for the navigation */ const setupEventListeners = (element) => { // Click event listener for delegation element.addEventListener('click', (e) => handleClick(e, element)); // Close menu on outside click documentClickHandler = (e) => { if (!element.contains(e.target)) { if (state.isOpen) { closeMobileMenu(element); } if (state.expandedId) { state.expandedId = null; updateExpandedStates(element); } } }; document.addEventListener('click', documentClickHandler); // Close menu on escape key documentKeyHandler = (e) => { if (e.key === 'Escape') { if (state.isOpen) { closeMobileMenu(element); } if (state.expandedId) { const expandedId = state.expandedId; state.expandedId = null; updateExpandedStates(element); // Return focus to the parent item const parentItem = element.querySelector( `.nav__item[data-id="${expandedId}"] > .nav__link` ); if (parentItem) { parentItem.focus(); } } } }; document.addEventListener('keydown', documentKeyHandler); // Handle window resize with debounce windowResizeHandler = debounce(() => { if (window.innerWidth > 768 && state.isOpen) { closeMobileMenu(element); } }, 100); window.addEventListener('resize', windowResizeHandler, { passive: true }); }; /** * Create the navigation element */ const renderNavigation = () => { const classNames = createClassNames( 'nav', { 'nav--responsive': state.responsive, 'nav--vertical': !state.horizontal, 'nav--horizontal': state.horizontal, 'nav--submenu-shadow': state.submenuShadow, [`nav--burger-${state.burgerPosition}`]: state.responsive, 'nav--open': state.isOpen, }, state.className ); const nav = createElement('nav', { classes: classNames, attributes: { role: 'navigation' }, }); // Create burger menu for mobile if (state.responsive) { const burger = createElement('button', { classes: 'nav__burger', attributes: { 'aria-label': 'Toggle navigation menu', 'aria-expanded': state.isOpen ? 'true' : 'false', type: 'button', }, }); for (let i = 0; i < 3; i++) { burger.appendChild( createElement('span', { classes: 'nav__burger-line' }) ); } nav.appendChild(burger); } // Create navigation list const navList = createElement('ul', { classes: 'nav__list', attributes: { role: 'menubar' }, }); // Use document fragment for better performance const fragment = document.createDocumentFragment(); state.items.forEach((item) => { fragment.appendChild(createNavigationItem(item)); }); navList.appendChild(fragment); nav.appendChild(navList); return nav; }; // Create the component using baseComponent const component = createBaseComponent(renderNavigation)(state); // Add cleanup method to remove event listeners const originalDestroy = component.destroy; component.destroy = () => { if (documentClickHandler) { document.removeEventListener('click', documentClickHandler); documentClickHandler = null; } if (documentKeyHandler) { document.removeEventListener('keydown', documentKeyHandler); documentKeyHandler = null; } if (windowResizeHandler) { window.removeEventListener('resize', windowResizeHandler); windowResizeHandler = null; } if (originalDestroy) { originalDestroy(); } }; // Setup events after initial render const element = component.getElement(); setupEventListeners(element); // Add methods specific to Navigation component.setActiveItem = (itemId) => { state.activeId = itemId; updateActiveStates(component.getElement()); return component; }; component.toggleMobileMenu = () => { toggleMobileMenu(component.getElement()); return component; }; component.closeMobileMenu = () => { closeMobileMenu(component.getElement()); return component; }; component.expandItem = (itemId) => { state.expandedId = itemId; updateExpandedStates(component.getElement()); return component; }; component.collapseAll = () => { state.expandedId = null; updateExpandedStates(component.getElement()); return component; }; // Add shouldRerender method for better update performance component.shouldRerender = (newProps) => { // Only trigger full rerender for significant structure changes return ( newProps.items !== undefined || newProps.horizontal !== undefined || newProps.responsive !== undefined ); }; // Add partialUpdate method for more efficient DOM updates component.partialUpdate = (element, newProps, oldState) => { // Migrate legacy props in update as well const migratedProps = migrateLegacyProps(newProps); let needsActiveUpdate = false; let needsExpandedUpdate = false; // Update internal state based on new props Object.entries(migratedProps).forEach(([key, value]) => { if ( (key === 'activeId' || key === 'value') && ((key === 'activeId' && value !== oldState.activeId) || (key === 'value' && value !== null && value !== oldState.activeId)) ) { // Handle value alias for activeId const newActiveId = key === 'value' ? value : value; state.activeId = newActiveId; state.value = newActiveId; needsActiveUpdate = true; } else if (key === 'expandedId' && value !== oldState.expandedId) { state[key] = value; needsExpandedUpdate = true; } else if (key === 'isOpen' && state.responsive) { if (value && !state.isOpen) { toggleMobileMenu(element); } else if (!value && state.isOpen) { closeMobileMenu(element); } } else if (key === 'className') { // Update className directly without rerendering const oldClasses = (oldState.className || '') .split(' ') .filter(Boolean); const newClasses = value.split(' ').filter(Boolean); oldClasses.forEach((cls) => { if (!newClasses.includes(cls)) { element.classList.remove(cls); } }); newClasses.forEach((cls) => { if (!oldClasses.includes(cls)) { element.classList.add(cls); } }); state.className = value; } else if (key === 'submenuShadow') { element.classList.toggle('nav--submenu-shadow', value); state.submenuShadow = value; } else if (key === 'burgerPosition' && state.responsive) { element.classList.remove(`nav--burger-${oldState.burgerPosition}`); element.classList.add(`nav--burger-${value}`); state.burgerPosition = value; } else { // Update other state properties state[key] = value; } }); // Apply DOM updates if needed if (needsActiveUpdate) { updateActiveStates(element); } if (needsExpandedUpdate) { updateExpandedStates(element); } return component; }; // Add theme change handler component.onThemeChange = (theme, previousTheme) => { // Theme changes are handled through CSS variables, no manual updates needed console.debug( `Navigation: theme changed from ${previousTheme} to ${theme}` ); }; return component; }; /** * Migrates legacy prop names to standardized ones * @param {Object} props - Component props * @returns {Object} Migrated props */ const migrateLegacyProps = (props) => { const migrated = { ...props }; // Handle onItemSelect -> onSelect migration if ('onItemSelect' in props && !('onSelect' in props)) { console.warn( '[Navigation] onItemSelect is deprecated, use onSelect instead' ); migrated.onSelect = props.onItemSelect; delete migrated.onItemSelect; } // Handle activeId and value relationship if ('value' in props && props.value !== null && !('activeId' in props)) { migrated.activeId = props.value; } else if ('activeId' in props && !('value' in props)) { migrated.value = props.activeId; } // Process navigation items to normalize href/url if (migrated.items) { migrated.items = migrateItemURLs(migrated.items); } return migrated; }; /** * Recursively migrates item URLs from url to href * @param {Array} items - Navigation items * @returns {Array} Migrated items */ const migrateItemURLs = (items) => { if (!Array.isArray(items)) return items; return items.map((item) => { const newItem = { ...item }; // Convert url to href if present if ('url' in newItem && !('href' in newItem)) { console.warn( '[Navigation] item.url is deprecated, use item.href instead' ); newItem.href = newItem.url; delete newItem.url; } // Process child items recursively if (Array.isArray(newItem.items)) { newItem.items = migrateItemURLs(newItem.items); } return newItem; }); }; // Specify required props for validation createNavigation.requiredProps = ['items']; // Create component with theme awareness export default createComponent( 'Navigation', withThemeAwareness(createNavigation) );